angular.module('beamng.apps')
.directive('oeprotune', ['$injector', function ($injector) {
  // Session-scoped rather than link-scoped: removing/readding the app creates a new directive
  // link, but live controllers for other spawned vehicles still own their exact saved MAP bodies.
  // Keep those bodies available until the UI module/game session itself is torn down.
  var vehicleMapBankBodyCache = {}, vehicleMapBankBodyCacheOrder = [];
  var directiveSessionId = 'oep_session_' + Date.now().toString(36) + '_' + Math.floor(Math.random() * 0x100000000).toString(36);
  return {
    templateUrl: '/ui/modules/apps/OEProTune/app.html',
    replace: true,
    restrict: 'EA',
    link: function (scope, element) {

      // This directive is also loaded by the browser mirror pages (Phase 2: remote.html /
      // Pro Studio). In the in-game app both flags are always false; the ported Remote
      // Dash block below already guards its game-PC-only calls with them.
      var IS_MIRROR = !!window.__oepMirror;
      var IS_PRO_STUDIO = IS_MIRROR && !!window.__oepStudioFrame;
      scope.isMirror = IS_MIRROR;
      scope.isProStudio = IS_PRO_STUDIO;
      var studioMessageHandler = null;

      // A directive/UI-session id changes when Angular or CEF reloads, so it cannot prove that a
      // durable Factory Reset marker belongs to another BeamNG process.  GE Lua survives those UI
      // reloads: let it adopt one opaque nonce for the lifetime of the game process, then stamp reset
      // intents with that nonce.  State is held briefly until the answer arrives so no first-link
      // autosave/reset-marker decision can run with an unknown session identity.
      var gameSessionNonce = null;
      var gameSessionNonceKnown = false;
      var gameSessionNonceGateOpen = IS_MIRROR;
      var gameSessionNonceRequestInFlight = false;
      var gameSessionNonceGateTimer = null;
      var gameSessionNonceRetryTimer = null;
      var deferredStateBeforeGameSessionNonce = null;
      var pendingRemoteFactoryResetIntent = null;
      var linkDestroyed = false;
      function replayDeferredStateAfterGameSessionNonce() {
        var deferred = deferredStateBeforeGameSessionNonce;
        deferredStateBeforeGameSessionNonce = null;
        if (deferred && !linkDestroyed) { scope.$broadcast('OEProTuneState', deferred); }
      }
      function openGameSessionNonceGate(nonce) {
        if (linkDestroyed) { return; }
        if (typeof nonce === 'string' && nonce) {
          gameSessionNonce = nonce;
          gameSessionNonceKnown = true;
        }
        if (gameSessionNonceGateOpen) { return; }
        gameSessionNonceGateOpen = true;
        if (gameSessionNonceGateTimer) { clearTimeout(gameSessionNonceGateTimer); gameSessionNonceGateTimer = null; }
        if (gameSessionNonceRetryTimer) { clearTimeout(gameSessionNonceRetryTimer); gameSessionNonceRetryTimer = null; }
        replayDeferredStateAfterGameSessionNonce();
      }
      function scheduleGameSessionNonceRetry() {
        if (linkDestroyed || gameSessionNonceKnown || gameSessionNonceGateOpen || gameSessionNonceRetryTimer) { return; }
        gameSessionNonceRetryTimer = setTimeout(function () {
          gameSessionNonceRetryTimer = null;
          requestGameSessionNonce();
        }, 100);
      }
      function requestGameSessionNonce() {
        if (IS_MIRROR || linkDestroyed || gameSessionNonceKnown || gameSessionNonceRequestInFlight) { return; }
        if (!gameSessionNonceGateTimer && !gameSessionNonceGateOpen) {
          // Fail safe, not fail closed forever: when no destructive marker exists, resume in a
          // conservative exact-vehicle mode. A stored marker is different: crossing first-link
          // persistence without the GE nonce could replay its old autosave and permanently miss a
          // valid prior-process reset, so keep retrying until that boundary can be classified.
          gameSessionNonceGateTimer = setTimeout(function () {
            gameSessionNonceGateTimer = null;
            scope.$evalAsync(function () {
              if (!deferredStateBeforeGameSessionNonce || storedFactoryResetIntentForState(deferredStateBeforeGameSessionNonce)) {
                gameSessionNonceRequestInFlight = false;
                scheduleGameSessionNonceRetry();
                return;
              }
              openGameSessionNonceGate(null);
            });
          }, 1000);
        }
        if (typeof bngApi === 'undefined' || !bngApi.engineLua) { scheduleGameSessionNonceRetry(); return; }
        gameSessionNonceRequestInFlight = true;
        var candidate = 'oep_game_' + directiveSessionId + '_' + Date.now().toString(36) + '_' +
          Math.floor(Math.random() * 0x100000000).toString(36);
        var lua = '(function() local k="__oeProTuneGameSessionNonce" local n=rawget(_G,k) ' +
          'if type(n)~="string" or n=="" then n=' + luaQuote(candidate) + ' rawset(_G,k,n) end return n end)()';
        try {
          bngApi.engineLua(lua, function (nonce) {
            gameSessionNonceRequestInFlight = false;
            if (linkDestroyed) { return; }
            if (typeof nonce !== 'string' || !nonce) { scheduleGameSessionNonceRetry(); return; }
            scope.$evalAsync(function () { openGameSessionNonceGate(nonce); });
          });
        } catch (e) {
          gameSessionNonceRequestInFlight = false;
          scheduleGameSessionNonceRetry();
        }
      }
      if (!IS_MIRROR) { requestGameSessionNonce(); }

      // ====================================================================
      // state
      // ====================================================================
      scope.connected = false;      // telemetry alive (controller attached)
      scope.controlling = false;    // user hit Connect: the ECU is controlling the car
      scope.otherTunerActive = false;
      scope.activeVehId = null;     // id of the player's CURRENT vehicle; we ignore any ECU state/telemetry from a DIFFERENT car (BeamMP / traffic / multi-spawn)
      scope.linkedVehId = null;     // ECU currently painted in this UI; unlike carName, distinguishes two identical spawned cars (ID 0 is valid)
      scope.applyingTune = false;   // a tune load is being pushed; don't let intermediate state clobber it
      var applyTuneTimer = null;
      var applyTuneWaitedForUnpause = false;
      // One trusted in-game app instance owns each published saved-tune catalog.
      // The opaque ids never leave the game/GE boundary; they stop duplicate app
      // instances and a changing library from resolving a browser click differently.
      var tunePublisherId = 'ui_' + Date.now().toString(36) + '_' + Math.floor(Math.random() * 0x100000000).toString(36);
      var tuneCatalogSerial = 0, tuneCatalogSnapshots = {}, tuneCatalogSnapshotOrder = [];
      var activeRemoteTuneLoad = null;
      var pendingAxisAutosave = false;
      var reconnectControl = false; // were we controlling before a reload? reconnect after it
      var pendingReinit = false;    // guard so a fresh-init (part change) is handled once
      scope.carName = '';
      scope.carNameFallback = '';
      scope.base = { revLimiterRPM: 7000, maxRPM: 7500, idleRPM: 800, hasTurbo: false, turboMaxKpa: 100 };
      // V3.0.2 stable engine identity. New controllers publish E2:<fuel>:<device>:<partPath>,
      // where partPath is the JBeam part that declared the combustion-engine device. Old saves carry
      // only the V2.6 performance fingerprint (e.g. GT-850-7000-320), whose torque/aspiration fields
      // changed with bolt-ons. The narrow legacy comparison exists only to migrate those old saves;
      // once stored again, two E2 identities must match exactly.
      function engineFingerprintIsStrong(fp) { return /^E2:/.test(String(fp || '')); }
      function parseLegacyEngineFingerprint(fp) {
        var m = String(fp || '').match(/^([DG])([TNS])-(-?\d+)-(-?\d+)-(-?\d+)$/);
        return m ? { fuel: m[1], induction: m[2], idle: Number(m[3]), rev: Number(m[4]), torque: Number(m[5]) } : null;
      }
      // opts.allowApproximate - permit the fuzzy legacy comparison below. OFF by default, and
      // deliberately so: that comparison ignores the induction letter and tolerates 20% of torque,
      // which is wide enough for genuinely different vanilla engine options on the same car (an I4
      // 2.5 vs a V6 3.3, a 4.5 vs a 5.5 V8) to read as the same engine. Applying a tune across that
      // gap is exactly the "cranked a swapped-in engine at 2% duty = dead engine" failure the
      // fingerprint exists to prevent, and it must never happen silently.
      //
      // Silent/automatic paths therefore require an EXACT legacy match, which is what a user who
      // simply updated the mod without touching parts has anyway - the common migration case. The
      // fuzzy branch is reserved for a tune the user explicitly picked and loaded, where they can
      // see which tune they chose and undo it.
      function engineIdentityCompatible(record, liveBase, opts) {
        if (typeof record === 'string') { record = { engineFp: record }; }
        record = record || {};
        liveBase = liveBase || {};
        opts = opts || {};
        var savedFp = record.engineFp == null ? '' : String(record.engineFp);
        var liveFp = liveBase.engineFp == null ? '' : String(liveBase.engineFp);
        if (!savedFp || !liveFp || savedFp === liveFp) { return true; }

        var savedStrong = engineFingerprintIsStrong(savedFp);
        var liveStrong = engineFingerprintIsStrong(liveFp);
        // Strong part identities are authoritative. Never let similar power figures waive a known
        // engine-part mismatch (this preserves the original gas/diesel/engine-swap safety guard).
        if (savedStrong && liveStrong) { return false; }

        var savedLegacy = record.engineFpLegacy || (!savedStrong ? savedFp : '');
        var liveLegacy = liveBase.engineFpLegacy || (!liveStrong ? liveFp : '');
        if (!savedLegacy || !liveLegacy) { return false; }
        // Exact legacy equality is always safe: identical fuel, induction, idle, limiter AND base
        // torque is the same engine with the same parts on it.
        if (String(savedLegacy) === String(liveLegacy)) { return true; }
        if (!opts.allowApproximate) { return false; }

        var saved = parseLegacyEngineFingerprint(savedLegacy);
        var live = parseLegacyEngineFingerprint(liveLegacy);
        if (!saved || !live || saved.fuel !== live.fuel) { return false; }

        // Exhaust/intake modifiers change the assembled torque curve; turbo/supercharger bolt-ons
        // change the induction letter. Idle and limiter changes are allowed only within the small
        // range produced by ECU/part variants. Torque tolerance is intentionally migration-only.
        var torqueTolerance = Math.max(20, Math.min(Math.abs(saved.torque), Math.abs(live.torque)) * 0.20);
        return Math.abs(saved.idle - live.idle) <= 50 &&
          Math.abs(saved.rev - live.rev) <= 100 &&
          Math.abs(saved.torque - live.torque) <= torqueTolerance;
      }
      scope.settings = {};
      scope.maps = null;
      scope.rpmAxis = [];
      scope.mapAxis = [];
      scope.alphaNAxis = [];
      scope.mapMeta = null;
      // V3.0.7 calibration grid size. SOURCE OF TRUTH is lua/common/oeProTuneShared.lua (M.axisLimits);
      // the controller republishes it in publishState and we adopt it below. These literals are only
      // the pre-state fallback - they must not be TIGHTER than the shared file, because
      // validSnapshotAxis runs against stored autosaves before any controller state has arrived, and
      // a too-tight bound there does not politely refuse a tune: it classifies the autosave as corrupt
      // and blocks the next Store entirely (the V3.0 MAP-ceiling failure, one axis dimension over).
      scope.axisLimits = { min: 4, max: 20 };
      // Four complete, hot-switchable performance calibrations. The controller is the
      // authority for active/pending/default; the full slot bodies stay UI-side so they
      // can ride with autosaves, quick-save backups, libraries, and tune files.
      scope.mapBank = {
        active: 1, pending: null, pendingMode: 'none', pendingReload: false, pendingDiscard: false,
        workingDirty: false, workingBoostTableFactory: false, workingPeakBoostKpa: 0,
        default: 1, sequence: 0,
        slots: [1, 2, 3, 4].map(function (index) {
          return { index: index, name: 'MAP ' + index, draftName: 'MAP ' + index, configured: false, peakBoostKpa: 0, boostTableFactory: undefined, data: null };
        })
      };
      scope.t = {};                 // latest telemetry
      scope.calibrationEpoch = null;
      scope.calibrationRevision = -1;
      scope.calibrationDirty = false;
      scope.calibrationChangeSource = '';
      scope.remoteClient = { authenticated: false, writerConnected: false, commandPhase: '', commandMessage: '' };
      var lastRejectToast = { msg: '', at: 0 };   // de-dupes the Pro Studio rejection toast
      scope.remoteTuneCatalog = { generation: 0, entries: [] };
      scope.appVersion = '3.0.7';   // shown in the title bar + Build Card footer + CSV meta below. KEEP IN SYNC with app.json "version" on every release - a screenshot naming its version saves a support round-trip every time.
      var versionSkewWarned = false;
      var KPA_TO_PSI = 0.1450377;
      scope.psi = false;            // boost/MAP unit toggle (kPa default, PSI when true)
      try { scope.psi = localStorage.getItem('oep_psi') === '1'; } catch (e) {}
      scope.toggleUnits = function () { scope.psi = !scope.psi; try { localStorage.setItem('oep_psi', scope.psi ? '1' : '0'); } catch (e) {} if (scope.activePage === 'logging' || scope.activePage === 'log_navigator') { drawLog(); } if (scope.mapAxisDraftRefresh) { scope.mapAxisDraftRefresh(); } refreshMixUi(scope.t, true); };
      scope.tempF = false;          // temperature unit toggle (°C default, °F when true) - display only
      try { scope.tempF = localStorage.getItem('oep_tempF') === '1'; } catch (e) {}
      scope.toggleTemp = function () { scope.tempF = !scope.tempF; try { localStorage.setItem('oep_tempF', scope.tempF ? '1' : '0'); } catch (e) {} setTimeout(drawDials, 30); refreshMixUi(scope.t, true); };
      scope.mph = false;            // ground-speed unit toggle (km/h default, mph when true) - display only, the speed table stays km/h
      try { scope.mph = localStorage.getItem('oep_mph') === '1'; } catch (e) {}
      scope.toggleSpeedUnits = function () { scope.mph = !scope.mph; try { localStorage.setItem('oep_mph', scope.mph ? '1' : '0'); } catch (e) {} };
      scope.speedUnit = function () { return scope.mph ? 'mph' : 'km/h'; };
      scope.boostSpeedLabel = function (kmh) { return scope.mph ? Math.round(Number(kmh) * 0.621371) : kmh; };
      scope.speedDisp = function (kmh) {
        var v = Number(kmh) || 0;
        return scope.mph ? ((v * 0.621371).toFixed(1) + ' mph') : (v.toFixed(1) + ' km/h');
      };
      scope.afr = false;            // Lambda Target table unit (Lambda default, AFR when true). DISPLAY ONLY - the table is always stored in lambda.
      try { scope.afr = localStorage.getItem('oep_afr') === '1'; } catch (e) {}
      scope.toggleAfr = function () { scope.afr = !scope.afr; try { localStorage.setItem('oep_afr', scope.afr ? '1' : '0'); } catch (e) {} };

      // ---- i18n: translate by English string, with English fallback ----------
      // (scope.t is telemetry, so the translate fn is scope.tr). Strings not in a
      // language map fall back to English, so coverage can grow without breaking.
      scope.lang = 'en';
      try { scope.lang = localStorage.getItem('oep_lang') || 'en'; } catch (e) {}
      // take the value as an arg and assign it here, so ng-model child-scope
      // shadowing can't stop scope.lang (the one tr() reads) from updating
      scope.setLang = function (v) { if (v) { scope.lang = v; } try { localStorage.setItem('oep_lang', scope.lang); } catch (e) {} };
      var LANG = {
        es: {
          'Learn': 'Aprender', 'Runtime': 'Tiempo real', 'ECU Settings': 'Ajustes ECU', 'Fuel': 'Combustible', 'Ignition': 'Encendido', 'Boost': 'Turbo', 'Idle / MotorSport': 'Ralentí / Carreras', 'Cam / Throttle / Trigger': 'Levas / Acelerador / Disparo', 'Aux / CAN': 'Aux / CAN',
          'Tutorial / Lessons': 'Tutorial / Lecciones', 'Help Me (Tune Doctor)': 'Ayúdame (Doctor)', 'Runtime Values': 'Valores en vivo', 'Runtime Gauges': 'Relojes en vivo', 'Dyno / AFR': 'Dyno / AFR', 'Log Analysis': 'Análisis de registro', 'Log Navigator': 'Navegador de registro',
          'Tune Files': 'Archivos de tune', 'Calibration Notes': 'Notas de calibración', 'Sensor Setup': 'Sensores', 'Axis Setup': 'Ejes (tablas)', 'Tune Compare': 'Comparar tune', 'Limiters': 'Limitadores', 'Protection': 'Protección', 'Faults': 'Fallos',
          'Fuel Setup': 'Config. combustible', 'Fuel Main': 'Combustible principal', 'Lambda Target': 'Objetivo lambda', 'Closed Loop Lambda': 'Lambda lazo cerrado', 'Individual Cyl Trims': 'Ajuste por cilindro', 'Mixture Map': 'Mapa de mezcla', 'Startup / Warmup': 'Arranque / Calentamiento', 'Transient Fuel': 'Combustible transitorio', 'Diesel Tuning': 'Ajuste diésel',
          'Ignition Main': 'Encendido principal', 'Knock Control': 'Control de detonación', 'Boost Target': 'Objetivo de boost', 'Boost Control': 'Control de boost', 'Idle Speed Control': 'Control de ralentí', 'Anti-Lag': 'Anti-Lag', 'VVT Control': 'Control VVT', 'E-Throttle': 'Acelerador electrónico', 'Trigger Scope': 'Osciloscopio de disparo', 'CAN Setup': 'Config. CAN', 'Aux Outputs': 'Salidas auxiliares', 'Help': 'Ayuda',
          'Configuration': 'Configuración', 'Tuning': 'Tuning', 'Logging': 'Registro',
          'File': 'Archivo', 'Options': 'Opciones', 'ECU Controls': 'Controles ECU', 'Display': 'Pantalla',
          'Connect': 'Conectar', 'Disconnect': 'Desconectar', 'Store ECU': 'Guardar ECU', 'Open Tune': 'Abrir tune', 'Compare': 'Comparar', 'Record Log': 'Grabar registro', 'Gauges': 'Relojes', 'Dyno': 'Dyno', 'Mixture': 'Mezcla', 'Help Me': 'Ayúdame',
          'Offline': 'Desconectado', 'Controlling': 'Controlando', 'Monitoring': 'Monitoreando', 'Store Req': 'Guardar', 'Stored': 'Guardado', 'Maybe later': 'Quizás luego',
          'VE Table': 'Tabla VE', 'Wastegate Duty': 'Ciclo wastegate', 'Launch extra fuel': 'Combustible extra en launch', 'Launch ignition retard': 'Retraso de encendido en launch',
          'Knock & damage sounds': 'Sonidos de detonación y daños'
        },
        fr: {
          'Learn': 'Apprendre', 'Runtime': 'Temps réel', 'ECU Settings': 'Réglages ECU', 'Fuel': 'Carburant', 'Ignition': 'Allumage', 'Boost': 'Turbo', 'Idle / MotorSport': 'Ralenti / Course', 'Cam / Throttle / Trigger': 'Cames / Accél. / Capteur', 'Aux / CAN': 'Aux / CAN',
          'Tutorial / Lessons': 'Tutoriel / Leçons', 'Help Me (Tune Doctor)': 'Aidez-moi (Docteur)', 'Runtime Values': 'Valeurs en direct', 'Runtime Gauges': 'Jauges en direct', 'Dyno / AFR': 'Dyno / AFR', 'Log Analysis': 'Analyse du journal', 'Log Navigator': 'Navigateur de journal',
          'Tune Files': 'Fichiers de tune', 'Calibration Notes': 'Notes de calibration', 'Sensor Setup': 'Capteurs', 'Axis Setup': 'Axes (tables)', 'Tune Compare': 'Comparer tune', 'Limiters': 'Limiteurs', 'Protection': 'Protection', 'Faults': 'Défauts',
          'Fuel Setup': 'Config. carburant', 'Fuel Main': 'Carburant principal', 'Lambda Target': 'Cible lambda', 'Closed Loop Lambda': 'Lambda boucle fermée', 'Individual Cyl Trims': 'Réglage par cylindre', 'Mixture Map': 'Carte de mélange', 'Startup / Warmup': 'Démarrage / Chauffe', 'Transient Fuel': 'Carburant transitoire',
          'Ignition Main': 'Allumage principal', 'Knock Control': 'Contrôle du cliquetis', 'Boost Target': 'Cible de suralim.', 'Boost Control': 'Contrôle suralim.', 'Idle Speed Control': 'Contrôle du ralenti', 'Anti-Lag': 'Anti-Lag', 'VVT Control': 'Contrôle VVT', 'E-Throttle': 'Accél. électronique', 'Trigger Scope': 'Oscilloscope capteur', 'CAN Setup': 'Config. CAN', 'Aux Outputs': 'Sorties auxiliaires', 'Help': 'Aide',
          'Configuration': 'Configuration', 'Tuning': 'Tuning', 'Logging': 'Journal',
          'File': 'Fichier', 'Options': 'Options', 'ECU Controls': 'Commandes ECU', 'Display': 'Affichage',
          'Connect': 'Connecter', 'Disconnect': 'Déconnecter', 'Store ECU': 'Enregistrer ECU', 'Open Tune': 'Ouvrir tune', 'Compare': 'Comparer', 'Record Log': 'Enreg. journal', 'Gauges': 'Jauges', 'Dyno': 'Dyno', 'Mixture': 'Mélange', 'Help Me': 'Aidez-moi',
          'Offline': 'Hors ligne', 'Controlling': 'Contrôle actif', 'Monitoring': 'Surveillance', 'Store Req': 'À enreg.', 'Stored': 'Enregistré', 'Maybe later': 'Plus tard',
          'VE Table': 'Table VE', 'Wastegate Duty': 'Duty wastegate', 'Launch extra fuel': 'Carburant additionnel launch', 'Launch ignition retard': 'Retard allumage launch',
          'Knock & damage sounds': 'Sons de cliquetis et dégâts'
        },
        pt: {
          'Learn': 'Aprender', 'Runtime': 'Tempo real', 'ECU Settings': 'Ajustes ECU', 'Fuel': 'Combustível', 'Ignition': 'Ignição', 'Boost': 'Turbo', 'Idle / MotorSport': 'Marcha lenta / Corrida', 'Cam / Throttle / Trigger': 'Comando / Acelerador / Disparo', 'Aux / CAN': 'Aux / CAN',
          'Tutorial / Lessons': 'Tutorial / Lições', 'Help Me (Tune Doctor)': 'Ajuda (Doutor)', 'Runtime Values': 'Valores ao vivo', 'Runtime Gauges': 'Medidores ao vivo', 'Dyno / AFR': 'Dyno / AFR', 'Log Analysis': 'Análise de registro', 'Log Navigator': 'Navegador de registro',
          'Tune Files': 'Arquivos de tune', 'Calibration Notes': 'Notas de calibração', 'Sensor Setup': 'Sensores', 'Axis Setup': 'Eixos (tabelas)', 'Tune Compare': 'Comparar tune', 'Limiters': 'Limitadores', 'Protection': 'Proteção', 'Faults': 'Falhas',
          'Fuel Setup': 'Config. combustível', 'Fuel Main': 'Combustível principal', 'Lambda Target': 'Alvo lambda', 'Closed Loop Lambda': 'Lambda malha fechada', 'Individual Cyl Trims': 'Ajuste por cilindro', 'Mixture Map': 'Mapa de mistura', 'Startup / Warmup': 'Partida / Aquecimento', 'Transient Fuel': 'Combustível transitório',
          'Ignition Main': 'Ignição principal', 'Knock Control': 'Controle de detonação', 'Boost Target': 'Alvo de boost', 'Boost Control': 'Controle de boost', 'Idle Speed Control': 'Controle de marcha lenta', 'Anti-Lag': 'Anti-Lag', 'VVT Control': 'Controle VVT', 'E-Throttle': 'Acelerador eletrônico', 'Trigger Scope': 'Osciloscópio de disparo', 'CAN Setup': 'Config. CAN', 'Aux Outputs': 'Saídas auxiliares', 'Help': 'Ajuda',
          'Configuration': 'Configuração', 'Tuning': 'Tuning', 'Logging': 'Registro',
          'File': 'Arquivo', 'Options': 'Opções', 'ECU Controls': 'Controles ECU', 'Display': 'Tela',
          'Connect': 'Conectar', 'Disconnect': 'Desconectar', 'Store ECU': 'Salvar ECU', 'Open Tune': 'Abrir tune', 'Compare': 'Comparar', 'Record Log': 'Gravar registro', 'Gauges': 'Medidores', 'Dyno': 'Dyno', 'Mixture': 'Mistura', 'Help Me': 'Ajuda',
          'Offline': 'Offline', 'Controlling': 'Controlando', 'Monitoring': 'Monitorando', 'Store Req': 'Salvar', 'Stored': 'Salvo', 'Maybe later': 'Talvez depois',
          'VE Table': 'Tabela VE', 'Wastegate Duty': 'Ciclo wastegate', 'Launch extra fuel': 'Combustível extra na largada', 'Launch ignition retard': 'Atraso de ignição na largada',
          'Knock & damage sounds': 'Sons de detonação e danos'
        }
      };
      scope.tr = function (s) {
        if (scope.lang === 'en' || !s) { return s; }
        var d = LANG[scope.lang];
        return (d && d[s] !== undefined) ? d[s] : s;
      };
      // Stage 2: runtime screen, page titles, dropdown options, settings control labels
      var LANG2 = {
        es: {
          'Faults / Diagnostics': 'Fallos / Diagnóstico', 'Help Me &mdash; Tune Doctor': 'Ayúdame &mdash; Doctor de Tune',
          'Ignition / Knock': 'Encendido / Detonación', 'Misfire / Faults': 'Fallos de encendido', 'Engine Protection': 'Protección del motor', 'Power Estimate': 'Estimación de potencia', 'Sensors': 'Sensores',
          'Engine Speed (RPM)': 'Régimen (RPM)', 'MAP (kPa)': 'MAP (kPa)', 'Boost (kPa)': 'Boost (kPa)', 'Lambda Actual': 'Lambda real', 'Lambda Error': 'Error lambda', 'Fuel Trim (%)': 'Ajuste comb. (%)', 'Injector Duty (%)': 'Ciclo inyector (%)', 'Fuel Pressure (kPa)': 'Presión comb. (kPa)', 'CL Lambda': 'Lambda CL', 'Ignition Angle (BTDC)': 'Ángulo encendido (APMS)', 'Ignition Trim (table) (deg)': 'Ajuste encendido (tabla) (°)', 'IAT Timing Trim (deg)': 'Ajuste IAT (°)', 'Knock Level': 'Nivel detonación', 'Knock Retard (deg)': 'Retardo det. (°)', 'Knock Count': 'Cuenta detonación', 'Misfire Status': 'Estado fallo', 'Misfire Count': 'Cuenta fallos', 'Fault Codes': 'Códigos de fallo', 'Limp Mode': 'Modo emergencia', 'RPM Limit': 'Límite RPM', 'Active Rev Limit': 'Límite RPM activo', 'Launch Limit': 'Límite largada', 'Limiter Status': 'Estado limitador', 'Engine Health (%)': 'Salud motor (%)', 'Power Limit (%)': 'Límite potencia (%)', 'Boost Target (kPa)': 'Objetivo boost (kPa)', 'Boost Error (kPa)': 'Error boost (kPa)', 'Wastegate Duty (%)': 'Ciclo wastegate (%)', 'Boost Status': 'Estado boost', 'Engine Torque (Nm)': 'Par motor (Nm)', 'Engine Power (hp)': 'Potencia motor (hp)', 'Wheel Power (whp)': 'Potencia rueda (whp)', 'Cal Torque Δ (Nm)': 'Δ par cal. (Nm)', 'Oil (°C)': 'Aceite (°C)',
          'Knock': 'Detonación', 'Knock retard': 'Retardo det.', 'Injector duty': 'Ciclo inyector', 'Fuel pressure': 'Presión comb.', 'Engine health': 'Salud motor',
          'Pump Gas': 'Gasolina', 'Open loop': 'Lazo abierto', 'Closed loop': 'Lazo cerrado', 'Gear trim': 'Ajuste por marcha', 'Traditional': 'Tradicional', 'Modelled': 'Modelado', 'Off': 'Apagado', 'Stoich only': 'Solo estequio.', 'Always on target': 'Siempre al objetivo', 'Cable throttle': 'Acelerador cable', 'Linear': 'Lineal', 'Progressive': 'Progresivo', 'Aggressive': 'Agresivo', 'Good pattern': 'Patrón bueno', 'Noisy crank signal': 'Señal cigüeñal ruidosa', 'Missing cam sync': 'Sin sync de leva', 'Generic Dash': 'Tablero genérico',
          'MAP offset': 'Offset MAP', 'MAP scale': 'Escala MAP', 'TPS offset': 'Offset TPS', 'TPS scale': 'Escala TPS', 'IAT bias': 'Sesgo IAT', 'ECT bias': 'Sesgo ECT', 'Lambda bias': 'Sesgo lambda', 'Fuel pressure bias': 'Sesgo presión comb.', 'Master fuel': 'Combustible base', 'Injector flow': 'Caudal inyector', 'Injector deadtime': 'Tiempo muerto iny.', 'Stoich AFR': 'AFR estequio.', 'ECT lockout': 'Bloqueo ECT', 'TPS lockout': 'Bloqueo TPS', 'MAP lockout': 'Bloqueo MAP', 'Cyl 1': 'Cil 1', 'Cyl 2': 'Cil 2', 'Cyl 3': 'Cil 3', 'Cyl 4': 'Cil 4', 'Cyl 5': 'Cil 5', 'Cyl 6': 'Cil 6', 'Cranking enrich': 'Enriq. arranque', 'Post-start enrich': 'Enriq. post-arranque', 'Post-start decay': 'Decaim. post-arranque', 'Warmup enrich': 'Enriq. calentamiento', 'Warmup ends': 'Fin calentamiento', 'Accel enrich': 'Enriq. aceleración', 'Accel decay': 'Decaim. aceleración', 'Wall wetting': 'Película pared', 'Lift-off fuel': 'Comb. al levantar', 'Base duty': 'Ciclo base', 'Proportional gain': 'Ganancia proporcional', 'Integral gain': 'Ganancia integral', 'Overboost cut': 'Corte sobreboost', 'Knock threshold': 'Umbral detonación', 'Max retard': 'Retardo máx.', 'Recovery rate': 'Tasa recuperación', 'Target idle': 'Ralentí objetivo', 'Base valve pos': 'Pos. válvula base', 'Retard': 'Retardo', 'Extra fuel': 'Combustible extra', 'Intake advance': 'Avance admisión', 'Tracking tolerance': 'Tolerancia seguim.', 'Fan on temp': 'Temp. ventilador', 'Fuel pump prime': 'Cebado bomba', 'Shift light': 'Luz de cambio', 'Rev limit (0=stock)': 'Límite RPM (0=orig.)', 'Launch limit': 'Límite de largada', 'Drivetrain loss (crank estimate)': 'Pérdida transm. (estim. cigüeñal)', 'Wastegate type': 'Tipo de wastegate', 'Turbo max (read/learned)': 'Máx. turbo (leído/aprendido)', 'Measured drivetrain loss (live)': 'Pérdida transm. medida (en vivo)', 'Reset axes to factory': 'Restablecer ejes de fábrica', 'Decel fuel cut': 'Corte comb. decel.', 'Knock control enabled': 'Control detonación act.', 'Idle control enabled': 'Control ralentí act.', 'Idle ignition control': 'Control encendido ralentí', 'Anti-lag enabled': 'Anti-lag activado', 'VVT enabled': 'VVT activado', 'Transmit runtime': 'Transmitir en vivo', 'Transmit lambda': 'Transmitir lambda', 'Transmit dash': 'Transmitir tablero', 'Check-engine output': 'Salida check-engine', '2-step launch enabled': 'Largada 2-step activada', 'ECU enabled': 'ECU activada', 'Engine protection': 'Protección motor', 'Knock control': 'Control detonación', 'Fuel type': 'Tipo combustible', 'Fuel equation': 'Ecuación combustible', 'CLL mode': 'Modo CLL', 'Boost mode': 'Modo boost', 'Throttle mode': 'Modo acelerador', 'Pedal curve': 'Curva del pedal', 'Trigger pattern': 'Patrón de disparo', 'Base timing': 'Avance base', 'Displayed ignition advance': 'Avance de encendido mostrado', 'CAN mode': 'Modo CAN', 'CAN speed': 'Velocidad CAN', 'Raw MAP': 'MAP cruda', 'ECU MAP': 'MAP ECU', 'Raw TPS': 'TPS cruda', 'ECU TPS': 'TPS ECU', 'ECU IAT': 'IAT ECU', 'ECU ECT': 'ECT ECU', 'True lambda': 'Lambda real', 'ECU lambda': 'Lambda ECU', 'Inj pulse': 'Pulso iny.', 'CLL status': 'Estado CLL', 'Trim avg': 'Prom. ajuste', 'Trim spread': 'Disp. ajuste', 'Misfire': 'Fallo encendido', 'Startup total': 'Arranque total', 'TPS delta': 'Delta TPS', 'Boost target': 'Objetivo boost', 'Boost error': 'Error boost', 'Wastegate duty': 'Ciclo wastegate', 'Status': 'Estado', 'Knock level': 'Nivel detonación', 'Knock retard': 'Retardo detonación', 'Knock count': 'Cuenta detonación', 'Idle status': 'Estado ralentí', 'Valve pos': 'Pos. válvula', 'Idle error': 'Error ralentí', 'Exhaust heat': 'Calor escape', 'VVT target': 'Objetivo VVT', 'VVT actual': 'VVT real', 'Pedal target': 'Objetivo pedal', 'Trigger status': 'Estado disparo', 'Active rev limit': 'Límite RPM activo', 'Limiter status': 'Estado limitador', 'Protection status': 'Estado protección', 'Engine health ': 'Salud motor', 'Limp mode': 'Modo emergencia'
        },
        fr: {
          'Faults / Diagnostics': 'Défauts / Diagnostic', 'Help Me &mdash; Tune Doctor': 'Aidez-moi &mdash; Docteur Tune',
          'Ignition / Knock': 'Allumage / Cliquetis', 'Misfire / Faults': 'Ratés / Défauts', 'Engine Protection': 'Protection moteur', 'Power Estimate': 'Estimation puissance', 'Sensors': 'Capteurs',
          'Engine Speed (RPM)': 'Régime (RPM)', 'MAP (kPa)': 'MAP (kPa)', 'Boost (kPa)': 'Boost (kPa)', 'Lambda Actual': 'Lambda réel', 'Lambda Error': 'Erreur lambda', 'Fuel Trim (%)': 'Réglage carb. (%)', 'Injector Duty (%)': 'Cycle injecteur (%)', 'Fuel Pressure (kPa)': 'Pression carb. (kPa)', 'CL Lambda': 'Lambda CL', 'Ignition Angle (BTDC)': 'Angle allumage (BTDC)', 'Ignition Trim (table) (deg)': 'Réglage allumage (table) (°)', 'IAT Timing Trim (deg)': 'Réglage IAT (°)', 'Knock Level': 'Niveau cliquetis', 'Knock Retard (deg)': 'Retard clq. (°)', 'Knock Count': 'Compte cliquetis', 'Misfire Status': 'État raté', 'Misfire Count': 'Compte ratés', 'Fault Codes': 'Codes défaut', 'Limp Mode': 'Mode dégradé', 'RPM Limit': 'Limite régime', 'Active Rev Limit': 'Limite régime active', 'Launch Limit': 'Limite départ', 'Limiter Status': 'État limiteur', 'Engine Health (%)': 'Santé moteur (%)', 'Power Limit (%)': 'Limite puissance (%)', 'Boost Target (kPa)': 'Cible boost (kPa)', 'Boost Error (kPa)': 'Erreur boost (kPa)', 'Wastegate Duty (%)': 'Cycle wastegate (%)', 'Boost Status': 'État boost', 'Engine Torque (Nm)': 'Couple moteur (Nm)', 'Engine Power (hp)': 'Puissance moteur (ch)', 'Wheel Power (whp)': 'Puissance roue (whp)', 'Cal Torque Δ (Nm)': 'Δ couple cal. (Nm)', 'Oil (°C)': 'Huile (°C)',
          'Knock': 'Cliquetis', 'Knock retard': 'Retard clq.', 'Injector duty': 'Cycle injecteur', 'Fuel pressure': 'Pression carb.', 'Engine health': 'Santé moteur',
          'Pump Gas': 'Essence', 'Open loop': 'Boucle ouverte', 'Closed loop': 'Boucle fermée', 'Gear trim': 'Réglage par rapport', 'Traditional': 'Traditionnel', 'Modelled': 'Modélisé', 'Off': 'Désactivé', 'Stoich only': 'Stoechio. seul.', 'Always on target': 'Toujours sur cible', 'Cable throttle': 'Accélérateur câble', 'Linear': 'Linéaire', 'Progressive': 'Progressif', 'Aggressive': 'Agressif', 'Good pattern': 'Bon motif', 'Noisy crank signal': 'Signal vilebrequin bruité', 'Missing cam sync': 'Sync came manquante', 'Generic Dash': 'Tableau générique',
          'MAP offset': 'Décalage MAP', 'MAP scale': 'Échelle MAP', 'TPS offset': 'Décalage TPS', 'TPS scale': 'Échelle TPS', 'IAT bias': 'Biais IAT', 'ECT bias': 'Biais ECT', 'Lambda bias': 'Biais lambda', 'Fuel pressure bias': 'Biais pression carb.', 'Master fuel': 'Carburant maître', 'Injector flow': 'Débit injecteur', 'Injector deadtime': 'Temps mort inj.', 'Stoich AFR': 'AFR stoechio.', 'ECT lockout': 'Verrou ECT', 'TPS lockout': 'Verrou TPS', 'MAP lockout': 'Verrou MAP', 'Cyl 1': 'Cyl 1', 'Cyl 2': 'Cyl 2', 'Cyl 3': 'Cyl 3', 'Cyl 4': 'Cyl 4', 'Cyl 5': 'Cyl 5', 'Cyl 6': 'Cyl 6', 'Cranking enrich': 'Enrich. lancement', 'Post-start enrich': 'Enrich. post-démarrage', 'Post-start decay': 'Déclin post-démarrage', 'Warmup enrich': 'Enrich. chauffe', 'Warmup ends': 'Fin de chauffe', 'Accel enrich': 'Enrich. accél.', 'Accel decay': 'Déclin accél.', 'Wall wetting': 'Mouillage paroi', 'Lift-off fuel': 'Carb. au lever de pied', 'Base duty': 'Rapport cyclique base', 'Proportional gain': 'Gain proportionnel', 'Integral gain': 'Gain intégral', 'Overboost cut': 'Coupure surpression', 'Knock threshold': 'Seuil cliquetis', 'Max retard': 'Retard max.', 'Recovery rate': 'Taux récupération', 'Target idle': 'Ralenti cible', 'Base valve pos': 'Pos. vanne base', 'Retard': 'Retard', 'Extra fuel': 'Carburant en plus', 'Intake advance': 'Avance admission', 'Tracking tolerance': 'Tolérance suivi', 'Fan on temp': 'Temp. ventilateur', 'Fuel pump prime': 'Amorçage pompe', 'Shift light': 'Témoin de passage', 'Rev limit (0=stock)': 'Limite régime (0=origine)', 'Launch limit': 'Limite de départ', 'Drivetrain loss (crank estimate)': 'Pertes transm. (estim. vilebrequin)', 'Wastegate type': 'Type de wastegate', 'Turbo max (read/learned)': 'Max turbo (lu/appris)', 'Measured drivetrain loss (live)': 'Pertes transm. mesurées (live)', 'Reset axes to factory': 'Réinitialiser les axes d\'usine', 'Decel fuel cut': 'Coupure carb. décél.', 'Knock control enabled': 'Contrôle cliquetis activé', 'Idle control enabled': 'Contrôle ralenti activé', 'Idle ignition control': 'Contrôle allumage ralenti', 'Anti-lag enabled': 'Anti-lag activé', 'VVT enabled': 'VVT activé', 'Transmit runtime': 'Transmettre live', 'Transmit lambda': 'Transmettre lambda', 'Transmit dash': 'Transmettre tableau', 'Check-engine output': 'Sortie voyant moteur', '2-step launch enabled': 'Départ 2-step activé', 'ECU enabled': 'ECU activé', 'Engine protection': 'Protection moteur', 'Knock control': 'Contrôle cliquetis', 'Fuel type': 'Type carburant', 'Fuel equation': 'Équation carburant', 'CLL mode': 'Mode CLL', 'Boost mode': 'Mode suralim.', 'Throttle mode': 'Mode accélérateur', 'Pedal curve': 'Courbe pédale', 'Trigger pattern': 'Motif capteur', 'Base timing': 'Avance de base', 'Displayed ignition advance': 'Avance d\'allumage affichée', 'CAN mode': 'Mode CAN', 'CAN speed': 'Vitesse CAN', 'Raw MAP': 'MAP brute', 'ECU MAP': 'MAP ECU', 'Raw TPS': 'TPS brut', 'ECU TPS': 'TPS ECU', 'ECU IAT': 'IAT ECU', 'ECU ECT': 'ECT ECU', 'True lambda': 'Lambda réel', 'ECU lambda': 'Lambda ECU', 'Inj pulse': 'Impulsion inj.', 'CLL status': 'État CLL', 'Trim avg': 'Moy. réglage', 'Trim spread': 'Écart réglage', 'Misfire': 'Raté', 'Startup total': 'Démarrage total', 'TPS delta': 'Delta TPS', 'Boost target': 'Cible boost', 'Boost error': 'Erreur boost', 'Wastegate duty': 'Cycle wastegate', 'Status': 'État', 'Knock level': 'Niveau cliquetis', 'Knock retard': 'Retard cliquetis', 'Knock count': 'Compte cliquetis', 'Idle status': 'État ralenti', 'Valve pos': 'Pos. vanne', 'Idle error': 'Erreur ralenti', 'Exhaust heat': 'Chaleur échapp.', 'VVT target': 'Cible VVT', 'VVT actual': 'VVT réel', 'Pedal target': 'Cible pédale', 'Trigger status': 'État capteur', 'Active rev limit': 'Limite régime active', 'Limiter status': 'État limiteur', 'Protection status': 'État protection', 'Engine health ': 'Santé moteur', 'Limp mode': 'Mode dégradé'
        },
        pt: {
          'Faults / Diagnostics': 'Falhas / Diagnóstico', 'Help Me &mdash; Tune Doctor': 'Ajuda &mdash; Doutor de Tune',
          'Ignition / Knock': 'Ignição / Detonação', 'Misfire / Faults': 'Falhas de ignição', 'Engine Protection': 'Proteção do motor', 'Power Estimate': 'Estimativa de potência', 'Sensors': 'Sensores',
          'Engine Speed (RPM)': 'Rotação (RPM)', 'MAP (kPa)': 'MAP (kPa)', 'Boost (kPa)': 'Boost (kPa)', 'Lambda Actual': 'Lambda real', 'Lambda Error': 'Erro lambda', 'Fuel Trim (%)': 'Ajuste comb. (%)', 'Injector Duty (%)': 'Ciclo injetor (%)', 'Fuel Pressure (kPa)': 'Pressão comb. (kPa)', 'CL Lambda': 'Lambda CL', 'Ignition Angle (BTDC)': 'Ângulo ignição (APMS)', 'Ignition Trim (table) (deg)': 'Ajuste ignição (tabela) (°)', 'IAT Timing Trim (deg)': 'Ajuste IAT (°)', 'Knock Level': 'Nível detonação', 'Knock Retard (deg)': 'Atraso det. (°)', 'Knock Count': 'Contagem detonação', 'Misfire Status': 'Estado falha', 'Misfire Count': 'Contagem falhas', 'Fault Codes': 'Códigos de falha', 'Limp Mode': 'Modo emergência', 'RPM Limit': 'Limite RPM', 'Active Rev Limit': 'Limite RPM ativo', 'Launch Limit': 'Limite largada', 'Limiter Status': 'Estado limitador', 'Engine Health (%)': 'Saúde motor (%)', 'Power Limit (%)': 'Limite potência (%)', 'Boost Target (kPa)': 'Alvo boost (kPa)', 'Boost Error (kPa)': 'Erro boost (kPa)', 'Wastegate Duty (%)': 'Ciclo wastegate (%)', 'Boost Status': 'Estado boost', 'Engine Torque (Nm)': 'Torque motor (Nm)', 'Engine Power (hp)': 'Potência motor (hp)', 'Wheel Power (whp)': 'Potência roda (whp)', 'Cal Torque Δ (Nm)': 'Δ torque cal. (Nm)', 'Oil (°C)': 'Óleo (°C)',
          'Knock': 'Detonação', 'Knock retard': 'Atraso det.', 'Injector duty': 'Ciclo injetor', 'Fuel pressure': 'Pressão comb.', 'Engine health': 'Saúde motor',
          'Pump Gas': 'Gasolina', 'Open loop': 'Malha aberta', 'Closed loop': 'Malha fechada', 'Gear trim': 'Ajuste por marcha', 'Traditional': 'Tradicional', 'Modelled': 'Modelado', 'Off': 'Desligado', 'Stoich only': 'Só estequio.', 'Always on target': 'Sempre no alvo', 'Cable throttle': 'Acelerador cabo', 'Linear': 'Linear', 'Progressive': 'Progressivo', 'Aggressive': 'Agressivo', 'Good pattern': 'Padrão bom', 'Noisy crank signal': 'Sinal virabrequim ruidoso', 'Missing cam sync': 'Sem sync de comando', 'Generic Dash': 'Painel genérico',
          'MAP offset': 'Offset MAP', 'MAP scale': 'Escala MAP', 'TPS offset': 'Offset TPS', 'TPS scale': 'Escala TPS', 'IAT bias': 'Viés IAT', 'ECT bias': 'Viés ECT', 'Lambda bias': 'Viés lambda', 'Fuel pressure bias': 'Viés pressão comb.', 'Master fuel': 'Combustível base', 'Injector flow': 'Vazão injetor', 'Injector deadtime': 'Tempo morto inj.', 'Stoich AFR': 'AFR estequio.', 'ECT lockout': 'Bloqueio ECT', 'TPS lockout': 'Bloqueio TPS', 'MAP lockout': 'Bloqueio MAP', 'Cyl 1': 'Cil 1', 'Cyl 2': 'Cil 2', 'Cyl 3': 'Cil 3', 'Cyl 4': 'Cil 4', 'Cyl 5': 'Cil 5', 'Cyl 6': 'Cil 6', 'Cranking enrich': 'Enriq. partida', 'Post-start enrich': 'Enriq. pós-partida', 'Post-start decay': 'Decaim. pós-partida', 'Warmup enrich': 'Enriq. aquecimento', 'Warmup ends': 'Fim aquecimento', 'Accel enrich': 'Enriq. aceleração', 'Accel decay': 'Decaim. aceleração', 'Wall wetting': 'Filme parede', 'Lift-off fuel': 'Comb. ao tirar pé', 'Base duty': 'Ciclo base', 'Proportional gain': 'Ganho proporcional', 'Integral gain': 'Ganho integral', 'Overboost cut': 'Corte sobreboost', 'Knock threshold': 'Limiar detonação', 'Max retard': 'Atraso máx.', 'Recovery rate': 'Taxa recuperação', 'Target idle': 'Marcha lenta alvo', 'Base valve pos': 'Pos. válvula base', 'Retard': 'Atraso', 'Extra fuel': 'Combustível extra', 'Intake advance': 'Avanço admissão', 'Tracking tolerance': 'Tolerância rastreio', 'Fan on temp': 'Temp. ventoinha', 'Fuel pump prime': 'Escorva bomba', 'Shift light': 'Luz de troca', 'Rev limit (0=stock)': 'Limite RPM (0=orig.)', 'Launch limit': 'Limite de largada', 'Drivetrain loss (crank estimate)': 'Perda transm. (estim. virabrequim)', 'Wastegate type': 'Tipo de wastegate', 'Turbo max (read/learned)': 'Máx. turbo (lido/aprendido)', 'Measured drivetrain loss (live)': 'Perda transm. medida (ao vivo)', 'Reset axes to factory': 'Redefinir eixos de fábrica', 'Decel fuel cut': 'Corte comb. desacel.', 'Knock control enabled': 'Controle detonação ativo', 'Idle control enabled': 'Controle marcha lenta ativo', 'Idle ignition control': 'Controle ignição marcha lenta', 'Anti-lag enabled': 'Anti-lag ativado', 'VVT enabled': 'VVT ativado', 'Transmit runtime': 'Transmitir ao vivo', 'Transmit lambda': 'Transmitir lambda', 'Transmit dash': 'Transmitir painel', 'Check-engine output': 'Saída check-engine', '2-step launch enabled': 'Largada 2-step ativada', 'ECU enabled': 'ECU ativada', 'Engine protection': 'Proteção motor', 'Knock control': 'Controle detonação', 'Fuel type': 'Tipo combustível', 'Fuel equation': 'Equação combustível', 'CLL mode': 'Modo CLL', 'Boost mode': 'Modo boost', 'Throttle mode': 'Modo acelerador', 'Pedal curve': 'Curva do pedal', 'Trigger pattern': 'Padrão de disparo', 'Base timing': 'Avanço base', 'Displayed ignition advance': 'Avanço de ignição exibido', 'CAN mode': 'Modo CAN', 'CAN speed': 'Velocidade CAN', 'Raw MAP': 'MAP bruta', 'ECU MAP': 'MAP ECU', 'Raw TPS': 'TPS bruto', 'ECU TPS': 'TPS ECU', 'ECU IAT': 'IAT ECU', 'ECU ECT': 'ECT ECU', 'True lambda': 'Lambda real', 'ECU lambda': 'Lambda ECU', 'Inj pulse': 'Pulso inj.', 'CLL status': 'Estado CLL', 'Trim avg': 'Média ajuste', 'Trim spread': 'Disp. ajuste', 'Misfire': 'Falha ignição', 'Startup total': 'Partida total', 'TPS delta': 'Delta TPS', 'Boost target': 'Alvo boost', 'Boost error': 'Erro boost', 'Wastegate duty': 'Ciclo wastegate', 'Status': 'Estado', 'Knock level': 'Nível detonação', 'Knock retard': 'Atraso detonação', 'Knock count': 'Contagem detonação', 'Idle status': 'Estado marcha lenta', 'Valve pos': 'Pos. válvula', 'Idle error': 'Erro marcha lenta', 'Exhaust heat': 'Calor escape', 'VVT target': 'Alvo VVT', 'VVT actual': 'VVT real', 'Pedal target': 'Alvo pedal', 'Trigger status': 'Estado disparo', 'Active rev limit': 'Limite RPM ativo', 'Limiter status': 'Estado limitador', 'Protection status': 'Estado proteção', 'Engine health ': 'Saúde motor', 'Limp mode': 'Modo emergência'
        }
      };
      ['es', 'fr', 'pt'].forEach(function (L) { for (var k in LANG2[L]) { LANG[L][k] = LANG2[L][k]; } });
      // Stage 5: page help blurbs (settings pageDef.help + the hardcoded page intros)
      var LANG3 = {
        es: {
          'Calibrates what the ECU sees. Bad MAP/TPS/IAT/ECT/lambda calibration makes a good tune behave like a bad one.': 'Calibra lo que la ECU ve. Una mala calibración de MAP/TPS/IAT/ECT/lambda hace que un buen tune se comporte como malo.',
          'Master fuel and injector sizing. Modelled mode uses MAP for air demand; traditional uses throttle+load.': 'Combustible base y dimensión de inyectores. El modo Modelado usa MAP para la demanda de aire; el Tradicional usa acelerador+carga.',
          'Closed-loop trims fuel toward the lambda target when conditions allow. Lockouts hold it open-loop under load.': 'El lazo cerrado ajusta el combustible hacia el objetivo lambda cuando las condiciones lo permiten. Los bloqueos lo mantienen en lazo abierto con carga.',
          'Per-cylinder fuel trim. A wide spread destabilises combustion and raises misfire risk.': 'Ajuste de combustible por cilindro. Una dispersión grande desestabiliza la combustión y sube el riesgo de fallos.',
          'Cranking, post-start and warmup fuel. Too little = lean cold stumble; too much = rich fouling.': 'Combustible de arranque, post-arranque y calentamiento. Muy poco = tirón pobre en frío; demasiado = ensuciamiento rico.',
          'Throttle tip-in and lift-off fuel. Bad values cause bog, lean hesitation, rich stumble or harsh decel cut.': 'Combustible al pisar y al levantar el acelerador. Valores malos causan ahogo, vacilación pobre, tirón rico o corte de desaceleración brusco.',
          'Wastegate strategy (turbo only). The Boost Target table sets the target; these gains decide how it is chased.': 'Estrategia del wastegate (solo turbo). La tabla Objetivo de boost fija el objetivo; estas ganancias deciden cómo se persigue.',
          'Knock detection retards timing to protect the engine. Too high a threshold lets real knock through.': 'La detección de detonación retarda el avance para proteger el motor. Un umbral muy alto deja pasar detonación real.',
          'Closed-loop idle valve position plus optional ignition correction to hold the target idle.': 'Posición de válvula de ralentí en lazo cerrado más corrección de encendido opcional para mantener el ralentí objetivo.',
          'Off-throttle retard + fuel models exhaust heat, cracks and flame. OE requests native anti-lag when the fitted turbo supports it; actual spool/boost is vehicle-dependent. Hard on the engine.': 'El retardo + combustible sin acelerador modela calor, explosiones y llama. OE solicita el anti-lag nativo cuando el turbo montado lo admite; el giro/boost real depende del vehículo. Duro para el motor.',
          'Intake cam advance targets the midrange for torque, tapering off at idle and the top end.': 'El avance de la leva de admisión apunta al medio régimen para par, reduciéndose en ralentí y arriba.',
          'Pedal-to-target curve plus TPS/target safety monitoring. A tracking fault triggers a safety cut.': 'Curva pedal-a-objetivo más monitoreo de seguridad TPS/objetivo. Un fallo de seguimiento dispara un corte de seguridad.',
          'Crank/cam trigger signal quality and base-timing calibration. In a real ECU this is where you set base timing with a timing light so commanded advance matches the crank; noise or lost sync injects misfire risk and latches a Trigger fault.': 'Calidad de la señal de disparo cigüeñal/leva y calibración del avance base. En una ECU real aquí se fija el avance base con lámpara estroboscópica para que el avance comandado coincida con el cigüeñal; el ruido o pérdida de sync mete riesgo de fallos y retiene un fallo de Disparo.',
          'CAN bus transmit setup (training/status only in this trainer).': 'Configuración de transmisión CAN (solo entrenamiento/estado en este entrenador).',
          'Fan, fuel pump prime, shift light and check-engine output.': 'Ventilador, cebado de bomba, luz de cambio y salida check-engine.',
          'Rev limit (0 = stock), 2-step launch limit, flat-foot / no-lift shift, and drivetrain loss (wheel power is MEASURED now - the slider is your crank-estimate factor).': 'Límite de RPM (0 = serie), largada 2-step, cambio a fondo sin levantar, y pérdida de transmisión (la potencia en rueda ahora se MIDE - el slider es tu factor de estimación al cigüeñal).',
          'Master enable, engine protection (knock/overboost cut), and knock control. Turning protection off lets you blow the engine.': 'Activación maestra, protección del motor (corte por detonación/sobreboost) y control de detonación. Apagar la protección te deja reventar el motor.',
          'Yellow = investigate, red = lift. The values a tuner watches during a pull.': 'Amarillo = investiga, rojo = levanta. Los valores que un tuner mira durante una pasada.',
          'Full-throttle pull from low RPM to the limiter. Red = engine (crank) HP, yellow = torque (lb-ft). First pull saves a baseline. Runtime wheel HP is MEASURED through the drivetrain like a hub dyno - the loss slider on Limiters is just your crank-estimate factor.': 'Pasada a fondo desde bajas RPM hasta el limitador. Rojo = HP de motor (cigüeñal), amarillo = par (lb-ft). La primera pasada guarda una base. El HP en rueda ahora se MIDE a través de la transmisión como un dyno de cubo - el slider de pérdida en Limitadores es solo tu factor de estimación al cigüeñal.',
          'After a logged pull, cells show suggested Fuel Main corrections (% ) from lambda error. Red = add fuel, blue = pull fuel.': 'Tras una pasada grabada, las celdas muestran correcciones sugeridas de Combustible Principal (%) del error de lambda. Rojo = añade combustible, azul = quita combustible.',
          // V3.0.7: replaces the orphaned pre-2.8 key (its English text was rewritten on the page in
          // 2.8 and these three tables were never repointed, so ES/FR/PT silently fell back to
          // English). The count is interpolated in app.html now, so no number lives in the key.
          'Edit RPM columns and the load-row breakpoints. Alpha-N TPS must have exactly one value per table row.': 'Edita los puntos de las columnas de RPM y de las filas de carga. El eje TPS Alpha-N debe tener exactamente un valor por fila de la tabla.',
          'values per axis.': 'valores por eje.',
          // V3.0.7 Table resolution control
          'Table resolution': 'Resolución de tablas', 'Standard': 'Estándar', 'Detailed': 'Detallada',
          'Fine': 'Fina', 'CONFIRM': 'CONFIRMAR', 'Now': 'Ahora', 'cells': 'celdas'
        },
        fr: {
          'Calibrates what the ECU sees. Bad MAP/TPS/IAT/ECT/lambda calibration makes a good tune behave like a bad one.': 'Calibre ce que l\'ECU voit. Une mauvaise calibration MAP/TPS/IAT/ECT/lambda fait se comporter un bon tune comme un mauvais.',
          'Master fuel and injector sizing. Modelled mode uses MAP for air demand; traditional uses throttle+load.': 'Carburant maître et taille injecteurs. Le mode Modélisé utilise le MAP pour la demande d\'air; le Traditionnel utilise papillon+charge.',
          'Closed-loop trims fuel toward the lambda target when conditions allow. Lockouts hold it open-loop under load.': 'La boucle fermée ajuste le carburant vers la cible lambda quand les conditions le permettent. Les verrous le gardent en boucle ouverte sous charge.',
          'Per-cylinder fuel trim. A wide spread destabilises combustion and raises misfire risk.': 'Réglage carburant par cylindre. Un grand écart déstabilise la combustion et augmente le risque de ratés.',
          'Cranking, post-start and warmup fuel. Too little = lean cold stumble; too much = rich fouling.': 'Carburant de lancement, post-démarrage et chauffe. Trop peu = trou pauvre à froid; trop = encrassement riche.',
          'Throttle tip-in and lift-off fuel. Bad values cause bog, lean hesitation, rich stumble or harsh decel cut.': 'Carburant à la remise des gaz et au lever de pied. De mauvaises valeurs causent un noyage, une hésitation pauvre, un à-coup riche ou une coupe de décélération brutale.',
          'Wastegate strategy (turbo only). The Boost Target table sets the target; these gains decide how it is chased.': 'Stratégie du wastegate (turbo seul). La table Cible de boost fixe la cible; ces gains décident comment elle est visée.',
          'Knock detection retards timing to protect the engine. Too high a threshold lets real knock through.': 'La détection du cliquetis retarde l\'avance pour protéger le moteur. Un seuil trop haut laisse passer le vrai cliquetis.',
          'Closed-loop idle valve position plus optional ignition correction to hold the target idle.': 'Position de vanne de ralenti en boucle fermée plus correction d\'allumage optionnelle pour tenir le ralenti cible.',
          'Off-throttle retard + fuel models exhaust heat, cracks and flame. OE requests native anti-lag when the fitted turbo supports it; actual spool/boost is vehicle-dependent. Hard on the engine.': 'Le retard + carburant sans gaz modélise chaleur, détonations et flammes. OE demande l’anti-lag natif quand le turbo monté le permet ; le boost réel dépend du véhicule. Dur pour le moteur.',
          'Intake cam advance targets the midrange for torque, tapering off at idle and the top end.': 'L\'avance de la came admission vise le mi-régime pour le couple, diminuant au ralenti et en haut.',
          'Pedal-to-target curve plus TPS/target safety monitoring. A tracking fault triggers a safety cut.': 'Courbe pédale-vers-cible plus surveillance de sécurité TPS/cible. Un défaut de suivi déclenche une coupe de sécurité.',
          'Crank/cam trigger signal quality and base-timing calibration. In a real ECU this is where you set base timing with a timing light so commanded advance matches the crank; noise or lost sync injects misfire risk and latches a Trigger fault.': 'Qualité du signal capteur vilebrequin/came et calibration de l\'avance de base. Sur une vraie ECU c\'est ici qu\'on règle l\'avance de base à la lampe stroboscopique pour que l\'avance commandée corresponde au vilebrequin ; le bruit ou la perte de sync injecte un risque de ratés et mémorise un défaut Capteur.',
          'CAN bus transmit setup (training/status only in this trainer).': 'Config. émission CAN (entraînement/état seul dans ce simulateur).',
          'Fan, fuel pump prime, shift light and check-engine output.': 'Ventilateur, amorçage pompe, témoin de passage et sortie voyant moteur.',
          'Rev limit (0 = stock), 2-step launch limit, flat-foot / no-lift shift, and drivetrain loss (wheel power is MEASURED now - the slider is your crank-estimate factor).': 'Limite de régime (0 = origine), départ 2-step, passage à fond sans lever, et pertes de transmission (la puissance roue est désormais MESURÉE - le curseur est votre facteur d\'estimation vilebrequin).',
          'Master enable, engine protection (knock/overboost cut), and knock control. Turning protection off lets you blow the engine.': 'Activation maître, protection moteur (coupe cliquetis/surpression) et contrôle du cliquetis. Désactiver la protection vous laisse faire exploser le moteur.',
          'Yellow = investigate, red = lift. The values a tuner watches during a pull.': 'Jaune = à vérifier, rouge = lever le pied. Les valeurs qu\'un tuner surveille pendant un passage.',
          'Full-throttle pull from low RPM to the limiter. Red = engine (crank) HP, yellow = torque (lb-ft). First pull saves a baseline. Runtime wheel HP is MEASURED through the drivetrain like a hub dyno - the loss slider on Limiters is just your crank-estimate factor.': 'Passage à fond des bas régimes au limiteur. Rouge = puissance moteur (vilebrequin), jaune = couple (lb-ft). Le premier passage enregistre une référence. La puissance roue est désormais MESURÉE à travers la transmission comme un banc à moyeu - le curseur de pertes dans Limiteurs n\'est que votre facteur d\'estimation vilebrequin.',
          'After a logged pull, cells show suggested Fuel Main corrections (% ) from lambda error. Red = add fuel, blue = pull fuel.': 'Après un passage enregistré, les cellules montrent des corrections suggérées de Carburant Principal (%) depuis l\'erreur lambda. Rouge = ajouter du carburant, bleu = retirer du carburant.',
          // V3.0.7: replaces the orphaned pre-2.8 key - see the ES table above.
          'Edit RPM columns and the load-row breakpoints. Alpha-N TPS must have exactly one value per table row.': 'Modifie les points de rupture des colonnes de régime et des lignes de charge. L\'axe TPS Alpha-N doit contenir exactement une valeur par ligne du tableau.',
          'values per axis.': 'valeurs par axe.',
          // V3.0.7 Table resolution control
          'Table resolution': 'Résolution des tables', 'Standard': 'Standard', 'Detailed': 'Détaillée',
          'Fine': 'Fine', 'CONFIRM': 'CONFIRMER', 'Now': 'Actuel', 'cells': 'cellules'
        },
        pt: {
          'Calibrates what the ECU sees. Bad MAP/TPS/IAT/ECT/lambda calibration makes a good tune behave like a bad one.': 'Calibra o que a ECU vê. Uma calibração ruim de MAP/TPS/IAT/ECT/lambda faz um bom tune se comportar como ruim.',
          'Master fuel and injector sizing. Modelled mode uses MAP for air demand; traditional uses throttle+load.': 'Combustível base e dimensão de injetores. O modo Modelado usa MAP para a demanda de ar; o Tradicional usa acelerador+carga.',
          'Closed-loop trims fuel toward the lambda target when conditions allow. Lockouts hold it open-loop under load.': 'A malha fechada ajusta o combustível em direção ao alvo lambda quando as condições permitem. Os bloqueios a mantêm em malha aberta sob carga.',
          'Per-cylinder fuel trim. A wide spread destabilises combustion and raises misfire risk.': 'Ajuste de combustível por cilindro. Uma dispersão grande desestabiliza a combustão e aumenta o risco de falhas.',
          'Cranking, post-start and warmup fuel. Too little = lean cold stumble; too much = rich fouling.': 'Combustível de partida, pós-partida e aquecimento. Pouco demais = tranco pobre a frio; muito = sujeira rica.',
          'Throttle tip-in and lift-off fuel. Bad values cause bog, lean hesitation, rich stumble or harsh decel cut.': 'Combustível ao acelerar e ao tirar o pé. Valores ruins causam afogamento, hesitação pobre, tranco rico ou corte de desaceleração brusco.',
          'Wastegate strategy (turbo only). The Boost Target table sets the target; these gains decide how it is chased.': 'Estratégia da wastegate (só turbo). A tabela Alvo de boost fixa o alvo; estes ganhos decidem como ele é perseguido.',
          'Knock detection retards timing to protect the engine. Too high a threshold lets real knock through.': 'A detecção de detonação atrasa o avanço para proteger o motor. Um limiar muito alto deixa passar detonação real.',
          'Closed-loop idle valve position plus optional ignition correction to hold the target idle.': 'Posição da válvula de marcha lenta em malha fechada mais correção de ignição opcional para manter a marcha lenta alvo.',
          'Off-throttle retard + fuel models exhaust heat, cracks and flame. OE requests native anti-lag when the fitted turbo supports it; actual spool/boost is vehicle-dependent. Hard on the engine.': 'O atraso + combustível sem acelerador modela calor, estouros e chama. OE solicita o anti-lag nativo quando o turbo instalado suporta; o giro/boost real depende do veículo. Pesado para o motor.',
          'Intake cam advance targets the midrange for torque, tapering off at idle and the top end.': 'O avanço do comando de admissão mira o médio para torque, diminuindo na marcha lenta e em cima.',
          'Pedal-to-target curve plus TPS/target safety monitoring. A tracking fault triggers a safety cut.': 'Curva pedal-para-alvo mais monitoramento de segurança TPS/alvo. Uma falha de rastreio dispara um corte de segurança.',
          'Crank/cam trigger signal quality and base-timing calibration. In a real ECU this is where you set base timing with a timing light so commanded advance matches the crank; noise or lost sync injects misfire risk and latches a Trigger fault.': 'Qualidade do sinal de disparo virabrequim/comando e calibração do avanço base. Numa ECU real é aqui que se ajusta o avanço base com lâmpada de ponto para que o avanço comandado bata com o virabrequim; ruído ou perda de sync injeta risco de falhas e retém um defeito de Disparo.',
          'CAN bus transmit setup (training/status only in this trainer).': 'Config. de transmissão CAN (treino/status só neste treinador).',
          'Fan, fuel pump prime, shift light and check-engine output.': 'Ventoinha, escorva da bomba, luz de troca e saída check-engine.',
          'Rev limit (0 = stock), 2-step launch limit, flat-foot / no-lift shift, and drivetrain loss (wheel power is MEASURED now - the slider is your crank-estimate factor).': 'Limite de RPM (0 = original), largada 2-step, troca sem tirar o pé, e perda de transmissão (a potência na roda agora é MEDIDA - o slider é seu fator de estimativa no virabrequim).',
          'Master enable, engine protection (knock/overboost cut), and knock control. Turning protection off lets you blow the engine.': 'Ativação mestre, proteção do motor (corte por detonação/sobreboost) e controle de detonação. Desligar a proteção deixa você explodir o motor.',
          'Yellow = investigate, red = lift. The values a tuner watches during a pull.': 'Amarelo = investigar, vermelho = levantar. Os valores que um tuner observa durante uma passada.',
          'Full-throttle pull from low RPM to the limiter. Red = engine (crank) HP, yellow = torque (lb-ft). First pull saves a baseline. Runtime wheel HP is MEASURED through the drivetrain like a hub dyno - the loss slider on Limiters is just your crank-estimate factor.': 'Passada a fundo das baixas RPM ao limitador. Vermelho = HP do motor (virabrequim), amarelo = torque (lb-ft). A primeira passada salva uma base. O HP na roda agora é MEDIDO através da transmissão como um dyno de cubo - o slider de perda em Limitadores é só seu fator de estimativa no virabrequim.',
          'After a logged pull, cells show suggested Fuel Main corrections (% ) from lambda error. Red = add fuel, blue = pull fuel.': 'Após uma passada gravada, as células mostram correções sugeridas de Combustível Principal (%) do erro de lambda. Vermelho = adicionar combustível, azul = tirar combustível.',
          // V3.0.7: replaces the orphaned pre-2.8 key - see the ES table above.
          'Edit RPM columns and the load-row breakpoints. Alpha-N TPS must have exactly one value per table row.': 'Edita os pontos das colunas de RPM e das linhas de carga. O eixo TPS Alpha-N deve ter exatamente um valor por linha da tabela.',
          'values per axis.': 'valores por eixo.',
          // V3.0.7 Table resolution control
          'Table resolution': 'Resolução das tabelas', 'Standard': 'Padrão', 'Detailed': 'Detalhada',
          'Fine': 'Fina', 'CONFIRM': 'CONFIRMAR', 'Now': 'Agora', 'cells': 'células'
        }
      };
      ['es', 'fr', 'pt'].forEach(function (L) { for (var k in LANG3[L]) { LANG[L][k] = LANG3[L][k]; } });
      // V1.5 strings: Build Card, hardware-needs-tuning, tune status, block-strength labels
      var LANG4 = {
        es: {
          'Build Card': 'Ficha del Build',
          'A shareable spec sheet for your build. Do a dyno pull to lock in VERIFIED power, then hit Snapshot Mode and screenshot it to flex on the crew.': 'Una ficha compartible de tu build. Haz una pasada de dyno para fijar la potencia VERIFICADA, luego pulsa Modo Captura y haz una captura para presumir ante el grupo.',
          'Snapshot Mode': 'Modo Captura', 'Refresh Parts': 'Refrescar piezas', 'Go to Dyno': 'Ir al Dyno', 'Exit Snapshot': 'Salir de Captura', 'Custom build': 'Build personalizado',
          'NO DATA - run a dyno pull': 'SIN DATOS - haz una pasada de dyno', 'DYNO VERIFIED': 'VERIFICADO EN DYNO', 'RATED - run a dyno to verify': 'NOMINAL - haz una pasada para verificar',
          'NO DATA - drive it hard or dyno it': 'SIN DATOS - condúcelo fuerte o haz dyno', 'DYNO VERIFIED - crank': 'VERIFICADO EN DYNO - cigüeñal',
          'ECU PEAK - logged while driving (dyno it for the curve + gains)': 'PICO ECU - registrado conduciendo (haz dyno para la curva y ganancias)', 'TUNED': 'TUNEADO',
          'Engine changed - restored your tune for THIS engine': 'Motor cambiado - restaurado tu tune para ESTE motor',
          'Engine changed - fresh base tune (your old tune is kept for the original engine)': 'Motor cambiado - tune base nuevo (tu tune anterior queda guardado para el motor original)',
          'Engine': 'Motor', 'Induction': 'Inducción', 'Block': 'Bloque', 'Tune status': 'Estado del tune', 'Engine build': 'Build del motor',
          'Parts list unavailable on this setup': 'Lista de piezas no disponible en este setup', 'Tuned on OE ProTune ECU': 'Ajustado con OE ProTune ECU', 'Rev limit': 'Límite de RPM',
          'Block strength': 'Fuerza del bloque', 'Strength basis': 'Base de fuerza',
          'Stock - nothing to tune': 'De serie - nada que ajustar', 'Needs tuning - off under load': 'Necesita ajuste - mal con carga', 'Dialled in': 'Ajustado', 'Untested - do a pull': 'Sin probar - haz una pasada',
          'Naturally Aspirated': 'Atmosférico', 'Turbocharged': 'Turbo', 'Supercharged': 'Sobrealimentado', 'psi peak': 'psi pico', 'Carburettor': 'Carburador', 'Diesel': 'Diésel',
          'Induction changed - MAP/boost tables reset for the new hardware': 'Inducción cambiada - tablas MAP/boost reiniciadas para el nuevo hardware',
          'a turbo (add fuel + pull timing in the boost cells)': 'un turbo (añade combustible + quita avance en las celdas de boost)',
          'a bigger fuel system (raise Injector flow on Fuel Setup to match the injectors)': 'un sistema de combustible más grande (sube Injector flow en Config. combustible para igualar los inyectores)',
          'a bigger motor (richen fuel across the band)': 'un motor más grande (enriquece el combustible en toda la banda)',
          'a cam (re-tune idle + midrange fuel)': 'una leva (reajusta el ralentí + combustible de medio régimen)',
          'ITBs (dial in idle and tip-in fuel)': 'ITBs (ajusta el ralentí y el combustible al pisar)'
        },
        fr: {
          'Build Card': 'Fiche du build',
          'A shareable spec sheet for your build. Do a dyno pull to lock in VERIFIED power, then hit Snapshot Mode and screenshot it to flex on the crew.': 'Une fiche partageable de votre build. Faites une passe au banc pour figer la puissance VÉRIFIÉE, puis appuyez sur Mode Capture et faites une capture pour frimer devant le groupe.',
          'Snapshot Mode': 'Mode Capture', 'Refresh Parts': 'Rafraîchir pièces', 'Go to Dyno': 'Aller au banc', 'Exit Snapshot': 'Quitter Capture', 'Custom build': 'Build personnalisé',
          'NO DATA - run a dyno pull': 'AUCUNE DONNÉE - faites une passe au banc', 'DYNO VERIFIED': 'VÉRIFIÉ AU BANC', 'RATED - run a dyno to verify': 'NOMINAL - faites une passe pour vérifier',
          'NO DATA - drive it hard or dyno it': 'AUCUNE DONNÉE - roulez fort ou passez au banc', 'DYNO VERIFIED - crank': 'VÉRIFIÉ AU BANC - vilebrequin',
          'ECU PEAK - logged while driving (dyno it for the curve + gains)': 'PIC ECU - enregistré en roulant (banc pour la courbe et les gains)', 'TUNED': 'PRÉPARÉ',
          'Engine changed - restored your tune for THIS engine': 'Moteur changé - votre tune pour CE moteur est restauré',
          'Engine changed - fresh base tune (your old tune is kept for the original engine)': 'Moteur changé - tune de base neuf (votre ancien tune est conservé pour le moteur d\'origine)',
          'Engine': 'Moteur', 'Induction': 'Admission', 'Block': 'Bloc', 'Tune status': 'État du tune', 'Engine build': 'Build moteur',
          'Parts list unavailable on this setup': 'Liste des pièces indisponible sur cette config', 'Tuned on OE ProTune ECU': 'Réglé sur OE ProTune ECU', 'Rev limit': 'Limiteur de régime',
          'Block strength': 'Force du bloc', 'Strength basis': 'Base de force',
          'Stock - nothing to tune': 'Série - rien à régler', 'Needs tuning - off under load': 'À régler - mauvais en charge', 'Dialled in': 'Réglé', 'Untested - do a pull': 'Non testé - faites une passe',
          'Naturally Aspirated': 'Atmosphérique', 'Turbocharged': 'Turbo', 'Supercharged': 'Compresseur', 'psi peak': 'psi crête', 'Carburettor': 'Carburateur',
          'Induction changed - MAP/boost tables reset for the new hardware': 'Admission changée - tables MAP/boost réinitialisées pour le nouveau matériel',
          'a turbo (add fuel + pull timing in the boost cells)': 'un turbo (ajoutez du carburant + retirez de l\'avance dans les cellules de boost)',
          'a bigger fuel system (raise Injector flow on Fuel Setup to match the injectors)': 'un plus gros système de carburant (montez Injector flow dans Config. carburant pour correspondre aux injecteurs)',
          'a bigger motor (richen fuel across the band)': 'un plus gros moteur (enrichissez le carburant sur toute la plage)',
          'a cam (re-tune idle + midrange fuel)': 'un arbre à cames (réglez le ralenti + le carburant à mi-régime)',
          'ITBs (dial in idle and tip-in fuel)': 'des ITB (réglez le ralenti et le carburant à l\'accélération)'
        },
        pt: {
          'Build Card': 'Ficha do build',
          'A shareable spec sheet for your build. Do a dyno pull to lock in VERIFIED power, then hit Snapshot Mode and screenshot it to flex on the crew.': 'Uma ficha compartilhável do seu build. Faça uma passada de dyno para fixar a potência VERIFICADA, depois toque em Modo Captura e tire um print para exibir pra galera.',
          'Snapshot Mode': 'Modo Captura', 'Refresh Parts': 'Atualizar peças', 'Go to Dyno': 'Ir ao Dyno', 'Exit Snapshot': 'Sair da Captura', 'Custom build': 'Build personalizado',
          'NO DATA - run a dyno pull': 'SEM DADOS - faça uma passada de dyno', 'DYNO VERIFIED': 'VERIFICADO NO DYNO', 'RATED - run a dyno to verify': 'NOMINAL - faça uma passada para verificar',
          'NO DATA - drive it hard or dyno it': 'SEM DADOS - dirija forte ou faça dyno', 'DYNO VERIFIED - crank': 'VERIFICADO NO DYNO - virabrequim',
          'ECU PEAK - logged while driving (dyno it for the curve + gains)': 'PICO ECU - registrado dirigindo (faça dyno para a curva e ganhos)', 'TUNED': 'TUNADO',
          'Engine changed - restored your tune for THIS engine': 'Motor trocado - restaurado seu tune para ESTE motor',
          'Engine changed - fresh base tune (your old tune is kept for the original engine)': 'Motor trocado - tune base novo (seu tune antigo fica guardado para o motor original)',
          'Engine': 'Motor', 'Induction': 'Indução', 'Block': 'Bloco', 'Tune status': 'Status do tune', 'Engine build': 'Build do motor',
          'Parts list unavailable on this setup': 'Lista de peças indisponível neste setup', 'Tuned on OE ProTune ECU': 'Ajustado no OE ProTune ECU', 'Rev limit': 'Limite de RPM',
          'Block strength': 'Força do bloco', 'Strength basis': 'Base de força',
          'Stock - nothing to tune': 'De fábrica - nada a ajustar', 'Needs tuning - off under load': 'Precisa de ajuste - ruim sob carga', 'Dialled in': 'Ajustado', 'Untested - do a pull': 'Sem teste - faça uma passada',
          'Naturally Aspirated': 'Aspirado', 'Turbocharged': 'Turbo', 'Supercharged': 'Supercharger', 'psi peak': 'psi pico', 'Carburettor': 'Carburador',
          'Induction changed - MAP/boost tables reset for the new hardware': 'Indução mudou - tabelas MAP/boost reiniciadas para o novo hardware',
          'a turbo (add fuel + pull timing in the boost cells)': 'um turbo (adicione combustível + tire avanço nas células de boost)',
          'a bigger fuel system (raise Injector flow on Fuel Setup to match the injectors)': 'um sistema de combustível maior (aumente Injector flow em Config. combustível para casar com os bicos)',
          'a bigger motor (richen fuel across the band)': 'um motor maior (enriqueça o combustível em toda a faixa)',
          'a cam (re-tune idle + midrange fuel)': 'um comando (reajuste a marcha lenta + combustível de médio giro)',
          'ITBs (dial in idle and tip-in fuel)': 'ITBs (ajuste a marcha lenta e o combustível ao acelerar)'
        }
      };
      ['es', 'fr', 'pt'].forEach(function (L) { for (var k in LANG4[L]) { LANG[L][k] = LANG4[L][k]; } });
      // Stage 6: Help Me page intro + the "Take me there" button
      var DIAG_INTRO_EN = 'Live read of your current tune. Drive / load the engine so it can see what\'s happening, then fix the red items first. Click "Take me there" to jump to the right page.';
      LANG.es[DIAG_INTRO_EN] = 'Lectura en vivo de tu tune actual. Conduce / carga el motor para que vea lo que pasa, luego arregla primero los elementos en rojo. Haz clic en "Llévame ahí" para saltar a la página correcta.';
      LANG.fr[DIAG_INTRO_EN] = 'Lecture en direct de votre tune actuel. Conduisez / chargez le moteur pour qu\'il voie ce qui se passe, puis corrigez d\'abord les éléments en rouge. Cliquez sur "M\'y emmener" pour aller à la bonne page.';
      LANG.pt[DIAG_INTRO_EN] = 'Leitura ao vivo do seu tune atual. Dirija / carregue o motor para que ele veja o que acontece, depois corrija primeiro os itens em vermelho. Clique em "Leve-me lá" para ir à página certa.';
      LANG.es['Take me there'] = 'Llévame ahí'; LANG.fr['Take me there'] = 'M\'y emmener'; LANG.pt['Take me there'] = 'Leve-me lá';
      scope.diagIntro = function () { return scope.tr(DIAG_INTRO_EN); };
      scope.activePage = 'runtime';
      scope.navSel = 'runtime';
      scope.selectedMap = 'fuel';
      // V3.0.9: restored from storage, not defaulted to false - see the applyCollapsed block below.
      try { scope.uiCollapsed = localStorage.getItem('oep_collapsed') === '1'; } catch (e) { scope.uiCollapsed = false; }
      scope.storeRequired = false;
      scope.toast = '';
      scope.surfaceOn = true;
      scope.showDelta = false;

      var lastTelemetryAt = 0;
      var toastTimer = null;
      scope.gamePaused = false;    // game pause stops vehicle Lua: no telemetry, and our commands don't run
      var pausedEdits = false;     // did the user edit anything WHILE paused? re-pushed on unpause
      // V3.0.9 COLLAPSE SURVIVES A RE-MOUNT. This used to read `scope.uiCollapsed = false` with the note
      // "collapse is a per-session convenience, not persisted" - but the session is far shorter than it
      // looks. BeamNG's app overlay is behind a v-if on `visible`
      // (ui-vue/src/modules/apps/views/UiAppsOverlay.vue:3, computed in appLayoutsStore.js:33 from the
      // ShowApps / UiAppsRouteUpdate events), and hiding it unmounts every app: AngularAppSlot's
      // onBeforeUnmount calls unmountDirective, which does scope.$destroy()
      // (ui/modules/apps/app-service.js:289). Opening the vehicle parts menu is one of those hides, so
      // installing a bumper tore down this directive and rebuilt it with a virgin scope - the app
      // "reopened itself". Nothing in the mod was reopening it; there was simply nothing left to
      // remember it was shut. Persisting is the only fix that survives a scope teardown.
      function applyCollapsed() {
        // Same imperative path toggleCollapse uses. Deliberately NOT an ng-class binding: the class is
        // applied synchronously here and in the toggle, and the header comment in app.html records that
        // an earlier ng-class / ng-show / ng-if mix used to desync across repeated hides.
        var root = element[0];
        if (root.classList && !root.classList.contains('oep-root')) {
          root = root.querySelector('.oep-root') || root;
        }
        if (root && root.classList) { root.classList.toggle('oep-collapsed', scope.uiCollapsed); }
      }
      applyCollapsed();

      function setToast(msg) {
        scope.toast = msg;
        if (toastTimer) { clearTimeout(toastTimer); }
        toastTimer = setTimeout(function () { scope.$applyAsync(function () { scope.toast = ''; }); }, 4000);
      }

      // ====================================================================
      // vehicle bridge
      // ====================================================================
      var pendingSettingEcho = {};
      var pendingMapEcho = {};
      var localCalibrationMutationGeneration = 0;
      var mirrorSendTimers = {};   // per-key setSetting coalescing on the browser mirror (see send())
      function scalarEchoMatches(actual, expected) {
        if (actual === undefined || actual === null || expected === undefined || expected === null) {
          return (actual === undefined || actual === null) && (expected === undefined || expected === null);
        }
        if (typeof actual === 'boolean') { return actual === (expected === true || expected === 1); }
        var a = Number(actual), b = Number(expected);
        return isFinite(a) && isFinite(b) && Math.abs(a - b) < 0.00001;
      }
      function rowEchoMatches(actual, expected) {
        if (!actual || !expected || actual.length !== expected.length) { return false; }
        for (var i = 0; i < actual.length; i++) { if (!scalarEchoMatches(actual[i], expected[i])) { return false; } }
        return true;
      }
      function mergeSettingsEcho(src, authoritative) {
        src = src || {};
        var out = angular.copy(src), now = Date.now(), current = scope.settings || {};
        if (authoritative) { pendingSettingEcho = {}; }
        else if (!IS_MIRROR) {
          Object.keys(pendingSettingEcho).forEach(function (key) {
            var p = pendingSettingEcho[key];
            if (scalarEchoMatches(src[key], p.value)) { delete pendingSettingEcho[key]; }
            else if (scope.gamePaused || now - p.at < 3000) { out[key] = current[key]; }
            else { delete pendingSettingEcho[key]; }
          });
        }
        scope.settings = out;
        normalizeSettings();
        seedMixSettingsMirror();   // V3: track the values the ECU is known to run so an unchanged unpause/reconnect re-push is a no-op
      }
      function mergeMapsEcho(src, authoritative) {
        if (!src) { return; }
        var out = angular.copy(src), now = Date.now(), current = scope.maps || {};
        if (authoritative) { pendingMapEcho = {}; }
        else if (!IS_MIRROR) {
          Object.keys(pendingMapEcho).forEach(function (key) {
            var p = pendingMapEcho[key], actual = src[p.map] && src[p.map][p.row];
            if (rowEchoMatches(actual, p.value)) { delete pendingMapEcho[key]; }
            else if (scope.gamePaused || now - p.at < 3000) {
              // Hold ONLY while the grid shape still matches: an axis refit/reset inside the 3s
              // window must repaint immediately - merging a stale-width row into the new grid
              // renders misaligned cells until the entry expires.
              var incoming = out[p.map] && out[p.map][p.row], held = current[p.map] && current[p.map][p.row];
              if (incoming && held && incoming.length === held.length) { out[p.map][p.row] = held.slice(); }
              else { delete pendingMapEcho[key]; }
            } else { delete pendingMapEcho[key]; }
          });
        }
        scope.maps = out;
      }
      // Temporary lessons can override the normal painted-ECU route while rolling an old vehicle
      // back. Outside that narrow case every mutation is routed to linkedVehId and fingerprinted
      // below, so BeamNG's active-object change cannot redirect stale UI state into another car.
      var controllerVehicleGuardId = null;
      var controllerVehicleRouteId = null;
      var controllerIdentityGuard = null;
      // V2.9.x: dropped commands used to be TOTALLY silent - the #1 "dyno/controls do nothing"
      // field mystery (3 independent reports). Rate-limited so a slider drag can't spam toasts.
      var lastDropToastAt = 0;
      function toastDroppedCommand(fnName) {
        var now = Date.now();
        if (now - lastDropToastAt < 2500) { return; }
        lastDropToastAt = now;
        setToast(scope.versionSkew
          ? 'Command "' + fnName + '" NOT sent: the vehicle controller is older than this app. Close BeamNG completely and relaunch.'
          : 'Not linked to this car\'s ECU yet, so "' + fnName + '" was not sent. Wait a second and try again.');
      }
      function callController(fnCall) {
        // V2.9 fix (version skew): an older cached controller does not export the newer functions
        // (resetMap/factoryReset). Calling a nil field raised a vehicle-Lua error; guard the call so
        // a missing function is a clean no-op instead - the same tolerance the controller extends to
        // older cached UIs (see its setFxAvailable stub).
        var fnName = String(fnCall || '').split('(')[0];
        var accepted;
        if (IS_MIRROR) {
          // remote.html parses this fixed wrapper into the v2 method/args protocol. It
          // rejects anything outside its literal schemas; raw Lua never crosses WS.
          // The identity/lesson-guarded wrappers below must never run here: their outer
          // 'if obj ... end end' shape would break the shim's "then c." ... " end" slice,
          // and lessons (the only writers of those guards) are gated off on the mirror.
          accepted = bngApi.activeObjectLua(
            'local c = controller.getController("oeProTune") or rawget(_G, "oeProTuneExt") if c and type(c.' + fnName + ') == "function" then c.' + fnCall + ' end'
          );
        } else {
          var identityCheck = '', mutationIdentity = controllerIdentityGuard || {
            carName: scope.carName,
            engineFp: (scope.base && scope.base.engineFp) || '',
            hasTurbo: !!(scope.base && scope.base.hasTurbo)
          };
          if (fnName !== 'requestState') {
            identityCheck = ' and type(c.matchesIdentity) == "function" and c.matchesIdentity(' +
              luaQuote(mutationIdentity.carName) + ', ' + luaQuote(mutationIdentity.engineFp || '') + ', ' +
              (mutationIdentity.hasTurbo ? 'true' : 'false') + ')';
          }
          var luaCall = 'local c = controller.getController("oeProTune") or rawget(_G, "oeProTuneExt") if c' + identityCheck + ' and type(c.' + fnName + ') == "function" then c.' + fnCall + ' end';
          // requestState is read-only and must always reach the CURRENT active object; otherwise a
          // whole-session mutation guard could prevent the newly selected car from ever identifying
          // itself. Every other command belongs to the exact ECU currently painted by this UI.
          var mutationRouteId = (typeof controllerVehicleRouteId === 'number')
            ? controllerVehicleRouteId : scope.linkedVehId;
          if (fnName !== 'requestState' && typeof mutationRouteId === 'number' &&
              typeof bngApi !== 'undefined' && bngApi.engineLua) {
            var mutationGuardId = (typeof controllerVehicleGuardId === 'number')
              ? controllerVehicleGuardId : mutationRouteId;
            luaCall = 'if obj and obj:getID() == ' + Math.round(mutationGuardId) + ' then ' + luaCall + ' end';
            bngApi.engineLua('local v = be:getObjectByID(' + Math.round(mutationRouteId) + ') if v then v:queueLuaCommand(' + luaQuote(luaCall) + ') end');
          } else if (fnName !== 'requestState') {
            // No coherent ECU has identified itself yet. Failing closed is safer than letting
            // activeObjectLua deliver a stale slider/table/reset command to an arbitrary car -
            // but it must be LOUD, or the user just sees buttons that do nothing (V2.9.x fix).
            accepted = false;
            toastDroppedCommand(fnName);
          } else {
            accepted = bngApi.activeObjectLua(luaCall);
          }
        }
        // Commands that replace a calibration or its axes make any learned lambda
        // evidence stale immediately.  Keep this at the bridge so tutorial/direct
        // callers cannot accidentally bypass the invalidation done by the normal UI.
        // On the mirror only an ACCEPTED transport decision may invalidate; in-game the
        // call returns undefined, so !IS_MIRROR preserves the 2.9 truth table (always fire).
        // V3.0.7: setGridResolution rewrites every table AND every breakpoint - a strictly larger
        // mutation than setAxis, which was already listed. Without it, samples logged in the
        // round-trip window before the reshaped broadcast lands get attributed to old-grid bins.
        if (/^(?:applyPreset|resetMaps|resetMap|resetAxes|setAxis|setGridResolution|factoryReset)\(/.test(String(fnCall || '')) && (!IS_MIRROR || accepted === true)) {
          invalidateMixture('calibration changed');
        }
        return accepted;
      }
      function controllerMatchesUi() { return String(scope.ctrlVersion || '') === String(scope.appVersion); }
      function send(key, value) {
        var n = (value === true) ? 1 : (value === false ? 0 : Number(value));
        if (!isFinite(n)) { return; }
        if (!scope.applyingTune) { localCalibrationMutationGeneration += 1; }
        // V2.9 fix: pushAllSettings re-sends EVERY setting verbatim on unpause / reconnect - only a
        // value that actually CHANGED may throw away the learned Mixture Map evidence. The mirror is
        // seeded from every accepted controller broadcast and tune load, then tracks what we send.
        if (!scope.applyingTune && mixtureSettingInvalidates(key)) {
          var prev = mixSettingsMirror[key];
          if (prev === undefined || Math.abs(prev - n) > 1e-9) { invalidateMixture('fuel setting changed'); }
        }
        if (mixtureSettingInvalidates(key)) { mixSettingsMirror[key] = n; }
        if (key === 'fuelEquationMode' && n !== 2) { scope.mixDisplayMode = 'correction'; }
        if (!IS_MIRROR) { pendingSettingEcho[key] = { value: n, at: Date.now() }; }
        if (IS_MIRROR) {
          // Coalesce per key: the WS server rate-limits commands (20/s) and the ECU only needs the
          // final value - an undebounced drag emits one setSetting per pointer move (~60/s on fine
          // sliders), tripping the limiter. 90ms trailing edge keeps taps snappy and drags smooth.
          if (mirrorSendTimers[key]) { clearTimeout(mirrorSendTimers[key]); }
          mirrorSendTimers[key] = setTimeout(function () {
            delete mirrorSendTimers[key];
            callController('setSetting("' + key + '", ' + n + ')');
          }, 90);
        } else {
          callController('setSetting("' + key + '", ' + n + ')');
        }
        scope.storeRequired = true;
        if (scope.gamePaused) { pausedEdits = true; }   // vehicle Lua isn't running: re-push this on unpause
      }
      function sendMapRow(mapName, r, rowArr) {
        if (!scope.applyingTune) { localCalibrationMutationGeneration += 1; }
        if (mapName === 'boost' && scope.maps && scope.maps.boost && scope.maps.boost[r]) {
          var priorBoostRow = scope.maps.boost[r], boostChanged = false;
          for (var bc = 0; bc < rowArr.length; bc++) {
            if (Math.abs(Number(rowArr[bc]) - Number(priorBoostRow[bc])) > 0.5) { boostChanged = true; break; }
          }
          if (boostChanged && scope.mapBank) {
            // This is the live draft flag. A table edit must never rewrite the
            // associated saved MAP body until the user explicitly presses Save.
            scope.mapBank.workingBoostTableFactory = false;
          }
        }
        if (!IS_MIRROR) { pendingMapEcho[mapName + ':' + r] = { map: mapName, row: r, value: rowArr.slice(), at: Date.now() }; }
        var accepted = callController('setMapRow("' + mapName + '", ' + (r + 1) + ', "' + rowArr.join(',') + '")');
        // The browser shim returns a real transport decision. Never paint a
        // local map change that a disconnected/read-only mirror did not send.
        if (IS_MIRROR && accepted !== true) { return false; }
        scope.storeRequired = true;
        if (scope.gamePaused) { pausedEdits = true; }
        return true;
      }
      function pushAllMaps() {
        if (IS_MIRROR) { return; }   // never bulk-push a browser-local snapshot
        if (!scope.maps) { return; }
        ['fuel', 'ignition', 'boost', 'lambda', 've', 'wgduty'].forEach(function (name) {
          if (!scope.maps[name]) { return; }   // old snapshots without newer maps: controller keeps its defaults (matched VE / neutral duty)
          scope.maps[name].forEach(function (row, r) { sendMapRow(name, r, row); });
        });
      }
      function pushAllSettings() {
        if (IS_MIRROR) { return; }   // individual writer edits still use send()
        var s = scope.settings || {};
        Object.keys(s).forEach(function (k) {
          if (k === 'revLimit' && (!s[k] || s[k] <= 0)) { callController('setSetting("revLimit", 0)'); return; }
          send(k, s[k]);
        });
      }

      // ====================================================================
      // connection poll
      // ====================================================================
      // Track which vehicle the player is actually in. In BeamMP, with traffic, or with several cars
      // spawned, EVERY car's controller broadcasts OEProTuneState/Telemetry to this same app. Without
      // knowing the active car, the app hears a foreign one, thinks you switched vehicles, and drops you
      // back to Monitoring the instant you Connect. Fails open: until this id is known, nothing is filtered.
      function refreshActiveVeh() {
        if (typeof bngApi !== 'undefined' && bngApi.engineLua) {
          bngApi.engineLua('be:getPlayerVehicleID(0)', function (id) {
            if (typeof id === 'number' && id >= 0 && scope.activeVehId !== id) {
              if (activeRemoteTuneLoad && Number(activeRemoteTuneLoad.request.vehicleId) !== id) { finishRemoteTuneLoad(false, 'vehicle_changed'); }
              buildRefreshGeneration = (Number(buildRefreshGeneration) || 0) + 1;
              scope.activeVehId = id;
              if (!IS_MIRROR) {
                // A numeric State was deliberately held until GE identified the player vehicle.
                // Request it again immediately so safe startup linking does not wait for the next poll.
                callController('requestState()');
                setTimeout(function () {
                  publishTuneCatalog();
                  // State can beat this GE lookup on first link. If its first build scan was
                  // discarded while the active/link identity was incomplete, retry once coherent.
                  if (scope.connected && scope.linkedVehId === scope.activeVehId &&
                      scope.refreshBuild && scope.build && !scope.build.loaded) { scope.refreshBuild(); }
                }, 0);
              }
            }
          });
        }
      }
      refreshActiveVeh();
      var pollTimer = setInterval(function () {
        refreshActiveVeh();
        callController('requestState()');
        // PAUSE-AWARE (V2_3, Louis's bug): pausing the game stops vehicle Lua - no telemetry flows and
        // our edit commands don't execute. The watchdog used to read that silence as a disconnect, so
        // un-pausing triggered a fresh link that reloaded the autosave and REVERTED everything edited
        // while paused. Now: track the pause GE-side, keep the watchdog quiet while paused, and on the
        // un-pause edge re-PUSH whatever was edited so the controller catches up with what you see.
        if (typeof bngApi !== 'undefined' && bngApi.engineLua) {
          bngApi.engineLua('(simTimeAuthority and simTimeAuthority.getPause and simTimeAuthority.getPause()) and 1 or 0', function (p) {
            scope.$applyAsync(function () {
              var paused = (p === 1 || p === true || p === '1');
              var wasPaused = scope.gamePaused === true;
              if (scope.gamePaused && !paused) {           // un-pause edge
                lastTelemetryAt = Date.now();              // grace so the watchdog can't instantly fire
                if (pausedEdits && scope.connected) {
                  pausedEdits = false;
                  pushAllSettings();
                  pushAllMaps();
                  setToast('Un-paused - your paused edits were applied');
                }
              }
              scope.gamePaused = paused;
              if (activeRemoteTuneLoad && paused && !wasPaused) {
                activeRemoteTuneLoad.waitedForUnpause = true;
                armRemoteTuneLoadTimeout(activeRemoteTuneLoad, 250);
              } else if (activeRemoteTuneLoad && !paused && wasPaused) {
                activeRemoteTuneLoad.waitedForUnpause = false;
                armRemoteTuneLoadTimeout(activeRemoteTuneLoad, 6000);
              }
              if (paused) { lastTelemetryAt = Date.now(); }   // hold the watchdog while paused
            });
          });
        }
        if (Date.now() - lastTelemetryAt > 2500 && scope.connected && !scope.gamePaused) {
          scope.$applyAsync(function () {
            // remember if we were controlling so a part-change/reload reconnects us
            reconnectControl = scope.controlling;
            scope.connected = false;
          });
        }
        if (scope.activePage === 'remote_dash' || scope.remote.on) { refreshRemote(); }
      }, 1000);

      // ====================================================================
      // Remote Dash / Studio - live gauges and optional single-writer tuning on LAN.
      // The server lives GE-side (lua/ge/extensions/oeProTuneRemote.lua, the
      // game's own extApp pattern); loading the extension IS the on switch.
      // Read-only by design: the dash watches the ECU, it can never touch it.
      // ====================================================================
      scope.remote = { on: false, url: '', pcUrl: '', lanUrl: '', lanPort: 8237,
                       healthy: false, recoveryNeeded: false, lanConfigured: false, lanReachable: null, port: 0, clients: 0, ips: [], write: false,
                       lanUrls: [], lanAddressCount: 0, lanUrlIndex: 0,
                       setupToolsReady: false, setupCode: '', setupMessage: '', setupCheckedAtUtc: '',
                       protocolVersion: 1, writerConnected: false,
                       writerLabel: '', dangerous: false, revision: 0 };
      // Defence in depth on whatever the game-side server hands us: it must be plain HTTP, on the
      // relay port, and a private-class IPv4. RFC1918 is the normal case; the wider branch matches
      // the server's own rule, which admits a campus/office LAN address ONLY when the elevated
      // setup helper proved that adapter sits on a Windows "Private" profile. Ranges that can never
      // be a LAN peer (loopback, APIPA, multicast, and CGNAT 100.64/10 = Tailscale et al) stay out.
      function acceptableLanUrl(url) {
        var m = /^http:\/\/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3}):8237\/?$/.exec(String(url || ''));
        if (!m) { return false; }
        var a = Number(m[1]), b = Number(m[2]), c = Number(m[3]), d = Number(m[4]);
        if (a > 255 || b > 255 || c > 255 || d > 255) { return false; }
        if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)) { return true; }
        if (a === 0 || a === 127 || a >= 224) { return false; }
        if (a === 169 && b === 254) { return false; }
        if (a === 100 && b >= 64 && b <= 127) { return false; }
        return true;
      }
      // Remote editing is session-only, so deliberately forget the old persisted grant.
      try { localStorage.removeItem('oep_remote_write'); } catch (e) {}
      // This in-game switch is the player's consent. The first Full App / Pro Studio
      // browser claims the server's one writer lease automatically; gauges stay read-only.
      scope.toggleRemoteWrite = function () {
        if (IS_MIRROR || typeof bngApi === 'undefined' || !bngApi.engineLua) { setToast('Only available on the game PC'); return; }
        var want = !scope.remote.write;
        scope.remote.write = want;
        bngApi.engineLua('if oeProTuneRemote and oeProTuneRemote.setWriteEnabled then oeProTuneRemote.setWriteEnabled(' + (want ? 'true' : 'false') + ') end');
        setToast(want ? 'Pro Studio editing allowed - first tuner becomes writer' : 'Remote editing locked and writer revoked');
      };
      scope.toggleRemoteDangerous = function () {
        if (IS_MIRROR || typeof bngApi === 'undefined' || !bngApi.engineLua) { setToast('Only available on the game PC'); return; }
        var want = !scope.remote.dangerous;
        // V3.0.9: the server refuses to set this unless a browser ALREADY holds the writer lease
        // (oeProTuneRemote.lua guards it with `and auth.writer ~= nil`). The old code painted the flag
        // on optimistically and told the user it was allowed; the 1s status poll then read the real
        // value back and silently flipped it off again. From the game PC that looks like a toggle that
        // will not stay on, and from the laptop it looks like the dyno button is simply dead.
        if (want && !scope.remote.writerConnected) {
          setToast('Open Pro Studio in the browser first - this permission only takes effect once a browser holds the writer lease');
          return;
        }
        scope.remote.dangerous = want;
        bngApi.engineLua('if oeProTuneRemote and oeProTuneRemote.setDangerousEnabled then oeProTuneRemote.setDangerousEnabled(' + (want ? 'true' : 'false') + ') end');
        setToast(want ? 'Dangerous remote commands allowed for this session' : 'Dangerous remote commands blocked');
      };
      scope.revokeRemoteWriter = function () {
        if (IS_MIRROR || typeof bngApi === 'undefined' || !bngApi.engineLua) { return; }
        bngApi.engineLua('if oeProTuneRemote and oeProTuneRemote.revokeWriter then oeProTuneRemote.revokeWriter() end');
        setToast('Writer lease reset - the first connected Studio reclaims it. Use the editing toggle to lock browsers out.');
        setTimeout(refreshRemote, 80);
      };
      function refreshRemote() {
        if (typeof bngApi === 'undefined' || !bngApi.engineLua) { return; }
        bngApi.engineLua('(oeProTuneRemote and oeProTuneRemote.getStatus and oeProTuneRemote.getStatus()) or false', function (st) {
          scope.$applyAsync(function () {
            if (st && (st.loaded || st.running)) {
              var previousLanUrl = scope.remote.lanUrl;
              scope.remote.on = true;
              scope.remote.healthy = st.running === true && st.healthy !== false;
              scope.remote.recoveryNeeded = !!st.recoveryNeeded || !scope.remote.healthy;
              // PC access is loopback (never needs the relay); the LAN URL is the phone address.
              scope.remote.pcUrl = st.pcUrl || ('http://127.0.0.1:' + (st.port || 8236));
              scope.remote.url = scope.remote.pcUrl;   // Copy URL / "open this address" = the PC one
              var nextLanUrls = [], rawLanUrls = st.lanUrls;
              if (rawLanUrls && typeof rawLanUrls.length === 'number') {
                for (var li = 0; li < rawLanUrls.length; li++) {
                  var candidateUrl = String(rawLanUrls[li] || '');
                  if (acceptableLanUrl(candidateUrl)) { nextLanUrls.push(candidateUrl.replace(/\/$/, '')); }
                }
              }
              if (!nextLanUrls.length && st.lanUrl) {
                var oneLanUrl = String(st.lanUrl);
                if (acceptableLanUrl(oneLanUrl)) { nextLanUrls.push(oneLanUrl.replace(/\/$/, '')); }
              }
              scope.remote.lanUrls = nextLanUrls;
              scope.remote.lanAddressCount = nextLanUrls.length;
              // Keep the user's chosen alternate ONLY while it is still on offer. Pinning by value
              // used to survive the address going away, so after a DHCP change the page kept
              // showing the old dead address instead of the one the server had just re-detected.
              var keepIndex = nextLanUrls.indexOf(previousLanUrl);
              scope.remote.lanUrlIndex = keepIndex >= 0 ? keepIndex : 0;
              scope.remote.lanUrl = nextLanUrls[scope.remote.lanUrlIndex] || '';
              scope.remote.lanPort = st.lanPort || 8237;
              scope.remote.port = st.port || 0;
              scope.remote.clients = st.clients || 0;
              scope.remote.lanConfigured = !!st.lanConfigured;
              scope.remote.lanReachable = (typeof st.lanReachable === 'boolean') ? st.lanReachable : null;
              scope.remote.setupToolsReady = st.setupToolsReady === true;
              scope.remote.setupCode = String(st.setupCode || '');
              scope.remote.setupMessage = String(st.setupMessage || '');
              scope.remote.setupCheckedAtUtc = String(st.setupCheckedAtUtc || '');
              scope.remote.write = scope.remote.healthy && !!st.writeEnabled;
              scope.remote.protocolVersion = Number(st.protocolVersion) || 1;
              scope.remote.writerConnected = !!st.writerConnected;
              scope.remote.writerLabel = String(st.writerLabel || '');
              scope.remote.dangerous = !!st.dangerousEnabled;
              scope.remote.revision = Number(st.revision) || 0;
            } else {
              scope.remote.on = false;
              scope.remote.healthy = false;
              scope.remote.recoveryNeeded = false;
              scope.remote.clients = 0;
              scope.remote.lanReachable = null;
              scope.remote.lanUrls = [];
              scope.remote.lanAddressCount = 0;
              scope.remote.lanUrlIndex = 0;
              scope.remote.setupCode = '';
              scope.remote.setupMessage = '';
              scope.remote.setupToolsReady = false;
              scope.remote.write = false;
              scope.remote.writerConnected = false;
              scope.remote.dangerous = false;
            }
            setTimeout(drawRemoteQr, 60);   // canvas exists once ng-if has rendered; cached per URL so this is cheap
          });
        });
      }
      scope.toggleRemote = function () {
        if (IS_MIRROR || typeof bngApi === 'undefined' || !bngApi.engineLua) { setToast('Only available on the game PC'); return; }
        if (scope.remote.on) {
          bngApi.engineLua('extensions.unload("oeProTuneRemote")');
          scope.remote.on = false; scope.remote.healthy = false; scope.remote.recoveryNeeded = false; scope.remote.clients = 0;
          scope.hideRemoteAddr = true;
          if (remoteAddrHideTimer) { clearTimeout(remoteAddrHideTimer); remoteAddrHideTimer = null; }
          try { localStorage.setItem('oep_remote', '0'); } catch (e) {}
        } else {
          bngApi.engineLua('extensions.load("oeProTuneRemote")');
          try { localStorage.setItem('oep_remote', '1'); } catch (e) {}
          setTimeout(function () { refreshRemote(); publishTuneCatalog(); }, 500);   // server creation probes each network adapter; give it a beat
        }
      };
      // BeamNG sandboxes game networking to this PC (loopback only), so LAN access needs a
      // one-time Windows port-forward - the server writes the setup tool into the settings
      // folder and this opens it so the user can double-click the auto-elevating launcher
      scope.openRemoteSetup = function () {
        if (IS_MIRROR || typeof bngApi === 'undefined' || !bngApi.engineLua) { setToast('Only available on the game PC'); return; }
        bngApi.engineLua('pcall(function() if not FS:directoryExists("/settings/oeProTune/") then FS:directoryCreate("/settings/oeProTune/", true) end Engine.Platform.exploreFolder("/settings/oeProTune/") end)');
      };
      function copyPlainText(value) {
        try {
          var ta = document.createElement('textarea');
          ta.value = String(value || '');
          ta.setAttribute('readonly', 'readonly');
          ta.style.position = 'fixed';
          ta.style.left = '-9999px';
          document.body.appendChild(ta);
          ta.select();
          var copied = document.execCommand('copy');
          document.body.removeChild(ta);
          return copied !== false;
        } catch (e) { return false; }
      }
      scope.copyRemoteUrl = function (which) {
        if (IS_MIRROR) { setToast('Only available on the game PC'); return; }
        var lan = which === 'phone';
        var val = lan ? scope.remote.lanUrl : scope.remote.pcUrl;
        if (!val) { setToast('Nothing to copy yet'); return; }
        if (copyPlainText(val)) {
          setToast((lan ? 'Phone / laptop' : 'PC') + ' Basic Dashboard URL copied');
        } else { setToast('Copy failed - reveal and type it by hand'); }
      };
      scope.cycleRemoteLanUrl = function () {
        if (IS_MIRROR) { setToast('Only available on the game PC'); return; }
        if (!scope.remote.lanUrls || scope.remote.lanUrls.length < 2) { return; }
        scope.remote.lanUrlIndex = (scope.remote.lanUrlIndex + 1) % scope.remote.lanUrls.length;
        scope.remote.lanUrl = scope.remote.lanUrls[scope.remote.lanUrlIndex];
        setToast('Alternate private-network address selected');
        setTimeout(drawRemoteQr, 0);
      };
      // Safe for Discord/support and safe to show on stream: no IP, URL, adapter name, filesystem
      // path, user name, WiFi name or pairing credential is included.
      scope.copyRemoteDiagnostics = function () {
        if (IS_MIRROR) { setToast('Only available on the game PC'); return; }
        function yn(v) { return v ? 'YES' : 'NO'; }
        function verdict(v) { return v === true ? 'PASS' : (v === false ? 'FAIL' : 'UNKNOWN'); }
        var lines = [
          'OE ProTune Remote Dashboard diagnostics (privacy-safe)',
          'Version: ' + String(scope.appVersion || '3.0.2'),
          'Server loaded: ' + yn(scope.remote.on),
          'Local server: ' + (scope.remote.healthy ? 'PASS' : 'FAIL'),
          'Setup files: ' + (scope.remote.setupToolsReady ? 'READY' : 'MISSING'),
          'LAN setup: ' + (scope.remote.lanConfigured ? 'VERIFIED' : 'NOT VERIFIED'),
          'Setup result: ' + (scope.remote.setupCode || 'none'),
          'Relay self-test: ' + verdict(scope.remote.lanReachable),
          'Private address candidates: ' + Number(scope.remote.lanAddressCount || 0),
          'Connected screens: ' + Number(scope.remote.clients || 0),
          'Setup last verified (UTC): ' + (scope.remote.setupCheckedAtUtc || 'never'),
          'Privacy: IP addresses, URLs, adapter names, WiFi names and paths REDACTED'
        ];
        if (copyPlainText(lines.join('\r\n'))) {
          setToast('Privacy-safe Remote Dashboard diagnostics copied');
        } else {
          setToast('Could not copy diagnostics');
        }
      };
      // Streamer privacy (community request): the LAN line and the QR encode this PC's PRIVATE
      // network address (10.x / 192.168.x). That is NOT a public/internet IP - a stream viewer
      // can't reach it or locate anyone with it - but showing your internal address on a livestream
      // is still worth avoiding, so hide it by DEFAULT and reveal only on demand. Copy URL and Open
      // in Browser keep working while hidden (they read the real value), so most users never need to
      // reveal the raw address at all. In-memory only: every app load starts hidden = safe.
      scope.hideRemoteAddr = true;
      var remoteAddrHideTimer = null;
      scope.shownUrl = function (url) {
        if (!url) { return ''; }
        if (!scope.hideRemoteAddr) { return url; }
        return url.replace(/\/\/[^/]+/, '//••••••••');   // mask host:port, keep the scheme
      };
      scope.toggleRemoteAddr = function () {
        scope.hideRemoteAddr = !scope.hideRemoteAddr;
        if (remoteAddrHideTimer) { clearTimeout(remoteAddrHideTimer); remoteAddrHideTimer = null; }
        if (!scope.hideRemoteAddr) {
          remoteAddrHideTimer = setTimeout(function () {
            remoteAddrHideTimer = null;
            scope.$applyAsync(function () {
              scope.hideRemoteAddr = true;
              setTimeout(drawRemoteQr, 0);
            });
          }, 60000);
        }
        setTimeout(drawRemoteQr, 0);   // canvas isn't an Angular binding - repaint it on the flip
      };
      // Open the local dashboard in the system default browser (no copy/paste). DELEGATED to the GE
      // extension (oeProTuneRemote.openDashInBrowser): the earlier UI-side openWebBrowser call did not
      // fire in the field, so the extension writes a Windows .url shortcut and opens it with the shell's
      // default handler - the reliable path, using the same FS/Platform primitives as the relay setup.
      // Always ends usefully: if nothing can open a browser, it copies the PC URL instead.
      scope.openDashInBrowser = function () {
        if (IS_MIRROR || typeof bngApi === 'undefined' || !bngApi.engineLua) { setToast('Only available on the game PC'); return; }
        setToast('Opening the dashboard launcher...');
        bngApi.engineLua('(oeProTuneRemote and oeProTuneRemote.openDashInBrowser and oeProTuneRemote.openDashInBrowser()) or "noext"', function (res) {
          scope.$applyAsync(function () {
            if (res === 'folder') {
              setToast('A folder opened — double-click "OpenDashboard" in it to open the dashboard in your browser.');
            } else {
              scope.copyRemoteUrl('pc');
              setToast('Could not open the launcher here — PC URL copied instead, paste it into any browser.');
            }
          });
        });
      };
      // ---- QR code for the dash URL (phones scan instead of typing an IP) ----
      // Tiny self-contained QR encoder: byte mode, ECC level M, versions 1-3
      // (up to 42 chars - any http://IP:PORT fits), fixed mask 0. No internet in
      // the game's UI, so this is generated right here on a canvas.
      function qrMatrix(text) {
        // GF(256) tables, polynomial 0x11D
        var EXP = new Array(512), LOG = new Array(256), x = 1, i, j, r, c;
        for (i = 0; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 0x100) { x ^= 0x11D; } }
        for (i = 255; i < 512; i++) { EXP[i] = EXP[i - 255]; }
        function gmul(a, b) { if (!a || !b) { return 0; } return EXP[LOG[a] + LOG[b]]; }
        var VER = { 1: [16, 10], 2: [28, 16], 3: [44, 26] };   // [data codewords, ecc codewords] at level M
        var bytes = [];
        for (i = 0; i < text.length; i++) { bytes.push(text.charCodeAt(i) & 0xFF); }
        var v = 0;
        for (i = 1; i <= 3; i++) { if (bytes.length <= VER[i][0] - 2) { v = i; break; } }
        if (!v) { return null; }
        var dataCw = VER[v][0], eccCw = VER[v][1], size = 17 + 4 * v;
        // bit stream: mode 0100, 8-bit count, data, terminator, pad bytes
        var bits = [];
        function push(val, n) { for (var k = n - 1; k >= 0; k--) { bits.push((val >>> k) & 1); } }
        push(4, 4); push(bytes.length, 8);
        for (i = 0; i < bytes.length; i++) { push(bytes[i], 8); }
        for (i = 0; i < 4 && bits.length < dataCw * 8; i++) { bits.push(0); }
        while (bits.length % 8) { bits.push(0); }
        var data = [];
        for (i = 0; i < bits.length; i += 8) { var b = 0; for (j = 0; j < 8; j++) { b = (b << 1) | bits[i + j]; } data.push(b); }
        var padFlip = true;
        while (data.length < dataCw) { data.push(padFlip ? 0xEC : 0x11); padFlip = !padFlip; }
        // Reed-Solomon ECC (Nayuki's divisor/remainder formulation)
        var gen = [];
        for (i = 0; i < eccCw - 1; i++) { gen.push(0); }
        gen.push(1);
        var root = 1;
        for (i = 0; i < eccCw; i++) {
          for (j = 0; j < gen.length; j++) {
            gen[j] = gmul(gen[j], root);
            if (j + 1 < gen.length) { gen[j] ^= gen[j + 1]; }
          }
          root = gmul(root, 2);
        }
        var ecc = [];
        for (i = 0; i < eccCw; i++) { ecc.push(0); }
        for (i = 0; i < data.length; i++) {
          var factor = data[i] ^ ecc.shift();
          ecc.push(0);
          for (j = 0; j < gen.length; j++) { ecc[j] ^= gmul(gen[j], factor); }
        }
        var all = data.concat(ecc);
        // module matrix: null = free for data, true/false = fixed function module
        var m = [];
        for (r = 0; r < size; r++) { var row = []; for (c = 0; c < size; c++) { row.push(null); } m.push(row); }
        function finder(r0, c0) {
          for (var fr = -1; fr <= 7; fr++) {
            for (var fc = -1; fc <= 7; fc++) {
              var rr = r0 + fr, cc = c0 + fc;
              if (rr < 0 || rr >= size || cc < 0 || cc >= size) { continue; }
              m[rr][cc] = (fr >= 0 && fr <= 6 && (fc === 0 || fc === 6)) || (fc >= 0 && fc <= 6 && (fr === 0 || fr === 6)) || (fr >= 2 && fr <= 4 && fc >= 2 && fc <= 4);
            }
          }
        }
        finder(0, 0); finder(0, size - 7); finder(size - 7, 0);
        for (i = 8; i < size - 8; i++) {
          if (m[6][i] === null) { m[6][i] = (i % 2 === 0); }
          if (m[i][6] === null) { m[i][6] = (i % 2 === 0); }
        }
        if (v >= 2) {   // single alignment pattern at (size-7, size-7) for v2/v3
          var p = size - 7;
          for (r = -2; r <= 2; r++) { for (c = -2; c <= 2; c++) { m[p + r][p + c] = (Math.max(Math.abs(r), Math.abs(c)) !== 1); } }
        }
        // format info for (ECC M, mask 0) = the constant 0x5412; both copies + dark module
        var fmt = 0x5412;
        for (i = 0; i < 15; i++) {
          var mod = ((fmt >> i) & 1) === 1;
          if (i < 6) { m[i][8] = mod; } else if (i < 8) { m[i + 1][8] = mod; } else { m[size - 15 + i][8] = mod; }
          if (i < 8) { m[8][size - i - 1] = mod; } else if (i < 9) { m[8][15 - i]  = mod; } else { m[8][14 - i] = mod; }
        }
        m[size - 8][8] = true;
        // zigzag data placement with mask 0 ((r+c) % 2 == 0 flips)
        var inc = -1, rowPos = size - 1, bitIndex = 7, byteIndex = 0;
        for (var col = size - 1; col > 0; col -= 2) {
          if (col === 6) { col--; }
          while (true) {
            for (c = 0; c < 2; c++) {
              if (m[rowPos][col - c] === null) {
                var dark = false;
                if (byteIndex < all.length) { dark = (((all[byteIndex] >>> bitIndex) & 1) === 1); }
                if ((rowPos + (col - c)) % 2 === 0) { dark = !dark; }
                m[rowPos][col - c] = dark;
                bitIndex--;
                if (bitIndex === -1) { byteIndex++; bitIndex = 7; }
              }
            }
            rowPos += inc;
            if (rowPos < 0 || rowPos >= size) { rowPos -= inc; inc = -inc; break; }
          }
        }
        return m;
      }
      function drawRemoteQr() {
        var cv = element[0].querySelector('.oep-qr');
        if (!cv) { return; }
        // the QR is for PHONES, so it encodes the LAN URL - and only once the relay is set up and the
        // watchdog hasn't reported it down (no point sending a phone to a dead address).
        var url = scope.remote.lanUrl || '';
        if (!scope.remote.on || !scope.remote.lanConfigured || !url || scope.remote.lanReachable === false) { cv.__qrUrl = null; return; }
        var key = scope.hideRemoteAddr ? '__hidden__' : url;
        if (cv.__qrUrl === key) { return; }
        var gh = cv.getContext('2d');
        // Privacy: while hidden, never render the real scannable code - it encodes the LAN address.
        if (scope.hideRemoteAddr) {
          cv.__qrUrl = key;
          gh.fillStyle = '#eef1f4'; gh.fillRect(0, 0, cv.width, cv.height);
          gh.fillStyle = '#8b939b'; gh.textAlign = 'center'; gh.textBaseline = 'middle';
          gh.font = 'bold 13px Arial'; gh.fillText('QR HIDDEN', cv.width / 2, cv.height / 2 - 8);
          gh.font = '10px Arial'; gh.fillText('click Reveal to scan', cv.width / 2, cv.height / 2 + 12);
          return;
        }
        var m = qrMatrix(url);
        if (!m) { return; }
        cv.__qrUrl = key;
        var n = m.length, quiet = 4;
        var scale = Math.floor(cv.width / (n + quiet * 2));
        var off = Math.floor((cv.width - scale * n) / 2);
        var g = cv.getContext('2d');
        g.fillStyle = '#fff';
        g.fillRect(0, 0, cv.width, cv.height);
        g.fillStyle = '#000';
        for (var r = 0; r < n; r++) {
          for (var c = 0; c < n; c++) {
            if (m[r][c]) { g.fillRect(off + c * scale, off + r * scale, scale, scale); }
          }
        }
      }
      // machine preference, not a tune: restore the server across game restarts
      try {
        if (!IS_MIRROR && localStorage.getItem('oep_remote') === '1' && typeof bngApi !== 'undefined' && bngApi.engineLua) {
          bngApi.engineLua('extensions.load("oeProTuneRemote")');
          setTimeout(function () { refreshRemote(); publishTuneCatalog(); }, 800);
        }
      } catch (e) {}

      // ====================================================================
      // incoming: structural state
      // ====================================================================
      var remoteStateAutosaveTimer = null;
      var workingDraftAutosaveTimer = null;
      function scheduleRemoteStateAutosave() {
        if (IS_MIRROR) { return; }
        if (remoteStateAutosaveTimer) { clearTimeout(remoteStateAutosaveTimer); }
        if (activeRemoteTuneLoad && activeRemoteTuneLoad.timer) { clearTimeout(activeRemoteTuneLoad.timer); }
        activeRemoteTuneLoad = null;
        remoteStateAutosaveTimer = setTimeout(function () {
          remoteStateAutosaveTimer = null;
          // Crash recovery only: calibrationDirty remains true until the user presses
          // Store, so the UI still teaches the explicit ECU-store workflow.
          saveAutosave();
        }, 450);
      }
      function scheduleWorkingDraftAutosave() {
        if (IS_MIRROR || scope.applyingTune) { return; }
        if (workingDraftAutosaveTimer) { clearTimeout(workingDraftAutosaveTimer); }
        workingDraftAutosaveTimer = setTimeout(function () {
          workingDraftAutosaveTimer = null;
          // v2 keeps this live crash-recovery draft at the snapshot top level;
          // serializedMapBank only reads saved slot bodies and cannot capture into them.
          saveAutosave();
        }, 350);
      }
      function armApplyTuneRelease() {
        if (applyTuneTimer) { clearTimeout(applyTuneTimer); }
        var delay = scope.gamePaused ? 250 : 1500;
        if (scope.gamePaused) { applyTuneWaitedForUnpause = true; }
        applyTuneTimer = setTimeout(function () {
          scope.$applyAsync(function () {
            applyTuneTimer = null;
            if (scope.gamePaused) {
              applyTuneWaitedForUnpause = true;
              armApplyTuneRelease();
              return;
            }
            if (applyTuneWaitedForUnpause) {
              // Vehicle-Lua was unable to consume any queued replay commands while paused.
              // Give the complete queue a fresh hold window after the first unpaused poll.
              applyTuneWaitedForUnpause = false;
              armApplyTuneRelease();
              return;
            }
            scope.applyingTune = false;
            callController('requestState()');
          });
        }, delay);
      }
      scope.$on('OEProTuneState', function (event, state) {
        // The first State is a persistence boundary: it can reissue a durable Factory Reset or
        // replay the previous autosave.  Hold only the latest full snapshot until GE identifies
        // this BeamNG process.  If GE is unavailable, the one-second gate opens in conservative
        // vehicle-id-only mode instead of leaving the tuner disconnected indefinitely.
        if (!IS_MIRROR && !gameSessionNonceGateOpen) {
          if (state) { deferredStateBeforeGameSessionNonce = state; }
          requestGameSessionNonce();
          return;
        }
        scope.$applyAsync(function () {
          if (!state) { return; }
          // At startup every spawned OE ProTune controller can broadcast into this game UI. Until
          // GE identifies the player vehicle, accepting the first numeric State could restore this
          // car's saved package into traffic/BeamMP/a different spawn. Legacy states without vehId
          // retain the old fail-open behavior; the browser mirror is already player-routed by GE.
          if (!IS_MIRROR && typeof state.vehId === 'number' && scope.activeVehId === null) {
            refreshActiveVeh();
            return;
          }
          // Ignore a DIFFERENT vehicle's ECU (BeamMP / traffic / multi-spawn). Only legacy controller
          // states with no vehicle id fail open. This is the fix for "Connect goes straight back to
          // Monitoring" - a foreign car's state was reading as a car-switch.
          if (typeof state.vehId === 'number' && scope.activeVehId !== null && state.vehId !== scope.activeVehId) { return; }
          if (!state.active) {
            if (activeRemoteTuneLoad) { finishRemoteTuneLoad(false, 'ecu_inactive'); }
            scope.connected = false; return;
          }
          var temporaryIdentityChanged = temporaryLessonIdentityChanged(state);
          var incomingMixVehId = (typeof state.vehId === 'number') ? state.vehId : null;
          var vehicleInstanceChanged = incomingMixVehId !== null && mixVehicleId !== null && incomingMixVehId !== mixVehicleId;
          // linkedVehId is additive alongside the mixVehicleId tracking: it names the ECU this UI
          // is painting so a same-name second spawn still wipes transient traces/echoes below.
          var vehicleIdChanged = incomingMixVehId !== null && scope.linkedVehId !== null && incomingMixVehId !== scope.linkedVehId;
          var previousEngineFp = scope.base && scope.base.engineFp;
          var incomingEngineFp = state.base && state.base.engineFp;
          var engineFingerprintChanged = previousEngineFp !== undefined && previousEngineFp !== null &&
            incomingEngineFp !== undefined && incomingEngineFp !== null &&
            String(previousEngineFp) !== String(incomingEngineFp);
          if (vehicleInstanceChanged) {
            invalidateMixture('vehicle changed');
          }
          if (incomingMixVehId !== null) { mixVehicleId = incomingMixVehId; }
          // An engine swap can reinitialize the controller inside the same vehicle object. Treat its
          // fingerprint as package identity too, otherwise equal fresh sequence numbers can leave the
          // previous engine's saved MAP bodies attached to the new engine until the next app reload.
          var packageIdentityChanged = scope.carName !== state.carName || vehicleInstanceChanged ||
            engineFingerprintChanged || temporaryIdentityChanged;
          var linkedIdentityStillMatches = incomingMixVehId !== null && typeof scope.linkedVehId === 'number' &&
            incomingMixVehId === scope.linkedVehId && String(scope.carName || '') === String(state.carName || '') &&
            String(previousEngineFp || '') === String(incomingEngineFp || '');
          var sameIdentityReinitBoundary = !packageIdentityChanged && !IS_MIRROR && state.justInited === true &&
            pendingReinit !== true && (scope.connected === true || linkedIdentityStillMatches);
          // A local control edit is visible before its controller echo schedules the normal
          // crash-recovery debounce. Remember those in-flight edits now, while the old package
          // identity is still active, so a rapid vehicle/engine swap cannot lose them.
          var identityPendingUiEcho = packageIdentityChanged &&
            (Object.keys(pendingSettingEcho).length > 0 || Object.keys(pendingMapEcho).length > 0);
          if (sameIdentityReinitBoundary) {
            // The old controller lifecycle is over. A queued result from it must not replay after
            // the fresh package is restored, even if that old State was already queued in Angular.
            supersedePendingMapBankMutation(true);
            // A part/controller reload can arrive before the 350/450ms draft debounce or even
            // before the first controller echo. Persist the old controller's painted working tune
            // before its fresh M1 state is allowed to repaint the UI, then replay that exact save.
            var reinitRecoveryPending = !!remoteStateAutosaveTimer || !!workingDraftAutosaveTimer ||
              Object.keys(pendingSettingEcho).length > 0 || Object.keys(pendingMapEcho).length > 0 ||
              scope.storeRequired === true || !!(scope.mapBank && scope.mapBank.workingDirty === true) ||
              scope.applyingTune === true;
            if (remoteStateAutosaveTimer) { clearTimeout(remoteStateAutosaveTimer); remoteStateAutosaveTimer = null; }
            if (workingDraftAutosaveTimer) { clearTimeout(workingDraftAutosaveTimer); workingDraftAutosaveTimer = null; }
            if (reinitRecoveryPending && scope.carName && scope.maps && scope.settings) { saveAutosave(); }
            pendingSettingEcho = {};
            pendingMapEcho = {};
            if (applyTuneTimer) { clearTimeout(applyTuneTimer); applyTuneTimer = null; }
            applyTuneWaitedForUnpause = false;
            scope.applyingTune = false;
          }
          if (packageIdentityChanged && activeRemoteTuneLoad) {
            finishRemoteTuneLoad(false, 'vehicle_or_engine_changed');
          } else {
            checkRemoteTuneConfirmation(state);
          }
          if (packageIdentityChanged) {
            // A tab/vehicle change does not cancel the command on the old ECU. Drop only the local
            // waiter; that controller retains its self-describing result and it will reconcile when
            // this exact vehicle/epoch is active again.
            cancelPendingMapBankMutation('vehicle_or_engine_changed', true);
            cacheCurrentMapBankBodies();
            buildRefreshGeneration = (Number(buildRefreshGeneration) || 0) + 1;
            if (applyTuneTimer) { clearTimeout(applyTuneTimer); applyTuneTimer = null; }
            applyTuneWaitedForUnpause = false;
            scope.applyingTune = false;
            pendingSettingEcho = {};
            pendingMapEcho = {};
            // Δ baselines describe the OLD package's tables - meaningless against another vehicle
            // or engine (worst case different axis dimensions paint absolute values as deltas)
            scope.baselineMaps = {};
            scope.showDelta = false;
            invalidateMixture(engineFingerprintChanged ? 'engine changed' : 'vehicle changed');
            // A factory-reset confirmation belongs to the controller that was visible when the
            // second click occurred. Never let its epoch/frSeq valve consume the new car's first
            // broadcast and persist/mark that unrelated package as reset.
            scope.awaitingFactorySettings = false;
            scope.frArmed = false;
            frSeqAtReset = null;
            if (frArmTimer) { clearTimeout(frArmTimer); frArmTimer = null; }
            if (frValveTimer) { clearTimeout(frValveTimer); frValveTimer = null; }
            // V2.9.7 wave-2 review: a hydration banner raised on the OLD car names ITS slots -
            // meaningless (and alarming) against another vehicle/engine. Re-audited on this car's
            // own relink pass.
            scope.mapBankHydrationWarning = null;
            // Mirror sends are trailing-edge debounced. A same-object engine swap keeps the
            // vehicle id, so explicitly cancel the old engine's queued setters before the new
            // controller state is adopted.
            if (IS_MIRROR) {
              Object.keys(mirrorSendTimers).forEach(function (key) { clearTimeout(mirrorSendTimers[key]); });
              mirrorSendTimers = {};
            }
          }
          // Debounced callbacks read scope identity when they FIRE. Flush an accepted old-car draft
          // while scope still names that car, then cancel both timers before adopting the new identity;
          // otherwise a late callback can populate the new car's key and block durable recovery.
          if (packageIdentityChanged && !IS_MIRROR) {
            var identityAutosavePending = !!remoteStateAutosaveTimer || !!workingDraftAutosaveTimer ||
              identityPendingUiEcho || scope.storeRequired === true ||
              !!(scope.mapBank && scope.mapBank.workingDirty === true);
            if (remoteStateAutosaveTimer) { clearTimeout(remoteStateAutosaveTimer); remoteStateAutosaveTimer = null; }
            if (workingDraftAutosaveTimer) { clearTimeout(workingDraftAutosaveTimer); workingDraftAutosaveTimer = null; }
            if (identityAutosavePending && scope.carName && scope.maps && scope.settings) { saveAutosave(); }
          }
          // Roll a temporary lesson back while scope and controller routing still belong to the
          // old vehicle. The new ECU metadata is consumed only afterward, so applySnapshot cannot
          // repopulate the freshly-cleared slots with the old lesson package.
          if (temporaryIdentityChanged) {
            abandonTemporaryLessonForIdentityChange();
            cacheCurrentMapBankBodies();
            // The routed rollback uses applySnapshot, whose replay hold is meaningful only for
            // the old ECU. Do not let it suppress this new controller's first-link package sync.
            if (applyTuneTimer) { clearTimeout(applyTuneTimer); applyTuneTimer = null; }
            applyTuneWaitedForUnpause = false;
            scope.applyingTune = false;
          }
          var firstLink = !scope.connected || packageIdentityChanged;
          var firstLinkStoredFactoryResetIntent = firstLink ? storedFactoryResetIntentForState(state) : null;
          var firstLinkFactoryResetIntent = firstLink ? factoryResetIntentForState(state) : null;
          var firstLinkFactoryResetIntentConfirmed = !!firstLinkFactoryResetIntent &&
            factoryResetIntentConfirmedByState(firstLinkFactoryResetIntent, state);
          var firstLinkFactoryResetPending = !!firstLinkFactoryResetIntent && !firstLinkFactoryResetIntentConfirmed;
          var firstLinkFactoryResetRecovery = firstLink &&
            (firstLinkFactoryResetIntentConfirmed ||
              (!firstLinkStoredFactoryResetIntent && autosavePredatesConfirmedFactoryReset(state)));
          if (firstLinkStoredFactoryResetIntent && firstLinkStoredFactoryResetIntent.phase === 'prepared' &&
              !firstLinkFactoryResetIntent) { schedulePreparedFactoryResetCleanup(state, firstLinkStoredFactoryResetIntent); }
          var hydratedVehicleMapBankCache = false;
          var wasWorkingMapDirty = scope.mapBank && scope.mapBank.workingDirty === true;
          // setAxis emits intermediate states while applySnapshot is replaying a package. Its
          // sequence changes are not a completed package and may describe the previous slot set.
          // Keep the installed UI package intact; the first poll after the tune hold adopts the
          // controller's final atomic configureMapBankPackage state.
          var mapBankAuthoritative = scope.applyingTune ? false : syncMapBankState(state.mapBank, firstLink);
          var autosaveMapBankOptions = (state.justInited || pendingReinit)
            ? { selectDefault: true }
            : { selectBank: controllerMapBankActive };
          var wasControllingUI = scope.controlling;   // capture before it's overwritten below
          if (engineFingerprintChanged && wasControllingUI) { reconnectControl = true; }
          var prevCar = scope.carName;                // capture before it's overwritten below
          // V3 calibration versioning: the controller stamps every broadcast with the calibration
          // epoch (fresh per controller life/factory reset), a monotonic revision, the dirty flag
          // (revision != storedRevision) and who made the last change. 'remote' + an advanced
          // revision marks a browser edit this in-game UI has not made itself.
          var prevEpoch = scope.calibrationEpoch;
          var prevRevision = scope.calibrationRevision;
          var incomingEpoch = state.calibrationEpoch !== undefined && state.calibrationEpoch !== null ? String(state.calibrationEpoch) : null;
          var incomingRevision = Number(state.calibrationRevision);
          var hasRevision = isFinite(incomingRevision);
          var epochChanged = incomingEpoch !== null && prevEpoch !== null && incomingEpoch !== prevEpoch;
          var revisionAdvanced = epochChanged || (hasRevision && (prevEpoch === null || incomingRevision > prevRevision));
          if (incomingEpoch !== null) { scope.calibrationEpoch = incomingEpoch; }
          if (hasRevision) { scope.calibrationRevision = incomingRevision; }
          scope.calibrationDirty = state.calibrationDirty === true;
          scope.calibrationChangeSource = String(state.changeSource || '');
          var remoteChanged = revisionAdvanced && scope.calibrationChangeSource === 'remote';
          if (remoteChanged) { invalidateMixture('remote calibration changed'); }
          if (state.justInited && !pendingReinit) { invalidateMixture('controller reinitialized'); }
          scope.connected = true;
          scope.ctrlVersion = state.ctrlVersion || null;   // V2.9: null = older cached controller (no per-table reset / factory reset)
          if (state.controlling !== undefined) { scope.controlling = !!state.controlling; }
          var carChanged = !!prevCar && prevCar !== state.carName;   // a genuinely DIFFERENT vehicle loaded
          if (carChanged) { invalidateMixture('vehicle changed'); }
          if (vehicleIdChanged) {
            // Never carry transient traces or in-flight echo state from the old object into the
            // newly selected one, even when both spawned cars have the same display name.
            // Deliberately NOT wiped in-game: logRows and dyno results - 2.9 users tab between
            // cars to compare a recorded pull, and the per-car flows already manage those.
            scope.t = {}; scope.knockFlash = false;
            invalidateMixture('vehicle changed'); pendingSettingEcho = {}; pendingMapEcho = {};
            lastTelemetryAt = Date.now();
            if (IS_MIRROR) {   // the mirror repaints purely from broadcasts; stale traces would linger
              scope.logRows = []; scope.logCursor = 0;
              scope.dynoRows = []; scope.dynoState = 'idle'; scope.dynoStatus = 'ready'; scope.dynoText = '';
            }
          }
          if (incomingMixVehId !== null) { scope.linkedVehId = incomingMixVehId; }
          scope.carName = state.carName;
          // REVIEW FIX 2026-07-29: name a 0.39-bugged build would have used, for the durable read fallback
          scope.carNameFallback = (typeof state.carNameFallback === 'string' && state.carNameFallback) ? state.carNameFallback : '';
          scope.base = state.base || scope.base;
          if (firstLink && packageIdentityChanged && !firstLinkFactoryResetIntent) {
            hydratedVehicleMapBankCache = hydrateMapBankBodiesFromVehicleCache(state);
          }
          // factory injector flow this engine needs (mirrors the controller's defaultSettings sizing).
          // Used to migrate pre-V2_1 saved tunes that still carry the old fixed 600cc default.
          // peakPowerHp rides in the base table on the STATE message (it's a telemetry-only field at
          // the top level, which is why an earlier state.peakPowerHp read came back NaN).
          scope.factoryInjFlow = Math.min(6000, Math.max(600, Math.floor(600 * Math.max(1, ((scope.base && scope.base.peakPowerHp) || 0) / 320))));
          scope.mapMeta = state.mapMeta || scope.mapMeta;
          // V3.0.7: adopt the controller's shared axis bounds. Only widen-or-equal is accepted from a
          // controller that predates axisLimits (undefined -> keep the fallback), so an older cached
          // controller can never narrow the validator under a wide grid already on disk.
          if (state.axisLimits && isFinite(Number(state.axisLimits.min)) && isFinite(Number(state.axisLimits.max))) {
            scope.axisLimits = { min: Number(state.axisLimits.min), max: Number(state.axisLimits.max) };
          }
          // V3.0.7 grid resolution. Derive the live shape from the AXES, not from these fields, so a
          // pre-3.0.7 controller (which sends neither) still reads correctly; gridRestore* has no
          // axis to fall back on and is simply absent there, which reads as "no exact way back".
          scope.gridRestoreRows = state.gridRestoreRows || null;
          scope.gridRestoreCols = state.gridRestoreCols || null;
          var previousRpmAxis = (scope.rpmAxis || []).slice();
          var previousMapAxis = (scope.mapAxis || []).slice();
          var previousAlphaNAxis = (scope.alphaNAxis || []).slice();
          if (!scope.applyingTune) {
            scope.rpmAxis = state.rpmAxis || [];
            scope.mapAxis = state.mapAxis || [];
            scope.alphaNAxis = state.alphaNAxis || scope.alphaNAxis || [];
          }
          var nextMixAxisSignature = mixtureAxisSignature();
          var axisChanged = mixAxisSignature !== null && nextMixAxisSignature !== mixAxisSignature;
          // REVIEW FIX 2026-07-29: the Alpha-N axis has to count as a table-axis change too. The
          // controller now reprojects the alpha-indexed tables and all four banks when it moves, so if
          // this gate ignores it the UI's retained slot bodies stay on the OLD alpha grid and the next
          // autosave persists misaligned bodies under the new axis.
          var tableAxisChanged = !axesEqualTol(previousRpmAxis, scope.rpmAxis) || !axesEqualTol(previousMapAxis, scope.mapAxis)
            || !axesEqualTol(previousAlphaNAxis, scope.alphaNAxis);
          if (axisChanged) {
            invalidateMixture('axes changed');
            if (tableAxisChanged && !firstLink && !scope.applyingTune) {
              resampleMapBankSlots(previousRpmAxis, previousMapAxis, scope.rpmAxis.slice(), scope.mapAxis.slice(),
                previousAlphaNAxis, scope.alphaNAxis.slice(), scope.alphaNActive());
              // Δ baselines are breakpoint-indexed like the bank slots: refit them to the new axes
              // too, or every delta compares cells that no longer mean the same RPM/MAP point
              // (grown axes even painted absolute values as deltas via the missing-cell fallback)
              if (previousRpmAxis.length && previousMapAxis.length) {
                Object.keys(scope.baselineMaps || {}).forEach(function (bn) {
                  var oldLoadAxis = scope.mapUsesAlphaN(bn) ? previousAlphaNAxis : previousMapAxis;
                  var newLoadAxis = scope.mapUsesAlphaN(bn) ? scope.alphaNAxis : scope.mapAxis;
                  scope.baselineMaps[bn] = resampleSnapshotTable(scope.baselineMaps[bn], previousRpmAxis, oldLoadAxis, scope.rpmAxis, newLoadAxis, bn);
                });
              }
            }
          }
          mixAxisSignature = nextMixAxisSignature;
          // always trust the car for settings/maps unless we are mid drag-edit.
          // awaitingFactorySettings: mid-session the UI owns settings (it pushes, the car echoes), so a
          // controller-side factory reset would never reach the sliders - the flag accepts that one broadcast.
          // V2.9 fix: only the POST-reset broadcast may consume the flag. A pre-reset echo already in
          // flight used to eat it and silently put the OLD settings back on the sliders while the
          // controller ran factory values. factoryReset() bumps frSeq, so require the bumped sequence.
          var incomingFrSeq = (typeof state.frSeq === 'number') ? state.frSeq : null;
          // inequality, not '>': frSeq is a controller local, so a respawn/part change mid-wait
          // restarts it at a LOWER number - any change from the sequence seen at the confirm click
          // marks a fresh controller state worth adopting.
          // V3 analog rides along: a controller factory reset also mints a FRESH calibration epoch
          // (like a respawn), so an epoch change while we're waiting on that reset marks the
          // authoritative post-reset broadcast too. The frSeq valve stays authoritative for its own
          // awaitingFactorySettings flow; epochChanged only widens the accepted signal.
          var previousFrSeq = (typeof scope.lastFrSeq === 'number') ? scope.lastFrSeq : null;
          // Remote/Pro Studio resets do not arm awaitingFactorySettings in this UI. A mid-session
          // frSeq change is the controller's authoritative reset stamp too; exclude fresh controller
          // init so a normal part reload cannot be mistaken for a whole-package wipe.
          var factorySequenceChanged = !firstLink && !state.justInited && previousFrSeq !== null &&
            incomingFrSeq !== null && incomingFrSeq !== previousFrSeq;
          // V2.9.7 (Store-wipe wave 2): the frSeqAtReset===null wildcard is only trusted for arms made
          // IN THIS UI (in-game confirm / durable-recovery flows). A remote-accepted arm requires the
          // stamp (frSeq transition or epoch evidence); without it the valve expiry releases the flag.
          var factoryBroadcast = firstLinkFactoryResetRecovery || factorySequenceChanged || (scope.awaitingFactorySettings &&
            ((frSeqAtReset === null && !frArmRemote) || (incomingFrSeq !== null && incomingFrSeq !== frSeqAtReset) || epochChanged));
          if (factoryBroadcast) { supersedePendingMapBankMutation(true); }
          if (factoryBroadcast && scope.applyingTune) {
            // A stamped factory reset outranks an in-flight tune replay. The early replay hold
            // deliberately skipped metadata/axes, so adopt the reset controller's M1 package now
            // before the wipe block below seeds its only saved body.
            if (applyTuneTimer) { clearTimeout(applyTuneTimer); applyTuneTimer = null; }
            applyTuneWaitedForUnpause = false;
            scope.applyingTune = false;
            mapBankAuthoritative = syncMapBankState(state.mapBank, false) || mapBankAuthoritative;
            scope.rpmAxis = state.rpmAxis || [];
            scope.mapAxis = state.mapAxis || [];
            scope.alphaNAxis = state.alphaNAxis || scope.alphaNAxis || [];
            mixAxisSignature = mixtureAxisSignature();
          }
          if (incomingFrSeq !== null) { scope.lastFrSeq = incomingFrSeq; }
          // The mirror paints EVERY settings echo (the ECU broadcast is its only calibration
          // source), and the PC additionally accepts revision-tagged remote changes so both UIs
          // converge. mergeSettingsEcho keeps a just-sent local edit on the sliders until its echo
          // lands (or 3s pass); an authoritative broadcast (remote change / factory reset) wins outright.
          var adoptSettings = firstLink || factoryBroadcast || (!scope.applyingTune && mapBankAuthoritative) ||
            !scope.settings || Object.keys(scope.settings).length === 0;
          if (adoptSettings || ((IS_MIRROR || remoteChanged) && state.settings)) {
            if (factoryBroadcast) { scope.awaitingFactorySettings = false; }
            mergeSettingsEcho(state.settings, remoteChanged || factoryBroadcast || mapBankAuthoritative || packageIdentityChanged);
            if (Number(scope.settings.fuelEquationMode) !== 2) { scope.mixDisplayMode = 'correction'; }
          }
          // don't copy the car's maps over ours during a tune-load, nor on a fresh-init
          // (its maps are factory defaults - we re-apply the real tune just below)
          if (state.maps && (IS_MIRROR || remoteChanged || (!scope.applyingTune && mapBankAuthoritative) || firstLink || factoryBroadcast || (!scope.maps) || (!scope.dragging && !scope.applyingTune && !state.justInited))) {
            // Mirror always paints the ECU echo. The PC also accepts revision-tagged
            // remote changes so both UIs converge instead of becoming split-brain.
            if (IS_MIRROR || remoteChanged || (!scope.applyingTune && mapBankAuthoritative) || firstLink || factoryBroadcast || !scope.maps || !scope.dragging) { mergeMapsEcho(state.maps, remoteChanged || factoryBroadcast || mapBankAuthoritative || packageIdentityChanged); }
          }
          // A clean active bank is guaranteed controller-side to equal the live working copy. Refresh
          // its UI-side body after an authoritative sequence change so controller-owned migrations
          // (notably the unread-turbo factory boost reseed) remain clean and serializable.
          if (!IS_MIRROR && !scope.applyingTune && mapBankAuthoritative && scope.mapBank.workingDirty !== true) {
            refreshCleanActiveMapBankBody();
          }
          if (firstLink && state.mapBank && state.maps && state.settings && !IS_MIRROR && lsGet(calKey()) == null) {
            seedInitialMapBankBody();
          }
          // A confirmed full reset is a persistent ECU wipe. Save only the
          // autosave after the stamped post-reset state arrives; named slots
          // and library tunes remain available by design.
          var factorySnapshotAcknowledged = false;
          // factory tables are a NEW reference point: stale Δ baselines would paint the entire old
          // tune as "changes". Both UIs process this broadcast, so the mirror heals too.
          if (factoryBroadcast) { scope.baselineMaps = {}; }
          if (factoryBroadcast && !IS_MIRROR) {
            scope.tuneName = 'Base';
            (scope.mapBank.slots || []).forEach(function (slot) {
              slot.configured = Number(slot.index) === Number(scope.mapBank.active);
              slot.data = null;
              slot.peakBoostKpa = 0;
              if (!slot.configured) { slot.boostTableFactory = undefined; }
            });
            seedInitialMapBankBody();
            var factorySnapshotSaved = saveAutosave();
            // Clear the durable reset intent only after the authoritative factory package is
            // verified in local storage. A crash before either step safely repeats the reset.
            if (factorySnapshotSaved && clearFactoryResetIntent()) {
              // That autosave just persisted the factory state - tell the ECU so calibrationDirty
              // agrees with the 'Stored' badge 2.9 always showed after a confirmed reset.
              callController('markStored()');
              scope.storeRequired = false;
              scope.calibrationDirty = false;
              factorySnapshotAcknowledged = true;
            } else {
              scope.storeRequired = true;
              setToast('Factory Reset completed, but its recovery save could not be verified - the reset marker was kept');
              auditMapBankBodies();   // V2.9.7 wave-2 review: name what is un-synced instead of a bare red badge
            }
          }
          if (firstLink) { pendingAxisAutosave = false; }
          if (!factoryBroadcast && !firstLink && !scope.applyingTune && state.maps && (pendingAxisAutosave || axisChanged)) {
            pendingAxisAutosave = false;
            saveAutosave();
            scope.storeRequired = true;
          }
          // The mirror may optimistically paint an edit before the server accepts
          // it. Its next ECU echo is authoritative, including clearing a false
          // UNSAVED badge after a blocked/rejected command. The trusted PC keeps
          // its existing local edit workflow.
          if (IS_MIRROR) { scope.storeRequired = scope.calibrationDirty; }
          // In-game the badge FOLLOWS the ECU's dirty flag both ways (remote edits surface Store
          // Req on the game PC, and markStored's corrective broadcast clears a stale badge -
          // a one-way latch stuck ON after spawn replays and lesson rollbacks). Old controllers
          // without the field keep 2.9's local-only storeRequired flow untouched.
          else if (typeof state.calibrationDirty === 'boolean') { scope.storeRequired = factorySnapshotAcknowledged ? false : state.calibrationDirty; }
          if (remoteChanged) { scheduleRemoteStateAutosave(); }
          if (!IS_MIRROR && !firstLink && !scope.applyingTune && state.maps && state.settings &&
              (revisionAdvanced || wasWorkingMapDirty !== scope.mapBank.workingDirty || mapBankAuthoritative)) {
            scheduleWorkingDraftAutosave();
          }
          var nextMixCalibrationSignature = mixtureCalibrationSignature();
          if (mixCalibrationSignature !== null && nextMixCalibrationSignature !== mixCalibrationSignature) { invalidateMixture('fuel calibration changed'); }
          mixCalibrationSignature = nextMixCalibrationSignature;
          if ((firstLink || scope.activePage === 'axis_setup') && !axisDraftEditing()) { refreshAxisDrafts(); }
          if (firstLink) {
            if (IS_MIRROR) {
              // The ECU broadcast above is the mirror's only calibration source. In
              // particular, never ack a fresh controller or restore this browser's
              // unrelated localStorage over it.
              scope.storeRequired = scope.calibrationDirty;
              pendingReinit = false;
            } else {
              rebuildSlots();
              if (firstLinkFactoryResetPending) {
                // The confirm click was durably recorded but this controller still advertises the
                // pre-reset epoch/sequence (for example the app was removed before vehicle Lua ran).
                // Do not replay the old autosave; reissue the idempotent reset and keep the marker
                // until its stamped State has been saved successfully.
                scope.tuneName = 'Factory reset pending';
                scope.storeRequired = true;
                frArmRemote = false;   // durably-confirmed recovery arm: wildcard stays trusted
                // V2.9.7 wave-2 review: a live valve from an arm under 6 s earlier must not clear
                // THIS flag early. No new valve armed - the durable marker is the recovery authority.
                if (frValveTimer) { clearTimeout(frValveTimer); frValveTimer = null; }
                scope.awaitingFactorySettings = true;
                frSeqAtReset = incomingFrSeq;
                localCalibrationMutationGeneration += 1;
                callController('factoryReset()');
                setToast('Completing the confirmed Factory Reset...');
              } else if (hydratedVehicleMapBankCache) {
                // Exact vehicle-id + controller-epoch cache wins over every shared per-car load
                // path, including returning through a differently named car. State already supplied
                // this ECU's live working tune; the cache supplied its inactive saved bodies/name.
                seedInitialMapBankBody();
                if (carChanged || vehicleInstanceChanged || temporaryIdentityChanged) {
                  callController('setControl(0)');
                  scope.controlling = false;
                  reconnectControl = false;
                }
              } else if (scope.calibrationDirty && !hasTemporaryLessonJournal()) {
                // Reopening the app on a live ECU (or opening it after a remote edit)
                // must preserve that unsaved working calibration, not replay an older
                // autosave over it. An interrupted temporary lesson still outranks this:
                // its journal must be rolled back, so it falls through to the branches below.
                if (!hydratedVehicleMapBankCache) { hydrateMapBankBodiesFromAutosave(state); }
                auditMapBankBodies();   // V2.9.7 wave 2: loud hydration - name what did not re-sync
                seedInitialMapBankBody();
                scope.tuneName = 'Live ECU';
                scope.storeRequired = true;
                if (carChanged || vehicleInstanceChanged || temporaryIdentityChanged) {
                  callController('setControl(0)');
                  scope.controlling = false;
                  reconnectControl = false;
                }
              } else if (carChanged) {
                // a DIFFERENT vehicle was loaded: never carry the previous car's working tune over
                // (carrying it dumped a wrong tune on the new engine - e.g. a Covet tune on a turbo car ->
                // lean -> blew up). But if THIS car has its own stored tune, restore THAT; only a car with
                // no stored tune starts on clean factory defaults (scope.maps/settings already hold them).
                // The old code always reset AND saved the defaults here, which permanently overwrote the
                // new car's stored tune every time you switched to it - the #1 way players lost tunes.
                if (lsGet(calKey()) != null || hasTemporaryLessonJournal()) {
                  loadAutosave(autosaveMapBankOptions);
                } else {
                  resetWorkingTune();
                  durRecover(autosaveMapBankOptions);   // no local data for this car: maybe the cache was wiped - recover from the durable file
                }
                // DROP control when you switch to a different car, so you consciously Connect to the car you
                // want to tune. That Connect is where we reliably read its engine and pick the right 2-step /
                // rolling sound (detecting mid-switch, with two cars spawned, was unreliable). Without this the
                // app stayed "connected" across a Tab switch, so the V8 kept the previous car's (Honda) sound.
                callController('setControl(0)');
                scope.controlling = false;
                reconnectControl = false;
              } else if (vehicleInstanceChanged || temporaryIdentityChanged) {
                // Same car/config name, but a different vehicle instance or engine arrived while a
                // temporary lesson was active. The state above has already supplied THIS controller's
                // own settings and maps. Do not load the old instance's rollback autosave into it.
                seedInitialMapBankBody();
                callController('setControl(0)');
                scope.controlling = false;
                reconnectControl = false;
              } else {
                // first connect on this car (game restart / reconnect): restore its own saved tune.
                // NOTHING saved for this car+engine -> factory defaults, so the previous car's working
                // settings (fuel type etc.) can't silently carry over (UncleSam's report, V2.8 fix).
                if (!loadAutosave(autosaveMapBankOptions)) { resetWorkingTune(); }
                durRecover(autosaveMapBankOptions);   // if local cache was wiped, recover this car's tune from the durable file
              }
              auditMapBankBodies();   // V2.9.7 wave-2 review: audit after EVERY firstLink path, not just the dirty-relink branch
              scope.build.namesLoaded = false;   // parts may differ on this car - re-scan names on next Build Card open
              scope.refreshBuild();   // re-read the fitted parts for the Build Card (cheap: raw names, no mod scan)
              if (pendingRecoveredLessonStoreRequired !== null) {
                scope.storeRequired = pendingRecoveredLessonStoreRequired;
                pendingRecoveredLessonStoreRequired = null;
              } else if (firstLinkFactoryResetPending) {
                scope.storeRequired = true;
              } else {
                // calibrationDirty replaces the old hard 'false': an old controller never
                // broadcasts it (stays false = 2.9 behavior), the 2.9.6 controller keeps the
                // Live ECU / remote-edit UNSAVED badge honest across a UI reload.
                scope.storeRequired = scope.calibrationDirty;
              }
              if (state.justInited) { callController('ackInit()'); }
              // V3.0.9: a part change re-inits the controller (which starts disconnected by design,
              // oeProTune.lua "start disconnected: monitor only") AND destroys this directive, so the
              // in-memory reconnectControl set at :793 never survives to be acted on. Seed it from the
              // stored intent instead. Guarded three ways: only on a genuine re-init, only when the
              // stored car matches this one, and only when we are not already controlling - so a fresh
              // spawn, a different vehicle, or a first-ever run can never auto-seize the engine.
              if (state.justInited && !reconnectControl && !IS_MIRROR && !scope.controlling) {
                var wantReconnect = '';
                try { wantReconnect = localStorage.getItem('oep_reconnect') || ''; } catch (eRc) {}
                if (wantReconnect && wantReconnect === (scope.carName || '1')) { reconnectControl = true; }
              }
              // if a part change / reload dropped us while connected, reconnect so the
              // restored tune stays live instead of the car reverting to stock
              if (reconnectControl) {
                reconnectControl = false;
                callController('setControl(1)');
                scope.controlling = true;
              }
            }
          } else if (!IS_MIRROR && state.justInited && !pendingReinit) {
            // the ECU re-spawned (e.g. a part change) WITHOUT dropping our connection:
            // re-apply the working tune and reconnect if we were live, then ack so this
            // runs once per spawn
            pendingReinit = true;
            loadAutosave({ selectDefault: true });
            scope.build.namesLoaded = false;   // a part may have changed - stale pretty names
            scope.refreshBuild();   // parts may have changed on the respawn (cheap: raw names, no mod scan)
            if (wasControllingUI || reconnectControl) {
              reconnectControl = false;
              callController('setControl(1)');
              scope.controlling = true;
            }
            callController('ackInit()');
          }
          // Tokenized MAP mutations are resolved only after State maps/settings and any first-link
          // body hydration have converged, keeping the persisted top level and saved body coherent.
          resolvePendingMapBankMutation(state.mapBank, state);
          // The controller broadcasts only slot metadata. When returning to another live instance
          // of the same car/config, its exact epoch-scoped body cache outranks the shared per-car
          // autosave. Persist the recovered package now so removing/readding the app cannot lose it.
          if (hydratedVehicleMapBankCache && !scope.applyingTune && !factoryBroadcast && !firstLinkFactoryResetPending) { saveAutosave(); }
          if (!state.justInited) { pendingReinit = false; }
          // Persistent flag drives the always-visible skew banner (app.html) + the dropped-command
          // toast wording. The one-shot toast below proved too easy to miss in the field (V2.9.x).
          scope.versionSkew = String(scope.ctrlVersion || '') !== String(scope.appVersion);
          if (!versionSkewWarned && String(scope.ctrlVersion || '') !== String(scope.appVersion)) {
            versionSkewWarned = true;
            var skewMessage = 'OE ProTune UI is ' + scope.appVersion + ' but the vehicle controller is ' + (scope.ctrlVersion || 'older/cached') + '. Restart BeamNG completely to load the matching controller.';
            // First-link tune restoration posts its own status toast. Delay this
            // warning so that setup cannot immediately hide the restart message.
            setTimeout(function () { scope.$applyAsync(function () { setToast(skewMessage); }); }, 500);
          }
        });
      });

      function normalizeSettings() {
        var s = scope.settings;
        if (s.revLimit === undefined || s.revLimit === null) { s.revLimit = 0; }
        if (s.loadSource === undefined) { s.loadSource = 1; }   // old tunes are speed-density/MAP tunes
        if (s.fuelType === undefined) { s.fuelType = 1; }      // back-compat for tunes saved before fuel types
        if (s.decelFuel === undefined) { s.decelFuel = -28; }
        if (s.egtLimit === undefined) { s.egtLimit = 950; }       // back-compat: EGT channel (#3)
        if (s.nitrousShot === undefined) { s.nitrousShot = 25; }  // back-compat: nitrous (#2)
        if (s.nitrousArmRpm === undefined) { s.nitrousArmRpm = 3500; }
        if (s.nitrousArmTps === undefined) { s.nitrousArmTps = 90; }
        if (s.nitrousFuel === undefined) { s.nitrousFuel = 15; }
        if (s.nitrousRetard === undefined) { s.nitrousRetard = 6; }
        if (s.dieselMaxFuel === undefined) { s.dieselMaxFuel = 100; }       // back-compat: diesel mode (#4)
        if (s.dieselRailPressure === undefined) { s.dieselRailPressure = 1200; }
        if (s.dieselSmokeLimit === undefined) { s.dieselSmokeLimit = 17; }
        if (s.glowPlugs === undefined) { s.glowPlugs = true; }
        // V3.0.5 hardware/transmission migration. These must be populated before pushAllSettings:
        // omitting a numeric key leaves whatever value the controller previously held, so an old tune
        // could accidentally inherit an active torque cap, converter or shift calibration.
        if (s.compBrakeEnabled === undefined) { s.compBrakeEnabled = false; }
        if (s.compBrakeLevel === undefined) { s.compBrakeLevel = 50; }
        if (s.dieselNozzleEnabled === undefined) { s.dieselNozzleEnabled = false; }
        if (s.dieselNozzleFlow === undefined) {
          var migrationInjFlow = Math.min(6000, Math.max(600, Math.floor(600 * Math.max(1, ((scope.base && scope.base.peakPowerHp) || 0) / 320))));
          s.dieselNozzleFlow = Math.min(6000, Math.max(200, Math.floor(migrationInjFlow * 1.35)));
        }
        if (s.dieselSmokeLimiter === undefined) { s.dieselSmokeLimiter = false; }
        if (s.shiftFirmness === undefined) { s.shiftFirmness = 0; }
        if (s.shiftTorqueMgmt === undefined) { s.shiftTorqueMgmt = 0; }
        if (s.torqueLimitNm === undefined) { s.torqueLimitNm = 0; }
        if (s.torqueLimitLowGears === undefined) { s.torqueLimitLowGears = false; }
        if (s.converterStall === undefined) { s.converterStall = 0; }
        if (s.clutchUpgrade === undefined) { s.clutchUpgrade = 1; }
        if (s.flatShiftRpm === undefined) { s.flatShiftRpm = 6500; }       // back-compat: flat-foot / no-lift shift
        if (s.launchSoundVol === undefined) { s.launchSoundVol = 5; }       // back-compat: 2-step bark
        if (s.popsLevel === undefined) { s.popsLevel = 1; }                 // back-compat: pops / flames intensity
        if (s.overrunThrottle === undefined) { s.overrunThrottle = 8; }     // back-compat: overrun pops & bangs
        if (s.overrunMinRpm === undefined) { s.overrunMinRpm = 2800; }
        if (s.overrunPopVol === undefined) { s.overrunPopVol = 6; }
        if (s.popSoundMode === undefined) { s.popSoundMode = 1; }           // back-compat: pop sound selector (1 = Both = pre-selector behaviour)
        if (s.fxStyle === undefined) { s.fxStyle = 2; }                     // back-compat: 2 = OE Realistic; 3 is opt-in Show / Coal
        s.fxStyle = Math.max(1, Math.min(3, Math.round(Number(s.fxStyle) || 2)));
        if (s.wgType === undefined) { s.wgType = 1; }                       // back-compat: wastegate type (1 = pneumatic spring gate, the pre-2.6.x behavior... honest version of it)
        if (s.rotIdleRpm === undefined) { s.rotIdleRpm = 1300; }            // back-compat: rotational idle
        if (s.rotIdleChop === undefined) { s.rotIdleChop = 6; }
        s.rotIdleChop = Math.max(1, Math.min(10, Math.round(Number(s.rotIdleChop) || 6)));   // V2.9.7: controller integer-rejects fractional enum values now - round legacy/hand-edited payloads into validity
        // back-compat: base timing (V2.9.7). Mirror the controller's engine-aware default so a
        // pre-2.9.7 tune/autosave doesn't leave the slider at an undefined 0.00 while the ECU runs
        // 12/16 (the knockAudio/autoFitAxes bug class fixed above).
        if (s.baseTiming === undefined) { s.baseTiming = (scope.base && scope.base.hasTurbo) ? 12 : 16; }
        // V2.9 fix: tunes saved before these settings existed left them undefined here while the
        // controller ran its own defaults - the Axis Setup toggle read OFF while auto-fit was really
        // ON, and the first click was a no-op. Mirror the controller defaults instead.
        if (s.knockAudio === undefined) { s.knockAudio = true; }            // back-compat: knock & damage sounds (V2.8)
        if (s.autoFitAxes === undefined) { s.autoFitAxes = true; }          // back-compat: auto-fit axes (V2.9, controller default ON)
        if (s.smokeDensity === undefined) { s.smokeDensity = 1; }           // back-compat: visual smoke scaler (V2.9)
        if (s.launchFuel === undefined) { s.launchFuel = 0; }               // back-compat: launch fuel/retard (V2.8)
        if (s.launchRetard === undefined) { s.launchRetard = 0; }
        // Boost-by-gear/speed is deliberately additive and opt-in. Old tunes (including the exact
        // proportional boostMode=3 profile) keep their behavior until the tuner enables this layer.
        if (s.boostShapeMode === undefined) { s.boostShapeMode = 1; }
        if (s.boostRampUpKpaS === undefined) { s.boostRampUpKpaS = 300; }
        if (s.boostRampDownKpaS === undefined) { s.boostRampDownKpaS = 0; }
        if (s.boostShiftHoldMs === undefined) { s.boostShiftHoldMs = 250; }
        s.boostShapeMode = Math.max(1, Math.min(4, Math.round(Number(s.boostShapeMode) || 1)));
        // V3.1 hard-cut shape. This backfill is NOT cosmetic: applySnapshot REPLACES scope.settings
        // wholesale and pushAllSettings only walks Object.keys, so a key still undefined here is
        // never pushed and the controller silently keeps the PREVIOUS tune's cut mode and range.
        if (s.limiterCutMode === undefined) { s.limiterCutMode = 1; }
        if (s.limiterRange === undefined) { s.limiterRange = 150; }
        if (s.limiterCutMs === undefined) { s.limiterCutMs = 50; }
        // Round the enum only - the controller integer-REJECTS a fractional mode. limiterRange is a
        // continuous rpm band and must NOT be rounded here or it fights the slider step.
        s.limiterCutMode = Math.max(1, Math.min(2, Math.round(Number(s.limiterCutMode) || 1)));
        for (var boostGear = 1; boostGear <= 8; boostGear++) {
          var gearTargetKey = 'boostGear' + boostGear + 'TargetTrimKpa';
          var gearDutyKey = 'boostGear' + boostGear + 'DutyTrimPct';
          if (s[gearTargetKey] === undefined) { s[gearTargetKey] = 0; }
          if (s[gearDutyKey] === undefined) { s[gearDutyKey] = 0; }
        }
        for (var boostSpeedPoint = 1; boostSpeedPoint <= 6; boostSpeedPoint++) {
          var speedTargetKey = 'boostSpeed' + boostSpeedPoint + 'TargetTrimKpa';
          var speedDutyKey = 'boostSpeed' + boostSpeedPoint + 'DutyTrimPct';
          if (s[speedTargetKey] === undefined) { s[speedTargetKey] = 0; }
          if (s[speedDutyKey] === undefined) { s[speedDutyKey] = 0; }
        }
        ['enabled', 'protection', 'launchEnabled', 'knockControl', 'canRuntime', 'canLambda', 'canDash',
         'auxCheckEngine', 'decelFuelCut', 'idleEnabled', 'idleIgnControl', 'antilagEnabled', 'nitrousEnabled', 'vvtEnabled', 'glowPlugs', 'compBrakeEnabled', 'dieselNozzleEnabled', 'dieselSmokeLimiter', 'torqueLimitLowGears', 'flatShiftEnabled', 'rollingEnabled', 'hardCut', 'rotIdleEnabled', 'knockAudio', 'autoFitAxes'
        ].forEach(function (k) { s[k] = !!s[k]; });
      }

      // ====================================================================
      // ====================================================================
      // 2-step / rolling anti-lag BARK is now played by the CONTROLLER through BeamNG's vehicle sound
      // engine (see oeProTune.lua > updateLaunchSound), NOT here. The old UI HTML5 <audio> was unreliable
      // (CEF autoplay locks randomly silenced it, leaving only the stock afterfire) and local-only, so
      // other players in BeamMP never heard it. The volume slider (launchSoundVol) still feeds the
      // controller, which owns the sound now. Kept as a no-op so the telemetry loop's call stays harmless.
      // ====================================================================
      function updateLaunchSound(t) { /* handled by the vehicle controller now (reliable + BeamMP-audible) */ }

      // incoming: telemetry (20 Hz)
      // ====================================================================
      scope.$on('OEProTuneTelemetry', function (event, t) {
        scope.$applyAsync(function () {
          if (!t) { return; }
          // ignore other vehicles' telemetry (BeamMP / traffic / multi-spawn); fails open if ids unknown
          if (typeof t.vehId === 'number' && scope.activeVehId !== null && t.vehId !== scope.activeVehId) { return; }
          lastTelemetryAt = Date.now();
          scope.t = t;
          scope.controlling = !!t.controlling;
          scope.otherTunerActive = !!t.otherTunerActive;
          // AUTO PEAK CAPTURE (V2.6 Build Card): persist the best ECU-measured power per car+engine.
          // Saves only on a new record, so localStorage writes are rare after the first hard pull.
          var _php = Number(t.peakHpSeen) || 0;
          if (_php > 3 && scope.powerRec && _php > (((scope.powerRec.peak || {}).hp) || 0) + 0.5) {
            scope.powerRec.peak = { hp: _php, rpm: Math.round(Number(t.peakHpRpm) || 0), date: new Date().toISOString().slice(0, 10) };
            savePowerRec();
          }
          updateLiveCell(t);
          accumulateMixture(t);
          refreshMixUi(t, false);   // learner stays at 20 Hz; only the fixed display projection is throttled
          accumulateLog(t);
          updateLaunchSound(t);
          tutorialTick();
          challengeTick(t);   // challenge scoring + the engine-death watchdog
          drawDials();   // analog gauge needles follow telemetry (self-gates on page + style)
          if (!welcomeChecked) {
            welcomeChecked = true;
            try { if (localStorage.getItem('oep_seen_welcome') !== '1') { scope.showWelcome = true; } } catch (e) {}
          }
        });
      });

      scope.$on('OEProTuneKnock', function (event, d) {
        scope.$applyAsync(function () {
          // another car's knock (BeamMP / traffic / multi-spawn) must not flash OUR screen; fails open
          if (d && typeof d.vehId === 'number' && scope.activeVehId !== null && d.vehId !== scope.activeVehId) { return; }
          scope.knockFlash = true;
          if (scope.knockFlashTimer) { clearTimeout(scope.knockFlashTimer); }
          scope.knockFlashTimer = setTimeout(function () { scope.$applyAsync(function () { scope.knockFlash = false; }); }, 900);
        });
      });
      scope.$on('OEProTuneBlown', function (event, d) {
        scope.$applyAsync(function () {
          // don't announce a FOREIGN car's grenade as ours (BeamMP / traffic); fails open
          if (d && typeof d.vehId === 'number' && scope.activeVehId !== null && d.vehId !== scope.activeVehId) { return; }
          setToast('ENGINE DESTROYED: ' + (d && d.reason || 'detonation'));
        });
      });

      // Browser-only v2 session/command feedback. The in-game instance never receives
      // these targeted peer events, but sharing the bindings keeps app.html simple.
      scope.$on('OEPRemoteSession', function (event, d) {
        scope.$applyAsync(function () {
          d = d || {};
          scope.remoteClient.authenticated = !!d.authenticated;
          scope.remoteClient.writerConnected = !!d.writerConnected;
          if (d.message !== undefined) { scope.remoteClient.message = String(d.message || ''); }
        });
      });
      scope.$on('OEPRemoteAuth', function (event, d) {
        scope.$applyAsync(function () {
          d = d || {};
          var phase = String(d.phase || '');
          if ((phase === 'claimed' || phase === 'paired') && d.ok === true) { scope.remoteClient.authenticated = true; }
          else if (d.ok === false) { scope.remoteClient.authenticated = false; }
          scope.remoteClient.writerConnected = scope.remoteClient.authenticated || !!d.writerConnected;
          scope.remoteClient.message = String(d.message || (typeof d.error === 'string' ? d.error : '') || d.errorCode || (scope.remoteClient.authenticated ? 'WRITER ACTIVE' : 'READ ONLY'));
        });
      });
      scope.$on('OEPRemoteCommandResult', function (event, d) {
        scope.$applyAsync(function () {
          d = d || {};
          scope.remoteClient.commandPhase = String(d.phase || (d.ok === false ? 'rejected' : ''));
          scope.remoteClient.commandMessage = String(d.message || (typeof d.error === 'string' ? d.error : '') || (d.error && d.error.message) || d.errorCode || scope.remoteClient.commandPhase || '');
          if (IS_PRO_STUDIO && scope.remoteClient.commandPhase === 'applied') {
            if (d.method === 'loadSavedTune') { setToast('Saved tune loaded into the live ECU'); }
            else if (d.method === 'applyPreset') { setToast('Preset loaded into the live ECU'); }
            else if (d.method === 'factoryReset') { setToast('Full factory reset applied'); }
          } else if (IS_PRO_STUDIO && scope.remoteClient.commandPhase === 'rejected') {
            // V3.0.9: this used to require d.method to be one of five hard-coded names. Any OTHER
            // rejected command - armDyno being the one a user actually hit - produced no toast, no
            // ribbon they were looking at, and no clue: the button simply did nothing. An allowlist of
            // things worth explaining is the wrong shape; a rejection is always worth explaining.
            var commandError = String(d.errorCode || (typeof d.error === 'string' ? d.error : '') || scope.remoteClient.commandMessage || '');
            var rejectMsg;
            if (commandError.indexOf('working_map_unsaved') !== -1) {
              rejectMsg = 'Save or Revert working tune on the game PC first';
            } else if (commandError.indexOf('dangerous_disabled') !== -1) {
              // The single most confusing rejection: the writer lease is held, so everything LOOKS
              // enabled, but operational commands need a separate consent click on the game PC - and
              // that consent resets on every writer claim/release.
              rejectMsg = 'Press "Allow dangerous commands" on the game PC - the browser has editing access but not permission for this action';
            } else {
              rejectMsg = 'Action rejected: ' + (scope.remoteClient.commandMessage || 'enable remote editing/reset access on the game PC');
            }
            // De-dupe: a read-only client can generate a rejection per edit, and setToast would
            // otherwise re-arm its 4s timer forever on the same sentence.
            if (rejectMsg !== lastRejectToast.msg || (Date.now() - lastRejectToast.at) > 3000) {
              lastRejectToast = { msg: rejectMsg, at: Date.now() };
              setToast(rejectMsg);
            }
          }
        });
      });
      scope.$on('OEPRemoteTuneCatalog', function (event, d) {
        if (!IS_PRO_STUDIO) { return; }
        scope.$applyAsync(function () {
          d = d || {};
          if (typeof d.vehicleId === 'number' && scope.activeVehId !== null && d.vehicleId !== scope.activeVehId) { return; }
          var entries = Array.isArray(d.entries) ? d.entries : [];
          scope.remoteTuneCatalog = { generation: Number(d.catalogGeneration) || 0, entries: entries.slice() };
          var slots = [0, 1, 2, 3].map(function (index) { return { index: index, saved: false, name: 'Empty' }; });
          var library = [], files = [];
          entries.forEach(function (entry) {
            if (!entry || typeof entry.index !== 'number') { return; }
            var name = String(entry.name || 'Saved tune');
            if (entry.source === 'slot' && entry.index >= 0 && entry.index < slots.length) {
              slots[entry.index] = { index: entry.index, saved: true, name: name, remoteSource: 'slot' };
            } else if (entry.source === 'library') {
              library.push({ index: entry.index, name: name, remoteSource: 'library' });
            } else if (entry.source === 'file') {
              files.push({ index: entry.index, name: name, remoteSource: 'file' });
            }
          });
          scope.slots = slots;
          scope.library = library;
          scope.shareFiles = files;
        });
      });

      // ====================================================================
      // navigation
      // ====================================================================
      scope.navTree = [
        { title: 'Learn', items: [
          { id: 'tutorial', label: 'Tutorial / Lessons' },
          { id: 'challenges', label: 'Challenges' },
          { id: 'diagnose', label: 'Help Me (Tune Doctor)' }
        ] },
        { title: 'Runtime', items: [
          { id: 'runtime', label: 'Runtime Values' },
          { id: 'gauges', label: 'Runtime Gauges' },
          { id: 'dyno', label: 'Dyno / AFR' },
          { id: 'buildcard', label: 'Build Card' },
          { id: 'logging', label: 'Log Analysis' },
          { id: 'log_navigator', label: 'Log Navigator' }
        ] },
        { title: 'ECU Settings', items: [
          { id: 'tunes', label: 'Tune Files' },
          { id: 'notes', label: 'Calibration Notes' },
          { id: 'sensor_setup', label: 'Sensor Setup' },
          { id: 'axis_setup', label: 'Axis Setup' },
          { id: 'tune_compare', label: 'Tune Compare' },
          { id: 'limits', label: 'Limiters' },
          { id: 'protection', label: 'Protection' },
          { id: 'faults', label: 'Faults' }
        ] },
        { title: 'Fuel', items: [
          { id: 'fuel_setup', label: 'Fuel Setup' },
          { id: 'fuel', label: 'Fuel Main', map: true },
          { id: 'lambda', label: 'Lambda Target', map: true },
          { id: 've', label: 'VE Table', map: true },
          { id: 'cll', label: 'Closed Loop Lambda' },
          { id: 'cyl_trims', label: 'Individual Cyl Trims' },
          { id: 'mixture_map', label: 'Mixture Map' },
          { id: 'startup_warmup', label: 'Startup / Warmup' },
          { id: 'transient_fuel', label: 'Transient Fuel' },
          { id: 'diesel', label: 'Diesel Tuning' }
        ] },
        { title: 'Ignition', items: [
          { id: 'ignition', label: 'Ignition Main', map: true },
          { id: 'knock_control', label: 'Knock Control' }
        ] },
        { title: 'Boost', items: [
          { id: 'boost', label: 'Boost Target', map: true },
          { id: 'wgduty', label: 'Wastegate Duty', map: true },
          { id: 'boost_control', label: 'Boost Control' },
          { id: 'boost_shaping', label: 'Boost by Gear / Speed' }
        ] },
        { title: 'Idle / MotorSport', items: [
          { id: 'idle_control', label: 'Idle Speed Control' },
          { id: 'antilag', label: 'Anti-Lag' },
          { id: 'pops', label: 'Pops & Bangs' },
          { id: 'rot_idle', label: 'Rotational Idle (BETA)' },
          { id: 'nitrous', label: 'Nitrous' }
        ] },
        { title: 'Transmission', items: [
          { id: 'trans', label: 'Transmission' }
        ] },
        { title: 'Cam / Throttle / Trigger', items: [
          { id: 'vvt', label: 'VVT Control' },
          { id: 'ethrottle', label: 'E-Throttle' },
          { id: 'triggers', label: 'Trigger Scope' }
        ] },
        { title: 'Aux / CAN', items: [
          { id: 'can_setup', label: 'CAN Setup' },
          { id: 'aux_outputs', label: 'Aux Outputs' },
          { id: 'remote_dash', label: 'Remote Dashboard' },
          { id: 'help', label: 'Help' }
        ] }
      ];
      if (IS_MIRROR && !IS_PRO_STUDIO) {
        scope.navTree.forEach(function (group) {
          group.items = group.items.filter(function (item) { return item.id !== 'tunes'; });
        });
      }

      scope.pageTabs = [
        { label: 'Configuration', target: 'runtime', match: function (p) { return ['runtime', 'sensor_setup', 'can_setup', 'aux_outputs', 'remote_dash', 'faults', 'protection', 'limits', 'triggers', 'notes', 'tune_compare', 'axis_setup'].indexOf(p) >= 0; } },
        { label: 'Tuning', target: null, match: function (p) { return scope.isMapPage(p) || ['fuel_setup', 'cll', 'cyl_trims', 'startup_warmup', 'transient_fuel', 'boost_control', 'boost_shaping', 'knock_control', 'idle_control', 'antilag', 'pops', 'rot_idle', 'nitrous', 'diesel', 'vvt', 'ethrottle', 'dyno', 'buildcard', 'gauges', 'tunes'].indexOf(p) >= 0; } },
        { label: 'Logging', target: 'logging', match: function (p) { return p === 'logging' || p === 'log_navigator'; } },
        { label: 'Mixture Map', target: 'mixture_map', match: function (p) { return p === 'mixture_map'; } }
      ];

      scope.isMapPage = function (p) { return p === 'fuel' || p === 'ignition' || p === 'boost' || p === 'lambda' || p === 've' || p === 'wgduty'; };
      // live AFR readout helpers (DISPLAY ONLY - read telemetry + the stoich the fuel uses; write nothing)
      scope.afrLive = function () {
        var t = scope.t || {};
        if (t.isDiesel) { return (Number(t.afr) || 0).toFixed(1); }
        var st = (scope.settings && Number(scope.settings.stoichRatio)) || 14.7;
        return ((Number(t.lambdaActual) || 1) * st).toFixed(1);
      };
      scope.afrClass = function () {
        var t = scope.t || {};
        if (t.isDiesel) { return {}; }
        var d = (Number(t.lambdaActual) || 1) - (Number(t.lambdaTarget) || 1);
        return { bad: d > 0.05, rich: d < -0.05 };   // bad = lean of target (risky), rich = rich of target
      };
      // A range input clamps its thumb to its OLD min/max (the 0-100 default) if ngModel renders the
      // value before ng-attr-min/max have populated. So a slider whose value sits outside 0-100 (e.g.
      // Flat-shift RPM ~6500, Launch limit ~5500) can show its thumb pinned at the minimum until you
      // nudge it. After a settings page renders, re-assert each slider's DOM value from the model on the
      // NEXT frame (min/max are in the DOM by then), so the thumb snaps to the right spot. The model is
      // already correct (this only fixes the visual thumb), and setting .value in JS doesn't fire input.
      function resyncSliders() {
        if (typeof window === 'undefined' || !window.requestAnimationFrame) { return; }
        window.requestAnimationFrame(function () {
          var ctrls = element[0].querySelectorAll('.oep-ctrl[data-tut^="set:"]');
          for (var i = 0; i < ctrls.length; i++) {
            var inp = ctrls[i].querySelector('input[type=range]');
            if (!inp) { continue; }
            var v = scope.settings[ctrls[i].getAttribute('data-tut').slice(4)];
            if (v !== undefined && v !== null && !isNaN(v)) { inp.value = v; }
          }
        });
      }
      scope.setPage = function (id) {
        if (!id) { id = scope.selectedMap; }
        scope.gridArmed = null;   // V3.0.7: a half-armed resolution change must not survive navigation
        if (IS_MIRROR && !IS_PRO_STUDIO && id === 'tunes') { setToast('Open Tune is available in Pro Studio'); id = 'runtime'; }
        if (id !== 'remote_dash' && !scope.hideRemoteAddr) {
          scope.hideRemoteAddr = true;
          if (remoteAddrHideTimer) { clearTimeout(remoteAddrHideTimer); remoteAddrHideTimer = null; }
        }
        scope.navSel = id;   // nav highlight follows EVERY navigation (incl. tutorial overlay + the map editors), decoupled from activePage so it can never get stuck
        if (id === 'tutorial') { scope.openTutorialMenu(); return; }
        scope.activePage = id;
        // V3.0.9: remember where the user is. The early return above means 'tutorial' is never stored,
        // which is what we want - it opens a menu rather than navigating. Everything else is stored as
        // navigated and filtered on the way back in (see restoreLastPage at the end of link).
        try { localStorage.setItem('oep_page', id); } catch (e) {}
        if (scope.isMapPage(id)) {
          scope.selectedMap = id; scope.clearSelection(); scope.redrawSurface();
          try { localStorage.setItem('oep_map', id); } catch (e) {}
          // Δ left on while switching tables: auto-baseline the new table too, so the amber button
          // never lies (previously the grid silently fell back to absolute values until re-toggled)
          if (scope.showDelta && !scope.curBaseline() && scope.curMap()) {
            scope.baselineMaps[id] = scope.curMap().map(function (row) { return row.slice(); });
            setToast('Δ view - baseline auto-saved for this table');
          }
          if (IS_PRO_STUDIO) { setTimeout(function () { draw3d(); drawDials(); }, 70); }
        }
        if (id === 'mixture_map') { refreshMixUi(scope.t, true); }
        if (id === 'axis_setup' && !axisDraftEditing()) { refreshAxisDrafts(); }
        if (id === 'dyno') { scope.redrawDyno(); }
        if (id === 'logging') { scope.redrawLog(); }
        // Build Card open: run the full pretty-name scan once per spawn (this is where the heavy
        // mod scan now lives - user-initiated and cached, instead of freezing every vehicle entry)
        if (id === 'buildcard' && !scope.build.namesLoaded) { scope.refreshBuild(true); }
        if (scope.pageDefs && scope.pageDefs[id]) { resyncSliders(); }   // fix range thumbs after the settings page renders
        if (IS_MIRROR && window.parent && window.parent !== window) {
          try { window.parent.postMessage({ type: 'oep-studio-page', page: id }, window.location.origin); } catch (e) {}
        }
      };
      if (IS_MIRROR && window.addEventListener) {
        var allowedStudioPages = { diagnose: true };
        scope.navTree.forEach(function (group) { group.items.forEach(function (item) { allowedStudioPages[item.id] = true; }); });
        studioMessageHandler = function (event) {
          if (event.source !== window.parent || event.origin !== window.location.origin) { return; }
          var data = event.data;
          if (!data || data.type !== 'oep-studio-nav' || !allowedStudioPages[data.page]) { return; }
          scope.$applyAsync(function () { scope.setPage(data.page); });
        };
        window.addEventListener('message', studioMessageHandler, false);
        try { window.parent.postMessage({ type: 'oep-studio-ready' }, window.location.origin); } catch (e) {}
      }
      scope.tabActive = function (tab) { return tab.match(scope.activePage); };
      scope.setTab = function (tab) { scope.setPage(tab.target || scope.selectedMap); };

      scope.toggleCollapse = function () {
        scope.uiCollapsed = !scope.uiCollapsed;
        // Persist BEFORE touching the DOM: this is the only record that outlives the scope, and the
        // next parts-menu visit destroys the scope (see applyCollapsed's note near the top).
        try { localStorage.setItem('oep_collapsed', scope.uiCollapsed ? '1' : '0'); } catch (e) {}
        // drive the collapsed state directly on the DOM (synchronous, immune to
        // Angular binding/digest quirks) - everything else keys off this class.
        // The directive uses replace:true, so element[0] IS the .oep-root div;
        // fall back to a descendant lookup just in case that ever changes.
        applyCollapsed();
      };

      // menu bar
      scope.menu = [
        { label: 'File', go: 'tunes' },
        { label: 'Options', go: 'sensor_setup' },
        { label: 'ECU Controls', go: 'runtime' },
        { label: 'Display', go: 'gauges' },
        { label: 'Logging', go: 'logging' },
        { label: 'Tuning', go: 'fuel' },
        { label: 'Help', go: 'help' }
      ];
      if (IS_MIRROR && !IS_PRO_STUDIO) { scope.menu = scope.menu.filter(function (m) { return m.go !== 'tunes'; }); }

      // ====================================================================
      // settings pages (data-driven generic renderer)
      // ====================================================================
      var SEL = {
        fuelType: ['Pump Gas', 'E85', 'E30', 'Race Gas C16', 'Methanol M1'],
        // 1-based to match scope.selOpt writing idx+1 / scope.selActive comparing idx+1.
        limiterCutMode: ['Ignition', 'Fuel'],
        boostMode: ['Open loop', 'Closed loop', 'Closed (legacy 60/80%)'],
        fuelEquationMode: ['Traditional', 'Modelled'],
        loadSource: ['Speed Density (MAP)', 'Alpha-N (TPS)'],
        cllMode: ['Off', 'Stoich only', 'Always on target'],
        ethrottleMode: ['Cable throttle', 'E-Throttle'],
        ethrottleCurve: ['Linear', 'Progressive', 'Aggressive'],
        triggerPattern: ['Good pattern', 'Noisy crank signal', 'Missing cam sync'],
        canMode: ['Off', 'Generic Dash', 'Link CAN Lambda'],
        popSoundMode: ['Both (OE + car)', 'Car\'s own only', 'OE samples only'],
        fxStyle: ['BeamNG stock', 'OE Realistic', 'OE Show / Coal'],
        wgType: ['Pneumatic (spring)', 'Electric gate'],
        clutchUpgrade: ['Fitted clutch', 'Street - 500 Nm', 'Performance - 800 Nm', 'Twin-disc - 1300 Nm', 'Triple-disc - 2200 Nm'],
        canSpeed: ['250 kbit/s', '500 kbit/s', '1 Mbit/s']
      };
      // hintFn/warnFn (V3.0.9): optional live caption rendered in the slider's OWN label row. Added for
      // the rev limit, where the bound is a property of the fitted engine rather than a constant, so a
      // static min/max in the spec cannot express it. Both are called per digest; keep them cheap.
      function sld(key, label, mn, mx, st, fmt, hintFn, warnFn) { return { type: 'slider', key: key, label: label, min: mn, max: mx, step: st, fmt: fmt || '', hint: hintFn || null, hintWarn: warnFn || null }; }
      // conditional control: only rendered while showFn() is true (e.g. Cyl 7/8 trims on V8+ engines)
      function sldIf(showFn, key, label, mn, mx, st, fmt) { var c = sld(key, label, mn, mx, st, fmt); c.show = showFn; return c; }
      scope.ctrlShow = function (ctrl) { return !ctrl.show || !!ctrl.show(); };
      // 7+ cylinders parsed from the engine part name; the controller gates trims 7/8 on the SAME
      // engSpec.cyl (via setEngineSpec), so a slider is only shown when it actually does something
      var showCyl78 = function () { return ((scope.engineSpec && scope.engineSpec.cyl) || 0) >= 7; };
      var showFrictionClutch = function () { return !scope.base || scope.base.hasFrictionClutch !== false; };
      function chk(key, label) { return { type: 'check', key: key, label: label }; }
      // V3.1: selects now carry the same optional hint/warn pair sliders have had, and the select
      // branch in app.html renders it. Without this a select could be given a hint that silently
      // went nowhere - extra args were simply dropped on the floor.
      function sel(key, label, hintFn, warnFn) { return { type: 'select', key: key, label: label, options: SEL[key], hint: hintFn || null, hintWarn: warnFn || null }; }
      function selIf(showFn, key, label) { var c = sel(key, label); c.show = showFn; return c; }
      function ro(label, tkey, unit, digits) { return { type: 'readout', label: label, tkey: tkey, unit: unit || '', digits: digits === undefined ? 1 : digits }; }
      function roIf(showFn, label, tkey, unit, digits) { var c = ro(label, tkey, unit, digits); c.show = showFn; return c; }
      function note(text) { return { type: 'note', text: text }; }
      scope.ctrlHint = function (ctrl) { return (ctrl && typeof ctrl.hint === 'function') ? ctrl.hint() : ''; };
      scope.ctrlHintWarn = function (ctrl) { return !!(ctrl && typeof ctrl.hintWarn === 'function' && ctrl.hintWarn()); };

      // ================================================================================
      // V3.0.9 REV-LIMIT CEILING, WHERE YOU CAN ACTUALLY SEE IT
      // The ceiling has been rendered on this page since 3.0.6 as the 'Raise ceiling' readout, and it
      // did not stop a single confused report. It sits below fourteen sliders, so at the moment you are
      // dragging the rev limit it is scrolled off screen, and nothing fires when your number gets
      // clamped. Meanwhile the slider advertises a hardcoded 0-12000 range that has nothing to do with
      // the engine. So the fix is not another readout - it is putting the bound on the control itself.
      // Deliberately NOT clamping the slider to the ceiling: Step 2 (valve float) wants you to be able
      // to ASK for more and have the engine answer for it, so this caption becomes "floats above N"
      // rather than being deleted.
      // ================================================================================
      scope.revCeilingRpm = function () {
        // base.* rides the STATE message and survives a telemetry stall; t.revLimitCeiling is only the
        // live echo and blanks to nothing when telemetry pauses. Prefer the durable one.
        if (!scope.connected) { return 0; }   // the offline placeholder base carries a made-up limiter
        var b = scope.base || {}, t = scope.t || {};
        var v = Number(b.revLimitCeilingRPM) || Number(t.revLimitCeiling) || Number(b.revLimiterRPM) || 0;
        return v > 0 ? Math.round(v) : 0;
      };
      // Where the valves start to float. Same durability rule as the ceiling: base.* first.
      scope.revFloatRpm = function () {
        if (!scope.connected) { return 0; }
        var b = scope.base || {}, t = scope.t || {};
        var v = Number(b.floatRPM) || Number(t.valveFloatRPM) || 0;
        return v > 0 ? Math.round(v) : 0;
      };
      scope.revLimitOverCeiling = function () {
        // V3.0.9: "over" now means "into valve float", not "impossible". The ceiling is the top of the
        // float band, so a request between float and ceiling is ALLOWED - it just wrecks the head.
        var f = scope.revFloatRpm(), want = Number((scope.settings || {}).revLimit) || 0;
        return f > 0 && want > f;   // 0 means "stock", never over
      };
      scope.revLimitHint = function () {
        var f = scope.revFloatRpm(), c = scope.revCeilingRpm();
        if (!f && !c) { return ''; }
        var want = Number((scope.settings || {}).revLimit) || 0;
        if (c > 0 && want > c) { return scope.tr('limited to') + ' ' + c + ' rpm'; }
        if (f > 0) { return scope.tr('valve float above') + ' ' + f + ' rpm'; }
        return scope.tr('max') + ' ' + c + ' rpm';
      };

      scope.pageDefs = {
        sensor_setup: { title: 'Sensor Setup', help: 'Calibrates what the ECU sees. MAP is an honest load/boost model because BeamNG does not expose a universal manifold-pressure sensor channel. Barometric pressure is read live from the environment.', controls: [
          sld('sensorMapOffset', 'MAP offset', -30, 30, 0.5, ' kPa'), sld('sensorMapScale', 'MAP scale', 0.85, 1.15, 0.005, ''),
          ro('Modeled raw MAP', 'rawMapKpa', ' kPa', 1), ro('ECU MAP (modeled)', 'mapKpa', ' kPa', 1),
          ro('Barometric pressure', 'baroKpa', ' kPa', 1), ro('Estimated vacuum', 'vacuumInHg', ' inHg', 1),
          sld('sensorTpsOffset', 'TPS offset', -10, 10, 0.5, ' %'), sld('sensorTpsScale', 'TPS scale', 0.85, 1.15, 0.005, ''),
          ro('Raw TPS', 'rawTps', ' %', 0), ro('ECU TPS', 'tps', ' %', 0),
          sld('sensorIatBias', 'IAT bias', -20, 20, 0.5, ' C'), sld('sensorEctBias', 'ECT bias', -20, 20, 0.5, ' C'),
          sld('sensorLambdaBias', 'Lambda bias', -0.08, 0.08, 0.005, ''), sld('sensorFuelPressureBias', 'Fuel pressure bias', -80, 80, 5, ' kPa'),
          ro('ECU IAT', 'iatC', ' C', 0), ro('ECU ECT', 'ectC', ' C', 0), ro('True lambda', 'trueLambda', '', 3), ro('ECU lambda', 'lambdaActual', '', 3)
        ] },
        fuel_setup: { title: 'Fuel Setup', help: 'Choose the fuel equation and load source independently. Speed Density indexes the main fuel, ignition, lambda and VE tables by MAP. Alpha-N indexes those tables by TPS; boost control remains MAP-based. Existing tunes default to Speed Density.', controls: [
          sel('fuelType', 'Fuel type'), sel('fuelEquationMode', 'Fuel equation'), sel('loadSource', 'Load source'),
          note('Alpha-N is best for engines with unstable manifold vacuum (large cams or individual throttle bodies). Switching changes what every row means, so re-tune the affected tables. On boosted engines, watch MAP/boost carefully; pure Alpha-N cannot distinguish two different boost pressures at the same throttle angle. Diesel remains MAP-based.'),
          sld('masterFuel', 'Master fuel', 3, 20, 0.1, ' ms'),
          sld('injectorFlow', 'Injector flow', 200, 6000, 10, ' cc'), sld('injectorDeadtime', 'Injector deadtime', 0.2, 2.5, 0.05, ' ms'),
          sld('stoichRatio', 'Stoich AFR', 6, 16, 0.1, ''),
          ro('Injector duty', 'injectorDuty', ' %', 0), ro('Inj pulse', 'injPulseMs', ' ms', 2),
          ro('Rail pressure', 'fuelPressureKpa', ' kPa', 0), ro('Diff pressure', 'fuelDiffPressureKpa', ' kPa', 0),
          ro('Active load strategy', 'loadSource'), ro('Active table load', 'tuneLoad', '', 1), ro('Injector match', 'injMatchStatus')
        ] },
        cll: { title: 'Closed Loop Lambda', help: 'STFT quickly corrects the remaining lambda error. LTFT learns that same total correction and gradually replaces STFT instead of stacking another copy. ECT, TPS, MAP and RPM lockouts hold the system open-loop.', controls: [
          sel('cllMode', 'CLL mode'), sld('cllEctLockout', 'ECT lockout', 30, 95, 1, ' C'),
          sld('cllTpsLockout', 'TPS lockout', 10, 100, 1, ' %'), sld('cllMapLockout', 'MAP lockout', 100, 500, 5, ' kPa'),
          ro('CLL status', 'closedLoopStatus'), ro('STFT', 'stft', ' %', 1), ro('LTFT', 'ltft', ' %', 1)
        ] },
        cyl_trims: { title: 'Individual Cylinder Trims', help: 'Per-cylinder fuel trim. A wide spread destabilises combustion and raises misfire risk.', controls: [
          sld('cylTrim1', 'Cyl 1', -10, 10, 0.5, ' %'), sld('cylTrim2', 'Cyl 2', -10, 10, 0.5, ' %'), sld('cylTrim3', 'Cyl 3', -10, 10, 0.5, ' %'),
          sld('cylTrim4', 'Cyl 4', -10, 10, 0.5, ' %'), sld('cylTrim5', 'Cyl 5', -10, 10, 0.5, ' %'), sld('cylTrim6', 'Cyl 6', -10, 10, 0.5, ' %'),
          sldIf(showCyl78, 'cylTrim7', 'Cyl 7', -10, 10, 0.5, ' %'), sldIf(showCyl78, 'cylTrim8', 'Cyl 8', -10, 10, 0.5, ' %'),
          ro('Trim avg', 'cylTrimAvg', ' %', 2), ro('Trim spread', 'cylTrimSpread', ' %', 2), ro('Misfire', 'misfireStatus')
        ] },
        startup_warmup: { title: 'Startup / Warmup', help: 'Cranking, post-start and warmup fuel. Too little = lean cold stumble; too much = rich fouling.', controls: [
          sld('crankEnrich', 'Cranking enrich', -20, 60, 1, ' %'), sld('postStartEnrich', 'Post-start enrich', -10, 35, 1, ' %'),
          sld('postStartDecay', 'Post-start decay', 2, 45, 1, ' s'), sld('warmupEnrich', 'Warmup enrich', -10, 35, 1, ' %'), sld('warmupEndTemp', 'Warmup ends', 40, 95, 1, ' C'),
          ro('Startup total', 'startupFuelTrim', ' %', 1), ro('ECU ECT', 'ectC', ' C', 0), ro('Lambda', 'lambdaActual', '', 3), ro('Misfire', 'misfireStatus')
        ] },
        transient_fuel: { title: 'Transient Fuel', help: 'Throttle tip-in and lift-off fuel. Bad values cause bog, lean hesitation, rich stumble or harsh decel cut.', controls: [
          sld('accelEnrich', 'Accel enrich', 0, 25, 0.5, ' %'), sld('accelDecay', 'Accel decay', 0.05, 2, 0.05, ''), sld('wallWetting', 'Wall wetting', 0, 1, 0.05, ''),
          chk('decelFuelCut', 'Decel fuel cut'), sld('decelFuel', 'Lift-off fuel', -35, 0, 1, ' %'),
          ro('TPS delta', 'tpsDelta', ' %/s', 0), ro('Lambda', 'lambdaActual', '', 3), ro('Misfire', 'misfireStatus')
        ] },
        boost_control: { title: 'Boost Control', help: 'TURBO: the Boost Target table sets the base aim and Closed Loop chases the final target. Configure realistic additive target trims and percentage feed-forward trims on Boost by Gear / Speed. The third loop option preserves old 60/80% tunes only. SUPERCHARGER: boost is mechanical (your pulley), so these gains do nothing and Boost Target is only a ceiling.', controls: [
          sel('boostMode', 'Boost mode'), sel('wgType', 'Wastegate type'),
          ro('Turbo max (read/learned)', 'turboMaxPsi', ' psi', 1),
          sld('boostBaseDuty', 'Base duty', 0, 100, 1, ' %'), sld('boostPGain', 'Proportional gain', 0, 2, 0.05, ''),
          sld('boostIGain', 'Integral gain', 0, 0.5, 0.01, ''), sld('boostOvercut', 'Overboost cut', 5, 60, 1, ' kPa'),
          ro('Base target', 'boostBaseTargetKpa', ' kPa', 0), ro('Final target', 'boostTargetKpa', ' kPa', 0), ro('Boost (MAP)', 'mapKpa', ' kPa', 0),
          ro('Boost error', 'boostError', ' kPa', 0), ro('Feed-forward duty', 'boostFeedforwardDuty', ' %', 1), ro('Wastegate duty', 'wastegateDuty', ' %', 0),
          // V2.9.7 wave 4 (Liqurdz's BBG report): loop breakdown - psi contribution of each closed-loop term
          ro('Loop P', 'boostPTermPsi', ' psi', 1), ro('Loop D', 'boostDTermPsi', ' psi', 1),
          ro('Loop I', 'boostITermPsi', ' psi', 1), ro('Loop bias+FF', 'boostBiasPsi', ' psi', 1),
          ro('Shaping', 'boostShapeStatus'), ro('Status', 'boostControlStatus')
        ] },
        knock_control: { title: 'Knock Control', help: 'Knock detection retards timing to protect the engine. Too high a threshold lets real knock through.', controls: [
          chk('knockControl', 'Knock control enabled'), chk('knockAudio', 'Knock & damage sounds'), sld('knockThreshold', 'Knock threshold', 0.4, 3.5, 0.05, ''),
          sld('knockMaxRetard', 'Max retard', 0, 12, 0.5, ' deg'), sld('knockRecoveryRate', 'Recovery rate', 0.05, 1, 0.05, ''),
          ro('Knock level', 'knockLevel', '', 2), ro('Knock retard', 'knockRetard', ' deg', 1), ro('Knock count', 'knockCount', '', 0)
        ] },
        idle_control: { title: 'Idle Speed Control', help: 'Closed-loop idle valve position plus optional ignition correction to hold the target idle.', controls: [
          chk('idleEnabled', 'Idle control enabled'), sld('idleTargetRpm', 'Target idle', 600, 1800, 10, ' rpm'),
          sld('idleBasePos', 'Base valve pos', 5, 80, 1, ' %'), chk('idleIgnControl', 'Idle ignition control'),
          ro('Idle status', 'idleStatus'), ro('Valve pos', 'idleValvePos', ' %', 0), ro('Idle error', 'idleError', ' rpm', 0)
        ] },
        antilag: { title: 'Anti-Lag', help: 'Lift off above ~3200 rpm and OE models exhaust heat, cracks and occasional flame. Retard + Extra fuel set how hard it works; when the fitted turbo exposes BeamNG native anti-lag control, OE requests it, but actual spool and boost remain vehicle-dependent. OE Realistic keeps warmed gasoline to brief pale haze, while OE Show / Coal deliberately exaggerates the cloud. Turbo only, hard on the engine.', controls: [
          chk('antilagEnabled', 'Anti-lag enabled'), sld('antilagRetard', 'Retard', 5, 35, 1, ' deg'), sld('antilagFuel', 'Extra fuel', 0, 30, 1, ' %'),
          ro('Status', 'antilagStatus'), ro('Exhaust heat', 'antilagHeat', ' %', 0)
        ] },
        pops: { title: 'Pops & Bangs', help: 'Backfire / burble tuning, the real way. Turn OFF decel fuel cut, richen the overrun vacuum rows of Lambda Target, and retard ignition there. Most real bangs are sound and pressure with only a faint haze; visible flame is occasional. OE Realistic follows fuel, lambda and misfire conditions. OE Show / Coal keeps the old dense-cloud look for screenshots.', controls: [
          sld('overrunMinRpm', 'Pops above this RPM', 1500, 7000, 100, ' rpm'),
          sld('overrunThrottle', 'Counts as lift below TPS', 2, 20, 1, ' %'),
          sel('popSoundMode', 'Pop sound'),
          sel('fxStyle', 'Flame & smoke profile'),
          sld('smokeDensity', 'Smoke amount', 0, 2, 0.1, 'x'),
          sld('overrunPopVol', 'Pop sound volume', 0, 10, 1, ''),
          ro('Status', 'overrunStatus'),
          ro('Pop strength (from your tune)', 'overrunPopStrength', ' %', 0),
          note('To MAKE it pop - lift off above the Min RPM, then: (1) Transient Fuel: turn OFF "Decel fuel cut". (2) Lambda Target: richen the TOP vacuum rows (negative psi, e.g. -9 / -3 = off-throttle) toward ~0.80. (3) Ignition Main: retard those same top vacuum rows (go negative). Richer + more retard = bigger bangs - watch "Pop strength" climb (you must be above the Min RPM AND off the throttle for it to read). Scaled by "Pops / flames intensity" on Limiters. Any engine - NA cars pop too. Warm engine.')
        ] },
        rot_idle: { title: 'Rotational Idle (BETA)', help: 'BETA - the chop feel varies per engine and will keep being tuned; expect rough edges and tell us what your car does. The drag-scene "rowdy idle": rhythmic ignition cuts at a standstill make the engine lope, surge and rock the car. Like real life it is ECU-made, not a cam - and like the pops it comes from your TUNE: retard the ignition hard in the IDLE cells and richen the idle lambda a touch, then arm it here, stop the car and get off the throttle. Barks and flames are OCCASIONAL, like the real thing - the lope is constant, the pop is the treat. The chop modulates the engine\'s own sound, so cars with good sound mods chop in their own voice.', controls: [
          chk('rotIdleEnabled', 'Rotational idle armed'),
          sld('rotIdleRpm', 'Idle target while chopping', 900, 2000, 25, ' rpm'),
          sld('rotIdleChop', 'Chop depth', 1, 10, 1, ''),
          ro('Status', 'rotIdleStatus'),
          ro('Chop strength (from your tune)', 'rotIdleStrength', ' %', 0),
          note('Workflow: (1) Ignition Main - retard the bottom-left (idle rpm, vacuum) cells hard, -10 or more. (2) Lambda Target - richen those same cells toward ~0.85. (3) Arm above, come to a stop, feet off. "Chop strength" shows how hard your tune chops; "Chop depth" sets how deep each cut hits (too deep can stall weak engines - back it off if it dies). BARKS ARE OCCASIONAL like real life: they need a HOT tune (strength well past ~55%) and even then only crack off every few seconds - the constant part is the lope, not the pops. Bark volume follows "Pop sound volume" on Pops & Bangs; in Pop sound "Car\'s own only" mode the chop is voiced by the engine alone.')
        ] },
        nitrous: { title: 'Nitrous', help: 'ECU control AROUND a real N2O kit. The KIT makes the power - its bottle and jets are a PART (fit/size it in the vehicle Parts menu), and shot size comes from the part, not a slider. The ECU handles everything around the spray: extra fuel and pulled timing inside your enrichment window. If the kit sprays with enrichment off or outside the window, the shot goes LEAN - watch Knock and EGT. Arm the bottle with the car\'s own N2O control; the readouts below come from the real hardware.', controls: [
          chk('nitrousEnabled', 'Nitrous ECU enrichment'),
          sld('nitrousArmRpm', 'Enrichment from RPM', 2000, 7500, 100, ' rpm'), sld('nitrousArmTps', 'Enrichment from TPS', 50, 100, 1, ' %'),
          sld('nitrousFuel', 'Extra fuel', 0, 40, 1, ' %'), sld('nitrousRetard', 'Timing pull', 0, 15, 0.5, ' deg'),
          ro('Kit rating (installed part)', 'nitrousKitHp', ' hp', 0),
          ro('Bottle level', 'nitrousTank', ' %', 0),
          ro('Arm switch (car)', 'nitrousArmed'),
          ro('Status', 'nitrousStatus')
        ] },
        diesel: { title: 'Diesel Tuning', help: 'For compression-ignition (DIESEL) engines - auto-detected (vanilla diesels + named mods like Cummins / Power Stroke / TDI). Diesel has NO spark and NO lambda target: you tune FUEL QUANTITY to a SMOKE LIMIT. More fuel = more power, but only the fuel the air (boost) can burn makes power - the rest is black smoke + EGT. Raise boost AND rail pressure to fuel more cleanly. On a diesel the Fuel Main table is fuel quantity and Ignition Main is injection timing. Watch EGT and Smoke. (Inert on petrol engines - the Mode below reads Off.)', controls: [
          sld('dieselMaxFuel', 'Max fuel', 60, 260, 5, ' %'),
          sld('dieselRailPressure', 'Rail pressure', 300, 2200, 50, ' bar'),
          sld('dieselSmokeLimit', 'Smoke limit (target AFR)', 12, 24, 0.5, ' :1'),
          chk('glowPlugs', 'Glow plugs (cold start)'),
          chk('dieselSmokeLimiter', 'Smoke limiter (ECU clamps fuel to air)'),
          chk('dieselNozzleEnabled', 'Nozzle model (realistic injection)'),
          sld('dieselNozzleFlow', 'Nozzle flow @1200 bar', 200, 6000, 25, ' cc'),
          chk('compBrakeEnabled', 'Compression (Jake) brake'),
          sld('compBrakeLevel', 'Compression brake level', 0, 100, 5, ' %'),
          ro('Mode', 'dieselStatus'),
          ro('Compression brake', 'compBrakeStatus'),
          ro('Nozzle duty', 'dieselNozzleDuty', ' %', 0),
          ro('Limiter fuel cut', 'dieselLimiterCut', ' %', 0),
          ro('AFR (air:fuel)', 'afr', ' :1', 1),
          ro('Smoke', 'smoke', ' %', 0),
          ro('EGT', 'egtC', ' C', 0)
        ] },
        trans: { title: 'Transmission Control', help: 'Real ECU/TCM levers plus finite manual-clutch hardware choices. SHIFT FIRMNESS is line pressure: firmer clamps an automatic faster for a short hard shift; softer slips it longer and creates more heat. CONVERTER STALL applies only to torque-converter automatics; its fluid temperature is an estimate because BeamNG has no ATF channel. TORQUE MANAGEMENT pulls ignition timing while an automatic is shifting. TORQUE LIMIT is an ECU cap that can protect any driveline. On a manual or clutch-equipped sequential, CLUTCH PACKAGE selects a fixed 500/800/1300/2200 Nm rating and keeps BeamNG\'s native heat, wear and permanent damage. It never scales from engine output, so an extreme build can still overpower the strongest package. Fitted/default leaves the vehicle clutch exactly as supplied.', controls: [
          selIf(showFrictionClutch, 'clutchUpgrade', 'Manual clutch package'),
          roIf(showFrictionClutch, 'Clutch status', 'clutchStatus'),
          roIf(showFrictionClutch, 'Fitted capacity', 'clutchFittedCapacityNm', ' Nm', 0),
          roIf(showFrictionClutch, 'Static package capacity', 'clutchStaticCapacityNm', ' Nm', 0),
          roIf(showFrictionClutch, 'Available capacity', 'clutchCapacityNm', ' Nm', 0),
          roIf(showFrictionClutch, 'Engine torque now', 'clutchDemandNm', ' Nm', 0),
          roIf(showFrictionClutch, 'Capacity headroom', 'clutchHeadroomNm', ' Nm', 0),
          roIf(showFrictionClutch, 'Clutch slip', 'clutchSlipRpm', ' rpm', 0),
          roIf(showFrictionClutch, 'Clutch temperature', 'clutchTempC', ' C', 0),
          roIf(showFrictionClutch, 'Clutch condition', 'clutchEfficiencyPct', ' %', 0),
          sld('converterStall', 'Converter stall (0 = fitted converter)', 0, 8000, 100, ' rpm'),
          sld('shiftFirmness', 'Shift firmness (line pressure)', 0, 100, 5, ' %'),
          sld('shiftTorqueMgmt', 'Shift torque management', 0, 100, 5, ' %'),
          sld('torqueLimitNm', 'Torque limit (0 = off)', 0, 6000, 25, ' Nm'),
          chk('torqueLimitLowGears', 'Limit only in gears 1-2'),
          ro('Stall speed', 'converterStall', ' rpm', 0),
          ro('Converter slip', 'converterSlipRpm', ' rpm', 0),
          ro('Slip heat', 'converterSlipKw', ' kW', 1),
          ro('Fluid temp (est.)', 'transTempC', ' C', 0),
          ro('Fluid status', 'transTempStatus'),
          ro('Lockup capacity lost', 'transFadePct', ' %', 0),
          ro('Trans brake', 'transbrake'),
          ro('Shift time', 'shiftTimeMs', ' ms', 0),
          ro('Torque limiter', 'torqueLimitStatus'),
          ro('Gear', 'gearIndex', '', 0),
          note('Manual clutch packages are fixed ratings, not a cheat tied to horsepower. OE never adds more than the 2200 Nm package and never downgrades genuinely stronger vehicle hardware. If available capacity is still too low, reduce torque here or fit vehicle-specific racing transmission hardware. Do not inflate the game\'s native boost slider just to manipulate BeamNG auto-sizing.')
        ] },
        vvt: { title: 'VVT Control', help: 'Intake cam advance targets the midrange for torque, tapering off at idle and the top end.', controls: [
          chk('vvtEnabled', 'VVT enabled'), sld('vvtIntakeAdvance', 'Intake advance', 0, 40, 1, ' deg'),
          ro('VVT target', 'vvtTarget', ' deg', 1), ro('VVT actual', 'vvtActual', ' deg', 1), ro('Status', 'vvtStatus')
        ] },
        ethrottle: { title: 'E-Throttle', help: 'Pedal-to-target curve plus TPS/target safety monitoring. A tracking fault triggers a safety cut.', controls: [
          sel('ethrottleMode', 'Throttle mode'), sel('ethrottleCurve', 'Pedal curve'), sld('ethrottleTolerance', 'Tracking tolerance', 4, 30, 1, ' %'),
          ro('Pedal target', 'ethrottleTarget', ' %', 0), ro('ECU TPS', 'tps', ' %', 0), ro('Status', 'ethrottleStatus')
        ] },
        triggers: { title: 'Trigger Scope', help: 'Crank/cam trigger signal quality and base-timing calibration. In a real ECU this is where you set base timing with a timing light so commanded advance matches the crank; noise or lost sync injects misfire risk and latches a Trigger fault.', controls: [
          sel('triggerPattern', 'Trigger pattern'), ro('Trigger status', 'triggerStatus'),
          sld('baseTiming', 'Base timing', 5, 35, 0.5, ' °BTDC'),
          ro('Displayed ignition advance', 'ignitionAdvance', ' °', 1),
          note('Like PCLink\'s "Set Base Timing": dial in YOUR engine\'s base advance (check its spec - pump-gas NA street engines commonly sit around 16°, boosted engines nearer 12°, and those are the auto defaults). Display calibration only: knock, EGT and power react to your TRIMS, so this never adds power - it makes the advance readout and logs read like the real engine\'s numbers.')
        ] },
        can_setup: { title: 'CAN Setup', help: 'CAN bus transmit setup (training/status only in this trainer).', controls: [
          sel('canMode', 'CAN mode'), sel('canSpeed', 'CAN speed'), chk('canRuntime', 'Transmit runtime'), chk('canLambda', 'Transmit lambda'), chk('canDash', 'Transmit dash')
        ] },
        aux_outputs: { title: 'Aux Outputs', help: 'Fan, fuel pump prime, shift light and check-engine output.', controls: [
          sld('auxFanOnTemp', 'Fan on temp', 80, 115, 1, ' C'), sld('auxFuelPumpPrime', 'Fuel pump prime', 0, 5, 0.5, ' s'),
          sld('auxShiftLight', 'Shift light', 3000, 11500, 100, ' rpm'), chk('auxCheckEngine', 'Check-engine output'),
          ro('ECU ECT', 'ectC', ' C', 0), ro('RPM', 'rpm', ' rpm', 0)
        ] },
        limits: { title: 'Limiters', help: 'Rev limit (0 = stock), 2-step launch limit, flat-foot / no-lift shift, and drivetrain loss. Raising the limiter is bounded by this engine\'s mechanical RPM threshold and torque-curve end; OE over-rev damage no longer keys off the original ECU limiter.', controls: [
          sld('revLimit', 'Rev limit (0=stock)', 0, 12000, 50, ' rpm',
              function () { return scope.revLimitHint(); }, function () { return scope.revLimitOverCeiling(); }),
          chk('hardCut', 'Hard-cut rev limiter (bounce + bangs)'),
          sel('limiterCutMode', 'Limiter cut mode',
              function () { return (scope.settings && scope.settings.hardCut) ? '' : 'hard cut is OFF - this does nothing until you turn it on'; },
              function () { return !(scope.settings && scope.settings.hardCut); }),
          sld('limiterRange', 'Limiter range', 100, 600, 10, ' rpm',
              function () { return (scope.settings && scope.settings.hardCut) ? '' : 'hard cut is OFF - this does nothing until you turn it on'; },
              function () { return !(scope.settings && scope.settings.hardCut); }),
          sld('limiterCutMs', 'Limiter cut duration', 10, 150, 5, ' ms',
              function () { return (scope.settings && scope.settings.hardCut) ? '' : 'hard cut is OFF - this does nothing until you turn it on'; },
              function () { return !(scope.settings && scope.settings.hardCut); }),
          chk('launchEnabled', '2-step launch enabled'),
          sld('launchLimit', 'Launch limit', 2500, 9000, 50, ' rpm',
              function () { return (scope.t && scope.t.launchAboveRevLimit) ? scope.tr('must be BELOW the rev limit or 2-step never arms') : ''; },
              function () { return !!(scope.t && scope.t.launchAboveRevLimit); }),
          chk('flatShiftEnabled', 'Flat-foot shift (no-lift)'), sld('flatShiftRpm', 'Flat-shift RPM', 3000, 11000, 50, ' rpm'),
          chk('rollingEnabled', 'Rolling anti-lag (hold key)'),
          note('Rolling anti-lag = a rolling 2-step: bind a key in Options > Controls > Vehicle > "OE ProTune Rolling Anti-Lag", then HOLD it (on the throttle, anywhere above idle) and it LOCKS the revs at whatever RPM you pressed at - bangs and flames - until you let go. Works on ANY engine (V8 muscle cars bark hard). On turbo cars OE requests native anti-lag when the fitted device supports it; the amount of boost retained is vehicle-dependent.'),
          sld('launchSoundVol', '2-step / anti-lag sound volume', 0, 10, 1, ''),
          sld('launchFuel', 'Launch extra fuel', 0, 30, 1, ' %'),
          sld('launchRetard', 'Launch ignition retard', 0, 15, 1, ' °'),
          sld('popsLevel', 'Pops / flames intensity', 0, 3, 0.5, ''),
          sld('drivetrainLoss', 'Drivetrain loss (crank estimate)', 0, 35, 1, ' %'),
          ro('Measured drivetrain loss (live)', 'drivetrainLossMeasured', ' %', 1),
          ro('Active rev limit', 'activeRevLimit', ' rpm', 0),
          // V3.0.9: 'Raise ceiling' is now the top of the float band rather than a wall, so the pair
          // that matters is where float STARTS and how far you may ask. Wear is cumulative until reset.
          // The two thresholds read in the order you meet them: fade first, damage second. Showing them
          // together is the point - it makes the warning band visible instead of implied.
          ro('Valve float starts at', 'valveFloatRPM', ' rpm', 0), ro('Engine damage starts at', 'rpmDamageRPM', ' rpm', 0),
          ro('Float figure from', 'valveFloatSource'), ro('Highest limit you can set', 'revLimitCeiling', ' rpm', 0),
          ro('Valve float wear', 'floatWear', '', 2), ro('Limiter status', 'limiterStatus')
        ] },
        protection: { title: 'Protection', help: 'Master enable, engine protection (knock/overboost cut), and knock control. Turning protection off lets you blow the engine.', controls: [
          chk('enabled', 'ECU enabled'), chk('protection', 'Engine protection'), chk('knockControl', 'Knock control'),
          sld('egtLimit', 'EGT limit', 700, 1100, 10, ' °C'),
          ro('Protection status', 'protectionStatus'), ro('Engine health', 'engineHealth', ' %', 0), ro('Limp mode', 'limpMode'),
          ro('EGT', 'egtC', ' °C', 0), ro('Block strength', 'blockStrength', ' x', 2), ro('Strength basis', 'blockStrengthSource')
        ] }
      };
      scope.pageDef = function () { return scope.pageDefs[scope.activePage]; };
      scope.isSettingsPage = function () { return !!scope.pageDefs[scope.activePage]; };

      // generic control getters/setters
      scope.getNum = function (key) { var v = scope.settings[key]; return v === undefined ? 0 : v; };
      scope.onSlider = function (ctrl) { send(ctrl.key, scope.settings[ctrl.key]); };
      scope.toggle = function (key) {
        // V2.9.x nitrous hardware gate UX: with no N2O kit the switch used to flip to a green ON
        // that did nothing - confusing. Block the ON flip with an honest toast. Turning OFF stays
        // allowed, and a loaded tune carrying ON is never auto-stripped (that would silently lose
        // the setting from a tune that goes back to a kitted car). Older controllers without
        // base.hasNitrous fail open - they have no gate ECU-side either.
        if (key === 'nitrousEnabled' && !scope.settings[key] && scope.base && scope.base.hasNitrous === false) {
          setToast('No N2O kit installed - fit one in the vehicle Parts menu first, then enable nitrous.');
          return;
        }
        scope.settings[key] = !scope.settings[key]; send(key, scope.settings[key] ? 1 : 0);
      };
      scope.selOpt = function (ctrl, idx) {
        scope.settings[ctrl.key] = idx + 1; send(ctrl.key, idx + 1);
        // V2.9 fix: a REAL fuel change makes the controller re-derive Stoich AFR - mirror it here so
        // the slider doesn't go stale and a later re-push can't revert the controller to the old
        // fuel's stoich. Values match the controller's FUEL_PROPS table.
        if (ctrl.key === 'fuelType') {
          var FUEL_STOICH = { 1: 14.7, 2: 9.8, 3: 12.9, 4: 14.7, 5: 6.45 };
          var st = FUEL_STOICH[idx + 1];
          if (st) { scope.settings.stoichRatio = st; send('stoichRatio', st); }
        }
      };
      scope.selActive = function (ctrl, idx) { return (scope.settings[ctrl.key] || 1) === idx + 1; };

      // Compact PCLink-style boost shaping tables. Flat scalar keys keep M1-M4, tune files, tune
      // codes and the Remote Studio on one validation/persistence path.
      scope.boostShapeModes = ['Off', 'Gear', 'Speed', 'Gear + Speed'];
      scope.boostGearRows = [];
      for (var boostGearRow = 1; boostGearRow <= 8; boostGearRow++) {
        scope.boostGearRows.push({
          gear: boostGearRow,
          targetKey: 'boostGear' + boostGearRow + 'TargetTrimKpa',
          dutyKey: 'boostGear' + boostGearRow + 'DutyTrimPct'
        });
      }
      scope.boostSpeedRows = [0, 30, 60, 100, 160, 240].map(function (speed, index) {
        return { speed: speed, targetKey: 'boostSpeed' + (index + 1) + 'TargetTrimKpa', dutyKey: 'boostSpeed' + (index + 1) + 'DutyTrimPct' };
      });
      scope.setBoostShapeMode = function (index) {
        var value = Math.max(1, Math.min(4, Number(index) + 1));
        // The legacy 60/80% profile only runs while shaping is Off, so keeping boostMode 3 with a
        // modern mode selected would leave a stale 'Closed (legacy 60/80%)' label describing behavior
        // that no longer exists. Migrate it, exactly as the legacy banner promises.
        if (value !== 1 && Number(scope.settings.boostMode) === 3) {
          scope.settings.boostMode = 2;
          send('boostMode', 2);
          setToast('Legacy 60/80% profile replaced - loop is now plain Closed Loop + shaping tables');
        }
        scope.settings.boostShapeMode = value;
        send('boostShapeMode', value);
      };
      scope.boostShapeModeTip = function (index) {
        return ['Off: no gear/speed shaping - the base Boost Target (or the legacy 60/80% profile) runs alone',
          'Gear: each forward gear applies its own target and WG duty trim row; neutral, reverse or an invalid signal falls back to G1',
          'Speed: trims follow whole-vehicle ground speed, linearly interpolated between the axis rows',
          'Gear + Speed: both trim sets stack - pressure trims add, WG duty trims add (capped at ±100%) and then scale the base duty'][Number(index)] || '';
      };
      scope.boostShapeModeActive = function (index) { return Number(scope.settings.boostShapeMode || 1) === index + 1; };
      scope.boostGearShapeEnabled = function () { var m = Number(scope.settings.boostShapeMode || 1); return m === 2 || m === 4; };
      scope.boostSpeedShapeEnabled = function () { var m = Number(scope.settings.boostShapeMode || 1); return m === 3 || m === 4; };
      // Live "Base + trims = request" equation for the Boost by Gear / Speed page - the same numbers
      // as the readout rows, composed the way a tuner checks the math (and the way PCLink docs write
      // it). Unit-aware through tv() (kPa/psi per the toggle). Empty when the target math doesn't
      // run: open loop (pressure trims ignored), shaping off, or no turbo.
      scope.boostTargetMath = function () {
        var t = scope.t || {};
        if (t.hasTurbo === false || !(scope.base && scope.base.hasTurbocharger)) { return ''; }
        var loopMode = Number(scope.settings.boostMode || 2);
        var shapeMode = Number(scope.settings.boostShapeMode || 1);
        var legacy = loopMode === 3 && shapeMode === 1;
        if (loopMode === 1 || (shapeMode === 1 && !legacy)) { return ''; }
        var gearOn = legacy || shapeMode === 2 || shapeMode === 4;
        var speedOn = shapeMode === 3 || shapeMode === 4;
        // hide until the shaping telemetry actually streams (vehicle-switch window, filtered
        // telemetry, or an older cached controller that never sends these keys)
        if (!isFinite(Number(t.boostBaseTargetKpa)) || !isFinite(Number(t.boostRequestedTargetKpa))) { return ''; }
        var signed = function (key) {
          var v = Number(t[key]);
          return (isFinite(v) && v >= 0 ? '+ ' : '− ') + scope.tv(key, 'kPa', 1).replace(/^-/, '');
        };
        var out = 'Base ' + scope.tv('boostBaseTargetKpa', 'kPa abs');
        if (gearOn) {
          var gearName = t.boostGearValid ? ('G' + t.boostGear) : 'G1 fallback';
          out += ' ' + signed('boostGearTargetTrimKpa') + ' (' + gearName + (legacy ? ' legacy' : ' trim') + ')';
        }
        if (speedOn) { out += ' ' + signed('boostSpeedTargetTrimKpa') + ' (speed trim)'; }
        // the request is clamped to baro..table-max, so the sum can legitimately differ - say so
        // instead of showing math that doesn't add up
        var expected = Number(t.boostBaseTargetKpa)
          + (gearOn ? (Number(t.boostGearTargetTrimKpa) || 0) : 0)
          + (speedOn ? (Number(t.boostSpeedTargetTrimKpa) || 0) : 0);
        var clampNote = (isFinite(expected)
          && Math.abs(expected - Number(t.boostRequestedTargetKpa)) > 0.05) ? ' (clamped to the valid range)' : '';
        // V2.9.7 wave 4 (Liqurdz's BBG report): while the active target is still slewing toward the
        // request (gear-change ramp in flight) the equation's answer and the Final target readout
        // disagree - annotate it. The hold phase itself is not streamed (boostGearTargetHoldTimer is
        // controller-side only), so the ramp is the only shaping state the UI can show.
        var rampNote = (isFinite(Number(t.boostTargetKpa))
          && Math.abs(Number(t.boostTargetKpa) - Number(t.boostRequestedTargetKpa)) > 1) ? ' (ramping to target)' : '';
        return out + ' = request ' + scope.tv('boostRequestedTargetKpa', 'kPa abs') + clampNote + rampNote;
      };

      // Tune data stays canonical in kPa. Stable getter/setter models convert only the editor
      // boundary, so toggling PSI repaints the controls without changing a saved calibration.
      scope.boostShapeEditors = {};
      function boostShapeUsesPressure(kind) { return kind === 'pressure' || kind === 'rate'; }
      function boostShapeDisplayRounded(value, kind) {
        var scale = boostShapeUsesPressure(kind) ? 10 : 1;
        return Math.round(Number(value) * scale) / scale;
      }
      function boostShapeNativeRounded(value, kind) {
        var scale = boostShapeUsesPressure(kind) ? 1000 : 1;
        return Math.round(Number(value) * scale) / scale;
      }
      function registerBoostShapeEditor(key, lo, hi, kind) {
        scope.boostShapeEditors[key] = function (displayValue) {
          if (arguments.length && displayValue !== null && displayValue !== undefined && displayValue !== '') {
            var parsed = Number(displayValue);
            if (isFinite(parsed)) {
              var nativeValue = (scope.psi && boostShapeUsesPressure(kind)) ? parsed / KPA_TO_PSI : parsed;
              nativeValue = boostShapeNativeRounded(Math.max(lo, Math.min(hi, nativeValue)), kind);
              var previous = Number(scope.settings[key]);
              scope.settings[key] = nativeValue;
              if (!isFinite(previous) || Math.abs(previous - nativeValue) > 0.0001) { send(key, nativeValue); }
            }
          }
          var nativeCurrent = Number(scope.settings[key]);
          if (!isFinite(nativeCurrent)) { nativeCurrent = 0; }
          var shown = (scope.psi && boostShapeUsesPressure(kind)) ? nativeCurrent * KPA_TO_PSI : nativeCurrent;
          return boostShapeDisplayRounded(shown, kind);
        };
      }
      scope.boostShapeUnit = function (kind) {
        if (kind === 'pressure') { return scope.psi ? 'psi' : 'kPa'; }
        if (kind === 'rate') { return scope.psi ? 'psi/s' : 'kPa/s'; }
        if (kind === 'duty') { return '%'; }
        if (kind === 'time') { return 'ms'; }
        return '';
      };
      scope.boostShapeDisplayStep = function (kind, nativeStep) {
        if (scope.psi && kind === 'pressure') { return 0.5; }
        if (scope.psi && kind === 'rate') { return 5; }
        return nativeStep;
      };
      scope.boostShapeDisplayLimit = function (kind, nativeLimit) {
        var value = (scope.psi && boostShapeUsesPressure(kind)) ? Number(nativeLimit) * KPA_TO_PSI : Number(nativeLimit);
        return boostShapeDisplayRounded(value, kind);
      };
      scope.boostShapeStepTitle = function (direction, kind, nativeStep) {
        var amount = scope.boostShapeDisplayStep(kind, nativeStep);
        return (Number(direction) < 0 ? 'Decrease by ' : 'Increase by ') + amount + ' ' + scope.boostShapeUnit(kind);
      };
      scope.stepBoostShape = function (key, direction, kind, nativeStep) {
        var editor = scope.boostShapeEditors[key];
        if (typeof editor !== 'function') { return; }
        var current = (scope.psi && boostShapeUsesPressure(kind)) ? (Number(scope.settings[key]) || 0) * KPA_TO_PSI : Number(editor());
        if (!isFinite(current)) { current = 0; }
        editor(current + (Number(direction) < 0 ? -1 : 1) * scope.boostShapeDisplayStep(kind, nativeStep));
      };
      scope.boostShapeKeyStep = function (event, key, kind, nativeStep) {
        if (!event || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) { return; }
        event.preventDefault();
        scope.stepBoostShape(key, event.key === 'ArrowUp' ? 1 : -1, kind, nativeStep);
      };
      registerBoostShapeEditor('boostRampUpKpaS', 0, 2000, 'rate');
      registerBoostShapeEditor('boostRampDownKpaS', 0, 2000, 'rate');
      registerBoostShapeEditor('boostShiftHoldMs', 0, 1000, 'time');
      // V3.0: target trims widened to ±500 kPa (~±72 psi), was ±200 - keep in sync with
      // oeProTuneShared settingRanges, MAP_BANK_SETTING_RANGES below and the app.html ng-attr limits.
      // Duty trims stay ±100% (the physical solenoid range).
      scope.boostGearRows.forEach(function (row) {
        registerBoostShapeEditor(row.targetKey, -500, 500, 'pressure');
        registerBoostShapeEditor(row.dutyKey, -100, 100, 'duty');
      });
      scope.boostSpeedRows.forEach(function (row) {
        registerBoostShapeEditor(row.targetKey, -500, 500, 'pressure');
        registerBoostShapeEditor(row.dutyKey, -100, 100, 'duty');
      });

      // ---- hover tooltips (short plain-English description of each option) ----
      var TIPS = {
        // limiters
        flatShiftEnabled: 'Flat-foot / no-lift shift: keep the throttle FLOORED and dip the clutch to change gear - the limiter holds revs at the flat-shift RPM so you never lift. OE requests native anti-lag on a supported turbo, but retained boost depends on the vehicle. Needs a clutch pedal (manual cars).',
        flatShiftRpm: 'The RPM the limiter holds while the clutch is in during a flat-foot shift. Set it near your shift point / just below redline.',
        launchSoundVol: 'Volume of the 2-step / anti-lag bark (0 = off). Plays on a TURBO car for the launch 2-step and the rolling anti-lag. 0-10.',
        popsLevel: 'How aggressive the bangs and exhaust FLAMES are on limiters, anti-lag and overrun (0 = none, 1 = default, 3 = wild). Smoke remains fuel-aware and is separately shaped by the selected visual profile and Smoke amount.',
        limiterCutMode: 'How the hard cut stops the engine, the same choice a real standalone gives you. IGNITION cuts the spark while fuel keeps flowing - the unburnt fuel lights in the exhaust, which is where the bangs and flames come from, and it is hard on the exhaust and turbo. FUEL is the quiet one: it holds the revs exactly the same but with no spark cut, so no bangs, no flames and no afterfire. Only applies while Hard-cut rev limiter is on. (Note: OE removes torque through one channel, so both modes hold revs identically - what changes is the bang, which is what changes on a real engine too.)',
        limiterCutMs: 'How long the spark stays off on each bounce, in milliseconds. This is the DEAD TIME at the bottom of every bounce - the revs have already fallen far enough to release, but torque does not come back until this expires - so it, not Limiter range, is what caps how FAST the limiter can machine-gun. LOWER (10-25) = a much faster, harder brap. HIGHER (100-150) = a lazy soft-limiter sag. 50 is the factory OE value. Below about 10 ms there is nothing to gain: the limiter is evaluated once per rendered frame, so a shorter hold has already expired by the next one. A gearbox shift cut is never shortened by this - only the OE cut is.',
        limiterRange: 'How far the revs fall before the cut lets go again - a real ECU calls this the limiter control range. NARROW (100-200) is a hard instant wall that braps rapidly: the drag/drift sound. WIDE (400-600) lets the engine lean into the limiter and sag, which is gentler on the drivetrain but feels soft. 150 is the factory OE behaviour. Pair it with Limiter cut duration: the cut hold is the other half of the cadence, and shortening that is what really speeds the bounce up. Only applies while Hard-cut rev limiter is on.',
        hardCut: 'Hard-cut rev limiter: instead of easing off, it cuts power dead the moment you hit the MAIN rev limit, so the engine BOUNCES off it (the aggressive drag/drift limiter bang). Dumps fuel into the exhaust for cracks and flames (scaled by Pops / flames intensity). Works on NA and turbo.',
        rollingEnabled: 'Rolling anti-lag = a flexible rolling 2-step: HOLD a bound key (on the throttle, anywhere above idle) and it LOCKS the revs at whatever RPM you pressed at with bangs and flames until you let go. On a supported turbo OE also requests native anti-lag, but retained boost depends on the vehicle. Bind it in Options > Controls > Vehicle > "OE ProTune Rolling Anti-Lag".',
        // sensors
        sensorMapOffset: 'Shifts what the ECU reads for manifold pressure. A bad offset makes a good tune behave wrong.',
        sensorMapScale: 'Scales the MAP sensor reading. 1.000 = correct.',
        sensorTpsOffset: 'Shifts the throttle-position reading up or down.',
        sensorTpsScale: 'Scales the throttle reading. 1.000 = correct.',
        sensorIatBias: 'Adds/subtracts to the intake air temp the ECU senses - moves the ECU IAT readout and the ECU\'s hot-air timing pull. The engine\'s real intake temp is untouched.',
        sensorEctBias: 'Adds/subtracts to coolant temp (affects warmup fuel + closed loop).',
        sensorLambdaBias: 'Shifts the wideband reading; a biased sensor fools closed-loop fuel.',
        sensorFuelPressureBias: 'Shifts the displayed fuel-rail pressure.',
        // fuel setup
        fuelEquationMode: 'How fuel is calculated. Traditional uses Fuel Main as the base correction. Modelled makes delivered fuel proportional to the four-cell-interpolated VE value. The separate Load source selects whether those rows are indexed by MAP or TPS. VE too low runs lean; VE too high runs rich.',
        loadSource: 'What indexes the load rows. Speed Density uses MAP and is the safe default for existing tunes. Alpha-N uses TPS for Fuel, Ignition, Lambda and VE; Boost Target and WG Duty stay MAP-based. Use Alpha-N for unstable MAP signals such as large cams or ITBs, then re-tune because every row now means a throttle angle.',
        masterFuel: 'Overall fuel scaler. Up = richer everywhere, down = leaner everywhere.',
        injectorFlow: 'Injector size (cc/min). Bigger injectors flow more fuel before maxing out.',
        injectorDeadtime: 'Injector open/close lag. Mostly affects small pulses at idle.',
        stoichRatio: 'Air/fuel ratio of your fuel (14.7 gas, 9.8 E85). Sets the AFR <-> lambda display.',
        fuelType: 'What the engine drinks. E85 resists knock (more timing/boost) and makes a bit more power, but needs ~50% more fuel. RACE GAS C16: leaded 118-octane drag fuel - big knock headroom, normal fuel volume. METHANOL M1 (the xFWD staple): the MOST knock-resistant, big charge-cooling power, runs cool EGTs and burns a near-invisible blue flame - but stoich is 6.45, so it needs ~2.3x the fuel: expect pegged injector duty until you fit a much bigger Injector flow. Watch duty on any alcohol.',
        // closed loop lambda
        cllMode: 'Closed-loop correction: Off, Stoich only, or Always on target. STFT carries the correction needed now; LTFT learns that same requirement and recenters STFT toward 0% instead of adding another copy.',
        cllEctLockout: 'Closed loop stays off until coolant is above this temp.',
        cllTpsLockout: 'CLL turns off at or above this throttle. Mixture Map can still learn open-loop data from a strict steady point or a clean WOT pull when all validity checks pass.',
        cllMapLockout: 'CLL turns off at or above this MAP. This trainer defaults to open-loop high-load tuning; real wideband systems may use controlled high-load correction with explicit authority limits.',
        // cyl trims
        cylTrim1: 'Per-cylinder fuel trim. A wide spread between cylinders causes misfire.',
        cylTrim2: 'Per-cylinder fuel trim. A wide spread between cylinders causes misfire.',
        cylTrim3: 'Per-cylinder fuel trim. A wide spread between cylinders causes misfire.',
        cylTrim4: 'Per-cylinder fuel trim. A wide spread between cylinders causes misfire.',
        cylTrim5: 'Per-cylinder fuel trim. A wide spread between cylinders causes misfire.',
        cylTrim6: 'Per-cylinder fuel trim. A wide spread between cylinders causes misfire.',
        cylTrim7: 'Per-cylinder fuel trim. A wide spread between cylinders causes misfire.',
        cylTrim8: 'Per-cylinder fuel trim. A wide spread between cylinders causes misfire.',
        // startup / warmup
        crankEnrich: 'Extra fuel while cranking. Too little = hard cold start.',
        postStartEnrich: 'Extra fuel right after start, then it decays away.',
        postStartDecay: 'How long post-start enrichment takes to fade.',
        warmupEnrich: 'Extra fuel while the engine is cold; fades as it warms.',
        warmupEndTemp: 'Coolant temp where warmup enrichment ends.',
        // transient
        accelEnrich: 'Squirt of fuel on throttle tip-in. Too little = lean stumble; too much = bog.',
        accelDecay: 'How fast the tip-in squirt fades.',
        wallWetting: 'Holds a bit of transient fuel to mimic fuel film on the ports.',
        decelFuelCut: 'Cuts fuel on lift-off to save fuel. Turn it OFF (then richen + retard the overrun cells) to build pops & bangs - see the Pops & Bangs page.',
        decelFuel: 'How much fuel to pull on lift-off when decel cut is on. More negative = harder cut; less negative = softer, cures lift-off lean.',
        // boost control
        boostMode: 'Open loop = duty command only. Closed loop = chases the final target. Closed (legacy 60/80%) preserves old tunes: 60% gauge target in G1, 80% in G2. Use Boost by Gear / Speed for the modern configurable strategy.',
        wgType: 'PNEUMATIC (default): a real spring gate - boost can never run BELOW the spring, and the spring is the boost slider in the game\'s Tuning tab. ELECTRIC: modern motor-driven gate with full authority, can hold boost below spring. Set the native slider for a believable spring, not as a hidden clutch-strength adjustment.',
        clutchUpgrade: 'A fixed manual-clutch torque rating: fitted, 500, 800, 1300 or 2200 Nm. It never follows engine torque, and BeamNG heat/wear/damage still reduce the available capacity. If the strongest package slips, reduce torque or fit different vehicle hardware.',
        launchFuel: 'Extra fuel while a limiter event owns the engine (2-step, rolling anti-lag, hard cut). Richer launch = bigger bangs and a cooler hold - a real launch-fuel setting. 0 = off.',
        launchRetard: 'Ignition retard during those same events. Retard pushes combustion into the exhaust = more modeled heat and flame on the hold. A supported native turbo may retain more boost, but that result is vehicle-dependent. 0 = off.',
        knockAudio: 'Hear the engine hurting - real recorded ticking, and each car has its own knock voice. Detonation ticks while knock is over threshold (lift or pull timing and it stops). Push through it and the damaged bottom end starts ROD KNOCKING - deep and constant, and like real life no tune change silences it: only repairing the engine does. Audio only.',
        boostBaseDuty: 'Wastegate starting point. Higher = more boost before the controller trims.',
        boostPGain: 'How hard boost control corrects. Higher = tighter, cuts the spool spike; too high = it hunts.',
        boostIGain: 'Slowly removes any steady-state boost error so it parks exactly on target. 0 = pure P control: the loop settles short of target by design - real EBCs need I to land exactly.', // V2.9.7 wave 4 (Liqurdz's BBG report)
        boostOvercut: 'How far over target boost may go before it counts as overboost (damage/cut).',
        // knock
        knockControl: 'Lets the ECU pull timing when it detects knock. OFF = detonation runs unchecked.',
        knockThreshold: 'How sensitive knock detection is. Lower = reacts sooner.',
        knockMaxRetard: 'Most timing the ECU will pull to stop knock.',
        knockRecoveryRate: 'How fast pulled timing is given back after knock stops.',
        // idle / motorsport
        idleEnabled: 'Closed-loop idle control to hold the target idle RPM.',
        idleTargetRpm: 'The idle speed the ECU holds.',
        idleBasePos: 'Starting idle-valve position before correction.',
        idleIgnControl: 'Also use small timing changes to steady the idle.',
        antilagEnabled: 'Models off-throttle anti-lag heat, sound and flame; requests native turbo anti-lag when supported. Actual spool is vehicle-dependent. Hard on the engine.',
        antilagRetard: 'Timing pulled during anti-lag to make modeled exhaust heat and stronger events.',
        antilagFuel: 'Extra fuel during anti-lag (more bang/heat; native spool only when the fitted turbo supports it).',
        overrunMinRpm: 'Pops only fire when you lift off ABOVE this RPM (so it does not pop at idle / low revs).',
        overrunThrottle: 'Throttle has to be below this % to count as a lift/overrun (where the pops happen).',
        overrunPopStrength: 'Live read of how hard it pops, built from your tune - rich raw fuel + retarded ignition on the overrun. 0 = stock/quiet; it climbs as you richen and retard the top vacuum rows.',
        overrunPopVol: 'Volume of the OE ProTune backfire pop samples on overrun (0 = off, 6 = default, 10 = loudest). No effect in "Car\'s own only" mode.',
        popSoundMode: 'What voices the pops and bangs. BOTH = OE ProTune samples layered over the car\'s own afterfire (the classic sound). CAR\'S OWN = only the car / sound-mod\'s native pop events, boosted louder and less muffled as "Pops / flames intensity" rises - pick this if your sound mod ships its own crackle. OE SAMPLES = only the OE ProTune samples (native bangs muted, flames unaffected). The 2-step / anti-lag bark has its own volume on Limiters.',
        fxStyle: 'BEAMNG STOCK uses vanilla particles. OE REALISTIC (default) uses short, light fuel-aware haze: normal gasoline, E85 and methanol stay mostly clean; black coal is reserved for diesel or true flooding. OE SHOW / COAL restores the old oversized, dense lingering clouds for screenshots. Visual only.',
        smokeDensity: 'Linear fine adjustment inside the selected profile. 1.0x = intended amount, 0 = no custom smoke, 2.0x = twice the requested amount; a hard profile cap prevents runaway clouds at extreme bang rates. It does not change the Smoke gauge, AFR/lambda, EGT, power, faults or the tune; bangs and flames still work at 0.',
        rotIdleEnabled: 'Arms the rotational idle. It only chops when the car is STOPPED, off the throttle, and your idle cells are tuned for it (retard + a touch rich).',
        rotIdleRpm: 'Idle target while the chop is active. Real rotational tunes idle high (1100-1500) so the lope has room to fall through the cuts.',
        rotIdleChop: 'How deep each ignition cut hits. Deeper = rowdier lope and harder rocking - and more stall risk on weak engines. Back it off if the engine dies.',
        // cam / throttle / trigger
        vvtEnabled: 'Variable cam timing - fills the midrange (the VTEC kick).',
        vvtIntakeAdvance: 'How far the intake cam advances. More = more midrange torque.',
        ethrottleMode: 'Cable = 1:1 pedal. E-Throttle = pedal goes through a map + safety monitor.',
        ethrottleCurve: 'Pedal feel: Linear, Progressive (lazy low) or Aggressive (snappy low).',
        ethrottleTolerance: 'How far throttle may differ from target before a safety cut.',
        triggerPattern: 'Crank/cam signal quality. Noisy or missing sync causes misfires + faults.',
        baseTiming: 'Absolute base advance the displayed ignition angle builds on - set it to your engine\'s spec, like PCLink\'s Set Base Timing. Display calibration only: power/knock/EGT follow your trims.',
        // can / aux
        canMode: 'CAN bus transmit setup (training/status only - no driving effect).',
        canSpeed: 'CAN bus speed (training/status only).',
        canRuntime: 'Transmit live runtime values on CAN (status only).',
        canLambda: 'Transmit lambda on CAN (status only).',
        canDash: 'Transmit dash data on CAN (status only).',
        auxFanOnTemp: 'Coolant temp the cooling fan switches on.',
        auxFuelPumpPrime: 'How long the fuel pump primes at key-on.',
        auxShiftLight: 'RPM the shift light comes on.',
        auxCheckEngine: 'Light the check-engine output when a fault/knock/damage happens.',
        // limits / protection
        revLimit: 'Max RPM (0 = factory limiter). Lower it to protect an engine, or raise it only up to the displayed mechanical ceiling (BeamNG valve-float limit / torque-curve end, never more than +1500). OE damage now begins at that physical engine threshold, not at the fitted ECU\'s original limiter. Raised it? Extend the top of your RPM axis (Axis Setup) to tune fuel/timing in the new range.',
        launchEnabled: '2-step launch limiter - holds revs on the line for a hard launch. Releases the instant you dump the clutch (or the car starts moving, for autos/trans brakes), like a real clutch-switch 2-step; re-arms when you come back to a stop and lift.',
        launchLimit: 'RPM the 2-step holds you at while staged.',
        drivetrainLoss: 'Wheel power is now MEASURED through the drivetrain (like a hub dyno) - this slider is only the fallback for setups the game cannot measure, and your crank-vs-wheel estimate factor. Pro move: do a pull, read the MEASURED loss below, set this to match.',
        enabled: 'Master ECU on/off. Off releases all control back to stock.',
        protection: 'Engine protection - cuts power on knock/overboost. Off lets you blow it up.',
        blockStrength: 'How much mechanical abuse the fitted engine block survives before it grenades. Auto-detected from the block - you cannot set it. Higher = tougher: a built/forged block earns more timing and boost headroom. Swap to a stronger block part to raise it.',
        blockStrengthSource: 'How strength was detected. "rated" = from the block BeamNG torque rating (most accurate). "estimated" = guessed from power (mod set no rating). "default" = no data, neutral 1.0x.'
      };
      scope.tipFor = function (ctrl) {
        if (!ctrl) { return ''; }
        var k = ctrl.key, k2 = ctrl.tkey;
        if (scope.lang !== 'en' && TIPSL[scope.lang]) {
          var tl = TIPSL[scope.lang];
          if (tl[k] !== undefined) { return tl[k]; }
          if (tl[k2] !== undefined) { return tl[k2]; }
        }
        return TIPS[k] || TIPS[k2] || (ctrl.label ? scope.tr(ctrl.label) : '');
      };
      var NAVTIPS = {
        runtime: 'Live ECU values - lambda, knock, injector duty, temps, faults.',
        gauges: 'At-a-glance warning bars for a pull.',
        dyno: 'Measure power - baseline pull, tune, re-pull to compare.',
        logging: 'Record and review a pull on a time graph.',
        log_navigator: 'Scrub through a logged pull and jump to events.',
        tutorial: 'Step-by-step interactive tuning lessons.',
        challenges: 'Scored tuning cases on a fixed car - diagnose a broken tune, or hunt power without blowing it up.',
        diagnose: 'Tune Doctor - tells you what is wrong and how to fix it.',
        tunes: 'Switchable Maps, quick-save backups, presets and the tune library.',
        notes: 'Notebook for what you changed and why.',
        sensor_setup: 'Calibrate what the ECU senses (MAP/TPS/IAT/ECT/lambda).',
        axis_setup: 'Edit the table RPM, MAP and Alpha-N TPS breakpoints.',
        tune_compare: 'Compare the current tune against a saved baseline.',
        limits: 'Rev limit, 2-step launch limit, drivetrain loss.',
        protection: 'Master enable, engine protection, knock control.',
        faults: 'Latched fault codes - clear faults / reset engine damage.',
        fuel_setup: 'Master fuel, injector size and the fuel equation.',
        fuel: 'Fuel Main table - fuel trim by RPM and load.',
        lambda: 'Lambda Target table - your air/fuel target by RPM and load.',
        ve: 'VE Table (Modelled fuel) - the airflow model used to calculate fuel. The live operating point blends four cells. VE too low runs lean; VE too high runs rich.',
        wgduty: 'Wastegate Duty table - shifts Base duty per RPM/target cell (50 = neutral), like a real EBC duty table. Sculpt duty DOWN at high rpm to stop big-turbo boost hunting.',
        cll: 'Closed-loop lambda: fast residual STFT, learned LTFT, and the ECT/TPS/MAP/RPM lockouts that decide when correction is active.',
        cyl_trims: 'Per-cylinder fuel trims.',
        mixture_map: 'Ratio-based fuel recommendations from accepted steady-state data or clean WOT pulls. Apply writes Fuel Main or VE, consumes the evidence, and requires a re-drive.',
        startup_warmup: 'Cranking, post-start and warmup fuel.',
        transient_fuel: 'Throttle tip-in and lift-off fuel.',
        ignition: 'Ignition Main table - timing trim by RPM and load. This TRIMS the base map the engine runs (you do not edit the base map directly), so it has extra pull authority: down to -20 deg to tame an aggressive map / stop knock on big boost, and up to +10 deg of advance.',
        knock_control: 'Knock detection and protection settings.',
        boost: 'Boost Target table - how much boost you want by RPM (turbo).',
        boost_control: 'How the wastegate delivers your boost target (turbo).',
        boost_shaping: 'PCLink-style boost by gear and ground speed: additive target trims, percentage wastegate-duty trims, shift hold and target ramping.',
        idle_control: 'Idle speed control.',
        antilag: 'Anti-lag (turbo) - modeled heat/bangs plus native spool control when the fitted turbo supports it.',
        vvt: 'Variable cam timing - midrange torque.',
        ethrottle: 'Electronic throttle mapping and safety.',
        triggers: 'Crank/cam trigger signal quality + base-timing calibration.',
        can_setup: 'CAN bus transmit setup (status only).',
        aux_outputs: 'Fan, fuel pump, shift light, check-engine outputs.',
        help: 'About this app and how it works.'
      };
      // Stage 3: tooltip translations keyed by setting-key / nav-id (per-entry English fallback)
      var CYL_ES = 'Ajuste de combustible por cilindro. Una dispersión grande entre cilindros causa fallos.';
      var CYL_FR = 'Réglage carburant par cylindre. Un grand écart entre cylindres cause des ratés.';
      var CYL_PT = 'Ajuste de combustível por cilindro. Uma dispersão grande entre cilindros causa falhas.';
      var TIPSL = {
        es: {
          sensorMapOffset: 'Desplaza lo que la ECU lee de presión de colector. Un offset malo hace que un buen tune se comporte mal.', sensorMapScale: 'Escala la lectura del sensor MAP. 1.000 = correcto.', sensorTpsOffset: 'Desplaza la lectura de posición del acelerador.', sensorTpsScale: 'Escala la lectura del acelerador. 1.000 = correcto.', sensorIatBias: 'Suma/resta a la temp. de aire de admisión que ve la ECU (mueve la lectura ECU IAT y el retraso de avance por aire caliente).', sensorEctBias: 'Suma/resta a la temp. de refrigerante (afecta combustible de calentamiento + lazo cerrado).', sensorLambdaBias: 'Desplaza la lectura del wideband; un sensor sesgado engaña al lazo cerrado.', sensorFuelPressureBias: 'Desplaza la presión de riel mostrada.',
          fuelEquationMode: 'Cómo se calcula el combustible. Modelado usa MAP para la demanda de aire; Tradicional usa acelerador + carga.', masterFuel: 'Escalador general de combustible. Arriba = más rico en todo, abajo = más pobre.', injectorFlow: 'Tamaño del inyector (cc/min). Inyectores más grandes fluyen más antes de saturarse.', injectorDeadtime: 'Retardo de apertura/cierre del inyector. Afecta sobre todo pulsos pequeños en ralentí.', stoichRatio: 'Relación aire/combustible de tu combustible (14.7 gasolina, 9.8 E85). Define la pantalla AFR <-> lambda.', fuelType: 'Gasolina, E85 o E30. El E85 resiste la detonación (más avance/boost) y hace algo más de potencia, pero necesita ~50% más combustible - vigila el ciclo del inyector. C16 = gasolina de competición (118 octanos). Metanol M1 = el que más resiste la detonación y corre EGT frías, pero necesita ~2.3x de inyector.',
          cllMode: 'Corrección de combustible en lazo cerrado: apagado, solo estequio., o siempre al objetivo lambda.', cllEctLockout: 'El lazo cerrado se mantiene apagado hasta que el refrigerante supere esta temp.', cllTpsLockout: 'El lazo cerrado se apaga por encima de este acelerador (para no estorbar a fondo).', cllMapLockout: 'El lazo cerrado se apaga por encima de este MAP (bajo boost).',
          cylTrim1: CYL_ES, cylTrim2: CYL_ES, cylTrim3: CYL_ES, cylTrim4: CYL_ES, cylTrim5: CYL_ES, cylTrim6: CYL_ES, cylTrim7: CYL_ES, cylTrim8: CYL_ES,
          crankEnrich: 'Combustible extra durante el arranque. Muy poco = arranque en frío difícil.', postStartEnrich: 'Combustible extra justo tras arrancar, luego decae.', postStartDecay: 'Cuánto tarda en desvanecerse el enriquecimiento post-arranque.', warmupEnrich: 'Combustible extra con el motor frío; se desvanece al calentar.', warmupEndTemp: 'Temp. de refrigerante donde termina el enriquecimiento de calentamiento.',
          accelEnrich: 'Chorro de combustible al pisar. Muy poco = tirón pobre; demasiado = ahogo.', accelDecay: 'Qué tan rápido se desvanece el chorro de aceleración.', wallWetting: 'Retiene algo de combustible transitorio para imitar la película en los puertos.', decelFuelCut: 'Corta combustible al levantar (ahorra combustible, da explosiones de escape).', decelFuel: 'Cuánto combustible quitar al levantar cuando el corte está activo. Más negativo = corte más fuerte; menos negativo = más suave, cura el empobrecimiento al levantar.',
          boostMode: 'Lazo abierto = solo ciclo base. Lazo cerrado = persigue el objetivo de boost. Por marcha = boost según la marcha.', boostBaseDuty: 'Punto de partida del wastegate. Más alto = más boost antes de que el control ajuste.', boostPGain: 'Qué tan fuerte corrige el control de boost. Más alto = más ajustado, recorta el pico; demasiado = oscila.', boostIGain: 'Quita lentamente el error de boost estable para quedar justo en el objetivo.', boostOvercut: 'Cuánto puede pasarse el boost del objetivo antes de contar como sobreboost (daño/corte).',
          knockControl: 'Permite a la ECU quitar avance al detectar detonación. APAGADO = la detonación corre libre.', knockThreshold: 'Qué tan sensible es la detección de detonación. Más bajo = reacciona antes.', knockMaxRetard: 'Máximo avance que la ECU quitará para frenar la detonación.', knockRecoveryRate: 'Qué tan rápido se devuelve el avance tras parar la detonación.',
          idleEnabled: 'Control de ralentí en lazo cerrado para mantener el RPM objetivo.', idleTargetRpm: 'El RPM de ralentí que mantiene la ECU.', idleBasePos: 'Posición inicial de la válvula de ralentí antes de corregir.', idleIgnControl: 'También usa pequeños cambios de avance para estabilizar el ralentí.', antilagEnabled: 'Modela calor, sonido y llama sin acelerador; solicita anti-lag nativo si el turbo lo admite. El giro real depende del vehículo.', antilagRetard: 'Avance quitado para aumentar el calor y los eventos modelados.', antilagFuel: 'Combustible extra (más explosión/calor; giro nativo solo en turbos compatibles).',
          vvtEnabled: 'Distribución variable de levas - llena el medio régimen (el golpe VTEC).', vvtIntakeAdvance: 'Cuánto avanza la leva de admisión. Más = más par en medio régimen.', ethrottleMode: 'Cable = pedal 1:1. E-Throttle = el pedal pasa por un mapa + monitor de seguridad.', ethrottleCurve: 'Tacto del pedal: Lineal, Progresivo (perezoso abajo) o Agresivo (vivo abajo).', ethrottleTolerance: 'Cuánto puede diferir el acelerador del objetivo antes de un corte de seguridad.', triggerPattern: 'Calidad de la señal de cigüeñal/leva. Ruido o falta de sync causa fallos + averías.', baseTiming: 'Avance base absoluto sobre el que se construye el ángulo de encendido mostrado - ponlo según la especificación de tu motor, como el Set Base Timing de PCLink. Solo calibración de pantalla: potencia/detonación/EGT siguen tus ajustes.',
          canMode: 'Configuración de transmisión CAN (solo entrenamiento/estado - sin efecto al conducir).', canSpeed: 'Velocidad del bus CAN (solo entrenamiento/estado).', canRuntime: 'Transmite valores en vivo por CAN (solo estado).', canLambda: 'Transmite lambda por CAN (solo estado).', canDash: 'Transmite datos de tablero por CAN (solo estado).', auxFanOnTemp: 'Temp. de refrigerante a la que arranca el ventilador.', auxFuelPumpPrime: 'Cuánto ceba la bomba de combustible al dar contacto.', auxShiftLight: 'RPM a la que se enciende la luz de cambio.', auxCheckEngine: 'Enciende la salida check-engine cuando hay avería/detonación/daño.',
          revLimit: 'RPM máximo (0 = limitador de fábrica). Bájalo para proteger el motor, o SÚBELO por encima de serie (hasta el final de la curva de par, máx +1500). Pasar del corte de fábrica daña el motor con el tiempo - como en la vida real. ¿Lo subiste? Extiende el tope del eje de RPM (Ejes) para afinar combustible/encendido en el nuevo rango.', launchEnabled: 'Limitador de largada 2-step - mantiene las vueltas en la línea para una salida fuerte.', launchLimit: 'RPM al que el 2-step te mantiene mientras estás en posición.', drivetrainLoss: 'La potencia en rueda ahora se MIDE (como un dyno de cubo) - este slider es solo el respaldo y tu factor de estimación al cigüeñal. Haz una pasada, lee la pérdida MEDIDA y ajústalo a que coincida.', wgType: 'NEUMÁTICO (por defecto): wastegate de muelle real - el boost nunca baja del muelle, y el muelle es el slider de boost del Tuning del juego. ELÉCTRICO: wastegate motorizado moderno con autoridad total, puede mantener el boost bajo el muelle. Ambos son hardware real.', enabled: 'Encendido/apagado maestro de la ECU. Apagado devuelve todo el control a serie.', protection: 'Protección del motor - corta potencia ante detonación/sobreboost. Apagada deja que lo revientes.', blockStrength: 'Cuánto abuso mecánico aguanta el bloque montado antes de reventar. Se detecta automáticamente del bloque - no se puede fijar. Más alto = más fuerte: un bloque preparado/forjado gana más margen de avance y boost. Monta un bloque más fuerte para subirlo.', blockStrengthSource: 'Cómo se detectó la fuerza. "rated" = del rating de par de BeamNG del bloque (lo más preciso). "estimated" = estimado de la potencia (sin rating). "default" = sin datos, 1.0x neutro.'
        },
        fr: {
          sensorMapOffset: 'Décale ce que l\'ECU lit en pression collecteur. Un mauvais décalage fait mal réagir un bon tune.', sensorMapScale: 'Met à l\'échelle la lecture du capteur MAP. 1.000 = correct.', sensorTpsOffset: 'Décale la lecture de position du papillon.', sensorTpsScale: 'Met à l\'échelle la lecture du papillon. 1.000 = correct.', sensorIatBias: 'Ajoute/retire à la temp. d\'air admission vue par l\'ECU (déplace la lecture ECU IAT et le retrait d\'avance lié à l\'air chaud).', sensorEctBias: 'Ajoute/retire à la temp. liquide (affecte carburant de chauffe + boucle fermée).', sensorLambdaBias: 'Décale la lecture du wideband; un capteur biaisé trompe la boucle fermée.', sensorFuelPressureBias: 'Décale la pression de rampe affichée.',
          fuelEquationMode: 'Comment le carburant est calculé. Modélisé utilise le MAP pour la demande d\'air; Traditionnel utilise papillon + charge.', masterFuel: 'Multiplicateur global de carburant. Haut = plus riche partout, bas = plus pauvre.', injectorFlow: 'Taille injecteur (cc/min). De plus gros injecteurs débitent plus avant de saturer.', injectorDeadtime: 'Délai ouverture/fermeture injecteur. Affecte surtout les petites impulsions au ralenti.', stoichRatio: 'Rapport air/carburant de votre carburant (14.7 essence, 9.8 E85). Définit l\'affichage AFR <-> lambda.', fuelType: 'Essence, E85 ou E30. Le E85 résiste au cliquetis (plus d\'avance/boost) et fait un peu plus de puissance, mais demande ~50% de carburant en plus - surveillez le cycle injecteur. C16 = essence course (118 octane). Méthanol M1 = le plus résistant au cliquetis, EGT froides, mais ~2.3x d\'injecteur.',
          cllMode: 'Correction carburant en boucle fermée: arrêt, stoechio. seul., ou toujours sur la cible lambda.', cllEctLockout: 'La boucle fermée reste arrêtée tant que le liquide n\'a pas dépassé cette temp.', cllTpsLockout: 'La boucle fermée se coupe au-dessus de ce papillon (pour ne pas gêner à fond).', cllMapLockout: 'La boucle fermée se coupe au-dessus de ce MAP (sous boost).',
          cylTrim1: CYL_FR, cylTrim2: CYL_FR, cylTrim3: CYL_FR, cylTrim4: CYL_FR, cylTrim5: CYL_FR, cylTrim6: CYL_FR, cylTrim7: CYL_FR, cylTrim8: CYL_FR,
          crankEnrich: 'Carburant en plus au lancement. Trop peu = démarrage à froid difficile.', postStartEnrich: 'Carburant en plus juste après le départ, puis ça décline.', postStartDecay: 'Temps que met l\'enrichissement post-démarrage à disparaître.', warmupEnrich: 'Carburant en plus moteur froid; disparaît en chauffant.', warmupEndTemp: 'Temp. liquide où finit l\'enrichissement de chauffe.',
          accelEnrich: 'Jet de carburant à la remise des gaz. Trop peu = trou pauvre; trop = noyé.', accelDecay: 'Vitesse de disparition du jet d\'accélération.', wallWetting: 'Retient un peu de carburant transitoire pour imiter le film aux conduits.', decelFuelCut: 'Coupe le carburant au lever de pied (économise, donne des pétarades).', decelFuel: 'Quantité de carburant retirée au lever de pied quand la coupe est active. Plus négatif = coupe plus forte; moins négatif = plus doux, corrige le pauvre au lever de pied.',
          boostMode: 'Boucle ouverte = rapport de base seul. Boucle fermée = vise la cible de boost. Par rapport = boost selon le rapport.', boostBaseDuty: 'Point de départ wastegate. Plus haut = plus de boost avant correction.', boostPGain: 'Force de correction du boost. Plus haut = plus serré, coupe le pic; trop haut = ça oscille.', boostIGain: 'Enlève lentement l\'erreur de boost stable pour rester pile sur la cible.', boostOvercut: 'De combien le boost peut dépasser la cible avant de compter comme surpression (dégâts/coupe).',
          knockControl: 'Laisse l\'ECU retirer de l\'avance en cas de cliquetis. ARRÊT = la détonation court librement.', knockThreshold: 'Sensibilité de détection du cliquetis. Plus bas = réagit plus tôt.', knockMaxRetard: 'Avance max que l\'ECU retire pour stopper le cliquetis.', knockRecoveryRate: 'Vitesse à laquelle l\'avance retirée est rendue après le cliquetis.',
          idleEnabled: 'Régulation de ralenti en boucle fermée pour tenir le régime cible.', idleTargetRpm: 'Le régime de ralenti tenu par l\'ECU.', idleBasePos: 'Position de départ de la vanne de ralenti avant correction.', idleIgnControl: 'Utilise aussi de petits changements d\'avance pour stabiliser le ralenti.', antilagEnabled: 'Modélise chaleur, son et flamme sans gaz ; demande l’anti-lag natif si le turbo le permet. Le boost réel dépend du véhicule.', antilagRetard: 'Avance retirée pour augmenter la chaleur et les événements modélisés.', antilagFuel: 'Carburant en plus (plus de bang/chaleur ; boost natif seulement sur turbo compatible).',
          vvtEnabled: 'Calage de cames variable - remplit le mi-régime (le coup VTEC).', vvtIntakeAdvance: 'De combien la came admission avance. Plus = plus de couple en mi-régime.', ethrottleMode: 'Câble = pédale 1:1. E-Throttle = la pédale passe par une carte + moniteur de sécurité.', ethrottleCurve: 'Ressenti pédale: Linéaire, Progressif (mou en bas) ou Agressif (vif en bas).', ethrottleTolerance: 'Écart papillon/cible toléré avant une coupe de sécurité.', triggerPattern: 'Qualité du signal vilebrequin/came. Bruit ou sync manquante cause ratés + défauts.', baseTiming: 'Avance de base absolue sur laquelle l\'angle d\'allumage affiché se construit - réglez-la selon la spec de votre moteur, comme le Set Base Timing de PCLink. Calibration d\'affichage seule : puissance/cliquetis/EGT suivent vos corrections.',
          canMode: 'Config. émission CAN (entraînement/état seul - aucun effet conduite).', canSpeed: 'Vitesse du bus CAN (entraînement/état seul).', canRuntime: 'Émet les valeurs live sur CAN (état seul).', canLambda: 'Émet le lambda sur CAN (état seul).', canDash: 'Émet les données tableau sur CAN (état seul).', auxFanOnTemp: 'Temp. liquide où le ventilateur se met en marche.', auxFuelPumpPrime: 'Durée d\'amorçage de la pompe au contact.', auxShiftLight: 'Régime où le témoin de passage s\'allume.', auxCheckEngine: 'Allume la sortie voyant moteur en cas de défaut/cliquetis/dégât.',
          revLimit: 'Régime max (0 = limiteur usine). Baissez-le pour protéger le moteur, ou MONTEZ-le au-dessus de l\'origine (jusqu\'à la fin de la courbe de couple, max +1500). Dépasser le rupteur d\'usine abîme le moteur avec le temps - comme en vrai. Monté ? Étendez le haut de l\'axe RPM (Axes) pour régler carburant/allumage dans la nouvelle plage.', launchEnabled: 'Limiteur de départ 2-step - tient les tours sur la ligne pour un départ fort.', launchLimit: 'Régime auquel le 2-step vous tient en position.', drivetrainLoss: 'La puissance roue est désormais MESURÉE (comme un banc à moyeu) - ce curseur n\'est que le secours et votre facteur d\'estimation vilebrequin. Faites une passe, lisez les pertes MESURÉES et calez-le dessus.', wgType: 'PNEUMATIQUE (défaut) : wastegate à ressort réel - le boost ne descend jamais sous le ressort, et le ressort est le curseur de boost du Tuning du jeu. ÉLECTRIQUE : wastegate motorisé moderne à pleine autorité, peut tenir le boost sous le ressort. Les deux sont du vrai matériel.', enabled: 'Marche/arrêt maître ECU. Arrêt rend tout le contrôle à l\'origine.', protection: 'Protection moteur - coupe la puissance au cliquetis/surpression. Désactivée, vous pouvez le faire exploser.', blockStrength: 'Combien d\'abus mécanique le bloc monté encaisse avant de lâcher. Détecté automatiquement du bloc - non réglable. Plus haut = plus solide : un bloc préparé/forgé gagne plus de marge d\'avance et de boost. Montez un bloc plus solide pour l\'augmenter.', blockStrengthSource: 'Comment la force a été détectée. "rated" = du rating de couple BeamNG du bloc (le plus précis). "estimated" = estimé de la puissance (pas de rating). "default" = aucune donnée, 1.0x neutre.'
        },
        pt: {
          sensorMapOffset: 'Desloca o que a ECU lê de pressão do coletor. Um offset ruim faz um bom tune se comportar mal.', sensorMapScale: 'Escala a leitura do sensor MAP. 1.000 = correto.', sensorTpsOffset: 'Desloca a leitura de posição do acelerador.', sensorTpsScale: 'Escala a leitura do acelerador. 1.000 = correto.', sensorIatBias: 'Soma/subtrai à temp. do ar de admissão que a ECU vê (move a leitura ECU IAT e a redução de avanço por ar quente).', sensorEctBias: 'Soma/subtrai à temp. do líquido (afeta combustível de aquecimento + malha fechada).', sensorLambdaBias: 'Desloca a leitura do wideband; um sensor enviesado engana a malha fechada.', sensorFuelPressureBias: 'Desloca a pressão de linha exibida.',
          fuelEquationMode: 'Como o combustível é calculado. Modelado usa MAP para a demanda de ar; Tradicional usa acelerador + carga.', masterFuel: 'Escalador geral de combustível. Acima = mais rico em tudo, abaixo = mais pobre.', injectorFlow: 'Tamanho do injetor (cc/min). Injetores maiores fluem mais antes de saturar.', injectorDeadtime: 'Atraso de abertura/fechamento do injetor. Afeta mais os pulsos pequenos na marcha lenta.', stoichRatio: 'Relação ar/combustível do seu combustível (14.7 gasolina, 9.8 E85). Define a tela AFR <-> lambda.', fuelType: 'Gasolina, E85 ou E30. O E85 resiste à detonação (mais avanço/boost) e faz um pouco mais de potência, mas precisa de ~50% mais combustível - observe o ciclo do injetor. C16 = gasolina de corrida (118 octanas). Metanol M1 = o mais resistente à detonação, EGT frias, mas precisa de ~2.3x de injetor.',
          cllMode: 'Correção de combustível em malha fechada: desligado, só estequio., ou sempre no alvo lambda.', cllEctLockout: 'A malha fechada fica desligada até o líquido passar desta temp.', cllTpsLockout: 'A malha fechada desliga acima deste acelerador (para não atrapalhar a fundo).', cllMapLockout: 'A malha fechada desliga acima deste MAP (sob boost).',
          cylTrim1: CYL_PT, cylTrim2: CYL_PT, cylTrim3: CYL_PT, cylTrim4: CYL_PT, cylTrim5: CYL_PT, cylTrim6: CYL_PT, cylTrim7: CYL_PT, cylTrim8: CYL_PT,
          crankEnrich: 'Combustível extra na partida. Pouco demais = partida a frio difícil.', postStartEnrich: 'Combustível extra logo após a partida, depois decai.', postStartDecay: 'Quanto tempo o enriquecimento pós-partida leva para sumir.', warmupEnrich: 'Combustível extra com o motor frio; some ao aquecer.', warmupEndTemp: 'Temp. do líquido onde termina o enriquecimento de aquecimento.',
          accelEnrich: 'Jato de combustível ao acelerar. Pouco = tranco pobre; muito = afogado.', accelDecay: 'Quão rápido o jato de aceleração some.', wallWetting: 'Retém um pouco de combustível transitório para imitar o filme nos dutos.', decelFuelCut: 'Corta combustível ao tirar o pé (economiza, dá estouros no escape).', decelFuel: 'Quanto combustível tirar ao soltar quando o corte está ativo. Mais negativo = corte mais forte; menos negativo = mais suave, cura o empobrecimento ao soltar.',
          boostMode: 'Malha aberta = só ciclo base. Malha fechada = persegue o alvo de boost. Por marcha = boost por marcha.', boostBaseDuty: 'Ponto de partida da wastegate. Maior = mais boost antes do controle ajustar.', boostPGain: 'Quão forte o controle de boost corrige. Maior = mais firme, corta o pico; alto demais = oscila.', boostIGain: 'Remove devagar o erro de boost estável para ficar exato no alvo.', boostOvercut: 'Quanto o boost pode passar do alvo antes de contar como sobreboost (dano/corte).',
          knockControl: 'Deixa a ECU tirar avanço ao detectar detonação. DESLIGADO = a detonação corre solta.', knockThreshold: 'Quão sensível é a detecção de detonação. Menor = reage antes.', knockMaxRetard: 'Avanço máximo que a ECU tira para parar a detonação.', knockRecoveryRate: 'Quão rápido o avanço tirado volta após a detonação parar.',
          idleEnabled: 'Controle de marcha lenta em malha fechada para manter o RPM alvo.', idleTargetRpm: 'O RPM de marcha lenta que a ECU mantém.', idleBasePos: 'Posição inicial da válvula de marcha lenta antes da correção.', idleIgnControl: 'Também usa pequenas mudanças de avanço para estabilizar a marcha lenta.', antilagEnabled: 'Modela calor, som e chama sem acelerador; solicita anti-lag nativo se o turbo suportar. O giro real depende do veículo.', antilagRetard: 'Avanço tirado para aumentar calor e eventos modelados.', antilagFuel: 'Combustível extra (mais estouro/calor; giro nativo só em turbo compatível).',
          vvtEnabled: 'Comando de válvulas variável - enche o médio (o tranco VTEC).', vvtIntakeAdvance: 'Quanto o comando de admissão avança. Mais = mais torque no médio.', ethrottleMode: 'Cabo = pedal 1:1. E-Throttle = o pedal passa por um mapa + monitor de segurança.', ethrottleCurve: 'Toque do pedal: Linear, Progressivo (preguiçoso embaixo) ou Agressivo (vivo embaixo).', ethrottleTolerance: 'Quanto o acelerador pode diferir do alvo antes de um corte de segurança.', triggerPattern: 'Qualidade do sinal de virabrequim/comando. Ruído ou falta de sync causa falhas + defeitos.', baseTiming: 'Avanço base absoluto sobre o qual o ângulo de ignição exibido é construído - ajuste pela especificação do seu motor, como o Set Base Timing do PCLink. Só calibração de exibição: potência/detonação/EGT seguem seus ajustes.',
          canMode: 'Config. de transmissão CAN (treino/status só - sem efeito ao dirigir).', canSpeed: 'Velocidade do barramento CAN (treino/status só).', canRuntime: 'Transmite valores ao vivo no CAN (status só).', canLambda: 'Transmite lambda no CAN (status só).', canDash: 'Transmite dados do painel no CAN (status só).', auxFanOnTemp: 'Temp. do líquido em que a ventoinha liga.', auxFuelPumpPrime: 'Quanto tempo a bomba escorva ao ligar a chave.', auxShiftLight: 'RPM em que a luz de troca acende.', auxCheckEngine: 'Acende a saída check-engine quando há defeito/detonação/dano.',
          revLimit: 'RPM máximo (0 = limitador de fábrica). Abaixe para proteger o motor, ou SUBA acima do original (até o fim da curva de torque, máx +1500). Passar do corte de fábrica danifica o motor com o tempo - como na vida real. Subiu? Estenda o topo do eixo de RPM (Eixos) para acertar combustível/ignição na nova faixa.', launchEnabled: 'Limitador de largada 2-step - segura o giro na linha para uma saída forte.', launchLimit: 'RPM em que o 2-step te segura na largada.', drivetrainLoss: 'A potência na roda agora é MEDIDA (como um dyno de cubo) - este slider é só o reserva e seu fator de estimativa no virabrequim. Faça uma passada, leia a perda MEDIDA e ajuste para bater.', wgType: 'PNEUMÁTICA (padrão): wastegate de mola real - o boost nunca desce abaixo da mola, e a mola é o slider de boost do Tuning do jogo. ELÉTRICA: wastegate motorizada moderna com autoridade total, pode segurar o boost abaixo da mola. Ambas são hardware real.', enabled: 'Liga/desliga mestre da ECU. Desligado devolve todo o controle ao original.', protection: 'Proteção do motor - corta potência na detonação/sobreboost. Desligada deixa você explodir.', blockStrength: 'Quanto abuso mecânico o bloco montado aguenta antes de estourar. Detectado automaticamente do bloco - não dá para definir. Mais alto = mais forte: um bloco preparado/forjado ganha mais margem de avanço e boost. Monte um bloco mais forte para aumentar.', blockStrengthSource: 'Como a força foi detectada. "rated" = do rating de torque BeamNG do bloco (mais preciso). "estimated" = estimado da potência (sem rating). "default" = sem dados, 1.0x neutro.'
        }
      };
      // These three legacy translations used to claim that merely crossing the fitted ECU's stock
      // limiter caused damage. Keep every language aligned with the physical-threshold correction.
      TIPSL.es.revLimit = 'RPM máximo (0 = limitador de fábrica). Bájalo para proteger el motor o súbelo solo hasta el techo mecánico mostrado. El daño de OE empieza en el límite físico del motor de BeamNG, no en el limitador original de la ECU.';
      TIPSL.fr.revLimit = 'Régime max (0 = limiteur usine). Baissez-le pour protéger le moteur ou montez-le seulement jusqu’au plafond mécanique affiché. Les dégâts OE commencent à la limite physique du moteur BeamNG, pas au limiteur d’origine de l’ECU.';
      TIPSL.pt.revLimit = 'RPM máximo (0 = limitador de fábrica). Abaixe para proteger o motor ou aumente apenas até o teto mecânico exibido. O dano da OE começa no limite físico do motor do BeamNG, não no limitador original da ECU.';
      var NAVTIPSL = {
        es: { runtime: 'Valores en vivo de la ECU - lambda, detonación, ciclo inyector, temps, fallos.', gauges: 'Barras de aviso de un vistazo para una pasada.', dyno: 'Mide potencia - pasada base, ajusta, repite para comparar.', logging: 'Graba y revisa una pasada en un gráfico de tiempo.', log_navigator: 'Recorre una pasada grabada y salta a eventos.', tutorial: 'Lecciones de tuning interactivas paso a paso.', diagnose: 'Doctor de Tune - te dice qué está mal y cómo arreglarlo.', tunes: 'Presets, ranuras de guardado y la biblioteca de tunes.', notes: 'Cuaderno de qué cambiaste y por qué.', sensor_setup: 'Calibra lo que la ECU sensa (MAP/TPS/IAT/ECT/lambda).', axis_setup: 'Edita los puntos de RPM y MAP de las tablas.', tune_compare: 'Compara el tune actual con una base guardada.', limits: 'Límite de RPM, largada 2-step, pérdida de transmisión.', protection: 'Activación maestra, protección del motor, control de detonación.', faults: 'Códigos de fallo retenidos - borra fallos / reinicia daño del motor.', fuel_setup: 'Combustible base, tamaño de inyector y la ecuación de combustible.', fuel: 'Tabla Combustible Principal - ajuste de combustible por RPM y carga.', lambda: 'Tabla Objetivo Lambda - tu objetivo aire/combustible por RPM y carga.', cll: 'Ajustes y bloqueos del lambda en lazo cerrado.', cyl_trims: 'Ajustes de combustible por cilindro.', mixture_map: 'Correcciones de combustible auto-sugeridas de una pasada grabada.', startup_warmup: 'Combustible de arranque, post-arranque y calentamiento.', transient_fuel: 'Combustible al pisar y al levantar el acelerador.', ignition: 'Tabla Encendido Principal - ajuste de avance por RPM y carga.', knock_control: 'Ajustes de detección de detonación y protección.', boost: 'Tabla Objetivo de Boost - cuánto boost quieres por RPM (turbo).', boost_control: 'Cómo el wastegate entrega tu objetivo de boost (turbo).', idle_control: 'Control de velocidad de ralentí.', antilag: 'Anti-lag (turbo) - mantiene el turbo soplando sin acelerador.', vvt: 'Distribución variable de levas - par en medio régimen.', ethrottle: 'Mapeo del acelerador electrónico y seguridad.', triggers: 'Calidad de la señal de disparo cigüeñal/leva + calibración del avance base.', can_setup: 'Configuración de transmisión CAN (solo estado).', aux_outputs: 'Salidas de ventilador, bomba, luz de cambio, check-engine.', help: 'Acerca de esta app y cómo funciona.' },
        fr: { runtime: 'Valeurs live de l\'ECU - lambda, cliquetis, cycle injecteur, temps, défauts.', gauges: 'Barres d\'alerte en un coup d\'oeil pour un passage.', dyno: 'Mesure la puissance - passage de base, réglez, refaites pour comparer.', logging: 'Enregistre et revoit un passage sur un graphe temporel.', log_navigator: 'Parcourez un passage enregistré et sautez aux événements.', tutorial: 'Leçons de tuning interactives pas à pas.', diagnose: 'Docteur Tune - vous dit ce qui ne va pas et comment corriger.', tunes: 'Préréglages, emplacements de sauvegarde et bibliothèque de tunes.', notes: 'Carnet de ce que vous avez changé et pourquoi.', sensor_setup: 'Calibre ce que l\'ECU capte (MAP/TPS/IAT/ECT/lambda).', axis_setup: 'Modifie les points de régime et MAP des tables.', tune_compare: 'Compare le tune actuel à une référence sauvegardée.', limits: 'Limite de régime, départ 2-step, pertes de transmission.', protection: 'Activation maître, protection moteur, contrôle du cliquetis.', faults: 'Codes défaut mémorisés - efface les défauts / réinitialise les dégâts moteur.', fuel_setup: 'Carburant maître, taille injecteur et l\'équation de carburant.', fuel: 'Table Carburant Principal - réglage carburant par régime et charge.', lambda: 'Table Cible Lambda - votre cible air/carburant par régime et charge.', cll: 'Réglages et verrous du lambda en boucle fermée.', cyl_trims: 'Réglages carburant par cylindre.', mixture_map: 'Corrections carburant auto-suggérées depuis un passage enregistré.', startup_warmup: 'Carburant de lancement, post-démarrage et chauffe.', transient_fuel: 'Carburant à la remise des gaz et au lever de pied.', ignition: 'Table Allumage Principal - réglage avance par régime et charge.', knock_control: 'Réglages de détection du cliquetis et protection.', boost: 'Table Cible de Boost - combien de boost vous voulez par régime (turbo).', boost_control: 'Comment le wastegate délivre votre cible de boost (turbo).', idle_control: 'Régulation du régime de ralenti.', antilag: 'Anti-lag (turbo) - garde le turbo gonflé sans gaz.', vvt: 'Calage de cames variable - couple en mi-régime.', ethrottle: 'Cartographie de l\'accélérateur électronique et sécurité.', triggers: 'Qualité du signal capteur vilebrequin/came + calibration de l\'avance de base.', can_setup: 'Config. émission CAN (état seul).', aux_outputs: 'Sorties ventilateur, pompe, témoin de passage, voyant moteur.', help: 'À propos de cette app et son fonctionnement.' },
        pt: { runtime: 'Valores ao vivo da ECU - lambda, detonação, ciclo injetor, temps, falhas.', gauges: 'Barras de aviso de relance para uma passada.', dyno: 'Mede potência - passada base, ajuste, repita para comparar.', logging: 'Grava e revê uma passada num gráfico de tempo.', log_navigator: 'Percorra uma passada gravada e pule para eventos.', tutorial: 'Lições de tuning interativas passo a passo.', diagnose: 'Doutor de Tune - diz o que está errado e como corrigir.', tunes: 'Presets, slots de salvamento e a biblioteca de tunes.', notes: 'Caderno do que você mudou e por quê.', sensor_setup: 'Calibra o que a ECU sente (MAP/TPS/IAT/ECT/lambda).', axis_setup: 'Edita os pontos de RPM e MAP das tabelas.', tune_compare: 'Compara o tune atual com uma base salva.', limits: 'Limite de RPM, largada 2-step, perda de transmissão.', protection: 'Ativação mestre, proteção do motor, controle de detonação.', faults: 'Códigos de falha retidos - apaga falhas / reseta dano do motor.', fuel_setup: 'Combustível base, tamanho de injetor e a equação de combustível.', fuel: 'Tabela Combustível Principal - ajuste de combustível por RPM e carga.', lambda: 'Tabela Alvo Lambda - seu alvo ar/combustível por RPM e carga.', cll: 'Ajustes e bloqueios do lambda em malha fechada.', cyl_trims: 'Ajustes de combustível por cilindro.', mixture_map: 'Correções de combustível auto-sugeridas de uma passada gravada.', startup_warmup: 'Combustível de partida, pós-partida e aquecimento.', transient_fuel: 'Combustível ao acelerar e ao tirar o pé.', ignition: 'Tabela Ignição Principal - ajuste de avanço por RPM e carga.', knock_control: 'Ajustes de detecção de detonação e proteção.', boost: 'Tabela Alvo de Boost - quanto boost você quer por RPM (turbo).', boost_control: 'Como a wastegate entrega seu alvo de boost (turbo).', idle_control: 'Controle de velocidade de marcha lenta.', antilag: 'Anti-lag (turbo) - mantém o turbo girando sem acelerador.', vvt: 'Comando variável - torque no médio.', ethrottle: 'Mapeamento do acelerador eletrônico e segurança.', triggers: 'Qualidade do sinal de disparo virabrequim/comando + calibração do avanço base.', can_setup: 'Config. de transmissão CAN (status só).', aux_outputs: 'Saídas de ventoinha, bomba, luz de troca, check-engine.', help: 'Sobre este app e como funciona.' }
      };
      // The repaired learner no longer depends on a recorded pull. Keep the
      // localized navigation help aligned with its ratio/evidence workflow.
      NAVTIPSL.es.mixture_map = 'Recomendaciones por relación lambda de puntos estables o pasadas limpias a fondo. Aplicar consume la evidencia y exige volver a conducir.';
      NAVTIPSL.fr.mixture_map = 'Recommandations par rapport lambda à partir de points stables ou de passages propres à pleine charge. Appliquer consomme les données et exige un nouveau roulage.';
      NAVTIPSL.pt.mixture_map = 'Recomendações pela relação lambda de pontos estáveis ou puxadas limpas em plena carga. Aplicar consome as evidências e exige dirigir novamente.';
      scope.navTip = function (id) {
        if (scope.lang !== 'en' && NAVTIPSL[scope.lang] && NAVTIPSL[scope.lang][id] !== undefined) { return NAVTIPSL[scope.lang][id]; }
        return NAVTIPS[id] || '';
      };

      // ====================================================================
      // telemetry formatting
      // ====================================================================
      var INT_KEYS = { rpm: 1, knockCount: 1, misfireCount: 1, faultCount: 1, commandedRevLimit: 1, activeRevLimit: 1, revLimitCeiling: 1, launchLimit: 1, rotIdleStrength: 1, mapKpa: 1, rawMapKpa: 1, boostKpa: 1, boostTargetKpa: 1, boostError: 1, engineTorqueNm: 1, wheelTorqueNm: 1, enginePowerHp: 1, wheelPowerHp: 1, calibrationTorqueDelta: 1, injectorDuty: 1, fuelPressureKpa: 1, fuelDiffPressureKpa: 1, iatC: 1, ectC: 1, oilC: 1, wastegateDuty: 1, ignitionAdvance: 1, idleValvePos: 1, idleError: 1, antilagHeat: 1, engineHealth: 1, damagePower: 1, vvtTarget: 1, vvtActual: 1, ethrottleTarget: 1, tpsDelta: 1, startupFuelTrim: 1 };
      var L3_KEYS = { lambdaTarget: 1, lambdaActual: 1, lambdaError: 1, trueLambda: 1 };
      var TEMP_KEYS = { ectC: 1, iatC: 1, oilC: 1, egtC: 1, clutchTempC: 1 };   // telemetry temps, all native °C - convert on DISPLAY only
      function cToF(c) { return c * 9 / 5 + 32; }
      // used by the Pro Studio Live Instruments (bind directly, not through tv())
      scope.tempVal = function (key) { var v = Number(scope.t[key]) || 0; return Math.round(scope.tempF ? cToF(v) : v); };
      scope.tempU = function () { return scope.tempF ? '°F' : '°C'; };
      var SPEED_KEYS = { boostSpeedKmh: 1, boostRawSpeedKmh: 1, speedKmh: 1 };   // ground-speed channels: display converts to mph when the toggle is on
      var PSI_REL = { boostKpa: 1, boostError: 1, boostGearTargetTrimKpa: 1, boostSpeedTargetTrimKpa: 1, boostTargetTrimAppliedKpa: 1 };          // already gauge/delta -> * psi
      var PSI_ABS_BOOST = { mapKpa: 1, rawMapKpa: 1, boostTargetKpa: 1, boostBaseTargetKpa: 1, boostRequestedTargetKpa: 1, boostHardwareFloorKpa: 1 }; // absolute MAP -> live-baro gauge psi
      var PSI_ABS = { baroKpa: 1, fuelPressureKpa: 1, fuelDiffPressureKpa: 1 }; // absolute pressure channels -> * psi
      function baroRefKpa() {
        var v = Number(scope.t && scope.t.baroKpa);
        return isFinite(v) && v > 0 ? v : 101.325;
      }
      function gaugePsiFor(key, absoluteKpa) {
        return (absoluteKpa - baroRefKpa()) * KPA_TO_PSI;
      }
      // boost-control outputs are meaningless on a naturally aspirated engine -> show "--"
      // (actual MAP/mapKpa is a real sensor on NA too, so it is NOT blanked)
      // V2.9.7 wave 4 (Liqurdz's BBG report): loop-breakdown psi terms are boost-control outputs too - blank on NA
      var BOOST_NA_BLANK = { boostTargetKpa: 1, boostBaseTargetKpa: 1, boostRequestedTargetKpa: 1, boostHardwareFloorKpa: 1, boostError: 1, wastegateDuty: 1, boostBaseWastegateDuty: 1, boostFeedforwardDuty: 1, boostPTermPsi: 1, boostDTermPsi: 1, boostITermPsi: 1, boostBiasPsi: 1 };
      scope.tv = function (key, unit, digits) {
        var v = scope.t[key];
        if (v === undefined || v === null) { return '--'; }
        if (BOOST_NA_BLANK[key] && scope.t.hasTurbo === false) { return '--'; }
        if (typeof v === 'boolean') { return v ? 'YES' : 'NO'; }
        if (typeof v === 'number') {
          if (TEMP_KEYS[key]) {
            var td = (digits === undefined) ? 0 : digits;
            return (scope.tempF ? cToF(v) : v).toFixed(td) + (scope.tempF ? ' °F' : ' °C');
          }
          if (SPEED_KEYS[key]) {
            var sd = (digits === undefined) ? 1 : digits;
            return (scope.mph ? v * 0.621371 : v).toFixed(sd) + (scope.mph ? ' mph' : ' km/h');
          }
          if (scope.psi && (PSI_REL[key] || PSI_ABS_BOOST[key] || PSI_ABS[key])) {
            var pv = PSI_ABS_BOOST[key] ? gaugePsiFor(key, v) : v * KPA_TO_PSI;
            return pv.toFixed(Math.abs(pv) < 100 ? 1 : 0) + ' psi';
          }
          var d = digits;
          if (d === undefined) { d = INT_KEYS[key] ? 0 : (L3_KEYS[key] ? 3 : 1); }
          return v.toFixed(d) + (unit ? ' ' + unit : '');
        }
        return String(v);
      };
      // swap "(kPa)" -> "(psi)" / "(km/h)" -> "(mph)" / "(°C)" -> "(°F)" in a row label per the toggles.
      // The °C case was missing: scope.tv converts the VALUE by key (TEMP_KEYS) and appends its own
      // unit, so with °F selected the Runtime rows read "ECT (°C)  194 °F" - label and value
      // disagreeing on the same line. Covers the four runtime temp rows and both log-chart temp lanes.
      scope.rowLabel = function (s) {
        var out = String(s);
        if (scope.psi) { out = out.replace(/\(kPa\)/g, '(psi)'); }
        if (scope.mph) { out = out.replace(/\(km\/h\)/g, '(mph)'); }
        if (scope.tempF) { out = out.replace(/\(°C\)/g, '(°F)'); }
        return out;
      };
      // kPa sliders that should DISPLAY in psi when PSI units are on. The slider track stays in
      // kPa (ng-model + what we send to the controller is unchanged), so the thumb position and
      // tune storage are untouched - only the shown number+unit convert (linear, so the thumb
      // stays in the right spot). 'rel' = delta/bias (psi = kPa*0.145); 'absG' = absolute MAP
      // shown as boost gauge ((kPa-100)*0.145).
      var PSI_SLIDER = { boostOvercut: 'rel', sensorMapOffset: 'rel', sensorFuelPressureBias: 'rel', cllMapLockout: 'absG' };
      scope.psiSlider = function (ctrl) { return scope.psi && !!PSI_SLIDER[ctrl.key]; };
      scope.slDisp = function (ctrl) {
        var v = scope.getNum(ctrl.key);
        var pv = (PSI_SLIDER[ctrl.key] === 'absG') ? (v - baroRefKpa()) * KPA_TO_PSI : v * KPA_TO_PSI;
        return pv.toFixed(1) + ' psi';
      };
      // Sliders where 0 is a SENTINEL, not a value. Rev limit uses 0 to mean "keep the factory
      // limiter", so printing the raw number gave "0 rpm" - which reads as a rev limit of zero,
      // the one thing it cannot possibly mean, while the engine was actually limiting at 7,400.
      // Reported from a real session. The readout now names the limit that is genuinely in force.
      var ZERO_MEANS_STOCK = { revLimit: 'activeRevLimit' };
      scope.sldZeroStock = function (ctrl) {
        return !!ZERO_MEANS_STOCK[ctrl.key] && (Number(scope.getNum(ctrl.key)) || 0) <= 0;
      };
      scope.sldZeroStockText = function (ctrl) {
        var live = Number(scope.t && scope.t[ZERO_MEANS_STOCK[ctrl.key]]);
        if (!isFinite(live) || live <= 0) { return 'Stock'; }
        return 'Stock - ' + String(Math.round(live)).replace(/\B(?=(\d{3})+(?!\d))/g, ',') + ' rpm';
      };

      // ====================================================================
      // runtime page
      // ====================================================================
      scope.runtimeLeft = [
        { title: 'Tuning', rows: [
          ['Engine Speed (RPM)', 'rpm', '', 'cyan'],
          ['ECU MAP, modeled (kPa)', 'mapKpa', '', 'green'],
          ['Barometric pressure (kPa)', 'baroKpa', '', 'green'],
          ['Manifold vacuum', 'vacuumInHg', 'inHg', 'green'],
          ['Boost (kPa)', 'boostKpa', '', 'yellow', function (t) { return t.hasTurbo && t.mapKpa > t.boostTargetKpa + 25; }],
          ['Lambda Target', 'lambdaTarget', '', 'yellow', null, function (t) { return !t.isDiesel; }],
          ['Lambda Actual', 'lambdaActual', '', 'yellow', function (t) { return Math.abs(t.lambdaError || 0) > 0.06; }, function (t) { return !t.isDiesel; }],
          ['Lambda Δ (actual - target)', 'lambdaError', '', 'orange', null, function (t) { return !t.isDiesel; }],
          ['AFR (air:fuel)', 'afr', '', 'yellow', null, function (t) { return t.isDiesel; }],
          ['Smoke (%)', 'smoke', '', 'cyan', function (t) { return (t.smoke || 0) > 60; }, function (t) { return t.isDiesel; }],
          ['STFT (%)', 'stft', '', 'white', null, function (t) { return !t.isDiesel; }],
          ['LTFT (%)', 'ltft', '', 'white', null, function (t) { return !t.isDiesel; }],
          ['Fuel Trim (%)', 'fuelTrim', '', 'white'],
          ['Injector Duty (%)', 'injectorDuty', '', 'white', function (t) { return (t.injectorDuty || 0) > 90; }],
          // 1:1 boost-referenced regulator: RAIL pressure rises with boost, the DIFFERENTIAL across the
          // injectors is what collapses when the pump runs out - so the alarm keys on the differential
          ['Fuel Rail Press (kPa)', 'fuelPressureKpa', '', 'magenta', function (t) { return (t.fuelDiffPressureKpa || 999) < 255; }],
          ['Fuel Diff Press (kPa)', 'fuelDiffPressureKpa', '', 'magenta', function (t) { return (t.fuelDiffPressureKpa || 999) < 255; }],
          ['CL Lambda', 'closedLoopStatus', '', 'cyan', null, function (t) { return !t.isDiesel; }]
        ] },
        { title: 'Ignition / Knock', rows: [
          ['Ignition Angle (BTDC)', 'ignitionAdvance', '', 'white'],
          ['Ignition Trim (table) (deg)', 'effectiveIgnitionTrim', '', 'white'],   // V2.9.7 beta feedback (C3): "(table)" - excludes Base Timing
          ['IAT Timing Trim (deg)', 'iatTimingTrim', '', 'orange'],
          ['Knock Level', 'knockLevel', '', 'cyan', function (t) { return (t.knockLevel || 0) > 1.2; }],
          ['Knock Retard (deg)', 'knockRetard', '', 'cyan', function (t) { return (t.knockLevel || 0) > 1.2 && (t.knockRetard || 0) < 0.1; }],
          ['Knock Count', 'knockCount', '', 'white']
        ] },
        { title: 'Misfire / Faults', rows: [
          ['Misfire Status', 'misfireStatus', '', 'white', function (t) { return t.misfireStatus && t.misfireStatus !== 'OK'; }],
          ['Misfire Count', 'misfireCount', '', 'white'],
          ['Fault Codes', 'faults', '', 'green', function (t) { return t.faults && t.faults !== 'None'; }],
          ['Limp Mode', 'limpMode', '', 'white', function (t) { return t.limpMode; }]
        ] }
      ];
      scope.runtimeRight = [
        { title: 'Engine Protection', rows: [
          ['RPM Limit', 'commandedRevLimit', '', 'green'],
          ['Active Rev Limit', 'activeRevLimit', '', 'green'],
          ['Launch Limit', 'launchLimit', '', 'magenta', function (t) { return t.launchActive; }],
          ['Limiter Status', 'limiterStatus', '', 'yellow'],
          ['Protection', 'protectionStatus', '', 'orange'],
          ['Engine Health (%)', 'engineHealth', '', 'green', function (t) { return (t.engineHealth || 100) < 85; }],
          ['Power Limit (%)', 'damagePower', '', 'white'],
          ['Block Strength', 'blockStrength', 'x', 'cyan']
        ] },
        { title: 'Boost Control', rows: [
          ['Active MAP', 'mapSlotLabel', '', 'green', function (t) { return (t.mapSlotPending || 0) > 0; }],
          ['Base Target (kPa)', 'boostBaseTargetKpa', '', 'cyan'],
          ['Shaped Request (kPa)', 'boostRequestedTargetKpa', '', 'cyan'],
          ['Active Target (kPa)', 'boostTargetKpa', '', 'cyan'],
          ['Boost Error (kPa)', 'boostError', '', 'cyan'],
          ['Ground Speed (km/h)', 'boostSpeedKmh', '', 'green'],
          ['Boost Shaping', 'boostShapeStatus', '', 'orange'],
          ['Target Source', 'boostLimitReason', '', 'yellow'],
          ['Feed-forward Duty (%)', 'boostFeedforwardDuty', '', 'white'],
          ['Combined WG Trim (%)', 'boostDutyTrimPct', '', 'white'],
          ['Wastegate Duty (%)', 'wastegateDuty', '', 'white'],
          // V3.0.8: this row had only 4 elements, but rowAlarm reads row[4] - so the one line that
          // says "TURBO MAXED at ~N psi - target unreachable" could never highlight and sat as
          // ordinary text among 11 other rows. The 'yellow' at index 3 colours the LABEL only.
          ['Boost Status', 'boostControlStatus', '', 'yellow', function (t) { return !!t.boostMaxed || !!t.boostFloored; }]
        ] },
        { title: 'Power Estimate', rows: [
          ['Engine Torque (Nm)', 'engineTorqueNm', '', 'white'],
          ['Engine Power (hp)', 'enginePowerHp', '', 'white'],
          ['Wheel Power (whp)', 'wheelPowerHp', '', 'white'],
          ['Cal Torque Δ (Nm)', 'calibrationTorqueDelta', '', 'white']
        ] },
        { title: 'Sensors', rows: [
          ['ECT (°C)', 'ectC', '', 'magenta', function (t) { return (t.ectC || 0) > 110; }],
          ['Oil (°C)', 'oilC', '', 'magenta', function (t) { return (t.oilC || 0) > 135; }],
          ['EGT (°C)', 'egtC', '', 'red', function (t) { return (t.egtC || 0) > 950; }],
          ['IAT (°C)', 'iatC', '', 'yellow'],
          ['TPS (%)', 'tps', '', 'green']
        ] }
      ];
      scope.rowAlarm = function (row) { return row[4] ? !!row[4](scope.t) : false; };
      scope.rowShow = function (row) { return row[5] ? !!row[5](scope.t) : true; };   // diesel (#4): show Lambda rows on petrol, AFR/Smoke on diesel

      // ====================================================================
      // gauges page
      // ====================================================================
      scope.gauges = [
        // DYNAMIC SCALES (V2.6): the RPM scale follows the car's redline INCLUDING a raised rev limit
        // (a 12k limit used to pin the needle at a fixed 9k and squash the red zone off the dial), and
        // the MAP scale follows the car's real max boost (68 psi drag builds used to peg a 300 kPa bar).
        // NOTE: slider-backed settings arrive as STRINGS from ng-model - always Number() before adding,
        // or "9850" + 700 becomes "9850700" and the scale explodes to millions (live bug report)
        { label: 'RPM', key: 'rpm', min: 0, max: function () { var rl = Number(scope.settings.revLimit) || Number(scope.base.revLimiterRPM) || 7000; return Math.max(8000, Math.ceil((rl + 700) / 1000) * 1000); }, unit: '', warn: function () { var rl = Number(scope.settings.revLimit) || Number(scope.base.revLimiterRPM) || 7000; return rl - 300; }, danger: function () { var rl = Number(scope.settings.revLimit) || Number(scope.base.revLimiterRPM) || 7000; return rl + 50; } },
        { label: 'MAP', key: 'mapKpa', min: 20, max: function () { return scope.base.hasTurbo ? Math.max(240, Math.ceil(((scope.base.turboMaxKpa || 200) + 40) / 20) * 20) : 120; }, unit: 'kPa', warn: function () { return (scope.t.boostTargetKpa || 180) + 20; }, danger: function () { return (scope.t.boostTargetKpa || 180) + 40; } },
        { label: 'Lambda', key: 'lambdaActual', min: 0.68, max: 1.18, unit: '', warn: function () { return (scope.t.lambdaTarget || 1) + 0.06; }, danger: function () { return (scope.t.lambdaTarget || 1) + 0.10; }, show: function () { return !scope.t.isDiesel; } },
        { label: 'AFR', key: 'afr', min: 8, max: 40, unit: ':1', warn: function () { return (scope.settings.dieselSmokeLimit || 17) + 3; }, danger: function () { return scope.settings.dieselSmokeLimit || 17; }, invert: true, show: function () { return scope.t.isDiesel; } },
        { label: 'Smoke', key: 'smoke', min: 0, max: 100, unit: '%', warn: function () { return 50; }, danger: function () { return 80; }, show: function () { return scope.t.isDiesel; } },
        { label: 'EGT', key: 'egtC', min: 300, max: 1100, unit: 'C', warn: function () { return (scope.settings.egtLimit || 950) - 50; }, danger: function () { return scope.settings.egtLimit || 950; }, show: function () { return scope.t.isDiesel; } },
        { label: 'Knock', key: 'knockLevel', min: 0, max: 5, unit: '', warn: function () { return scope.settings.knockThreshold || 1.2; }, danger: function () { return (scope.settings.knockThreshold || 1.2) + 1; }, show: function () { return !scope.t.isDiesel; } },
        { label: 'Knock retard', key: 'knockRetard', min: 0, max: 12, unit: 'deg', warn: function () { return (scope.settings.knockMaxRetard || 8) * 0.5; }, danger: function () { return scope.settings.knockMaxRetard || 8; }, show: function () { return !scope.t.isDiesel; } },
        { label: 'Injector duty', key: 'injectorDuty', min: 0, max: 120, unit: '%', warn: function () { return 90; }, danger: function () { return 105; } },
        // gauge watches the DIFFERENTIAL (the pump-health number: ~300 healthy, collapsing = trouble);
        // rail pressure legitimately climbs with boost so it can't have a fixed warn threshold
        { label: 'Fuel diff press', key: 'fuelDiffPressureKpa', min: 200, max: 360, unit: 'kPa', warn: function () { return 255; }, danger: function () { return 240; }, invert: true },
        { label: 'Engine health', key: 'engineHealth', min: 0, max: 100, unit: '%', warn: function () { return 85; }, danger: function () { return 60; }, invert: true }
      ];
      scope.gaugeShow = function (g) { return g.show ? !!g.show() : true; };   // diesel (#4): swap Lambda/Knock gauges for AFR/Smoke/EGT
      // min/max may be numbers or functions (dynamic scales) - resolve through these everywhere
      function gMin(g) { return (typeof g.min === 'function') ? g.min() : g.min; }
      function gMax(g) { return (typeof g.max === 'function') ? g.max() : g.max; }
      scope.gaugeFrac = function (g) {
        var lo = gMin(g), hi = gMax(g);
        var v = Number(scope.t[g.key]) || lo;
        return Math.max(0, Math.min(1, (v - lo) / (hi - lo))) * 100;
      };
      scope.gaugeColor = function (g) {
        var v = Number(scope.t[g.key]); if (v === undefined || isNaN(v)) { return '#2aa3ff'; }
        var w = g.warn(), d = g.danger();
        if (g.invert) { if (v <= d) { return '#e0252f'; } if (v <= w) { return '#ffb02e'; } }
        else { if (v >= d) { return '#e0252f'; } if (v >= w) { return '#ffb02e'; } }
        return '#2aa3ff';
      };

      // ---- PCLink-style ANALOG DIALS (V2.6): a second renderer for the same gauge defs. Real tuner
      // software shows round dials with a needle, tick scale and colored warn/danger arcs - this draws
      // exactly that on a small canvas per gauge, redrawn on each telemetry tick while the page shows.
      // Toggle persists like the kPa/PSI pref; the bar view stays available.
      scope.gaugeStyle = 2;   // 1 = bars, 2 = dials (default - it's the real-tuner look)
      try { scope.gaugeStyle = parseInt(localStorage.getItem('oep_gaugestyle') || '2', 10) || 2; } catch (e) {}
      scope.setGaugeStyle = function (s) {
        scope.gaugeStyle = s;
        try { localStorage.setItem('oep_gaugestyle', String(s)); } catch (e) {}
        setTimeout(drawDials, 40);
      };
      // native-scale value -> the unit the dial DISPLAYS (mirrors tv()'s psi conversions so the tick
      // labels always agree with the value readout)
      function dialDispVal(g, v) {
        if (scope.tempF && TEMP_KEYS[g.key]) { return cToF(v); }
        if (scope.psi && (PSI_REL[g.key] || PSI_ABS_BOOST[g.key] || PSI_ABS[g.key])) {
          return PSI_ABS_BOOST[g.key] ? gaugePsiFor(g.key, v) : v * KPA_TO_PSI;
        }
        return v;
      }
      function dialTickLabel(g, v) {
        var dv = dialDispVal(g, v);
        var range = Math.abs(dialDispVal(g, gMax(g)) - dialDispVal(g, gMin(g)));
        if (range <= 2) { return dv.toFixed(2); }
        if (range <= 40) { return dv.toFixed(0); }
        return String(Math.round(dv));
      }
      function dialUnit(g) {
        if (TEMP_KEYS[g.key]) { return scope.tempF ? '°F' : '°C'; }
        if (scope.psi && (PSI_REL[g.key] || PSI_ABS_BOOST[g.key] || PSI_ABS[g.key])) { return 'psi'; }
        return g.unit || '';
      }
      var dialPeaks = {};   // per-gauge peak-hold state for the dials (held ~6s, then bleeds down)
      function drawDial(cv, g) {
        var ctx = cv.getContext('2d');
        var w = cv.width, h = cv.height;
        var cx = w / 2, cy = h / 2 + 4, R = Math.min(w, h) / 2 - 8;
        var lo = gMin(g), hi = gMax(g);   // resolved once - min/max can be dynamic (RPM follows the rev limit, MAP the real max boost)
        var a0 = Math.PI * 0.75, a1 = Math.PI * 2.25;   // 270-degree sweep, gap at the bottom
        function angOf(v) {
          var f = Math.max(0, Math.min(1, (v - lo) / (hi - lo)));
          return a0 + f * (a1 - a0);
        }
        ctx.clearRect(0, 0, w, h);
        // face
        ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2);
        ctx.fillStyle = '#0b0b0e'; ctx.fill();
        ctx.lineWidth = 2; ctx.strokeStyle = '#3c3c46'; ctx.stroke();
        // warn/danger zone arcs (invert = the danger lives at the LOW end, e.g. fuel diff pressure)
        var wv = Math.max(lo, Math.min(hi, g.warn()));
        var dv = Math.max(lo, Math.min(hi, g.danger()));
        function zone(fromV, toV, color) {
          if (toV <= fromV) { return; }
          ctx.beginPath(); ctx.arc(cx, cy, R - 6, angOf(fromV), angOf(toV));
          ctx.strokeStyle = color; ctx.lineWidth = 6; ctx.stroke();
        }
        if (g.invert) { zone(lo, dv, '#e0252f'); zone(dv, wv, '#c8871e'); }
        else { zone(wv, dv, '#c8871e'); zone(dv, hi, '#e0252f'); }
        // ticks: 8 major segments with minors between
        for (var i = 0; i <= 24; i++) {
          var vv = lo + (hi - lo) * i / 24;
          var a = angOf(vv);
          var major = (i % 3 === 0);
          var r1 = R - 2, r2 = major ? R - 12 : R - 7;
          ctx.beginPath();
          ctx.moveTo(cx + Math.cos(a) * r1, cy + Math.sin(a) * r1);
          ctx.lineTo(cx + Math.cos(a) * r2, cy + Math.sin(a) * r2);
          ctx.strokeStyle = major ? '#d8d8d8' : '#77777f'; ctx.lineWidth = major ? 1.6 : 1; ctx.stroke();
          if (major && (i % 6 === 0)) {   // number every other major (5 labels: min, 25%, mid, 75%, max)
            var rl = R - 22;
            ctx.fillStyle = '#c8c8cc'; ctx.font = '8px Tahoma'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
            ctx.fillText(dialTickLabel(g, vv), cx + Math.cos(a) * rl, cy + Math.sin(a) * rl);
          }
        }
        // unit inside the face, PCLink style
        ctx.fillStyle = '#8b8b93'; ctx.font = '8px Tahoma'; ctx.textAlign = 'center';
        ctx.fillText(dialUnit(g), cx, cy - R * 0.36);
        // needle
        var val = Number(scope.t[g.key]);
        if (val !== undefined && !isNaN(val)) {
          var an = angOf(val);
          ctx.beginPath();
          ctx.moveTo(cx - Math.cos(an) * 9, cy - Math.sin(an) * 9);
          ctx.lineTo(cx + Math.cos(an) * (R - 16), cy + Math.sin(an) * (R - 16));
          ctx.strokeStyle = '#ffd23b'; ctx.lineWidth = 2.5; ctx.stroke();
          // peak-hold tick (V2.6.x, real tuner-gauge behavior): remember the highest needle sweep,
          // hold ~6s, then bleed down - catches peak boost/EGT/knock from a pull at a glance
          var now = Date.now();
          var pk = dialPeaks[g.key];
          if (!pk || val >= pk.v) { dialPeaks[g.key] = pk = { v: Math.min(val, hi), t: now }; }
          else if (now - pk.t > 6000) { pk.v -= (hi - lo) * 0.012; if (pk.v <= val) { pk.v = Math.min(val, hi); pk.t = now; } }
          if (pk.v > val + (hi - lo) * 0.01 && pk.v > lo) {
            var ap = angOf(Math.max(lo, Math.min(pk.v, hi)));
            ctx.beginPath();
            ctx.moveTo(cx + Math.cos(ap) * (R - 16), cy + Math.sin(ap) * (R - 16));
            ctx.lineTo(cx + Math.cos(ap) * (R - 5), cy + Math.sin(ap) * (R - 5));
            ctx.strokeStyle = 'rgba(255,210,59,0.55)'; ctx.lineWidth = 2; ctx.stroke();
          }
        }
        // hub
        ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2);
        ctx.fillStyle = '#c9c9cf'; ctx.fill(); ctx.strokeStyle = '#55555d'; ctx.lineWidth = 1; ctx.stroke();
        // value readout in the bottom gap, colored by the same warn/danger logic as the bars.
        // The unit already sits on the dial face - strip it from the value so psi cars don't
        // read "psi ... 18.3 psi" (tv()'s psi conversion appends the unit unconditionally).
        var vTxt = String(scope.tv(g.key, ''));
        var du = dialUnit(g);
        if (du && vTxt.slice(-(du.length + 1)) === ' ' + du) { vTxt = vTxt.slice(0, -(du.length + 1)); }
        ctx.fillStyle = scope.gaugeColor(g); ctx.font = 'bold 12px Tahoma'; ctx.textAlign = 'center';
        ctx.fillText(vTxt, cx, cy + R * 0.62);
      }
      function drawDials() {
        var proMapDials = IS_PRO_STUDIO && scope.isMapPage(scope.activePage);
        if (!proMapDials && (scope.activePage !== 'gauges' || scope.gaugeStyle !== 2)) { return; }
        var cvs = element[0].querySelectorAll('canvas[data-dial]');
        for (var i = 0; i < cvs.length; i++) {
          var key = cvs[i].getAttribute('data-dial');
          for (var j = 0; j < scope.gauges.length; j++) {
            if (scope.gauges[j].key === key) { drawDial(cvs[i], scope.gauges[j]); break; }
          }
        }
      }

      // ====================================================================
      // map tables: selection + editing
      // ====================================================================
      scope.sel = null;       // {r1,c1,r2,c2}
      scope.liveR = -1; scope.liveC = -1;
      scope.dragging = false;
      scope.editAmount = 1;
      scope.baselineMaps = {};   // per-table Δ baselines (V2.9.7) - keyed by selectedMap

      function meta() { return scope.mapMeta ? scope.mapMeta[scope.selectedMap] : { min: -25, max: 25, step: 0.5 }; }
      scope.curMap = function () { return scope.maps ? scope.maps[scope.selectedMap] : null; };
      scope.clearSelection = function () { scope.sel = null; scope.dragging = false; };
      function norm() {
        if (!scope.sel) { return null; }
        return { r1: Math.min(scope.sel.r1, scope.sel.r2), r2: Math.max(scope.sel.r1, scope.sel.r2), c1: Math.min(scope.sel.c1, scope.sel.c2), c2: Math.max(scope.sel.c1, scope.sel.c2) };
      }
      scope.selCount = function () { var s = norm(); return s ? (s.r2 - s.r1 + 1) * (s.c2 - s.c1 + 1) : 0; };
      scope.cellSelected = function (r, c) { var s = norm(); return !!s && r >= s.r1 && r <= s.r2 && c >= s.c1 && c <= s.c2; };
      scope.cellLive = function (r, c) { return r === scope.liveR && c === scope.liveC; };
      scope.beginSelect = function (r, c, ev2) { if (IS_MIRROR && ev2 && ev2.button !== undefined && ev2.button !== 0) { return; } if (ev2) { ev2.preventDefault(); } scope.dragging = true; scope.sel = { r1: r, c1: c, r2: r, c2: c }; if (IS_MIRROR && scope.focusTable) { scope.focusTable(); } };
      scope.extendSelect = function (r, c) { if (scope.dragging && scope.sel) { scope.sel.r2 = r; scope.sel.c2 = c; } };
      scope.endSelect = function () { scope.dragging = false; };
      scope.selectAll = function () { var m = scope.curMap(); if (m) { scope.sel = { r1: 0, c1: 0, r2: m.length - 1, c2: m[0].length - 1 }; } };

      scope.alphaNActive = function () {
        var diesel = (scope.t && scope.t.isDiesel === true) || (scope.base && scope.base.isDiesel === true);
        return !diesel && Number((scope.settings || {}).loadSource) === 2;
      };
      scope.mapUsesAlphaN = function (name) {
        name = name || scope.selectedMap;
        return scope.alphaNActive() && ['fuel', 'ignition', 'lambda', 've'].indexOf(name) >= 0;
      };
      scope.activeLoadAxis = function (name) {
        return scope.mapUsesAlphaN(name) ? (scope.alphaNAxis || []) : (scope.mapAxis || []);
      };
      scope.activeLoadValue = function (name, telemetry) {
        telemetry = telemetry || scope.t || {};
        return scope.mapUsesAlphaN(name) ? Number(telemetry.tps || 0) : Number(telemetry.mapKpa || 0);
      };
      scope.loadAxisTitle = function (name) {
        return scope.mapUsesAlphaN(name) ? 'Throttle Position / Load' : 'Manifold Pressure / Load';
      };

      function updateLiveCell(t) {
        var loadAxis = scope.activeLoadAxis();
        if (!scope.rpmAxis.length || !loadAxis.length) { return; }
        var rpm = t.rpm || 0, loadValue = scope.activeLoadValue(scope.selectedMap, t);
        var bc = 0, bd = 1e9;
        for (var c = 0; c < scope.rpmAxis.length; c++) { var d = Math.abs(rpm - scope.rpmAxis[c]); if (d < bd) { bd = d; bc = c; } }
        var br = 0; bd = 1e9;
        for (var r = 0; r < loadAxis.length; r++) { var d2 = Math.abs(loadValue - loadAxis[r]); if (d2 < bd) { bd = d2; br = r; } }
        scope.liveR = br; scope.liveC = bc;
      }

      scope.fmtCell = function (val) {
        var n = Number(val); if (!isFinite(n)) { return '?'; }
        var st = meta().step;
        return st >= 1 ? n.toFixed(0) : (st >= 0.1 ? n.toFixed(1) : n.toFixed(2));
      };
      // PSI display for the map table: the MAP/load axis shows boost-gauge psi, and Boost Target
      // cell values show psi (the other tables keep their native % / deg / lambda units).
      scope.axisLab = function (value, name) {
        if (scope.mapUsesAlphaN(name)) { return Math.round(Number(value) * 10) / 10; }
        if (!scope.psi) { return value; }
        var psiVal = (value - baroRefKpa()) * KPA_TO_PSI;
        // V3.0.8: precision follows row SPACING, not a fixed 0 decimals. An NA car's whole MAP axis
        // spans 20-100 kPa = ~11.6 psi, so at 16 rows the breakpoints are 0.73 psi apart and integer
        // rounding printed DUPLICATE labels on distinct rows (-9, -9, -7, -7 ...). Harmless at the
        // factory 6 rows where they sit 2.3 psi apart, which is why it never showed up before the
        // selectable grid. kPa needs no equivalent - those breakpoints are whole numbers already.
        var ax = scope.activeLoadAxis(name) || [];
        var stepPsi = ax.length > 1
          ? Math.abs((Number(ax[ax.length - 1]) - Number(ax[0])) * KPA_TO_PSI) / (ax.length - 1)
          : 99;
        return stepPsi < 1.5 ? Math.round(psiVal * 10) / 10 : Math.round(psiVal);
      };
      scope.axisUnit = function (name) { return scope.mapUsesAlphaN(name) ? 'TPS %' : (scope.psi ? 'psi' : 'kPa'); };
      scope.proMapTitle = function () {
        return ({ fuel: 'Fuel Table 1', ignition: 'Ignition Table 1', boost: 'Boost Target Table 1', lambda: 'Lambda Target Table 1', ve: 'VE Table 1', wgduty: 'Wastegate Duty Table 1' })[scope.selectedMap] || 'Calibration Table';
      };
      scope.proPct = function (key) {
        var n = Number((scope.t || {})[key]);
        return isFinite(n) ? Math.max(0, Math.min(100, n)) : 0;
      };
      // Display stoich for AFR conversion: the user-settable Stoich AFR (defaults to the fuel's
      // stoich - 14.7 pump, 9.8 E85, 12.4 E30). AFR = lambda * stoich; on pump gas that's the
      // familiar 14.7-referenced AFR. Matches the LIVE AFR bar so the two never disagree.
      scope.stoich = function () { return (scope.settings && Number(scope.settings.stoichRatio)) || 14.7; };
      scope.fmtCellU = function (val) {
        if (scope.psi && scope.selectedMap === 'boost') { return ((Number(val) - baroRefKpa()) * KPA_TO_PSI).toFixed(1); }
        if (scope.afr && scope.selectedMap === 'lambda') { return (Number(val) * scope.stoich()).toFixed(1); }
        return scope.fmtCell(val);
      };
      scope.cellUnitU = function () {
        if (scope.psi && scope.selectedMap === 'boost') { return 'psi'; }
        if (scope.afr && scope.selectedMap === 'lambda') { return 'AFR'; }
        return (scope.mapMeta && scope.mapMeta[scope.selectedMap]) ? scope.mapMeta[scope.selectedMap].unit : '';
      };
      // selection + live-cell are drawn as translucent background overlays, which
      // ALWAYS render on a <td> (outline / box-shadow are unreliable on table
      // cells in a border-collapse table inside Chromium / BeamNG's UI).
      scope.cellStyle = function (r, c) {
        var m = scope.curMap();
        if (!m || !m[r] || m[r][c] === undefined) { return {}; }
        var mt = meta();
        var style = {};
        if (scope.showDelta && scope.curBaseline()) {
          style['background-color'] = IS_PRO_STUDIO ? '#151516' : '#f0f0f0';
        } else {
          var frac = Math.max(0, Math.min(1, (m[r][c] - mt.min) / (mt.max - mt.min)));
          // PCLink-style heatmap: low values = red, high values = green
          var hue = frac * 120;
          // Pro Studio: dark saturated cells with white text; Basic/XP: lighter pastel with black text
          style['background-color'] = IS_PRO_STUDIO
            ? 'hsl(' + hue.toFixed(1) + ', 78%, ' + (34 + frac * 12) + '%)'
            : 'hsl(' + hue.toFixed(1) + ', 65%, ' + (72 + frac * 14) + '%)';
        }
        var overlays = [];
        // selection = bright yellow overlay; live = bright cyan overlay
        if (scope.cellSelected(r, c)) { overlays.push('linear-gradient(rgba(255,220,60,0.62),rgba(255,220,60,0.62))'); }
        if (scope.cellLive(r, c)) { overlays.push('linear-gradient(rgba(0,180,255,0.55),rgba(0,180,255,0.55))'); }
        if (overlays.length) { style['background-image'] = overlays.join(','); }
        return style;
      };
      scope.cellDelta = function (r, c) {
        var b = scope.curBaseline();
        if (!b || !b[r]) { return 0; }
        return (scope.curMap()[r][c] || 0) - (b[r][c] || 0);
      };
      // signed, step-aware delta text: fuel/lambda edit at 0.01 steps, so a +0.05 change must not
      // round away to +0.1 (or +0.0) the way a fixed toFixed(1) did
      scope.cellDeltaStr = function (r, c) {
        var d = scope.cellDelta(r, c);
        // match fmtCellU's display units, or the delta contradicts the header: a delta is a
        // DIFFERENCE, so the baro offset cancels for psi and AFR is a straight stoich multiply
        if (scope.psi && scope.selectedMap === 'boost') { d = d * KPA_TO_PSI; return (d >= 0 ? '+' : '') + d.toFixed(1); }
        if (scope.afr && scope.selectedMap === 'lambda') { d = d * scope.stoich(); return (d >= 0 ? '+' : '') + d.toFixed(2); }
        var mt = meta();
        return (d >= 0 ? '+' : '') + d.toFixed((mt && mt.step >= 1) ? 1 : 2);
      };

      function applyToSelection(fn) {
        var m = scope.curMap(); if (!m) { return; }
        if (IS_MIRROR && (!scope.remoteClient || !scope.remoteClient.authenticated)) {
          setToast(scope.remoteClient && scope.remoteClient.writerConnected ? 'Read only: another browser owns tuning control' : 'Read only: waiting for browser tuning control');
          return false;
        }
        if (IS_MIRROR && scope.gamePaused) { setToast('Game paused: table change not sent'); return false; }
        var s = norm() || { r1: scope.liveR, r2: scope.liveR, c1: scope.liveC, c2: scope.liveC };
        if (s.r1 < 0) { return false; }
        var mt = meta(), changed = false;
        for (var r = s.r1; r <= s.r2; r++) {
          var row = m[r].slice();
          for (var c = s.c1; c <= s.c2; c++) {
            var nv = fn(Number(row[c]) || 0, r, c);
            row[c] = Math.round(Math.max(mt.min, Math.min(mt.max, nv)) * 1000) / 1000;
          }
          // Send FIRST; only paint what the transport actually accepted. A disconnected/read-only
          // mirror must never show phantom cells the ECU never received (in-game sendMapRow always
          // returns true, so every row still paints exactly as 2.9.5 did).
          if (sendMapRow(scope.selectedMap, r, row) !== false) { m[r] = row; changed = true; }
        }
        if (changed) {
          // The learned table is evidence about the calibration that produced it.
          // A hand edit (including fill/smooth/interpolate/paste) starts a new test.
          invalidateMixture('table edited');
          saveAutosave();
        }
        return changed;
      }
      scope.stepAmount = function () { return Math.round(meta().step * scope.editAmount * 100) / 100; };
      // Label the +/- buttons in the unit you're LOOKING at. addSel below still nudges the stored
      // lambda by stepAmount(); because AFR = lambda * stoich is linear, that nudge equals exactly
      // this AFR step, so the button and the cell always agree.
      scope.stepAmountU = function () {
        if (scope.afr && scope.selectedMap === 'lambda') { return Math.round(scope.stepAmount() * scope.stoich() * 100) / 100; }
        return scope.stepAmount();
      };
      scope.addSel = function (sign) { var a = scope.stepAmount() * sign; applyToSelection(function (v) { return v + a; }); };
      scope.scaleSel = function (pct) { applyToSelection(function (v) { return v * (1 + pct / 100); }); };
      scope.fillSel = function () {
        var val = Number(scope.fillValue); if (!isFinite(val)) { return; }
        if (scope.psi && scope.selectedMap === 'boost') { val = val / KPA_TO_PSI + baroRefKpa(); }
        else if (scope.afr && scope.selectedMap === 'lambda') { val = val / scope.stoich(); }
        applyToSelection(function () { return val; });
      };
      scope.smoothSel = function () {
        var m = scope.curMap(); if (!m) { return; }
        var src = m.map(function (row) { return row.slice(); });
        var R = m.length, C = m[0].length;
        applyToSelection(function (v, r, c) {
          var tot = src[r][c], n = 1;
          if (c > 0) { tot += src[r][c - 1]; n++; }
          if (c < C - 1) { tot += src[r][c + 1]; n++; }
          if (r > 0) { tot += src[r - 1][c]; n++; }
          if (r < R - 1) { tot += src[r + 1][c]; n++; }
          return tot / n;
        });
      };
      scope.interpSel = function () {
        var s = norm(); if (!s) { return; }
        var m = scope.curMap(); var src = m.map(function (row) { return row.slice(); });
        if (s.r1 === s.r2 && s.c1 === s.c2) { return; }
        applyToSelection(function (v, r, c) {
          var tx = s.c1 === s.c2 ? 0 : (c - s.c1) / (s.c2 - s.c1);
          var ty = s.r1 === s.r2 ? 0 : (r - s.r1) / (s.r2 - s.r1);
          if (s.r1 === s.r2) { return src[s.r1][s.c1] + (src[s.r1][s.c2] - src[s.r1][s.c1]) * tx; }
          if (s.c1 === s.c2) { return src[s.r1][s.c1] + (src[s.r2][s.c1] - src[s.r1][s.c1]) * ty; }
          var top = src[s.r1][s.c1] + (src[s.r1][s.c2] - src[s.r1][s.c1]) * tx;
          var bot = src[s.r2][s.c1] + (src[s.r2][s.c2] - src[s.r2][s.c1]) * tx;
          return top + (bot - top) * ty;
        });
      };
      scope.clipboard = null;
      scope.copySel = function () {
        var s = norm(); if (!s) { return; }
        var m = scope.curMap(); var vals = [];
        for (var r = s.r1; r <= s.r2; r++) { var row = []; for (var c = s.c1; c <= s.c2; c++) { row.push(m[r][c]); } vals.push(row); }
        scope.clipboard = { rows: s.r2 - s.r1 + 1, cols: s.c2 - s.c1 + 1, vals: vals };
        setToast('Copied ' + scope.selCount() + ' cells');
      };
      scope.pasteSel = function () {
        if (!scope.clipboard) { setToast('Nothing copied'); return; }
        var cb = scope.clipboard;
        var s = norm() || { r1: scope.liveR, r2: scope.liveR, c1: scope.liveC, c2: scope.liveC };
        applyToSelection(function (v, r, c) { return cb.vals[(r - s.r1) % cb.rows][(c - s.c1) % cb.cols]; });
      };
      // PCLink-style keyboard tuning. Real PCLink keys: Q/A raise-lower cells
      // (Shift = big change), arrows move the active cell, digits type a value,
      // O smooth, Ctrl+I interpolate, F/I jump tables, Ctrl+S store.
      // BeamNG only routes the keyboard to the UI while a real text input is
      // focused, so keyboard mode is an explicit toggle that parks focus on a
      // hidden input (.oep-kbtrap) — driving binds pause while it's on, exactly
      // like typing in any text box. Esc or clicking the world hands them back.
      scope.kbMode = false;
      scope.kbFocus = function (on) { scope.kbMode = on; };
      scope.focusTable = function () {
        try {
          var w = element[0].querySelector('.oep-kbtrap');
          if (w) { w.value = ''; w.focus({ preventScroll: true }); }
        } catch (e) {}
      };
      function releaseBrowserTableKeyboard() {
        if (!IS_MIRROR) { return; }
        try {
          var w = element[0].querySelector('.oep-kbtrap');
          if (w && document.activeElement === w) { w.blur(); }
        } catch (e) {}
        if (scope.kbMode) { scope.$evalAsync(function () { scope.kbMode = false; }); }
      }
      var browserKbPointerDown = null, browserKbWindowBlur = null;
      if (IS_MIRROR) {
        browserKbPointerDown = function (ev2) {
          var n = ev2 && ev2.target, insideGrid = false;
          while (n && n !== document) {
            if (n.classList && n.classList.contains('oep-grid')) { insideGrid = element[0].contains(n); break; }
            n = n.parentNode;
          }
          if (!insideGrid) { releaseBrowserTableKeyboard(); }
        };
        browserKbWindowBlur = function () { releaseBrowserTableKeyboard(); };
        document.addEventListener('mousedown', browserKbPointerDown, true);
        window.addEventListener('blur', browserKbWindowBlur, false);
      }
      scope.toggleKb = function () {
        if (scope.kbMode) {
          try { var w = element[0].querySelector('.oep-kbtrap'); if (w) { w.blur(); } } catch (e) {}
        } else {
          scope.focusTable();
          if (!scope.sel) { scope.sel = { r1: 0, c1: 0, r2: 0, c2: 0 }; }
        }
      };
      var lastBrowserKbEditAt = 0;
      function browserKbEditAllowed() {
        if (!IS_MIRROR) { return true; }
        var s = norm(), rows = s ? (s.r2 - s.r1 + 1) : 1;
        var minGap = Math.max(140, Math.ceil(rows * 1000 / 15));
        var now = Date.now();
        if (now - lastBrowserKbEditAt < minGap) { return false; }
        lastBrowserKbEditAt = now;
        return true;
      }
      var kbMove = function (dr, dc, extend) {
        var m = scope.curMap(); if (!m || !m.length) { return; }
        if (!scope.sel) { scope.sel = { r1: 0, c1: 0, r2: 0, c2: 0 }; return; }
        var r = Math.max(0, Math.min(m.length - 1, scope.sel.r2 + dr));
        var c = Math.max(0, Math.min(m[0].length - 1, scope.sel.c2 + dc));
        scope.sel.r2 = r; scope.sel.c2 = c;
        if (!extend) { scope.sel.r1 = r; scope.sel.c1 = c; }
      };
      var kbReturn = false; // direct entry started from keyboard mode -> Enter returns there
      var focusFill = function (seed) {
        if (seed !== null) { scope.fillValue = seed; }
        kbReturn = true;
        try {
          var inp = element[0].querySelector('.oep-edit input[ng-model="$parent.fillValue"]');
          if (inp) { inp.focus(); if (seed === null) { inp.select(); } }
        } catch (e) {}
      };
      scope.tableKey = function (ev2) {
        var k = ev2.key || '';
        var mult = ev2.shiftKey ? 5 : 1;
        if (k === 'ArrowUp') { kbMove(-1, 0, ev2.shiftKey); }
        else if (k === 'ArrowDown') { kbMove(1, 0, ev2.shiftKey); }
        else if (k === 'ArrowLeft') { kbMove(0, -1, ev2.shiftKey); }
        else if (k === 'ArrowRight') { kbMove(0, 1, ev2.shiftKey); }
        else if ((k === 'q' || k === 'Q' || k === 'PageUp') && !ev2.ctrlKey) { if (!IS_MIRROR || (!ev2.repeat && browserKbEditAllowed())) { scope.addSel(mult); } }
        else if ((k === 'a' || k === 'A' || k === 'PageDown') && !ev2.ctrlKey) { if (!IS_MIRROR || (!ev2.repeat && browserKbEditAllowed())) { scope.addSel(-mult); } }
        else if (ev2.ctrlKey && (k === 'a' || k === 'A')) { scope.selectAll(); }
        else if (ev2.ctrlKey && (k === 'i' || k === 'I')) { if (!IS_MIRROR || (!ev2.repeat && browserKbEditAllowed())) { scope.interpSel(); } }
        else if (ev2.ctrlKey && (k === 'c' || k === 'C')) { scope.copySel(); }
        else if (ev2.ctrlKey && (k === 'v' || k === 'V')) { if (!IS_MIRROR || (!ev2.repeat && browserKbEditAllowed())) { scope.pasteSel(); } }
        else if (!IS_MIRROR && ev2.ctrlKey && (k === 's' || k === 'S')) { scope.store(); }
        else if (k === 'o' || k === 'O') { if (!IS_MIRROR || (!ev2.repeat && browserKbEditAllowed())) { scope.smoothSel(); } }
        else if (k === 'k' || k === 'K') { scope.toggle3d(); } // PCLink: K = grid/graph view
        else if (ev2.shiftKey && (k === 'T' || k === 't')) { scope.setPage('lambda'); }
        else if (k === 'f' || k === 'F') { scope.setPage('fuel'); }
        else if (k === 'i' || k === 'I') { scope.setPage('ignition'); }
        else if (k === 'l' || k === 'L') { scope.setPage('lambda'); }
        else if (k === 'v' || k === 'V') { scope.setPage('ve'); }
        else if (k === 'b' || k === 'B') { scope.setPage('boost'); }
        else if (k === 'w' || k === 'W') { scope.setPage('wgduty'); }
        else if (k === 'Escape') { if (!IS_MIRROR) { scope.clearSelection(); } try { ev2.target.blur(); } catch (e) {} }
        else if (k === '=') { focusFill(null); }
        else if (k.length === 1 && k >= '0' && k <= '9') { focusFill(Number(k)); }
        // consume everything while keyboard mode is on so nothing leaks
        ev2.preventDefault(); ev2.stopPropagation();
      };
      scope.fillBlur = function () { kbReturn = false; };
      scope.fillKey = function (ev2) {
        var k = ev2.key || '';
        if (k === 'Enter') { if (!IS_MIRROR || (!ev2.repeat && browserKbEditAllowed())) { scope.fillSel(); } if (kbReturn) { scope.focusTable(); } ev2.preventDefault(); }
        else if (k === 'Escape') { if (kbReturn) { scope.focusTable(); } else { try { ev2.target.blur(); } catch (e) {} } ev2.preventDefault(); }
        else { return; }
        kbReturn = false;
        ev2.stopPropagation();
      };
      // Per-TABLE baselines (V2.9.7, store declared with the table-editing state above): one
      // snapshot per table, so the Δ view can never compare a fuel table against an ignition
      // snapshot (the old single baselineMap did exactly that if you switched tables with Δ on).
      scope.curBaseline = function () { return scope.baselineMaps[scope.selectedMap] || null; };
      scope.snapBaseline = function () {
        if (!scope.curMap()) { return; }
        scope.baselineMaps[scope.selectedMap] = scope.curMap().map(function (row) { return row.slice(); });
        setToast('Baseline saved for this table');
      };
      // Δ toggle auto-snapshots a baseline the first time it's used on a table, so the view always
      // shows something meaningful even if the student never found the Baseline button.
      scope.toggleDelta = function () {
        scope.showDelta = !scope.showDelta;
        if (scope.showDelta && !scope.curBaseline() && scope.curMap()) {
          scope.baselineMaps[scope.selectedMap] = scope.curMap().map(function (row) { return row.slice(); });
          setToast('Δ view on - baseline auto-saved for this table');
        }
      };
      scope.resetMaps = function () {
        if (pendingMapBankMutation) { setToast('Wait for the current MAP action to finish'); return; }
        localCalibrationMutationGeneration += 1;
        invalidateMixture('tables reset'); pendingMapEcho = {}; callController('resetMaps()');
        scope.baselineMaps = {}; setToast('All tables reset to factory');
      };
      // Per-table reset for the toolbar button: only the table the user is looking at, so it matches
      // every other button in that toolbar (which all act on the current table). Full factory reset
      // still lives on Tune Files -> Stock Base. Community report (Liqurdz, 2026-07): the shared Reset
      // wiped every table when players expected just the current one.
      scope.resetCurrentMap = function () {
        if (pendingMapBankMutation) { setToast('Wait for the current MAP action to finish'); return; }
        var name = scope.selectedMap;
        var LABELS = { fuel: 'Fuel Main', ignition: 'Ignition', boost: 'Boost Target', lambda: 'Lambda', ve: 'VE', wgduty: 'WG Duty' };
        // V2.9 fix (version skew): resetMap only exists on the 2.9 controller. Against an older cached
        // one the old code errored in Lua while the toast still claimed success and the Mixture Map
        // evidence was thrown away - say what actually happened instead.
        if (scope.connected && !scope.ctrlVersion) {
          setToast('The car is running an older OE ProTune controller - restart the game so the updated mod loads, then Reset');
          return;
        }
        localCalibrationMutationGeneration += 1;
        invalidateMixture('table reset');
        pendingMapEcho = {};   // the reset echo must repaint instantly, not fight a held pre-reset edit
        callController('resetMap("' + name + '")');
        delete scope.baselineMaps[name];
        setToast((LABELS[name] || name) + ' table reset to factory');
      };

      // selected cell properties + risk hint
      scope.selInfo = function () {
        var s = norm(); var m = scope.curMap(); if (!m) { return null; }
        var r = s ? s.r1 : scope.liveR, c = s ? s.c1 : scope.liveC;
        if (r < 0 || !m[r]) { return null; }
        return { r: r, c: c, map: scope.activeLoadAxis()[r], rpm: scope.rpmAxis[c], val: m[r][c] };
      };
      scope.riskHint = function () {
        var info = scope.selInfo(); if (!info) { return ''; }
        var name = scope.selectedMap, v = info.val, mapKpa = info.map;
        var highLoad = scope.mapUsesAlphaN(name) ? mapKpa >= 60 : mapKpa >= 140;
        if (name === 'fuel' && highLoad && v < -2) { return 'Lean-risk: pulling fuel under load can cause lean misfire or knock.'; }
        if (name === 'ignition' && highLoad && v > 3) { return 'Knock-risk: positive timing under load raises cylinder pressure.'; }
        if (name === 'boost' && v > 220) { return 'Boost-risk: verify fuel, lambda, injector duty and knock before applying.'; }
        if (name === 'lambda' && highLoad && v > 0.88) { return 'Lean target under load: watch lambda actual, knock and engine health.'; }
        if (name === 've' && Number((scope.settings || {}).fuelEquationMode) !== 2) { return 'VE is inactive in Traditional mode. Switch Fuel equation to Modelled before tuning this table.'; }
        if (name === 've' && v > 100) { return 'VE above 100% can be valid when cylinder filling is helped by resonance or boost. Tune the smooth airflow model from measured lambda, not toward an arbitrary 100% ceiling.'; }
        if (name === 've') { return 'VE models trapped airflow, not desired lambda. Too low runs lean; too high runs rich. Make small, smooth changes and verify measured lambda in open loop.'; }
        return 'Normal training range.';
      };

      // ====================================================================
      // surface preview (simple shaded mesh)
      // ====================================================================
      function surfaceCanvas() { return element[0].querySelector('.oep-surface'); }
      function drawSurface() {
        var cv = surfaceCanvas(); var m = scope.curMap(); if (!cv || !m) { return; }
        var ctx = cv.getContext('2d'); var w = cv.width, h = cv.height;
        ctx.clearRect(0, 0, w, h); ctx.fillStyle = '#0d0d11'; ctx.fillRect(0, 0, w, h);
        var R = m.length, C = m[0].length; var mt = meta();
        var ox = w * 0.10, oy = h * 0.80, cw = (w * 0.78) / C, dz = h * 0.5 / R, dy = h * 0.42;
        for (var r = R - 1; r >= 0; r--) {
          for (var c = 0; c < C; c++) {
            var frac = Math.max(0, Math.min(1, (m[r][c] - mt.min) / (mt.max - mt.min)));
            var x = ox + c * cw + r * (w * 0.10) / R;
            var y = oy - r * dz - frac * dy;
            // PCLink-style heatmap: red -> yellow -> green
            var hue = frac * 120;
            ctx.fillStyle = 'hsl(' + hue.toFixed(1) + ',70%,' + (40 + frac * 12) + '%)';
            ctx.fillRect(x, y, cw - 1, Math.max(2, frac * dy * 0.5 + 3));
          }
        }
      }
      // The 40ms defer lands after ng-if has re-created the nodes, which is exactly when the table is
      // also back in the DOM - so this is the earliest correct moment to size it, and it spares the
      // user one 350ms tick of unfitted layout when opening a map page or leaving the 3D view.
      scope.redrawSurface = function () { setTimeout(function () { if (IS_PRO_STUDIO) { draw3d(); drawDials(); } else { fitGrid(); drawSurface(); } }, 40); };

      // ====================================================================
      // Big 3D table view (Hondata/PCLink style surface graph)
      // Geometry auto-scales to the data; colors follow the grid heat ramp.
      // ====================================================================
      scope.tableView = (function () { try { return localStorage.getItem('oep_tableview') === '3d' ? '3d' : 'grid'; } catch (e) { return 'grid'; } })();
      scope.rot3d = { yaw: 0.65, pitch: 0.55, zoom: 1 };
      scope.toggle3d = function () {
        if (IS_PRO_STUDIO) { setTimeout(draw3d, 30); return; }
        scope.tableView = scope.tableView === '3d' ? 'grid' : '3d';
        try { localStorage.setItem('oep_tableview', scope.tableView); } catch (e) {}
        setTimeout(draw3d, 60);
        scope.redrawSurface(); // mini Surface box is recreated by ng-if when leaving 3D
      };
      var proj3d = null; // projected node centers from the last draw, for click hit-testing
      scope.pro3dZoom = function (direction) {
        scope.rot3d.zoom = Math.max(0.5, Math.min(2.2, scope.rot3d.zoom * (direction > 0 ? 1.12 : 0.89)));
        setTimeout(draw3d, 0);
      };
      scope.pro3dReset = function () { scope.rot3d = { yaw: 0.65, pitch: 0.55, zoom: 1 }; setTimeout(draw3d, 0); };
      scope.pro3dRotate = function (dx, dy) {
        scope.rot3d.yaw += (dx || 0) * Math.PI / 10;
        scope.rot3d.pitch = Math.max(0.12, Math.min(1.35, scope.rot3d.pitch + (dy || 0) * Math.PI / 10));
        setTimeout(draw3d, 0);
      };
      function big3dCanvas() { return element[0].querySelector('.oep-big3d'); }
      function draw3d() {
        var cv = big3dCanvas(); var m = scope.curMap();
        if (!cv || !m || !m.length || !m[0]) { return; }
        ensure3dHandlers(cv);
        var ctx = cv.getContext('2d'); var w = cv.width, h = cv.height;
        // PCLink style: pure black workspace
        ctx.fillStyle = '#000000'; ctx.fillRect(0, 0, w, h);
        var R = m.length, C = m[0].length, r, c;
        if (R < 2 || C < 2) { return; }
        var lo = Infinity, hi = -Infinity;
        for (r = 0; r < R; r++) { for (c = 0; c < C; c++) { var v = m[r][c]; if (v < lo) { lo = v; } if (v > hi) { hi = v; } } }
        var mt = meta();
        if (!(hi > lo)) { lo -= (mt.step || 1); hi += (mt.step || 1); } // flat table -> flat plane
        var yaw = scope.rot3d.yaw, pit = scope.rot3d.pitch;
        var cyw = Math.cos(yaw), syw = Math.sin(yaw), cp = Math.cos(pit), sp = Math.sin(pit);
        var S = Math.min(w, h) * 0.62 * scope.rot3d.zoom;
        var cx = w * 0.5, cy0 = h * 0.56, zH = 0.6;
        var P = function (x, y, z) {
          var x1 = x * cyw - y * syw, y1 = x * syw + y * cyw;
          return { x: cx + x1 * S, y: cy0 - (y1 * sp + z * cp) * S * 0.9, d: y1 * cp - z * sp };
        };
        var nodes = [], zlo = -0.5 * zH;
        for (r = 0; r < R; r++) {
          nodes.push([]);
          for (c = 0; c < C; c++) {
            var zf = (m[r][c] - lo) / (hi - lo);
            nodes[r].push(P(c / (C - 1) - 0.5, 0.5 - r / (R - 1), (zf - 0.5) * zH));
          }
        }
        proj3d = nodes;
        // PCLink-style heatmap: low values = red, high values = green
        function heatColor(frac) {
          // frac 0 -> red (hue 0), 0.5 -> yellow (hue 60), 1 -> green (hue 120)
          var hue = frac * 120;
          return 'hsl(' + hue.toFixed(1) + ',85%,45%)';
        }
        // quads back to front (painter's algorithm)
        var quads = [];
        for (r = 0; r < R - 1; r++) {
          for (c = 0; c < C - 1; c++) {
            var val = (m[r][c] + m[r][c + 1] + m[r + 1][c] + m[r + 1][c + 1]) / 4;
            quads.push({ r: r, c: c, val: val, d: (nodes[r][c].d + nodes[r][c + 1].d + nodes[r + 1][c].d + nodes[r + 1][c + 1].d) / 4 });
          }
        }
        quads.sort(function (a, b) { return b.d - a.d; });
        for (var i = 0; i < quads.length; i++) {
          var q = quads[i];
          var a = nodes[q.r][q.c], b2 = nodes[q.r][q.c + 1], d2 = nodes[q.r + 1][q.c + 1], e2 = nodes[q.r + 1][q.c];
          var frac = Math.max(0, Math.min(1, (q.val - mt.min) / (mt.max - mt.min)));
          ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b2.x, b2.y); ctx.lineTo(d2.x, d2.y); ctx.lineTo(e2.x, e2.y); ctx.closePath();
          ctx.fillStyle = heatColor(frac);
          ctx.fill();
          // PCLink-style bright white mesh grid over the colored surface
          ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 1; ctx.stroke();
        }
        // selection rings + live cell dot on top
        var s = norm();
        if (s) {
          ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5;
          for (r = s.r1; r <= s.r2; r++) {
            for (c = s.c1; c <= s.c2; c++) {
              var n = nodes[r] && nodes[r][c]; if (!n) { continue; }
              ctx.beginPath(); ctx.arc(n.x, n.y, 3.5, 0, 6.283); ctx.stroke();
            }
          }
        }
        if (scope.liveR >= 0 && nodes[scope.liveR] && nodes[scope.liveR][scope.liveC]) {
          var ln = nodes[scope.liveR][scope.liveC];
          ctx.fillStyle = 'rgba(42,163,255,0.95)';
          ctx.beginPath(); ctx.arc(ln.x, ln.y, 4, 0, 6.283); ctx.fill();
        }
        // PCLink-style readout at top left: live RPM, load, selected/live value.
        // Pro Studio pins its own styled HTML overlay (.oep-pro-surface-readout) to this exact
        // corner, so drawing the canvas copy too smears the two readouts together (V2.9 fix).
        if (!IS_PRO_STUDIO) {
          ctx.font = 'bold 11px Consolas, "Courier New", monospace'; ctx.fillStyle = '#ffffff'; ctx.textAlign = 'left';
          var t = scope.t || {};
          var readoutRpm = (t.rpm !== undefined && t.rpm !== null) ? Math.round(Number(t.rpm)) : 0;
          var readoutLoad = Math.round(scope.activeLoadValue(scope.selectedMap, t) * 10) / 10;
          var liveVal = '--';
          if (scope.liveR >= 0 && scope.liveC >= 0 && m[scope.liveR] && m[scope.liveR][scope.liveC] !== undefined) {
            liveVal = scope.fmtCellU(m[scope.liveR][scope.liveC]);
          } else if (s && s.r1 === s.r2 && s.c1 === s.c2 && m[s.r1] && m[s.r1][s.c1] !== undefined) {
            liveVal = scope.fmtCellU(m[s.r1][s.c1]);
          }
          ctx.fillText(readoutRpm + ' (RPM)', 8, 16);
          ctx.fillText(readoutLoad + ' (' + scope.axisUnit() + ')', 8, 30);
          ctx.fillText(liveVal + ' ' + scope.cellUnitU(), 8, 44);
        }
        // Axis labels at floor corners
        ctx.font = '9px Tahoma, sans-serif'; ctx.fillStyle = '#9ab0c4'; ctx.textAlign = 'center';
        var fl = function (x, y) { return P(x, y, zlo); };
        var pA = fl(-0.5, -0.5), pB = fl(0.5, -0.5), pD = fl(-0.5, 0.5);
        var displayLoadAxis = scope.activeLoadAxis();
        ctx.fillText(String(scope.rpmAxis[0]), pA.x, pA.y + 12);
        ctx.fillText(String(scope.rpmAxis[C - 1]), pB.x, pB.y + 12);
        ctx.fillText('RPM', (pA.x + pB.x) / 2, (pA.y + pB.y) / 2 + 14);
        ctx.fillText(String(scope.axisLab(displayLoadAxis[R - 1])), pA.x, pA.y - 6);
        ctx.fillText(String(scope.axisLab(displayLoadAxis[0])), pD.x, pD.y - 6);
        ctx.fillText(scope.axisUnit(), (pA.x + pD.x) / 2 - 14, (pA.y + pD.y) / 2);
        if (!IS_PRO_STUDIO) {
          ctx.textAlign = 'left';
          ctx.fillText(scope.fmtCellU(hi) + ' ' + scope.cellUnitU(), 6, h - 24);
          ctx.fillText(scope.fmtCellU(lo) + ' ' + scope.cellUnitU(), 6, h - 12);
        }
      }
      function ensure3dHandlers(cv) {
        if (cv.__oep3d) { return; }
        cv.__oep3d = true;
        var drag = null;
        cv.addEventListener('mousedown', function (ev) {
          drag = { x: ev.offsetX, y: ev.offsetY, moved: false, ctrl: ev.ctrlKey, shift: ev.shiftKey };
          ev.preventDefault();
        });
        cv.addEventListener('mousemove', function (ev) {
          if (!drag) { return; }
          var dx = ev.offsetX - drag.x, dy = ev.offsetY - drag.y;
          if (Math.abs(dx) + Math.abs(dy) > 3) { drag.moved = true; }
          if (drag.moved) {
            scope.rot3d.yaw += dx * 0.008;
            scope.rot3d.pitch = Math.max(0.12, Math.min(1.35, scope.rot3d.pitch + dy * 0.006));
            drag.x = ev.offsetX; drag.y = ev.offsetY;
            draw3d();
          }
        });
        cv.addEventListener('mouseup', function (ev) {
          if (!drag) { return; }
          var wasClick = !drag.moved, ctrl = drag.ctrl, shiftK = drag.shift;
          drag = null;
          if (!wasClick || !proj3d) { return; }
          var sx = ev.offsetX * (cv.width / (cv.clientWidth || cv.width));
          var sy = ev.offsetY * (cv.height / (cv.clientHeight || cv.height));
          var best = null, bd = 400; // within 20px
          for (var r = 0; r < proj3d.length; r++) {
            for (var c = 0; c < proj3d[r].length; c++) {
              var p = proj3d[r][c]; var dd = (p.x - sx) * (p.x - sx) + (p.y - sy) * (p.y - sy);
              if (dd < bd) { bd = dd; best = { r: r, c: c }; }
            }
          }
          if (best) {
            scope.$applyAsync(function () {
              var m = scope.curMap(); if (!m) { return; }
              if (ctrl) { scope.sel = { r1: 0, c1: best.c, r2: m.length - 1, c2: best.c }; }            // column, like KManager
              else if (shiftK) { scope.sel = { r1: best.r, c1: 0, r2: best.r, c2: m[0].length - 1 }; }  // row
              else { scope.sel = { r1: best.r, c1: best.c, r2: best.r, c2: best.c }; }
              if (IS_MIRROR && scope.focusTable) { scope.focusTable(); }
            });
            setTimeout(draw3d, 60);
          }
        });
        cv.addEventListener('mouseleave', function () { drag = null; });
        cv.addEventListener('wheel', function (ev) {
          ev.preventDefault();
          scope.rot3d.zoom = Math.max(0.5, Math.min(2.2, scope.rot3d.zoom * (ev.deltaY < 0 ? 1.1 : 0.9)));
          draw3d();
        }, { passive: false });
      }
      var t3d = setInterval(function () {
        if (!scope.isMapPage(scope.activePage)) { return; }
        // Piggy-backed on the existing map-page tick rather than a second timer or a resize listener:
        // the BeamNG app slot is resized by the game's app layout editor, which fires no window
        // 'resize' event, so polling is the only thing that actually catches it. fitGrid() bails on
        // its own signature check, so a steady state costs one measure and no DOM writes.
        fitGrid();
        if (IS_PRO_STUDIO || scope.tableView === '3d') { draw3d(); } else { drawSurface(); }
        if (IS_PRO_STUDIO) { drawDials(); }
      }, 350);
      scope.$on('$destroy', function () { clearInterval(t3d); });

      // ====================================================================
      // dyno
      // ====================================================================
      var NM_TO_LBFT = 0.73756;
      scope.dynoState = 'idle'; scope.dynoRows = []; scope.dynoBaseline = null; scope.dynoText = ''; scope.dynoStatus = 'ready'; scope.showDynoBase = true; scope.dynoPullCount = 0;
      scope.dynoAuto = false; scope.dynoStartGear = 0; scope.dynoStartRpm = 0;   // Auto pull: auto-start/stop the dyno by gear (0 = any) and start RPM (0 = auto/just off idle)
      scope.dynoShowAfr = true; scope.dynoShowBoost = true;
      scope.toggleDynoAfr = function () { scope.dynoShowAfr = !scope.dynoShowAfr; drawDyno(); };
      scope.toggleDynoBoost = function () { scope.dynoShowBoost = !scope.dynoShowBoost; drawDyno(); };
      function dynoBoostStr(kpa) { return scope.psi ? (kpa * KPA_TO_PSI).toFixed(1) + ' psi' : Math.round(kpa) + ' kPa'; }
      function dynoCanvas() { return element[0].querySelector('.oep-dyno'); }
      function rowTq(r) { return r[1] * NM_TO_LBFT; }
      function rowHp(r) { return rowTq(r) * r[0] / 5252; }
      function smoothRows(rows) {
        if (!rows || rows.length < 5) { return rows || []; }
        var out = [], wts = [1, 3, 5, 3, 1];
        for (var i = 0; i < rows.length; i++) { var s = 0, ws = 0; for (var k = -2; k <= 2; k++) { var j = i + k; if (j >= 0 && j < rows.length) { s += rows[j][1] * wts[k + 2]; ws += wts[k + 2]; } } out.push([rows[i][0], s / ws, rows[i][2], rows[i][3]]); }   // carry lambda(2)+boost(3) through smoothing
        return out;
      }
      function dynoPeaks(rows) { var p = { hp: 0, hpRpm: 0, tq: 0, tqRpm: 0 }; rows.forEach(function (r) { var hp = rowHp(r), tq = rowTq(r); if (hp > p.hp) { p.hp = hp; p.hpRpm = r[0]; } if (tq > p.tq) { p.tq = tq; p.tqRpm = r[0]; } }); return p; }
      // ENGINE-AWARE (V2.6): baselines and power records belong to the ENGINE they were measured on,
      // same rule as the tunes - a swapped engine must not inherit the old engine's "before" numbers
      // or the BASE -> TUNED gain becomes fiction. Legacy per-car baselines are adopted once.
      function dynoBaseKey() { return 'oep_dyno_base_' + scope.carName + (((scope.base || {}).engineFp) ? '|' + scope.base.engineFp : ''); }
      function powerKey() { return 'oep_power_' + scope.carName + '|' + (((scope.base || {}).engineFp) || 'na'); }
      function savePowerRec() { try { localStorage.setItem(powerKey(), JSON.stringify(scope.powerRec)); } catch (e) {} }
      function loadPowerRec() {
        scope.powerRec = {};
        try {
          var raw = localStorage.getItem(powerKey());
          if (!raw && scope.base && scope.base.engineFpLegacy && scope.base.engineFpLegacy !== scope.base.engineFp) {
            raw = localStorage.getItem('oep_power_' + scope.carName + '|' + scope.base.engineFpLegacy);
            if (raw) { localStorage.setItem(powerKey(), raw); }
          }
          if (raw) { scope.powerRec = JSON.parse(raw) || {}; }
        } catch (e) {}
      }
      function loadDynoBaseline() {
        scope.dynoBaseline = null;
        try {
          var raw = localStorage.getItem(dynoBaseKey());
          if (!raw && scope.base && scope.base.engineFp) {
            // Adopt the exact legacy-fingerprint baseline before falling back to the older per-car
            // key. This keeps dyno history through the E2 part-identity migration.
            if (scope.base.engineFpLegacy && scope.base.engineFpLegacy !== scope.base.engineFp) {
              raw = localStorage.getItem('oep_dyno_base_' + scope.carName + '|' + scope.base.engineFpLegacy);
            }
            // pre-fingerprint baseline: adopt it for this engine once (most cars never swap engines)
            if (!raw) { raw = localStorage.getItem('oep_dyno_base_' + scope.carName); }
            if (raw) { try { localStorage.setItem(dynoBaseKey(), raw); } catch (e2) {} }
          }
          if (raw) { scope.dynoBaseline = JSON.parse(raw); }
        } catch (e) {}
        loadPowerRec();
      }
      function drawDyno(cursorFrac) {
        var cv = dynoCanvas(); if (!cv) { return; }
        var ctx = cv.getContext('2d'); var w = cv.width, h = cv.height;
        ctx.setLineDash([]); ctx.globalAlpha = 1; ctx.clearRect(0, 0, w, h);
        ctx.fillStyle = '#0d0d11'; ctx.fillRect(0, 0, w, h);
        ctx.strokeStyle = '#1c1c24'; ctx.lineWidth = 1;
        for (var gx = 0; gx <= 4; gx++) { ctx.beginPath(); ctx.moveTo(gx * w / 4, 0); ctx.lineTo(gx * w / 4, h); ctx.stroke(); }
        for (var gy = 0; gy <= 3; gy++) { ctx.beginPath(); ctx.moveTo(0, gy * h / 3); ctx.lineTo(w, gy * h / 3); ctx.stroke(); }
        var sets = [];
        if (scope.showDynoBase && scope.dynoBaseline && scope.dynoBaseline.length > 1) { sets.push({ rows: smoothRows(scope.dynoBaseline), dash: true }); }
        if (scope.dynoRows && scope.dynoRows.length > 1) { sets.push({ rows: smoothRows(scope.dynoRows), dash: false }); }
        if (!sets.length) { return; }
        var rpmMin = Infinity, rpmMax = -Infinity, yMax = 0;
        var effLimit = scope.settings.revLimit > 0 ? scope.settings.revLimit : scope.base.revLimiterRPM;
        sets.forEach(function (st) { st.rows.forEach(function (r) { rpmMin = Math.min(rpmMin, r[0]); rpmMax = Math.max(rpmMax, r[0]); yMax = Math.max(yMax, rowHp(r), rowTq(r)); }); });
        rpmMax = Math.max(rpmMax, effLimit);
        if (rpmMax <= rpmMin || yMax <= 0) { return; }
        yMax *= 1.08;
        function px(rpm) { return (rpm - rpmMin) / (rpmMax - rpmMin) * w; }
        function py(v) { return h - (v / yMax) * (h - 6) - 3; }
        sets.forEach(function (st) {
          ctx.setLineDash(st.dash ? [4, 3] : []); ctx.globalAlpha = st.dash ? 0.5 : 1;
          [{ fn: rowHp, color: '#e0252f' }, { fn: rowTq, color: '#ffc428' }].forEach(function (tr) {
            ctx.strokeStyle = tr.color; ctx.lineWidth = 1.5; ctx.beginPath();
            for (var i = 0; i < st.rows.length; i++) { var x = px(st.rows[i][0]), y = py(tr.fn(st.rows[i])); if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } }
            ctx.stroke();
          });
        });
        ctx.setLineDash([]); ctx.globalAlpha = 1;
        // AFR + boost overlays for the CURRENT pull, each on its own implicit scale (like a real dyno sheet)
        var curRows = smoothRows(scope.dynoRows || []);
        if (curRows.length > 1 && curRows[0][2] != null) {
          if (scope.dynoShowAfr) {
            // lambda 0.65 (rich, bottom) .. 1.25 (lean, top)
            ctx.strokeStyle = '#39c8ff'; ctx.lineWidth = 1.3; ctx.beginPath();
            for (var ai = 0; ai < curRows.length; ai++) { var lam = curRows[ai][2] || 1; var ax = px(curRows[ai][0]); var ay = h - Math.max(0, Math.min(1, (lam - 0.65) / 0.60)) * (h - 6) - 3; if (ai === 0) { ctx.moveTo(ax, ay); } else { ctx.lineTo(ax, ay); } }
            ctx.stroke();
          }
          if (scope.dynoShowBoost) {
            var bMax = 1; curRows.forEach(function (r) { if ((r[3] || 0) > bMax) { bMax = r[3]; } });
            ctx.strokeStyle = '#ff5bd0'; ctx.lineWidth = 1.3; ctx.beginPath();
            for (var bi = 0; bi < curRows.length; bi++) { var bv = curRows[bi][3] || 0; var bx = px(curRows[bi][0]); var by = h - Math.max(0, Math.min(1, bv / bMax)) * (h - 6) - 3; if (bi === 0) { ctx.moveTo(bx, by); } else { ctx.lineTo(bx, by); } }
            ctx.stroke();
          }
        }
        if (cursorFrac != null) { var cx = cursorFrac * w; ctx.strokeStyle = '#888'; ctx.beginPath(); ctx.moveTo(cx, 0); ctx.lineTo(cx, h); ctx.stroke(); }
      }
      scope.redrawDyno = function () { setTimeout(function () { drawDyno(); }, 50); };
      scope.toggleDyno = function () { if (scope.dynoState === 'armed' || scope.dynoState === 'running') { callController('cancelDyno()'); } else { callController('armDyno()'); } };
      scope.dynoAutoArgs = function () { return (scope.dynoStartGear || 0) + ', ' + (scope.dynoStartRpm || 0); };
      scope.toggleDynoAuto = function () {
        var nextAuto = !scope.dynoAuto;
        var nextGear = nextAuto ? (scope.dynoStartGear || 0) : 0;
        var nextRpm = nextAuto ? (scope.dynoStartRpm || 0) : 0;
        // The browser waits for OEProTuneDyno confirmation. Otherwise a blocked
        // read-only/dangerous-gate command can leave Auto Pull falsely painted ON.
        if (!IS_MIRROR) {
          scope.dynoAuto = nextAuto;
          if (!nextAuto) { scope.dynoStartGear = 0; scope.dynoStartRpm = 0; }   // leftover Start gear/RPM silently blocked manual pulls (V2.8 fix)
        }
        callController('setDynoAuto(' + (nextAuto ? 'true' : 'false') + ', ' + nextGear + ', ' + nextRpm + ')');
      };
      scope.setDynoStartGear = function (g) { scope.dynoStartGear = g; if (scope.dynoAuto) { callController('setDynoAuto(true, ' + scope.dynoAutoArgs() + ')'); } };
      scope.setDynoStartRpm = function () {
        var r = Math.round(Number(scope.dynoStartRpm) || 0);
        if (isNaN(r) || r < 0) { r = 0; } if (r > 9000) { r = 9000; }
        scope.dynoStartRpm = r;
        if (scope.dynoAuto) { callController('setDynoAuto(true, ' + scope.dynoAutoArgs() + ')'); }
      };
      scope.toggleDynoBase = function () { scope.showDynoBase = !scope.showDynoBase; drawDyno(); };
      scope.setDynoBaseline = function () { if (!scope.dynoRows.length) { return; } scope.dynoBaseline = scope.dynoRows; try { localStorage.setItem(dynoBaseKey(), JSON.stringify(scope.dynoBaseline)); } catch (e) {} scope.dynoStatus = 'baseline updated'; scope.dynoText = describePull(); drawDyno(); };
      scope.clearDyno = function () { callController('cancelDyno()'); scope.dynoState = 'idle'; scope.dynoRows = []; scope.dynoText = ''; scope.dynoStatus = 'ready'; drawDyno(); };
      function describePull() {
        var p = dynoPeaks(smoothRows(scope.dynoRows));
        var txt = p.hp.toFixed(1) + ' hp @ ' + p.hpRpm + ' | ' + p.tq.toFixed(1) + ' lb-ft @ ' + p.tqRpm;
        if (scope.dynoBaseline && scope.dynoBaseline.length && scope.dynoBaseline !== scope.dynoRows) { var b = dynoPeaks(smoothRows(scope.dynoBaseline)); var d = p.hp - b.hp; txt += ' (' + (d >= 0 ? '+' : '') + d.toFixed(1) + ' hp vs base)'; }
        return txt;
      }
      scope.afrText = function () {
        var l = scope.t.lambdaActual, st = scope.settings.stoichRatio || 14.7;
        if (l === undefined) { return '--'; }
        return (l * st).toFixed(1) + ' AFR (' + l.toFixed(2) + 'λ)';
      };
      // dedicated peak/comparison readouts (independent of the hover cursor)
      function peakOf(rows) { return dynoPeaks(smoothRows(rows || [])); }
      scope.dynoBasePeak = function () { return (scope.dynoBaseline && scope.dynoBaseline.length > 1) ? peakOf(scope.dynoBaseline) : null; };
      scope.dynoCurPeak = function () { return (scope.dynoRows && scope.dynoRows.length > 1) ? peakOf(scope.dynoRows) : null; };
      scope.dynoHasCompare = function () { return !!(scope.dynoBaseline && scope.dynoRows && scope.dynoBaseline.length > 1 && scope.dynoRows.length > 1 && scope.dynoBaseline !== scope.dynoRows); };
      scope.dynoGain = function (kind) {
        var b = scope.dynoBasePeak(), c = scope.dynoCurPeak();
        if (!b || !c) { return 0; }
        return kind === 'tq' ? (c.tq - b.tq) : (c.hp - b.hp);
      };
      scope.dynoGainPct = function () {
        var b = scope.dynoBasePeak(), c = scope.dynoCurPeak();
        if (!b || !c || !b.hp) { return 0; }
        return (c.hp - b.hp) / b.hp * 100;
      };
      var lastDynoDoneSeq = null;   // V2.9.x replay dedupe: one-shot side effects fire once per real pull
      scope.$on('OEProTuneDyno', function (event, data) {
        scope.$applyAsync(function () {
          if (!data) { return; }
          // another car's dyno run must not overwrite OUR graph/baseline (BeamMP); fails open
          if (typeof data.vehId === 'number' && scope.activeVehId !== null && data.vehId !== scope.activeVehId) { return; }
          scope.dynoState = data.state;
          if (data.auto !== undefined) { scope.dynoAuto = data.auto; }
          if (data.startGear !== undefined) { scope.dynoStartGear = data.startGear; }
          if (data.startRpm !== undefined) { scope.dynoStartRpm = data.startRpm; }
          if (data.state === 'idle') { scope.dynoStatus = 'ready'; }
          if (data.state === 'armed') {
            // name the actual start window so "it won't register" self-explains: pulls only START
            // at >90% throttle while RPM is inside this window (comparable runs by design)
            var win = (data.winLo && data.winHi) ? (data.winLo + '–' + data.winHi + ' rpm') : 'low RPM';
            scope.dynoStatus = scope.dynoAuto
              ? ('Auto-armed — floor it in ' + (scope.dynoStartGear ? ('gear ' + scope.dynoStartGear) : 'any gear') + ' between ' + win)
              : ('armed — get revs between ' + win + ', then floor it and hold to the limiter');
          }
          if (data.state === 'running') { scope.dynoStatus = 'PULLING — stay pinned!'; if (data.rows && data.rows.length) { scope.dynoRows = data.rows; drawDyno(); } }
          if (data.state === 'done' && data.rows && data.rows.length) {
            // V2.9.x: the ECU now REPLAYS the last completed pull on the state poll so a UI that
            // linked after the pull (hub-rig seat swap, reconnect) can still paint it. Repainting
            // is idempotent; the one-shot side effects below must fire once per REAL pull only.
            var isReplay = (data.runSeq !== undefined && data.runSeq !== null && data.runSeq === lastDynoDoneSeq);
            if (data.runSeq !== undefined && data.runSeq !== null) { lastDynoDoneSeq = data.runSeq; }
            scope.dynoRows = data.rows;
            if (!isReplay) {
              scope.dynoPullCount++;
              challengeDynoDone(peakOf(data.rows));   // scores a live challenge run; no-op otherwise
              if (!scope.dynoBaseline || !scope.dynoBaseline.length) { scope.dynoBaseline = data.rows; try { localStorage.setItem(dynoBaseKey(), JSON.stringify(scope.dynoBaseline)); } catch (e) {} scope.dynoStatus = 'baseline saved — tune, then pull again'; }
              else { scope.dynoStatus = 'pull complete'; }
              // persist this pull's peaks per car+engine so DYNO VERIFIED survives restarts (Build Card)
              try {
                var vp = peakOf(data.rows);
                if (vp && vp.hp > 0) {
                  scope.powerRec = scope.powerRec || {};
                  scope.powerRec.ver = { hp: vp.hp, hpRpm: vp.hpRpm, tq: vp.tq, tqRpm: vp.tqRpm, date: new Date().toISOString().slice(0, 10) };
                  savePowerRec();
                }
              } catch (e) {}
              // SWEEP QUALITY (V2.6): torque is measured after engine inertia, so a rocketing 1st-gear
              // pull genuinely reads low - same as a real inertia dyno. Teach the real habit: consistent,
              // slower sweeps in a higher gear give truer, repeatable numbers.
              var sw = Number(data.sweep) || 0;
              if (sw > 0) {
                scope.dynoStatus += sw > 1400 ? (' — sweep ' + sw + ' rpm/s: too fast for honest numbers, pull in a higher gear')
                  : sw > 900 ? (' — sweep ' + sw + ' rpm/s: a bit quick, a higher gear reads truer')
                  : (' — sweep ' + sw + ' rpm/s: clean pull');
              }
            }
            scope.dynoText = describePull(); drawDyno();
          }
        });
      });

      // ====================================================================
      // logging (UI accumulates telemetry samples)
      // ====================================================================
      scope.logRows = []; scope.logging = true; scope.logCursor = 0;
      scope.logLanes = { rpm: true, map: true, tps: true, lambda: true, knock: true, egt: true, temp: true };
      scope.logView = { a: 0, b: 1 };   // visible window on the Navigator, as fractions of the whole log (Phase B zoom/pan)
      var lastLogAt = 0;
      function accumulateLog(t) {
        if (!scope.logging) { return; }
        var now = Date.now();
        if (now - lastLogAt < 250) { return; }
        lastLogAt = now;
        scope.logRows.push({
          t: (scope.logRows.length ? scope.logRows[scope.logRows.length - 1].t : 0) + 0.25,
          rpm: t.rpm || 0, tps: t.tps || 0, mapKpa: t.mapKpa || 0,
          tuneLoad: isFinite(Number(t.tuneLoad)) ? Number(t.tuneLoad) : (Number((scope.settings || {}).loadSource) === 2 ? (t.tps || 0) : (t.mapKpa || 0)),
          loadSource: t.loadSource || (Number((scope.settings || {}).loadSource) === 2 ? 'Alpha-N (TPS)' : 'Speed Density (MAP)'),
          baroKpa: isFinite(Number(t.baroKpa)) ? Number(t.baroKpa) : 101.325,
          mapGaugePsi: isFinite(Number(t.mapGaugePsi)) ? Number(t.mapGaugePsi) : (((Number(t.mapKpa) || 0) - (isFinite(Number(t.baroKpa)) ? Number(t.baroKpa) : 101.325)) * KPA_TO_PSI),
          vacuumInHg: isFinite(Number(t.vacuumInHg)) ? Number(t.vacuumInHg) : (Math.max(0, (isFinite(Number(t.baroKpa)) ? Number(t.baroKpa) : 101.325) - (Number(t.mapKpa) || 0)) * 0.295299875),
          lambdaTarget: t.lambdaTarget || 1, lambdaActual: t.lambdaActual || 1,
          lambdaError: t.lambdaError || 0, knockLevel: t.knockLevel || 0, knockRetard: t.knockRetard || 0,
          // V2.9.7 beta feedback (C2): the log carried no ignition channels - capture displayed advance + the table trim
          ignitionAdvance: t.ignitionAdvance || 0, effectiveIgnitionTrim: t.effectiveIgnitionTrim || 0,
          injectorDuty: t.injectorDuty || 0, fuelPressureKpa: t.fuelPressureKpa || 0, fuelDiffPressureKpa: t.fuelDiffPressureKpa || 0, stft: t.stft || 0, ltft: t.ltft || 0,
          iatC: t.iatC || 0, ectC: t.ectC || 0, egtC: t.egtC || 0, engineHealth: t.engineHealth || 100, wheelPowerHp: t.wheelPowerHp || 0,
          boostBaseTargetKpa: t.boostBaseTargetKpa || 0, boostRequestedTargetKpa: t.boostRequestedTargetKpa || 0,
          boostTargetKpa: t.boostTargetKpa || 0, boostGearTargetTrimKpa: t.boostGearTargetTrimKpa || 0,
          boostSpeedTargetTrimKpa: t.boostSpeedTargetTrimKpa || 0, boostTargetTrimAppliedKpa: t.boostTargetTrimAppliedKpa || 0,
          boostFeedforwardDuty: t.boostFeedforwardDuty || 0, wastegateDuty: t.wastegateDuty || 0,
          // V2.9.7 wave 4 (Liqurdz's BBG report): capture PID duty trim + integrator low limit so the new LOG_PARAMS rows have data
          boostPidDutyTrim: t.boostPidDutyTrim || 0, boostILimitLo: t.boostILimitLo || 0,
          boostGearDutyTrimPct: t.boostGearDutyTrimPct || 0, boostSpeedDutyTrimPct: t.boostSpeedDutyTrimPct || 0,
          boostDutyTrimPct: t.boostDutyTrimPct || 0, boostSpeedKmh: t.boostSpeedKmh || 0, boostGear: t.boostGear || 0,
          boostShapeStatus: t.boostShapeStatus || 'Off', boostLimitReason: t.boostLimitReason || 'Base target', oilC: t.oilC || 0,
          misfire: t.misfireStatus || 'OK',
          // V3.0.9 valve float. The CSV column SPEC alone is not enough - this sampler copies an
          // explicit field list onto each row, so a key that is published by the controller but not
          // copied here reaches the writer as undefined and prints as an empty cell. That is exactly
          // what the first two float logs showed: four blank columns that looked like the controller
          // had not reloaded, when the controller was fine and the row was simply missing the fields.
          // `|| 0` is wrong for these: floatPowerCut's neutral value is 1, not 0.
          // 1/0 rather than true/false so the columns sort and chart like every other channel
          limiterStatus: t.limiterStatus || '',
          launchLimitRpm: Number(t.launchLimit) || 0,
          launchActive: t.launchActive ? 1 : 0,
          launchDone: t.launchDone ? 1 : 0,
          launchRollTimer: isFinite(Number(t.launchRollTimer)) ? Number(t.launchRollTimer) : 0,
          launchClutchArmed: t.launchClutchArmed ? 1 : 0,
          floatSeverity: isFinite(Number(t.floatSeverity)) ? Number(t.floatSeverity) : 0,
          floatPowerCut: isFinite(Number(t.floatPowerCut)) ? Number(t.floatPowerCut) : 1,
          floatWear: isFinite(Number(t.floatWear)) ? Number(t.floatWear) : 0,
          floatStatus: t.floatStatus || 'OK',
          // V3.0.8: degrees of shift-management pull. Captured for every engine, petrol and diesel -
          // on petrol it enters effIgnTrim, on diesel it multiplies coef directly (A7).
          shiftMgmtRetard: Number(t.shiftMgmtRetard) || 0,
          // V3.0.8 diesel channels. NULL - not 0 - on a petrol car: the CSV writer turns null into an
          // empty cell, and a column of zeroes for AFR/Smoke on a petrol log reads as data rather
          // than as "not applicable". model.afr is only assigned inside the diesel branch, so the
          // value carried here would be stale otherwise.
          afr: t.isDiesel === true ? (Number(t.afr) || 0) : null,
          smoke: t.isDiesel === true ? (Number(t.smoke) || 0) : null,
          dieselLimiterCut: t.isDiesel === true ? (Number(t.dieselLimiterCut) || 0) : null,
          dieselNozzleDuty: t.isDiesel === true ? (Number(t.dieselNozzleDuty) || 0) : null,
          dieselStatus: t.isDiesel === true ? (t.dieselStatus || 'Running') : null,
          // V3.0.8 (Mem + cmk7's "boost caps at 20psi" reports): the TURBO MAXED diagnosis was
          // live-only and therefore effectively invisible. model.boostMaxed is a 0.7s flag that the
          // controller CLEARS the instant you lift, and its only consumer is a Tune Doctor card -
          // which renders inside ng-if="activePage === 'diagnose'". So the one sentence that answers
          // "why won't my boost reach target" could only ever be seen by someone parked on the Help
          // Me page WHILE on the throttle. Nobody does that. Sample it so the card can look back over
          // the 30s window like every other boost card already does.
          // NOT a CSV change: the export is driven by the fixed LOG_PARAMS list, not by row keys.
          boostMaxed: t.boostMaxed === true,
          // Stamp WHICH car and WHEN. Both are required by the boostMaxed history read in diagnose()
          // and neither can be recovered afterwards:
          //  - logRows is deliberately NOT wiped on a vehicle change in-game (see the note at the
          //    mirror-only wipe below), so without an id the next car inherits this car's ceiling.
          //  - the `t` above is a synthetic 0.25 counter that only advances when a row is pushed, so
          //    it freezes on pause, despawn, the parts menu, or the logging toggle. A window
          //    computed from it is a ROW COUNT, not elapsed time, and would let a frozen buffer keep
          //    asserting "during your last pull" forever.
          veh: scope.activeVehId,
          wall: now,
          faults: t.faults || 'None'
        });
        if (scope.logRows.length > 600) { scope.logRows.shift(); }
        if (scope.activePage === 'logging') { drawLog(); }
      }
      scope.toggleLogging = function () { scope.logging = !scope.logging; };
      scope.clearLog = function () { scope.logRows = []; scope.logCursor = 0; drawLog(); };
      // RFC 4180 cell encoding shared by headers, telemetry and metadata. Normalizing embedded
      // newlines keeps the whole file Windows/Excel friendly without changing the cell value.
      function csvCell(value) {
        var text = value == null ? '' : String(value);
        text = text.replace(/\r\n|\r|\n/g, '\r\n');
        return /[",\r\n]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
      }
      function buildLogCsv(rows, metadata) {
        var sourceWindowStart = rows.length && isFinite(Number(rows[0].t)) ? Number(rows[0].t) : 0;
        var metaColumns = [
          { l: 'OE ProTune version', v: metadata.appVersion },
          { l: 'Car', v: metadata.carName },
          { l: 'Configuration', v: metadata.config },
          { l: 'Engine', v: metadata.engine },
          { l: 'Fuel', v: metadata.fuel },
          { l: 'Injector flow (cc)', v: metadata.injectorFlow },
          { l: 'Drivetrain loss (%)', v: metadata.drivetrainLoss },
          // V3.0.8 diesel settings - blank on petrol. Appended before the two existing trailing
          // columns would reorder them, so they go here and Exported UTC / Source window stay last
          // only by convention; nothing parses these by index, unlike the per-row channels.
          { l: 'Diesel rail (bar)', v: metadata.dieselRail },
          { l: 'Diesel max fuel (%)', v: metadata.dieselMaxFuel },
          { l: 'Diesel smoke limit (AFR)', v: metadata.dieselSmokeLimit },
          { l: 'Diesel switches', v: metadata.dieselSwitches },
          { l: 'Shift torque mgmt (%)', v: metadata.shiftTorqueMgmt },
          { l: 'Shift firmness (%)', v: metadata.shiftFirmness },
          { l: 'Exported UTC', v: metadata.exportedUtc },
          { l: 'Source window start (s)', v: sourceWindowStart.toFixed(2) }
        ];
        var headers = LOG_PARAMS.map(function (p) { return p.l + (p.u ? ' (' + p.u + ')' : ''); });
        headers = headers.concat(metaColumns.map(function (m) { return m.l; }));
        var lines = [headers.map(csvCell).join(',')];
        for (var i = 0; i < rows.length; i++) {
          var r = rows[i], cells = [];
          for (var c = 0; c < LOG_PARAMS.length; c++) {
            var p = LOG_PARAMS[c], v = r[p.k];
            // The rolling buffer may begin far into a session. Make the CSV timeline start at zero
            // while preserving the original offset in Source window start (s).
            if (p.k === 't' && typeof v === 'number' && isFinite(v)) { v -= sourceWindowStart; }
            if (v == null) { v = ''; }
            else if (p.d != null && typeof v === 'number') { v = v.toFixed(p.d); }
            cells.push(csvCell(v));
          }
          for (var m = 0; m < metaColumns.length; m++) { cells.push(csvCell(metaColumns[m].v)); }
          lines.push(cells.join(','));
        }
        // BOM makes Excel select UTF-8 (degree symbols); CRLF and a final terminator maximize
        // compatibility with spreadsheet programs and conventional CSV parsers.
        return '\uFEFF' + lines.join('\r\n') + '\r\n';
      }
      // Phase E: export the log to a CSV in the userfolder (settings/oeProTune/) so it opens in Excel / can be shared.
      scope.exportLogCsv = function () {
        if (IS_MIRROR) { setToast('Export files from the game PC'); return; }
        var rows = scope.logRows; if (!rows || !rows.length) { setToast('No log to export - record a pull first'); return; }
        if (typeof bngApi === 'undefined' || !bngApi.engineLua) { setToast('Export not available here'); return; }
        var settings = scope.settings || {};
        // Same test the Tune Doctor and the log capture use: live telemetry first, spawn identity as
        // the fallback for a frame where telemetry has not landed yet.
        var isDieselNow = (scope.t && scope.t.isDiesel === true) || (scope.base && scope.base.isDiesel === true);
        var csv = buildLogCsv(rows, {
          appVersion: scope.appVersion || '?',
          carName: scope.carName || '?',
          config: (scope.build && scope.build.config) || '',
          engine: (scope.cardEngine && scope.cardEngine()) || '?',
          fuel: SEL.fuelType[(Number(settings.fuelType) || 1) - 1] || '?',
          injectorFlow: settings.injectorFlow == null ? '' : settings.injectorFlow,
          drivetrainLoss: settings.drivetrainLoss == null ? 15 : settings.drivetrainLoss,
          // V3.0.8: the four settings that dominate how a diesel behaves. They are SETTINGS, not
          // telemetry, so they ride as metadata beside injector flow rather than as per-row channels.
          // Without them two logs from the same truck at rail 300 and rail 2200 are indistinguishable
          // - which is exactly what happened while verifying A3, and what will happen to anyone
          // sending a diesel log in for help. Blank on a petrol car so the columns stay honest.
          dieselRail: isDieselNow ? (settings.dieselRailPressure == null ? '' : settings.dieselRailPressure) : '',
          dieselMaxFuel: isDieselNow ? (settings.dieselMaxFuel == null ? '' : settings.dieselMaxFuel) : '',
          dieselSmokeLimit: isDieselNow ? (settings.dieselSmokeLimit == null ? '' : settings.dieselSmokeLimit) : '',
          dieselSwitches: isDieselNow
            ? ('nozzle ' + (settings.dieselNozzleEnabled === true ? 'ON' : 'off')
               + ', limiter ' + (settings.dieselSmokeLimiter === true ? 'ON' : 'off'))
            : '',
          // V3.0.8: transmission sliders, every engine. Same reasoning as the diesel block - the
          // per-row retard channel shows what the ECU DID, these show what it was ASKED to do, and
          // you need both to tell "set to zero" apart from "set but not working" (which is exactly
          // the A7 failure: the slider was set and the ECU reported active while doing nothing).
          shiftTorqueMgmt: settings.shiftTorqueMgmt == null ? '' : settings.shiftTorqueMgmt,
          shiftFirmness: settings.shiftFirmness == null ? '' : settings.shiftFirmness,
          exportedUtc: new Date().toISOString()
        });
        var fname = 'settings/oeProTune/' + String(scope.carName || 'car').replace(/[^a-z0-9]+/gi, '_') + '_log_' + Date.now() + '.csv';
        bngApi.engineLua('(function() local ok = pcall(function() writeFile(' + luaQuote(fname) + ', ' + luaQuote(csv) + ') end) return ok end)()', function (ok) {
          scope.$evalAsync(function () { setToast(ok ? ('Exported ' + rows.length + ' rows to ' + fname) : 'Export failed (writeFile unavailable)'); });
        });
      };
      // open the export folder in the system file explorer (same game API as the "open user folder"
      // buttons: Engine.Platform.exploreFolder). Creates the folder first so it works before the
      // first export too - it's also where the durable tune backups live, handy for support.
      scope.openLogFolder = function () {
        if (IS_MIRROR) { setToast('Open folders from the game PC'); return; }
        if (typeof bngApi === 'undefined' || !bngApi.engineLua) { setToast('Not available here'); return; }
        bngApi.engineLua('pcall(function() if not FS:directoryExists("/settings/oeProTune/") then FS:directoryCreate("/settings/oeProTune/", true) end Engine.Platform.exploreFolder("/settings/oeProTune/") end)');
      };
      scope.toggleLane = function (k) { scope.logLanes[k] = !scope.logLanes[k]; drawLog(); };
      // PCLink-style stacked, separately-scaled lanes. Each trace carries its OWN min/max so it scales
      // within its lane band; lanes with two same-unit traces (lambda tgt+act, ECT+IAT) overlay them.
      var LOG_LANES = [
        { id: 'rpm', label: 'Engine speed (rpm)', dec: 0, traces: [{ key: 'rpm', color: '#39c8ff', min: 0, max: 8000, lbl: 'RPM' }] },
        { id: 'map', label: 'Manifold / boost (kPa)', dec: 0, traces: [
            { key: 'boostBaseTargetKpa', color: '#70809a', min: 0, max: 300, lbl: 'base', dash: true },
            { key: 'boostRequestedTargetKpa', color: '#d7a83c', min: 0, max: 300, lbl: 'req', dash: true },
            { key: 'boostTargetKpa', color: '#9aa83a', min: 0, max: 300, lbl: 'active', dash: true },
            { key: 'mapKpa', color: '#cde84a', min: 0, max: 300, lbl: 'MAP' } ] },
        { id: 'tps', label: 'Throttle (%)', dec: 0, traces: [{ key: 'tps', color: '#39ff70', min: 0, max: 100, lbl: 'TPS' }] },
        { id: 'lambda', label: 'Lambda', dec: 3, traces: [
            { key: 'lambdaTarget', color: '#ffa028', min: 0.6, max: 1.3, lbl: 'tgt', dash: true },
            { key: 'lambdaActual', color: '#bf8cff', min: 0.6, max: 1.3, lbl: 'act' } ] },
        { id: 'knock', label: 'Knock', dec: 1, traces: [
            { key: 'knockLevel', color: '#ff5bd0', min: 0, max: 5, lbl: 'lvl' },
            { key: 'knockRetard', color: '#ff8a3d', min: 0, max: 15, lbl: 'ret' } ] },
        { id: 'egt', label: 'EGT (°C)', dec: 0, traces: [
            { key: 'egtC', color: '#ff5a3c', min: 300, max: 1100, lbl: 'EGT' } ] },
        { id: 'temp', label: 'Temps (°C)', dec: 0, traces: [
            { key: 'ectC', color: '#ff7ad0', min: 20, max: 130, lbl: 'ECT' },
            { key: 'iatC', color: '#ffd23b', min: 0, max: 90, lbl: 'IAT' } ] }
      ];
      function logCanvas() { return element[0].querySelector('.oep-log'); }
      function logNavCanvas() { return element[0].querySelector('.oep-lognav'); }
      // visible window -> row index range. Only the Log Navigator zooms; Log Analysis stays full/live.
      function viewRange(n) {
        if (scope.activePage !== 'log_navigator' || n < 2) { return { i0: 0, i1: Math.max(1, n - 1), span: Math.max(1, n - 1) }; }
        var i0 = Math.max(0, Math.min(n - 2, Math.round(scope.logView.a * (n - 1))));
        var i1 = Math.max(i0 + 1, Math.min(n - 1, Math.round(scope.logView.b * (n - 1))));
        return { i0: i0, i1: i1, span: i1 - i0 };
      }
      // keep the cursor inside the visible window (used by the arrows / event jumps)
      function ensureCursorInView() {
        var n = scope.logRows.length; if (n < 2 || scope.activePage !== 'log_navigator') { return; }
        var cf = scope.logCursor / (n - 1), v = scope.logView, wdt = v.b - v.a;
        if (cf < v.a) { v.a = cf; v.b = cf + wdt; } else if (cf > v.b) { v.b = cf; v.a = cf - wdt; }
        if (v.a < 0) { v.b -= v.a; v.a = 0; } if (v.b > 1) { v.a -= (v.b - 1); v.b = 1; }
      }
      // Phase D: detect event ONSETS (rising edge, debounced) to mark on the lanes + navigator strip.
      var EVENT_DEFS = [
        { type: 'knock', color: '#ff5bd0', label: 'KNOCK', test: function (r) { return r.knockLevel > (scope.settings.knockThreshold || 1.2); } },
        { type: 'lean', color: '#ff4040', label: 'LEAN', test: function (r) { return r.lambdaError > 0.06; } },
        { type: 'overboost', color: '#ffa028', label: 'OVERBOOST', test: function (r) { return r.boostTargetKpa > (Number(r.baroKpa) || 101.325) && (r.mapKpa - r.boostTargetKpa) > 25; } },
        { type: 'idc', color: '#ffd23b', label: 'IDC MAX', test: function (r) { return r.injectorDuty > 99; } },
        { type: 'misfire', color: '#c08cff', label: 'MISFIRE', test: function (r) { return r.misfire && r.misfire !== 'OK'; } },
        { type: 'limp', color: '#ff2020', label: 'DAMAGE', test: function (r) { return r.engineHealth < 90; } }
      ];
      var _logEvCache = { key: '', list: [] };
      function computeLogEvents() {
        var rows = scope.logRows, n = rows.length;
        var key = n + ':' + (n ? rows[n - 1].t.toFixed(2) : 0);
        if (_logEvCache.key === key) { return _logEvCache.list; }
        var list = [], prev = {}, lastIdx = {};
        for (var i = 0; i < n; i++) {
          var r = rows[i];
          for (var e = 0; e < EVENT_DEFS.length; e++) {
            var d = EVENT_DEFS[e], on = d.test(r);
            if (on && !prev[d.type] && (lastIdx[d.type] == null || (i - lastIdx[d.type]) > 4)) {
              list.push({ idx: i, color: d.color, label: d.label }); lastIdx[d.type] = i;
            }
            prev[d.type] = on;
          }
        }
        _logEvCache = { key: key, list: list };
        return list;
      }
      function loggedGaugePsi(row, key) {
        row = row || {};
        if (key === 'mapKpa' && isFinite(Number(row.mapGaugePsi))) { return Number(row.mapGaugePsi); }
        var absoluteKpa = Number(row[key]);
        if (!isFinite(absoluteKpa)) { return 0; }
        var capturedBaro = Number(row.baroKpa);
        if (!isFinite(capturedBaro) || capturedBaro <= 0) { capturedBaro = 101.325; }
        return (absoluteKpa - capturedBaro) * KPA_TO_PSI;
      }
      function drawLog() {
        var cv = logCanvas(); if (!cv) { return; }
        var ctx = cv.getContext('2d'); var w = cv.width, h = cv.height;
        ctx.clearRect(0, 0, w, h); ctx.fillStyle = '#0d0d11'; ctx.fillRect(0, 0, w, h);
        var rows = scope.logRows, n = rows.length;
        var lanes = LOG_LANES.filter(function (L) { return scope.logLanes[L.id]; });
        if (!lanes.length) { return; }
        var laneH = h / lanes.length;
        var vr = viewRange(n), i0 = vr.i0, i1 = vr.i1, span = vr.span;
        var zoom = (scope.activePage === 'log_navigator');
        var ci = zoom ? Math.max(i0, Math.min(i1, scope.logCursor)) : (n - 1);
        ctx.font = '10px Tahoma, Arial, sans-serif'; ctx.textBaseline = 'top';
        for (var li = 0; li < lanes.length; li++) {
          var L = lanes[li], top = li * laneH, bot = top + laneH;
          ctx.strokeStyle = '#23232c'; ctx.lineWidth = 1;
          ctx.beginPath(); ctx.moveTo(0, bot); ctx.lineTo(w, bot); ctx.stroke();
          ctx.strokeStyle = '#15151b';
          ctx.beginPath(); ctx.moveTo(0, top + laneH / 2); ctx.lineTo(w, top + laneH / 2); ctx.stroke();
          var lanePsi = scope.psi && L.id === 'map';
          var lanePsiMin = -15, lanePsiMax = 30;
          if (lanePsi && n >= 2) {
            var visiblePeakPsi = 0;
            for (var pi = i0; pi <= i1; pi++) {
              for (var pt = 0; pt < L.traces.length; pt++) { visiblePeakPsi = Math.max(visiblePeakPsi, loggedGaugePsi(rows[pi], L.traces[pt].key)); }
            }
            lanePsiMax = Math.max(30, Math.ceil((visiblePeakPsi + 5) / 10) * 10);
          }
          if (n >= 2) {
            for (var ti = 0; ti < L.traces.length; ti++) {
              var tr = L.traces[ti];
              // Temp traces carry °C ranges in LOG_LANES, so the SCALE has to convert with the
              // value - converting only the value would squash the trace against the top of a lane
              // still scaled 20-130 °C.
              var trTemp = scope.tempF && TEMP_KEYS[tr.key];
              ctx.strokeStyle = tr.color; ctx.lineWidth = 1.3; ctx.setLineDash(tr.dash ? [4, 3] : []);
              ctx.beginPath();
              for (var i = i0; i <= i1; i++) {
                var x = (i - i0) / span * w;
                var plotValue = lanePsi ? loggedGaugePsi(rows[i], tr.key) : Number(rows[i][tr.key]);
                if (trTemp) { plotValue = cToF(plotValue); }
                if (!isFinite(plotValue)) { plotValue = 0; }
                var plotMin = lanePsi ? lanePsiMin : (trTemp ? cToF(tr.min) : tr.min),
                    plotMax = lanePsi ? lanePsiMax : (trTemp ? cToF(tr.max) : tr.max);
                var frac = Math.max(0, Math.min(1, (plotValue - plotMin) / (plotMax - plotMin)));
                var y = bot - 4 - frac * (laneH - 16);
                if (i === i0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); }
              }
              ctx.stroke();
            }
            ctx.setLineDash([]);
          }
          // PSI units: the Manifold/boost lane uses the barometric reference captured with each row.
          ctx.fillStyle = '#c8ccd2'; ctx.fillText(scope.rowLabel(L.label), 5, top + 3);
          var vx = 5, vy = top + 15, row = rows[ci] || {};
          for (var t2 = 0; t2 < L.traces.length; t2++) {
            var tr2 = L.traces[t2], val = row[tr2.key]; if (val == null) { val = 0; }
            var disp = val;
            if (lanePsi) {
              disp = loggedGaugePsi(row, tr2.key);
            } else if (scope.tempF && TEMP_KEYS[tr2.key]) {
              disp = cToF(Number(disp) || 0);
            }
            var txt = tr2.lbl + ' ' + disp.toFixed(lanePsi ? 1 : (L.dec || 0));
            ctx.fillStyle = tr2.color; ctx.fillText(txt, vx, vy);
            vx += Math.max(58, ctx.measureText(txt).width + 14);
          }
        }
        if (n >= 2) {
          var evs = computeLogEvents(), lastLabelX = -999;
          ctx.font = '9px Tahoma, Arial';
          for (var em = 0; em < evs.length; em++) {
            var ev = evs[em];
            if (ev.idx < i0 || ev.idx > i1) { continue; }
            var ex = (ev.idx - i0) / span * w;
            ctx.strokeStyle = ev.color; ctx.globalAlpha = 0.55; ctx.lineWidth = 1; ctx.setLineDash([3, 3]);
            ctx.beginPath(); ctx.moveTo(ex, 13); ctx.lineTo(ex, h); ctx.stroke();
            ctx.setLineDash([]); ctx.globalAlpha = 1;
            if (ex - lastLabelX > 46) { ctx.fillStyle = ev.color; ctx.fillText(ev.label, Math.min(ex + 2, w - 54), h - 11); lastLabelX = ex; }
          }
        }
        if (zoom && n > 1) {
          var cx = (ci - i0) / span * w; ctx.strokeStyle = '#e6e6e6'; ctx.setLineDash([2, 2]);
          ctx.beginPath(); ctx.moveTo(cx, 0); ctx.lineTo(cx, h); ctx.stroke(); ctx.setLineDash([]);
        }
        drawLogNav();
      }
      // Phase B: bottom overview strip — full log (RPM) with a draggable/resizable window = the zoom range
      function drawLogNav() {
        var cv = logNavCanvas(); if (!cv) { return; }
        var ctx = cv.getContext('2d'); var w = cv.width, h = cv.height;
        ctx.clearRect(0, 0, w, h); ctx.fillStyle = '#0a0a0e'; ctx.fillRect(0, 0, w, h);
        var rows = scope.logRows, n = rows.length;
        if (n >= 2) {
          ctx.strokeStyle = '#2f7fa8'; ctx.lineWidth = 1; ctx.beginPath();
          for (var i = 0; i < n; i++) {
            var x = i / (n - 1) * w, frac = Math.max(0, Math.min(1, rows[i].rpm / 8000));
            var y = h - 2 - frac * (h - 4);
            if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); }
          }
          ctx.stroke();
          var ccx = scope.logCursor / (n - 1) * w; ctx.strokeStyle = '#ffd23b'; ctx.lineWidth = 1;
          ctx.beginPath(); ctx.moveTo(ccx, 0); ctx.lineTo(ccx, h); ctx.stroke();
        }
        var xa = scope.logView.a * w, xb = scope.logView.b * w;
        ctx.fillStyle = 'rgba(180,200,255,0.12)'; ctx.fillRect(xa, 0, Math.max(2, xb - xa), h);
        ctx.strokeStyle = '#e6e6e6'; ctx.lineWidth = 1.2; ctx.strokeRect(xa + 0.5, 0.5, Math.max(2, xb - xa - 1), h - 1);
        ctx.fillStyle = '#e6e6e6'; ctx.fillRect(xa - 1, h / 2 - 7, 3, 14); ctx.fillRect(xb - 2, h / 2 - 7, 3, 14);
        if (n >= 2) {
          var evs2 = computeLogEvents();
          for (var k = 0; k < evs2.length; k++) {
            var tx = evs2[k].idx / (n - 1) * w;
            ctx.strokeStyle = evs2[k].color; ctx.globalAlpha = 0.9; ctx.lineWidth = 1.5;
            ctx.beginPath(); ctx.moveTo(tx, h - 6); ctx.lineTo(tx, h); ctx.stroke();
          }
          ctx.globalAlpha = 1;
        }
        ctx.fillStyle = '#8a8f96'; ctx.font = '9px Tahoma, Arial'; ctx.textBaseline = 'top';
        ctx.fillText('Navigator - drag window to pan, edges to zoom', 4, 2);
      }
      scope.redrawLog = function () { setTimeout(function () { drawLog(); }, 50); };
      scope.logStat = function (key, kind) {
        var rows = scope.logRows; if (!rows.length) { return '--'; }
        var mn = 1e9, mx = -1e9, sum = 0;
        rows.forEach(function (r) { var v = r[key]; mn = Math.min(mn, v); mx = Math.max(mx, v); sum += v; });
        if (kind === 'min') { return mn.toFixed(1); } if (kind === 'max') { return mx.toFixed(1); } return (sum / rows.length).toFixed(1);
      };
      scope.logCursorRow = function () { return scope.logRows[scope.logCursor] || null; };
      // Phase C: full Parameter List at the cursor (like PCLink's right panel). Every logged channel.
      var LOG_PARAMS = [
        { k: 't', l: 'Time', u: 's', d: 2 },
        { k: 'rpm', l: 'Engine speed', u: 'rpm', d: 0 },
        { k: 'tps', l: 'Throttle', u: '%', d: 0 },
        { k: 'mapKpa', l: 'ECU MAP (modeled, absolute)', u: 'kPa', d: 1 },
        { k: 'tuneLoad', l: 'Active table load', u: 'TPS % or kPa', d: 1 },
        { k: 'loadSource', l: 'Table load source', u: '', d: null },
        { k: 'baroKpa', l: 'Barometric pressure', u: 'kPa', d: 1 },
        { k: 'mapGaugePsi', l: 'MAP gauge pressure', u: 'psi', d: 2 },
        { k: 'vacuumInHg', l: 'Estimated manifold vacuum', u: 'inHg', d: 2 },
        { k: 'boostBaseTargetKpa', l: 'Boost base target', u: 'kPa abs', d: 0 },
        { k: 'boostRequestedTargetKpa', l: 'Boost shaped request', u: 'kPa abs', d: 0 },
        { k: 'boostTargetKpa', l: 'Boost active target', u: 'kPa abs', d: 0 },
        { k: 'boostTargetTrimAppliedKpa', l: 'Boost applied target trim', u: 'kPa', d: 1 },
        { k: 'boostFeedforwardDuty', l: 'Boost feed-forward duty', u: '%', d: 1 },
        { k: 'wastegateDuty', l: 'Wastegate duty', u: '%', d: 1 },
        { k: 'boostPidDutyTrim', l: 'Boost PID duty trim', u: '%', d: 1 },
        { k: 'boostILimitLo', l: 'Boost integrator low limit', u: 'psi', d: 1 },
        { k: 'boostDutyTrimPct', l: 'Boost combined WG trim', u: '%', d: 1 },
        { k: 'boostSpeedKmh', l: 'Boost ground speed', u: 'km/h', d: 1 },
        { k: 'boostGear', l: 'Boost gear', u: '', d: 0 },
        { k: 'boostShapeStatus', l: 'Boost shaping', u: '', d: null },
        { k: 'boostLimitReason', l: 'Boost target source', u: '', d: null },
        { k: 'lambdaTarget', l: 'Lambda target', u: '', d: 3 },
        { k: 'lambdaActual', l: 'Lambda actual', u: '', d: 3 },
        { k: 'lambdaError', l: 'Lambda delta (actual - target)', u: '', d: 3 },
        { k: 'stft', l: 'Short fuel trim', u: '%', d: 1 },
        { k: 'ltft', l: 'Long fuel trim', u: '%', d: 1 },
        { k: 'injectorDuty', l: 'Injector duty', u: '%', d: 0 },
        { k: 'fuelPressureKpa', l: 'Fuel rail press', u: 'kPa', d: 0 },
        { k: 'fuelDiffPressureKpa', l: 'Fuel diff press', u: 'kPa', d: 0 },
        { k: 'ignitionAdvance', l: 'Ignition advance', u: '°', d: 1 },
        { k: 'effectiveIgnitionTrim', l: 'Ignition trim (table)', u: '°', d: 1 },
        { k: 'knockLevel', l: 'Knock level', u: '', d: 1 },
        { k: 'knockRetard', l: 'Knock retard', u: '°', d: 1 },
        { k: 'iatC', l: 'IAT', u: '°C', d: 0 },
        { k: 'ectC', l: 'ECT', u: '°C', d: 0 },
        { k: 'egtC', l: 'EGT', u: '°C', d: 0 },
        { k: 'oilC', l: 'Oil temp', u: '°C', d: 0 },
        { k: 'engineHealth', l: 'Engine health', u: '%', d: 0 },
        { k: 'wheelPowerHp', l: 'Wheel power', u: 'hp', d: 0 },
        { k: 'misfire', l: 'Misfire', u: '', d: null },
        { k: 'faults', l: 'Faults', u: '', d: null },
        { k: 'limiterStatus', l: 'Limiter status', u: '', d: null },
        { k: 'launchLimitRpm', l: 'Launch limit', u: 'rpm', d: 0 },
        { k: 'launchActive', l: 'Launch active', u: '', d: null },
        { k: 'launchDone', l: 'Launch latched done', u: '', d: null },
        { k: 'launchRollTimer', l: 'Launch roll timer', u: 's', d: 2 },
        { k: 'launchClutchArmed', l: 'Launch clutch armed', u: '', d: null },
        { k: 'floatSeverity', l: 'Valve float severity', u: '', d: 2 },
        { k: 'floatPowerCut', l: 'Valve float power mult', u: '', d: 2 },
        { k: 'floatWear', l: 'Valve float wear', u: '', d: 3 },
        { k: 'floatStatus', l: 'Valve float status', u: '', d: null },
        { k: 'shiftMgmtRetard', l: 'Shift torque mgmt pull', u: '°', d: 1 },
        { k: 'afr', l: 'AFR (diesel)', u: ':1', d: 1 },
        { k: 'smoke', l: 'Smoke (diesel)', u: '', d: 0 },
        { k: 'dieselLimiterCut', l: 'Smoke limiter fuel cut', u: '%', d: 0 },
        { k: 'dieselNozzleDuty', l: 'Injector nozzle duty', u: '%', d: 0 },
        { k: 'dieselStatus', l: 'Diesel status', u: '', d: null }
      ];
      scope.logParams = function () {
        var row = scope.logRows[scope.logCursor]; if (!row) { return []; }
        var out = [];
        for (var i = 0; i < LOG_PARAMS.length; i++) {
          var p = LOG_PARAMS[i], v = row[p.k], u = p.u, d = p.d;
          // PSI units: MAP/boost target use the barometric pressure captured with this row.
          // the runtime rows + boost table + dyno, so the whole app agrees when PSI is selected.
          if (scope.psi && typeof v === 'number') {
            if (p.k === 'mapKpa') { v = isFinite(Number(row.mapGaugePsi)) ? Number(row.mapGaugePsi) : (v - (Number(row.baroKpa) || 101.325)) * KPA_TO_PSI; u = 'psi'; d = 1; }
            else if (p.k === 'boostTargetKpa' || p.k === 'boostBaseTargetKpa' || p.k === 'boostRequestedTargetKpa') { v = (v - (Number(row.baroKpa) || 101.325)) * KPA_TO_PSI; u = 'psi'; d = 1; }
            else if (p.k === 'boostTargetTrimAppliedKpa') { v = v * KPA_TO_PSI; u = 'psi'; d = 1; }
            else if (p.k === 'baroKpa' || p.k === 'fuelPressureKpa' || p.k === 'fuelDiffPressureKpa') { v = v * KPA_TO_PSI; u = 'psi'; d = 1; }
          }
          // Temperatures: this readout already honours the PSI toggle, so honouring the °C/°F one
          // is the consistent behaviour. LOG_PARAMS stores the unit as a literal '°C', so the unit
          // string has to be swapped here too, not just the number.
          if (scope.tempF && typeof v === 'number' && TEMP_KEYS[p.k]) { v = cToF(v); u = '°F'; }
          var val = (d == null || typeof v !== 'number') ? (v == null ? '--' : String(v)) : v.toFixed(d);
          out.push({ label: p.l, val: val + (u ? ' ' + u : '') });
        }
        return out;
      };
      scope.moveCursor = function (d) { scope.logCursor = Math.max(0, Math.min(scope.logRows.length - 1, scope.logCursor + d)); ensureCursorInView(); drawLog(); };
      scope.logZoomFit = function () { scope.logView.a = 0; scope.logView.b = 1; drawLog(); };
      scope.jumpToEvent = function (kind) {
        var rows = scope.logRows;
        for (var i = 0; i < rows.length; i++) {
          var r = rows[i], hit = false;
          if (kind === 'lean') { hit = r.lambdaError > 0.06; }
          else if (kind === 'knock') { hit = r.knockLevel > (scope.settings.knockThreshold || 1.2); }
          else if (kind === 'idc') { hit = r.injectorDuty > 95; }
          else if (kind === 'misfire') { hit = r.misfire && r.misfire !== 'OK'; }
          else if (kind === 'health') { hit = r.engineHealth < 90; }
          if (hit && i > scope.logCursor) { scope.logCursor = i; ensureCursorInView(); drawLog(); return; }
        }
        setToast('No further ' + kind + ' event');
      };

      // ====================================================================
      // mixture map (ratio-based fuel correction from accepted lambda samples)
      // ====================================================================
      // V2.9 VE learner repair. Public Angular functions stay compatible with
      // the existing page while the evidence model is baseline-aware.
      // Evidence is tied to the exact table baseline and is never allowed to
      // survive a calibration change.
      scope.mixBins = null;
      scope.mixDisplayMode = 'correction'; // correction | suggestedVE | veDelta
      scope.mixLearnMode = 'auto';         // auto | steady | pull
      scope.mixO2DelayMs = 0;       // BeamNG supplies an instantaneous internal lambda channel, so delay stays locked at zero
      scope.mixO2DelayDraftMs = 0;
      scope.mixLearnStats = {
        accepted: 0, acceptedSteady: 0, acceptedPull: 0, skipped: 0, rejected: 0,
        lastSkip: '', lastReject: '', lastResetReason: '', phase: 'waiting', currentState: 'Waiting for valid data', generation: 1
      };
      // Fixed, low-rate display projection. Learning still consumes the full
      // 20 Hz stream; this object updates at 4 Hz so counters and long reasons
      // do not make the page flicker or shift while driving.
      scope.mixUi = {
        phase: 'WAITING', state: 'Waiting for valid data', readyCells: 0, learningCells: 0,
        accepted: 0, acceptedSteady: 0, acceptedPull: 0, skipped: 0, rejected: 0,
        lastSkip: '—', lastReject: '—', resetReason: '—', rpm: '—', map: '—', baro: '—', vacuumBoost: '—',
        tps: '—', lambda: '—', axisStatus: 'Waiting for telemetry', axisInside: false,
        loadLabel: 'ECU LOAD (MAP)', mapSource: 'Modeled MAP'
      };
      var mixGeneration = 1, mixVehicleId = null, mixAxisSignature = null, mixCalibrationSignature = null;
      var mixSettingsMirror = {}, mixLastInvalidateAt = 0;   // V2.9: last-known fuel-setting values + invalidation timestamp (first reason wins)
      var mixTransportBuffer = [], mixMotionWindow = [], mixLastEvidenceAt = 0, mixResumeAfter = 0;
      var mixRecoveryUntil = 0, mixRecoveryReason = '', mixLastGear = null;
      var mixPullSessionActive = false, mixPullPassId = 0;
      var MIX_SAMPLE_INTERVAL_MS = 50, MIX_STABLE_WINDOW_MS = 300;
      var MIX_UI_INTERVAL_MS = 250, mixUiNextAt = 0;
      var MIX_STABLE_RPM_SPAN = 160, MIX_STABLE_MAP_SPAN = 6, MIX_STABLE_TPS_SPAN = 4;
      var MIX_PULL_MIN_TPS = 85, MIX_PULL_EXIT_TPS = 75, MIX_PULL_MAX_TPS_SPAN = 3;
      var MIX_PULL_MAX_TPS_RATE = 10, MIX_PULL_MIN_RPM_RATE = 100, MIX_PULL_MAX_RPM_RATE = 12000;
      var MIX_PULL_MAX_RPM_DROP = 150, MIX_PULL_MIN_MAP_RATE = -30, MIX_PULL_MAX_MAP_RATE = 600;
      var MIX_LOW_TPS_DECEL_MAX_TPS = 3, MIX_LOW_TPS_DECEL_RPM_RATE = -150, MIX_LOW_TPS_DECEL_SPEED_RATE = -0.5;
      var MIX_MIN_ECT_C = 70, MIX_MAX_INJECTOR_DUTY = 90, MIX_MIN_DIFF_PRESSURE_KPA = 270;
      var MIX_OUTLIER_CORRECTION = 0.35, MIX_MAX_CORRECTION = 0.25;
      var MIX_EVIDENCE_TTL_MS = 1800000, MIX_MAX_EVIDENCE = 200;
      var MIX_MIN_HITS = 6, MIX_MIN_WEIGHT = 2.5, MIX_FULL_CONFIDENCE_HITS = 15, MIX_FULL_CONFIDENCE_WEIGHT = 7;
      var MIX_MIN_PULL_PASS_HITS = 2, MIX_MIN_PULL_PASS_WEIGHT = 0.35;
      // V3.0.7 grid-aware steadiness. The two span gates above were tuned against the factory 6x7
      // grid; express them as a FRACTION of the live cell width so a finer grid does not silently
      // relax what "the operating point is sitting in this cell" means. Clamped to the original
      // absolute value so this can only ever tighten - a coarser-than-factory grid keeps today's
      // behaviour exactly, and no existing tune changes.
      //
      // Deliberately NOT scaled: MIX_MIN_HITS / MIX_MIN_WEIGHT and the confidence thresholds. How
      // much evidence a cell needs before its correction is trustworthy is a statistical question,
      // not a geometric one - weakening it because there are more cells would buy a faster-filling
      // Mixture Map by making every individual correction less reliable. A finer grid genuinely does
      // need proportionally more driving to learn, and the Axis Setup help says so.
      // Scale by CELL COUNT, not by absolute cell width. The axis SPAN is a property of the car (an
      // NA MAP axis covers 20-100 kPa, a big-turbo one 20-1000), so a fixed reference width would
      // make the gate absurdly tight on an NA engine and loose on a turbo. Holding the span at a
      // constant FRACTION of a cell means the factory grid reproduces today's numbers on every car.
      var MIX_FACTORY_COLS = 7, MIX_FACTORY_ROWS = 6;
      function mixStableSpan(kind) {
        var axis = kind === 'rpm' ? scope.rpmAxis : scope.mapAxis;
        var base = kind === 'rpm' ? MIX_STABLE_RPM_SPAN : MIX_STABLE_MAP_SPAN;
        var factoryCount = kind === 'rpm' ? MIX_FACTORY_COLS : MIX_FACTORY_ROWS;
        if (!axis || axis.length < 2) { return base; }
        return Math.min(base, base * ((factoryCount - 1) / (axis.length - 1)));
      }
      var MIX_INVALIDATING_SETTINGS = {
        fuelEquationMode: 1, loadSource: 1, globalFuelTrim: 1, masterFuel: 1, injectorFlow: 1, injectorDeadtime: 1,
        fuelType: 1, stoichRatio: 1, cllMode: 1, cllEctLockout: 1, cllTpsLockout: 1, cllMapLockout: 1,
        crankEnrich: 1, postStartEnrich: 1, postStartDecay: 1, warmupEnrich: 1, warmupEndTemp: 1,
        accelEnrich: 1, accelDecay: 1, wallWetting: 1, decelFuelCut: 1, decelFuel: 1,
        launchFuel: 1,
        sensorMapOffset: 1, sensorMapScale: 1, sensorTpsOffset: 1, sensorTpsScale: 1,
        sensorIatBias: 1, sensorEctBias: 1, sensorLambdaBias: 1, sensorFuelPressureBias: 1,
        cylTrim1: 1, cylTrim2: 1, cylTrim3: 1, cylTrim4: 1,
        cylTrim5: 1, cylTrim6: 1, cylTrim7: 1, cylTrim8: 1
      };

      function mixtureSettingInvalidates(key) { return !!MIX_INVALIDATING_SETTINGS[key]; }
      // V2.9: remember the fuel-setting values the controller is known to run, normalized exactly like
      // send() normalizes them, so an unchanged re-push (unpause / reconnect) is recognized as a no-op.
      function seedMixSettingsMirror() {
        var s = scope.settings || {};
        Object.keys(MIX_INVALIDATING_SETTINGS).forEach(function (k) {
          if (s[k] === undefined) { return; }
          var n = (s[k] === true) ? 1 : (s[k] === false ? 0 : Number(s[k]));
          if (isFinite(n)) { mixSettingsMirror[k] = n; }
        });
      }
      function finiteMix(v, fallback) { var n = Number(v); return isFinite(n) ? n : fallback; }
      function clampMix(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
      function mixtureAxisSignature() {
        return String(Number((scope.settings || {}).loadSource) || 1) + '|' +
          (scope.rpmAxis || []).join(',') + '|' + (scope.mapAxis || []).join(',') + '|' + (scope.alphaNAxis || []).join(',');
      }
      function calibrationSignatureFrom(settingsObj, rpmAxisArr, mapAxisArr, alphaAxisArr, mapsObj) {
        var parts = [String(Number((settingsObj || {}).fuelEquationMode) || 1), String(Number((settingsObj || {}).loadSource) || 1),
          (rpmAxisArr || []).join(',') + '|' + (mapAxisArr || []).join(',') + '|' + (alphaAxisArr || []).join(',')];
        var maps = mapsObj || {};
        ['fuel', 've', 'lambda'].forEach(function (name) {
          var table = maps[name] || [];
          parts.push(name);
          for (var r = 0; r < table.length; r++) { parts.push((table[r] || []).join(',')); }
        });
        return parts.join('|');
      }
      function mixtureCalibrationSignature() {
        return calibrationSignatureFrom(scope.settings, scope.rpmAxis, scope.mapAxis, scope.alphaNAxis, scope.maps);
      }
      // V2.9: value-compare for fuel-related settings (send() normalization: true/false -> 1/0)
      function sameFuelSettingValue(a, b) {
        if (a === undefined && b === undefined) { return true; }
        var na = (a === true) ? 1 : (a === false ? 0 : Number(a));
        var nb = (b === true) ? 1 : (b === false ? 0 : Number(b));
        return isFinite(na) && isFinite(nb) && Math.abs(na - nb) <= 1e-9;
      }
      // Axis breakpoints accept deliberate fractional edits. Ignore only floating-point noise,
      // matching the controller's setAxisValues comparison without swallowing a real small move.
      function axesEqualTol(a, b) {
        if (!a || !b || a.length !== b.length) { return false; }
        for (var i = 0; i < a.length; i++) { if (!(Math.abs(Number(a[i]) - Number(b[i])) <= 1e-6)) { return false; } }
        return true;
      }
      function newMixBinsV2() {
        var bins = [];
        for (var r = 0; r < scope.activeLoadAxis('fuel').length; r++) {
          bins[r] = [];
          for (var c = 0; c < scope.rpmAxis.length; c++) { bins[r][c] = { evidence: [], mapName: null }; }
        }
        return bins;
      }
      function invalidateMixture(reason) {
        mixGeneration++;
        scope.mixBins = null;
        mixTransportBuffer = [];
        mixMotionWindow = [];
        mixLastEvidenceAt = 0;
        mixRecoveryUntil = 0;
        mixRecoveryReason = '';
        mixLastGear = null;
        mixPullSessionActive = false;
        mixPullPassId = 0;
        mixResumeAfter = Date.now() + 750; // let asynchronous ECU map/settings echoes settle
        // V2.9 fix: adopt the next broadcast's signatures silently - this invalidation already covers
        // the change that is about to echo back, and re-invalidating on that echo clobbered the shown
        // reason (every 'corrections applied' / 'table reset' turned into 'fuel calibration changed').
        mixAxisSignature = null;
        mixCalibrationSignature = null;
        // Rapid follow-up invalidations (the bridge regex firing right after an explicit call) keep
        // the FIRST reason - it is the one that names what the player actually did.
        var now = Date.now();
        var priorReason = scope.mixLearnStats && (scope.mixLearnStats.lastResetReason || scope.mixLearnStats.reason) || '';
        var keepReason = (now - mixLastInvalidateAt < 1200) && priorReason;
        mixLastInvalidateAt = now;
        var resetReason = keepReason ? priorReason : (reason || '');
        scope.mixLearnStats = {
          accepted: 0, acceptedSteady: 0, acceptedPull: 0, skipped: 0, rejected: 0,
          lastSkip: '', lastReject: '', phase: 'waiting', currentState: 'Waiting for valid data',
          generation: mixGeneration, reason: resetReason, lastResetReason: resetReason
        };
        refreshMixUi(scope.t, true);
      }
      function rejectMixture(reason) {
        reason = reason || 'sample rejected';
        scope.mixLearnStats.rejected++;
        scope.mixLearnStats.lastReject = reason;
        scope.mixLearnStats.phase = 'rejected';
        scope.mixLearnStats.currentState = 'Rejected measurement';
      }
      function skipMixture(reason) {
        reason = reason || 'not eligible';
        scope.mixLearnStats.skipped++;
        scope.mixLearnStats.lastSkip = reason;
        scope.mixLearnStats.phase = 'skipped';
        scope.mixLearnStats.currentState = 'Skipped sample';
      }
      function waitMixture(reason) {
        scope.mixLearnStats.phase = 'waiting';
        scope.mixLearnStats.currentState = 'Waiting: ' + (reason || 'selected learning conditions');
      }
      function mixAxisBracket(axis, value) {
        if (!axis || axis.length < 2) { return null; }
        var i = 0;
        while (i < axis.length - 2 && value > axis[i + 1]) { i++; }
        var den = Math.max(0.0001, Number(axis[i + 1]) - Number(axis[i]));
        return { lo: i, hi: i + 1, f: clampMix((value - Number(axis[i])) / den, 0, 1) };
      }
      function mixContributors(rpm, loadValue, mapName) {
        var rb = mixAxisBracket(scope.activeLoadAxis(mapName), loadValue), cb = mixAxisBracket(scope.rpmAxis, rpm);
        var table = scope.maps && scope.maps[mapName];
        if (!rb || !cb || !table) { return []; }
        var candidates = [
          { r: rb.lo, c: cb.lo, w: (1 - rb.f) * (1 - cb.f) },
          { r: rb.lo, c: cb.hi, w: (1 - rb.f) * cb.f },
          { r: rb.hi, c: cb.lo, w: rb.f * (1 - cb.f) },
          { r: rb.hi, c: cb.hi, w: rb.f * cb.f }
        ];
        var out = [];
        for (var i = 0; i < candidates.length; i++) {
          var p = candidates[i];
          if (p.w <= 0.0001 || !table[p.r] || !isFinite(Number(table[p.r][p.c]))) { continue; }
          p.baseline = Number(table[p.r][p.c]);
          out.push(p);
        }
        return out;
      }
      function mixInsideAxes(rpm, loadValue, mapName) {
        var loadAxis = scope.activeLoadAxis(mapName);
        if (!scope.rpmAxis || !scope.rpmAxis.length || !loadAxis.length) { return false; }
        var rpmMin = Number(scope.rpmAxis[0]), rpmMax = Number(scope.rpmAxis[scope.rpmAxis.length - 1]);
        var loadMin = Number(loadAxis[0]), loadMax = Number(loadAxis[loadAxis.length - 1]);
        return rpm >= rpmMin - 1 && rpm <= rpmMax + 1 && loadValue >= loadMin - 0.5 && loadValue <= loadMax + 0.5;
      }
      function mixLinearRate(rows, key) {
        if (!rows || rows.length < 2) { return 0; }
        var t0 = rows[0].at, n = rows.length, sumT = 0, sumV = 0;
        for (var i = 0; i < n; i++) { sumT += (rows[i].at - t0) / 1000; sumV += rows[i][key]; }
        var meanT = sumT / n, meanV = sumV / n, covariance = 0, variance = 0;
        for (var j = 0; j < n; j++) {
          var dt = (rows[j].at - t0) / 1000 - meanT;
          covariance += dt * (rows[j][key] - meanV);
          variance += dt * dt;
        }
        return variance > 0.000001 ? covariance / variance : 0;
      }
      function mixMotionMetrics(t, now) {
        // V2.9 fix (version skew): gearIndex/clutchInput are 2.9 telemetry. A pre-2.9 controller never
        // sends them, and treating "missing" as gear 0 made every WOT pull fail 'forward gear required'
        // forever. Missing gear = null = the gear gates are skipped (they are extra safety, not core).
        var gear = isFinite(Number(t.gearIndex)) ? Math.round(Number(t.gearIndex)) : null;
        mixMotionWindow.push({
          at: now, rpm: finiteMix(t.rpm, 0), map: finiteMix(t.mapKpa, 0), tps: finiteMix(t.tps, 0),
          gear: gear, clutch: finiteMix(t.clutchInput, 0), speed: finiteMix(t.speedKmh, 0)
        });
        // V2.9.7 beta feedback (D1): retain the newest sample that straddles the window
        // boundary so the retained span always covers the full ~300 ms window. The Pro
        // Studio mirror runs this same learner on the 10 Hz remote feed; the old strict
        // prune left ~3 samples spanning ~200 ms - below the 225 ms readiness gate - so
        // readiness flapped false forever ('building rolling motion window' while the
        // in-game instance reported Learning Steady).
        while (mixMotionWindow.length > 1 && mixMotionWindow[1].at <= now - MIX_STABLE_WINDOW_MS) { mixMotionWindow.shift(); }

        var result = {
          ready: false, durationMs: 0, rpmSpan: 0, mapSpan: 0, tpsSpan: 0,
          rpmRate: 0, mapRate: 0, tpsRate: 0, speedRate: 0, maxRpmDrop: 0,
          sameGear: true, forwardGearWindow: true, currentGear: gear, steady: false, pull: false, pullWeight: 1
        };
        if (mixMotionWindow.length < 2) { return result; }

        var first = mixMotionWindow[0], last = mixMotionWindow[mixMotionWindow.length - 1];
        result.durationMs = Math.max(0, last.at - first.at);
        result.ready = mixMotionWindow.length >= 3 && result.durationMs >= MIX_STABLE_WINDOW_MS * 0.75;
        var rpmLo = Infinity, rpmHi = -Infinity, mapLo = Infinity, mapHi = -Infinity, tpsLo = Infinity, tpsHi = -Infinity;
        var knownGear = null;
        for (var i = 0; i < mixMotionWindow.length; i++) {
          var q = mixMotionWindow[i];
          rpmLo = Math.min(rpmLo, q.rpm); rpmHi = Math.max(rpmHi, q.rpm);
          mapLo = Math.min(mapLo, q.map); mapHi = Math.max(mapHi, q.map);
          tpsLo = Math.min(tpsLo, q.tps); tpsHi = Math.max(tpsHi, q.tps);
          if (i > 0) { result.maxRpmDrop = Math.max(result.maxRpmDrop, mixMotionWindow[i - 1].rpm - q.rpm); }
          if (q.gear === null) { /* pre-2.9 controller: no gear telemetry - skip the gear gates */ }
          else if (q.gear > 0) {
            if (knownGear === null) { knownGear = q.gear; }
            else if (knownGear !== q.gear) { result.sameGear = false; }
          } else { result.forwardGearWindow = false; }
        }
        result.rpmSpan = rpmHi - rpmLo;
        result.mapSpan = mapHi - mapLo;
        result.tpsSpan = tpsHi - tpsLo;
        result.rpmRate = mixLinearRate(mixMotionWindow, 'rpm');
        result.mapRate = mixLinearRate(mixMotionWindow, 'map');
        result.tpsRate = mixLinearRate(mixMotionWindow, 'tps');
        result.speedRate = mixLinearRate(mixMotionWindow, 'speed');
        // V3.0.7: the steadiness gate decides whether a sample may be ATTRIBUTED to one cell, so it
        // has to be judged against that cell's WIDTH, not against a fixed rpm/kPa span. At the
        // factory 6x7 an rpm cell is ~1100 rpm wide and 160 rpm of drift is ~15% of it; on a 16-column
        // grid the same cell is ~450 rpm wide and the identical gate would let the operating point
        // wander over a third of the cell while still counting as "steady". Tighten proportionally,
        // and never loosen: on any grid at or coarser than factory these are exactly today's values.
        result.steady = result.ready && result.rpmSpan <= mixStableSpan('rpm') &&
          result.mapSpan <= mixStableSpan('map') && result.tpsSpan <= MIX_STABLE_TPS_SPAN;
        result.pull = result.ready && last.tps >= MIX_PULL_MIN_TPS &&
          result.tpsSpan <= MIX_PULL_MAX_TPS_SPAN && Math.abs(result.tpsRate) <= MIX_PULL_MAX_TPS_RATE &&
          result.rpmRate >= MIX_PULL_MIN_RPM_RATE && result.rpmRate <= MIX_PULL_MAX_RPM_RATE &&
          result.maxRpmDrop <= MIX_PULL_MAX_RPM_DROP &&
          result.mapRate >= MIX_PULL_MIN_MAP_RATE && result.mapRate <= MIX_PULL_MAX_MAP_RATE &&
          (last.gear === null || (knownGear !== null && last.gear > 0)) && result.forwardGearWindow && result.sameGear && last.clutch <= 0.15 && last.speed >= 8;
        if (result.rpmRate > 4000) { result.pullWeight = clampMix(4000 / result.rpmRate, 0.4, 1); }
        return result;
      }
      function extendMixRecovery(now, delayMs, reason) {
        var until = now + delayMs;
        if (until >= mixRecoveryUntil) { mixRecoveryUntil = until; mixRecoveryReason = reason; }
      }
      function updateMixLearningState(t, now) {
        var tps = finiteMix(t.tps, 0), speed = finiteMix(t.speedKmh, 0);
        var gear = Math.round(finiteMix(t.gearIndex, 0)), clutch = finiteMix(t.clutchInput, 0);
        var tpsRate = finiteMix(t.tpsDelta, 0), transientTrim = finiteMix(t.transientFuelTrim, 0);
        var misfire = String(t.misfireStatus || '').toLowerCase();

        if (mixLastGear !== null && gear > 0 && mixLastGear > 0 && gear !== mixLastGear) {
          extendMixRecovery(now, 500, 'gear change');
        }
        if (gear > 0) { mixLastGear = gear; }
        if (clutch > 0.15) { extendMixRecovery(now, 500, 'clutch/shift'); }
        if (Math.abs(tpsRate) > 20) { extendMixRecovery(now, 300, 'TPS transient'); }
        if (Math.abs(transientTrim) > 0.25) { extendMixRecovery(now, 250, 'transient fuel'); }
        if (t.decelFuelCutActive) { extendMixRecovery(now, 750, 'decel fuel resume'); }
        if (t.limiterActive || t.launchActive || t.flatShiftActive || t.rollingActive) { extendMixRecovery(now, 500, 'limiter/shift'); }
        if (t.nitrousActive || String(t.antilagStatus || '').toLowerCase() === 'active' ||
            String(t.overrunStatus || '').toLowerCase().indexOf('popping') >= 0) {
          extendMixRecovery(now, 750, 'power-adder/overrun');
        }
        if (misfire && misfire !== 'ok') { extendMixRecovery(now, 1000, 'misfire'); }

        if (!mixPullSessionActive && tps >= MIX_PULL_MIN_TPS && speed >= 8) {
          mixPullPassId++;
          mixPullSessionActive = true;
        } else if (mixPullSessionActive && (tps <= MIX_PULL_EXIT_TPS || speed < 3)) {
          mixPullSessionActive = false;
        }
      }
      function mixMotionReason(motion, mode, frame) {
        if (!motion || !motion.ready) { return 'building rolling motion window'; }
        var sec = Math.max(0.001, motion.durationMs / 1000).toFixed(2);
        if (mode === 'pull') {
          if (frame.tps < MIX_PULL_MIN_TPS) { return 'TPS ' + frame.tps.toFixed(1) + '% below pull minimum ' + MIX_PULL_MIN_TPS + '%'; }
          if (motion.tpsSpan > MIX_PULL_MAX_TPS_SPAN) { return 'TPS span ' + motion.tpsSpan.toFixed(1) + '%/' + sec + 's exceeds pull limit ' + MIX_PULL_MAX_TPS_SPAN + '%'; }
          if (Math.abs(motion.tpsRate) > MIX_PULL_MAX_TPS_RATE) { return 'TPS rate ' + motion.tpsRate.toFixed(1) + '%/s exceeds pull limit ' + MIX_PULL_MAX_TPS_RATE; }
          if (motion.rpmRate < MIX_PULL_MIN_RPM_RATE) { return 'RPM sweep ' + motion.rpmRate.toFixed(0) + ' rpm/s is not accelerating'; }
          if (motion.rpmRate > MIX_PULL_MAX_RPM_RATE) { return 'RPM rate ' + motion.rpmRate.toFixed(0) + ' rpm/s exceeds pull limit ' + MIX_PULL_MAX_RPM_RATE; }
          if (motion.maxRpmDrop > MIX_PULL_MAX_RPM_DROP) { return 'RPM dropped ' + motion.maxRpmDrop.toFixed(0) + ' rpm during pull'; }
          if (motion.mapRate < MIX_PULL_MIN_MAP_RATE || motion.mapRate > MIX_PULL_MAX_MAP_RATE) { return 'MAP rate ' + motion.mapRate.toFixed(1) + ' kPa/s outside pull limits'; }
          if (frame.gearIndex !== null && frame.gearIndex <= 0) { return 'forward gear required for pull learning'; }
          if (!motion.forwardGearWindow) { return 'forward gear was not stable through motion window'; }
          if (!motion.sameGear) { return 'gear changed inside motion window'; }
          if (frame.clutchInput > 0.15) { return 'clutch/shift active'; }
          if (frame.speedKmh < 8) { return 'vehicle speed below pull minimum'; }
          return 'pull conditions not settled';
        }
        // V3.0.7: quote the LIVE gates, not the factory constants. result.steady is judged against
        // mixStableSpan(), so on a 16x16 grid the real limits are 64 rpm and 2.0 kPa - the explainer
        // would otherwise pass every branch while the sample was rejected, and fall through to a
        // useless "not settled". This is the exact tool a player opens when the map will not fill.
        var rpmGate = mixStableSpan('rpm'), mapGate = mixStableSpan('map');
        if (motion.rpmSpan > rpmGate) { return 'RPM span ' + motion.rpmSpan.toFixed(0) + ' rpm/' + sec + 's exceeds steady limit ' + rpmGate.toFixed(0); }
        if (motion.mapSpan > mapGate) { return 'MAP span ' + motion.mapSpan.toFixed(1) + ' kPa/' + sec + 's exceeds steady limit ' + mapGate.toFixed(1); }
        if (motion.tpsSpan > MIX_STABLE_TPS_SPAN) { return 'TPS span ' + motion.tpsSpan.toFixed(1) + '%/' + sec + 's exceeds steady limit ' + MIX_STABLE_TPS_SPAN; }
        return 'steady conditions not settled';
      }
      function captureMixFrame(t, now, motion) {
        var sd = Number((scope.settings || {}).fuelEquationMode) === 2 && scope.maps && scope.maps.ve;
        var mapName = sd ? 've' : 'fuel';
        var rpm = finiteMix(t.rpm, 0), mapKpa = finiteMix(t.mapKpa, 0);
        var loadValue = scope.activeLoadValue(mapName, t);
        return {
          at: now, generation: mixGeneration, mapName: mapName,
          contributors: mixContributors(rpm, loadValue, mapName), motion: motion,
          insideAxes: mixInsideAxes(rpm, loadValue, mapName),
          requestedMode: scope.mixLearnMode || 'auto', acceptedMode: null,
          controllerStable: t.fuelLearningStable !== false, controlling: t.controlling === true,
          rpm: rpm, loadValue: loadValue, mapKpa: mapKpa, tps: finiteMix(t.tps, 0), ectC: finiteMix(t.ectC, -99),
          tpsDelta: finiteMix(t.tpsDelta, 0), speedKmh: finiteMix(t.speedKmh, 0),
          gearIndex: isFinite(Number(t.gearIndex)) ? Math.round(Number(t.gearIndex)) : null, clutchInput: finiteMix(t.clutchInput, 0),
          activeRevLimit: finiteMix(t.activeRevLimit, 0), transientFuelTrim: finiteMix(t.transientFuelTrim, 0),
          recoveryActive: now < mixRecoveryUntil, recoveryReason: now < mixRecoveryUntil ? mixRecoveryReason : '',
          recoveryRemainingMs: Math.max(0, mixRecoveryUntil - now), passId: mixPullPassId,
          lambdaTarget: finiteMix(t.lambdaTarget, NaN),
          lambdaSensorReady: t.lambdaSensorReady !== false, lambdaSensorFault: t.lambdaSensorFault === true,
          closedLoopFuelFactor: finiteMix(t.closedLoopFuelFactor, NaN),
          stft: finiteMix(t.stft, 0), ltft: finiteMix(t.ltft, 0), closedLoopStatus: String(t.closedLoopStatus || ''),
          injectorDuty: finiteMix(t.injectorDuty, 999), fuelDiffPressureKpa: finiteMix(t.fuelDiffPressureKpa, 0),
          misfireStatus: String(t.misfireStatus || ''), limiterStatus: String(t.limiterStatus || ''),
          limiterActive: t.limiterActive === true,
          launchActive: !!t.launchActive, flatShiftActive: !!t.flatShiftActive, rollingActive: !!t.rollingActive,
          nitrousActive: !!t.nitrousActive, startupFuelActive: !!t.startupFuelActive,
          idleActive: !!t.idleActive,
          accelEnrichActive: !!t.accelEnrichActive, decelFuelCutActive: !!t.decelFuelCutActive,
          zeroDemandActive: !!t.zeroDemandActive,
          transientFuelActive: !!t.transientFuelActive, antilagStatus: String(t.antilagStatus || ''),
          overrunStatus: String(t.overrunStatus || '')
        };
      }
      function mixQualityReason(frame) {
        if (!frame || frame.generation !== mixGeneration) { return 'stale generation'; }
        if (!frame.controlling) { return 'ECU not controlling'; }
        if (!frame.lambdaSensorReady) { return 'lambda sensor not ready'; }
        if (frame.lambdaSensorFault) { return 'lambda sensor fault'; }
        if (frame.ectC < MIX_MIN_ECT_C) { return 'engine cold'; }
        if (frame.idleActive) { return 'idle control active'; }
        if (frame.decelFuelCutActive) { return 'decel fuel cut active'; }
        if (frame.zeroDemandActive) { return 'zero demand/coast'; }
        if (frame.motion && frame.motion.ready && frame.tps <= MIX_LOW_TPS_DECEL_MAX_TPS &&
            frame.motion.rpmRate <= MIX_LOW_TPS_DECEL_RPM_RATE && frame.motion.speedRate <= MIX_LOW_TPS_DECEL_SPEED_RATE) {
          return 'low-throttle decel/coast (RPM ' + frame.motion.rpmRate.toFixed(0) + ' rpm/s, speed ' + frame.motion.speedRate.toFixed(1) + ' km/h/s)';
        }
        if (frame.startupFuelActive) { return 'startup fuel active'; }
        if (frame.transientFuelActive && Math.abs(frame.transientFuelTrim) > 0.25) { return 'transient fuel active'; }
        if (String(frame.misfireStatus).toLowerCase() !== 'ok') { return 'misfire'; }
        if (frame.limiterActive) { return 'limiter active'; }
        if (frame.launchActive || frame.flatShiftActive || frame.rollingActive || frame.nitrousActive) { return 'power-adder/limiter transient'; }
        if (String(frame.antilagStatus).toLowerCase() === 'active') { return 'anti-lag active'; }
        if (String(frame.overrunStatus).toLowerCase().indexOf('popping') >= 0) { return 'overrun transient'; }
        if (frame.recoveryActive) {
          frame.waitOnly = true;
          return 'settling after ' + frame.recoveryReason + ' (' + (frame.recoveryRemainingMs / 1000).toFixed(1) + 's)';
        }
        if (frame.activeRevLimit > 0 && frame.rpm >= frame.activeRevLimit - 250) { return 'near rev limiter'; }
        if (frame.injectorDuty >= MIX_MAX_INJECTOR_DUTY) { return 'injector duty above learning limit'; }
        if (frame.fuelDiffPressureKpa < MIX_MIN_DIFF_PRESSURE_KPA) { return 'fuel pressure low'; }
        if (!isFinite(frame.lambdaTarget) || frame.lambdaTarget < 0.65 || frame.lambdaTarget > 1.15) { return 'invalid target lambda'; }
        if (!frame.insideAxes) { return 'outside table axes - extend or auto-fit RPM/MAP axes'; }
        if (!frame.contributors.length) { return 'no table baseline'; }
        var requested = frame.requestedMode || 'auto', motion = frame.motion || {};
        if (requested === 'steady') {
          if (!motion.steady) {
            if (!motion.ready) { frame.waitOnly = true; }
            return mixMotionReason(motion, 'steady', frame);
          }
          frame.acceptedMode = 'steady';
        } else if (requested === 'pull') {
          if (!motion.pull) {
            if (!motion.ready || frame.tps < MIX_PULL_MIN_TPS) { frame.waitOnly = true; }
            return mixMotionReason(motion, 'pull', frame);
          }
          frame.acceptedMode = 'pull';
        } else if (motion.pull && frame.tps >= MIX_PULL_MIN_TPS) {
          frame.acceptedMode = 'pull';
        } else if (motion.steady) {
          frame.acceptedMode = 'steady';
        } else {
          if (!motion.ready) { frame.waitOnly = true; }
          return mixMotionReason(motion, frame.tps >= MIX_PULL_EXIT_TPS ? 'pull' : 'steady', frame);
        }
        return '';
      }
      function delayedMixFrame(now) {
        var cutoff = now - clampMix(finiteMix(scope.mixO2DelayMs, 0), 0, 1000);
        for (var i = mixTransportBuffer.length - 1; i >= 0; i--) { if (mixTransportBuffer[i].at <= cutoff) { return mixTransportBuffer[i]; } }
        return null;
      }
      function addMixEvidence(frame, factor, now) {
        if (!scope.mixBins || scope.mixBins.length !== scope.activeLoadAxis('fuel').length || !scope.mixBins[0] || scope.mixBins[0].length !== scope.rpmAxis.length) {
          scope.mixBins = newMixBinsV2();
        }
        for (var i = 0; i < frame.contributors.length; i++) {
          var p = frame.contributors[i], bin = scope.mixBins[p.r] && scope.mixBins[p.r][p.c];
          if (!bin) { continue; }
          if (bin.mapName && bin.mapName !== frame.mapName) { bin.evidence = []; }
          bin.mapName = frame.mapName;
          var mode = frame.acceptedMode || 'steady';
          var sampleWeight = mode === 'pull' && frame.motion ? frame.motion.pullWeight : 1;
          bin.evidence.push({
            at: now, factor: factor, baseline: p.baseline, weight: p.w * sampleWeight,
            mode: mode, passId: mode === 'pull' ? frame.passId : 0
          });
          while (bin.evidence.length > MIX_MAX_EVIDENCE) { bin.evidence.shift(); }
        }
      }
      function accumulateMixture(t) {
        // Mixture learning is its own tuner tool; turning the time-history logger
        // off must not silently disable it. Its own validity gates decide whether
        // a telemetry sample is usable.
        if (!scope.activeLoadAxis('fuel').length || !scope.rpmAxis.length || !scope.maps) { mixTransportBuffer = []; mixMotionWindow = []; return; }
        var now = Date.now();
        if (now < mixResumeAfter) { mixTransportBuffer = []; mixMotionWindow = []; return; }
        updateMixLearningState(t, now);
        var motion = mixMotionMetrics(t, now);
        mixTransportBuffer.push(captureMixFrame(t, now, motion));
        var keepMs = clampMix(finiteMix(scope.mixO2DelayMs, 0), 0, 1000) + MIX_STABLE_WINDOW_MS + 500;
        while (mixTransportBuffer.length && mixTransportBuffer[0].at < now - keepMs) { mixTransportBuffer.shift(); }
        if (now - mixLastEvidenceAt < MIX_SAMPLE_INTERVAL_MS) { return; }
        mixLastEvidenceAt = now;
        var frame = delayedMixFrame(now), reason = mixQualityReason(frame);
        if (reason) {
          if (frame && frame.waitOnly) { waitMixture(reason); }
          else { skipMixture(reason); }
          return;
        }

        // Prefer the controller's before-closed-loop sensor lambda.  Older
        // controllers fall back to measured lambda times the active CL factor.
        var measuredBase = finiteMix(t.lambdaBase, finiteMix(t.lambdaUncorrected, NaN));
        if (!isFinite(measuredBase)) {
          var measured = finiteMix(t.lambdaActual, NaN);
          var clFactor = frame.closedLoopFuelFactor;
          if (!isFinite(clFactor)) {
            var clActive = String(frame.closedLoopStatus).toLowerCase() === 'active';
            clFactor = clActive
              ? (1 + clampMix(frame.ltft, -25, 25) / 100) * (1 + clampMix(frame.stft, -25, 25) / 100)
              : 1 + clampMix(frame.ltft * 0.25, -25, 25) / 100;
          }
          measuredBase = measured * clFactor;
        }
        if (!isFinite(measuredBase) || measuredBase < 0.5 || measuredBase > 1.5) { rejectMixture('invalid measured lambda'); return; }
        var correction = measuredBase / frame.lambdaTarget - 1;
        if (!isFinite(correction) || Math.abs(correction) > MIX_OUTLIER_CORRECTION) { rejectMixture('lambda correction outlier'); return; }
        addMixEvidence(frame, 1 + clampMix(correction, -MIX_MAX_CORRECTION, MIX_MAX_CORRECTION), now);
        scope.mixLearnStats.accepted++;
        if (frame.acceptedMode === 'pull') { scope.mixLearnStats.acceptedPull++; }
        else { scope.mixLearnStats.acceptedSteady++; }
        scope.mixLearnStats.phase = 'learning';
        scope.mixLearnStats.currentState = frame.acceptedMode === 'pull'
          ? 'Learning: Pull - pass ' + Math.max(1, frame.passId)
          : 'Learning: Steady';
        scope.mixLearnStats.reason = '';
      }
      function mixCellDataV2(r, c) {
        if (!scope.mixBins || !scope.mixBins[r] || !scope.mixBins[r][c]) { return null; }
        var bin = scope.mixBins[r][c], now = Date.now();
        while (bin.evidence.length && bin.evidence[0].at < now - MIX_EVIDENCE_TTL_MS) { bin.evidence.shift(); }
        if (!bin.evidence.length) { return null; }
        var sumW = 0, sumFactor = 0, sumBase = 0, newest = 0, steadyHits = 0, pullHits = 0;
        var steadyWeight = 0, steadyFactor = 0, steadyBase = 0;
        var pullWeight = 0, pullPassStats = {}, pullPassesSeen = 0;
        for (var i = 0; i < bin.evidence.length; i++) {
          var e = bin.evidence[i];
          sumW += e.weight; sumFactor += e.factor * e.weight; sumBase += e.baseline * e.weight; newest = Math.max(newest, e.at);
          if (e.mode === 'pull') {
            pullHits++;
            pullWeight += e.weight;
            if (e.passId) {
              if (!pullPassStats[e.passId]) { pullPassStats[e.passId] = { sum: 0, base: 0, weight: 0, hits: 0 }; pullPassesSeen++; }
              pullPassStats[e.passId].sum += e.factor * e.weight;
              pullPassStats[e.passId].base += e.baseline * e.weight;
              pullPassStats[e.passId].weight += e.weight;
              pullPassStats[e.passId].hits++;
            }
          } else {
            steadyHits++; steadyWeight += e.weight;
            steadyFactor += e.factor * e.weight; steadyBase += e.baseline * e.weight;
          }
        }
        if (sumW <= 0) { return null; }
        var factor = sumFactor / sumW, baseline = sumBase / sumW, hits = bin.evidence.length;
        var passFactorMin = Infinity, passFactorMax = -Infinity, pullPasses = 0;
        var qualifiedPullHits = 0, qualifiedPullWeight = 0, qualifiedPullFactor = 0, qualifiedPullBase = 0;
        for (var passKey in pullPassStats) {
          if (!Object.prototype.hasOwnProperty.call(pullPassStats, passKey)) { continue; }
          var ps = pullPassStats[passKey];
          if (ps.hits < MIX_MIN_PULL_PASS_HITS || ps.weight < MIX_MIN_PULL_PASS_WEIGHT) { continue; }
          pullPasses++;
          qualifiedPullHits += ps.hits; qualifiedPullWeight += ps.weight;
          qualifiedPullFactor += ps.sum; qualifiedPullBase += ps.base;
          var passFactor = ps.sum / ps.weight;
          passFactorMin = Math.min(passFactorMin, passFactor);
          passFactorMax = Math.max(passFactorMax, passFactor);
        }
        var pullSpread = pullPasses >= 2 ? passFactorMax - passFactorMin : null;
        var readySteady = steadyHits >= MIX_MIN_HITS && steadyWeight >= MIX_MIN_WEIGHT;
        var readyPull = pullPasses >= 2 && qualifiedPullHits >= MIX_MIN_HITS && qualifiedPullWeight >= MIX_MIN_WEIGHT && pullSpread <= 0.08;
        var ready = readySteady || readyPull;
        // A provisional evidence type must not alter a correction made ready by
        // the other type. Combine modes only after each one meets its own rules.
        if (readySteady && !readyPull) {
          factor = steadyFactor / steadyWeight; baseline = steadyBase / steadyWeight;
        } else if (readyPull && !readySteady) {
          factor = qualifiedPullFactor / qualifiedPullWeight; baseline = qualifiedPullBase / qualifiedPullWeight;
        } else if (readySteady && readyPull) {
          factor = (steadyFactor + qualifiedPullFactor) / (steadyWeight + qualifiedPullWeight);
          baseline = (steadyBase + qualifiedPullBase) / (steadyWeight + qualifiedPullWeight);
        }
        var steadyConfidence = clampMix(Math.min(steadyHits / MIX_FULL_CONFIDENCE_HITS, steadyWeight / MIX_FULL_CONFIDENCE_WEIGHT), 0, 1);
        var pullAgreement = pullPasses < 2 ? 0.35 : clampMix(1 - Math.max(0, pullSpread - 0.03) / 0.12, 0, 1);
        var pullConfidence = clampMix(Math.min(pullPasses / 3, qualifiedPullHits / MIX_FULL_CONFIDENCE_HITS,
          qualifiedPullWeight / MIX_FULL_CONFIDENCE_WEIGHT, pullAgreement), 0, 1);
        var confidence = Math.max(steadyConfidence, pullConfidence);
        var isVE = bin.mapName === 've';
        return {
          pct: (factor - 1) * 100, factor: factor, n: hits, hits: hits, weight: sumW,
          confidence: confidence, ready: ready, baseline: baseline, mapName: bin.mapName, ageMs: now - newest,
          steadyHits: steadyHits, steadyWeight: steadyWeight, pullHits: pullHits, pullWeight: pullWeight,
          qualifiedPullHits: qualifiedPullHits, qualifiedPullWeight: qualifiedPullWeight,
          pullPasses: pullPasses, pullPassesSeen: pullPassesSeen, pullSpread: pullSpread,
          readySteady: readySteady, readyPull: readyPull,
          suggestedVE: isVE ? baseline * factor : null, veDelta: isVE ? baseline * (factor - 1) : null
        };
      }
      scope.mixCellData = function (r, c) { return mixCellDataV2(r, c); };
      scope.mixCell = function (r, c) { var m = mixCellDataV2(r, c); return m && m.ready ? m : null; };
      scope.mixCellTitle = function (r, c) {
        var m = mixCellDataV2(r, c);
        if (!m) { return 'No accepted steady or pull samples'; }
        var status = m.ready ? 'ready' : 'learning';
        var text = status + ': ' + m.hits + ' hits, weighted ' + m.weight.toFixed(1) + ', confidence ' + Math.round(m.confidence * 100) + '%';
        if (m.steadyHits || m.pullHits) { text += ', steady ' + m.steadyHits + ', pull ' + m.pullHits + (m.pullPassesSeen ? ' across ' + m.pullPasses + ' confirmed/' + m.pullPassesSeen + ' seen pass(es)' : ''); }
        if (!m.readySteady && m.pullHits && m.pullPasses < 2) { text += ', repeat the pull for confirmation'; }
        if (m.pullSpread !== null && m.pullSpread > 0.08) { text += ', pull estimates disagree by ' + (m.pullSpread * 100).toFixed(1) + '%'; }
        text += ', correction ' + (m.pct >= 0 ? '+' : '') + m.pct.toFixed(1) + '%';
        if (m.suggestedVE !== null) { text += ', VE ' + m.baseline.toFixed(1) + ' -> ' + m.suggestedVE.toFixed(1); }
        return text;
      };
      function refreshMixUi(t, force) {
        var now = Date.now();
        if (!force && (scope.activePage !== 'mixture_map' || now < mixUiNextAt)) { return; }
        if (!scope.mixSummary || !scope.mixUi) { return; }
        mixUiNextAt = now + MIX_UI_INTERVAL_MS;
        t = t || scope.t || {};
        var s = scope.mixSummary(), rpm = Number(t.rpm), mapKpa = Number(t.mapKpa), tps = Number(t.tps);
        var alphaLoad = scope.mapUsesAlphaN('fuel');
        var loadAxis = scope.activeLoadAxis('fuel');
        var loadValue = alphaLoad ? tps : mapKpa;
        var target = Number(t.lambdaTarget), actual = Number(t.lambdaActual), baro = Number(t.baroKpa);
        if (!isFinite(baro) || baro <= 0) { baro = 101.325; }
        var gaugePsi = Number(t.mapGaugePsi);
        if (!isFinite(gaugePsi) && isFinite(mapKpa)) { gaugePsi = (mapKpa - baro) * KPA_TO_PSI; }
        var vacuumInHg = Number(t.vacuumInHg);
        if (!isFinite(vacuumInHg) && isFinite(mapKpa)) { vacuumInHg = Math.max(0, baro - mapKpa) * 0.295299875; }
        var axisProblems = [];
        if (isFinite(rpm) && scope.rpmAxis && scope.rpmAxis.length) {
          if (rpm < Number(scope.rpmAxis[0])) { axisProblems.push('RPM below ' + scope.rpmAxis[0]); }
          else if (rpm > Number(scope.rpmAxis[scope.rpmAxis.length - 1])) { axisProblems.push('RPM above ' + scope.rpmAxis[scope.rpmAxis.length - 1]); }
        }
        if (isFinite(loadValue) && loadAxis.length) {
          if (loadValue < Number(loadAxis[0])) { axisProblems.push((alphaLoad ? 'TPS below ' : 'MAP below ') + loadAxis[0] + (alphaLoad ? '%' : ' kPa')); }
          else if (loadValue > Number(loadAxis[loadAxis.length - 1])) { axisProblems.push((alphaLoad ? 'TPS above ' : 'MAP above ') + loadAxis[loadAxis.length - 1] + (alphaLoad ? '%' : ' kPa')); }
        }
        var vacuumBoost = '—';
        if (isFinite(gaugePsi)) {
          if (gaugePsi < 0) {
            vacuumBoost = scope.psi ? ((isFinite(vacuumInHg) ? vacuumInHg : 0).toFixed(1) + ' inHg vacuum') : (Math.max(0, baro - mapKpa).toFixed(1) + ' kPa vacuum');
          } else {
            vacuumBoost = scope.psi ? ('+' + gaugePsi.toFixed(1) + ' psi boost') : ('+' + Math.max(0, mapKpa - baro).toFixed(1) + ' kPa boost');
          }
        }
        var axisReady = isFinite(rpm) && isFinite(loadValue) && scope.rpmAxis && scope.rpmAxis.length && loadAxis.length;
        angular.extend(scope.mixUi, {
          phase: String(scope.mixLearnStats.phase || 'waiting').toUpperCase(), state: s.currentState || 'Waiting for valid data',
          readyCells: s.readyCells, learningCells: s.learningCells, accepted: s.accepted,
          acceptedSteady: s.acceptedSteady, acceptedPull: s.acceptedPull, skipped: s.skipped, rejected: s.rejected,
          lastSkip: s.lastSkip || '—', lastReject: s.lastReject || '—', resetReason: s.invalidatedBy || '—',
          rpm: isFinite(rpm) ? Math.round(rpm) + ' rpm' : '—',
          map: isFinite(loadValue) ? (alphaLoad ? loadValue.toFixed(1) + '% TPS' : (scope.psi ? gaugePsi.toFixed(1) + ' psi gauge' : mapKpa.toFixed(1) + ' kPa abs')) : '—',
          loadLabel: alphaLoad ? 'ECU LOAD (TPS)' : 'ECU LOAD (MAP)',
          baro: scope.psi ? (baro * KPA_TO_PSI).toFixed(1) + ' psi abs' : baro.toFixed(1) + ' kPa abs',
          vacuumBoost: vacuumBoost, tps: isFinite(tps) ? tps.toFixed(1) + '%' : '—',
          lambda: isFinite(actual) && isFinite(target) ? actual.toFixed(3) + ' / ' + target.toFixed(3) : '—',
          axisStatus: !axisReady ? ('Waiting for RPM / ' + (alphaLoad ? 'TPS' : 'MAP') + ' telemetry') : (axisProblems.length ? ('Outside table: ' + axisProblems.join(', ')) : 'Inside table axes'),
          axisInside: !!axisReady && axisProblems.length === 0,
          mapSource: alphaLoad ? 'Alpha-N throttle position' : (t.mapSource === 'modeled_load_boost' ? 'Modeled MAP (load + boost)' : 'MAP source unavailable')
        });
      }
      scope.mixSummary = function () {
        var ready = 0, learning = 0, newestConfidence = 0;
        if (scope.mixBins) {
          for (var r = 0; r < scope.mixBins.length; r++) {
            for (var c = 0; c < scope.mixBins[r].length; c++) {
              var m = mixCellDataV2(r, c);
              if (!m) { continue; }
              if (m.ready) { ready++; } else { learning++; }
              newestConfidence = Math.max(newestConfidence, m.confidence);
            }
          }
        }
        return {
          readyCells: ready, learningCells: learning, confidence: newestConfidence,
          accepted: scope.mixLearnStats.accepted, acceptedSteady: scope.mixLearnStats.acceptedSteady,
          acceptedPull: scope.mixLearnStats.acceptedPull, skipped: scope.mixLearnStats.skipped, rejected: scope.mixLearnStats.rejected,
          currentState: scope.mixLearnStats.currentState,
          lastSkip: scope.mixLearnStats.lastSkip, lastReject: scope.mixLearnStats.lastReject,
          invalidatedBy: scope.mixLearnStats.lastResetReason || scope.mixLearnStats.reason || ''
        };
      };
      scope.mixSummaryText = function () {
        var s = scope.mixSummary();
        var text = (s.currentState || 'Waiting for valid data') + ' | Ready ' + s.readyCells + ' | learning ' + s.learningCells;
        text += ' | accepted ' + s.accepted + ' (steady ' + s.acceptedSteady + ', pull ' + s.acceptedPull + ') | skipped ' + s.skipped + ' | rejected ' + s.rejected;
        if (s.invalidatedBy) { text += ' | reset: ' + s.invalidatedBy; }
        return text;
      };
      scope.setMixDisplayMode = function (mode) { if (mode === 'correction' || mode === 'suggestedVE' || mode === 'veDelta') { scope.mixDisplayMode = mode; } };
      // V2.9.7 beta feedback (D3): readable labels for the always-visible "Learn: X | View: Y"
      // status line on the Mixture Map page (shared template - game UI and Pro Studio mirror).
      var mixModeLabels = {
        learn: { auto: 'Auto', steady: 'Steady', pull: 'WOT Pull' },
        view: { correction: 'Correction %', veDelta: 'VE delta', suggestedVE: 'Suggested VE' }
      };
      scope.mixLearnModeLabel = function () { return mixModeLabels.learn[scope.mixLearnMode] || 'Auto'; };
      scope.mixDisplayModeLabel = function () { return mixModeLabels.view[scope.mixDisplayMode] || 'Correction %'; };
      scope.mixApplyTarget = function () { return Number((scope.settings || {}).fuelEquationMode) === 2 ? 'VE Table' : 'Fuel Main'; };
      scope.setMixLearnMode = function (mode) {
        if (mode !== 'auto' && mode !== 'steady' && mode !== 'pull') { return; }
        scope.mixLearnMode = mode;
        mixMotionWindow = [];
        mixTransportBuffer = [];
        mixResumeAfter = Date.now() + 350;
        scope.mixLearnStats.currentState = 'Waiting: building motion window for ' + (mode === 'pull' ? 'WOT Pull' : mode.charAt(0).toUpperCase() + mode.slice(1));
        scope.mixLearnStats.phase = 'waiting';
        refreshMixUi(scope.t, true);
      };
      scope.setMixO2Delay = function (ms) {
        scope.mixO2DelayMs = 0;
        scope.mixO2DelayDraftMs = 0;
        setToast('Internal BeamNG lambda is instantaneous; O2 delay remains 0 ms');
      };
      scope.clearMixture = function () { invalidateMixture('cleared by user'); scope.mixBins = newMixBinsV2(); setToast('Mixture map cleared'); };
      scope.mixCellText = function (r, c) {
        var m = mixCellDataV2(r, c); if (!m) { return '·'; }
        if (!m.ready) { return '...'; }
        if (scope.mixDisplayMode === 'suggestedVE') { return m.suggestedVE === null ? '--' : m.suggestedVE.toFixed(1); }
        if (scope.mixDisplayMode === 'veDelta') { return m.veDelta === null ? '--' : (m.veDelta >= 0 ? '+' : '') + m.veDelta.toFixed(1); }
        return (m.pct >= 0 ? '+' : '') + m.pct.toFixed(1);
      };
      scope.mixCellStyle = function (r, c) {
        var m = mixCellDataV2(r, c); if (!m) { return {}; }
        if (!m.ready) {
          return { 'background-color': 'rgba(65,125,175,' + (0.08 + m.confidence * 0.18) + ')', color: '#6f8291' };
        }
        var p = Math.max(-15, Math.min(15, m.pct)), hue = p >= 0 ? 0 : 210;
        var alpha = (0.18 + Math.abs(p) / 15 * 0.55) * (0.45 + m.confidence * 0.55);
        return { 'background-color': 'hsla(' + hue + ',70%,45%,' + alpha + ')' };
      };
      scope.applyMixture = function (blend) {
        if (IS_MIRROR && (!scope.remoteClient || !scope.remoteClient.authenticated)) { setToast(scope.remoteClient && scope.remoteClient.writerConnected ? 'Read only: another browser owns tuning control' : 'Read only: waiting for browser tuning control'); return; }
        if (IS_MIRROR && scope.gamePaused) { setToast('Game paused: Mixture Apply not sent'); return; }
        if (!scope.mixBins || !scope.maps) { setToast('No accepted Mixture Map data yet - collect steady driving or a clean WOT pull first'); return; }
        var sd = Number(scope.settings.fuelEquationMode) === 2 && scope.maps.ve;
        var name = sd ? 've' : 'fuel', tbl = scope.maps[name], mt = scope.mapMeta[name];
        var applied = 0, stale = 0, readyMatched = 0;
        blend = clampMix(finiteMix(blend, 0), 0, 1);
        for (var r = 0; r < tbl.length; r++) {
          var changed = false, row = tbl[r].slice(), rowApplied = 0;
          for (var c = 0; c < row.length; c++) {
            var m = scope.mixCell(r, c);
            if (!m || m.mapName !== name) { continue; }
            readyMatched++;
            var tolerance = name === 've' ? 0.02 : 0.0005;
            if (Math.abs(Number(row[c]) - m.baseline) > tolerance) { stale++; continue; }
            var next = Math.round(Math.max(mt.min, Math.min(mt.max, Number(row[c]) * (1 + (m.factor - 1) * blend))) * 100) / 100;
            if (Math.abs(next - Number(row[c])) > 0.00001) { row[c] = next; changed = true; rowApplied++; }
          }
          // Count and paint only rows the transport accepted: a rejected send on the mirror must fall
          // into the no-change path below, which preserves the accumulated learning evidence.
          if (changed && sendMapRow(name, r, row) !== false) { tbl[r] = row; applied += rowApplied; }
        }
        if (!applied && !stale) {
          setToast(readyMatched
            ? 'Ready corrections round to the current table values - no change was needed'
            : 'No ready cells yet - hold a steady point or repeat a clean WOT pull to build confidence');
          return; // preserve partial evidence; an early Apply click must not erase learning progress
        }
        invalidateMixture(applied ? 'corrections applied' : 'baseline changed');
        if (applied) { saveAutosave(); }
        if (stale && !applied) { setToast('Mixture data was stale after a table change - re-drive those cells'); return; }
        setToast('Applied ' + applied + ' ' + (sd ? 'VE' : 'fuel') + ' cells (' + Math.round(blend * 100) + '% blend)' + (stale ? '; skipped ' + stale + ' stale cells' : '') + ' - re-drive to refine');
      };

      // ====================================================================
      // tune files (full calibration in localStorage)
      // ====================================================================
      scope.tuneName = 'My tune';
      scope.slots = [];
      var temporaryLessonSession = null;
      var pendingRecoveredLessonStoreRequired = null;
      function temporaryLessonMapSaveLocked() { return !!temporaryLessonSession; }
      scope.mapBankSaveLocked = function () { return temporaryLessonMapSaveLocked(); };
      function rejectTemporaryLessonMapMutation() {
        if (!temporaryLessonMapSaveLocked()) { return false; }
        setToast('Finish or exit the temporary lesson before managing M1-M4');
        return true;
      }
      var MAP_BANK_MAP_NAMES = ['fuel', 'ignition', 'boost', 'lambda', 've', 'wgduty'];
      // KEEP IN SYNC with oeProTune.lua. Hardware, sensor, safety, CAN, axes and
      // cosmetic-only settings deliberately remain shared between all four banks.
      var MAP_BANK_SETTING_KEYS = [
        'revLimit',
        'launchEnabled', 'launchLimit', 'flatShiftEnabled', 'flatShiftRpm', 'launchSoundVol', 'launchFuel', 'launchRetard', 'rollingEnabled', 'hardCut',
        'limiterCutMode', 'limiterRange', 'limiterCutMs',
        'globalFuelTrim', 'globalIgnitionTrim',
        'boostMode', 'boostBaseDuty', 'boostPGain', 'boostIGain', 'boostOvercut',
        'boostShapeMode', 'boostRampUpKpaS', 'boostRampDownKpaS', 'boostShiftHoldMs',
        'boostGear1TargetTrimKpa', 'boostGear1DutyTrimPct', 'boostGear2TargetTrimKpa', 'boostGear2DutyTrimPct',
        'boostGear3TargetTrimKpa', 'boostGear3DutyTrimPct', 'boostGear4TargetTrimKpa', 'boostGear4DutyTrimPct',
        'boostGear5TargetTrimKpa', 'boostGear5DutyTrimPct', 'boostGear6TargetTrimKpa', 'boostGear6DutyTrimPct',
        'boostGear7TargetTrimKpa', 'boostGear7DutyTrimPct', 'boostGear8TargetTrimKpa', 'boostGear8DutyTrimPct',
        'boostSpeed1TargetTrimKpa', 'boostSpeed1DutyTrimPct', 'boostSpeed2TargetTrimKpa', 'boostSpeed2DutyTrimPct',
        'boostSpeed3TargetTrimKpa', 'boostSpeed3DutyTrimPct', 'boostSpeed4TargetTrimKpa', 'boostSpeed4DutyTrimPct',
        'boostSpeed5TargetTrimKpa', 'boostSpeed5DutyTrimPct', 'boostSpeed6TargetTrimKpa', 'boostSpeed6DutyTrimPct',
        'idleEnabled', 'idleTargetRpm', 'idleBasePos', 'idleIgnControl',
        'antilagEnabled', 'antilagRetard', 'antilagFuel',
        'nitrousEnabled', 'nitrousShot', 'nitrousArmRpm', 'nitrousArmTps', 'nitrousFuel', 'nitrousRetard',
        'dieselMaxFuel', 'dieselRailPressure', 'dieselSmokeLimit', 'glowPlugs',
        'compBrakeEnabled', 'compBrakeLevel',
        'dieselNozzleEnabled', 'dieselNozzleFlow', 'dieselSmokeLimiter',
        'shiftFirmness', 'shiftTorqueMgmt', 'torqueLimitNm', 'torqueLimitLowGears', 'converterStall',
        'vvtEnabled', 'vvtIntakeAdvance',
        'ethrottleMode', 'ethrottleCurve', 'ethrottleTolerance',
        'decelFuelCut', 'decelFuel',
        'popsLevel', 'popSoundMode',
        'rotIdleEnabled', 'rotIdleRpm', 'rotIdleChop',
        'overrunThrottle', 'overrunMinRpm', 'overrunPopVol'
      ];
      // Per-page MAP scope badge ('switches with the MAP' vs 'shared by all four'), derived from
      // MAP_BANK_SETTING_KEYS itself so the badge can never drift from what actually switches.
      var MAP_BANK_KEY_SET = {};
      MAP_BANK_SETTING_KEYS.forEach(function (k) { MAP_BANK_KEY_SET[k] = true; });
      scope.pageMapScope = function () {
        var def = typeof scope.pageDef === 'function' ? scope.pageDef() : null;
        if (!def || !def.controls) { return ''; }
        var perMap = 0, shared = 0;
        def.controls.forEach(function (c) {
          if (!c || !c.key || c.type === 'readout' || c.type === 'note') { return; }
          if (c.key === 'revLimit' || MAP_BANK_KEY_SET[c.key]) { perMap += 1; } else { shared += 1; }
        });
        if (!perMap && !shared) { return ''; }
        if (perMap && !shared) { return 'per-map'; }
        if (shared && !perMap) { return 'shared'; }
        return 'mixed';
      };
      // Fills fields that did not exist in an old tune. Normally the complete live
      // controller settings are the fallback; these values make migration deterministic
      // even when a very old/offline snapshot is opened.
      var MAP_BANK_SETTING_DEFAULTS = {
        launchEnabled: false, launchLimit: 5500, flatShiftEnabled: false, flatShiftRpm: 6500,
        launchSoundVol: 5, launchFuel: 0, launchRetard: 0, rollingEnabled: false, hardCut: false,
        limiterCutMode: 1, limiterRange: 150, limiterCutMs: 50,   // MUST equal defaultSettings() + H.boostShapeSettingDefaults in oeProTune.lua, or a clean 3.0.x bank restore is refused as clean_bank_mismatch
        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,
        idleEnabled: true, idleTargetRpm: 950, idleBasePos: 35, idleIgnControl: true,
        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,
        dieselNozzleEnabled: false, dieselNozzleFlow: 810, dieselSmokeLimiter: false,
        shiftFirmness: 0, shiftTorqueMgmt: 0, torqueLimitNm: 0, torqueLimitLowGears: false, converterStall: 0,
        vvtEnabled: true, vvtIntakeAdvance: 25,
        ethrottleMode: 1, ethrottleCurve: 1, ethrottleTolerance: 12,
        decelFuelCut: true, decelFuel: -28,
        popsLevel: 1, popSoundMode: 1,
        rotIdleEnabled: false, rotIdleRpm: 1300, rotIdleChop: 6,
        overrunThrottle: 8, overrunMinRpm: 2800, overrunPopVol: 6
      };
      var MAP_BANK_SETTING_RANGES = {
        launchLimit: [2500, 9000], flatShiftRpm: [3000, 11000], launchSoundVol: [0, 10], launchFuel: [0, 30], launchRetard: [0, 15],
        limiterCutMode: [1, 2], limiterRange: [100, 600], limiterCutMs: [10, 150],   // byte-identical to oeProTuneShared.lua settingRanges - the controller CLAMPS silently where the LAN remote REJECTS, so a mismatch shows up only as a Pro Studio ecu_confirmation_timeout
        globalFuelTrim: [-10, 10], globalIgnitionTrim: [-6, 6],
        boostMode: [1, 3], boostBaseDuty: [0, 100], boostPGain: [0, 2], boostIGain: [0, 0.5], boostOvercut: [5, 60],
        boostShapeMode: [1, 4], boostRampUpKpaS: [0, 2000], boostRampDownKpaS: [0, 2000], boostShiftHoldMs: [0, 1000],
        // V3.0: target trims ±500 kPa (was ±200) - keep in sync with oeProTuneShared settingRanges
        boostGear1TargetTrimKpa: [-500, 500], boostGear1DutyTrimPct: [-100, 100], boostGear2TargetTrimKpa: [-500, 500], boostGear2DutyTrimPct: [-100, 100],
        boostGear3TargetTrimKpa: [-500, 500], boostGear3DutyTrimPct: [-100, 100], boostGear4TargetTrimKpa: [-500, 500], boostGear4DutyTrimPct: [-100, 100],
        boostGear5TargetTrimKpa: [-500, 500], boostGear5DutyTrimPct: [-100, 100], boostGear6TargetTrimKpa: [-500, 500], boostGear6DutyTrimPct: [-100, 100],
        boostGear7TargetTrimKpa: [-500, 500], boostGear7DutyTrimPct: [-100, 100], boostGear8TargetTrimKpa: [-500, 500], boostGear8DutyTrimPct: [-100, 100],
        boostSpeed1TargetTrimKpa: [-500, 500], boostSpeed1DutyTrimPct: [-100, 100], boostSpeed2TargetTrimKpa: [-500, 500], boostSpeed2DutyTrimPct: [-100, 100],
        boostSpeed3TargetTrimKpa: [-500, 500], boostSpeed3DutyTrimPct: [-100, 100], boostSpeed4TargetTrimKpa: [-500, 500], boostSpeed4DutyTrimPct: [-100, 100],
        boostSpeed5TargetTrimKpa: [-500, 500], boostSpeed5DutyTrimPct: [-100, 100], boostSpeed6TargetTrimKpa: [-500, 500], boostSpeed6DutyTrimPct: [-100, 100],
        idleTargetRpm: [600, 1800], idleBasePos: [5, 80],
        antilagRetard: [5, 35], antilagFuel: [0, 30],
        nitrousShot: [0, 60], nitrousArmRpm: [2000, 7500], nitrousArmTps: [50, 100], nitrousFuel: [0, 40], nitrousRetard: [0, 15],
        dieselMaxFuel: [60, 260], dieselRailPressure: [300, 2200], dieselSmokeLimit: [12, 24],
        compBrakeLevel: [0, 100], dieselNozzleFlow: [200, 6000],
        shiftFirmness: [0, 100], shiftTorqueMgmt: [0, 100], torqueLimitNm: [0, 6000], converterStall: [0, 8000],
        vvtIntakeAdvance: [0, 40], ethrottleMode: [1, 2], ethrottleCurve: [1, 3], ethrottleTolerance: [4, 30],
        decelFuel: [-35, 0], popsLevel: [0, 3], popSoundMode: [1, 3],
        rotIdleRpm: [900, 2000], rotIdleChop: [1, 10],
        overrunThrottle: [2, 20], overrunMinRpm: [1500, 7000], overrunPopVol: [0, 10]
      };
      var MAP_BANK_BOOLEAN_SETTINGS = {
        launchEnabled: true, flatShiftEnabled: true, rollingEnabled: true, hardCut: true,
        idleEnabled: true, idleIgnControl: true, antilagEnabled: true, nitrousEnabled: true,
        glowPlugs: true, compBrakeEnabled: true, dieselNozzleEnabled: true, dieselSmokeLimiter: true,
        torqueLimitLowGears: true, vvtEnabled: true, decelFuelCut: true, rotIdleEnabled: true
      };
      var MAP_BANK_ENUM_SETTINGS = { boostMode: true, boostShapeMode: true, ethrottleMode: true, ethrottleCurve: true, popSoundMode: true, rotIdleChop: true, limiterCutMode: true };   // keep in sync with oeProTuneShared.lua enumSettings (bank-relevant keys)
      function isBoostShapeMigrationKey(key) {
        return key === 'boostShapeMode' || key === 'boostRampUpKpaS' || key === 'boostRampDownKpaS' || key === 'boostShiftHoldMs' ||
          /^boost(?:Gear|Speed)\d+(?:TargetTrimKpa|DutyTrimPct)$/.test(key);
      }
      var SAFE_DEFAULT_MIGRATION_SETTINGS = {
        compBrakeEnabled: true, compBrakeLevel: true,
        dieselNozzleEnabled: true, dieselNozzleFlow: true, dieselSmokeLimiter: true,
        shiftFirmness: true, shiftTorqueMgmt: true, torqueLimitNm: true,
        torqueLimitLowGears: true, converterStall: true,
        // V3.1: without these two, a 3.0.x bank body (which has no such key) resolves them from the
        // LIVE car's settings via the fallback in performanceSettingsFrom - and mapBankControllerData
        // runs that on every Store ECU / autosave, so the currently-loaded cut mode would be written
        // permanently into somebody's saved M-slot. This makes the migration deterministic instead.
        limiterCutMode: true, limiterRange: true, limiterCutMs: true
      };
      var controllerMapBankSeen = false;
      var controllerMapBankActive = null;
      var controllerMapBankSequence = null;
      var controllerMapBankPending = null;
      var controllerMapBankPendingMode = 'none';
      var pendingMapBankMutation = null;
      var pendingMapBankMutationTimer = null;
      var mapBankMutationSerial = 0;
      var pendingWorkingTuneDecision = null;
      var pendingWorkingTuneContinuation = null;
      scope.workingTunePrompt = null;
      var ignoredMapBankMutationTokens = {};
      var ignoredMapBankMutationTokenOrder = [];

      function truncateUtf8Bytes(value, maxBytes) {
        var out = '', used = 0;
        for (var i = 0; i < value.length; i++) {
          var code = value.charCodeAt(i), chars = value.charAt(i), bytes;
          if (code >= 0xD800 && code <= 0xDBFF && i + 1 < value.length) {
            var low = value.charCodeAt(i + 1);
            if (low >= 0xDC00 && low <= 0xDFFF) { chars += value.charAt(++i); bytes = 4; }
          }
          if (bytes === undefined) { bytes = code <= 0x7F ? 1 : (code <= 0x7FF ? 2 : 3); }
          if (used + bytes > maxBytes) { break; }
          out += chars; used += bytes;
        }
        return out;
      }
      function cleanMapBankName(value, index) {
        var name = String(value === undefined || value === null ? '' : value).replace(/[\x00-\x1f]/g, ' ').replace(/\s+/g, ' ').trim();
        return truncateUtf8Bytes(name || ('MAP ' + index), 32);
      }
      function mapBankSlot(index) {
        index = Math.floor(Number(index));
        var slots = scope.mapBank && scope.mapBank.slots || [];
        for (var i = 0; i < slots.length; i++) { if (Number(slots[i].index) === index) { return slots[i]; } }
        return null;
      }
      function emptyMapBankSlot(index) {
        return { index: index, name: 'MAP ' + index, draftName: 'MAP ' + index, configured: false, peakBoostKpa: 0, boostTableFactory: undefined, data: null };
      }
      function vehicleMapBankBodyCacheKey(vehId, epoch, carName, engineFp) {
        if (typeof vehId !== 'number' || epoch === undefined || epoch === null || String(epoch) === '') { return null; }
        return JSON.stringify([vehId, String(epoch), String(carName || ''), String(engineFp || '')]);
      }
      function cacheCurrentMapBankBodies() {
        if (IS_MIRROR || !scope.mapBank) { return false; }
        var key = vehicleMapBankBodyCacheKey(scope.linkedVehId, scope.calibrationEpoch,
          scope.carName, scope.base && scope.base.engineFp);
        if (!key) { return false; }
        var cacheSequence = Number(scope.mapBank.sequence), cacheRevision = Number(scope.calibrationRevision);
        if (!isFinite(cacheSequence) || !isFinite(cacheRevision)) { return false; }
        var slots = angular.copy(scope.mapBank.slots || []), hasBody = false;
        for (var i = 0; i < slots.length; i++) {
          if (slots[i] && slots[i].configured && slots[i].data) { hasBody = true; break; }
        }
        if (!hasBody) { return false; }
        vehicleMapBankBodyCache[key] = {
          rpmAxis: (scope.rpmAxis || []).slice(), mapAxis: (scope.mapAxis || []).slice(),
          alphaNAxis: (scope.alphaNAxis || []).slice(), alphaMode: scope.alphaNActive(),
          tuneName: String(scope.tuneName || ''),
          mapBankSequence: cacheSequence,
          calibrationRevision: cacheRevision,
          slots: slots
        };
        vehicleMapBankBodyCacheOrder = vehicleMapBankBodyCacheOrder.filter(function (item) { return item !== key; });
        vehicleMapBankBodyCacheOrder.push(key);
        while (vehicleMapBankBodyCacheOrder.length > 12) {
          delete vehicleMapBankBodyCache[vehicleMapBankBodyCacheOrder.shift()];
        }
        return true;
      }
      function hydrateMapBankBodiesFromVehicleCache(state) {
        if (IS_MIRROR || !state || !state.mapBank) { return false; }
        var stateBase = state.base || {};
        var key = vehicleMapBankBodyCacheKey(state.vehId, state.calibrationEpoch, state.carName, stateBase.engineFp);
        var cached = key && vehicleMapBankBodyCache[key];
        if (!cached || !Array.isArray(cached.slots)) { return false; }
        var cachedSequence = Number(cached.mapBankSequence), liveSequence = Number(state.mapBank.sequence);
        var cachedRevision = Number(cached.calibrationRevision), liveRevision = Number(state.calibrationRevision);
        var versionsExact = isFinite(cachedSequence) && isFinite(liveSequence) && cachedSequence === liveSequence &&
          isFinite(cachedRevision) && isFinite(liveRevision) && cachedRevision === liveRevision;
        // Native M1-M4/cycle bindings intentionally change only map-bank routing metadata. The
        // calibration revision and all immutable bodies stay identical, so preserve the live
        // controller's new active/pending selection instead of replaying an older autosave choice.
        var metadataOnlyVersion = !versionsExact && isFinite(cachedSequence) && isFinite(liveSequence) &&
          isFinite(cachedRevision) && isFinite(liveRevision) && cachedRevision === liveRevision;
        var retained = state.mapBank.mutationResult || {}, retainedIndex = Math.floor(Number(retained.index));
        var retainedOperation = String(retained.operation || '');
        // The controller retains one tokenized result until the UI acknowledges it. If the app was
        // removed in that tiny window, the old cache is allowed to be exactly one metadata mutation
        // behind, but only when the result is stamped for this precise State. The result itself then
        // supplies the changed Save/Rename body in resolvePendingMapBankMutation().
        var retainedDelta = !versionsExact && retained.ok === true && !!retained.token &&
          /^(?:save|rename|clear|default)$/.test(retainedOperation) && retainedIndex >= 1 && retainedIndex <= 4 &&
          Number(retained.sequence) === liveSequence && Number(retained.calibrationRevision) === liveRevision &&
          isFinite(cachedSequence) && (cachedSequence === liveSequence || cachedSequence + 1 === liveSequence);
        if (!versionsExact && !metadataOnlyVersion && !retainedDelta) { return false; }
        var entries = Array.isArray(state.mapBank.banks) ? state.mapBank.banks : [];
        var entryByIndex = {};
        entries.forEach(function (entry) { if (entry && Number(entry.index) >= 1 && Number(entry.index) <= 4) { entryByIndex[Number(entry.index)] = entry; } });
        var oldRpm = cached.rpmAxis || [], oldMap = cached.mapAxis || [];
        var newRpm = state.rpmAxis || [], newMap = state.mapAxis || [];
        var oldAlpha = cached.alphaNAxis || defaultAlphaNAxis(oldMap.length);
        var newAlpha = state.alphaNAxis || defaultAlphaNAxis(newMap.length);
        var cachedAlphaMode = cached.alphaMode === true;
        var tableAxesSame = axesEqualTol(oldRpm, newRpm) && axesEqualTol(oldMap, newMap);
        if (!tableAxesSame && (!validSnapshotAxis(oldRpm, 'rpm') || !validSnapshotAxis(oldMap, 'map') ||
            !validSnapshotAxis(newRpm, 'rpm') || !validSnapshotAxis(newMap, 'map'))) { return false; }
        if (!tableAxesSame && cachedAlphaMode &&
            (!validSnapshotAxis(oldAlpha, 'alpha') || !validSnapshotAxis(newAlpha, 'alpha'))) { return false; }
        var candidates = [], restored = 0, missingConfigured = [];
        for (var index = 1; index <= 4; index++) {
          var live = mapBankSlot(index), saved = cached.slots[index - 1], entry = entryByIndex[index];
          if (!live || !live.configured) { candidates[index] = null; continue; }
          if (!saved || !saved.configured || !saved.data || !entry || entry.configured !== true ||
              cleanMapBankName(saved.name, index) !== cleanMapBankName(entry.name, index)) {
            candidates[index] = null; missingConfigured.push(index); continue;
          }
          var savedFactory = saved.data.boostTableFactory === true || saved.boostTableFactory === true;
          if ((entry.boostTableFactory === true) !== savedFactory) {
            candidates[index] = null; missingConfigured.push(index); continue;
          }
          var data = angular.copy(saved.data);
          if (!tableAxesSame) {
            var refit = {}, refitValid = true;
            for (var n = 0; n < MAP_BANK_MAP_NAMES.length; n++) {
              var mapName = MAP_BANK_MAP_NAMES[n], shape = snapshotTableShape(data.maps && data.maps[mapName]);
              if (!shape || shape.rows !== oldMap.length || shape.cols !== oldRpm.length) { refitValid = false; break; }
              var useAlpha = cachedAlphaMode && ['fuel', 'ignition', 'lambda', 've'].indexOf(mapName) >= 0;
              refit[mapName] = resampleSnapshotTable(data.maps[mapName], oldRpm,
                useAlpha ? oldAlpha : oldMap, newRpm, useAlpha ? newAlpha : newMap, mapName);
            }
            if (!refitValid) { candidates[index] = null; missingConfigured.push(index); continue; }
            data.maps = refit;
          }
          var peak = peakBoostFromMaps(data.maps), statedPeak = Number(entry.peakBoostKpa);
          if (isFinite(statedPeak) && Math.abs(peak - statedPeak) > 0.75) {
            candidates[index] = null; missingConfigured.push(index); continue;
          }
          candidates[index] = { data: data, peak: peak, factory: savedFactory };
          restored += 1;
        }
        // Never let one stale body suppress the authoritative autosave and then serialize the other
        // configured slots as empty. Save/Rename may omit only their one result-backed target;
        // Clear/Default change no remaining body, so every configured slot must still be complete.
        var resultBackedMissing = retainedDelta && /^(?:save|rename)$/.test(retainedOperation) ? retainedIndex : null;
        for (var missing = 0; missing < missingConfigured.length; missing++) {
          if (missingConfigured[missing] !== resultBackedMissing) { return false; }
        }
        if (missingConfigured.length > (resultBackedMissing === null ? 0 : 1)) { return false; }
        if (!restored && !(missingConfigured.length === 1 && missingConfigured[0] === resultBackedMissing)) { return false; }
        for (var applyIndex = 1; applyIndex <= 4; applyIndex++) {
          var candidate = candidates[applyIndex], slot = mapBankSlot(applyIndex);
          if (!candidate || !slot) { continue; }
          slot.data = candidate.data;
          slot.peakBoostKpa = candidate.peak;
          slot.boostTableFactory = candidate.factory;
        }
        if (cached.tuneName) { scope.tuneName = String(cached.tuneName); }
        return true;
      }
      function performanceSettingsFrom(src, fallback) {
        src = validSnapshotSettings(src) ? src : {};
        fallback = validSnapshotSettings(fallback) ? fallback : {};
        var out = {};
        MAP_BANK_SETTING_KEYS.forEach(function (key) {
          var has = Object.prototype.hasOwnProperty.call(src, key);
          // Boost shaping and the V3.0.5 hardware/transmission controls did not exist in older
          // tune/MAP packages. Their safety-sensitive fields must migrate deterministically, never
          // inherit whichever car/tune happens to be live while the old file is opened.
          var safeMigration = isBoostShapeMigrationKey(key) || SAFE_DEFAULT_MIGRATION_SETTINGS[key];
          var value = has ? src[key] : (safeMigration ? MAP_BANK_SETTING_DEFAULTS[key] : fallback[key]);
          if ((value === undefined || value === null) && key === 'revLimit') { return; } // absent = stock limiter
          if (value === undefined || value === null) { value = MAP_BANK_SETTING_DEFAULTS[key]; }
          if (MAP_BANK_BOOLEAN_SETTINGS[key]) {
            if (typeof value === 'boolean') { out[key] = value; }
            else { value = Number(value); out[key] = isFinite(value) ? value >= 0.5 : !!MAP_BANK_SETTING_DEFAULTS[key]; }
          }
          else {
            value = Number(value);
            out[key] = isFinite(value) ? value : Number(MAP_BANK_SETTING_DEFAULTS[key]);
          }
        });
        if (!(Number(out.revLimit) > 0)) { delete out.revLimit; }
        return out;
      }
      function performanceSettingsIssue(settings, allowMissing, label) {
        label = label || 'Working tune';
        if (!validSnapshotSettings(settings)) { return label + ' settings are not a valid object'; }
        settings = settings || {};
        for (var i = 0; i < MAP_BANK_SETTING_KEYS.length; i++) {
          var key = MAP_BANK_SETTING_KEYS[i];
          if (!Object.prototype.hasOwnProperty.call(settings, key) || settings[key] === undefined || settings[key] === null) {
            if (key === 'revLimit' || allowMissing) { continue; }
            return label + ' is missing setting "' + key + '"';
          }
          var value = settings[key];
          if (key === 'revLimit') {
            value = Number(value);
            if (!isFinite(value)) { return label + ' setting "revLimit" is not a finite number'; }
            if (value > 0) {
              var revFloor = (Number(scope.base && scope.base.idleRPM) || 800) + 700;
              var revCeiling = Number(scope.base && (scope.base.revLimitCeilingRPM || scope.base.revLimiterRPM)) || 30000;
              if (value < revFloor || value > revCeiling) {
                return label + ' setting "revLimit" is ' + value + '; allowed ' + revFloor + '–' + revCeiling;
              }
            }
          } else if (MAP_BANK_BOOLEAN_SETTINGS[key]) {
            if (typeof value !== 'boolean' && !isFinite(Number(value))) {
              return label + ' setting "' + key + '" is not true/false';
            }
          } else {
            value = Number(value);
            var range = MAP_BANK_SETTING_RANGES[key];
            if (!isFinite(value)) { return label + ' setting "' + key + '" is not a finite number'; }
            if (!range) { return label + ' setting "' + key + '" has no validation range'; }
            if (value < range[0] || value > range[1]) {
              return label + ' setting "' + key + '" is ' + value + '; allowed ' + range[0] + '–' + range[1];
            }
            if (MAP_BANK_ENUM_SETTINGS[key] && value !== Math.floor(value)) {
              return label + ' setting "' + key + '" must be a whole-number mode';
            }
          }
        }
        return null;
      }
      function validPerformanceSettings(settings, allowMissing) {
        return !performanceSettingsIssue(settings, allowMissing);
      }
      function copyMapBankMaps(maps) {
        if (!maps) { return null; }
        var out = {};
        for (var i = 0; i < MAP_BANK_MAP_NAMES.length; i++) {
          var name = MAP_BANK_MAP_NAMES[i];
          if (!Array.isArray(maps[name])) { return null; }
          out[name] = angular.copy(maps[name]);
        }
        return out;
      }
      function peakBoostFromMaps(maps) {
        var table = maps && maps.boost, peak = 0;
        if (!Array.isArray(table)) { return 0; }
        for (var r = 0; r < table.length; r++) {
          for (var c = 0; c < (table[r] || []).length; c++) { peak = Math.max(peak, Number(table[r][c]) || 0); }
        }
        return peak;
      }
      function boostTablesMatch(a, b) {
        if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { return false; }
        for (var r = 0; r < a.length; r++) {
          if (!Array.isArray(a[r]) || !Array.isArray(b[r]) || a[r].length !== b[r].length) { return false; }
          for (var c = 0; c < a[r].length; c++) {
            if (Math.abs(Number(a[r][c]) - Number(b[r][c])) > 0.5) { return false; }
          }
        }
        return true;
      }
      function boostTableIsFactoryFlat(table) {
        if (!Array.isArray(table) || !table.length || !Array.isArray(table[0]) || !table[0].length) { return false; }
        var first = Number(table[0][0]);
        if (!isFinite(first)) { return false; }
        for (var r = 0; r < table.length; r++) {
          if (!Array.isArray(table[r]) || !table[r].length) { return false; }
          for (var c = 0; c < table[r].length; c++) {
            var value = Number(table[r][c]);
            if (!isFinite(value) || Math.abs(value - first) > 0.5) { return false; }
          }
        }
        return true;
      }
      // A factory flag is control metadata, not calibration data. Older synchronization races could
      // leave it true beside a tuned/non-flat Boost Target table. That contradiction is recoverable:
      // demoting the flag preserves every cell and makes the ECU honor the player's table. No other
      // malformed shape, setting, or out-of-range value is repaired here.
      function repairStaleFactoryBoostClaims(snap) {
        var repaired = [], raw = snap && snap.mapBank;
        if (!raw || !Array.isArray(raw.slots)) { return repaired; }
        if (raw.workingBoostTableFactory === true &&
            !boostTableIsFactoryFlat(snap.maps && snap.maps.boost)) {
          raw.workingBoostTableFactory = false;
          repaired.push({ kind: 'working', label: 'working Boost Target' });
        }
        for (var i = 0; i < raw.slots.length; i++) {
          var slot = raw.slots[i], data = slot && slot.data;
          if (!slot || slot.configured !== true || !data) { continue; }
          var claimedFactory = data.boostTableFactory === true || slot.boostTableFactory === true;
          if (claimedFactory && !boostTableIsFactoryFlat(data.maps && data.maps.boost)) {
            data.boostTableFactory = false;
            if (typeof slot.boostTableFactory === 'boolean') { slot.boostTableFactory = false; }
            repaired.push({ kind: 'slot', index: Math.floor(Number(slot.index)) || (i + 1), label: 'M' + (Math.floor(Number(slot.index)) || (i + 1)) + ' Boost Target' });
          }
        }
        return repaired;
      }
      function workingMapBankData() {
        if (!scope.mapBank || !scope.maps || !scope.settings) { return null; }
        var maps = copyMapBankMaps(scope.maps);
        if (!maps) { return null; }
        var data = { maps: maps, settings: performanceSettingsFrom(scope.settings, MAP_BANK_SETTING_DEFAULTS) };
        if (typeof scope.mapBank.workingBoostTableFactory === 'boolean') {
          data.boostTableFactory = scope.mapBank.workingBoostTableFactory;
        }
        return data;
      }
      // Give a brand-new controller/package a recoverable MAP 1. Routine edits never call this;
      // refreshCleanActiveMapBankBody below handles only controller-confirmed clean migrations.
      function seedInitialMapBankBody() {
        if (!scope.mapBank || !scope.maps || !scope.settings) { return false; }
        var active = Math.floor(Number(scope.mapBank.active));
        var slot = mapBankSlot(active), data = workingMapBankData();
        if (!slot || !slot.configured || slot.data || !data) { return false; }
        slot.name = cleanMapBankName(slot.name, active); slot.draftName = slot.name;
        slot.data = data;
        slot.boostTableFactory = data.boostTableFactory;
        slot.peakBoostKpa = peakBoostFromMaps(data.maps);
        return true;
      }
      function refreshCleanActiveMapBankBody() {
        if (!scope.mapBank || scope.mapBank.workingDirty === true) { return false; }
        var active = Math.floor(Number(scope.mapBank.active));
        var slot = mapBankSlot(active), data = workingMapBankData();
        if (!slot || !slot.configured || !data) { return false; }
        slot.data = data;
        slot.boostTableFactory = data.boostTableFactory;
        slot.peakBoostKpa = peakBoostFromMaps(data.maps);
        return true;
      }
      function mapBankControllerData(slot) {
        if (!slot || !slot.configured || !slot.data) { return null; }
        var data = {
          name: cleanMapBankName(slot.name, slot.index),
          maps: copyMapBankMaps(slot.data.maps),
          settings: performanceSettingsFrom(slot.data.settings, scope.settings)
        };
        if (!data.maps) { return null; }
        if (typeof slot.data.boostTableFactory === 'boolean') { data.boostTableFactory = slot.data.boostTableFactory; }
        else if (typeof slot.boostTableFactory === 'boolean') { data.boostTableFactory = slot.boostTableFactory; }
        return data;
      }
      function serializedMapBank() {
        var active = Math.floor(Number(scope.mapBank.active));
        if (!(active >= 1 && active <= 4) || !(mapBankSlot(active) || {}).configured) {
          active = 1;
          for (var a = 1; a <= 4; a++) { if ((mapBankSlot(a) || {}).configured) { active = a; break; } }
        }
        var def = Math.floor(Number(scope.mapBank.default));
        if (!(def >= 1 && def <= 4) || !(mapBankSlot(def) || {}).configured) { def = active; }
        return {
          v: 2, active: active, default: def,
          workingDirty: scope.mapBank.workingDirty === true,
          workingBoostTableFactory: scope.mapBank.workingBoostTableFactory === true,
          slots: [1, 2, 3, 4].map(function (index) {
            var slot = mapBankSlot(index) || emptyMapBankSlot(index);
            var data = slot.configured ? mapBankControllerData(slot) : null;
            if (data) { delete data.name; }
            return { index: index, name: cleanMapBankName(slot.name, index), configured: !!data, data: data };
          })
        };
      }
      function calKeyFor(carName) { return 'oep_autosave_' + carName; }
      function calKey() { return calKeyFor(scope.carName); }
      // V2.9.7 STORE-WIPE FIX (Liqurdz's 4-MAP loss) part 3: generation backup. The previous
      // autosave rotates to _prev before every overwrite, so ANY bad write - slot demotion, a
      // poison package, a false factory broadcast, or a bug we have not met yet - stays one step
      // recoverable. loadAutosave falls back to it; the Tunes page exposes a manual restore.
      function prevCalKey() { return calKey() + '_prev'; }
      function rotateAutosaveGeneration(key, newRaw) {
        try {
          var current = localStorage.getItem(key);
          if (current == null || current === newRaw) { return; }
          JSON.parse(current);   // never rotate unparseable garbage over a good _prev
          localStorage.setItem(key + '_prev', current);
        } catch (e) {}
      }
      function factoryResetIntentPrefixFor(carName) {
        return 'oep_factory_reset_intent_' + encodeURIComponent(String(carName || 'car')) + '|';
      }
      function factoryResetIntentKeyFor(carName, engineFp) {
        return factoryResetIntentPrefixFor(carName) + encodeURIComponent(String(engineFp || 'na'));
      }
      function factoryResetIntentKey() {
        return factoryResetIntentKeyFor(scope.carName, (scope.base && scope.base.engineFp) || 'na');
      }
      function parseFactoryResetIntent(raw) {
        var intent = null;
        try { intent = raw ? (typeof raw === 'string' ? JSON.parse(raw) : raw) : null; } catch (e) {}
        var version = intent && Number(intent.version);
        return intent && typeof intent === 'object' && (version === 1 || version === 2) ? intent : null;
      }
      function storedFactoryResetIntentForState(state) {
        // Raw presence check is also used while the nonce gate is deliberately closed.
        if (IS_MIRROR || !state) { return null; }
        var engineFp = String((state.base && state.base.engineFp) || 'na');
        var intent = parseFactoryResetIntent(lsGet(factoryResetIntentKeyFor(state.carName, engineFp)));
        if (intent) { return intent; }
        // V3.0.2 identity migration: an intent written just before the update is filed under the old
        // performance fingerprint. Without this, a Factory Reset the player confirmed is forgotten
        // across the update and their calibration comes back instead of the factory one. One-way
        // adopt under the new key, matching loadPowerRec/loadDynoBaseline.
        var legacyFp = String((state.base && state.base.engineFpLegacy) || '');
        if (!legacyFp || legacyFp === engineFp) { return null; }
        var legacyRaw = lsGet(factoryResetIntentKeyFor(state.carName, legacyFp));
        var legacyIntent = parseFactoryResetIntent(legacyRaw);
        if (!legacyIntent || String(legacyIntent.carName || '') !== String(state.carName || '')) { return null; }
        try {
          localStorage.setItem(factoryResetIntentKeyFor(state.carName, engineFp), legacyRaw);
          localStorage.removeItem(factoryResetIntentKeyFor(state.carName, legacyFp));
        } catch (e) {}
        return legacyIntent;
      }
      function factoryResetIntentForState(state) {
        if (IS_MIRROR || !state || !gameSessionNonceGateOpen) { return null; }
        var engineFp = String((state.base && state.base.engineFp) || 'na');
        var intent = storedFactoryResetIntentForState(state);
        if (!intent || String(intent.carName || '') !== String(state.carName || '') ||
            String(intent.engineFp || 'na') !== engineFp) { return null; }
        // Waive the object id only when two non-empty GE nonces PROVE that this marker crossed a
        // real BeamNG process restart. The same nonce (including after Angular/CEF reload), an old
        // v1 marker, or a bridge timeout all stay conservatively tied to the exact vehicle instance.
        var intentNonce = typeof intent.gameSessionNonce === 'string' ? intent.gameSessionNonce : '';
        var sameGameSession = gameSessionNonceKnown && !!gameSessionNonce && !!intentNonce && intentNonce === gameSessionNonce;
        var provedGameRestart = gameSessionNonceKnown && !!gameSessionNonce && !!intentNonce && intentNonce !== gameSessionNonce;
        if (intent.phase === 'prepared') {
          // Prepared means GE had not yet acknowledged queueing. Never carry that authority across
          // a process restart. In this same process, accept it only when the exact reset transition
          // is already visible: factoryReset mints a new epoch and increments frSeq by exactly one.
          var preparedLiveEpoch = state.calibrationEpoch === undefined || state.calibrationEpoch === null ? '' : String(state.calibrationEpoch);
          var preparedIntentEpoch = intent.calibrationEpoch === undefined || intent.calibrationEpoch === null ? '' : String(intent.calibrationEpoch);
          var preparedLiveSeq = Number(state.frSeq), preparedIntentSeq = Number(intent.frSeq);
          if (!sameGameSession || !preparedLiveEpoch || !preparedIntentEpoch || preparedLiveEpoch === preparedIntentEpoch ||
              !isFinite(preparedLiveSeq) || !isFinite(preparedIntentSeq) || preparedLiveSeq !== preparedIntentSeq + 1) { return null; }
        }
        if (!provedGameRestart && (typeof intent.vehicleId !== 'number' || typeof state.vehId !== 'number' ||
            intent.vehicleId !== state.vehId)) { return null; }
        if (typeof intent.hasTurbo === 'boolean' && state.base && typeof state.base.hasTurbo === 'boolean' &&
            intent.hasTurbo !== !!state.base.hasTurbo) { return null; }
        return intent;
      }
      function factoryResetIntentConfirmedByState(intent, state) {
        if (!intent || !state) { return false; }
        var liveEpoch = state.calibrationEpoch === undefined || state.calibrationEpoch === null ? '' : String(state.calibrationEpoch);
        var intentEpoch = intent.calibrationEpoch === undefined || intent.calibrationEpoch === null ? '' : String(intent.calibrationEpoch);
        if (liveEpoch && intentEpoch && liveEpoch !== intentEpoch) { return true; }
        var liveSeq = Number(state.frSeq), intentSeq = Number(intent.frSeq);
        return isFinite(liveSeq) && isFinite(intentSeq) && liveSeq !== intentSeq;
      }
      function writeFactoryResetIntent(requestId, deferDurableSync) {
        if (IS_MIRROR || !gameSessionNonceKnown || !gameSessionNonce || !scope.carName || !scope.base || !scope.base.engineFp ||
            scope.calibrationEpoch === undefined || scope.calibrationEpoch === null || !packageIdentityIsCoherent()) { return false; }
        var key = factoryResetIntentKey();
        // Never overwrite another confirmed reset. Two identical live vehicles share this
        // car/engine key even though a same-process intent remains object-id constrained.
        if (parseFactoryResetIntent(lsGet(key))) { return false; }
        var intent = {
          version: 2, createdAt: Date.now(), uiSessionId: directiveSessionId,
          gameSessionNonce: gameSessionNonce, requestId: String(requestId || ''),
          phase: deferDurableSync ? 'prepared' : 'committed', carName: String(scope.carName),
          engineFp: String(scope.base.engineFp), hasTurbo: !!scope.base.hasTurbo,
          vehicleId: scope.linkedVehId, calibrationEpoch: String(scope.calibrationEpoch),
          frSeq: typeof scope.lastFrSeq === 'number' ? scope.lastFrSeq : 0
        };
        try {
          var raw = JSON.stringify(intent);
          localStorage.setItem(key, raw);
          if (localStorage.getItem(key) !== raw) { return false; }
        } catch (e) { return false; }
        if (!deferDurableSync) { durSync(); }
        return { key: key, raw: raw, requestId: intent.requestId };
      }
      function commitFactoryResetIntentRecord(record) {
        if (!record || !record.key || !record.raw) { return false; }
        try {
          if (localStorage.getItem(record.key) !== record.raw) { return false; }
          var intent = parseFactoryResetIntent(record.raw);
          if (!intent || intent.phase !== 'prepared' || String(intent.requestId || '') !== String(record.requestId || '')) { return false; }
          intent.phase = 'committed';
          var committedRaw = JSON.stringify(intent);
          localStorage.setItem(record.key, committedRaw);
          if (localStorage.getItem(record.key) !== committedRaw) { return false; }
          record.raw = committedRaw;
          return true;
        } catch (e) { return false; }
      }
      function clearFactoryResetIntentRecord(record) {
        if (!record || !record.key || !record.raw) { return false; }
        try {
          if (localStorage.getItem(record.key) !== record.raw) { return false; }
          var intent = parseFactoryResetIntent(record.raw);
          if (!intent || String(intent.requestId || '') !== String(record.requestId || '')) { return false; }
          localStorage.removeItem(record.key);
          return localStorage.getItem(record.key) == null;
        } catch (e) { return false; }
      }
      function schedulePreparedFactoryResetCleanup(state, intent) {
        if (!state || !intent || intent.phase !== 'prepared') { return; }
        var engineFp = String((state.base && state.base.engineFp) || 'na');
        var key = factoryResetIntentKeyFor(state.carName, engineFp), raw = lsGet(key);
        var stored = parseFactoryResetIntent(raw);
        if (!stored || stored.phase !== 'prepared' || String(stored.requestId || '') !== String(intent.requestId || '')) { return; }
        var createdAt = Number(stored.createdAt), delay = isFinite(createdAt) ? Math.max(0, createdAt + 5500 - Date.now()) : 0;
        // An accepted callback promotes the exact raw record first, so this delayed abort cannot
        // delete a committed/newer marker. A CEF reload simply reschedules against the old timestamp.
        setTimeout(function () {
          clearFactoryResetIntentRecord({ key: key, raw: raw, requestId: String(stored.requestId || '') });
        }, Math.min(delay, 5500));
      }
      function clearFactoryResetIntent() {
        if (IS_MIRROR) { return false; }
        var key = factoryResetIntentKey();
        try {
          localStorage.removeItem(key);
          if (localStorage.getItem(key) != null) { return false; }
        } catch (e) { return false; }
        durSync();
        return true;
      }
      function autosavePredatesConfirmedFactoryReset(state) {
        if (IS_MIRROR || !state || Number(state.frSeq) <= 0 || state.calibrationEpoch === undefined || state.calibrationEpoch === null) { return false; }
        var key = calKeyFor(state.carName), raw = lsGet(key), snap = null;
        try { snap = raw ? JSON.parse(raw) : null; } catch (e) {}
        var engineFp = String((state.base && state.base.engineFp) || '');
        if (snap && snap.engineFp && engineFp && !engineIdentityCompatible(snap, state.base)) {
          raw = lsGet(key + '_fp_' + engineFp); snap = null;
          try { snap = raw ? JSON.parse(raw) : null; } catch (e2) {}
        }
        if (!snap || !snap.maps || snap.calibrationEpoch === undefined || snap.calibrationEpoch === null) { return false; }
        if (snap.engineFp && engineFp && !engineIdentityCompatible(snap, state.base)) { return false; }
        // An ordinary controller reinit also changes epoch, but starts frSeq at zero. A non-zero
        // live reset sequence plus a pre-reset source epoch is the durable proof that Factory Reset
        // completed after this autosave, even if the app closed before its confirmation broadcast.
        return String(snap.calibrationEpoch) !== String(state.calibrationEpoch);
      }
      function slotKey(i) { return 'oep_slot_' + scope.carName + '_' + i; }
      function libKey() { return 'oep_lib_' + scope.carName; }
      function snapshot() {
        var mapBank = serializedMapBank();
        return { name: scope.tuneName, hasTurbo: !!scope.base.hasTurbo, engineFp: (scope.base && scope.base.engineFp) || null,
          engineFpLegacy: (scope.base && scope.base.engineFpLegacy) || null,
          calibrationEpoch: scope.calibrationEpoch || null, frSeq: typeof scope.lastFrSeq === 'number' ? scope.lastFrSeq : null,
          settings: angular.copy(scope.settings), maps: angular.copy(scope.maps), rpmAxis: scope.rpmAxis.slice(),
          mapAxis: scope.mapAxis.slice(), alphaNAxis: scope.alphaNAxis.slice(), mapBank: mapBank };
      }
      function lessonJournalKeyFor(carName, engineFp) {
        return 'oep_lesson_restore_' + encodeURIComponent(String(carName || 'car')) + '|' + encodeURIComponent(String(engineFp || 'na'));
      }
      function lessonJournalPrefixFor(carName) { return 'oep_lesson_restore_' + encodeURIComponent(String(carName || 'car')) + '|'; }
      function lessonJournalKey() { return lessonJournalKeyFor(scope.carName, (scope.base && scope.base.engineFp) || null); }
      // Must agree with recoverTemporaryLessonJournal, which scans the whole per-car namespace.
      // Checking only the current-fingerprint key made this return false for a journal written
      // before the V3.0.2 identity change, so an interrupted lesson looked absent and the user's
      // pre-lesson calibration was never offered back to them.
      function hasTemporaryLessonJournal() {
        if (lsGet(lessonJournalKey()) != null) { return true; }
        var journals = lessonJournalsForCurrentCar();
        for (var key in journals) {
          if (!Object.prototype.hasOwnProperty.call(journals, key)) { continue; }
          var candidate = null;
          try { candidate = JSON.parse(journals[key]); } catch (e) { continue; }
          if (lessonJournalMatchesCurrent(candidate)) { return true; }
        }
        return false;
      }
      function lessonJournalsForCurrentCar() {
        var out = {}, prefix = lessonJournalPrefixFor(scope.carName);
        try {
          for (var i = 0; i < localStorage.length; i++) {
            var key = localStorage.key(i);
            if (key && key.indexOf(prefix) === 0) { out[key] = localStorage.getItem(key); }
          }
        } catch (e) {}
        return out;
      }
      function temporaryLessonIdentityChanged(state) {
        if (!temporaryLessonSession || !temporaryLessonSession.journal || !state) { return false; }
        var j = temporaryLessonSession.journal;
        var incomingBase = state.base || {};
        var incomingVehId = (typeof state.vehId === 'number') ? state.vehId : null;
        if (String(state.carName || '') !== String(j.carName || '')) { return true; }
        if (j.vehId !== null && incomingVehId !== null && Number(j.vehId) !== Number(incomingVehId)) { return true; }
        if (incomingBase.engineFp !== undefined && incomingBase.engineFp !== null && !engineIdentityCompatible(j, incomingBase)) { return true; }
        return typeof incomingBase.hasTurbo === 'boolean' && !!incomingBase.hasTurbo !== !!j.hasTurbo;
      }
      function abandonTemporaryLessonForIdentityChange() {
        if (!temporaryLessonSession || !temporaryLessonSession.journal) { return; }
        var session = temporaryLessonSession, j = session.journal, wrote = false;
        try {
          var original = JSON.stringify(j.snapshot);
          localStorage.setItem(calKeyFor(j.carName), original);
          wrote = localStorage.getItem(calKeyFor(j.carName)) === original;
        } catch (e) {}
        // A State from the old vehicle cannot be authoritatively observed after the UI has changed
        // identity. Do not treat a routed/queued command as restoration and never consume the journal
        // here. Tabbing back (or reopening the app) runs the same token + full-State recovery path.
        cancelPendingMapBankMutation('vehicle_or_engine_changed', true);
        controllerVehicleGuardId = session.previousVehicleGuard;
        controllerVehicleRouteId = session.previousVehicleRoute;
        controllerIdentityGuard = session.previousIdentityGuard;
        temporaryLessonSession = null;
        if (scope.tutorial) { scope.tutorial.active = false; scope.tutorial.lesson = null; }
        durSync();
        setToast(wrote
          ? 'Temporary lesson ended - the previous calibration was saved and will be restored when that vehicle is selected again'
          : 'Temporary lesson ended after the vehicle changed; its recovery backup was kept');
      }

      // ---- durable persistence ----------------------------------------------
      // BeamNG keeps browser localStorage in its volatile temp/ folder, so a CEF
      // cache wipe (which players do when the game misbehaves) erases saved tunes.
      // Mirror everything to a real userfolder file and recover from it if the
      // local copy is gone. localStorage stays the fast working store.
      function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
      function luaQuote(s) { return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t') + '"'; }
      function currentPackageIdentity() {
        return {
          vehicleId: (typeof scope.linkedVehId === 'number') ? scope.linkedVehId : null,
          carName: String(scope.carName || ''),
          engineFp: String((scope.base && scope.base.engineFp) || '')
        };
      }
      function packageIdentityIsCoherent() {
        return typeof scope.activeVehId === 'number' && typeof scope.linkedVehId === 'number' &&
          scope.activeVehId === scope.linkedVehId;
      }
      function packageIdentityMatches(identity) {
        if (!identity || !packageIdentityIsCoherent()) { return false; }
        var current = currentPackageIdentity();
        return current.vehicleId === identity.vehicleId &&
          current.carName === String(identity.carName || '') &&
          current.engineFp === String(identity.engineFp || '');
      }
      function publishTuneCatalog() {
        // refreshActiveVeh() learns the new player id before that controller's first state arrives.
        // Do not publish a hybrid catalog containing the old car's slots under the new vehicle id.
        if (IS_MIRROR || typeof bngApi === 'undefined' || !bngApi.engineLua ||
            !packageIdentityIsCoherent() || scope.activeVehId < 0) { return; }
        var entries = [], refs = {};
        (scope.slots || []).forEach(function (slot) {
          if (!slot || !slot.saved) { return; }
          var raw = lsGet(slotKey(slot.index)), snap = null;
          try { snap = raw ? JSON.parse(raw) : null; } catch (e) {}
          if (!snap || !snap.maps) { return; }
          var name = slot.name || ('Backup ' + (slot.index + 1));
          entries.push({ source: 'slot', index: slot.index, name: name });
          refs['slot:' + slot.index] = { source: 'slot', index: slot.index, name: name, data: snap };
        });
        (scope.library || []).forEach(function (entry, index) {
          if (!entry || !entry.data || !entry.data.maps) { return; }
          var name = entry.name || ('Library tune ' + (index + 1));
          entries.push({ source: 'library', index: index, name: name });
          refs['library:' + index] = { source: 'library', index: index, name: name, data: angular.copy(entry.data) };
        });
        (scope.shareFiles || []).forEach(function (entry, index) {
          if (!entry || typeof entry.path !== 'string' || !entry.path) { return; }
          var name = entry.name || ('Tune file ' + (index + 1));
          entries.push({ source: 'file', index: index, name: name });
          refs['file:' + index] = { source: 'file', index: index, name: name, path: entry.path };
        });
        tuneCatalogSerial += 1;
        var catalogToken = tunePublisherId + '_' + tuneCatalogSerial.toString(36);
        var packageIdentity = currentPackageIdentity();
        tuneCatalogSnapshots[catalogToken] = {
          vehicleId: packageIdentity.vehicleId,
          carName: packageIdentity.carName,
          engineFp: packageIdentity.engineFp,
          refs: refs
        };
        tuneCatalogSnapshotOrder.push(catalogToken);
        while (tuneCatalogSnapshotOrder.length > 8) { delete tuneCatalogSnapshots[tuneCatalogSnapshotOrder.shift()]; }
        // Only labels/references cross this boundary. Resolver snapshots (including
        // saved tune bodies and file paths) remain private to this trusted game UI.
        var catalog = { vehicleId: scope.activeVehId, publisherId: tunePublisherId, catalogToken: catalogToken, entries: entries };
        bngApi.engineLua('if oeProTuneRemote and oeProTuneRemote.updateTuneCatalog then oeProTuneRemote.updateTuneCatalog(jsonDecode(' + luaQuote(JSON.stringify(catalog)) + ')) end');
      }
      function requestRemoteTune(source, index) {
        if (!IS_PRO_STUDIO) { setToast('Open this tune from the game PC'); return false; }
        var generation = Number(scope.remoteTuneCatalog && scope.remoteTuneCatalog.generation) || 0;
        index = Number(index);
        if (!generation || !isFinite(index) || Math.floor(index) !== index) { setToast('Saved tune catalog is not ready'); return false; }
        var queued = callController('loadSavedTune("' + source + '", ' + index + ', ' + generation + ')');
        setToast(queued === false ? 'Enable Pro Studio editing on the game PC first' : 'Loading saved tune from the game PC...');
        return queued !== false;
      }
      function durPathFor(name) { return 'settings/oeProTune/' + String(name || 'car').replace(/[^a-z0-9]+/gi, '_') + '.json'; }
      function durPath() { return durPathFor(scope.carName); }
      // REVIEW FIX 2026-07-29. Writes always go to durPath() (the historical name, restored by the
      // autoload fix). This is the READ-ONLY fallback: the file a build carrying the 0.39 naming bug
      // would have written. Recovery tries the historical file first and only consults this if that
      // one is absent, so a player who installed OE after the update - or who re-tuned during the
      // window before this fix - finds their work instead of it being orphaned the other way.
      function durPathFallback() { return scope.carNameFallback ? durPathFor(scope.carNameFallback) : null; }
      function durBlob() {
        var b = { autosave: lsGet(calKey()), autosavePrev: lsGet(prevCalKey()), engineAutosaves: {}, factoryResetIntents: {}, lessonRestore: lsGet(lessonJournalKey()), lessonRestores: lessonJournalsForCurrentCar(), library: lsGet(libKey()), notes: lsGet(notesKey()), dynoBase: lsGet(dynoBaseKey()), slots: {} };
        var enginePrefix = calKey() + '_fp_';
        try {
          for (var e = 0; e < localStorage.length; e++) {
            var engineKey = localStorage.key(e);
            if (engineKey && engineKey.indexOf(enginePrefix) === 0) { b.engineAutosaves[engineKey] = localStorage.getItem(engineKey); }
          }
        } catch (e1) {}
        var resetPrefix = factoryResetIntentPrefixFor(scope.carName);
        try {
          for (var f = 0; f < localStorage.length; f++) {
            var resetKey = localStorage.key(f);
            if (resetKey && resetKey.indexOf(resetPrefix) === 0) {
              var resetRaw = localStorage.getItem(resetKey);
              // Remote reset phase one is localStorage-only. No unrelated autosave may smuggle its
              // marker into the durable file before GE acknowledges that vehicle Lua was queued.
              var resetIntent = parseFactoryResetIntent(resetRaw);
              if (!resetIntent || resetIntent.phase !== 'prepared') { b.factoryResetIntents[resetKey] = resetRaw; }
            }
          }
        } catch (e2) {}
        for (var i = 0; i < 4; i++) { b.slots[i] = lsGet(slotKey(i)); }
        return b;
      }
      function durSync() {
        if (IS_MIRROR) { return; }
        try {
          if (typeof bngApi === 'undefined' || !bngApi.engineLua || !scope.carName) { return; }
          bngApi.engineLua('jsonWriteFile(' + luaQuote(durPath()) + ', jsonDecode(' + luaQuote(JSON.stringify(durBlob())) + '), true)');
        } catch (e) {}
      }
      function durRestoreKey(key, val) {
        if (val == null) { return; }
        try { if (localStorage.getItem(key) == null) { localStorage.setItem(key, typeof val === 'string' ? val : JSON.stringify(val)); } } catch (e) {}
      }
      // V2.9.7 wave 4 (Liqurdz's BBG report): mirror of the exact gate chain loadAutosave ->
      // applySnapshot applies to the primary autosave. TRUE only when the stored string is one no
      // reader will ever accept (unparseable, or parsed-with-maps yet rejected by the same
      // prepare/range/package gates - including classes normalizeMapBankPackage still rejects
      // after its wave-4 demote). A name-only autosave and a different-engine autosave are NOT
      // broken: loadAutosave handles both (name restore / fingerprint stash) without rejecting.
      function autosaveRawIsReaderRejected(raw) {
        var snap;
        try { snap = JSON.parse(raw); } catch (eParse) { return true; }
        if (!snap || !snap.maps) { return false; }
        var curFp = (scope.base && scope.base.engineFp) || null;
        if (snap.engineFp && curFp && !engineIdentityCompatible(snap, scope.base)) { return false; }
        try {
          snap = angular.copy(snap);
          if (!validSnapshotSettings(snap.settings)) { return true; }
          if (!prepareSnapshotAxes(snap)) { return true; }
          migrateFuelMap(snap.maps.fuel);
          if (!snapshotMapsInRange(snap.maps)) { return true; }
          return !normalizeMapBankPackage(snap);
        } catch (eGate) { return true; }
      }
      function durRecover(loadOptions) {
        if (IS_MIRROR) { return; }
        // only kicks in when this car has no local data (e.g. the player cleared cache), or -
        // V2.9.7 wave 4 (Liqurdz's BBG report) - when the primary autosave EXISTS but no reader
        // accepts it (loadAutosave has already fallen through to _prev/defaults by the time this
        // runs): the durable file may still hold a replayable copy. recoverBrokenPrimary keeps the
        // exact rejected bytes so the async callback can prove nothing newer wrote the key.
        var recoverBrokenPrimary = null;
        var primaryRaw = lsGet(calKey());
        if (primaryRaw != null && autosaveRawIsReaderRejected(primaryRaw)) { recoverBrokenPrimary = primaryRaw; }
        if (recoverBrokenPrimary == null && (primaryRaw != null || lsGet(libKey()) != null || lsGet(slotKey(0)) != null)) { return; }
        var recoverCarName = String(scope.carName || '');
        var recoverEngineFp = String((scope.base && scope.base.engineFp) || '');
        var recoverVehicleId = (typeof scope.linkedVehId === 'number') ? scope.linkedVehId : null;
        var recoverPath = durPath();
        var recoverMutationGeneration = localCalibrationMutationGeneration;
        var recoverStoreRequired = scope.storeRequired === true;
        var recoverWorkingDirty = !!(scope.mapBank && scope.mapBank.workingDirty === true);
        try {
          if (typeof bngApi === 'undefined' || !bngApi.engineLua) { return; }
          // REVIEW FIX 2026-07-29: try the historical path, then the name a 0.39-bugged build used.
          // Only one of the two ever holds a tune for a given user, so this cannot merge two different
          // calibrations - it just means neither naming era gets stranded.
          var recoverAltPath = durPathFallback();
          function applyDurableRecovery(blob) {
            scope.$applyAsync(function () {
              // jsonReadFile is asynchronous. Never restore car A's durable blob through the
              // dynamic calKey()/slotKey() helpers after the UI has moved to car/engine B.
              if (String(scope.carName || '') !== recoverCarName ||
                  String((scope.base && scope.base.engineFp) || '') !== recoverEngineFp ||
                  (recoverVehicleId !== null && (typeof scope.linkedVehId !== 'number' || scope.linkedVehId !== recoverVehicleId))) { return; }
              // The disk read can finish after the player has already started tuning this fresh
              // vehicle. Never let recovery overwrite that newer draft or its first queued echo.
              // V2.9.7 wave 4 (Liqurdz's BBG report): on the broken-primary path the key EXISTS by
              // definition, so the null check becomes an exact-bytes check - the key must still
              // hold the very string that was sampled and rejected. Any other value (a Store, a
              // lesson rollback, another recovery) means something newer wrote it: stand down.
              var currentPrimaryRaw = lsGet(calKey());
              if (localCalibrationMutationGeneration !== recoverMutationGeneration ||
                  (recoverBrokenPrimary == null ? currentPrimaryRaw != null : currentPrimaryRaw !== recoverBrokenPrimary) ||
                  Object.keys(pendingSettingEcho).length > 0 || Object.keys(pendingMapEcho).length > 0 ||
                  (!recoverStoreRequired && scope.storeRequired === true) ||
                  (!recoverWorkingDirty && scope.mapBank && scope.mapBank.workingDirty === true)) { return; }
              if (blob.factoryResetIntents && typeof blob.factoryResetIntents === 'object') {
                var resetPrefix = factoryResetIntentPrefixFor(scope.carName);
                Object.keys(blob.factoryResetIntents).forEach(function (key) {
                  if (key.indexOf(resetPrefix) === 0) { durRestoreKey(key, blob.factoryResetIntents[key]); }
                });
              }
              var recoveredResetIntent = factoryResetIntentForState({ carName: scope.carName, base: scope.base, vehId: scope.linkedVehId });
              // A durable confirmed reset outranks the old durable autosave. Restore the marker and
              // supporting user data, then reissue Factory Reset; never replay the pre-reset tune.
              if (!recoveredResetIntent) {
                if (recoverBrokenPrimary != null) {
                  // V2.9.7 wave 4 (Liqurdz's BBG report): the primary exists but no reader accepts
                  // it. Only a DIFFERENT durable autosave can help - an identical string is equally
                  // broken - and it must pass the same reader gates itself: swapping rejected bytes
                  // for other rejected bytes would destroy the primary for nothing. On any no-help
                  // outcome leave every key alone and stop: loadAutosave already presented _prev or
                  // defaults, and re-running it below would re-apply that same state a second time
                  // (and re-invalidate learned mixture evidence) for no gain.
                  if (typeof blob.autosave !== 'string' || blob.autosave === recoverBrokenPrimary ||
                      autosaveRawIsReaderRejected(blob.autosave)) { return; }
                  try {
                    localStorage.setItem(calKey(), blob.autosave);
                    if (localStorage.getItem(calKey()) !== blob.autosave) { return; }
                  } catch (eBrokenWrite) { return; }
                  setToast('Stored calibration was unreadable - restored the durable backup copy');
                  durRestoreKey(prevCalKey(), blob.autosavePrev);
                } else {
                  durRestoreKey(calKey(), blob.autosave);
                  durRestoreKey(prevCalKey(), blob.autosavePrev);   // V2.9.7: the _prev generation recovers too
                }
              }
              if (blob.engineAutosaves && typeof blob.engineAutosaves === 'object') {
                var enginePrefix = calKey() + '_fp_';
                Object.keys(blob.engineAutosaves).forEach(function (key) {
                  if (key.indexOf(enginePrefix) === 0) { durRestoreKey(key, blob.engineAutosaves[key]); }
                });
              }
              if (blob.lessonRestores && typeof blob.lessonRestores === 'object') {
                Object.keys(blob.lessonRestores).forEach(function (key) { durRestoreKey(key, blob.lessonRestores[key]); });
              } else {
                // Backward-compatible single-journal field. Put it under the identity it names,
                // even if the player changed engines before cache recovery ran.
                var lessonRaw = blob.lessonRestore, lessonKey = lessonJournalKey();
                try {
                  var lessonObj = typeof lessonRaw === 'string' ? JSON.parse(lessonRaw) : lessonRaw;
                  if (lessonObj && lessonObj.carName) { lessonKey = lessonJournalKeyFor(lessonObj.carName, lessonObj.engineFp); }
                } catch (e2) {}
                durRestoreKey(lessonKey, lessonRaw);
              }
              durRestoreKey(libKey(), blob.library);
              durRestoreKey(notesKey(), blob.notes);
              durRestoreKey(dynoBaseKey(), blob.dynoBase);
              if (blob.slots) { for (var i = 0; i < 4; i++) { durRestoreKey(slotKey(i), blob.slots[i]); } }
              rebuildSlots(); loadNotes();
              if (recoveredResetIntent) {
                scope.tuneName = 'Factory reset pending';
                scope.storeRequired = true;
                frArmRemote = false;   // durable-recovery arm: wildcard stays trusted
                // V2.9.7 wave-2 review: same valve discipline as the firstLink recovery arm above.
                if (frValveTimer) { clearTimeout(frValveTimer); frValveTimer = null; }
                scope.awaitingFactorySettings = true;
                frSeqAtReset = typeof scope.lastFrSeq === 'number' ? scope.lastFrSeq : null;
                localCalibrationMutationGeneration += 1;
                callController('factoryReset()');
                setToast('Completing the confirmed Factory Reset from durable recovery...');
              } else {
                loadAutosave(loadOptions);
              }
              loadDynoBaseline();
              auditMapBankBodies();   // V2.9.7 wave-2 review: recovery is async - re-audit once it lands
            });
          }
          var readDurable = function (path, onMissing) {
            bngApi.engineLua('jsonReadFile(' + luaQuote(path) + ')', function (blob) {
              if (!blob || typeof blob !== 'object') { if (onMissing) { onMissing(); } return; }
              applyDurableRecovery(blob);
            });
          };
          if (recoverAltPath) { readDurable(recoverPath, function () { readDurable(recoverAltPath, null); }); }
          else { readDurable(recoverPath, null); }
        } catch (e) {}
      }
      // old tunes stored Fuel Main as a 0-centered % trim; V1.5 uses a 1.0-centered scalar
      // (1.0 = follow lambda, 1.10 = +10%). Auto-convert old saves on load so nobody loses work.
      // Detect old format: a valid scalar is always 0.5-2.0, so anything below 0.4 or above 2.5 is a trim.
      function migrateFuelMap(fuel) {
        if (!fuel || !fuel.length) { return; }
        var lo = Infinity, hi = -Infinity;
        for (var r = 0; r < fuel.length; r++) { for (var c = 0; c < fuel[r].length; c++) { var v = fuel[r][c]; if (v < lo) { lo = v; } if (v > hi) { hi = v; } } }
        if (lo < 0.4 || hi > 2.5) {
          for (var r2 = 0; r2 < fuel.length; r2++) { for (var c2 = 0; c2 < fuel[r2].length; c2++) { fuel[r2][c2] = Math.round((1 + fuel[r2][c2] / 100) * 100) / 100; } }
        }
      }
      function validSnapshotAxis(axis, kind) {
        // V3.0.7: length bound reads scope.axisLimits (shared file -> controller -> here). THIS gate is
        // the one that matters most: it backs prepareSnapshotAxes, which gates BOTH readSnapshot on
        // load AND validatedSnapshotForPersistence on Store. Too tight and a wide-grid tune cannot be
        // saved at all - a strictly worse failure than being unable to load one.
        if (!Array.isArray(axis) || axis.length < scope.axisLimits.min || axis.length > scope.axisLimits.max) { return false; }
        // V3.0: MAP top 1000, was 600 - auto-fit grows axes past 600 on 100+ psi tunes, and this
        // validator rejecting them made every such saved tune unloadable (review-fleet critical).
        // Keep in sync with oeProTuneShared mapMeta.boost.max + R.setAxisValues.
        var lo = kind === 'rpm' ? 100 : (kind === 'alpha' ? 0 : 5);
        var hi = kind === 'rpm' ? 30000 : (kind === 'alpha' ? 100 : 1000);
        for (var i = 0; i < axis.length; i++) {
          var v = Number(axis[i]);
          if (!isFinite(v) || v < lo || v > hi || (i > 0 && v <= Number(axis[i - 1]))) { return false; }
          axis[i] = v;
        }
        return true;
      }
      function snapshotTableShape(table) {
        if (!Array.isArray(table) || !table.length || !Array.isArray(table[0]) || !table[0].length) { return null; }
        var cols = table[0].length;
        for (var r = 0; r < table.length; r++) { if (!Array.isArray(table[r]) || table[r].length !== cols) { return null; } }
        return { rows: table.length, cols: cols };
      }
      // Axis-less saves predate custom grids. There is no honest way to recover an engine-specific
      // axis that was never serialized, so use the fixed legacy 6x7 family rather than borrowing the
      // unrelated grid currently open in the UI. Raised limits did not rebuild those historical grids.
      function legacyFactoryRpmAxis(snap, cols) {
        if (cols !== 7) { return null; }
        var lo = 800, hi = 7000;
        var axis = [];
        for (var c = 0; c < cols; c++) { axis.push(Math.round((lo + (hi - lo) * c / (cols - 1)) / 100) * 100); }
        return axis;
      }
      function snapshotTableMax(table) {
        var top = -Infinity;
        if (!Array.isArray(table)) { return top; }
        for (var r = 0; r < table.length; r++) { for (var c = 0; c < (table[r] || []).length; c++) { top = Math.max(top, Number(table[r][c])); } }
        return top;
      }
      function legacyFactoryMapAxis(snap, rows) {
        if (rows !== 6) { return null; }
        var boostTop = snapshotTableMax(snap && snap.maps && snap.maps.boost);
        var turbo = typeof snap.hasTurbo === 'boolean' ? snap.hasTurbo : boostTop > 110;
        if (!turbo) { return [25, 40, 55, 70, 85, 100]; }
        var topKpa = Math.max(220, isFinite(boostTop) ? boostTop : 220);
        topKpa = Math.max(220, Math.min(1000, Math.round(topKpa / 5) * 5));   // V3.0: 1000, was 600 - keep in sync with oeProTuneShared mapMeta.boost.max
        var axis = [40, 80];
        for (var i = 0; i < 4; i++) { axis.push(Math.round((100 + (topKpa - 100) * i / 3) / 5) * 5); }
        return axis;
      }
      function defaultAlphaNAxis(rows) {
        var canonical = [0, 2, 5, 12, 30, 100], out = [];
        if (!rows || rows < 2) { return null; }
        for (var r = 0; r < rows; r++) {
          var pos = r * (canonical.length - 1) / (rows - 1);
          var lo = Math.floor(pos), hi = Math.min(canonical.length - 1, lo + 1), f = pos - lo;
          out.push(canonical[lo] + (canonical[hi] - canonical[lo]) * f);
        }
        return out;
      }
      function snapshotBracket(axis, value) {
        if (value <= axis[0]) { return { lo: 0, hi: 0, f: 0 }; }
        var last = axis.length - 1;
        if (value >= axis[last]) { return { lo: last, hi: last, f: 0 }; }
        for (var i = 0; i < last; i++) {
          if (value <= axis[i + 1]) { return { lo: i, hi: i + 1, f: (value - axis[i]) / (axis[i + 1] - axis[i]) }; }
        }
        return { lo: last, hi: last, f: 0 };
      }
      function resampleSnapshotTable(table, sourceRpm, sourceMap, targetRpm, targetMap, name) {
        var out = [];
        for (var r = 0; r < targetMap.length; r++) {
          out[r] = [];
          var mb = snapshotBracket(sourceMap, targetMap[r]);
          for (var c = 0; c < targetRpm.length; c++) {
            var rb = snapshotBracket(sourceRpm, targetRpm[c]);
            var v00 = Number(table[mb.lo][rb.lo]), v01 = Number(table[mb.lo][rb.hi]);
            var v10 = Number(table[mb.hi][rb.lo]), v11 = Number(table[mb.hi][rb.hi]);
            var low = v00 + (v01 - v00) * rb.f, high = v10 + (v11 - v10) * rb.f;
            var value = low + (high - low) * mb.f;
            // Match the controller's bilinear refit exactly. Display/edit step is a UI affordance,
            // not storage precision; rounding saved slots here made a clean axis refit look dirty.
            out[r][c] = value;
          }
        }
        return out;
      }
      function defaultSnapshotTable(name, rpmAxis, mapAxis) {
        var defaults = { fuel: 1, ignition: 0, boost: 100, lambda: 1, ve: 85, wgduty: 50 };
        var mt = scope.mapMeta && scope.mapMeta[name], value = mt && isFinite(Number(mt.default)) ? Number(mt.default) : defaults[name];
        return mapAxis.map(function () { return rpmAxis.map(function () { return value; }); });
      }
      function completeSnapshotMaps(snap) {
        var names = ['fuel', 'ignition', 'boost', 'lambda', 've', 'wgduty'];
        var required = { fuel: true, ignition: true, boost: true, lambda: true };
        var sourceMaps = scope.maps || {}, sourceRpm = (scope.rpmAxis || []).slice(), sourceMap = (scope.mapAxis || []).slice();
        var sourceAxesValid = validSnapshotAxis(sourceRpm, 'rpm') && validSnapshotAxis(sourceMap, 'map');
        var completed = {};
        for (var n = 0; n < names.length; n++) {
          var name = names[n], existing = snap.maps[name];
          if (Array.isArray(existing)) { completed[name] = existing; continue; }
          if (required[name]) { return false; }
          var source = sourceMaps[name], shape = snapshotTableShape(source);
          if (sourceAxesValid && shape && shape.rows === sourceMap.length && shape.cols === sourceRpm.length) {
            completed[name] = resampleSnapshotTable(source, sourceRpm, sourceMap, snap.rpmAxis, snap.mapAxis, name);
          } else {
            completed[name] = defaultSnapshotTable(name, snap.rpmAxis, snap.mapAxis);
          }
        }
        snap.maps = completed;
        return true;
      }
      function prepareSnapshotAxes(snap) {
        var shape = snapshotTableShape(snap.maps && snap.maps.fuel);
        if (!shape) { return false; }
        if (snap.settings && Object.prototype.toString.call(snap.settings) === '[object Object]' && snap.settings.loadSource === undefined) {
          snap.settings.loadSource = 1;
        }
        if (!snap.rpmAxis) { snap.rpmAxis = legacyFactoryRpmAxis(snap, shape.cols); snap._legacyAxesMigrated = true; }
        if (!snap.mapAxis) { snap.mapAxis = legacyFactoryMapAxis(snap, shape.rows); snap._legacyAxesMigrated = true; }
        if (!snap.alphaNAxis) { snap.alphaNAxis = defaultAlphaNAxis(shape.rows); snap._legacyAlphaAxisMigrated = true; }
        if (!validSnapshotAxis(snap.rpmAxis, 'rpm') || !validSnapshotAxis(snap.mapAxis, 'map') ||
            !validSnapshotAxis(snap.alphaNAxis, 'alpha') || snap.alphaNAxis.length !== shape.rows) { return false; }
        if (snap.hasTurbo === undefined) { snap.hasTurbo = Math.max.apply(null, snap.mapAxis) > 110; }
        if (!completeSnapshotMaps(snap)) { return false; }
        var rows = snap.mapAxis.length, cols = snap.rpmAxis.length, names = Object.keys(snap.maps || {});
        for (var n = 0; n < names.length; n++) {
          var table = snap.maps[names[n]];
          if (!Array.isArray(table) || table.length !== rows) { return false; }
          for (var r = 0; r < table.length; r++) {
            if (!Array.isArray(table[r]) || table[r].length !== cols) { return false; }
            for (var c = 0; c < table[r].length; c++) {
              var cell = Number(table[r][c]); if (!isFinite(cell)) { return false; } table[r][c] = cell;
            }
          }
        }
        return true;
      }
      var MAP_VALUE_LIMITS = {
        fuel: { min: 0.5, max: 2 }, ignition: { min: -20, max: 30 }, boost: { min: 60, max: 1000 },   // V3.0: boost max 1000, was 600 - sync oeProTuneShared
        lambda: { min: 0.68, max: 1.10 }, ve: { min: 30, max: 130 }, wgduty: { min: 0, max: 100 }
      };
      // shapeOnly=true checks structure and finiteness but NOT value ranges - used where the caller
      // still has to run a unit migration before the numbers are meant to be in range.
      function mapSetValidationIssue(maps, expectedRows, expectedCols, label, shapeOnly) {
        label = label || 'Working tune';
        if (!maps || Object.prototype.toString.call(maps) !== '[object Object]') { return label + ' maps are missing'; }
        for (var n = 0; n < MAP_BANK_MAP_NAMES.length; n++) {
          var name = MAP_BANK_MAP_NAMES[n], mt = scope.mapMeta && scope.mapMeta[name] || MAP_VALUE_LIMITS[name];
          var lo = Number(mt && mt.min), hi = Number(mt && mt.max), table = maps[name];
          if (!Array.isArray(table)) { return label + ' is missing the "' + name + '" table'; }
          if (expectedRows !== undefined && expectedRows !== null && table.length !== expectedRows) {
            return label + ' "' + name + '" table has ' + table.length + ' rows; expected ' + expectedRows;
          }
          for (var r = 0; r < table.length; r++) {
            if (!Array.isArray(table[r])) { return label + ' "' + name + '" row ' + (r + 1) + ' is missing'; }
            if (expectedCols !== undefined && expectedCols !== null && table[r].length !== expectedCols) {
              return label + ' "' + name + '" row ' + (r + 1) + ' has ' + table[r].length + ' cells; expected ' + expectedCols;
            }
            for (var c = 0; c < table[r].length; c++) {
              var value = Number(table[r][c]);
              if (!isFinite(value)) { return label + ' "' + name + '" R' + (r + 1) + ' C' + (c + 1) + ' is not a finite number'; }
              if (shapeOnly) { continue; }
              if (!isFinite(lo) || !isFinite(hi)) { return label + ' "' + name + '" has no validation limits'; }
              if (value < lo - 1e-9 || value > hi + 1e-9) {
                return label + ' "' + name + '" R' + (r + 1) + ' C' + (c + 1) + ' is ' + value + '; allowed ' + lo + '–' + hi;
              }
            }
          }
        }
        return null;
      }
      function snapshotMapsInRange(maps) {
        return !mapSetValidationIssue(maps, null, null, 'Working tune');
      }
      function validSnapshotSettings(settings) {
        return settings === undefined || settings === null || Object.prototype.toString.call(settings) === '[object Object]';
      }
      var mapBankValidationReason = null;
      var mapBankRepairNotes = [];
      function rejectMapBankPackage(reason) {
        mapBankValidationReason = reason || 'Switchable MAP package is invalid';
        return null;
      }
      function normalizeMapBankMaps(rawMaps, rpmAxis, mapAxis, label) {
        label = label || 'Saved MAP';
        // ORDER MATTERS: shape-check, coerce, MIGRATE, then range-check - the same order 3.0.1 used.
        // migrateFuelMap converts a legacy 0-centred % Fuel Main table into the 1.0-centred scalar
        // form, and pre-migration values (0, -5, +10) sit far outside the 0.5-2.0 scalar range. Range
        // checking the RAW cells first would reject exactly the old saves this release set out to
        // keep loadable. Not reachable through today's callers, but it is one refactor away from
        // being reachable, so the safe order is restored rather than relied upon.
        var shapeIssue = mapSetValidationIssue(rawMaps, mapAxis.length, rpmAxis.length, label, true);
        if (shapeIssue) { mapBankValidationReason = shapeIssue; return null; }
        var maps = copyMapBankMaps(rawMaps);
        if (!maps) { mapBankValidationReason = label + ' tables could not be copied'; return null; }
        for (var n = 0; n < MAP_BANK_MAP_NAMES.length; n++) {
          var table = maps[MAP_BANK_MAP_NAMES[n]];
          for (var r = 0; r < table.length; r++) {
            for (var c = 0; c < table[r].length; c++) {
              table[r][c] = Number(table[r][c]);
            }
          }
        }
        migrateFuelMap(maps.fuel);
        var issue = mapSetValidationIssue(maps, mapAxis.length, rpmAxis.length, label);
        if (issue) { mapBankValidationReason = issue; return null; }
        return maps;
      }
      function resampleMapBankSlots(oldRpmAxis, oldMapAxis, newRpmAxis, newMapAxis, oldAlphaNAxis, newAlphaNAxis, alphaModeOverride) {
        if (!validSnapshotAxis(oldRpmAxis, 'rpm') || !validSnapshotAxis(oldMapAxis, 'map') ||
            !validSnapshotAxis(newRpmAxis, 'rpm') || !validSnapshotAxis(newMapAxis, 'map')) { return; }
        var alphaMode = alphaModeOverride === undefined ? scope.alphaNActive() : !!alphaModeOverride;
        if (alphaMode && (!validSnapshotAxis(oldAlphaNAxis, 'alpha') || !validSnapshotAxis(newAlphaNAxis, 'alpha'))) { return; }
        (scope.mapBank.slots || []).forEach(function (slot) {
          if (!slot.configured || !slot.data || !slot.data.maps) { return; }
          var next = {};
          for (var i = 0; i < MAP_BANK_MAP_NAMES.length; i++) {
            var name = MAP_BANK_MAP_NAMES[i], shape = snapshotTableShape(slot.data.maps[name]);
            if (!shape || shape.rows !== oldMapAxis.length || shape.cols !== oldRpmAxis.length) { return; }
            var useAlpha = alphaMode && ['fuel', 'ignition', 'lambda', 've'].indexOf(name) >= 0;
            next[name] = resampleSnapshotTable(slot.data.maps[name], oldRpmAxis,
              useAlpha ? oldAlphaNAxis : oldMapAxis, newRpmAxis, useAlpha ? newAlphaNAxis : newMapAxis, name);
          }
          slot.data.maps = next;
          slot.peakBoostKpa = peakBoostFromMaps(next);
        });
      }
      function cleanMapBankMapsMatch(left, right) {
        if (!left || !right) { return false; }
        for (var i = 0; i < MAP_BANK_MAP_NAMES.length; i++) {
          var name = MAP_BANK_MAP_NAMES[i];
          if (!calibrationArrayMatches(left[name], right[name])) { return false; }
        }
        return true;
      }
      function cleanMapBankSettingsMatch(left, right) {
        if (!left || !right) { return false; }
        for (var i = 0; i < MAP_BANK_SETTING_KEYS.length; i++) {
          var key = MAP_BANK_SETTING_KEYS[i];
          if (!scalarEchoMatches(left[key], right[key])) { return false; }
        }
        return true;
      }
      function normalizeMapBankPackage(snap) {
        mapBankValidationReason = null;
        mapBankRepairNotes = [];
        var raw = snap.mapBank;
        var rawVersion = raw && Number(raw.v);
        if (raw !== undefined && raw !== null && (!raw || (rawVersion !== 1 && rawVersion !== 2) || !Array.isArray(raw.slots))) {
          return rejectMapBankPackage('Switchable MAP package header/version is invalid');
        }
        var settingsIssue = performanceSettingsIssue(snap.settings, true, 'Working tune');
        if (settingsIssue) { return rejectMapBankPackage(settingsIssue); }
        // Recover the known V2.9.x/V3.0.1 metadata poison before applying the strict reader. A
        // non-flat table cannot truthfully be the untouched factory seed; clearing only that flag
        // preserves every calibration value and makes old Quick Save/Library packages loadable.
        mapBankRepairNotes = repairStaleFactoryBoostClaims(snap);
        raw = snap.mapBank;
        var slots = [1, 2, 3, 4].map(emptyMapBankSlot);
        var fallbackSettings = angular.extend({}, MAP_BANK_SETTING_DEFAULTS, scope.settings || {}, snap.settings || {});

        if (raw) {
          var seen = {};
          for (var i = 0; i < raw.slots.length; i++) {
            var rs = raw.slots[i] || {}, index = Math.floor(Number(rs.index));
            if (!(index >= 1 && index <= 4) || seen[index]) { continue; }
            seen[index] = true;
            var slot = slots[index - 1];
            slot.name = cleanMapBankName(rs.name, index); slot.draftName = slot.name;
            if (rs.configured === true) {
              if (!rs.data) { return rejectMapBankPackage('M' + index + ' is configured but its saved body is missing'); }
              settingsIssue = performanceSettingsIssue(rs.data.settings, true, 'M' + index);
              if (!validPerformanceSettings(rs.data.settings, true) || settingsIssue) { return rejectMapBankPackage(settingsIssue); }
              var maps = normalizeMapBankMaps(rs.data.maps, snap.rpmAxis, snap.mapAxis, 'M' + index);
              if (!maps) { return null; }
              slot.configured = true;
              slot.peakBoostKpa = peakBoostFromMaps(maps);
              slot.data = { maps: maps, settings: performanceSettingsFrom(rs.data.settings, fallbackSettings) };
              settingsIssue = performanceSettingsIssue(slot.data.settings, false, 'M' + index);
              if (settingsIssue) { return rejectMapBankPackage(settingsIssue); }
              if (typeof rs.data.boostTableFactory === 'boolean') {
                if (rs.data.boostTableFactory && !boostTableIsFactoryFlat(maps.boost)) {
                  return rejectMapBankPackage('M' + index + ' incorrectly claims its tuned Boost Target is still factory');
                }
                slot.boostTableFactory = rs.data.boostTableFactory; slot.data.boostTableFactory = rs.data.boostTableFactory;
              }
            }
          }
        }

        // Pre-map-bank tunes become MAP 1. A package with no configured
        // bank also gets a usable MAP 1 rather than an impossible active selection.
        var configured = slots.filter(function (slot) { return slot.configured; });
        if (!raw || !configured.length) {
          slots[0].configured = true;
          slots[0].name = 'MAP 1'; slots[0].draftName = 'MAP 1';
          slots[0].data = { maps: angular.copy(snap.maps), settings: performanceSettingsFrom(snap.settings, fallbackSettings) };
          settingsIssue = performanceSettingsIssue(slots[0].data.settings, false, 'M1');
          if (settingsIssue) { return rejectMapBankPackage(settingsIssue); }
          var liveSlot = mapBankSlot((scope.mapBank && scope.mapBank.active) || 1);
          if (!raw && liveSlot && liveSlot.boostTableFactory === true && boostTablesMatch(snap.maps && snap.maps.boost, scope.maps && scope.maps.boost)) {
            slots[0].boostTableFactory = true;
            slots[0].data.boostTableFactory = true;
          }
          slots[0].peakBoostKpa = peakBoostFromMaps(slots[0].data.maps);
          configured = [slots[0]];
        }

        var active = raw ? Math.floor(Number(raw.active)) : 1;
        if (!(active >= 1 && active <= 4) || !slots[active - 1].configured) { active = configured[0].index; }
        var def = raw ? Math.floor(Number(raw.default)) : active;
        if (!(def >= 1 && def <= 4) || !slots[def - 1].configured) { def = active; }

        var workingDirty = rawVersion === 2 && raw.workingDirty === true;
        var workingFactory = rawVersion === 2 && typeof raw.workingBoostTableFactory === 'boolean'
          ? raw.workingBoostTableFactory
          : undefined;

        // Never label a hand-edited/inconsistent v2 package clean. Its top-level
        // calibration is what will run, so preserve it as an unsaved draft rather
        // than silently replacing it with (or claiming it equals) the saved body.
        if (rawVersion === 2 && !workingDirty) {
          var cleanSaved = slots[active - 1].data;
          var cleanWorkingSettings = performanceSettingsFrom(snap.settings, fallbackSettings);
          var cleanFactory = typeof workingFactory === 'boolean' ? workingFactory : (cleanSaved && cleanSaved.boostTableFactory === true);
          if (!cleanSaved || !cleanMapBankMapsMatch(cleanSaved.maps, snap.maps) ||
              !cleanMapBankSettingsMatch(cleanSaved.settings, cleanWorkingSettings) ||
              (cleanSaved.boostTableFactory === true) !== (cleanFactory === true)) {
            workingDirty = true;
          }
        }

        // v1's documented invariant was that the top-level calibration replaced
        // its active slot. Preserve that migration exactly, but never perform this
        // rewrite for v2: in v2 the top level is the live draft and slots are saved MAPs.
        if (!raw || rawVersion === 1) {
          var activeSlot = slots[active - 1], activeFactory = activeSlot.data && activeSlot.data.boostTableFactory;
          activeSlot.data = { maps: angular.copy(snap.maps), settings: performanceSettingsFrom(snap.settings, fallbackSettings) };
          settingsIssue = performanceSettingsIssue(activeSlot.data.settings, false, 'M' + active);
          if (settingsIssue) { return rejectMapBankPackage(settingsIssue); }
          if (typeof activeFactory === 'boolean') { activeSlot.boostTableFactory = activeFactory; activeSlot.data.boostTableFactory = activeFactory; }
          activeSlot.peakBoostKpa = peakBoostFromMaps(activeSlot.data.maps);
          if (workingFactory === undefined) { workingFactory = activeSlot.data.boostTableFactory === true; }
        }
        if (workingFactory === undefined) {
          var savedFactory = slots[active - 1].data && slots[active - 1].data.boostTableFactory;
          workingFactory = !workingDirty && savedFactory === true;
        }
        if (workingFactory === true && !boostTableIsFactoryFlat(snap.maps && snap.maps.boost)) {
          // V2.9.7 wave 4 (Liqurdz's BBG report): old builds persisted workingBoostTableFactory:true
          // beside a tuned (non-flat) working boost table (the broadcast-adoption asymmetry wave 1
          // now blocks at write time). Rejecting the whole package HERE threw every existing
          // victim back to factory defaults on replay. The working tables themselves are valid -
          // only the flat claim is false - so demote instead of rejecting: drop the factory claim
          // and surface the package as an honest unsaved draft. Nothing below this line reads
          // workingFactory/workingDirty except the return value, and every caller already handles
          // dirty packages (applySnapshot preserves a dirty package's active association).
          // Persisted per-slot bodies keep the strict flat check in the slot loop above; the slot
          // fix-up below only fires when the v1 migration itself copied these same non-flat
          // working maps over the active slot's body while preserving its stale factory flag -
          // without it the healed package would trip that strict per-slot check on its next
          // save/replay (Store blocked until the MAP is re-saved).
          workingFactory = false;
          workingDirty = true;
          var demotedSlot = slots[active - 1];
          if (demotedSlot && demotedSlot.boostTableFactory === true &&
              !boostTableIsFactoryFlat(demotedSlot.data && demotedSlot.data.maps && demotedSlot.data.maps.boost)) {
            demotedSlot.boostTableFactory = false;
            if (demotedSlot.data) { demotedSlot.data.boostTableFactory = false; }
          }
        }
        return {
          v: 2, active: active, default: def, workingDirty: workingDirty,
          workingBoostTableFactory: workingFactory === true,
          workingPeakBoostKpa: peakBoostFromMaps(snap.maps), slots: slots
        };
      }
      var snapshotValidationReason = null;
      var lastPersistenceRepairs = [];
      function adoptFactoryBoostRepairs(repairs) {
        if (!repairs || !repairs.length || !scope.mapBank) { return; }
        for (var i = 0; i < repairs.length; i++) {
          var repair = repairs[i];
          if (repair.kind === 'working') {
            scope.mapBank.workingBoostTableFactory = false;
          } else if (repair.kind === 'slot') {
            var slot = mapBankSlot(repair.index);
            if (slot) {
              slot.boostTableFactory = false;
              if (slot.data) { slot.data.boostTableFactory = false; }
            }
          }
        }
        scope.storeRequired = true;
        cacheCurrentMapBankBodies();
        // Repair the vehicle authority too. This call changes metadata only; the Lua boundary
        // independently proves each claimed-factory table is non-flat before clearing its flag.
        callController('repairMapBankFactoryClaims()');
      }
      function persistenceRepairSuffix() {
        return lastPersistenceRepairs.length ? ' (repaired stale Boost Target metadata)' : '';
      }
      // opts.adoptRepairs - only the Store ECU path may push a repair back into the live UI and the
      //   controller. A Quick Save, a Library save or a tune-file Export is the user COPYING the
      //   calibration somewhere; it must not flip STORE REQUIRED, mark the working tune EDITED or
      //   bump the controller's calibration revision as a side effect of being asked for a copy.
      // opts.requireCompleteBank - a bodyless configured slot means the recovery package would be
      //   silently incomplete. That must block the writers that OVERWRITE a recovery layer (Store
      //   ECU, autosave). It must NOT block Export: when Store is already blocked for this exact
      //   reason, exporting to a file is the user's last way to rescue the tune, and refusing it
      //   too closed every door at once.
      function validatedSnapshotForPersistence(opts) {
        opts = opts || {};
        snapshotValidationReason = null;
        lastPersistenceRepairs = [];
        if (opts.requireCompleteBank !== false) {
          for (var slotIndex = 1; slotIndex <= 4; slotIndex++) {
            var liveSlot = mapBankSlot(slotIndex);
            if (liveSlot && liveSlot.configured === true && !mapBankControllerData(liveSlot)) {
              snapshotValidationReason = 'M' + slotIndex + ' "' + cleanMapBankName(liveSlot.name, slotIndex)
                + '" has not re-synced its saved body; select or re-Save M' + slotIndex + ' first';
              return null;
            }
          }
        }
        var snap;
        try { snap = snapshot(); } catch (eSnapshot) {
          snapshotValidationReason = 'the live calibration could not be captured';
          return null;
        }
        if (!snap || !snap.maps) {
          snapshotValidationReason = 'the live calibration tables are missing';
          return null;
        }
        if (!validSnapshotSettings(snap.settings)) {
          snapshotValidationReason = 'the live settings are not a valid object';
          return null;
        }
        if (!prepareSnapshotAxes(snap)) {
          snapshotValidationReason = 'the live axes or table dimensions are inconsistent';
          return null;
        }
        migrateFuelMap(snap.maps.fuel);
        var mapIssue = mapSetValidationIssue(snap.maps, snap.mapAxis.length, snap.rpmAxis.length, 'Working tune');
        if (mapIssue) {
          snapshotValidationReason = mapIssue;
          return null;
        }
        var normalized = normalizeMapBankPackage(snap);
        if (!normalized) {
          snapshotValidationReason = mapBankValidationReason || 'Switchable MAP package is invalid';
          return null;
        }
        // normalizeMapBankPackage repairs only the proven-safe stale factory-flag contradiction.
        // The repaired copy is always what gets written; adopting it into the LIVE UI + controller
        // is a separate, deliberate act reserved for Store ECU (see opts.adoptRepairs above).
        lastPersistenceRepairs = mapBankRepairNotes.slice();
        if (lastPersistenceRepairs.length && opts.adoptRepairs) { adoptFactoryBoostRepairs(lastPersistenceRepairs); }
        return snap;
      }
      function hydrateMapBankBodiesFromAutosave(state) {
        if (IS_MIRROR) { return; }
        var raw = lsGet(calKey()), snap;
        try { snap = raw ? JSON.parse(raw) : null; } catch (e) { return; }
        if (!snap || !snap.mapBank || !snap.maps) { return; }
        if (snap.engineFp && scope.base && scope.base.engineFp && !engineIdentityCompatible(snap, scope.base)) { return; }
        snap = angular.copy(snap);
        if (!prepareSnapshotAxes(snap) || !snapshotMapsInRange(snap.maps)) { return; }
        var pkg = normalizeMapBankPackage(snap);
        if (!pkg) { return; }
        var snapshotAlphaMode = Number((snap.settings || {}).loadSource) === 2 &&
          !((scope.t && scope.t.isDiesel === true) || (scope.base && scope.base.isDiesel === true));
        // REVIEW FIX 2026-08-03: the alpha term belongs here for the same reason it belongs in the
        // draft-load guard. Fuel/ignition/lambda/VE are indexed by alphaNAxis in Alpha-N mode (see
        // the useAlpha branch below), so an autosave whose alpha axis differs needs resampling even
        // when rpm and map match exactly. Without it those four tables were copied across unresampled
        // and their rows silently relabelled. Gated on snapshotAlphaMode because the alpha axis is
        // inert in Speed Density - a stale alpha axis there must not force a pointless refit.
        var tableAxesSame = axesEqualTol(snap.rpmAxis, scope.rpmAxis) &&
          axesEqualTol(snap.mapAxis, scope.mapAxis) &&
          (!snapshotAlphaMode || axesEqualTol(snap.alphaNAxis, scope.alphaNAxis));
        pkg.slots.forEach(function (stored) {
          var live = mapBankSlot(stored.index);
          if (!live || !live.configured || !stored.configured || !stored.data) { return; }
          if (cleanMapBankName(live.name, live.index) !== cleanMapBankName(stored.name, stored.index)) { return; }
          var data = angular.copy(stored.data);
          if (!tableAxesSame) {
            var refit = {};
            for (var i = 0; i < MAP_BANK_MAP_NAMES.length; i++) {
              var name = MAP_BANK_MAP_NAMES[i];
              var useAlpha = snapshotAlphaMode && ['fuel', 'ignition', 'lambda', 've'].indexOf(name) >= 0;
              refit[name] = resampleSnapshotTable(data.maps[name], snap.rpmAxis,
                useAlpha ? snap.alphaNAxis : snap.mapAxis, scope.rpmAxis,
                useAlpha ? scope.alphaNAxis : scope.mapAxis, name);
            }
            data.maps = refit;
          }
          var storedFactory = data.boostTableFactory === true || stored.boostTableFactory === true;
          if ((live.boostTableFactory === true) !== storedFactory) { return; }
          var storedPeak = peakBoostFromMaps(data.maps);
          // V2.9.7 (Store-wipe wave 2, review find): the flat 0.75 kPa gate armed PERMANENTLY after
          // any axis change/auto-fit - the axis autosave persists UI-resampled bodies whose peaks
          // lawfully drift a few kPa from the controller's own refit math, so every later relink
          // rejected the slot forever (the trap that set up the wipe). Identity is carried by the
          // name/factory/clean-active checks; the peak is a heuristic - allow honest resample drift
          // (2%), still reject genuinely different calibrations. Wave 1's refuse-to-demote guard
          // turns any residual mistake into a BLOCKED, named Store instead of silent data loss.
          if (isFinite(Number(live.peakBoostKpa)) && Math.abs(storedPeak - Number(live.peakBoostKpa)) > Math.max(0.75, storedPeak * 0.02)) { return; }
          // A clean controller says its active saved body is exactly the accompanying live
          // calibration. Reject an old autosave body that only happens to share M1 metadata
          // (notably close-during-factory-reset), then seed from authoritative State instead.
          if (state && state.mapBank && state.mapBank.workingDirty !== true &&
              Number(stored.index) === Number(state.mapBank.active)) {
            var liveSettings = performanceSettingsFrom(state.settings, scope.settings);
            if (!cleanMapBankMapsMatch(data.maps, state.maps) ||
                !cleanMapBankSettingsMatch(data.settings, liveSettings) ||
                storedFactory !== (state.mapBank.workingBoostTableFactory === true)) { return; }
          }
          live.data = data;
          live.peakBoostKpa = storedPeak;
          if (typeof data.boostTableFactory === 'boolean') { live.boostTableFactory = data.boostTableFactory; }
        });
      }
      // V2.9.7 (Store-wipe wave 2, fix 4): LOUD hydration. Any saved MAP still configured (metadata)
      // but without a re-synced body after a relink pass is exactly the state the wave-1 guard blocks
      // Store over - name it persistently (banner) instead of leaving a silent red badge that invites
      // the fatal press. Cleared automatically once every configured slot has a body again (a Store
      // that passes the guard, or re-selecting/re-saving the slot).
      function auditMapBankBodies() {
        if (IS_MIRROR) { scope.mapBankHydrationWarning = null; return; }
        var missing = [];
        for (var a = 1; a <= 4; a++) {
          var audited = mapBankSlot(a);
          if (audited && audited.configured === true && !audited.data) { missing.push('M' + a); }
        }
        scope.mapBankHydrationWarning = missing.length
          ? (missing.join('/') + ' saved-map bodies could not be re-synced from the autosave - select each once (or re-Save it) so Store ECU can protect them')
          : null;
      }
      function installMapBankPackage(pkg) {
        scope.mapBank = {
          active: pkg.active, pending: null, pendingMode: 'none', pendingReload: false, pendingDiscard: false,
          workingDirty: pkg.workingDirty === true,
          workingBoostTableFactory: pkg.workingBoostTableFactory === true,
          workingPeakBoostKpa: Number(pkg.workingPeakBoostKpa) || 0,
          default: pkg.default,
          sequence: scope.mapBank && scope.mapBank.sequence || 0,
          slots: pkg.slots.map(function (slot) {
            var copy = angular.copy(slot);
            copy.name = cleanMapBankName(copy.name, copy.index);
            copy.draftName = copy.name;
            return copy;
          })
        };
      }
      function stageMapBankPackage(pkg, selectIndex, options) {
        if (IS_MIRROR) { return; }
        options = options || {};
        selectIndex = Math.floor(Number(selectIndex));
        if (!(selectIndex >= 1 && selectIndex <= 4) || !pkg.slots[selectIndex - 1] || !pkg.slots[selectIndex - 1].configured) { selectIndex = pkg.active; }
        var staged = {
          v: 2, active: pkg.active, default: pkg.default,
          workingDirty: pkg.workingDirty === true,
          workingBoostTableFactory: pkg.workingBoostTableFactory === true,
          slots: pkg.slots.map(function (slot) {
            var data = slot.configured ? mapBankControllerData(slot) : null;
            if (data) { delete data.name; }
            return { index: slot.index, name: cleanMapBankName(slot.name, slot.index), configured: !!data, data: data };
          })
        };
        var commandPrefix = 'configureMapBankPackage(jsonDecode(' + luaQuote(JSON.stringify(staged)) + '), false';
        if (options.confirm === true) {
          // The safety handshake proves one atomic package State. A follow-up selection would need a
          // second confirmation and is intentionally rejected here; lesson rollback always restores
          // the snapshot's exact active association.
          if (selectIndex !== pkg.active) { return false; }
          var expectedState = {
            vehicleId: typeof controllerVehicleRouteId === 'number' ? controllerVehicleRouteId : scope.linkedVehId,
            calibrationEpoch: String(scope.calibrationEpoch || ''),
            settings: angular.copy(scope.settings), maps: angular.copy(scope.maps),
            rpmAxis: scope.rpmAxis.slice(), mapAxis: scope.mapAxis.slice(), alphaNAxis: scope.alphaNAxis.slice(),
            mapBank: {
              active: staged.active, default: staged.default, workingDirty: staged.workingDirty,
              workingBoostTableFactory: staged.workingBoostTableFactory,
              slots: staged.slots.map(function (slot) {
                return {
                  index: slot.index, name: slot.name, configured: slot.configured,
                  boostTableFactory: !!(slot.data && slot.data.boostTableFactory === true)
                };
              })
            }
          };
          return beginPendingMapBankMutation({
            type: 'package', index: staged.active, expectedState: expectedState,
            onConfirmed: function () {
              if (typeof options.onConfirmed === 'function') { options.onConfirmed(); }
            },
            onRejected: options.onRejected
          }, function (token) { return commandPrefix + ', ' + luaQuote(token) + ')'; });
        }
        var accepted = callController(commandPrefix + ')');
        if (accepted === false) {
          setToast('Switchable MAP package was rejected by the ECU');
          return false;
        }
        if (selectIndex !== pkg.active) {
          callController('selectMapBank(' + selectIndex + ', true)');
        }
        return true;
      }

      // Consume controller metadata without ever guessing that a requested switch has
      // completed. Returns true when the accompanying settings/maps broadcast must win
      // over the normal mid-session UI ownership rules.
      function syncMapBankState(raw, firstLink) {
        if (!raw) { return false; }
        var wasWorkingDirtyForDecision = !!(scope.mapBank && scope.mapBank.workingDirty === true);
        var previousPendingForDecision = controllerMapBankPending;
        var previousPendingModeForDecision = controllerMapBankPendingMode;
        if (firstLink) {
          clearWorkingTunePrompt();
          pendingWorkingTuneContinuation = null;
        }
        var active = Math.floor(Number(raw.active));
        if (!(active >= 1 && active <= 4)) { active = controllerMapBankActive || 1; }
        var pending = Math.floor(Number(raw.pending));
        if (!(pending >= 1 && pending <= 4)) { pending = null; }
        var pendingMode = raw.pendingMode === 'reload' ? 'reload' : (raw.pendingMode === 'select' ? 'select' : 'none');
        if (pending === null) { pendingMode = 'none'; }
        var def = Math.floor(Number(raw.default));
        if (!(def >= 1 && def <= 4)) { def = active; }
        var sequence = Number(raw.sequence);
        if (!isFinite(sequence)) { sequence = controllerMapBankSequence === null ? 0 : controllerMapBankSequence; }
        var wasSeen = controllerMapBankSeen;
        var activeChanged = wasSeen && active !== controllerMapBankActive;
        var sequenceChanged = wasSeen && sequence !== controllerMapBankSequence;
        var metadataAuthoritative = !wasSeen || activeChanged || sequenceChanged || firstLink;
        var entries = Array.isArray(raw.banks) ? raw.banks : (Array.isArray(raw.entries) ? raw.entries : []);

        // Slot bodies belong to one exact car/engine package. On a fresh link, discard any
        // in-memory bodies left by the previously selected vehicle before consuming this ECU's
        // metadata. The matching per-car autosave hydrates them later; a brand-new car seeds only
        // its own MAP 1. Without this reset, a new car with no autosave could inherit the old car's
        // still-configured M1 body even though the controller metadata itself was new.
        if (firstLink) {
          if (mapBankSaveArmTimer) { clearTimeout(mapBankSaveArmTimer); mapBankSaveArmTimer = null; }
          scope.mapBankSaveArmed = null;
          if (mapBankClearArmTimer) { clearTimeout(mapBankClearArmTimer); mapBankClearArmTimer = null; }
          scope.mapBankClearArmed = null;
          (scope.mapBank.slots || []).forEach(function (slot) {
            slot.configured = false;
            slot.data = null;
            slot.peakBoostKpa = 0;
            slot.boostTableFactory = undefined;
          });
        }
        if (metadataAuthoritative) {
          for (var i = 0; i < entries.length; i++) {
            var entry = entries[i] || {}, index = Math.floor(Number(entry.index));
            if (!(index >= 1 && index <= 4)) { continue; }
            var slot = mapBankSlot(index);
            if (!slot) { continue; }
            slot.name = cleanMapBankName(entry.name, index); slot.draftName = slot.name;
            slot.configured = entry.configured === true;
            slot.peakBoostKpa = isFinite(Number(entry.peakBoostKpa)) ? Number(entry.peakBoostKpa) : (slot.peakBoostKpa || 0);
            if (typeof entry.boostTableFactory === 'boolean') {
              slot.boostTableFactory = entry.boostTableFactory;
            }
            if (!slot.configured) { slot.data = null; slot.boostTableFactory = undefined; }
          }
        }
        scope.mapBank.active = active;
        scope.mapBank.pending = pending;
        scope.mapBank.pendingMode = pendingMode;
        scope.mapBank.pendingReload = raw.pendingReload === true || pendingMode === 'reload';
        scope.mapBank.pendingDiscard = raw.pendingDiscard === true;
        scope.mapBank.workingDirty = raw.workingDirty === true;
        if (typeof raw.workingBoostTableFactory === 'boolean') { scope.mapBank.workingBoostTableFactory = raw.workingBoostTableFactory; }
        if (isFinite(Number(raw.workingPeakBoostKpa))) { scope.mapBank.workingPeakBoostKpa = Number(raw.workingPeakBoostKpa); }
        scope.mapBank.default = def;
        scope.mapBank.sequence = sequence;
        controllerMapBankSeen = true;
        controllerMapBankActive = active;
        controllerMapBankSequence = sequence;

        if (pending !== null && (pending !== controllerMapBankPending || pendingMode !== controllerMapBankPendingMode)) {
          // No instruction here: the block reason (lift throttle / boost / anti-lag...) rides
          // telemetry and is often not painted yet at toast time - the banner carries the live reason.
          setToast(pendingMode === 'reload'
            ? 'Revert to saved M' + pending + ' pending'
            : 'M' + pending + ' switch pending');
        }
        // A queued switch that vanishes without the active MAP moving was cancelled (the controller
        // drops it on any working-tune edit; reselecting the active MAP cancels it too). A reload
        // commit also ends with the same active index, but that path leaves workingDirty false.
        // A pending REVERT is always dirty while queued, so its cause is unknowable here - use a
        // neutral message instead of blaming an edit that may never have happened.
        if (pending === null && controllerMapBankPending !== null && !activeChanged && !firstLink &&
            scope.mapBank.workingDirty === true) {
          setToast(controllerMapBankPendingMode === 'reload'
            ? 'Pending MAP revert cancelled'
            : 'Pending MAP switch cancelled - the working tune was edited');
        }
        if (activeChanged && !firstLink) {
          var activeSlot = mapBankSlot(active);
          setToast('M' + active + ' (' + (activeSlot ? activeSlot.name : ('MAP ' + active)) + ') ACTIVE');
        }
        controllerMapBankPending = pending;
        controllerMapBankPendingMode = pendingMode;
        if (pendingWorkingTuneContinuation && pendingWorkingTuneContinuation.kind === 'discard') {
          if (wasWorkingDirtyForDecision && scope.mapBank.workingDirty !== true && pending === null) {
            resumeWorkingTuneContinuation('discard');
          } else if (previousPendingForDecision !== null && previousPendingModeForDecision === 'reload' &&
                     pending === null && scope.mapBank.workingDirty === true) {
            cancelWorkingTuneContinuation('Revert cancelled before it applied - the queued action was dropped');
          }
        }
        return activeChanged || sequenceChanged;
      }

      function clearPendingMapBankMutation() {
        if (pendingMapBankMutationTimer) { clearTimeout(pendingMapBankMutationTimer); pendingMapBankMutationTimer = null; }
        pendingMapBankMutation = null;
        scope.mapBankMutationPending = false;
      }
      function rememberIgnoredMapBankMutationToken(token) {
        token = String(token || '');
        if (!token || ignoredMapBankMutationTokens[token]) { return; }
        ignoredMapBankMutationTokens[token] = true;
        ignoredMapBankMutationTokenOrder.push(token);
        while (ignoredMapBankMutationTokenOrder.length > 24) {
          delete ignoredMapBankMutationTokens[ignoredMapBankMutationTokenOrder.shift()];
        }
      }
      function supersedePendingMapBankMutation(acknowledgeNow) {
        if (!pendingMapBankMutation) { return; }
        var superseded = pendingMapBankMutation;
        var token = superseded.token;
        var supersededType = superseded.type;
        rememberIgnoredMapBankMutationToken(token);
        clearPendingMapBankMutation();
        if (supersededType === 'save' && pendingWorkingTuneContinuation && pendingWorkingTuneContinuation.kind === 'save') {
          pendingWorkingTuneContinuation = null;
        }
        if (acknowledgeNow) { callController('ackMapBankMutation(' + luaQuote(token) + ')'); }
        if (typeof superseded.onRejected === 'function') { superseded.onRejected('superseded'); }
      }
      function cancelPendingMapBankMutation(reason, silent) {
        if (!pendingMapBankMutation) { return; }
        var cancelled = pendingMapBankMutation, cancelledType = cancelled.type;
        clearPendingMapBankMutation();
        if (cancelledType === 'save' && pendingWorkingTuneContinuation && pendingWorkingTuneContinuation.kind === 'save') {
          pendingWorkingTuneContinuation = null;
        }
        if (!silent) {
          setToast(reason === 'stale_map_bank_state'
            ? 'MAP action cancelled because the active MAP changed - try again'
            : ('MAP action failed: ' + (reason || 'ECU did not confirm')));
        }
        if (typeof cancelled.onRejected === 'function') { cancelled.onRejected(reason || 'ECU did not confirm'); }
      }
      function armPendingMapBankMutationTimeout() {
        if (!pendingMapBankMutation) { return; }
        if (pendingMapBankMutationTimer) { clearTimeout(pendingMapBankMutationTimer); }
        pendingMapBankMutationTimer = setTimeout(function () {
          scope.$applyAsync(function () {
            if (!pendingMapBankMutation) { return; }
            if (scope.gamePaused || Date.now() - pendingMapBankMutation.startedAt < 6500) {
              callController('requestState()');
              armPendingMapBankMutationTimeout();
            } else {
              cancelPendingMapBankMutation('ECU confirmation timeout');
            }
          });
        }, scope.gamePaused ? 500 : 1200);
      }
      function beginPendingMapBankMutation(op, commandBuilder) {
        if (pendingMapBankMutation) { setToast('Wait for the current MAP action to finish'); return false; }
        mapBankMutationSerial += 1;
        op.token = 'mb_' + Date.now().toString(36) + '_' + mapBankMutationSerial.toString(36);
        op.startedAt = Date.now();
        pendingMapBankMutation = op;
        scope.mapBankMutationPending = true;
        localCalibrationMutationGeneration += 1;
        var accepted = callController(commandBuilder(op.token));
        if (accepted === false) { cancelPendingMapBankMutation('ECU unavailable'); return false; }
        armPendingMapBankMutationTimeout();
        return true;
      }
      function mapBankPackageConfirmationMatches(expected, state, result) {
        if (!expected || !state || !state.mapBank || !result) { return false; }
        var raw = state.mapBank, want = expected.mapBank || {};
        if (String(state.calibrationEpoch || '') !== String(expected.calibrationEpoch || '')) { return false; }
        if (Number(result.sequence) !== Number(raw.sequence) ||
            Number(result.calibrationRevision) !== Number(state.calibrationRevision)) { return false; }
        if (!calibrationMatchesState(expected, state)) { return false; }
        if (Number(raw.active) !== Number(want.active) || Number(raw.default) !== Number(want.default) ||
            (raw.workingDirty === true) !== (want.workingDirty === true) ||
            (raw.workingBoostTableFactory === true) !== (want.workingBoostTableFactory === true)) { return false; }
        if (Number(raw.pending) > 0) { return false; }
        var entries = Array.isArray(raw.banks) ? raw.banks : [], byIndex = {};
        entries.forEach(function (entry) { if (entry) { byIndex[Number(entry.index)] = entry; } });
        var slots = want.slots || [];
        for (var i = 0; i < slots.length; i++) {
          var slot = slots[i], entry = byIndex[Number(slot.index)];
          if (!entry || cleanMapBankName(entry.name, slot.index) !== cleanMapBankName(slot.name, slot.index) ||
              (entry.configured === true) !== (slot.configured === true)) { return false; }
          if (slot.configured && (entry.boostTableFactory === true) !== (slot.boostTableFactory === true)) { return false; }
        }
        return true;
      }
      function resolvePendingMapBankMutation(raw, fullState) {
        if (IS_MIRROR) { return; }
        var result = raw && raw.mutationResult;
        if (!result || !result.token) { return; }
        var token = String(result.token), localMatch = pendingMapBankMutation && pendingMapBankMutation.token === token;
        if (ignoredMapBankMutationTokens[token]) {
          delete ignoredMapBankMutationTokens[token];
          callController('ackMapBankMutation(' + luaQuote(token) + ')');
          return;
        }
        if (pendingMapBankMutation && !localMatch) { return; }
        var op = localMatch ? pendingMapBankMutation : {
          token: token, type: String(result.operation || ''), index: Math.floor(Number(result.index)),
          name: String(result.name || '')
        };
        function acknowledge() { callController('ackMapBankMutation(' + luaQuote(token) + ')'); }
        if (result.ok !== true) {
          if (localMatch) { cancelPendingMapBankMutation(String(result.error || 'ECU rejected the MAP action')); }
          acknowledge();
          return;
        }
        if (op.type === 'package') {
          if (localMatch && !mapBankPackageConfirmationMatches(op.expectedState, fullState, result)) { return; }
          if (localMatch) {
            clearPendingMapBankMutation();
            // The normal State pass deliberately held metadata while applyingTune was true. Adopt
            // the exact confirmed package now so the autosave/cache written by the callback carries
            // the controller's real sequence and active/default association.
            syncMapBankState(raw, false);
          }
          acknowledge();
          if (localMatch && typeof op.onConfirmed === 'function') { op.onConfirmed(result); }
          return;
        }
        if (!/^(?:save|rename|clear|default)$/.test(op.type) || !(op.index >= 1 && op.index <= 4)) {
          if (localMatch) { cancelPendingMapBankMutation('ECU returned an invalid MAP result'); }
          acknowledge();
          return;
        }
        var slot = mapBankSlot(op.index);
        if (op.type === 'save') {
          var maps = result.data && copyMapBankMaps(result.data.maps);
          var settingsCopy = result.data && performanceSettingsFrom(result.data.settings, scope.settings);
          if (!slot || !slot.configured || !maps || !validPerformanceSettings(settingsCopy, false)) {
            if (localMatch) { cancelPendingMapBankMutation('ECU returned invalid saved MAP data'); }
            acknowledge();
            return;
          }
          slot.data = { maps: maps, settings: settingsCopy, boostTableFactory: result.data.boostTableFactory === true };
          slot.boostTableFactory = result.data.boostTableFactory === true;
          slot.peakBoostKpa = peakBoostFromMaps(maps);
          if (localMatch) { clearPendingMapBankMutation(); }
          cacheCurrentMapBankBodies();
          acknowledge();
          setToast('Working tune saved to M' + op.index + ' (' + slot.name + ') - M' + op.index + ' is now the ACTIVE MAP');
          // V2.9.7 STORE-WIPE FIX: persistence-ack AFTER the op toast - a blocked save's specific
          // reason (setToast last-wins) must not be clobbered by the success message, or the exact
          // degraded window this wave targets shows a green toast + an unexplained red badge.
          acknowledgeMapBankPersistence();
          resumeWorkingTuneContinuation('save');
          return;
        }
        if (op.type === 'rename' && result.data) {
          var renamedMaps = copyMapBankMaps(result.data.maps);
          var renamedSettings = performanceSettingsFrom(result.data.settings, scope.settings);
          if (!slot || !slot.configured || !renamedMaps || !validPerformanceSettings(renamedSettings, false)) {
            if (localMatch) { cancelPendingMapBankMutation('ECU returned invalid renamed MAP data'); }
            acknowledge();
            return;
          }
          slot.data = { maps: renamedMaps, settings: renamedSettings, boostTableFactory: result.data.boostTableFactory === true };
          slot.boostTableFactory = result.data.boostTableFactory === true;
          slot.peakBoostKpa = peakBoostFromMaps(renamedMaps);
        }
        if (localMatch) { clearPendingMapBankMutation(); }
        cacheCurrentMapBankBodies();
        acknowledge();
        if (op.type === 'rename') { setToast('M' + op.index + ' renamed to ' + (slot ? slot.name : op.name)); }
        else if (op.type === 'clear') { setToast('M' + op.index + ' cleared'); }
        else if (op.type === 'default') { setToast('M' + op.index + ' will be selected on spawn'); }
        // V2.9.7 STORE-WIPE FIX: persistence-ack after the op toast (see the save-branch note).
        acknowledgeMapBankPersistence();
      }

      // A successful MAP mutation used to flash 'Store Req' right after its own success toast - a
      // save that looked like a failed save. saveAutosave() persists the exact package Store ECU
      // does, so when it succeeds, acknowledge the controller the same way scope.store() would.
      function acknowledgeMapBankPersistence() {
        var persisted = saveAutosave();
        if (!IS_MIRROR && persisted) {
          callController('markStored()');
          scope.storeRequired = false;
          scope.calibrationDirty = false;
        } else {
          scope.storeRequired = true;
          // V2.9.7 STORE-WIPE FIX: a blocked save right after a successful MAP action deserves the
          // specific cause, not just a silent red badge.
          if (!IS_MIRROR && autosaveBlockReason) { setToast(autosaveBlockReason); }
        }
      }

      function clearWorkingTunePrompt() {
        pendingWorkingTuneDecision = null;
        scope.workingTunePrompt = null;
      }
      function deferWorkingTuneAction(label, run, discardOnly) {
        if (!(scope.mapBank && scope.mapBank.workingDirty === true)) { return false; }
        pendingWorkingTuneDecision = { label: String(label || 'This action'), run: run, discardOnly: discardOnly === true };
        scope.workingTunePrompt = {
          label: pendingWorkingTuneDecision.label,
          discardOnly: pendingWorkingTuneDecision.discardOnly,
          remote: IS_MIRROR
        };
        return true;
      }
      function cancelWorkingTuneContinuation(reason) {
        pendingWorkingTuneContinuation = null;
        if (reason) { setToast(reason); }
      }
      function resumeWorkingTuneContinuation(kind) {
        if (!pendingWorkingTuneContinuation || pendingWorkingTuneContinuation.kind !== kind) { return; }
        var continuation = pendingWorkingTuneContinuation;
        pendingWorkingTuneContinuation = null;
        setTimeout(function () {
          scope.$applyAsync(function () {
            if (typeof continuation.run === 'function') { continuation.run(); }
          });
        }, 0);
      }
      scope.cancelWorkingTuneAction = function () {
        clearWorkingTunePrompt();
      };
      scope.resolveWorkingTuneAction = function (choice) {
        var decision = pendingWorkingTuneDecision;
        if (!decision) { clearWorkingTunePrompt(); return; }
        if (choice !== 'save' && choice !== 'discard') { clearWorkingTunePrompt(); return; }
        if (IS_MIRROR) {
          clearWorkingTunePrompt();
          setToast('Save or revert the working tune on the game PC first');
          return;
        }
        clearWorkingTunePrompt();
        pendingWorkingTuneContinuation = { kind: choice, run: decision.run };
        if (choice === 'save') {
          scope.saveWorkingToMapBank(scope.mapBank.active);
          if (!pendingMapBankMutation || pendingMapBankMutation.type !== 'save') {
            cancelWorkingTuneContinuation('Working tune was not saved - the next action was cancelled');
          }
          return;
        }
        if (scope.discardWorkingMapBank() === false) {
          cancelWorkingTuneContinuation('Working tune was not reverted - the next action was cancelled');
        }
      };
      scope.requestDiscardWorkingMapBank = function () {
        if (rejectTemporaryLessonMapMutation()) { return; }
        if (!(scope.mapBank && scope.mapBank.workingDirty === true)) {
          setToast('Working tune already matches its saved MAP');
          return;
        }
        deferWorkingTuneAction('Restore saved M' + scope.mapBank.active, function () {}, true);
      };

      scope.mapBankActiveName = function () {
        var slot = mapBankSlot(scope.mapBank && scope.mapBank.active);
        return slot ? slot.name : ('MAP ' + ((scope.mapBank && scope.mapBank.active) || 1));
      };
      scope.selectMapBank = function (index) {
        if (rejectTemporaryLessonMapMutation()) { return; }
        if (pendingMapBankMutation) { setToast('Wait for the current MAP action to finish'); return; }
        index = Math.floor(Number(index));
        var slot = mapBankSlot(index);
        if (!slot || !slot.configured) { setToast('MAP ' + index + ' is empty - Save the working tune into it first'); return; }
        if (deferWorkingTuneAction('Switch to M' + index, function () { scope.selectMapBank(index); })) { return; }
        localCalibrationMutationGeneration += 1;
        var accepted = callController('selectMapBank(' + index + ')');
        if (accepted === false) { setToast(IS_MIRROR ? 'Enable Pro Studio editing on the game PC first' : 'MAP switch was rejected'); }
      };
      scope.cycleMapBank = function (direction) {
        if (rejectTemporaryLessonMapMutation()) { return; }
        if (pendingMapBankMutation) { setToast('Wait for the current MAP action to finish'); return; }
        direction = Number(direction) < 0 ? -1 : 1;
        if (deferWorkingTuneAction(direction < 0 ? 'Switch to the previous MAP' : 'Switch to the next MAP', function () { scope.cycleMapBank(direction); })) { return; }
        localCalibrationMutationGeneration += 1;
        var accepted = callController('cycleMapBank(' + direction + ')');
        if (accepted === false) { setToast(IS_MIRROR ? 'Enable Pro Studio editing on the game PC first' : 'No other configured MAP'); }
      };
      var mapBankSaveArmTimer = null;
      scope.mapBankSaveArmed = null;
      scope.mapBankSaveLabel = function (index) {
        return Number(scope.mapBankSaveArmed) === Number(index) ? 'CONFIRM OVERWRITE' : ('Save to M' + index);
      };
      // Clear is strictly more destructive than overwriting, so it gets the same two-click arm the
      // save button (and factory reset) already use instead of firing on a single misclick.
      var mapBankClearArmTimer = null;
      scope.mapBankClearArmed = null;
      scope.mapBankClearLabel = function (index) {
        return Number(scope.mapBankClearArmed) === Number(index) ? 'CONFIRM CLEAR' : 'Clear';
      };
      scope.cancelPendingMapBank = function () {
        if (!(scope.mapBank && scope.mapBank.pending)) { return; }
        if (rejectTemporaryLessonMapMutation()) { return; }
        // Reselecting the active bank is the controller's documented cancel path: selectMapBank
        // clears a pending switch when asked for the MAP that is already active.
        localCalibrationMutationGeneration += 1;
        var accepted = callController('selectMapBank(' + Math.floor(Number(scope.mapBank.active)) + ')');
        setToast(accepted === false ? 'Could not cancel the pending MAP switch' : 'Pending MAP switch cancelled');
      };
      function expectedMapBankSequenceLua() {
        // A first local edit advances the controller sequence before its echo can update this UI.
        // Let the vehicle queue preserve edit-then-Save ordering in that short window; otherwise
        // require the exact painted sequence to reject a genuinely stale management click.
        if (pendingAxisAutosave || (scope.storeRequired === true && scope.mapBank.workingDirty !== true) ||
            Object.keys(pendingSettingEcho).length > 0 || Object.keys(pendingMapEcho).length > 0) { return 'nil'; }
        var sequence = Number(scope.mapBank.sequence);
        return isFinite(sequence) ? String(Math.floor(sequence)) : 'nil';
      }
      scope.saveWorkingToMapBank = function (index) {
        if (IS_MIRROR) { setToast('Manage switchable MAPs on the game PC'); return; }
        if (rejectTemporaryLessonMapMutation()) { return; }
        index = Math.floor(Number(index));
        var target = mapBankSlot(index), data = workingMapBankData();
        if (!target || !data) { setToast('Wait for the live calibration before saving a MAP'); return; }
        if (target.configured && Number(scope.mapBank.active) !== index && Number(scope.mapBankSaveArmed) !== index) {
          scope.mapBankSaveArmed = index;
          if (mapBankSaveArmTimer) { clearTimeout(mapBankSaveArmTimer); }
          mapBankSaveArmTimer = setTimeout(function () {
            scope.$applyAsync(function () { scope.mapBankSaveArmed = null; mapBankSaveArmTimer = null; });
          }, 5000);
          setToast('MAP ' + index + ' already has a saved tune - click CONFIRM OVERWRITE');
          return;
        }
        if (mapBankSaveArmTimer) { clearTimeout(mapBankSaveArmTimer); mapBankSaveArmTimer = null; }
        scope.mapBankSaveArmed = null;
        var name = cleanMapBankName(target.draftName || target.name, index);
        // Saving into an EMPTY slot the user hasn't named adopts the tune's name, so loading
        // "Drag day" and pressing Save to M3 lands as M3 "Drag day" without retyping. Programmatic
        // placeholder tune names never qualify - a from-scratch tune should still land as "MAP n".
        var MAPBANK_PLACEHOLDER_TUNE_NAMES = { 'Base': 1, 'Live ECU': 1, 'My tune': 1, 'Factory reset pending': 1 };
        var typedName = String(target.draftName || '').trim();
        var adoptableTuneName = String(scope.tuneName || '').trim();
        if (!target.configured && (!typedName || typedName === ('MAP ' + index)) &&
            adoptableTuneName && MAPBANK_PLACEHOLDER_TUNE_NAMES[adoptableTuneName] !== 1) {
          name = cleanMapBankName(adoptableTuneName, index);
        }
        var expectedActive = Math.floor(Number(scope.mapBank.active));
        var expectedSequence = expectedMapBankSequenceLua();
        var expectedEpoch = String(scope.calibrationEpoch || '');
        if (!beginPendingMapBankMutation({ type: 'save', index: index, name: name }, function (token) {
          return 'saveCurrentToMapBank(' + index + ', ' + luaQuote(name) + ', true, ' + expectedActive + ', ' + expectedSequence + ', ' + luaQuote(expectedEpoch) + ', ' + luaQuote(token) + ')';
        })) { return; }
        setToast('Saving working tune to MAP ' + index + '...');
      };
      scope.renameMapBank = function (index) {
        if (IS_MIRROR) { setToast('Rename switchable MAPs on the game PC'); return; }
        if (rejectTemporaryLessonMapMutation()) { return; }
        var slot = mapBankSlot(index);
        if (!slot) { return; }
        if (!slot.configured) { setToast('Save a working tune here before renaming this MAP'); return; }
        var name = cleanMapBankName(slot.draftName, slot.index);
        var expectedSequence = expectedMapBankSequenceLua();
        var expectedEpoch = String(scope.calibrationEpoch || '');
        if (!beginPendingMapBankMutation({ type: 'rename', index: slot.index, name: name }, function (token) {
          return 'renameMapBank(' + slot.index + ', ' + luaQuote(name) + ', true, ' + expectedSequence + ', ' + luaQuote(expectedEpoch) + ', ' + luaQuote(token) + ')';
        })) { return; }
        setToast('Renaming MAP ' + slot.index + '...');
      };
      scope.discardWorkingMapBank = function () {
        if (IS_MIRROR) { setToast('Revert the working tune on the game PC'); return false; }
        if (rejectTemporaryLessonMapMutation()) { return false; }
        if (pendingMapBankMutation) { setToast('Wait for the current MAP action to finish'); return false; }
        if (!scope.mapBank.workingDirty) { setToast('Working tune already matches its saved MAP'); return false; }
        localCalibrationMutationGeneration += 1;
        var accepted = callController('discardWorkingMapBank()');
        if (accepted === false) { setToast('Working tune could not be reverted'); return false; }
        setToast('Reverting to saved MAP ' + scope.mapBank.active + ' - lift throttle if requested');
        return true;
      };
      scope.clearMapBank = function (index) {
        if (IS_MIRROR) { setToast('Manage switchable MAPs on the game PC'); return; }
        if (rejectTemporaryLessonMapMutation()) { return; }
        var slot = mapBankSlot(index);
        if (!slot || !slot.configured) { return; }
        if (Number(scope.mapBank.active) === Number(index)) { setToast('The active MAP cannot be cleared'); return; }
        if (Number(scope.mapBankClearArmed) !== Number(index)) {
          scope.mapBankClearArmed = index;
          if (mapBankClearArmTimer) { clearTimeout(mapBankClearArmTimer); }
          mapBankClearArmTimer = setTimeout(function () {
            scope.$applyAsync(function () { scope.mapBankClearArmed = null; mapBankClearArmTimer = null; });
          }, 5000);
          setToast('Clearing M' + index + ' deletes its saved tune - click CONFIRM CLEAR');
          return;
        }
        if (mapBankClearArmTimer) { clearTimeout(mapBankClearArmTimer); mapBankClearArmTimer = null; }
        scope.mapBankClearArmed = null;
        var expectedActive = Math.floor(Number(scope.mapBank.active));
        var expectedSequence = expectedMapBankSequenceLua();
        var expectedEpoch = String(scope.calibrationEpoch || '');
        if (!beginPendingMapBankMutation({ type: 'clear', index: slot.index }, function (token) {
          return 'clearMapBank(' + slot.index + ', true, ' + expectedActive + ', ' + expectedSequence + ', ' + luaQuote(expectedEpoch) + ', ' + luaQuote(token) + ')';
        })) { return; }
        setToast('Clearing MAP ' + slot.index + '...');
      };
      scope.setDefaultMapBank = function (index) {
        if (IS_MIRROR) { setToast('Choose the default MAP on the game PC'); return; }
        if (rejectTemporaryLessonMapMutation()) { return; }
        var slot = mapBankSlot(index);
        if (!slot || !slot.configured) { setToast('That MAP is empty'); return; }
        var expectedSequence = expectedMapBankSequenceLua();
        var expectedEpoch = String(scope.calibrationEpoch || '');
        if (!beginPendingMapBankMutation({ type: 'default', index: slot.index }, function (token) {
          return 'setDefaultMapBank(' + slot.index + ', true, ' + expectedSequence + ', ' + luaQuote(expectedEpoch) + ', ' + luaQuote(token) + ')';
        })) { return; }
        setToast('Setting MAP ' + slot.index + ' as default...');
      };
      scope.bindMapAction = function (action) {
        if (IS_MIRROR) { setToast('Configure wheel and keyboard bindings on the game PC'); return; }
        try {
          if (!$injector || !$injector.has('$state')) { setToast('Open Options → Controls to configure this binding'); return; }
          scope.$emit('MenuHide', false);
          $injector.get('$state').go('menu.options.controls.bindings.edit', { action: String(action), oldBinding: {} });
        } catch (e) { setToast('Open Options → Controls to configure this binding'); }
      };
      function applySnapshot(snap, options) {
        options = options || {};
        if (IS_MIRROR) { setToast('Tune loading is only available on the game PC'); return false; }
        // A user-initiated load supersedes in-flight MAP mutations, but a pending Save's result is
        // the ONLY carrier of the saved slot body - acking it mid-flight leaves the slot bodyless
        // and the next autosave replay would wipe it. Match the other mutation entry points and wait.
        if (options.userLoad === true && pendingMapBankMutation) { setToast('Wait for the current MAP action to finish'); return false; }
        if (!snap || !snap.maps) { return false; }
        if (!validSnapshotSettings(snap.settings)) { setToast('Tune settings are invalid - nothing was loaded'); return false; }
        // Work on a private copy: migration and numeric normalization must not rewrite a library/slot
        // object before the full tune has passed validation and is actually accepted.
        snap = angular.copy(snap);
        // Only a load the user explicitly asked for may fall back to the fuzzy pre-3.0.2 identity
        // match, and only to recover a tune saved before the update. They chose this tune by name
        // and can undo it; nothing here happens behind their back.
        if (snap.mapBank && snap.engineFp && scope.base && scope.base.engineFp &&
            !engineIdentityCompatible(snap, scope.base, { allowApproximate: options.userLoad === true })) {
          setToast('This Switchable Maps package belongs to a different engine — nothing was loaded');
          return false;
        }
        if (!prepareSnapshotAxes(snap)) { setToast('Tune axes or table dimensions are invalid - nothing was loaded'); return false; }
        migrateFuelMap(snap.maps.fuel);
        if (!snapshotMapsInRange(snap.maps)) { setToast('Tune contains table values outside the ECU limits - nothing was loaded'); return false; }
        // A USER-initiated load of a single tune (a legacy library entry or backup from before the
        // Switchable MAPs update, or a Discord tune code - both carry no MAP package) must never
        // touch the four MAP slots: pre-update players have named tune libraries, and opening one
        // used to wipe M2-M4 and force the tune onto M1. Instead it loads as a working-tune draft
        // on the active MAP and the player chooses Save to M1-M4 - the PCLink model (a loaded
        // basemap lives in ECU RAM until you Store). Recovery/autosave replays, lesson rollbacks
        // and full packages (post-update backups/exports) keep whole-package semantics.
        var draftLoad = options.userLoad === true && !snap.mapBank;
        // Drafts never consume the package, so don't let package-only rules (e.g. this engine's
        // rev-limit floor inside validPerformanceSettings) block a legacy tune with a misleading
        // 'Switchable MAP data' toast - the controller clamps every pushed setting anyway.
        var mapBankPackage = null;
        if (!draftLoad) {
          mapBankPackage = normalizeMapBankPackage(snap);
          if (!mapBankPackage) {
            setToast((mapBankValidationReason || 'Switchable MAP data is invalid') + ' - nothing was loaded');
            return false;
          }
        }
        // V2.9 fix: a watchdog reconnect / respawn re-applies the SAME autosave. When the snapshot's
        // calibration (fuel equation, axes, fuel/VE/lambda tables AND every fuel-related setting) is
        // identical to what is already running, the learned Mixture Map evidence still describes
        // reality - keep it instead of wiping 15 minutes of driving on a telemetry blip.
        var sameAxes = axesEqualTol(snap.rpmAxis, scope.rpmAxis) && axesEqualTol(snap.mapAxis, scope.mapAxis) &&
          axesEqualTol(snap.alphaNAxis, scope.alphaNAxis);
        var sameCal = sameAxes &&
          calibrationSignatureFrom(snap.settings, [], [], snap.alphaNAxis, snap.maps) ===
          calibrationSignatureFrom(scope.settings, [], [], scope.alphaNAxis, scope.maps);
        if (sameCal && snap.settings) {
          var mixKeys = Object.keys(MIX_INVALIDATING_SETTINGS);
          for (var mk = 0; mk < mixKeys.length; mk++) {
            if (!sameFuelSettingValue(snap.settings[mixKeys[mk]], (scope.settings || {})[mixKeys[mk]])) { sameCal = false; break; }
          }
        }
        // A tune/package load supersedes any unconfirmed persistence-only MAP action on this ECU.
        // Ignore an already-queued old result so it cannot reattach a pre-load slot body afterward.
        supersedePendingMapBankMutation(true);
        localCalibrationMutationGeneration += 1;
        if (!sameCal) { invalidateMixture('tune loaded'); }
        scope.applyingTune = true;
        if (applyTuneTimer) { clearTimeout(applyTuneTimer); }
        scope.tuneName = snap.name || scope.tuneName;
        var controllerWasAlphaN = scope.alphaNActive();
        scope.settings = snap.settings || scope.settings; normalizeSettings();
        seedMixSettingsMirror();
        if (Number(scope.settings.fuelEquationMode) !== 2) { scope.mixDisplayMode = 'correction'; }
        // migrate pre-V2_1 tunes: a saved injector flow at/below the old fixed 600cc default on an
        // engine that needs more pegs duty (rich/erratic + knock). Size it up to this engine's factory
        // default so a big build runs clean. New tunes already carry the scaled value (>600), untouched.
        // catch tunes stuck at an OLD auto value (the old fixed 600cc default OR the old 3600cc ceiling)
        // on an engine that needs more - size up to this engine's factory default. A deliberately-set
        // value (anything not 600/3600, or already >= what the engine needs) is left alone.
        var savedInj = scope.settings.injectorFlow || 0;
        if (scope.factoryInjFlow > savedInj && (savedInj <= 600 || savedInj === 3600)) {
          scope.settings.injectorFlow = scope.factoryInjFlow;
        }
        // induction-type guard: a tune built NA can't carry its MAP axis + boost table to a turbo
        // car (or vice versa) - the load axis is wrong (no boost rows). Keep the freshly-built
        // hardware-appropriate tables in that case and restore only the settings. Detect the saved
        // induction from the flag, or (old saves) infer it from whether the MAP axis exceeds 100 kPa.
        // Draft loads keep the four MAP slots, so remember the pre-load boost table and axes: the
        // draft branch below needs them to clear a stale factory flag and to resample the retained
        // slot bodies when the pushed axes differ.
        var prevBoost = scope.maps && scope.maps.boost;
        var prevRpmAxis = scope.rpmAxis && scope.rpmAxis.slice();
        var prevMapAxis = scope.mapAxis && scope.mapAxis.slice();
        var prevAlphaNAxis = scope.alphaNAxis && scope.alphaNAxis.slice();
        var savedTurbo = snap.hasTurbo;
        if (savedTurbo === undefined && snap.mapAxis && snap.mapAxis.length) { savedTurbo = Math.max.apply(null, snap.mapAxis) > 110; }
        var inductionMatch = (savedTurbo === undefined) || (!!savedTurbo === !!scope.base.hasTurbo);
        if (inductionMatch) {
          scope.maps = snap.maps;
          scope.baselineMaps = {};   // a loaded tune is a NEW reference point - old Δ baselines would paint the whole load as "changes" (and mismatch dimensions if the tune brings other axes)
          // V2.9 fix: only push axes that actually DIFFER - re-sending identical axes tripped the
          // bridge's calibration-changed invalidation (and a controller-side rebuild) on every load.
          // V3: the mirror displays the ECU's own axes/maps; it must never restore a
          // device-local snapshot over them (defensive - applySnapshot is mirror-gated above).
          if (!IS_MIRROR) {
            if (snap.rpmAxis) {
              var rpmAxisSame = axesEqualTol(snap.rpmAxis, scope.rpmAxis);
              scope.rpmAxis = snap.rpmAxis;
              if (!rpmAxisSame) { callController('setAxis("rpm", "' + snap.rpmAxis.join(',') + '")'); }
            }
            if (snap.mapAxis) {
              var mapAxisSame = axesEqualTol(snap.mapAxis, scope.mapAxis);
              scope.mapAxis = snap.mapAxis;
              if (!mapAxisSame) { callController('setAxis("map", "' + snap.mapAxis.join(',') + '")'); }
            }
            if (snap.alphaNAxis) {
              var alphaAxisSame = axesEqualTol(snap.alphaNAxis, scope.alphaNAxis);
              scope.alphaNAxis = snap.alphaNAxis;
              if (!alphaAxisSame) { callController('setAxis("alpha", "' + snap.alphaNAxis.join(',') + '")'); }
            }
          }
          pushAllMaps();
        } else {
          setToast(scope.tr('Induction changed - MAP/boost tables reset for the new hardware'));
          // The source banks use the source induction's shared MAP axis. Keep only a
          // safe MAP 1 made from this vehicle's live hardware-appropriate tables.
          var safeBoostFactory = scope.mapBank && scope.mapBank.workingBoostTableFactory === true;
          mapBankPackage = {
            v: 2, active: 1, default: 1, workingDirty: false,
            workingBoostTableFactory: safeBoostFactory,
            workingPeakBoostKpa: peakBoostFromMaps(scope.maps),
            slots: [1, 2, 3, 4].map(function (index) {
              if (index !== 1) { return emptyMapBankSlot(index); }
              return { index: 1, name: 'MAP 1', draftName: 'MAP 1', configured: true,
                peakBoostKpa: peakBoostFromMaps(scope.maps), boostTableFactory: safeBoostFactory,
                data: { maps: angular.copy(scope.maps), settings: performanceSettingsFrom(scope.settings, MAP_BANK_SETTING_DEFAULTS), boostTableFactory: safeBoostFactory } };
            })
          };
        }
        pushAllSettings();
        if (draftLoad) {
          // Slots untouched: the pushed rows/settings dirty the controller's working copy through
          // the normal edit lanes (only where values actually differ), the echo raises the
          // M{n} EDITED chip, and Save to M1-M4 is the player's explicit next step.
          // The controller refits every saved bank when pushed axes differ (M.refitMaps); mirror
          // that on the retained UI slot bodies, or the next autosave would persist old-grid bodies
          // under the new axes (silently misaligned on same-size grids, rejected outright - losing
          // the whole package - on resized ones).
          // REVIEW FIX 2026-08-03 (supersedes the 2026-07-29 note, which was wrong): the guard is
          // LIVE, not dead. rpmAxisSame / mapAxisSame / alphaAxisSame are `var` declarations, so they
          // hoist to applySnapshot's function scope - not to the block they are written in - and the
          // guard reads them only behind `inductionMatch`, which is precisely the branch that assigns
          // them. The previous note claimed `undefined === false` and used that to justify leaving the
          // resample off; it left every configured M1-M4 bank body on stale breakpoints instead.
          // The alpha term is new: the controller reprojects the working copy AND all four banks on
          // setAxis("alpha") via M.refitMaps, so an alpha-only change needs the same UI-side resample
          // that an rpm/map change already gets. It is guarded by controllerWasAlphaN because the
          // alpha axis only indexes tables while the ECU is actually in Alpha-N mode.
          if (inductionMatch && (rpmAxisSame === false || mapAxisSame === false ||
              (controllerWasAlphaN && alphaAxisSame === false))) {
            resampleMapBankSlots(prevRpmAxis, prevMapAxis, scope.rpmAxis, scope.mapAxis,
              prevAlphaNAxis, scope.alphaNAxis, controllerWasAlphaN);
          }
          // The pushed rows compare against themselves (scope.maps was already reassigned), so the
          // boost-edit detector can't clear a stale factory flag here - and persisting
          // workingBoostTableFactory:true beside a non-flat boost table poisons the autosave
          // (normalizeMapBankPackage rejects the whole package on replay). Clear it unless the
          // boost table verifiably did not change; the controller echo restores true after release
          // if the table really is still factory.
          if (inductionMatch && scope.mapBank && scope.mapBank.workingBoostTableFactory === true &&
              !(prevBoost && snap.maps.boost && boostTablesMatch(prevBoost, snap.maps.boost))) {
            scope.mapBank.workingBoostTableFactory = false;
          }
          applyTuneWaitedForUnpause = false;
          armApplyTuneRelease();
          // 'draft-reset' = induction mismatched, tables were reset and only settings loaded - the
          // 'Induction changed' toast set above must survive, so callers skip their own message.
          if (!inductionMatch) { return 'draft-reset'; }
          setToast(draftLoadHint(snap.name));
          return 'draft';
        }
        installMapBankPackage(mapBankPackage);
        var selectedBank = options.selectDefault ? scope.mapBank.default : Math.floor(Number(options.selectBank));
        if (!(selectedBank >= 1 && selectedBank <= 4)) { selectedBank = scope.mapBank.active; }
        // A dirty v2 package is an intentional live draft. Even a fresh-spawn
        // default-selection request must preserve its association instead of discarding it.
        if (scope.mapBank.workingDirty) { selectedBank = scope.mapBank.active; }
        if (!stageMapBankPackage(scope.mapBank, selectedBank, {
          confirm: options.confirmMapBankPackage === true,
          onConfirmed: options.onMapBankConfirmed,
          onRejected: options.onMapBankRejected
        })) {
          scope.applyingTune = false;
          return false;
        }
        // hold the loaded tune in the UI until the controller echoes it back - its
        // setAxis publishes an intermediate default state that would otherwise
        // clobber the maps we just loaded (the "loading a tune did nothing" bug)
        applyTuneWaitedForUnpause = false;
        armApplyTuneRelease();
        return true;
      }
      function draftLoadHint(name) {
        return 'Loaded "' + String(name || 'tune').slice(0, 40) + '" as the working tune - Save to M1-M4 to keep it on a MAP';
      }
      // V2.9.7 STORE-WIPE FIX (Liqurdz's 4-MAP loss): saveAutosave is the single choke point every
      // wipe-capable writer funnels through (Store ECU, Ctrl+S, MAP acks, axis autosave, debounces),
      // and it used to overwrite all three recovery layers with zero content checks. The guards:
      // (1) REFUSE-TO-DEMOTE: after a degraded relink, hydrateMapBankBodiesFromAutosave can leave a
      //     slot configured (metadata) but BODYLESS; serializedMapBank would persist it as
      //     configured:false - silently gutting the recovery package. The sibling cache hydrator
      //     documents this exact hazard ("never let one stale body... serialize the other configured
      //     slots as empty"); the autosave path never had the guard. Block and name the slot.
      //     Legit clears/factory resets flip configured=false BEFORE saving, so they still pass.
      // (2) WRITE-TIME VALIDATION: run the package through the same gates every READER applies
      //     (normalizeMapBankPackage / snapshotMapsInRange). A package no reader will accept must
      //     never overwrite one that replays - it presents as "New vehicle - factory defaults".
      // (3) V3.0.2 RECOVERY: the one proven-safe legacy poison (factory=true beside a non-flat
      //     Boost Target) is metadata-only. Demote that claim in UI + controller and preserve every
      //     table cell; all shapes, settings and numeric ranges remain strict and get named errors.
      // autosaveBlockReason lets scope.store() surface the SPECIFIC cause instead of its generic
      // failure toast (setToast replaces, so a toast fired here would be lost).
      var autosaveBlockReason = null;
      function saveAutosave() {
        if (IS_MIRROR) { return false; }
        autosaveBlockReason = null;
        for (var g = 1; g <= 4; g++) {
          var guardSlot = mapBankSlot(g);
          if (guardSlot && guardSlot.configured === true && !mapBankControllerData(guardSlot)) {
            autosaveBlockReason = 'M' + g + ' "' + cleanMapBankName(guardSlot.name, g)
              + '" is not re-synced - Store blocked to protect your saved maps (select M' + g + ' once, then Store again)';
            scope.storeRequired = true;
            auditMapBankBodies();   // V2.9.7 wave-2 review: raise the banner with the blocked toast, not just the badge
            return false;
          }
        }
        try {
          var snap = validatedSnapshotForPersistence({ adoptRepairs: true });   // Store ECU: the one writer that owns live state
          if (!snap) {
            autosaveBlockReason = 'Calibration not saved: ' + (snapshotValidationReason || 'validation failed')
              + ' - your last good Store was kept';
            scope.storeRequired = true;
            return false;
          }
          var raw = JSON.stringify(snap), key = calKey();
          rotateAutosaveGeneration(key, raw);
          localStorage.setItem(key, raw);
          if (localStorage.getItem(key) !== raw) { return false; }
        } catch (e) { return false; }
        // Keep the exact live-instance cache at the same sequence/revision as the package that was
        // just verified. It must never outrank a newer autosave after the app is removed/re-added.
        cacheCurrentMapBankBodies();
        durSync();
        auditMapBankBodies();   // guard passed = every configured slot has a body; clear the banner
        return true;
      }
      function captureTemporaryMixtureEvidence() {
        return {
          bins: angular.copy(scope.mixBins), stats: angular.copy(scope.mixLearnStats),
          displayMode: scope.mixDisplayMode, learnMode: scope.mixLearnMode,
          generation: mixGeneration, vehicleId: mixVehicleId, pullPassId: mixPullPassId,
          lastEvidenceAt: mixLastEvidenceAt
        };
      }
      function restoreTemporaryMixtureEvidence(saved) {
        if (!saved) { return; }
        scope.mixBins = angular.copy(saved.bins);
        scope.mixLearnStats = angular.copy(saved.stats);
        scope.mixDisplayMode = saved.displayMode || 'correction';
        scope.mixLearnMode = saved.learnMode || 'auto';
        mixGeneration = Number(saved.generation) || 1;
        mixVehicleId = saved.vehicleId === undefined ? mixVehicleId : saved.vehicleId;
        mixPullPassId = Number(saved.pullPassId) || 0;
        mixLastEvidenceAt = Number(saved.lastEvidenceAt) || 0;
        mixTransportBuffer = [];
        mixMotionWindow = [];
        mixRecoveryUntil = 0;
        mixRecoveryReason = '';
        mixLastGear = null;
        mixPullSessionActive = false;
        mixResumeAfter = Date.now() + 350;
        mixUiNextAt = 0;
        mixAxisSignature = mixtureAxisSignature();
        mixCalibrationSignature = mixtureCalibrationSignature();
        seedMixSettingsMirror();
        refreshMixUi(scope.t, true);
      }
      function lessonJournalMatchesCurrent(j) {
        if (!j || !j.snapshot || !j.snapshot.maps) { return false; }
        if (String(j.carName || '') !== String(scope.carName || '')) { return false; }
        if (!engineIdentityCompatible(j, scope.base)) { return false; }
        return !!j.hasTurbo === !!(scope.base && scope.base.hasTurbo);
      }
      function inMemoryLessonVehicleMatches(j) {
        if (!j || typeof j.vehId !== 'number') { return false; }
        if (typeof scope.activeVehId === 'number' && Number(scope.activeVehId) !== Number(j.vehId)) { return false; }
        if (typeof mixVehicleId === 'number' && Number(mixVehicleId) !== Number(j.vehId)) { return false; }
        return true;
      }
      function installTemporaryLessonSession(j, key, mixture, recoveryOnly) {
        temporaryLessonSession = {
          journal: j, key: key, mixture: mixture || null, recoveryOnly: recoveryOnly === true,
          restorePending: false, restoreCallbacks: [],
          previousVehicleGuard: controllerVehicleGuardId,
          previousVehicleRoute: controllerVehicleRouteId,
          previousIdentityGuard: controllerIdentityGuard
        };
        // Guard every lesson/rollback command to the exact vehicle that owns the journal.
        controllerVehicleGuardId = j.vehId;
        controllerVehicleRouteId = j.vehId;
        controllerIdentityGuard = {
          carName: j.carName,
          engineFp: (scope.base && scope.base.engineFp) || j.engineFp,
          hasTurbo: j.hasTurbo
        };
        return temporaryLessonSession;
      }
      function recoverTemporaryLessonJournal() {
        if (temporaryLessonSession) { return false; }
        var key = lessonJournalKey(), raw = lsGet(key);
        var j = null;
        try { j = raw ? JSON.parse(raw) : null; } catch (e) {}
        // V3.0.2 identity migration: an interrupted lesson may still be filed under the
        // old performance-derived fingerprint. Find a compatible journal for this car
        // so the user's original calibration is restored before another lesson starts.
        if (!lessonJournalMatchesCurrent(j)) {
          var journals = lessonJournalsForCurrentCar();
          j = null;
          for (var candidateKey in journals) {
            if (!Object.prototype.hasOwnProperty.call(journals, candidateKey)) { continue; }
            var candidate = null;
            try { candidate = JSON.parse(journals[candidateKey]); } catch (e2) {}
            if (lessonJournalMatchesCurrent(candidate)) {
              key = candidateKey;
              raw = journals[candidateKey];
              j = candidate;
              break;
            }
          }
        }
        if (!lessonJournalMatchesCurrent(j)) { return false; }
        installTemporaryLessonSession(j, key, null, true);
        if (!restoreTemporaryLesson(true)) {
          setToast('A temporary lesson backup was found but could not be restored');
          return false;
        }
        setToast('Restoring your calibration from an interrupted temporary lesson...');
        return true;
      }
      function beginTemporaryLessonSession() {
        if (!scope.carName || !scope.maps || !scope.maps.fuel || !scope.settings || !scope.rpmAxis.length || !scope.mapAxis.length) {
          setToast('Connect to a vehicle and wait for its calibration before starting this lesson');
          return false;
        }
        if (hasTemporaryLessonJournal()) {
          if (!recoverTemporaryLessonJournal()) {
            setToast('An older temporary lesson backup must be recovered before starting another lesson');
          }
          return false;
        }
        var lessonVehId = mixVehicleId;
        if (typeof lessonVehId !== 'number' || (typeof scope.activeVehId === 'number' && Number(scope.activeVehId) !== Number(lessonVehId))) {
          setToast('Wait for the active vehicle identity before starting this temporary lesson');
          return false;
        }
        var j = {
          version: 1, createdAt: Date.now(), snapshot: snapshot(), storeRequired: !!scope.storeRequired,
          carName: scope.carName, vehId: lessonVehId,
          engineFp: (scope.base && scope.base.engineFp) || null,
          engineFpLegacy: (scope.base && scope.base.engineFpLegacy) || null,
          hasTurbo: !!(scope.base && scope.base.hasTurbo),
          wasControlling: !!scope.controlling
        };
        var key = lessonJournalKeyFor(j.carName, j.engineFp), raw;
        try {
          raw = JSON.stringify(j);
          localStorage.setItem(key, raw);
          if (localStorage.getItem(key) !== raw) { throw new Error('lesson backup verification failed'); }
        } catch (e) {
          try { localStorage.removeItem(key); } catch (e2) {}
          setToast('Could not create the temporary lesson backup - no calibration changes were made');
          return false;
        }
        installTemporaryLessonSession(j, key, captureTemporaryMixtureEvidence(), false);
        durSync();
        return true;
      }
      function restoreTemporaryLesson(silent, onConfirmed) {
        if (!temporaryLessonSession || !temporaryLessonSession.journal) { return false; }
        var session = temporaryLessonSession, j = session.journal;
        if (session.restorePending) {
          if (typeof onConfirmed === 'function') { session.restoreCallbacks.push(onConfirmed); }
          return true;
        }
        if (!lessonJournalMatchesCurrent(j) || !inMemoryLessonVehicleMatches(j)) {
          abandonTemporaryLessonForIdentityChange();
          return false;
        }
        // Write the rollback to the normal autosave BEFORE pushing it. The separate journal is
        // removed only after every restore step succeeds, making this idempotent after a crash.
        try {
          var original = JSON.stringify(j.snapshot);
          localStorage.setItem(calKey(), original);
          if (localStorage.getItem(calKey()) !== original) { throw new Error('rollback write failed'); }
        } catch (e) {
          setToast('Could not restore the temporary lesson yet - its recovery backup was kept');
          return false;
        }
        if (typeof onConfirmed === 'function') { session.restoreCallbacks.push(onConfirmed); }
        session.restorePending = true;
        session.restoreSilent = silent === true;
        controllerVehicleGuardId = j.vehId;
        if (!applySnapshot(j.snapshot, {
          confirmMapBankPackage: true,
          onMapBankConfirmed: function () {
            if (temporaryLessonSession !== session) { return; }
            // The token belongs to this exact State and the raw controller maps/settings/axes were
            // compared before this callback. It is now safe to consume the crash-recovery journal.
            if (applyTuneTimer) { clearTimeout(applyTuneTimer); applyTuneTimer = null; }
            applyTuneWaitedForUnpause = false;
            scope.applyingTune = false;
            saveAutosave();
            restoreTemporaryMixtureEvidence(session.mixture);
            if (!j.storeRequired) { callController('markStored()'); }
            scope.storeRequired = !!j.storeRequired;
            if (session.recoveryOnly) { pendingRecoveredLessonStoreRequired = !!j.storeRequired; }
            if (!j.wasControlling) { callController('setControl(0)'); scope.controlling = false; }
            controllerVehicleGuardId = session.previousVehicleGuard;
            controllerVehicleRouteId = session.previousVehicleRoute;
            controllerIdentityGuard = session.previousIdentityGuard;
            try { localStorage.removeItem(session.key); } catch (e2) {}
            var callbacks = session.restoreCallbacks.slice();
            temporaryLessonSession = null;
            durSync();
            if (session.recoveryOnly) { setToast('Recovered your calibration from an interrupted temporary lesson'); }
            else if (!session.restoreSilent) { setToast('Your pre-lesson calibration and Mixture Map evidence were restored'); }
            callbacks.forEach(function (callback) { callback(); });
          },
          onMapBankRejected: function () {
            if (temporaryLessonSession !== session) { return; }
            session.restorePending = false;
            session.restoreCallbacks = [];
            setToast('The ECU did not confirm the MAP restore - its recovery backup was kept');
          }
        })) {
          session.restorePending = false;
          session.restoreCallbacks = [];
          setToast('Could not restore the temporary lesson yet - its recovery backup was kept');
          return false;
        }
        setToast('Restoring your pre-lesson calibration...');
        return true;
      }
      function fpStashKey(fp) { return calKey() + '_fp_' + fp; }
      function compatibleFpStash() {
        var curFp = (scope.base && scope.base.engineFp) || null;
        if (!curFp) { return null; }
        try {
          var exactRaw = localStorage.getItem(fpStashKey(curFp));
          var exact = exactRaw ? JSON.parse(exactRaw) : null;
          if (exact && exact.maps && engineIdentityCompatible(exact, scope.base)) { return exact; }
        } catch (e) {}

        // One-time V2.6 -> E2 migration: an engine tune may have been stashed under the old
        // performance-derived fingerprint before this build existed. Scan only this car's small
        // stash namespace, validate the payload, then cache it under the strong key.
        try {
          var prefix = calKey() + '_fp_';
          var currentKey = fpStashKey(curFp);
          for (var i = 0; i < localStorage.length; i++) {
            var key = null, raw = null, candidate = null;
            // Per-entry isolation: one unparseable or unrelated stash must not abort the scan and
            // strand a perfectly good tune that is sitting a few keys further along.
            try {
              key = localStorage.key(i);
              if (!key || key.indexOf(prefix) !== 0 || key === currentKey) { continue; }
              raw = localStorage.getItem(key);
              candidate = raw ? JSON.parse(raw) : null;
            } catch (eEntry) { continue; }
            if (candidate && candidate.maps && engineIdentityCompatible(candidate, scope.base)) {
              // Caching under the strong key is an optimisation. If the write fails (quota, private
              // mode) the tune we already found is still returned - never lost over a failed cache.
              try { localStorage.setItem(currentKey, raw); } catch (eCache) {}
              return candidate;
            }
          }
        } catch (e2) {}
        return null;
      }
      // returns TRUE when it applied/handled a tune, FALSE when this car+engine has nothing saved -
      // callers use that to fall back to factory defaults instead of silently keeping the PREVIOUS
      // car's working settings on screen (the "fuel type follows me to new cars" report, V2.8 fix)
      function loadAutosave(options) {
        options = options || {};
        if (IS_MIRROR) { return false; }
        var applied = false;
        if (recoverTemporaryLessonJournal()) { loadDynoBaseline(); return true; }
        try {
          var raw = localStorage.getItem(calKey());
          var curFp = (scope.base && scope.base.engineFp) || null;
          if (raw) {
            var s = JSON.parse(raw);
            // ENGINE SWAP GUARD (V2.6): a tune written for a different ENGINE must never auto-apply -
            // the Vivace's gas tune cranked a swapped-in diesel at 2% injector duty (dead engine) and
            // limp-looped the next engine. The mismatched autosave is stashed under ITS fingerprint so
            // swapping the original engine back restores that tune, even after tuning the new engine.
            // Old saves without a fingerprint apply as before and get stamped on their next store.
            if (s && s.engineFp && curFp && !engineIdentityCompatible(s, scope.base)) {
              try { localStorage.setItem(fpStashKey(s.engineFp), raw); } catch (e2) {}
              var prev = compatibleFpStash();
              if (prev && prev.maps && applySnapshot(prev, options)) {
                callController('markStored()');   // the replayed state IS the persisted state - don't report it unsaved
                setToast(scope.tr('Engine changed - restored your tune for THIS engine'));
              } else {
                resetWorkingTune();
                setToast(scope.tr(prev && prev.maps
                  ? 'Engine changed - saved tune for this engine was invalid, using a fresh base tune'
                  : 'Engine changed - fresh base tune (your old tune is kept for the original engine)'));
              }
              loadDynoBaseline();
              return true;   // handled (applied the stash or reset) either way
            }
            // restore the FULL saved tune into the ECU, not just the name, so it
            // survives part changes and game restarts ("saved in the ECU")
            if (s && s.maps) { applied = applySnapshot(s, options); }
            else if (s && s.name) { scope.tuneName = s.name; applied = true; }
          }
        } catch (e) {}
        // V2.9.7 STORE-WIPE FIX part 3: the primary autosave failed to apply (missing, unparseable,
        // or reader-rejected - the poison class). Fall back to the previous Store generation instead
        // of presenting factory defaults. Deliberately NOT marked stored: live state now differs from
        // the bad primary, so Store Req invites a fresh Store, which re-persists the good package.
        if (!applied && tryPreviousStoreFallback(options)) {
          loadDynoBaseline();
          return true;
        }
        loadDynoBaseline();
        // The autosave replay reproduces exactly what localStorage holds - mark it stored so the
        // calibrationDirty flag means "differs from what the player persisted", not "spawned".
        // Queued AFTER the replayed setters on the same vehicle queue, so it adopts the final revision.
        if (applied) { callController('markStored()'); }
        return applied;
      }
      function tryPreviousStoreFallback(options, rawOverride) {
        if (IS_MIRROR) { return false; }
        try {
          var raw = rawOverride != null ? rawOverride : localStorage.getItem(prevCalKey());
          if (!raw) { return false; }
          var s = JSON.parse(raw);
          // same guard as the primary: a different ENGINE's package must never auto-apply
          var curFp = (scope.base && scope.base.engineFp) || null;
          if (s && s.engineFp && curFp && !engineIdentityCompatible(s, scope.base)) { return false; }
          if (!(s && s.maps && applySnapshot(s, options || {}))) { return false; }
          scope.storeRequired = true;
          setToast('Stored calibration was invalid - restored your previous Store ECU save');
          return true;
        } catch (e) { return false; }
      }
      scope.hasPrevStore = function () {
        if (IS_MIRROR) { return false; }
        try { return localStorage.getItem(prevCalKey()) != null; } catch (e) { return false; }
      };
      scope.restorePreviousStore = function () {
        if (IS_MIRROR) { setToast('Restore the previous Store on the game PC'); return; }
        if (pendingMapBankMutation) { setToast('Wait for the current MAP action to finish'); return; }
        // CAPTURE AT CLICK (review catch): the prompt's Save leg persists the working tune, whose
        // ECU ack runs saveAutosave -> rotation, overwriting _prev with a near-copy of the current
        // state BEFORE the continuation runs - the restore would then "restore" that copy and the
        // generation the user clicked for would be gone. Hold the payload; ignore later rotations.
        var prevRaw = null;
        try { prevRaw = localStorage.getItem(prevCalKey()); } catch (e) {}
        var run = function () {
          if (tryPreviousStoreFallback({}, prevRaw)) { setToast('Previous Store ECU save restored - press Store ECU to keep it'); }
          else { setToast('Previous Store could not be restored (missing, different engine, or invalid)'); }
        };
        // replacing working edits deserves the standard Save / Discard / Cancel prompt
        if (deferWorkingTuneAction('Restore previous Store ECU save', run)) { return; }
        run();
      };
      // a DIFFERENT vehicle with NO stored tune was loaded - show this car's clean factory defaults
      // (scope.maps/settings already hold them). Deliberately saves NOTHING: the autosave is only written
      // when the player edits or hits Store, so switching cars can never overwrite a stored tune (the old
      // saveAutosave() here was wiping the new car's saved tune with defaults on every switch).
      function resetWorkingTune() {
        invalidateMixture('working tune reset');
        scope.tuneName = 'Base';
        scope.applyingTune = false;
        // firstLink has already installed this controller's metadata and live factory body. Seed
        // only its configured active slot so a new engine/car never borrows a saved body retained
        // by the previously selected package.
        seedInitialMapBankBody();
        loadDynoBaseline();
        setToast('New vehicle - ECU on factory defaults');
      }
      scope.store = function () {
        if (IS_MIRROR) { setToast('Store the calibration on the game PC'); return; }
        if (pendingMapBankMutation) { setToast('Wait for the current MAP action to finish before Store ECU'); return; }
        if (!saveAutosave()) { scope.storeRequired = true; setToast(autosaveBlockReason || 'Calibration could not be saved - Store ECU was not acknowledged'); return; }
        // V3 calibration versioning: advance the controller's stored revision so its
        // calibrationDirty flag clears on the next broadcast (the mirror's UNSAVED badge
        // reads it). Bare markStored() adopts the controller's current revision; the
        // exported M.markStored(revision, epoch) validates both when provided. On an
        // older cached controller the type()=="function" bridge guard makes it a no-op.
        callController('markStored()');
        scope.storeRequired = false;
        scope.calibrationDirty = false;
        setToast('Calibration stored (' + scope.tuneName + ')' + persistenceRepairSuffix());
      };
      function applyPresetNow(id) {
        if (pendingMapBankMutation) { setToast('Wait for the current MAP action to finish'); return; }
        if (!IS_MIRROR) { localCalibrationMutationGeneration += 1; }
        var presetQueued = callController('applyPreset("' + id + '")');
        if (IS_MIRROR) {
          // The shim returns a real transport decision: false = blocked (no writer lease /
          // editing locked game-side), anything queued reaches the live ECU and echoes back.
          setToast(presetQueued === false ? 'Enable Pro Studio editing and reset access on the game PC first' : 'Preset requested from the live ECU...');
          return;
        }
        // V2.9 fix: the controller's applyPreset mutates settings on its own (mid-session broadcasts
        // never adopt settings into the UI), so mirror those exact mutations here - otherwise the
        // sliders show stale values, and the next unpause re-push would silently write them back
        // over the preset without the mixture/fuel-learning invalidation a real change deserves.
        if (scope.settings) {
          scope.settings.enabled = true;
          scope.settings.globalFuelTrim = 0;
          scope.settings.globalIgnitionTrim = 0;
          scope.settings.knockControl = (id !== 'bad');
          scope.settings.protection = (id !== 'bad');
          if (id === 'stock' || id === 'stage1') { scope.settings.revLimit = 0; }
          if (id === 'stock') {
            var stockRevLim = (scope.base && scope.base.revLimiterRPM) || 7000;
            scope.settings.launchLimit = Math.max(2500, Math.min(5500, stockRevLim));
          }
          seedMixSettingsMirror();
        }
        // Stock == factory reset, so restore factory injector sizing too. The controller's applyPreset
        // leaves injectors alone and the UI owns the slider, so set + push it here to stay in sync.
        if (id === 'stock' && scope.factoryInjFlow && scope.settings) {
          scope.settings.injectorFlow = scope.factoryInjFlow; send('injectorFlow', scope.factoryInjFlow);
        }
        var loadedPresetName = { stock: 'Stock Base', stage1: 'Safe Stage 1', aggressive: 'Aggressive', bad: 'Bad lean demo' }[id] || 'Preset';
        scope.storeRequired = true; setToast(loadedPresetName + ' loaded as working tune');
      }
      scope.applyPreset = function (id) {
        id = String(id || '');
        var presetName = { stock: 'Stock Base', stage1: 'Safe Stage 1', aggressive: 'Aggressive', bad: 'Bad lean demo' }[id] || 'preset';
        if (deferWorkingTuneAction('Load ' + presetName, function () { applyPresetNow(id); })) { return; }
        applyPresetNow(id);
      };
      // FULL factory reset: every setting, table, axis, damage and fault back to a fresh spawn.
      // Two-click arm because it's the most destructive button in the app (lesson from the per-table
      // Reset report). The controller reseeds itself and broadcasts; awaitingFactorySettings lets the
      // state handler accept that broadcast (mid-session it normally trusts the UI's own settings).
      scope.frArmed = false;
      var frSeqAtReset = null;   // V2.9: frSeq seen when the confirm click ran - a DIFFERENT sequence marks the post-reset broadcast
      var frArmTimer = null;     // handle so an old first-click timeout cannot disarm a newer confirmation window
      var frValveTimer = null;   // V2.9: handle for the safety valve so a second reset can't be cleared by the first one's timer
      // V2.9.7 (Store-wipe wave 2): which flow armed awaitingFactorySettings. A REMOTE-accepted arm
      // must not treat frSeqAtReset===null as accept-any-broadcast - under a cached pre-frSeq
      // controller (mod update without a full restart) the very next ordinary broadcast (e.g. the
      // markStored echo Store itself triggers) would satisfy the factory predicate and run the wipe
      // block. Remote arms require frSeq/epoch evidence and fall back to valve expiry instead.
      var frArmRemote = false;
      scope.factoryReset = function () {
        if (pendingRemoteFactoryResetIntent) { setToast('Wait for the remote Factory Reset check to finish'); return; }
        // V2.9 fix (version skew): factoryReset only exists on the 2.9 controller - don't claim a wipe
        // an older cached controller can't perform (and don't arm a wait for a broadcast that never comes).
        if (!controllerMatchesUi()) {
          setToast('The UI and vehicle controller versions do not match - restart the game before Factory reset');
          return;
        }
        // V2.9 fix: vehicle Lua doesn't run while the game is paused - the queued reset would execute
        // on unpause AFTER the safety valve already dropped the acceptance flag (sliders would keep
        // the old values), and paused edits re-pushed on unpause could overwrite the fresh reset.
        if (scope.gamePaused) {
          setToast(IS_MIRROR ? 'Game paused on the tuning PC — un-pause it there first' : 'Un-pause the game first - the ECU cannot run commands while paused');
          return;
        }
        if (!scope.frArmed) {
          scope.frArmed = true;
          setToast('Factory reset EVERYTHING? Click again to confirm');
          if (frArmTimer) { clearTimeout(frArmTimer); }
          frArmTimer = setTimeout(function () { frArmTimer = null; scope.frArmed = false; scope.$applyAsync(); }, 5000);
          return;
        }
        if (pendingMapBankMutation) { setToast('Wait for the current MAP action to finish'); return; }
        scope.frArmed = false;
        if (frArmTimer) { clearTimeout(frArmTimer); frArmTimer = null; }
        if (IS_MIRROR) {
          // The reset executes on the live ECU; the post-reset broadcast repaints this mirror.
          // The frSeq valve machinery below is the game PC's own acceptance flow - never armed here.
          var resetQueued = callController('factoryReset()');
          setToast(resetQueued === false ? 'Enable Pro Studio editing and reset access on the game PC first' : 'Factory reset requested from the live ECU...');
          return;
        }
        // Persist the destructive intent before vehicle Lua is queued. If the app/game closes in
        // the next frame, the next trusted UI link completes the reset instead of replaying the
        // pre-reset autosave. Refuse the reset when this recovery marker cannot be verified.
        if (!writeFactoryResetIntent()) {
          setToast('Factory Reset was not sent because its recovery marker could not be saved');
          return;
        }
        frSeqAtReset = (typeof scope.lastFrSeq === 'number') ? scope.lastFrSeq : null;
        supersedePendingMapBankMutation(true);
        localCalibrationMutationGeneration += 1;
        frArmRemote = false;   // in-game confirm: the wildcard stays trusted for this arm
        scope.awaitingFactorySettings = true;
        // V2.9: safety valve - if the post-reset broadcast never lands (controller torn down mid-reset),
        // drop the one-shot flag so it can't swallow an ordinary broadcast minutes later.
        if (frValveTimer) { clearTimeout(frValveTimer); }
        frValveTimer = setTimeout(function () { scope.awaitingFactorySettings = false; scope.$applyAsync(); }, 6000);
        callController('factoryReset()');
        scope.baselineMaps = {};
        scope.mixBins = null;          // learned mixture data described the old tune
        scope.storeRequired = true;
        setToast('Factory reset — tables, axes and every setting back to stock');
      };
      function rebuildSlots() {
        scope.slots = [0, 1, 2, 3].map(function (i) { var raw = null; try { raw = localStorage.getItem(slotKey(i)); } catch (e) {} var name = 'Empty'; try { if (raw) { name = (JSON.parse(raw).name) || ('Backup ' + (i + 1)); } } catch (e) {} return { index: i, saved: !!raw, name: name }; });
        scope.library = [];
        try { var lib = JSON.parse(localStorage.getItem(libKey()) || '[]'); if (Array.isArray(lib)) { scope.library = lib; } } catch (e) {}
        if (scope.refreshTuneFiles) { scope.refreshTuneFiles(); }   // shared .oeptune.json files list rides along
        publishTuneCatalog();
      }
      scope.saveSlot = function (s) {
        if (IS_MIRROR) { setToast('Save quick-save backups on the game PC'); return; }
        var snap = validatedSnapshotForPersistence();                            // Quick Save: copy only, never mutates live state
        if (!snap) {
          setToast('Quick Save not saved: ' + (snapshotValidationReason || 'calibration validation failed'));
          return;
        }
        var raw;
        try {
          raw = JSON.stringify(snap);
          localStorage.setItem(slotKey(s.index), raw);
          if (localStorage.getItem(slotKey(s.index)) !== raw) { throw new Error('write verification failed'); }
        } catch (e) {
          setToast('Quick Save failed - the previous backup was kept');
          return;
        }
        var repairSuffix = persistenceRepairSuffix();
        rebuildSlots();
        durSync();
        setToast('Saved ' + scope.tuneName + ' to backup ' + (s.index + 1) + repairSuffix);
      };
      function loadSlotNow(s) {
        if (IS_MIRROR) { requestRemoteTune('slot', s && s.index); return; }
        var raw = null;
        try { raw = localStorage.getItem(slotKey(s.index)); } catch (e) {}
        if (!raw) { setToast('Backup empty'); return; }
        try {
          var loadedBackupMode = applySnapshot(JSON.parse(raw), { userLoad: true });
          if (loadedBackupMode) {
            saveAutosave();
            if (loadedBackupMode === true) { setToast('Loaded backup ' + (s.index + 1)); }
          }
        } catch (e2) { setToast('Quick-save backup is damaged - nothing was loaded'); }
      }
      scope.loadSlot = function (s) {
        var label = 'Load backup ' + ((Number(s && s.index) || 0) + 1);
        if (deferWorkingTuneAction(label, function () { loadSlotNow(s); })) { return; }
        loadSlotNow(s);
      };
      scope.clearSlot = function (s) { if (IS_MIRROR) { return; } try { localStorage.removeItem(slotKey(s.index)); } catch (e) {} rebuildSlots(); durSync(); };
      scope.saveLibrary = function () {
        if (IS_MIRROR) { setToast('Save tune libraries on the game PC'); return; }
        var snap = validatedSnapshotForPersistence();                            // Tune Library: copy only, never mutates live state
        if (!snap) {
          setToast('Tune Library not saved: ' + (snapshotValidationReason || 'calibration validation failed'));
          return;
        }
        var lib = angular.copy(scope.library || []); var name = (scope.tuneName || 'tune').slice(0, 40);
        var entry = { name: name, data: snap };
        var found = false; for (var i = 0; i < lib.length; i++) { if (lib[i].name === name) { lib[i] = entry; found = true; } }
        if (!found) { if (lib.length >= 20) { setToast('Library full (20)'); return; } lib.push(entry); }
        try {
          var raw = JSON.stringify(lib);
          localStorage.setItem(libKey(), raw);
          if (localStorage.getItem(libKey()) !== raw) { throw new Error('write verification failed'); }
        } catch (e) {
          setToast('Tune Library save failed - the previous library was kept');
          return;
        }
        var repairSuffix = persistenceRepairSuffix();
        rebuildSlots();
        durSync();
        setToast((found ? 'Updated ' : 'Saved ') + name + repairSuffix);
      };
      function openLibraryNow(entry) {
        if (IS_MIRROR) { requestRemoteTune('library', entry && entry.index); return; }
        var openedLibraryMode = applySnapshot(entry.data, { userLoad: true });
        if (openedLibraryMode) {
          saveAutosave();
          if (openedLibraryMode === true) { setToast('Opened ' + entry.name); }
        }
      }
      scope.openLibrary = function (entry) {
        var label = 'Open ' + String((entry && entry.name) || 'saved tune');
        if (deferWorkingTuneAction(label, function () { openLibraryNow(entry); })) { return; }
        openLibraryNow(entry);
      };
      scope.deleteLibrary = function (entry) {
        if (IS_MIRROR) { return; }
        scope.library = (scope.library || []).filter(function (e) { return e !== entry; });
        try { localStorage.setItem(libKey(), JSON.stringify(scope.library)); } catch (e) {}
        // Deletion shifts zero-based library indices. Rebuild and republish now so
        // no remote catalog can point an old label at a different tune.
        rebuildSlots(); durSync();
      };

      // ====================================================================
      // TUNE SHARING (V2_3): "tune codes" you can paste in any chat + .oeptune.json files.
      // A compact code carries the active calibration only; readable tune files carry the
      // active calibration plus all four switchable MAP banks.
      // variable-width LZW into a 64-char chat-safe alphabet - roundtrip-verified in node over
      // ~950 randomized cases before shipping. Imports go through applySnapshot, so every value
      // gets the same clamps/guards as loading a slot (a tampered code can only be a BAD TUNE).
      // ====================================================================
      var OEP_B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$-';
      function oepPack(str) {
        var dict = {}, dictSize = 256, w = '', codes = [];
        for (var i = 0; i < 256; i++) { dict[String.fromCharCode(i)] = i; }
        for (var i2 = 0; i2 < str.length; i2++) {
          var c = str.charAt(i2);
          var wc = w + c;
          if (dict[wc] !== undefined) { w = wc; }
          else { codes.push(dict[w]); dict[wc] = dictSize++; w = c; }
        }
        if (w !== '') { codes.push(dict[w]); }
        // width for code #k = bitsFor(256 + k): the dict size as the DECODER knows it just before
        // reading code #k (it's one entry behind the encoder) - both sides derive it identically
        var bits = 0, bitBuf = 0, out = [];
        function bitsFor(n) { var b = 9; while ((1 << b) < n + 1) { b++; } return b; }
        for (var k = 0; k < codes.length; k++) {
          var width = bitsFor(256 + k);
          bitBuf = (bitBuf << width) | codes[k];
          bits += width;
          while (bits >= 6) {
            bits -= 6;
            out.push(OEP_B64.charAt((bitBuf >> bits) & 63));
          }
          bitBuf = bitBuf & ((1 << bits) - 1);
        }
        if (bits > 0) { out.push(OEP_B64.charAt((bitBuf << (6 - bits)) & 63)); }
        return out.join('');
      }
      function oepUnpack(packed) {
        if (packed === '') { return ''; }
        var rev = {};
        for (var i = 0; i < 64; i++) { rev[OEP_B64.charAt(i)] = i; }
        var dict = [], dictSize = 256;
        for (var j = 0; j < 256; j++) { dict[j] = String.fromCharCode(j); }
        var bitBuf = 0, bits = 0, pos = 0;
        function bitsFor(n) { var b = 9; while ((1 << b) < n + 1) { b++; } return b; }
        function readCode(width) {
          while (bits < width) {
            if (pos >= packed.length) { return null; }
            var v = rev[packed.charAt(pos++)];
            if (v === undefined) { return null; }
            bitBuf = (bitBuf << 6) | v;
            bits += 6;
          }
          bits -= width;
          var code = (bitBuf >> bits) & ((1 << width) - 1);
          bitBuf = bitBuf & ((1 << bits) - 1);
          return code;
        }
        var k = 0;
        var first = readCode(bitsFor(256 + k)); k++;
        if (first === null || first > 255) { return null; }
        var w = dict[first], result = [w];
        while (true) {
          var width = bitsFor(256 + k);
          if (pos >= packed.length && bits < width) { break; }
          var code = readCode(width); k++;
          if (code === null) { break; }
          var entry;
          if (dict[code] !== undefined) { entry = dict[code]; }
          else if (code === dictSize) { entry = w + w.charAt(0); }   // the classic KwKwK case
          else { return null; }   // corrupt
          result.push(entry);
          dict[dictSize++] = w + entry.charAt(0);
          w = entry;
        }
        return result.join('');
      }
      function oepSum(s) {   // 4-char base36 checksum so a mangled paste fails cleanly, not weirdly
        var h = 0;
        for (var i = 0; i < s.length; i++) { h = (h * 31 + s.charCodeAt(i)) % 1679616; }
        var out = h.toString(36);
        while (out.length < 4) { out = '0' + out; }
        return out;
      }
      // maps rounded to their meta step (lossless vs what the ECU stores) so codes stay small
      function roundMapsForShare(maps) {
        var dp = { fuel: 2, ignition: 1, boost: 0, lambda: 2, ve: 0, wgduty: 0 };
        var out = {};
        ['fuel', 'ignition', 'boost', 'lambda', 've', 'wgduty'].forEach(function (name) {
          var f = Math.pow(10, dp[name]);
          out[name] = (maps && maps[name] || []).map(function (row) { return row.map(function (v) { return Math.round((Number(v) || 0) * f) / f; }); });
        });
        return out;
      }
      // FROZEN v1 share order - APPEND-ONLY: tune CODES store settings as a keyless value array in
      // exactly this order (the ~95 key names don't compress and blew codes past Discord's 2000-char
      // limit). New settings must be APPENDED here, never inserted/reordered - old codes then simply
      // come up short and the missing tail falls back to defaults via normalizeSettings.
      var SHARE_KEYS_V1 = ['enabled','protection','revLimit','launchEnabled','launchLimit','drivetrainLoss','flatShiftEnabled','flatShiftRpm','launchSoundVol','rollingEnabled','hardCut','globalFuelTrim','globalIgnitionTrim','boostMode','boostBaseDuty','boostPGain','boostIGain','boostOvercut','knockControl','knockThreshold','knockMaxRetard','knockRecoveryRate','egtLimit','canMode','canSpeed','canRuntime','canLambda','canDash','auxFanOnTemp','auxFuelPumpPrime','auxShiftLight','auxCheckEngine','sensorMapOffset','sensorMapScale','sensorTpsOffset','sensorTpsScale','sensorIatBias','sensorEctBias','sensorLambdaBias','sensorFuelPressureBias','crankEnrich','postStartEnrich','postStartDecay','warmupEnrich','warmupEndTemp','accelEnrich','accelDecay','wallWetting','decelFuelCut','decelFuel','fuelEquationMode','masterFuel','injectorFlow','injectorDeadtime','stoichRatio','fuelType','cllMode','cllEctLockout','cllTpsLockout','cllMapLockout','idleEnabled','idleTargetRpm','idleBasePos','idleIgnControl','cylTrim1','cylTrim2','cylTrim3','cylTrim4','cylTrim5','cylTrim6','cylTrim7','cylTrim8','antilagEnabled','antilagRetard','antilagFuel','nitrousEnabled','nitrousShot','nitrousArmRpm','nitrousArmTps','nitrousFuel','nitrousRetard','dieselMaxFuel','dieselRailPressure','dieselSmokeLimit','glowPlugs','vvtEnabled','vvtIntakeAdvance','ethrottleMode','ethrottleCurve','ethrottleTolerance','triggerPattern','popsLevel','overrunThrottle','overrunMinRpm','overrunPopVol','popSoundMode','fxStyle','rotIdleEnabled','rotIdleRpm','rotIdleChop','launchFuel','launchRetard','knockAudio','wgType','smokeDensity','autoFitAxes','boostShapeMode','boostRampUpKpaS','boostRampDownKpaS','boostShiftHoldMs','boostGear1TargetTrimKpa','boostGear1DutyTrimPct','boostGear2TargetTrimKpa','boostGear2DutyTrimPct','boostGear3TargetTrimKpa','boostGear3DutyTrimPct','boostGear4TargetTrimKpa','boostGear4DutyTrimPct','boostGear5TargetTrimKpa','boostGear5DutyTrimPct','boostGear6TargetTrimKpa','boostGear6DutyTrimPct','boostGear7TargetTrimKpa','boostGear7DutyTrimPct','boostGear8TargetTrimKpa','boostGear8DutyTrimPct','boostSpeed1TargetTrimKpa','boostSpeed1DutyTrimPct','boostSpeed2TargetTrimKpa','boostSpeed2DutyTrimPct','boostSpeed3TargetTrimKpa','boostSpeed3DutyTrimPct','boostSpeed4TargetTrimKpa','boostSpeed4DutyTrimPct','boostSpeed5TargetTrimKpa','boostSpeed5DutyTrimPct','boostSpeed6TargetTrimKpa','boostSpeed6DutyTrimPct','baseTiming','compBrakeEnabled','compBrakeLevel','dieselNozzleEnabled','dieselNozzleFlow','dieselSmokeLimiter','shiftFirmness','shiftTorqueMgmt','torqueLimitNm','torqueLimitLowGears','converterStall','clutchUpgrade','loadSource','limiterCutMode','limiterRange','limiterCutMs'];   // V3.1: APPEND ONLY. This list is POSITIONAL - inserting or reordering silently re-maps every tune code already in the wild into plausible-but-wrong values. Do not bump the payload stamp v:1 either; older builds test p.v !== 1 by exact equality and would reject the whole code.
      function settingsToArray(s) {
        return SHARE_KEYS_V1.map(function (k) {
          var v = s[k];
          if (v === true) { return 1; }
          if (v === false) { return 0; }
          return (v === undefined || v === null) ? null : v;
        });
      }
      function settingsFromArray(arr) {
        var out = {};
        for (var i = 0; i < SHARE_KEYS_V1.length && i < arr.length; i++) {
          if (arr[i] !== null && arr[i] !== undefined) { out[SHARE_KEYS_V1[i]] = arr[i]; }
        }
        return out;
      }
      // compact=true -> keyless settings array (tune CODES, size-critical);
      // otherwise readable keyed settings (tune FILES, no size limit)
      function sharePayload(compact, snapOverride) {
        var snap = snapOverride || snapshot();
        var payload = { v: 1, n: snap.name, car: scope.carName, ht: scope.base.hasTurbo ? 1 : 0,
          s: compact ? settingsToArray(snap.settings) : angular.copy(snap.settings),
          m: roundMapsForShare(snap.maps),
          ra: snap.rpmAxis.slice(), ma: snap.mapAxis.slice(), an: snap.alphaNAxis.slice() };
        if (!compact) {
          payload.fp = snap.engineFp || null;
          payload.fpl = snap.engineFpLegacy || null;
          payload.mapBank = angular.copy(snap.mapBank);
        }
        return payload;
      }
      function copyText(txt) {
        try {
          var ta = document.createElement('textarea');
          ta.value = txt;
          ta.style.position = 'fixed'; ta.style.opacity = '0';
          document.body.appendChild(ta);
          ta.select();
          var ok = document.execCommand('copy');
          document.body.removeChild(ta);
          return ok;
        } catch (e) { return false; }
      }
      scope.shareCode = ''; scope.shareCodeIn = ''; scope.shareFiles = [];
      // Discord's per-message ceiling, the narrowest of the places these codes actually get pasted.
      var SHARE_CODE_CHAT_LIMIT = 1900;   // 2000 minus room for the surrounding message text
      scope.makeTuneCode = function () {
        try {
          // unescape(encodeURIComponent(x)) = UTF-8 byte string: every charCode <= 255 for the LZW
          // dict, JSON syntax stays 1 char each (encodeURIComponent alone tripled it past chat limits)
          var packed = oepPack(unescape(encodeURIComponent(JSON.stringify(sharePayload(true)))));
          scope.shareCode = 'OEPT1:' + packed + ':' + oepSum(packed);
          // V3.0.7: a tune code has to survive being pasted into a chat message, and Discord cuts at
          // 2000 characters. Six maps at the factory 42 cells fits comfortably; at 256 cells it does
          // not, and a code that silently arrives truncated imports as a corrupt tune rather than
          // failing cleanly. Warn at the point of copying - the tune FILE has no such limit and is
          // the right answer for a fine grid, so say that instead of just refusing.
          if (scope.shareCode.length > SHARE_CODE_CHAT_LIMIT) {
            setToast('Tune code is ' + scope.shareCode.length + ' chars - too long for most chat apps (limit ~'
              + SHARE_CODE_CHAT_LIMIT + '). It is below and still valid, but use Tune Files → Save to File '
              + 'to share a ' + scope.gridRows() + ' x ' + scope.gridCols() + ' calibration.');
            return;
          }
          setToast(copyText(scope.shareCode) ? ('Tune code copied (' + scope.shareCode.length + ' chars) - paste it anywhere') : 'Code ready below - select and copy it');
        } catch (e) { setToast('Could not build the tune code'); }
      };
      // shared import path: code and file both land here -> applySnapshot (all the usual guards/clamps)
      function importPayloadNow(p, sourceLabel) {
        if (!p || p.v !== 1 || !p.m || !p.m.fuel || !p.s) { setToast('Not a valid tune (' + sourceLabel + ')'); return false; }
        var st = Array.isArray(p.s) ? settingsFromArray(p.s) : p.s;   // codes carry the keyless array, files the keyed object
        var snap = {
          name: p.n || 'Shared tune',
          hasTurbo: p.ht === undefined ? undefined : p.ht === 1,
          engineFp: p.fp || null,
          engineFpLegacy: p.fpl || null,
          settings: st, maps: p.m, rpmAxis: p.ra, mapAxis: p.ma, alphaNAxis: p.an, mapBank: p.mapBank
        };
        var importMode = applySnapshot(snap, { userLoad: true });
        if (!importMode) { setToast('Tune could not be applied'); return false; }
        saveAutosave();
        if (importMode !== 'draft-reset') {
          var msg = importMode === 'draft' ? draftLoadHint(snap.name) : ('Loaded "' + snap.name + '"');
          if (p.car && p.car !== scope.carName) { msg += ' - made for ' + p.car + ', you are in ' + scope.carName + ': check it on the dyno'; }
          setToast(msg);
        } else if (p.car && p.car !== scope.carName) {
          // Keep the induction warning AND the cross-car warning: re-issue both in one toast.
          setToast('Induction changed - MAP/boost tables reset for the new hardware - made for ' + p.car + ', you are in ' + scope.carName + ': check it on the dyno');
        }
        return true;
      }
      function importPayload(p, sourceLabel) {
        if (!p || p.v !== 1 || !p.m || !p.m.fuel || !p.s) { return importPayloadNow(p, sourceLabel); }
        var label = 'Load ' + String(p.n || 'shared tune');
        if (deferWorkingTuneAction(label, function () { importPayloadNow(p, sourceLabel); })) { return false; }
        return importPayloadNow(p, sourceLabel);
      }
      scope.importTuneCode = function () {
        var raw = String(scope.shareCodeIn || '').replace(/\s+/g, '');   // Discord line-wraps long codes; whitespace is never part of one
        var m = raw.match(/^OEPT1:([A-Za-z0-9$\-]+):([a-z0-9]{4})$/);
        if (!m) { setToast('Not a tune code (should start with OEPT1:)'); return; }
        if (oepSum(m[1]) !== m[2]) { setToast('Tune code is damaged (checksum failed) - re-copy the whole thing'); return; }
        try {
          var bytes = oepUnpack(m[1]);
          if (bytes === null) { setToast('Tune code is damaged - re-copy the whole thing'); return; }
          importPayload(JSON.parse(decodeURIComponent(escape(bytes))), 'code');   // escape() reverses the UTF-8 byte-string step
          scope.shareCodeIn = '';
        } catch (e) { setToast('Tune code is damaged - re-copy the whole thing'); }
      };
      // ---- tune FILES (.oeptune.json in settings/oeProTune/tunes/) ----
      function tuneFileDir() { return '/settings/oeProTune/tunes/'; }
      scope.exportTuneFile = function () {
        if (IS_MIRROR) { setToast('Export tune files from the game PC'); return; }
        if (typeof bngApi === 'undefined' || !bngApi.engineLua) { setToast('Not available here'); return; }
        var snap = validatedSnapshotForPersistence({ requireCompleteBank: false });  // Export: last rescue path, must stay open
        if (!snap) {
          setToast('Tune file not saved: ' + (snapshotValidationReason || 'calibration validation failed'));
          return;
        }
        var fname = (String(scope.carName || 'car') + ' - ' + String(scope.tuneName || 'tune')).replace(/[^a-z0-9 _\-]/gi, '_') + '.oeptune.json';
        var path = tuneFileDir() + fname;
        bngApi.engineLua('jsonWriteFile(' + luaQuote(path) + ', jsonDecode(' + luaQuote(JSON.stringify(sharePayload(false, snap))) + '), true)');
        setToast('Saved ' + fname + persistenceRepairSuffix() + ' - use Open folder to grab it');
        setTimeout(function () { scope.refreshTuneFiles(); }, 400);
      };
      scope.refreshTuneFiles = function () {
        if (IS_MIRROR) { return; }
        if (typeof bngApi === 'undefined' || !bngApi.engineLua) { return; }
        bngApi.engineLua("FS:findFiles('" + tuneFileDir() + "', '*.oeptune.json', 0, true, false)", function (files) {
          scope.$applyAsync(function () {
            scope.shareFiles = (Array.isArray(files) ? files : []).map(function (p) {
              return { path: p, name: String(p).split('/').pop().replace(/\.oeptune\.json$/, '') };
            });
            publishTuneCatalog();
          });
        });
      };
      scope.loadTuneFile = function (f) {
        var fileLabel = 'Load ' + String((f && f.name) || 'tune file');
        if (deferWorkingTuneAction(fileLabel, function () { scope.loadTuneFile(f); })) { return; }
        if (IS_MIRROR) { requestRemoteTune('file', f && f.index); return; }
        if (!f || typeof bngApi === 'undefined' || !bngApi.engineLua) { return; }
        var loadIdentity = currentPackageIdentity(), loadPath = String(f.path || '');
        bngApi.engineLua('jsonReadFile(' + luaQuote(loadPath) + ')', function (payload) {
          scope.$applyAsync(function () {
            if (!packageIdentityMatches(loadIdentity)) {
              setToast('Tune load cancelled because the vehicle or engine changed');
              return;
            }
            importPayload(payload, 'file');
          });
        });
      };
      function reportRemoteTuneLoad(request, ok, error) {
        if (!request || typeof bngApi === 'undefined' || !bngApi.engineLua) { return; }
        var vehId = Number(request.vehicleId);
        if (!isFinite(vehId)) { return; }
        bngApi.engineLua('if oeProTuneRemote and oeProTuneRemote.tuneLoadResult then oeProTuneRemote.tuneLoadResult(' +
          luaQuote(String(request.requestId || '')) + ', ' + Math.floor(vehId) + ', ' + (ok ? 'true' : 'false') + ', ' + luaQuote(String(error || '')) + ') end');
      }
      function calibrationScalarMatches(key, want, got) {
        if (key === 'revLimit' && Number(want) <= 0 && (got === undefined || got === null || Number(got) <= 0)) { return true; }
        if (want === got) { return true; }
        if (want === undefined || want === null || got === undefined || got === null) { return false; }
        var a = Number(want), b = Number(got);
        return isFinite(a) && isFinite(b) && Math.abs(a - b) <= 0.0001;
      }
      function calibrationArrayMatches(want, got) {
        if (!Array.isArray(want) || !Array.isArray(got) || want.length !== got.length) { return false; }
        for (var i = 0; i < want.length; i++) {
          if (Array.isArray(want[i])) {
            if (!calibrationArrayMatches(want[i], got[i])) { return false; }
          } else if (!calibrationScalarMatches('', want[i], got[i])) { return false; }
        }
        return true;
      }
      function calibrationMatchesState(expected, state) {
        if (!expected || !state || !state.settings || !state.maps) { return false; }
        if (typeof state.vehId === 'number' && state.vehId !== expected.vehicleId) { return false; }
        var settingKeys = Object.keys(expected.settings || {});
        for (var i = 0; i < settingKeys.length; i++) {
          var key = settingKeys[i];
          if (!calibrationScalarMatches(key, expected.settings[key], state.settings[key])) { return false; }
        }
        var mapKeys = Object.keys(expected.maps || {});
        for (var j = 0; j < mapKeys.length; j++) {
          var mapName = mapKeys[j];
          if (!calibrationArrayMatches(expected.maps[mapName], state.maps[mapName])) { return false; }
        }
        return calibrationArrayMatches(expected.rpmAxis, state.rpmAxis) &&
          calibrationArrayMatches(expected.mapAxis, state.mapAxis) &&
          (!expected.alphaNAxis || calibrationArrayMatches(expected.alphaNAxis, state.alphaNAxis));
      }
      function finishRemoteTuneLoad(ok, error) {
        var pending = activeRemoteTuneLoad;
        if (!pending) { return; }
        activeRemoteTuneLoad = null;
        if (pending.timer) { clearTimeout(pending.timer); }
        reportRemoteTuneLoad(pending.request, ok, error);
        setToast(!ok ? ('Pro Studio tune load failed: ' + (error || 'ECU did not confirm'))
          : pending.loadMode === 'draft'
            ? ('Loaded ' + pending.label + ' from Pro Studio as the working tune - Save to M1-M4 to keep it on a MAP')
          : pending.loadMode === 'draft-reset'
            ? ('Loaded ' + pending.label + ' from Pro Studio - induction changed, MAP/boost tables were reset; settings applied as the working tune')
            : ('Loaded ' + pending.label + ' from Pro Studio'));
      }
      function armRemoteTuneLoadTimeout(pending, delay) {
        if (!pending || activeRemoteTuneLoad !== pending) { return; }
        if (pending.timer) { clearTimeout(pending.timer); }
        delay = Number(delay);
        if (!(delay > 0)) { delay = scope.gamePaused ? 250 : 6000; }
        pending.timer = setTimeout(function () {
          scope.$applyAsync(function () {
            if (activeRemoteTuneLoad !== pending) { return; }
            pending.timer = null;
            if (scope.gamePaused) {
              pending.waitedForUnpause = true;
              armRemoteTuneLoadTimeout(pending, 250);
              return;
            }
            if (pending.waitedForUnpause) {
              pending.waitedForUnpause = false;
              armRemoteTuneLoadTimeout(pending, 6000);
              return;
            }
            finishRemoteTuneLoad(false, 'ecu_confirmation_timeout');
          });
        }, delay);
      }
      function checkRemoteTuneConfirmation(state) {
        var pending = activeRemoteTuneLoad;
        if (!pending || !pending.expected) { return; }
        if (calibrationMatchesState(pending.expected, state)) { finishRemoteTuneLoad(true, ''); }
      }
      function applyResolvedRemoteTune(request, snap, label) {
        if (!activeRemoteTuneLoad || activeRemoteTuneLoad.request.requestId !== request.requestId) { return; }
        var ok = false, error = '';
        try {
          // Remote "Open tune" behaves like the local one: a single-tune (legacy) entry loads as a
          // working draft and leaves the four MAP slots alone; packages restore whole.
          var remoteLoadMode = applySnapshot(snap, { userLoad: true });
          ok = !!remoteLoadMode;
          if (ok) {
            saveAutosave();
            activeRemoteTuneLoad.label = label;
            activeRemoteTuneLoad.loadMode = remoteLoadMode;
            var expectedSettings = angular.copy(scope.settings);
            // The controller legally transforms two pushed scalars, so exact-echo comparing them
            // can time out a tune that fully applied (false 'tune load failed'):
            // - stoichRatio is re-derived whenever the pushed fuelType differs from the ECU's,
            //   and the push order follows saved-JSON key order - accept whatever the ECU echoes.
            // - revLimit is clamped to [idle+700, this engine's ceiling]; mirror the clamp.
            delete expectedSettings.stoichRatio;
            var expRl = Number(expectedSettings.revLimit);
            if (expRl > 0 && scope.base) {
              var rlLo = (Number(scope.base.idleRPM) || 800) + 700;
              var rlHi = Number(scope.base.revLimitCeilingRPM) || Number(scope.base.revLimiterRPM) || 0;
              if (rlHi > 0) { expectedSettings.revLimit = Math.min(Math.max(expRl, rlLo), rlHi); }
            }
            activeRemoteTuneLoad.expected = {
              vehicleId: Number(request.vehicleId),
              settings: expectedSettings, maps: angular.copy(scope.maps),
              rpmAxis: scope.rpmAxis.slice(), mapAxis: scope.mapAxis.slice(), alphaNAxis: scope.alphaNAxis.slice()
            };
            setToast('Applying ' + label + ' from Pro Studio...');
            callController('requestState()');
          }
          else { error = 'tune_rejected'; }
        } catch (e) { error = 'invalid_saved_tune'; }
        if (!ok) { finishRemoteTuneLoad(false, error); }
      }
      scope.$on('OEProTuneRemoteFactoryResetIntent', function (event, request) {
        if (IS_MIRROR) { return; }
        request = request || {};
        var requestId = String(request.requestId || ''), vehicleId = Number(request.vehicleId);
        var identityAccepted = !!requestId && isFinite(vehicleId) && vehicleId === Math.floor(vehicleId) &&
          vehicleId === Number(scope.activeVehId) && vehicleId === Number(scope.linkedVehId) && packageIdentityIsCoherent();
        // V2.9.7 (wave-2 review): mirror the in-game paused refusal. While paused, vehicle Lua is
        // parked - the 6 s valve would expire before the queued reset ever runs, and the unpause
        // edge re-pushes paused edits over the fresh reset (split-brain ECU/UI).
        var pausedBlocked = scope.gamePaused === true;
        // This is phase one of the destructive commit. GE retains the exact request capability and
        // will not queue vehicle Lua until this synchronous localStorage verification is reported.
        var persisted = !pendingRemoteFactoryResetIntent && identityAccepted && !pausedBlocked && writeFactoryResetIntent(requestId, true);
        if (persisted) { pendingRemoteFactoryResetIntent = persisted; }
        var ackFinished = false, ackTimer = null;
        function finishFactoryResetAck(result) {
          if (ackFinished) { return; }
          ackFinished = true;
          if (ackTimer) { clearTimeout(ackTimer); ackTimer = null; }
          var accepted = !!persisted && result && (result.accepted === true || Number(result.accepted) === 1);
          var markerCommitted = accepted && commitFactoryResetIntentRecord(persisted);
          if (pendingRemoteFactoryResetIntent === persisted) { pendingRemoteFactoryResetIntent = null; }
          if (accepted) {
            // GE has now queued this exact request. Only now make the marker durable and arm the
            // one-shot UI acceptance path; a controller frSeq change remains authoritative too.
            if (markerCommitted) { durSync(); }
            supersedePendingMapBankMutation(true);
            localCalibrationMutationGeneration += 1;
            frSeqAtReset = typeof scope.lastFrSeq === 'number' ? scope.lastFrSeq : null;
            // V2.9.7 (Store-wipe wave 2, review find): this arm had NO expiry valve and, with
            // frSeqAtReset null under a cached pre-frSeq controller, accepted ANY next broadcast as
            // the factory stamp - including the markStored echo a Store press triggers, instantly
            // running (and persisting) the wipe block. Remote arms now demand real stamp evidence
            // (frArmRemote gates the wildcard) and carry the same 6 s valve as the in-game path.
            frArmRemote = true;
            scope.awaitingFactorySettings = true;
            if (frValveTimer) { clearTimeout(frValveTimer); }
            frValveTimer = setTimeout(function () { scope.awaitingFactorySettings = false; scope.$applyAsync(); }, 6000);
            scope.storeRequired = true;
          } else if (persisted) {
            // GE rejected after phase one. Remove only the exact raw record written by this request;
            // never recompute a key after the player may have switched cars or clear a newer intent.
            clearFactoryResetIntentRecord(persisted);
          }
          scope.$applyAsync(function () {
            setToast(accepted
              ? (markerCommitted ? 'Remote Factory Reset confirmed - recovery marker saved and reset queued'
                : 'Remote Factory Reset queued - waiting for the ECU reset stamp')
              : 'Remote Factory Reset rejected before it reached the ECU');
            });
          }
        if (persisted) {
          // GE's prepared capability expires after four seconds. If its return callback is lost,
          // abort the still-prepared record and unblock this link; an executed reset is independently
          // recognized by the controller's new-epoch + frSeq transition.
          ackTimer = setTimeout(function () { finishFactoryResetAck({ accepted: false }); }, 5500);
        }
        if (requestId && isFinite(vehicleId) && typeof bngApi !== 'undefined' && bngApi.engineLua) {
          var ackLua = '(function() if oeProTuneRemote and oeProTuneRemote.factoryResetIntentResult then ' +
            'local ok,err=oeProTuneRemote.factoryResetIntentResult(' +
            luaQuote(requestId) + ', ' + Math.floor(vehicleId) + ', ' + (persisted ? 'true' : 'false') + ', ' +
            luaQuote(persisted ? '' : (pausedBlocked ? 'game_paused' : (identityAccepted ? 'recovery_marker_write_failed' : 'vehicle_or_engine_changed'))) +
            ') return {accepted=ok==true,error=tostring(err or "")} end return {accepted=false,error="remote_extension_unavailable"} end)()';
          try { bngApi.engineLua(ackLua, finishFactoryResetAck); }
          catch (e) { finishFactoryResetAck({ accepted: false }); }
        } else { finishFactoryResetAck({ accepted: false }); }
        scope.$applyAsync(function () {
          setToast(persisted ? 'Verifying remote Factory Reset recovery marker...' : 'Remote Factory Reset rejected before it reached the ECU');
        });
      });
      scope.$on('OEProTuneRemoteTuneRequest', function (event, request) {
        if (IS_MIRROR) { return; }
        scope.$applyAsync(function () {
          request = request || {};
          // The global GUI hook reaches every app instance. Only the exact instance
          // that published this catalog may resolve it.
          if (String(request.publisherId || '') !== tunePublisherId) { return; }
          if (scope.gamePaused) { reportRemoteTuneLoad(request, false, 'simulation_paused'); return; }
          var index = Number(request.index), source = String(request.source || '');
          var generation = Number(request.catalogGeneration), catalogToken = String(request.catalogToken || '');
          var catalog = tuneCatalogSnapshots[catalogToken];
          if (!request.requestId || !isFinite(index) || Math.floor(index) !== index || !isFinite(generation) || generation < 1 ||
              !catalog || catalog.vehicleId !== request.vehicleId ||
              !packageIdentityMatches(catalog) ||
              (typeof request.vehicleId === 'number' && scope.activeVehId !== null && request.vehicleId !== scope.activeVehId)) {
            reportRemoteTuneLoad(request, false, 'invalid_tune_reference'); return;
          }
          var ref = catalog.refs[source + ':' + index];
          if (!ref || ref.source !== source || ref.index !== index) { reportRemoteTuneLoad(request, false, 'unknown_tune_reference'); return; }
          if (activeRemoteTuneLoad) { reportRemoteTuneLoad(request, false, 'tune_load_busy'); return; }
          activeRemoteTuneLoad = {
            request: request, label: ref.name || 'saved tune', expected: null,
            timer: null, waitedForUnpause: false
          };
          armRemoteTuneLoadTimeout(activeRemoteTuneLoad, 6000);
          if (source === 'slot' || source === 'library') {
            applyResolvedRemoteTune(request, angular.copy(ref.data), ref.name || (source + ' tune'));
            return;
          }
          if (source === 'file') {
            if (!ref.path || typeof bngApi === 'undefined' || !bngApi.engineLua) { finishRemoteTuneLoad(false, 'tune_file_missing'); return; }
            bngApi.engineLua('jsonReadFile(' + luaQuote(ref.path) + ')', function (payload) {
              scope.$applyAsync(function () {
                if (!activeRemoteTuneLoad || activeRemoteTuneLoad.request.requestId !== request.requestId) { return; }
                if (!packageIdentityMatches(catalog)) { finishRemoteTuneLoad(false, 'vehicle_or_engine_changed'); return; }
                if (!payload || payload.v !== 1 || !payload.m || !payload.m.fuel || !payload.s) { finishRemoteTuneLoad(false, 'invalid_tune_file'); return; }
                var st = Array.isArray(payload.s) ? settingsFromArray(payload.s) : payload.s;
                applyResolvedRemoteTune(request, { name: payload.n || ref.name || 'Shared tune', hasTurbo: payload.ht === undefined ? undefined : payload.ht === 1,
                  engineFp: payload.fp || null, engineFpLegacy: payload.fpl || null,
                  settings: st, maps: payload.m, rpmAxis: payload.ra, mapAxis: payload.ma,
                  alphaNAxis: payload.an, mapBank: payload.mapBank }, ref.name || 'tune file');
              });
            });
            return;
          }
          finishRemoteTuneLoad(false, 'unknown_tune_source');
        });
      });
      scope.openTuneFolder = function () {   // tunes subfolder might not exist yet; create then open
        if (IS_MIRROR) { setToast('Open folders from the game PC'); return; }
        if (typeof bngApi === 'undefined' || !bngApi.engineLua) { return; }
        bngApi.engineLua('pcall(function() if not FS:directoryExists("' + tuneFileDir() + '") then FS:directoryCreate("' + tuneFileDir() + '", true) end Engine.Platform.exploreFolder("' + tuneFileDir() + '") end)');
      };

      // ====================================================================
      // calibration notes
      // ====================================================================
      scope.notes = []; scope.noteDraft = '';
      function notesKey() { return 'oep_notes_' + scope.carName; }
      function loadNotes() { scope.notes = []; if (IS_MIRROR) { return; } try { var raw = localStorage.getItem(notesKey()); if (raw) { scope.notes = JSON.parse(raw); } } catch (e) {} }
      function saveNotes() { if (IS_MIRROR) { return; } try { localStorage.setItem(notesKey(), JSON.stringify(scope.notes)); } catch (e) {} durSync(); }
      scope.addNote = function () { if (!scope.noteDraft) { return; } scope.notes.unshift({ txt: scope.noteDraft, time: new Date().toLocaleTimeString() }); scope.noteDraft = ''; if (scope.notes.length > 50) { scope.notes.length = 50; } saveNotes(); };
      scope.snapshotNote = function () {
        var t = scope.t;
        scope.notes.unshift({ txt: 'Snapshot: ' + Math.round(t.rpm || 0) + ' rpm, MAP ' + Math.round(t.mapKpa || 0) + ', λ ' + (t.lambdaActual || 0).toFixed(2) + '/' + (t.lambdaTarget || 0).toFixed(2) + ', knock ' + (t.knockLevel || 0).toFixed(1) + ', IDC ' + Math.round(t.injectorDuty || 0) + '%, ' + Math.round(t.wheelPowerHp || 0) + ' whp, ' + (t.faults || 'None'), time: new Date().toLocaleTimeString() });
        saveNotes();
      };
      scope.clearNotes = function () { scope.notes = []; saveNotes(); };

      // ====================================================================
      // tune compare
      // ====================================================================
      scope.compareBase = null;
      scope.saveCompare = function () { scope.compareBase = snapshot(); setToast('Compare baseline saved'); };
      scope.compareRows = function () {
        if (!scope.compareBase) { return []; }
        var out = []; var b = scope.compareBase.settings || {}; var s = scope.settings || {};
        var scalarKeys = ['revLimit', 'launchLimit', 'drivetrainLoss', 'boostPGain', 'knockThreshold', 'fuelEquationMode', 'loadSource', 'masterFuel', 'globalFuelTrim', 'globalIgnitionTrim',
          'boostShapeMode', 'boostRampUpKpaS', 'boostRampDownKpaS', 'boostShiftHoldMs'];
        for (var gear = 1; gear <= 8; gear++) { scalarKeys.push('boostGear' + gear + 'TargetTrimKpa', 'boostGear' + gear + 'DutyTrimPct'); }
        for (var point = 1; point <= 6; point++) { scalarKeys.push('boostSpeed' + point + 'TargetTrimKpa', 'boostSpeed' + point + 'DutyTrimPct'); }
        scalarKeys.forEach(function (k) {
          var bv = Number(b[k] || 0), sv = Number(s[k] || 0);
          if (Math.abs(bv - sv) > 0.001) { out.push({ label: k, from: bv, to: sv }); }
        });
        ['fuel', 'ignition', 'boost', 'wgduty', 'lambda'].forEach(function (name) {
          if (!scope.compareBase.maps[name] || !scope.maps[name]) { return; }
          var changed = 0; var bm = scope.compareBase.maps[name], cm = scope.maps[name];
          for (var r = 0; r < cm.length; r++) { for (var c = 0; c < cm[0].length; c++) { if (Math.abs((cm[r][c] || 0) - ((bm[r] && bm[r][c]) || 0)) > 0.0005) { changed++; } } }
          if (changed) { out.push({ label: name + ' table', from: '', to: changed + ' cells changed' }); }
        });
        return out;
      };
      function restoreCompareNow() {
        if (scope.compareBase && applySnapshot(scope.compareBase)) { saveAutosave(); setToast('Restored compare baseline'); }
      }
      scope.restoreCompare = function () {
        if (deferWorkingTuneAction('Restore compare baseline', restoreCompareNow)) { return; }
        restoreCompareNow();
      };

      // ====================================================================
      // axis setup
      // ====================================================================
      scope.axisDraftRpm = ''; scope.axisDraftMap = ''; scope.axisDraftAlpha = '';
      var axisDraftBaroKpa = 101.325;
      function axisDraftEditing() {
        var active = document.activeElement;
        return !!active && (active.id === 'oep-axis-rpm' || active.id === 'oep-axis-map' || active.id === 'oep-axis-alpha');
      }
      function refreshAxisDrafts() {
        scope.axisDraftRpm = (scope.rpmAxis || []).join(', ');
        scope.axisDraftMap = mapAxisDraft();
        scope.axisDraftAlpha = (scope.alphaNAxis || []).join(', ');
      }
      // the MAP axis editor honors the kPa/PSI toggle (bug report: it stayed kPa in psi mode). psi
      // values here are gauge psi against the live barometric reference, exactly like the table headers.
      // Keep the same captured atmosphere through Apply so an altitude/weather update cannot shift the grid.
      function mapAxisDraft() {
        axisDraftBaroKpa = baroRefKpa();
        return (scope.mapAxis || []).map(function (k) { return scope.psi ? Math.round((k - axisDraftBaroKpa) * KPA_TO_PSI * 10) / 10 : k; }).join(', ');
      }
      scope.mapAxisDraftRefresh = function () { if (!axisDraftEditing()) { scope.axisDraftMap = mapAxisDraft(); } };
      scope.applyAxis = function (which) {
        if (pendingMapBankMutation) { setToast('Wait for the current MAP action to finish'); return; }
        if (!controllerMatchesUi()) { setToast('The UI and vehicle controller versions do not match - restart the game before changing axes'); return; }
        if (scope.gamePaused) { setToast('Un-pause the game first - the ECU cannot change axes while paused'); return; }
        var draft = which === 'rpm' ? scope.axisDraftRpm : (which === 'alpha' ? scope.axisDraftAlpha : scope.axisDraftMap);
        var parts = String(draft || '').split(','), parsed = [];
        for (var p = 0; p < parts.length; p++) {
          var token = parts[p].trim(), value = Number(token);
          if (!token || !isFinite(value)) { setToast('Every axis entry must be a number'); return; }
          parsed.push(value);
        }
        if (parsed.length < scope.axisLimits.min || parsed.length > scope.axisLimits.max) {   // V3.0.7: shared bound
          setToast('Need ' + scope.axisLimits.min + '-' + scope.axisLimits.max + ' values'); return;
        }
        if (which === 'alpha' && parsed.length !== (scope.mapAxis || []).length) {
          setToast('Alpha-N axis needs exactly ' + (scope.mapAxis || []).length + ' values - one per table row');
          return;
        }
        if (which === 'map' && scope.psi) { parsed = parsed.map(function (v) { return Math.round(v / KPA_TO_PSI + axisDraftBaroKpa); }); } // gauge psi -> absolute kPa using the draft's atmosphere
        // V3.0: MAP top follows the live meta (fallback 1000), was a hardcoded 600 - the editor
        // must accept the same axes R.setAxisValues and the remote boundary now do, or it refuses
        // to re-enter an axis auto-fit already gave the ECU. The toast derives from hi so the
        // user-facing copy can never lag the real limit again.
        var mapHi = Number(scope.mapMeta && scope.mapMeta.boost && scope.mapMeta.boost.max) || 1000;
        var lo = which === 'rpm' ? 100 : (which === 'alpha' ? 0 : 5);
        var hi = which === 'rpm' ? 30000 : (which === 'alpha' ? 100 : mapHi);
        for (var b = 0; b < parsed.length; b++) {
          if (parsed[b] < lo || parsed[b] > hi) {
            setToast((which === 'rpm' ? 'RPM' : (which === 'alpha' ? 'Alpha-N TPS' : 'MAP')) + ' axis values must be between ' + lo + ' and ' + hi);
            return;
          }
        }
        for (var i = 1; i < parsed.length; i++) { if (parsed[i] <= parsed[i - 1]) { setToast('Axis values must be strictly increasing'); return; } }
        pendingAxisAutosave = true;
        scope.storeRequired = true;
        pendingMapEcho = {};   // grid shape is about to change - never hold old-width rows over the refit echo
        callController('setAxis("' + which + '", "' + parsed.join(',') + '")');
        setToast('Axis updated — re-syncing');
      };
      // Factory breakpoints back in one click; the controller bilinearly resamples every table.
      // ====================================================================
      // V3.0.7 calibration grid resolution
      //
      // The presets are the only entry point; arbitrary row/col counts stay behind the existing
      // breakpoint editors. Both readouts are computed from the LIVE axes rather than from stored
      // state, so the control can never disagree with the tables actually on screen.
      // ====================================================================
      scope.GRID_PRESETS = [
        { key: 'standard', label: 'Standard', rows: 6,  cols: 7  },
        { key: 'detailed', label: 'Detailed', rows: 12, cols: 12 },
        { key: 'fine',     label: 'Fine',     rows: 16, cols: 16 }
      ];
      // V3.0.7 auto-density: the cell metrics in app.html were sized for a 7-column table. Shrink
      // them as the grid widens so an opted-in fine grid fits without a horizontal scroll, and leave
      // the factory 6x7 rendering byte-for-byte as it always was. Keyed on COLUMNS, because width is
      // what runs out first - rows just make the table taller, which the pane already scrolls.
      scope.gridDensityClass = function () {
        // mapAxis, not activeLoadAxis(): the Alpha-N axis is length-pinned to the row count, so both
        // give the same number, and this runs on every digest for three separate tables.
        var cols = (scope.rpmAxis || []).length, rows = (scope.mapAxis || []).length;
        // 13+ columns drops to 9px. Confirmed in game: at 16 columns a cell is ~33px and "1.00" at
        // 10px Consolas is ~22px with ZERO horizontal padding, so the numbers ran together into
        // "1.001.001.00". 12x12 reads perfectly at 10px, which sets the boundary. 9px gives ~20px of
        // text in the same 33px cell - readable with real separation - and Pro Studio still opts back
        // out to 10px, so this cannot recreate the 8px regression it replaced.
        var cls = cols >= 13 ? 'dense2' : '';
        // V3.0.9: these tiers now only reach the two tables fitGrid() does NOT measure - Mixture Map
        // and Pro Studio - because the map editor carries .oep-fit, whose inline row heights and
        // zeroed padding beat them. The old third tier was 'rows-fill', which paired with a
        // `height:100%` rule that only ever applied above 12 rows; that off-by-one is exactly why the
        // 12x12 Detailed preset - the most-used shape in the app - never claimed its leftover height.
        // Filling is fitGrid()'s job now, at every shape, so the tier just stops adding padding.
        if (rows <= 8) { cls += ' rows-tall'; }
        else if (rows <= 12) { cls += ' rows-mid'; }
        return cls;
      };

      // ==================================================================
      // V3.0.9 fitGrid - size the map editor's table to the space it has.
      // The V3.0.7 pass made the grid FIT (fill the width, never scroll); this makes it FILL - the
      // numbers scale up into the cell instead of sitting at a fixed 10px in a 47px box, and the
      // leftover height below the table becomes taller rows. Column count alone was the wrong signal:
      // it says nothing about how wide the pane actually is, so a narrow app window and a wide one got
      // identical type. This measures the container instead, which is correct at any window size.
      // Scoped to the ONE table tagged .oep-fit. Mixture Map has no fill container and Pro Studio has
      // its own scrolling 48px-floor layout, so both keep the class-based density above.
      // ==================================================================
      var FIT_STRIP = 94;   // .oep-side flex-basis on the map page (app.html)
      var FIT_GAP = 5;      // .oep-tablewrap gap
      var FIT_ADV = 0.55;   // Consolas advance width, in em
      var fitSig = '';
      var fitNode = null;   // the exact <table> the signature belongs to - see the guard below
      function fitGrid() {
        if (IS_PRO_STUDIO) { return; }
        var tbl = element[0].querySelector('.oep-grid.oep-fit');
        // absent on every non-map page, and while the 3D view replaces the table
        if (!tbl || !tbl.rows || tbl.rows.length < 2) { return; }
        var scroll = tbl.parentNode, work = element[0].querySelector('.oep-work');
        if (!work) { return; }
        var rows = tbl.rows.length - 1, cols = tbl.rows[0].cells.length - 1;
        if (rows < 1 || cols < 1) { return; }

        // Height is measured from .oep-work down, NOT from the wrapper's own clientHeight: once the
        // table has grown, the wrapper reports the grown size and re-reading it would ratchet.
        var availW = scroll.clientWidth;
        // + work.scrollTop cancels the scroll offset baked into the two rects, making this the
        // UNSCROLLED distance from the top of .oep-work's content to the top of the table. Without it
        // a scrolled pane reports a larger gap, the table grows, that creates more scrollable height,
        // and the next tick grows it again - the ratchet this measurement exists to avoid.
        var availH = work.clientHeight - FIT_STRIP - FIT_GAP - 6
                   - (scroll.getBoundingClientRect().top - work.getBoundingClientRect().top + work.scrollTop);
        if (availW < 120 || availH < 80) { return; }   // collapsed, hidden, or a tiny app slot

        // Widest rendered string, read from the DOM rather than assumed: Delta view swaps values for
        // signed deltas and the unit toggles swap lambda for AFR, both of which change the width.
        var maxCell = 3, maxAxis = 2, i, j, row, len;
        for (j = 1; j < tbl.rows[0].cells.length; j++) {
          len = (tbl.rows[0].cells[j].textContent || '').trim().length;
          if (len > maxCell) { maxCell = len; }
        }
        for (i = 1; i < tbl.rows.length; i++) {
          row = tbl.rows[i];
          len = (row.cells[0].textContent || '').trim().length;
          if (len > maxAxis) { maxAxis = len; }
          for (j = 1; j < row.cells.length; j++) {
            len = (row.cells[j].textContent || '').trim().length;
            if (len > maxCell) { maxCell = len; }
          }
        }

        // Font first, off the width left once the axis column takes its floor, then the axis column
        // is sized to its own widest label at that font - so it shrinks on a 3-character load axis
        // instead of always reserving the old flat 54px.
        var fs = Math.floor((((availW - 46 - 2) / cols) - 9) / (maxCell * FIT_ADV));
        fs = Math.max(9, Math.min(17, fs));
        var axisW = Math.max(46, Math.min(58, Math.round(maxAxis * fs * FIT_ADV) + 12));
        var cellW = (availW - axisW - 2) / cols;
        var headH = fs + 11;
        // Rows take the leftover height, bounded two ways: never past 96px, and never more than 1.15x
        // the cell width. The aspect bound is what stops a 4-column table from turning into six tall
        // ribbons; the 96px bound is what stops the 6-row factory shape - which now has the full pane
        // width to spread over - from becoming a handful of enormous boxes. A 6x7 lands on 96 and a
        // 12x12 is bounded by the pane instead, which is the shape the presets were drawn for.
        var rowH = Math.floor((availH - headH - 2) / rows);
        rowH = Math.max(fs + 7, Math.min(Math.min(96, Math.round(cellW * 1.15)), rowH));
        // Second pass on the font: a short row cannot carry tall type no matter how wide the cell is.
        // Only ever lowers fs, so the width budget computed above still holds.
        fs = Math.max(9, Math.min(fs, Math.floor(rowH * 0.42)));
        headH = fs + 11;

        var sig = availW + 'x' + availH + '|' + rows + 'x' + cols + '|' + fs + '|' + rowH + '|' + axisW;
        // The node check is not redundant with the signature. ng-if DESTROYS this table whenever you
        // leave a map page or switch to the 3D view, and rebuilds it with no inline styles at all. Come
        // back to the same table at the same window size and the freshly built node produces an
        // IDENTICAL signature - so a signature-only guard would skip it and leave it permanently
        // unfitted, which is the single most common way to open this page.
        if (sig === fitSig && tbl === fitNode) { return; }
        fitSig = sig;
        fitNode = tbl;
        // One font write on the table (cells inherit via .oep-fit) and one height per ROW - with
        // table-layout:fixed the first row also fixes every column width, so the axis column only
        // needs setting on the corner cell.
        tbl.style.fontSize = fs + 'px';
        tbl.rows[0].cells[0].style.width = axisW + 'px';
        tbl.rows[0].style.height = headH + 'px';
        for (i = 1; i < tbl.rows.length; i++) { tbl.rows[i].style.height = rowH + 'px'; }
      }
      scope.fitGrid = fitGrid;
      scope.gridArmed = null;   // preset key awaiting a confirming second click, or null
      scope.gridRows = function () { return (scope.mapAxis || []).length || 0; };
      scope.gridCols = function () { return (scope.rpmAxis || []).length || 0; };
      scope.gridCells = function () { return scope.gridRows() * scope.gridCols(); };
      scope.gridIsPreset = function (p) { return scope.gridRows() === p.rows && scope.gridCols() === p.cols; };
      scope.gridPresetAvailable = function (p) {
        return p.rows >= scope.axisLimits.min && p.rows <= scope.axisLimits.max &&
               p.cols >= scope.axisLimits.min && p.cols <= scope.axisLimits.max;
      };
      // An exact return trip exists only while the controller still holds the snapshot it took on
      // the way up. Anything else is a bilinear resample, and the copy must say so.
      scope.gridCanRestore = function (p) {
        return scope.gridRestoreRows === p.rows && scope.gridRestoreCols === p.cols;
      };
      scope.gridPresetTitle = function (p) {
        if (scope.gridIsPreset(p)) { return 'Already on ' + p.rows + ' x ' + p.cols + '.'; }
        if (scope.gridCanRestore(p)) {
          return 'Restores the exact calibration you had before switching up - the snapshot is still held. '
               + 'Anything you tuned on the current grid is discarded.';
        }
        if (p.rows * p.cols > scope.gridCells()) {
          return 'More cells across the same axis span. Your tuned surface is resampled onto the finer '
               + 'breakpoints, and a snapshot is kept so you can come straight back.';
        }
        return 'Fewer cells. The surface is resampled down, which loses detail between the kept '
             + 'breakpoints. No snapshot is held for this shape.';
      };
      // The warning shown on the FIRST click of any resolution change. Every one of these costs the
      // player something real, so none of them happens on a single click - the app already uses this
      // arm-then-confirm idiom for Factory Reset, and a reshape rewrites just as much.
      scope.gridWarning = function (p) {
        var target = p.rows + ' × ' + p.cols + ' (' + (p.rows * p.cols) + ' cells)';
        if (scope.gridCanRestore(p)) {
          return 'Click again to RESTORE ' + target + '. Your pre-conversion calibration comes back exactly'
               + ' as it was — but anything you tuned on the current grid is discarded, and learned trims'
               + ' and the Mixture Map are cleared.';
        }
        if (p.rows * p.cols > scope.gridCells()) {
          return 'Click again to switch to ' + target + '. Your tune is REFINED onto the finer breakpoints:'
               + ' the surface shape is kept, but the new cells in between are interpolated, not values you'
               + ' tuned — re-check the map before trusting it. Learned trims and the Mixture Map are'
               + ' cleared, and a grid this size needs a lot more driving to learn. A snapshot is kept so'
               + ' you can come straight back.';
        }
        return 'Click again to drop to ' + target + '. The surface is resampled DOWN and detail between the'
             + ' kept breakpoints is lost for good — there is no snapshot for this shape. Learned trims and'
             + ' the Mixture Map are cleared.';
      };
      scope.setGridResolution = function (p) {
        // V3.0.7 REVIEW FIX: game-PC only. M.setGridResolution is deliberately absent from R.commands
        // and from remote.html's closed allowlist, so on the mirror the call is rejected at the
        // transport and the player would get a success toast for a grid that never changed. Same
        // idiom as scope.saveWorkingToMapBank. The control row is also ng-if="!isMirror" so this is
        // belt and braces, not the only gate.
        if (IS_MIRROR) { setToast('Change the table resolution on the game PC'); return; }
        if (scope.gridIsPreset(p)) { return; }
        if (!scope.gridPresetAvailable(p)) { setToast('That grid is outside the supported axis range'); return; }
        // Same guards the axis editor uses - a reshape is a superset of an axis edit.
        if (pendingMapBankMutation) { setToast('Wait for the current MAP action to finish'); return; }
        if (!controllerMatchesUi()) { setToast('The UI and vehicle controller versions do not match - restart the game before changing the grid'); return; }
        if (scope.gamePaused) { setToast('Un-pause the game first - the ECU cannot change the grid while paused'); return; }
        // Arm on the preset KEY, not a bare boolean: arming Fine and then clicking Standard must not
        // fire Standard on what looks like a confirmation.
        if (scope.gridArmed !== p.key) {
          scope.gridArmed = p.key;
          setToast(scope.gridWarning(p));
          return;
        }
        var exact = scope.gridCanRestore(p);
        scope.gridArmed = null;
        pendingAxisAutosave = true;
        scope.storeRequired = true;
        pendingMapEcho = {};   // grid shape is about to change - never hold old-width rows over the refit echo
        callController('setGridResolution(' + p.rows + ', ' + p.cols + ')');
        setToast(exact
          ? ('Restoring your ' + p.rows + ' × ' + p.cols + ' calibration — re-syncing')
          : ('Table resolution now ' + p.rows + ' × ' + p.cols + ' (' + (p.rows * p.cols) + ' cells) — re-syncing'));
      };

      scope.resetAxesUi = function () {
        if (pendingMapBankMutation) { setToast('Wait for the current MAP action to finish'); return; }
        if (!controllerMatchesUi()) { setToast('The UI and vehicle controller versions do not match - restart the game before resetting axes'); return; }
        if (scope.gamePaused) { setToast('Un-pause the game first - the ECU cannot reset axes while paused'); return; }
        pendingAxisAutosave = true;
        scope.storeRequired = true;
        pendingMapEcho = {};   // grid shape is about to change - never hold old-width rows over the refit echo
        callController('resetAxes()');
        setToast('Axes reset to factory — table surface preserved');
      };

      // ====================================================================
      // faults
      // ====================================================================
      scope.dtcDefs = {
        Lean: 'Air/fuel leaner than target under load — verify the wideband, injector data, fuel pressure and duty, then correct Fuel Main (Traditional) or VE (Modelled).',
        Knock: 'Detonation detected — reduce timing, richen, or lower boost.',
        Overboost: 'Manifold pressure over target + overboost margin — check wastegate/boost target.',
        FuelPressure: 'Differential fuel pressure collapsing — the pump can\'t hold the rail against boost, or injectors near max duty.',
        InjectorDuty: 'Injector duty over 100% — injectors too small for the fuel demand.',
        CoolantTemp: 'Coolant temperature high — cool down or check the cooling system.',
        OilTemp: 'Oil temperature high — back off and let it cool.',
        EngineHealth: 'Engine has taken tuning damage — reset vehicle to repair.',
        EThrottle: 'Electronic throttle tracking fault — safety cut active.',
        Trigger: 'Crank/cam trigger signal problem — check Trigger Scope.',
        LimpMode: 'Limp mode active — multiple/severe faults are protecting the engine.'
      };
      scope.faultArray = function () { var f = scope.t.faults; return (!f || f === 'None') ? [] : f.split(','); };
      scope.clearFaults = function () { callController('clearFaults()'); setToast('Faults cleared'); };
      scope.resetDamage = function () { callController('resetDamage()'); setToast('Engine damage reset'); };

      // ====================================================================
      // "Help Me" tune doctor - live, plain-English fixes for the current tune
      // ====================================================================
      // Stage 6: Tune Doctor advice templates ({x} placeholders filled from live
      // values). Per-message English fallback when a key/lang isn't present.
      var ADV = {
        es: {
          damage_bad_title: 'Motor dañado ({h}% de salud)', damage_bad_advice: 'Has dañado el motor: no hará potencia completa hasta repararlo. Reinicia el vehículo (Insert o R), luego arregla la causa de abajo y ajusta en pasos pequeños.',
          damage_warn_title: 'El motor está recibiendo daño ({h}% de salud)', damage_warn_advice: 'Algo en el tune está dañando el motor con carga: normalmente detonación, mezcla pobre o sobreboost (ver abajo). En un bloque de serie mantén el boost modesto. Reinicia el vehículo para reparar una vez arreglada la causa.',
          knock_now_title: 'Detonando ahora', knock_now_advice: 'QUITA AVANCE: abre Encendido principal, selecciona la celda viva cian y haz clic en - para quitar 2-3°. {turbo}Un poco más de combustible también calma la detonación.', knock_now_turbo: 'Bajar el Objetivo de boost también ayuda. ',
          knock_load_title: 'DETONÓ con carga (pico {peak} cerca de {rpm} rpm)', knock_load_advice: 'Está tranquilo en ralentí, pero detonó durante tu pasada: esos son los bajones en el dyno. Abre Encendido principal y quita 2-3° de avance alrededor de {rpm} rpm{turbo}, luego repite y vuelve a comprobar.', knock_load_turbo: ', o baja el boost ahí',
          lean_now_title: 'Corriendo POBRE (λ {lam} vs objetivo {tgt})', lean_now_advice: 'Pobre con carga derrite pistones: arréglalo primero. AÑADE COMBUSTIBLE: baja la celda cian de Objetivo lambda hacia ~0.88, o sube Combustible principal.',
          rich_now_title: 'Corriendo RICO (λ {lam} vs objetivo {tgt})', rich_now_advice: 'Demasiado combustible: pierde potencia y echa humo. EMPOBRÉCELO: sube la celda cian de Objetivo lambda hacia ~0.88, o baja Combustible principal.',
          lean_load_title: 'Corrió POBRE con carga (desviado +{lean} λ cerca de {rpm} rpm)', lean_load_advice: 'El ralentí está bien pero se empobreció con carga: peligroso. Añade combustible alrededor de {rpm} rpm: baja el Objetivo lambda (o sube Combustible principal) en esa parte de la tabla.',
          rich_load_title: 'Corrió RICO con carga (λ {rich} cerca de {rpm} rpm)', rich_load_advice: 'Corrió rico con carga: pierde potencia y echa humo. Empobrécelo alrededor de {rpm} rpm (sube Objetivo lambda hacia 0.88 ahí).',
          lean_modelled_advice: 'Lambda medida está más pobre que el objetivo. Verifica el wideband, los datos del inyector, la presión y el ciclo; luego sube la Tabla VE en esta zona o recoge datos nuevos en Mapa de mezcla. No cambies el Objetivo lambda solo para esconder un error de seguimiento.',
          rich_modelled_advice: 'Lambda medida está más rica que el objetivo. Verifica el wideband y los datos del sistema de combustible; luego baja la Tabla VE en esta zona o recoge datos nuevos en Mapa de mezcla. No cambies el Objetivo lambda solo para esconder el error.',
          lean_traditional_advice: 'Lambda medida está más pobre que el objetivo. Verifica el wideband, los datos del inyector, la presión y el ciclo; luego sube Combustible principal en esta zona o recoge datos nuevos en Mapa de mezcla. No cambies el Objetivo lambda solo para esconder un error de seguimiento.',
          rich_traditional_advice: 'Lambda medida está más rica que el objetivo. Verifica el wideband y los datos del sistema de combustible; luego baja Combustible principal en esta zona o recoge datos nuevos en Mapa de mezcla. No cambies el Objetivo lambda solo para esconder el error.',
          idc_title: 'Inyectores cerca del máximo ({duty}% de ciclo)', idc_advice: 'Los inyectores no pueden fluir mucho más: pasado el 100% se empobrece aunque la tabla pida combustible. Pon inyectores más grandes en Config. combustible (sube Injector flow), o reduce la demanda de combustible.',
          fp_title: 'Presión de combustible baja ({fp} kPa)', fp_advice: 'El sistema de combustible está al límite. Suelta un poco, o revisa la dimensión de inyectores/bomba en Config. combustible.',
          boost_now_title: 'Sobreboost ahora ({over} kPa sobre el objetivo)', boost_now_advice: 'El boost está sobre el objetivo. Baja el Objetivo de boost, o en Control de boost baja la ganancia P para que deje de picar. En un bloque de serie mantén el boost modesto.',
          boost_load_title: 'El boost picó sobre el objetivo con carga (pico +{over} kPa cerca de {rpm} rpm)', boost_load_advice: 'El boost se pasó del objetivo durante tu pasada: el sobreboost es duro para un motor de serie y es probablemente lo que lo dañó. Baja un poco el Objetivo de boost, y en Control de boost baja la ganancia P para que deje de picar. Mantén el boost modesto con internos de serie.',
          temp_ect_title: 'Refrigerante caliente ({ect} °C)', temp_ect_advice: 'Déjalo enfriar: conduce suave antes de la próxima pasada.',
          temp_oil_title: 'Aceite caliente ({oil} °C)', temp_oil_advice: 'La temp. del aceite está alta: afloja y déjalo enfriar.',
          block_weak_title: 'Bloque de fuerza de serie ({bs}x)', block_weak_advice: 'Este bloque es de fuerza de serie, así que tiene poco margen para el abuso: mantén el avance y el boost modestos. Un bloque preparado/forjado (desde el menú de piezas del vehículo) aguantaría más castigo antes de soltar. Puedes verlo como Fuerza del bloque en la página de Protección.',
          block_mid_title: 'Bloque algo preparado ({bs}x)', block_mid_advice: 'Tu bloque es algo más fuerte que el de serie ({bs}x), así que tiene algo de margen, pero aún lo estás forzando de más. Afloja un poco el tune, o monta un bloque aún más fuerte.',
          block_strong_title: 'Hasta un bloque preparado tiene límites ({bs}x)', block_strong_advice: 'Este es un bloque fuerte ({bs}x) y AÚN está recibiendo daño: estás muy por encima de lo que aguantan incluso internos preparados. Baja el avance y el boost; ningún bloque salvará este tune.',
          hw_title: 'El nuevo hardware necesita ajuste', hw_advice: 'Has montado {bits}. Las tablas de serie no lo tienen en cuenta: ajusta las celdas afectadas hasta que el lambda se mantenga en el objetivo y deje de detonar con carga.',
          prot_title: 'La protección del motor está APAGADA', prot_advice: 'Nada está salvando al motor de un mal tune. Activa la Protección a menos que estés probando el límite.',
          ok_title: 'El tune se ve sano', ok_advice: 'Lambda cerca del objetivo, y sin detonación en ralentí NI durante tu pasada reciente. El ciclo de inyector y las temps están bien. Busca un poco más en el dyno: añade un par de grados de avance o acerca el AFR a 0.88, un cambio a la vez.'
        }
      };
      function advT(key, vars) {
        var d = (scope.lang !== 'en' && ADV[scope.lang]) ? ADV[scope.lang][key] : null;
        if (d === undefined || d === null) { return null; }
        return d.replace(/\{(\w+)\}/g, function (_, k) { return (vars && vars[k] !== undefined) ? vars[k] : ''; });
      }
      scope.diagnose = function () {
        var t = scope.t || {};
        var s = scope.settings || {};
        var num = function (k) { var v = t[k]; return (typeof v === 'number') ? v : null; };
        var lam = num('lambdaActual'), tgt = num('lambdaTarget'), rpm = num('rpm') || 0;
        var knock = num('knockLevel') || 0, kr = num('knockRetard') || 0, thr = s.knockThreshold || 1.2;
        var idc = num('injectorDuty') || 0, fp = num('fuelDiffPressureKpa'), ect = num('ectC'), oil = num('oilC'), egt = num('egtC');
        var health = num('engineHealth'), hasTurbo = scope.base.hasTurbo, bs = num('blockStrength');
        var mapK = num('mapKpa'), bt = num('boostTargetKpa'), overcut = s.boostOvercut || 25;
        var out = [], seen = {};
        var add = function (cat, o) { if (!seen[cat]) { seen[cat] = true; out.push(o); } };

        // recent UNDER-LOAD history (last ~30s) - a tune can be calm at idle but knock/lean
        // during a pull. Scan the log buffer so we catch what the dyno just showed.
        var rows = scope.logRows || [];
        var rec = { knock: 0, knockRpm: 0, lean: 0, leanRpm: 0, rich: 0, richLambda: null, richRpm: 0, idc: 0, over: 0, overRpm: 0, droopT: 0, droopKpa: 0, boostMaxedSeen: false, boostMaxedPsi: -Infinity, boostMaxedRpm: 0 };
        var histFresh = false;
        var droopPrevT = null, droopRun = 0; // V2.9.7 wave 4 (Liqurdz's BBG report): contiguous-run state for the iGain=0 droop card
        if (rows.length) {
          // Is the buffer actually LIVE? rows[].t is a row counter, not a clock (see accumulateLog),
          // so a frozen buffer still looks like "the last 30 s". Only the boostMaxed card consults
          // this - the other history cards keep their existing behaviour.
          var lastRow = rows[rows.length - 1];
          histFresh = !lastRow.wall || (Date.now() - lastRow.wall) < 10000;
          var fromT = lastRow.t - 30;
          for (var i = 0; i < rows.length; i++) {
            var r = rows[i]; if (r.t < fromT) { continue; }
            if (r.knockLevel > rec.knock) { rec.knock = r.knockLevel; rec.knockRpm = r.rpm; }
            // mixture history only counts ON-THROTTLE samples: the decel fuel cut leans lambda by
            // design on every lift (harmless overrun), which used to log as a false "Ran LEAN under
            // load" danger card any time the player had lifted in the last 30s.
            if (r.tps >= 15 && r.lambdaError > rec.lean) { rec.lean = r.lambdaError; rec.leanRpm = r.rpm; }
            if (r.tps >= 15 && r.lambdaError < rec.rich) { rec.rich = r.lambdaError; rec.richLambda = r.lambdaActual; rec.richRpm = r.rpm; }
            if (r.injectorDuty > rec.idc) { rec.idc = r.injectorDuty; }
            // Remember the turbo hitting its ceiling anywhere in the window, and the BEST boost it
            // held while pegged - that number is the honest "this is all it has" figure to quote
            // back, and mapGaugePsi is already sampled above so no extra channel is needed.
            // Only THIS car's samples. logRows survives a vehicle change by design, so an unstamped
            // read would tell the owner of the next car to fit a bigger turbo.
            if (r.boostMaxed && r.veh === scope.activeVehId) {
              rec.boostMaxedSeen = true;
              if (Number(r.mapGaugePsi) > rec.boostMaxedPsi) { rec.boostMaxedPsi = Number(r.mapGaugePsi); rec.boostMaxedRpm = r.rpm; }
            }
            if (r.boostTargetKpa > 0 && (r.mapKpa - r.boostTargetKpa) > rec.over) { rec.over = r.mapKpa - r.boostTargetKpa; rec.overRpm = r.rpm; }
            // V2.9.7 wave 4 (Liqurdz's BBG report): sustained WOT droop - boost parked SHORT of
            // target. Track the longest contiguous run, and the latest (settled) shortfall rather
            // than the max, so the card reports what the loop is holding, not the spool-up transient.
            if (r.tps >= 80 && r.boostTargetKpa > 0 && (r.boostTargetKpa - r.mapKpa) > 14) {
              if (droopPrevT !== null) { droopRun += Math.max(0, r.t - droopPrevT); }
              if (droopRun > rec.droopT) { rec.droopT = droopRun; }
              rec.droopKpa = r.boostTargetKpa - r.mapKpa;
              droopPrevT = r.t;
            } else { droopPrevT = null; droopRun = 0; }
          }
        }

        // damage (current health reflects accumulated tuning damage; doesn't recover until reset)
        var hR = (health !== null ? Math.round(health) : '?');
        if (t.limpMode || (health !== null && health < 75)) {
          add('damage', { sev: 'danger', title: advT('damage_bad_title', { h: hR }) || ('Engine damaged (' + hR + '% health)'),
            advice: advT('damage_bad_advice') || "You've hurt the engine - it won't make full power until repaired. Reset the vehicle (Insert or R), then fix the cause below and tune in small steps.", page: 'faults' });
        } else if (health !== null && health < 99) {
          add('damage', { sev: 'warn', title: advT('damage_warn_title', { h: Math.round(health) }) || ('Engine is taking damage (' + Math.round(health) + '% health)'),
            advice: advT('damage_warn_advice') || "Something in the tune is hurting the engine under load - usually knock, lean or overboost (see below). On a stock block keep boost modest. Reset the vehicle to repair once you've fixed the cause.", page: 'faults' });
        }
        if (Number(s.loadSource) === 2 && t.isDiesel === true) {
          add('alpha_diesel', { sev: 'warn', title: 'Alpha-N is not active on this diesel',
            advice: 'Diesel operation remains MAP-based. Select Speed Density (MAP) on Fuel Setup so the saved setting matches the strategy the ECU is actually using.', page: 'fuel_setup' });
        } else if (Number(s.loadSource) === 2 && hasTurbo) {
          add('alpha_turbo', { sev: 'warn', title: 'Pure Alpha-N on a boosted engine needs extra care',
            advice: 'Fuel, ignition, lambda and VE now use TPS rows, but the same throttle angle can occur at very different boost pressure. Watch MAP, lambda, knock and injector duty through spool. If the tune cannot cover both conditions safely, return to MAP load; a hybrid Alpha-N/MAP strategy would be the appropriate later upgrade.', page: 'fuel_setup' });
        } else if (Number(s.loadSource) === 1 && t.isDiesel !== true &&
                   scope.hardwareFitted && scope.hardwareFitted.itb) {
          // V3.0.7 (CosmicEgg): the mod has always DETECTED ITBs and never once mentioned the one
          // setting that exists for them. Keyed on hardwareFitted, not scope.hardware: the latter is
          // the factory-baseline DELTA, so a config that SPAWNS with ITBs reports 0 forever and the
          // hint would never fire on exactly the cars that need it. classifyHardware's narrow regex
          // (\bitb\b|individual throttle|velocity stack) is the one that backs this - the Build Card's
          // wider one also matches a bare "throttle body" and would fire on any single-throttle car.
          //
          // 'warn' as of V3.0.7: the manifold-pressure model now actually collapses on ITBs, so this
          // is a real calibration problem on this car rather than background reading. (It was 'info'
          // for the few hours between the card landing and the MAP model landing.)
          add('alpha_itb', { sev: 'warn', title: 'ITBs fitted - your MAP load axis has collapsed',
            advice: 'Individual throttle bodies have no shared plenum, so there is nothing to hold vacuum: once each throttle is part way open its runner sits at atmospheric. Watch MAP on the Runtime page as you squeeze the pedal - it pins near barometric by about a third of throttle travel and never moves again. Everything from there to wide open is landing in the SAME table row, so most of your calibration has nowhere to live. That is why real ITB builds index fuel and ignition on throttle position instead. Set Load source to Alpha-N (TPS) on Fuel Setup and the rows become throttle angle, which stays clean and usable across the whole range.', page: 'fuel_setup' });
        }

        // Manual clutch: this is real BeamNG friction-clutch slip/temperature/capacity, not inferred
        // from horsepower. Fixed packages remain finite, and the advice never recommends gaming the
        // native boost slider to inflate BeamNG's spawn-time auto-sizing.
        var clutchStatus = String(t.clutchStatus || '');
        if (t.hasFrictionClutch && (clutchStatus.indexOf('SLIPPING') === 0 || clutchStatus.indexOf('FAILED') === 0 || clutchStatus.indexOf('OVERHEATING') === 0 || clutchStatus.indexOf('Worn') === 0)) {
          var clutchCap = num('clutchCapacityNm') || 0;
          var clutchDemand = num('clutchDemandNm') || 0;
          var clutchTemp = num('clutchTempC');
          var clutchFailed = clutchStatus.indexOf('FAILED') === 0;
          var clutchHot = clutchStatus.indexOf('OVERHEATING') === 0;
          var clutchWorn = clutchStatus.indexOf('Worn') === 0;
          add('clutch', {
            sev: clutchFailed ? 'danger' : 'warn',
            title: clutchFailed ? 'Manual clutch permanently damaged'
              : (clutchHot ? 'Manual clutch overheating'
                : (clutchWorn ? 'Manual clutch capacity reduced'
                  : ('Manual clutch slipping (' + Math.round(clutchDemand) + ' Nm on ' + Math.round(clutchCap) + ' Nm available)'))),
            advice: clutchFailed
              ? 'Reset or repair the vehicle first. Then choose only the fixed clutch package your build needs, or use Torque limit / boost-by-gear. The strongest package is intentionally finite.'
              : ((clutchHot ? 'The clutch is too hot' : (clutchWorn ? 'Wear or damage has reduced the clutch rating' : 'The clutch is losing torque'))
                + (clutchTemp !== null ? (' at ' + Math.round(clutchTemp) + ' C') : '')
                + '. Let it cool, select a fixed rated package on Transmission Control, or cap engine torque. Do not raise the game boost slider just to manipulate fitted-clutch sizing.'),
            page: 'trans', highlight: 'set:clutchUpgrade'
          });
        }

        // DIESEL (#4): smoke/AFR cards instead of lambda. Uses the 'mix' category so it pre-empts the
        // petrol mixture card below (add() dedupes by category). Diesel has no spark-knock, so the gas
        // knock card stays inert (knock reads 0).
        if (t.isDiesel) {
          var dafr = num('afr'), dsmoke = num('smoke') || 0;
          if (dsmoke > 45) {
            add('mix', { sev: dsmoke > 70 ? 'danger' : 'warn', title: 'Black smoke (' + Math.round(dsmoke) + '%)',
              advice: "You're injecting more fuel than the air can burn - the extra just makes soot and EGT, not power. Pull Max fuel DOWN, or (better) add BOOST and RAIL PRESSURE so the air can burn the fuel cleanly. Watch AFR climb back above your smoke limit.", page: 'diesel', highlight: 'set:dieselMaxFuel' });
          } else if (dafr !== null && dafr > 45 && rpm > 1500) {
            add('mix', { sev: 'info', title: 'Spare air (AFR ' + Math.round(dafr) + ':1)',
              advice: "Running very lean - there's unburned air, so you have room for more fuel = more power. Raise Max fuel (or richen the Fuel Main cells) until smoke just starts, then back off a touch.", page: 'diesel', highlight: 'set:dieselMaxFuel' });
          }
        }

        // knock - happening now OR during the last pull
        if (knock > thr || kr > 1) {
          var kt = hasTurbo ? (advT('knock_now_turbo') || 'Lowering Boost Target helps too. ') : '';
          add('knock', { sev: 'danger', title: advT('knock_now_title') || 'Knocking now', advice: advT('knock_now_advice', { turbo: kt }) || ("PULL IGNITION TIMING: open Ignition Main, select the cyan live cell and click - to remove 2-3°. " + (hasTurbo ? 'Lowering Boost Target helps too. ' : '') + 'A little more fuel also calms knock.'), page: 'ignition', highlight: 'edit:minus' });
        } else if (rec.knock > thr) {
          var krpm = Math.round(rec.knockRpm), klt = hasTurbo ? (advT('knock_load_turbo') || ', or lower boost there') : '';
          add('knock', { sev: 'danger', title: advT('knock_load_title', { peak: rec.knock.toFixed(1), rpm: krpm }) || ('It KNOCKED under load (peak ' + rec.knock.toFixed(1) + ' near ' + krpm + ' rpm)'),
            advice: advT('knock_load_advice', { rpm: krpm, turbo: klt }) || ("It's calm at idle, but it knocked during your pull - those are the dips on the dyno. Open Ignition Main and pull 2-3° of timing around " + krpm + " rpm" + (hasTurbo ? ', or lower boost there' : '') + ", then pull again and re-check."), page: 'ignition', highlight: 'edit:minus' });
        }

        // PAST MBT (V2_3): with the MBT power model, timing beyond the plateau LOSES power before knock
        // even bites - tell the tuner they're on the wrong side of the peak (the old model rewarded max
        // advance, so long-time players will genuinely not expect this).
        var effIgn = num('effectiveIgnitionTrim');
        var tpsMbt = num('tps') || 0;
        if (effIgn !== null && effIgn > 9 && tpsMbt >= 40 && rpm > 2500) {
          add('mbt', { sev: 'info', title: 'Timing past MBT (+' + effIgn.toFixed(1) + '° trim)',
            advice: "You're past the torque peak: beyond ~+7° of advance the power FALLS even without knock - just like a real engine past MBT. Pull timing back a couple of degrees and dyno it; you'll likely GAIN power. The peak is the target, not the end of the slider.", page: 'ignition', highlight: 'edit:minus' });
        }

        // INJECTOR MISMATCH (V2_3, the Louis-report fix): when the ECU's Injector Flow doesn't match
        // what the Build Card detected as fitted, the mixture runs rich/lean of target BY DESIGN (real
        // injector-data mismatch) - but silently, so players chased a mystery. Explain the root cause
        // BEFORE the mixture cards, with the exact fix.
        var injM = num('injMismatch');
        var fittedCc = num('fittedInjCc') || 0;
        if (injM !== null && fittedCc > 0 && Math.abs(1 - injM) > 0.07) {
          var ecuCc = Math.round((scope.settings && scope.settings.injectorFlow) || 600);
          if (injM < 1) {
            add('injmatch', { sev: 'warn', title: 'ECU injector setting is too SMALL (' + ecuCc + 'cc set, ~' + Math.round(fittedCc) + 'cc fitted)',
              advice: "The fitted injectors are bigger than the ECU thinks, so every squirt delivers MORE fuel than commanded - the car runs rich of target and closed loop can't correct that much. Fix the DATA, not the tables: raise 'Injector flow' on Fuel Setup to ~" + Math.round(fittedCc) + "cc and the lambda target will finally track. (If the Build Card misread a mod part's name, set the flow to what the injectors really are.)", page: 'fuel_setup', highlight: 'set:injectorFlow' });
          } else {
            add('injmatch', { sev: 'warn', title: 'ECU injector setting is too BIG (' + ecuCc + 'cc set, ~' + Math.round(fittedCc) + 'cc fitted)',
              advice: "The ECU thinks the injectors flow more than they physically do, so every squirt delivers LESS fuel than commanded - it runs lean of target. Lower 'Injector flow' on Fuel Setup toward ~" + Math.round(fittedCc) + "cc.", page: 'fuel_setup', highlight: 'set:injectorFlow' });
          }
        }

        // mixture - now OR during the last pull. A measured-vs-target miss is a base-model error, not
        // a reason to move the desired Lambda Target. Traditional mode corrects Fuel Main; Modelled
        // mode corrects VE. The LEAN card needs real throttle: the decel fuel cut
        // leans the mixture on every lift by design (safe overrun), and lean only matters under load -
        // without the TPS gate this card cried "melts pistons" while coasting. The absolute leg sits
        // above the max targetable lambda (1.10) so an on-target lean-cruise tune doesn't flag either.
        var tpsNow = num('tps') || 0;
        var modelledFuel = Number(s.fuelEquationMode) === 2;
        var trackingPage = modelledFuel ? 've' : 'fuel';
        var leanTrackingAdvice = advT(modelledFuel ? 'lean_modelled_advice' : 'lean_traditional_advice') ||
          (modelledFuel
            ? "Measured lambda is leaner than target. Verify the wideband, injector data, fuel pressure and duty, then raise VE in this region or collect fresh Mixture Map data. Do not lower Lambda Target just to hide a tracking error."
            : "Measured lambda is leaner than target. Verify the wideband, injector data, fuel pressure and duty, then raise Fuel Main in this region or collect fresh Mixture Map data. Do not lower Lambda Target just to hide a tracking error.");
        var richTrackingAdvice = advT(modelledFuel ? 'rich_modelled_advice' : 'rich_traditional_advice') ||
          (modelledFuel
            ? "Measured lambda is richer than target. Verify the wideband and fuel-system data, then lower VE in this region or collect fresh Mixture Map data. Do not move Lambda Target just to hide the tracking error."
            : "Measured lambda is richer than target. Verify the wideband and fuel-system data, then lower Fuel Main in this region or collect fresh Mixture Map data. Do not move Lambda Target just to hide the tracking error.");
        // Lambda Target itself is only the fix when the REQUEST is inappropriate. Keep that
        // separate from a table-model tracking error so the two workflows cannot contradict.
        var liveBaro = Math.max(40, Number(t.baroKpa) || 101.325);
        var targetHighLoad = !t.isDiesel && rpm > 1500 && tpsNow >= 20 && mapK !== null && mapK >= liveBaro * 0.82;
        if (tgt !== null && targetHighLoad && tgt > 1.02) {
          add('targetlambda', { sev: 'danger', title: 'Lambda Target is lean for high load (λ ' + tgt.toFixed(2) + ')',
            advice: "This is a requested-mixture problem, not a tracking correction. Set an appropriate richer high-load Lambda Target for the engine and fuel, then tune Fuel Main or VE until measured lambda follows it. Verify knock, EGT, fuel pressure and injector duty before another pull.", page: 'lambda' });
        } else if (tgt !== null && targetHighLoad && tgt < 0.72) {
          add('targetlambda', { sev: 'warn', title: 'Lambda Target is extremely rich at high load (λ ' + tgt.toFixed(2) + ')',
            advice: "The requested mixture itself may be excessive and can cost power, foul plugs, or dilute oil. Choose a target appropriate for the engine and fuel first; then use Fuel Main or VE only to make measured lambda track that request.", page: 'lambda' });
        }
        if (!t.isDiesel && lam !== null && tgt !== null && rpm > 1500 && tpsNow >= 10 && lam > tgt + 0.05) {
          add('mix', { sev: 'danger', title: advT('lean_now_title', { lam: lam.toFixed(2), tgt: tgt.toFixed(2) }) || ('Running LEAN (λ ' + lam.toFixed(2) + ' vs target ' + tgt.toFixed(2) + ')'), advice: leanTrackingAdvice, page: trackingPage });
        } else if (!t.isDiesel && lam !== null && tgt !== null && rpm > 1500 && lam < tgt - 0.05) {
          add('mix', { sev: 'warn', title: advT('rich_now_title', { lam: lam.toFixed(2), tgt: tgt.toFixed(2) }) || ('Running RICH (λ ' + lam.toFixed(2) + ' vs target ' + tgt.toFixed(2) + ')'), advice: richTrackingAdvice, page: trackingPage });
        } else if (!t.isDiesel && rec.lean > 0.06) {
          var lrpm = Math.round(rec.leanRpm);
          add('mix', { sev: 'danger', title: advT('lean_load_title', { lean: rec.lean.toFixed(2), rpm: lrpm }) || ('Ran LEAN under load (off by +' + rec.lean.toFixed(2) + ' λ near ' + lrpm + ' rpm)'),
            advice: leanTrackingAdvice, page: trackingPage });
        } else if (!t.isDiesel && rec.rich < -0.06) {
          var rrpm = Math.round(rec.richRpm);
          var recentRichLambda = rec.richLambda === null ? '?' : Number(rec.richLambda).toFixed(2);
          add('mix', { sev: 'warn', title: advT('rich_load_title', { rich: recentRichLambda, rpm: rrpm }) || ('Ran RICH under load (λ ' + recentRichLambda + ' near ' + rrpm + ' rpm)'),
            advice: richTrackingAdvice, page: trackingPage });
        }

        // injector duty - now OR recent
        if (idc > 95 || rec.idc > 95) {
          var dty = Math.round(Math.max(idc, rec.idc));
          add('idc', { sev: 'warn', title: advT('idc_title', { duty: dty }) || ('Injectors near max (' + dty + '% duty)'), advice: advT('idc_advice') || "The injectors can't flow much more - past 100% it goes lean even though the table asks for fuel. Fit bigger injectors on Fuel Setup (raise Injector flow), or reduce fuel demand.", page: 'fuel_setup', highlight: 'set:injectorFlow' });
        }

        // SPEED DENSITY VE-MODEL ERROR (V2.8): the simulator can compare the live table value with
        // hidden true airflow. A real ECU cannot know true VE directly, so label this as training-only
        // context after the broad, mode-aware measured-lambda warning. Silent when the model is matched.
        if (!t.isDiesel && Number(s.fuelEquationMode) === 2 && Number(t.veTableCell) > 0 && Number(t.veTrueCell) > 0) {
          var veOff = (t.veTableCell - t.veTrueCell) / t.veTrueCell * 100;
          if (Math.abs(veOff) > 6) {
            add('vemodel', { sev: 'warn', title: 'Training insight: VE Table ' + (veOff > 0 ? 'over' : 'under') + '-estimates this cell by ~' + Math.round(Math.abs(veOff)) + '%',
              advice: "The trainer can compare your table with its hidden airflow model; a real ECU cannot directly know true VE. Here, the table " + (veOff > 0 ? "overstates airflow, so it over-fuels and runs rich" : "understates airflow, so it under-fuels and runs lean") + ". After verifying injector, pressure and sensor data, nudge VE " + (veOff > 0 ? "down" : "up") + " here or collect fresh Mixture Map data. Keep Lambda Target as the desired mixture, then re-drive to verify.", page: 've' });
          }
        }

        // EGT - exhaust gas temperature too high. Opposite fix on diesel vs petrol: on a diesel you
        // cut FUEL (more fuel = more heat), on petrol you richen.
        if (egt !== null && egt > (s.egtLimit || 950) - 30) {
          if (t.isDiesel) {
            add('egt', { sev: egt > (s.egtLimit || 950) ? 'danger' : 'warn', title: 'EGT high (' + Math.round(egt) + ' °C)', advice: "On a diesel, high EGT means too much fuel for the air - it melts pistons. Pull Max fuel DOWN, add BOOST (more air burns cooler), or advance injection timing a touch (the Ignition Main table). Do NOT add fuel - that makes it hotter. Aim well under your EGT limit sustained.", page: 'diesel', highlight: 'set:dieselMaxFuel' });
          } else {
            add('egt', { sev: egt > (s.egtLimit || 950) ? 'danger' : 'warn', title: 'EGT high (' + Math.round(egt) + ' °C)', advice: "The exhaust is running HOT - that's a lean and/or over-retarded condition (or just too much boost) and it melts pistons & valves. Richen the mixture (lower Lambda Target / raise Fuel Main) and add a touch of timing where it's hot, and back off boost. Aim under ~900 °C sustained.", page: 'lambda' });
          }
        }

        if (fp !== null && fp < 250) { add('fp', { sev: 'warn', title: advT('fp_title', { fp: Math.round(fp) }) || ('Low fuel pressure (' + Math.round(fp) + ' kPa)'), advice: advT('fp_advice') || "The fuel system is struggling. Ease off, or look at injector/pump sizing on Fuel Setup.", page: 'fuel_setup' }); }
        // TURBO PHYSICALLY MAXED (V2.6): the #1 unanswerable "boost won't reach target" - wastegate
        // pegged, boost plateaued, target simply above what the compressor can flow. Shown BEFORE the
        // overboost cards because it's the root cause, and no ECU setting can fix hardware.
        // V3.0.8: fire on the 30s HISTORY too, not just live. See the note in accumulateLog - the
        // live flag is cleared on lift, so by the time you finish the pull and open Help Me to ask
        // why boost fell short, the answer had already evaporated.
        // The HISTORY branch needs three guards the live branch got for free. model.boostMaxed is
        // hard-cleared by the controller on NA and on superchargers before the turbo branch ever
        // runs, and it belongs to the car that is connected right now - rec.boostMaxedSeen carries
        // none of that, so it must re-assert: a real turbo (hasTurbo is TRUE for superchargers, so
        // that is not sufficient on its own) and a buffer that is actually live.
        var maxedHist = rec.boostMaxedSeen && histFresh && hasTurbo
          && !(scope.base && scope.base.isSupercharger) && isFinite(rec.boostMaxedPsi);
        if (t.boostMaxed || maxedHist) {
          var atPsi = Math.round(t.boostMaxed
            ? (isFinite(Number(t.mapGaugePsi)) ? Number(t.mapGaugePsi) : ((mapK || 101.325) - (Number(t.baroKpa) || 101.325)) * KPA_TO_PSI)
            : rec.boostMaxedPsi);
          // "peaking near" not "near": boostMaxedRpm is the rpm of the HIGHEST-psi maxed sample, and
          // since the ceiling rises with rpm that is the top of the pull, not where it first pegged.
          var maxedWhen = t.boostMaxed ? '' : (rec.boostMaxedRpm ? (' during your last pull, peaking near ' + Math.round(rec.boostMaxedRpm) + ' rpm') : ' during your last pull');
          add('boostmax', { sev: 'warn', title: 'Boost target is above this turbo\'s ceiling (~' + atPsi + ' psi is all it has' + maxedWhen + ')',
            advice: "The wastegate is fully closed and boost has stopped climbing short of your target - the compressor is at its physical limit, exactly like a real turbo. In BeamNG a wastegate can only BLEED boost off, never add it, so once the ECU commands it shut you are at the compressor's ceiling and no base duty, gain or overboost setting can add air it cannot flow. Note that ceiling RISES WITH RPM, so the same turbo can read one number in a low gear and several psi more at redline. Two real fixes: LOWER the Boost Target to what it actually holds (~" + atPsi + " psi), or fit a BIGGER turbo from the vehicle's parts menu and re-tune for it.", page: 'boost', highlight: 'edit:minus' });
        }
        if (hasTurbo && mapK !== null && bt !== null && mapK > bt + overcut) {
          var ovr = Math.round(mapK - bt);
          add('boost', { sev: 'warn', title: advT('boost_now_title', { over: ovr }) || ('Overboosting now (' + ovr + ' kPa over target)'), advice: advT('boost_now_advice') || "Boost is over target. Lower the Boost Target, or on Boost Control lower the P gain so it stops spiking. On a stock block keep boost modest.", page: 'boost_control', highlight: 'set:boostPGain' });
        } else if (hasTurbo && rec.over > overcut) {
          var bovr = Math.round(rec.over), borpm = Math.round(rec.overRpm);
          add('boost', { sev: 'warn', title: advT('boost_load_title', { over: bovr, rpm: borpm }) || ('Boost spiked over target under load (peak +' + bovr + ' kPa near ' + borpm + ' rpm)'), advice: advT('boost_load_advice') || "Boost overshot the target during your pull - overboost is hard on a stock engine, and is likely what damaged it. Lower the Boost Target a touch, and on Boost Control lower the P gain so it stops spiking. Keep boost modest on stock internals.", page: 'boost_control', highlight: 'set:boostPGain' });
        }
        // V2.9.7 wave 4 (Liqurdz's BBG report): iGain = 0 droop coaching. Pure P control needs a
        // standing error to hold wastegate duty, so a P-only closed loop settles short of target by
        // design - players read the shortfall as "boost is broken". Distinct from t.boostMaxed
        // (compressor ceiling - that card owns the hardware case, so stay quiet there).
        // 14 kPa ~= 2 psi; rec.droopT > 2 means it held through a real pull, not a spool blip.
        // V3.0.8: also stay quiet when the ceiling was hit anywhere in the window, not just live -
        // otherwise the history-based TURBO MAXED card and this one now both fire for the same pull
        // and blame the I gain for a hardware limit.
        if (hasTurbo && !t.boostMaxed && !rec.boostMaxedSeen && Number(s.boostMode || 2) !== 1 && Number(s.boostIGain) === 0 && rec.droopT > 2 && rec.droopKpa > 14) {
          var droopPsi = (rec.droopKpa * KPA_TO_PSI).toFixed(1);
          add('boostdroop', { sev: 'info', title: 'Holding ' + droopPsi + ' psi under target',
            advice: "Base duty cannot close this gap in closed loop - with I gain at 0 this is pure P control, and P needs a standing error to hold any correction, so it settles short of target by design. Add I gain (0.05 is plenty) on Boost Control and the loop will walk that last bit onto target.", page: 'boost_control', highlight: 'set:boostIGain' });
        }
        if (ect !== null && ect > 110) { add('temp', { sev: 'warn', title: advT('temp_ect_title', { ect: Math.round(ect) }) || ('Coolant hot (' + Math.round(ect) + ' °C)'), advice: advT('temp_ect_advice') || "Let it cool - cruise gently before the next pull.", page: null }); }
        else if (oil !== null && oil > 135) { add('temp', { sev: 'warn', title: advT('temp_oil_title', { oil: Math.round(oil) }) || ('Oil hot (' + Math.round(oil) + ' °C)'), advice: advT('temp_oil_advice') || "Oil temp is high - back off and let it cool.", page: null }); }
        // block strength - only when the engine is actually being hurt; wording adapts to how
        // strong the fitted block is (a built/forged block part takes more before it lets go).
        var harming = (health !== null && health < 99) || t.limpMode || knock > thr || rec.knock > thr;
        if (bs !== null && harming) {
          var bsv = bs.toFixed(1);
          if (bs < 1.15) {
            add('block', { sev: 'info', title: advT('block_weak_title', { bs: bsv }) || ('Stock-strength block (' + bsv + 'x)'),
              advice: advT('block_weak_advice') || "This engine block is stock-strength, so it has little margin for abuse - keep timing and boost modest. A built/forged block part (from the vehicle's parts menu) would take more punishment before it lets go. You can see this as Block strength on the Protection page.", page: 'protection', highlight: 'nav:protection' });
          } else if (bs < 1.6) {
            add('block', { sev: 'info', title: advT('block_mid_title', { bs: bsv }) || ('Mildly built block (' + bsv + 'x)'),
              advice: advT('block_mid_advice', { bs: bsv }) || ("Your block is a bit stronger than stock (" + bsv + "x), so it has some headroom - but you're still pushing past it. Back the tune off a little, or fit an even stronger block part."), page: 'protection', highlight: 'nav:protection' });
          } else {
            add('block', { sev: 'warn', title: advT('block_strong_title', { bs: bsv }) || ('Even a built block has limits (' + bsv + 'x)'),
              advice: advT('block_strong_advice', { bs: bsv }) || ("This is a strong block (" + bsv + "x) and it's STILL taking damage - you're well past what even built internals will take. Back off timing and boost; no amount of block will save this tune."), page: 'protection', highlight: 'nav:protection' });
          }
        }
        // new-hardware context: explain WHY it is running off and what to tune
        var hw = scope.hardware || {};
        if ((hw.turbo || hw.displ || hw.cam || hw.itb || hw.inj) && (harming || rec.lean > 0.05 || rec.rich < -0.05 || rec.knock > thr || (lam !== null && tgt !== null && Math.abs(lam - tgt) > 0.06))) {
          var bits = [];
          if (hw.turbo) { bits.push(scope.tr('a turbo (add fuel + pull timing in the boost cells)')); }
          if (hw.inj) { bits.push(scope.tr('a bigger fuel system (raise Injector flow on Fuel Setup to match the injectors)')); }
          if (hw.displ) { bits.push(scope.tr('a bigger motor (richen fuel across the band)')); }
          if (hw.cam) { bits.push(scope.tr('a cam (re-tune idle + midrange fuel)')); }
          if (hw.itb) { bits.push(scope.tr('ITBs (dial in idle and tip-in fuel)')); }
          add('hardware', { sev: 'info', title: advT('hw_title') || 'New hardware needs tuning',
            advice: advT('hw_advice', { bits: bits.join(', ') }) || ("You've fitted " + bits.join(', ') + ". The stock maps don't account for it - tune the affected cells until lambda holds target and it stops knocking under load."), page: 'lambda' });
        }
        if (s.protection === false) { add('prot', { sev: 'info', title: advT('prot_title') || 'Engine protection is OFF', advice: advT('prot_advice') || "Nothing is saving the engine from a bad tune. Turn Protection on unless you're testing the limit.", page: 'protection', highlight: 'set:protection' }); }

        if (!out.length) {
          if (t.isDiesel) {
            add('ok', { sev: 'good', title: 'Diesel tune looks healthy',
              advice: "AFR is above the smoke limit, EGT is in check and the engine's healthy. Chase more power on the dyno: raise Max fuel until smoke just appears, then add boost + rail pressure to clear it and fuel further - one change at a time.", page: 'dyno' });
          } else {
            add('ok', { sev: 'good', title: advT('ok_title') || 'Tune looks healthy',
              advice: advT('ok_advice') || "Lambda near target, and no knock at idle OR during your recent pull. Injector duty and temps are fine. Chase a little more on the dyno - add a couple degrees of timing or nudge AFR toward 0.88, one change at a time.", page: 'dyno' });
          }
        }
        return out;
      };
      scope.gotoFix = function (adv) {
        if (!adv) { return; }
        if (adv.page) { scope.setPage(adv.page); }
        if (adv.highlight) {
          setTimeout(function () { applyHighlight(adv.highlight); setTimeout(clearHighlight, 5000); }, 130);
        }
      };

      // ====================================================================
      // Build Card (Phase 1) - a screenshot-ready spec sheet for bragging rights
      // ====================================================================
      scope.build = { loaded: false, error: false, model: '', config: '', parts: [], blockStrength: null, blockTier: null, namesLoaded: false };
      scope.hardware = { turbo: 0, cam: 0, itb: 0, inj: 0, displ: 0, intake: 0 };
      // is the tune dialled in for the fitted hardware? reads the last ~30s of under-load telemetry
      scope.tuneStatus = function () {
        var h = scope.hardware || {};
        if (!(h.turbo || h.cam || h.itb || h.inj || h.displ || h.intake)) { return { ok: true, text: 'Stock - nothing to tune' }; }
        var rows = scope.logRows || [], bad = false, ran = false;
        var fromT = rows.length ? rows[rows.length - 1].t - 30 : 0;
        for (var i = 0; i < rows.length; i++) {
          var r = rows[i]; if (r.t < fromT) { continue; }
          if (r.rpm > 2500 && r.mapKpa > 70) {
            ran = true;
            if (r.knockLevel > 1.4 || r.lambdaActual > (r.lambdaTarget || 1) + 0.06 || r.lambdaActual < 0.78 || r.injectorDuty > 101) { bad = true; }
          }
        }
        if (bad) { return { ok: false, text: 'Needs tuning - off under load' }; }
        if (ran) { return { ok: true, text: 'Dialled in' }; }
        return { ok: false, text: 'Untested - do a pull' };
      };
      scope.snapshot = false;
      scope.toggleSnapshot = function () { scope.snapshot = !scope.snapshot; };
      // matched against the slot+part name with underscores turned to spaces, so word
      // boundaries work (\bhead\b catches a cylinder head but not a headlight). Covers the
      // whole induction/fuel/exhaust/internals spread incl. ITBs, carbs, blowers, NOS.
      var BUILD_SLOT_RE = /\b(engine|motor|block|internal|internals|stroker|sleeve|piston|crank|conrod|rod|gasket|cylinderhead|head|valve|valvetrain|cam|camshaft|rocker|turbo|turbocharger|supercharger|blower|whipple|nitrous|n2o|nos|intake|itb|carb|carburettor|carburetor|throttle|velocity|stack|plenum|runner|manifold|intercooler|charge|boost|wastegate|blowoff|bov|exhaust|header|downpipe|catback|muffler|fuel|injector|injection|rail|pump|ecu|standalone|ignition|coil|spark|distributor|flywheel|clutch|oil|sump|airbox|filter|snorkel|forced|displacement|bore)\b/i;
      function prettyName(s) {
        var p = String(s || '').replace(/\.pc$/i, '').trim();
        if (!p) { return ''; }
        // friendly names (have spaces/mixed case, e.g. "2.0L DOHC I4 Engine") are display-ready -
        // leave them. Only tidy a raw jbeam id ("covet_engine_2.0_dohc") into Title Case.
        if (/_/.test(p) || p === p.toLowerCase()) {
          p = p.replace(/_/g, ' ').replace(/\b\w/g, function (m) { return m.toUpperCase(); });
        }
        return p;
      }
      function prettySlot(slot) { return prettyName(String(slot || '').replace(/^[a-z0-9]+_/i, '')); }
      // block strength from the block PART NAME - reliable on every mod (incl. custom-Lua engines
      // where the torque-based guess is unreliable). Order matters: most-built first.
      function blockStrengthFromName(name) {
        if (!name) { return null; }
        var n = name.toLowerCase();
        if (/billet/.test(n)) { return { v: 2.9, tier: 'Billet' }; }
        if (/forged|sleeved|sleeve|built|\brace\b|ultra/.test(n)) { return { v: 2.3, tier: 'Forged/Race' }; }
        if (/stage ?3|heavy ?duty|\bhd\b/.test(n)) { return { v: 1.9, tier: 'Stage 3' }; }
        if (/stage ?2/.test(n)) { return { v: 1.5, tier: 'Stage 2' }; }
        if (/stage ?1|street|sport|mild|performance/.test(n)) { return { v: 1.25, tier: 'Stage 1' }; }
        if (/stock|\boem\b|standard/.test(n)) { return { v: 1.0, tier: 'Stock' }; }
        // unrecognized block name (e.g. a swap mod named by engine code: "2JZ Long Block",
        // "RB26 Engine", "K24 Block"): send NO override so the controller keeps its real jbeam
        // torque-rating reading instead of being forced to a fake stock 1.0x.
        return null;
      }
      // classify fitted parts into per-system "needs tuning" demands pushed to the controller.
      // 0 = stock/absent (no penalty); higher = bigger change to tune around.
      function classifyHardware(parts) {
        var h = { turbo: 0, cam: 0, itb: 0, inj: 0, displ: 0, intake: 0 };
        for (var i = 0; i < parts.length; i++) {
          var k = (parts[i].slot + ' ' + parts[i].name).toLowerCase();
          if (/turbo|supercharg|blower|whipple/.test(k)) { h.turbo = Math.max(h.turbo, /stage ?3|gt[34]|big|race|ultra/.test(k) ? 2 : 1); }
          if ((/\bcam\b|camshaft/.test(k)) && /race|stage|performance|aggressive|drag|sport/.test(k)) { h.cam = Math.max(h.cam, /stage ?3|race|drag/.test(k) ? 2 : 1); }
          if (/\bitb\b|individual throttle|velocity stack/.test(k)) { h.itb = 1; }
          // injector demand requires a REAL cc number in the part name (V2_3, Louis's rich-on-connect
          // report): the old fallback GUESSED 750/1050cc for any non-stock "fuel system"/"injector"
          // part, and creatively-named mod parts became phantom demands -> mystery rich that lambda
          // could never correct. No number now means no demand - the honest failure is "didn't notice
          // your injectors" (harmless), and the Fuel Setup "Injector match" readout + Tune Doctor card
          // cover the real-mismatch case.
          if ((/fuel system|injector/.test(k)) && !/stock/.test(k)) { var ccm = k.match(/(\d{3,4})\s*cc/); if (ccm) { h.inj = Math.max(h.inj, parseInt(ccm[1], 10)); } }
          if (/stroker|displacement|big ?bore|\bbore\b/.test(k)) { h.displ = Math.max(h.displ, /stroker/.test(k) ? 2 : 1); }
          if ((/header|downpipe|\bexhaust\b|cold air|\bintake\b/.test(k)) && !/stock|itb/.test(k)) { h.intake = 1; }
        }
        if (scope.base.hasTurbo && h.turbo === 0) { h.turbo = 1; }
        return h;
      }
      // ---- Factory-baseline tracking (Pure OEM, V2_1) --------------------------------------------
      // The config a car SPAWNS with is its factory baseline and runs fine on the stock tune; only
      // hardware fitted AFTER spawning needs tuning. We snapshot the fitted hardware per spawned
      // config (keyed by model + config file) the first time we see it, then tell the controller only
      // the DELTA (current - baseline). A different car/config snapshots a fresh baseline; editing a
      // part keeps the baseline so the added part surfaces as a demand. If detection is ever unsure
      // (e.g. the config filename clears on an edit) it re-snapshots = "runs fine" - the safe Pure-OEM
      // default that never re-introduces the "factory turbo runs bad on connect" bug.
      // PERSISTED (2.3): the baseline used to live only in memory, so a UI reload / game restart
      // re-snapshotted the CURRENT (modified) build as "factory" - bolt-on tuning demands silently
      // vanished after a restart and the car ran fine untuned. Kept in localStorage so a fitted turbo
      // still demands tuning tomorrow. Failure mode unchanged: unreadable data -> re-snapshot = "runs fine".
      var factoryBaseline = {};
      var buildRefreshGeneration = 0;
      if (!IS_MIRROR) { try { factoryBaseline = JSON.parse(localStorage.getItem('oep_hw_baseline') || '{}') || {}; } catch (e) { factoryBaseline = {}; } }
      if (typeof factoryBaseline !== 'object' || Array.isArray(factoryBaseline)) { factoryBaseline = {}; }
      function factoryDemand(model, cfgFile, h) {
        var key = String(model || '') + '|' + String(cfgFile || '');
        var b = factoryBaseline[key];
        if (!b) {
          b = factoryBaseline[key] = { turbo: h.turbo, cam: h.cam, itb: h.itb, inj: h.inj, displ: h.displ, intake: h.intake };
          if (!IS_MIRROR) { try { localStorage.setItem('oep_hw_baseline', JSON.stringify(factoryBaseline)); } catch (e) {} }
        }
        // sanitize (persisted entries could be malformed): a NaN here would end up in the Lua
        // setHardware call and error it out
        var bn = function (k) { return Number(b[k]) || 0; };
        return {
          turbo:  Math.max(0, h.turbo  - bn('turbo')),
          cam:    Math.max(0, h.cam    - bn('cam')),
          itb:    Math.max(0, h.itb    - bn('itb')),
          // injectors are a flow number (cc), not a 0/1/2 level: a demand only if BIGGER than factory,
          // passing the new physical size so the controller asks the player to match the ECU flow.
          inj:    (h.inj > bn('inj')) ? h.inj : 0,
          // V3.0: the ABSOLUTE fitted size travels too (inj above is baseline-gated, so a factory
          // drag config with 1600cc injectors reports inj=0) - the fuel-pump headroom model sizes
          // the pump from whatever fuel system is genuinely in the car, factory-fitted included.
          injAbs: Math.max(0, Number(h.inj) || 0),
          // V3.0.7: same treatment for ITBs, and for a sharper reason. `itb` above is baseline-gated,
          // so a config that SPAWNS with individual throttle bodies reports itb=0 permanently - it
          // never even gets the idle roughness, and it is exactly the car that most needs the ITB
          // manifold-pressure model. Whether the plenum is missing is a property of the engine, not
          // of what you bolted on after spawning, so the absolute value has to travel too.
          itbAbs: Math.max(0, Number(h.itb) || 0),
          displ:  Math.max(0, h.displ  - bn('displ')),
          intake: Math.max(0, h.intake - bn('intake'))
        };
      }
      scope.refreshBuild = function (withNames) {
        try {
          if (typeof bngApi === 'undefined' || !bngApi.engineLua) { scope.build.error = true; scope.build.loaded = true; return; }
          var buildIdentity = currentPackageIdentity();
          if (typeof buildIdentity.vehicleId !== 'number') { scope.build.loaded = false; return; }
          scope.build.loaded = false;
          var buildRequestGeneration = ++buildRefreshGeneration;
          // PERF (V2.6, the 10-15s vehicle-entry freeze on big mod folders): jbeam/io.getAvailableParts
          // scans EVERY installed mod's parts - the same heavy scan as the game's parts menu - and we
          // only ever used it for pretty part NAMES on the Build Card. The scan is LAZY now: vehicle
          // entry/respawn builds the card instantly with raw part names (withNames falsy), and the
          // full-name scan runs only the FIRST time the Build Card page is opened per spawn.
          // assemble a small, flat result IN Lua (nil-guarded). The live config stores the fitted
          // parts as a TREE (config.partsTree: nodes with .chosenPartName + .children keyed by slot),
          // NOT a flat config.parts map - so walk the tree and flatten slot -> chosenPartName.
          var availBlock = withNames
            ? "local avail = nil pcall(function() local jio = require('jbeam/io') if jio and d.ioCtx then avail = jio.getAvailableParts(d.ioCtx) end end) "
            : "local avail = nil ";
          var lua = "(function() local vid = be and be:getPlayerVehicleID(0) or -1 local d = core_vehicle_manager and core_vehicle_manager.getPlayerVehicleData() if not d or not d.config then return {ok=false, vehicleId=vid} end local c = d.config local r = {ok=true, vehicleId=vid, model = c.model or '', config = c.partConfigFilename or '', parts = {}} " + availBlock + "local function friendly(pn) if avail and avail[pn] and type(avail[pn].description)=='string' and avail[pn].description ~= '' then return avail[pn].description end return pn end local function walk(node, slotId) if type(node) ~= 'table' then return end local pn = node.chosenPartName if type(pn)=='string' and pn ~= '' then r.parts[tostring(slotId or pn)] = friendly(pn) end if type(node.children)=='table' then for cslot, child in pairs(node.children) do walk(child, cslot) end end end if c.partsTree then walk(c.partsTree, 'main') end return r end)()";
          bngApi.engineLua(lua, function (res) {
            scope.$evalAsync(function () {
              // A full-name part scan can finish seconds after a Tab, respawn, or engine swap.
              // Only the newest scan for the still-linked car/engine may repaint the Build Card
              // or issue its derived hardware/classification setters.
              if (buildRequestGeneration !== buildRefreshGeneration || !packageIdentityMatches(buildIdentity) ||
                  !res || Number(res.vehicleId) !== Number(buildIdentity.vehicleId)) { return; }
              scope.build.loaded = true;
              if (!res || typeof res !== 'object' || res.ok === false) { scope.build.error = true; return; }
              scope.build.error = false;
              if (withNames) { scope.build.namesLoaded = true; }
              scope.build.model = res.model || '';
              scope.build.config = prettyName(String(res.config || '').split('/').pop());
              var arr = [], parts = res.parts || {};
              for (var slot in parts) {
                if (!parts.hasOwnProperty(slot)) { continue; }
                var pv = parts[slot];
                var hay = (slot + ' ' + pv).replace(/_/g, ' ').toLowerCase();
                if (!BUILD_SLOT_RE.test(hay)) { continue; }
                var nm = prettyName(pv);
                if (nm) { arr.push({ slot: prettySlot(slot), name: nm }); }
              }
              arr.sort(function (a, b) { return a.slot < b.slot ? -1 : (a.slot > b.slot ? 1 : 0); });
              scope.build.parts = arr;
              // block strength: only override the controller when the block PART NAME actually
              // advertises a strength tier (forged/billet/stage/stock). For an unrecognized name
              // (most swap mods, named by engine code) we send NO override, so the controller keeps
              // its REAL jbeam reading (maxTorqueRating / power estimate) instead of a fake 1.0x.
              var bc = blockStrengthFromName(scope.cardBlock());
              if (bc) {
                scope.build.blockStrength = bc.v; scope.build.blockTier = bc.tier;
                callController('setBlockStrength(' + bc.v + ')');
              } else {
                scope.build.blockStrength = null; scope.build.blockTier = null;
                callController('setBlockStrength(nil)');
              }
              // classify the fitted hardware, then push the controller only what the player ADDED
              // beyond the factory baseline (the config it spawned with). A car that comes turbo/
              // cammed/big-injector from its config is factory and runs fine on the stock tune (Pure
              // OEM); only parts fitted AFTER spawning need tuning - exactly like real life.
              var h = classifyHardware(arr);
              var demand = factoryDemand(res.model, res.config, h);
              scope.hardware = demand;       // what still needs tuning (drives Tune Doctor + Build Card)
              scope.hardwareFitted = h;      // raw fitted hardware, for reference
              callController('setHardware({turbo=' + demand.turbo + ', cam=' + demand.cam + ', itb=' + demand.itb + ', itbAbs=' + (demand.itbAbs || 0) + ', inj=' + demand.inj + ', injAbs=' + (demand.injAbs || 0) + ', displ=' + demand.displ + ', intake=' + demand.intake + '})');
              var es = parseEngineSpec(scope.cardEngine());   // V2 engine-aware injector scaling
              scope.engineSpec = es;
              callController('setEngineSpec(' + (es.cyl || 0) + ', ' + (es.displL || 0) + ')');
              // V8 / muscle detection (sound): cyl>=8 or a classic V8 engine-code name -> deep V8 2-step sound
              var isV8 = detectMuscle(scope.cardEngine(), scope.build.model, es.cyl);
              scope.engineIsV8 = isV8;
              callController('setEngineClass(' + (isV8 ? 'true' : 'false') + ')');
              // diesel detection (#4): name-based, covers modded diesels that don't tag requiredEnergyType.
              // The controller OR's this with its own energy-type check, so we only ever force diesel ON.
              var isDsl = detectDiesel(arr, scope.cardEngine(), scope.build.model);
              scope.build.isDiesel = isDsl;
              callController('setDiesel(' + (isDsl ? 1 : 0) + ')');
            });
          });
        } catch (e) { scope.build.error = true; scope.build.loaded = true; }
      };
      scope.cardCar = function () { return scope.carName || scope.build.model || 'Vehicle'; };
      scope.cardFuel = function () {
        if ((scope.t && scope.t.isDiesel) || (scope.build && scope.build.isDiesel)) { return scope.tr('Diesel'); }
        var f = scope.settings && scope.settings.fuelType; return f === 2 ? 'E85' : (f === 3 ? 'E30' : (f === 4 ? 'Race Gas C16' : (f === 5 ? 'Methanol M1' : scope.tr('Pump Gas'))));
      };
      scope.cardStrength = function () {
        // show the controller's AUTHORITATIVE jbeam-derived strength (telemetry) so the card always
        // matches what the damage model uses; fall back to the name guess only before telemetry arrives.
        var b = (typeof scope.t.blockStrength === 'number') ? scope.t.blockStrength
          : (typeof scope.build.blockStrength === 'number') ? scope.build.blockStrength : null;
        return (typeof b === 'number') ? b.toFixed(1) + 'x' : '--';
      };
      scope.peakBoostPsi = function () {
        var mx = 0, rows = scope.logRows || [];
        for (var i = 0; i < rows.length; i++) {
          var g = isFinite(Number(rows[i].mapGaugePsi)) ? Number(rows[i].mapGaugePsi) : (Number(rows[i].mapKpa) - (Number(rows[i].baroKpa) || 101.325)) * KPA_TO_PSI;
          if (isFinite(g) && g > mx) { mx = g; }
        }
        if (mx <= 0) {
          var liveGauge = isFinite(Number(scope.t.mapGaugePsi)) ? Number(scope.t.mapGaugePsi)
            : (Number(scope.t.mapKpa) - (Number(scope.t.baroKpa) || 101.325)) * KPA_TO_PSI;
          if (isFinite(liveGauge)) { mx = liveGauge; }
        }
        return Math.max(0, mx);
      };
      function partKey(p) { return (p.slot + ' ' + p.name).toLowerCase(); }
      // induction extras read from the fitted parts (ITBs, carbs, blowers)
      scope.cardIntake = function () {
        var parts = scope.build.parts || [];
        for (var i = 0; i < parts.length; i++) {
          var k = partKey(parts[i]);
          if (/\bitb\b|individual throttle|velocity|throttle bod/.test(k)) { return 'ITBs'; }
          if (/carb/.test(k)) { return 'Carburettor'; }
        }
        return null;
      };
      scope.cardSuper = function () {
        var parts = scope.build.parts || [];
        for (var i = 0; i < parts.length; i++) { if (/supercharg|blower|whipple|roots|centrifugal/.test(partKey(parts[i]))) { return true; } }
        return false;
      };
      scope.cardInduction = function () {
        var intake = scope.cardIntake(), base;
        if (scope.base.hasTurbo) {
          var psi = scope.peakBoostPsi();
          base = psi > 0.3 ? (scope.tr('Turbocharged') + ' - ' + psi.toFixed(1) + ' ' + scope.tr('psi peak')) : scope.tr('Turbocharged');
        } else if (scope.cardSuper()) {
          base = scope.tr('Supercharged');
        } else {
          base = scope.tr('Naturally Aspirated');
        }
        return intake ? (base + ' - ' + scope.tr(intake)) : base;
      };
      // the fitted block (so the card can show "Stage 2 - 1.4x"). Prefer the actual long/short
      // block over the internals sub-part, then fall back to the main engine.
      scope.cardBlock = function () {
        var parts = scope.build.parts || [], internals = null, eng = null;
        for (var i = 0; i < parts.length; i++) {
          var k = partKey(parts[i]);
          if (/long ?block|short ?block|\bblock\b/.test(k)) { return parts[i].name; }
          if (!internals && /internal|stroker|sleeve/.test(k)) { internals = parts[i].name; }
          if (!eng && /\bengine\b|\bmotor\b/.test(k) && !/mount|bay|cover|wiring|cooler|\boil\b|bracket|management|ecu/.test(k)) { eng = parts[i].name; }
        }
        return internals || eng;
      };
      // the main engine part (the motor itself, distinct from the block/internals)
      // V2 engine-aware: read displacement + cylinder count from the engine part name ("2.0L DOHC I4",
      // "6.7L V8", "3.0L I6", "2.5L Flat 4"...) so the injector model scales per-cylinder. Neutral if unparsed.
      function parseEngineSpec(name) {
        var spec = { cyl: 0, displL: 0 };
        if (!name) { return spec; }
        var n = name.toLowerCase();
        var dm = n.match(/(\d(?:\.\d{1,2})?)\s*l\b/);   // "2.0l", "6.7 l", "5l"
        if (dm) { spec.displL = parseFloat(dm[1]); }
        var cm = n.match(/\b(?:flat|boxer|[ivhw])[\s-]?(\d{1,2})\b/);   // i4, v8, v-6, flat 4, h6, w12
        if (cm) { spec.cyl = parseInt(cm[1], 10); }
        return spec;
      }
      // diesel detection (#4): scan the engine part name + all fitted part names/slots + the vehicle
      // model for diesel keywords. The controller's requiredEnergyType check is authoritative for vanilla;
      // this catches modded diesels that don't tag it (named "Cummins", "Power Stroke", "TDI", etc.).
      var DIESEL_RE = /diesel|cummins|duramax|power ?stroke|\btdi\b|\bcrd\b|\bcdi\b|\bdci\b|\bhdi\b|tdci|\bjtd\b|bluetec|ecodiesel|\bdmax\b|\bd-?max\b|common ?rail/;
      function detectDiesel(parts, engineName, model) {
        var hay = ((engineName || '') + ' ' + (model || '')).toLowerCase();
        for (var i = 0; parts && i < parts.length; i++) {
          hay += ' ' + ((parts[i].slot || '') + ' ' + (parts[i].name || '')).toLowerCase();
        }
        return DIESEL_RE.test(hay.replace(/_/g, ' '));
      }
      // V8 / muscle detection (sound): true for V8/V10/V12 by cylinder count, OR by classic engine-family
      // name that parseEngineSpec can't read a cylinder count from (LS, HEMI, VK56, Coyote...). These get
      // the deep V8 2-step + rolling sound instead of the Honda / anti-lag braap.
      var MUSCLE_RE = /\bv-?(8|10|12)\b|\bls[1-9x]?\b|\blt[1-4]\b|\bvortec\b|hemi|hellcat|redeye|\bsrt-?8\b|coyote|voodoo|godzilla|windsor|cleveland|\bsbc\b|\bbbc\b|big.?block|small.?block|\bvk(4[05]|5[06])|\b[1-3]uz\b/;
      function detectMuscle(engineName, model, cyl) {
        if ((cyl || 0) >= 8) { return true; }
        var hay = ((engineName || '') + ' ' + (model || '')).toLowerCase().replace(/_/g, ' ');
        return MUSCLE_RE.test(hay);
      }
      scope.cardEngine = function () {
        var parts = scope.build.parts || [];
        for (var i = 0; i < parts.length; i++) {
          var slot = parts[i].slot.toLowerCase();
          if (/\bengine\b|\bmotor\b/.test(slot) && !/internal|block|mount|bay|cover|cooler|\boil\b|wiring|ecu|management|bracket/.test(slot)) { return parts[i].name; }
        }
        for (var j = 0; j < parts.length; j++) {
          var nm = parts[j].name.toLowerCase();
          if (/engine|litre|liter|sohc|dohc|vtec|\bi4\b|\bi6\b|\bv\d\b|\dl\b/.test(nm) && !/internal|block|mount/.test(nm)) { return parts[j].name; }
        }
        return null;
      };
      scope.cardNos = function () {
        for (var i = 0; i < scope.build.parts.length; i++) {
          var n = (scope.build.parts[i].name + ' ' + scope.build.parts[i].slot).toLowerCase();
          if (n.indexOf('nitrous') >= 0 || n.indexOf('n2o') >= 0 || n.indexOf('nos') >= 0) { return scope.build.parts[i].name; }
        }
        return null;
      };
      // headline numbers: VERIFIED from a real dyno pull if we have one, else rated from telemetry
      // Power tiers (V2.6): live dyno pull > persisted verified pull > auto ECU PEAK > mod's rating.
      // The ECU PEAK tier is the fix for "99% of shared cards say run a dyno": any card that's been
      // DRIVEN carries a real measured number automatically; the dyno stays the gold stamp.
      scope.cardPower = function () {
        var c = (scope.dynoCurPeak && scope.dynoCurPeak()) || null;
        if (c && c.hp > 0) { return { verified: true, hp: c.hp, hpRpm: c.hpRpm, tq: c.tq, tqRpm: c.tqRpm, date: null }; }
        var pv = scope.powerRec && scope.powerRec.ver;
        if (pv && pv.hp > 0) { return { verified: true, hp: pv.hp, hpRpm: pv.hpRpm, tq: pv.tq, tqRpm: pv.tqRpm, date: pv.date }; }
        var pk = scope.powerRec && scope.powerRec.peak;
        if (pk && pk.hp > 0) {
          // torque shown is AT the power peak (hp x 5252 / rpm) - real math, no curve needed
          return { verified: false, ecupeak: true, hp: pk.hp, hpRpm: pk.rpm, tq: pk.rpm > 0 ? pk.hp * 5252 / pk.rpm : 0, tqRpm: pk.rpm, date: pk.date };
        }
        var rp = scope.t.peakPowerHp, rt = scope.t.peakTorqueNm;
        if (typeof rp === 'number' && rp > 0) { return { verified: false, hp: rp, hpRpm: 0, tq: (typeof rt === 'number' ? rt : 0) * NM_TO_LBFT, tqRpm: 0 }; }
        return null;
      };
      // BASE -> TUNED gain for the card: needs a saved baseline AND a verified (dyno) number -
      // gains are earned on the dyno, the ECU PEAK tier doesn't qualify (no comparable curve).
      scope.cardGain = function () {
        var b = (scope.dynoBasePeak && scope.dynoBasePeak()) || null;
        var p = scope.cardPower();
        if (!b || !b.hp || !p || !p.verified || !p.hp) { return null; }
        return { base: b.hp, now: p.hp, pct: (p.hp - b.hp) / b.hp * 100 };
      };

      // ====================================================================
      // $digest stability (infdig fix)
      // ====================================================================
      // These functions are bound in ng-if (on the returned object) or ng-repeat (over the returned
      // array), and each call built a FRESH object/array. Angular's per-digest dirty check sees a new
      // reference every iteration, never settles, hits 10 iterations and throws [$rootScope:infdig] -
      // which ABORTS the digest before the DOM is fully flushed. That's why pages like Dyno / the map
      // editors / Help Me navigated but their nav highlight (and content) got stuck. Memoize each so it
      // returns the SAME reference while the computed value is unchanged (JSON-compared); a genuine data
      // change still yields exactly one new reference, so live updates keep working.
      var _stableMemo = {};
      function stable(key, val) {
        var s; try { s = JSON.stringify(val); } catch (e) { s = String(val); }
        var m = _stableMemo[key];
        if (m && m.s === s) { return m.v; }
        _stableMemo[key] = { s: s, v: val };
        return val;
      }
      (function () {
        var fns = ['dynoBasePeak', 'dynoCurPeak', 'selInfo', 'diagnose', 'compareRows', 'faultArray', 'cardPower', 'cardGain', 'tuneStatus', 'logParams'];
        for (var i = 0; i < fns.length; i++) {
          (function (name) {
            var raw = scope[name];
            if (typeof raw !== 'function') { return; }
            scope[name] = function () { return stable(name, raw.apply(this, arguments)); };
          })(fns[i]);
        }
      })();

      // ====================================================================
      // toolbar
      // ====================================================================
      function ensureControlling() {
        if (!scope.controlling) { callController('setControl(1)'); scope.controlling = true; }
      }
      // V3.0.9: remember the user's connect INTENT across a teardown. reconnectControl (app.js:118) is
      // a plain scope local, so it already handled "the controller re-inited under us" - but a part
      // change also destroys this whole directive (BeamNG unmounts the app overlay for the parts menu),
      // and the variable that remembers dies with the scope that would have acted on it. Keyed to the
      // car so spawning a DIFFERENT vehicle never inherits the intent, and absent on a first run so a
      // new user is never auto-connected before pressing anything.
      function rememberConnectIntent(on) {
        try { localStorage.setItem('oep_reconnect', on ? (scope.carName || '1') : ''); } catch (e) {}
      }
      scope.toggleConnect = function () {
        if (scope.controlling) {
          callController('setControl(0)');
          scope.controlling = false;
          rememberConnectIntent(false);
          setToast('ECU disconnected - monitoring only');
        } else {
          ensureControlling();
          rememberConnectIntent(true);
          // Re-read THIS car's engine on Connect (the reliable moment: the active car is settled and is the
          // one you're tuning) and push its engine family so the 2-step / rolling sound matches. Detecting on
          // a vehicle SWITCH was unreliable with two cars spawned; you always Connect to the car you tune.
          scope.refreshBuild();
          scope.setPage('runtime');
          setToast('ECU connected - now controlling the engine');
          if (scope.otherTunerActive) { setToast('Heads up: the OrlandoEdits Tuner is also live on this car - disable one to avoid conflicts.'); }
        }
      };
      scope.connect = scope.toggleConnect;  // legacy alias
      scope.help = HELP_TEXT;

      // ====================================================================
      // draggable window (grab the header to move; position resets on reload)
      // ====================================================================
      var appBox = null;
      var winDrag = { on: false, ox: 0, oy: 0 };
      function findAppBox() {
        // Prefer THIS app's wrapper: BeamNG sets inline left/top/transform per app.
        // Grabbing a shared absolute layer would move every app, so look for the
        // nearest ancestor with inline positioning first.
        var el = element[0].parentElement, depth = 0;
        while (el && el !== document.body && depth < 8) {
          var s = el.style;
          if ((s.left && s.left !== 'auto') || (s.top && s.top !== 'auto') || (s.transform && s.transform !== 'none')) { return el; }
          el = el.parentElement; depth++;
        }
        // fallback: nearest absolutely/fixed positioned ancestor
        el = element[0].parentElement; depth = 0;
        while (el && el !== document.body && depth < 8) {
          var pos = window.getComputedStyle(el).position;
          if (pos === 'absolute' || pos === 'fixed') { return el; }
          el = el.parentElement; depth++;
        }
        return element[0].parentElement || element[0];
      }
      function winMove(e) {
        if (!winDrag.on || !appBox) { return; }
        var w = appBox.offsetWidth, h = appBox.offsetHeight;
        var left = Math.max(0, Math.min(window.innerWidth - w, e.clientX - winDrag.ox));
        var top = Math.max(0, Math.min(window.innerHeight - h, e.clientY - winDrag.oy));
        appBox.style.position = 'absolute';
        appBox.style.margin = '0';
        appBox.style.right = 'auto';
        appBox.style.bottom = 'auto';
        appBox.style.transform = 'none';
        appBox.style.left = left + 'px';
        appBox.style.top = top + 'px';
      }
      function winUp() {
        winDrag.on = false;
        document.removeEventListener('mousemove', winMove, true);
        document.removeEventListener('mouseup', winUp, true);
      }
      scope.startWindowDrag = function (e) {
        if (!e) { return; }
        if (!appBox) { appBox = findAppBox(); }
        if (!appBox) { return; }
        var rect = appBox.getBoundingClientRect();
        winDrag.on = true;
        winDrag.ox = e.clientX - rect.left;
        winDrag.oy = e.clientY - rect.top;
        e.preventDefault();
        e.stopPropagation();
        document.addEventListener('mousemove', winMove, true);
        document.addEventListener('mouseup', winUp, true);
      };

      // ====================================================================
      // global mouse handling for drag-select
      // ====================================================================
      function endDrag() { if (scope.dragging) { scope.$applyAsync(function () { scope.dragging = false; }); } }
      element.on('mouseup', endDrag);
      element.on('mouseleave', endDrag);
      element.on('mousemove', function (e) {
        var cls = e.target && e.target.classList;
        if (!cls) { return; }
        if (cls.contains('oep-dyno')) {
          var rows = (scope.dynoRows && scope.dynoRows.length > 1) ? scope.dynoRows : scope.dynoBaseline;
          if (!rows || rows.length < 2) { return; }
          var rect = e.target.getBoundingClientRect(); var frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
          var r = rows[Math.round(frac * (rows.length - 1))]; drawDyno(frac);
          scope.$applyAsync(function () {
            var t = r[0] + ' rpm | ' + rowHp(r).toFixed(1) + ' hp | ' + rowTq(r).toFixed(1) + ' lb-ft';
            if (r[2] != null) { t += ' | ' + (r[2] * (scope.settings.stoichRatio || 14.7)).toFixed(1) + ' AFR'; }
            if (r[3] != null) { t += ' | ' + dynoBoostStr(r[3]) + ' boost'; }
            scope.dynoText = t;
          });
        }
      });

      // Log scrub: press-and-drag a cursor across the stacked lanes (like PCLink), instead of
      // clicking the arrow over and over. Sets logCursor from the mouse X and refreshes the readout.
      var logScrubEl = null;
      function logScrubTo(e) {
        var cv = logScrubEl; if (!cv) { return; }
        var rows = scope.logRows, n = rows.length; if (n < 2) { return; }
        var rect = cv.getBoundingClientRect();
        var frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
        var vr = viewRange(n);
        var ci = vr.i0 + Math.round(frac * vr.span);   // map within the zoom window
        if (ci === scope.logCursor) { return; }
        scope.logCursor = ci; drawLog(); scope.$applyAsync();
      }
      element.on('mousedown', function (e) {
        var cls = e.target && e.target.classList;
        if (cls && cls.contains('oep-log') && scope.activePage === 'log_navigator') {
          logScrubEl = e.target; logScrubTo(e); e.preventDefault();
        }
      });
      element.on('mousemove', function (e) { if (logScrubEl) { logScrubTo(e); } });

      // Phase B: drag the Navigator window — pan inside, resize the edges, click outside to recentre.
      var logNavDrag = null;
      element.on('mousedown', function (e) {
        var t = e.target;
        if (!(t && t.classList && t.classList.contains('oep-lognav'))) { return; }
        var rect = t.getBoundingClientRect();
        var frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
        var v = scope.logView, edge = 8 / rect.width, mode;
        if (Math.abs(frac - v.a) <= edge) { mode = 'left'; }
        else if (Math.abs(frac - v.b) <= edge) { mode = 'right'; }
        else if (frac > v.a && frac < v.b) { mode = 'move'; }
        else { var wdt0 = v.b - v.a, na0 = Math.max(0, Math.min(1 - wdt0, frac - wdt0 / 2)); v.a = na0; v.b = na0 + wdt0; mode = 'move'; }
        logNavDrag = { mode: mode, f0: frac, a0: v.a, b0: v.b };
        drawLog(); scope.$applyAsync(); e.preventDefault();
      });
      element.on('mousemove', function (e) {
        if (!logNavDrag) { return; }
        var cv = logNavCanvas(); if (!cv) { return; }
        var rect = cv.getBoundingClientRect();
        var frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
        var d = frac - logNavDrag.f0, v = scope.logView, minW = 0.02;
        if (logNavDrag.mode === 'move') { var wdt = logNavDrag.b0 - logNavDrag.a0, na = Math.max(0, Math.min(1 - wdt, logNavDrag.a0 + d)); v.a = na; v.b = na + wdt; }
        else if (logNavDrag.mode === 'left') { v.a = Math.max(0, Math.min(logNavDrag.b0 - minW, logNavDrag.a0 + d)); }
        else { v.b = Math.min(1, Math.max(logNavDrag.a0 + minW, logNavDrag.b0 + d)); }
        drawLog(); scope.$applyAsync();
      });
      function endLogScrub() { logScrubEl = null; logNavDrag = null; }
      element.on('mouseup', endLogScrub);
      element.on('mouseleave', endLogScrub);

      // ====================================================================
      // interactive tutorial / coaching
      // ====================================================================
      scope.tutorial = { active: false, lesson: null, lessonIndex: -1, stepIndex: 0, canAdvance: false };
      scope.showWelcome = false;
      scope.tutChapter = null;   // null = show the chapter list; index = show that chapter's lessons
      var welcomeChecked = false;
      var advanceTimer = null;

      function fuelMaxAbs() {
        if (!scope.maps || !scope.maps.fuel) { return -1; }
        var neutral = (scope.mapMeta && scope.mapMeta.fuel) ? scope.mapMeta.fuel.default : 0;   // scalar table: neutral is 1.0
        var m = 0; scope.maps.fuel.forEach(function (row) { row.forEach(function (v) { m = Math.max(m, Math.abs(v - neutral)); }); }); return m;
      }
      function ignMin() {
        if (!scope.maps || !scope.maps.ignition) { return -99; }
        var m = 99; scope.maps.ignition.forEach(function (row) { row.forEach(function (v) { m = Math.min(m, v); }); }); return m;
      }
      function boostMaxCell() {
        if (!scope.maps || !scope.maps.boost) { return 0; }
        var m = 0; scope.maps.boost.forEach(function (row) { row.forEach(function (v) { m = Math.max(m, v); }); }); return m;
      }

      // Beginner boost-shaping lesson predicates use canonical kPa settings and telemetry, so
      // changing the display to PSI cannot change whether a step passes. They prove the requested
      // target math only; a pneumatic spring floor may still prevent the hardware following it.
      function boostLessonAllSafeTrims(s, prefix, count) {
        var st = (s && s.settings) || {};
        for (var i = 1; i <= count; i++) {
          if ((Number(st[prefix + i + 'TargetTrimKpa']) || 0) > 0.001) { return false; }
          if (Math.abs(Number(st[prefix + i + 'DutyTrimPct']) || 0) > 0.001) { return false; }
        }
        return true;
      }
      function boostLessonGearConfigReady(s) {
        var st = (s && s.settings) || {};
        var g1 = Number(st.boostGear1TargetTrimKpa) || 0;
        var g2 = Number(st.boostGear2TargetTrimKpa) || 0;
        if (Number(st.boostMode) !== 2 || Number(st.boostShapeMode) !== 2 || st.protection !== true || st.knockControl !== true ||
            g1 > -6.5 || g2 > -3 || g1 >= g2 || !boostLessonAllSafeTrims(s, 'boostGear', 8)) { return false; }
        for (var gear = 3; gear <= 8; gear++) {
          if (Math.abs(Number(st['boostGear' + gear + 'TargetTrimKpa']) || 0) > 0.001) { return false; }
        }
        return true;
      }
      function boostLessonSpeedConfigReady(s) {
        var st = (s && s.settings) || {};
        var low = Number(st.boostSpeed1TargetTrimKpa) || 0;
        var thirty = Number(st.boostSpeed2TargetTrimKpa) || 0;
        var sixty = Number(st.boostSpeed3TargetTrimKpa) || 0;
        if (Number(st.boostMode) !== 2 || Number(st.boostShapeMode) !== 3 || st.protection !== true || st.knockControl !== true ||
            low > -6.5 || thirty > -3 || low >= thirty || Math.abs(sixty) > 0.001 || !boostLessonAllSafeTrims(s, 'boostSpeed', 6)) { return false; }
        for (var point = 3; point <= 6; point++) {
          if (Math.abs(Number(st['boostSpeed' + point + 'TargetTrimKpa']) || 0) > 0.001) { return false; }
        }
        return true;
      }
      function boostLessonPreflightIssue(s) {
        var b = (s && s.base) || {}, t = (s && s.t) || {};
        if (s && s.gamePaused) { return 'Un-pause the game before starting; vehicle Lua cannot apply or verify the temporary setup while paused.'; }
        if ((s && s.mapBankMutationPending) || (s && s.mapBank && Number(s.mapBank.pending) > 0) ||
            (s && s.applyingTune) || (s && s.workingTunePrompt)) { return 'Finish or cancel the current tune/MAP action before starting this lesson.'; }
        if ((s && s.otherTunerActive) || t.otherTunerActive === true) { return 'Disable the other active vehicle tuner before starting; two ECUs must not command boost together.'; }
        if (b.hasTurbocharger !== true) { return 'This lesson requires a turbocharged vehicle with a controllable wastegate.'; }
        if (t.isDiesel === true) { return 'This beginner verification currently requires a gasoline turbo engine; diesel boost safety uses a different AFR/smoke workflow.'; }
        if (!isFinite(Number(t.engineHealth)) || !isFinite(Number(t.fuelDiffPressureKpa))) { return 'Wait for live engine and fuel-pressure data, then try again.'; }
        if ((Number(t.speedKmh) || Number(t.boostSpeedKmh) || 0) > 1 || (Number(t.tps) || 0) > 5) { return 'Stop the vehicle and release the throttle before starting this temporary lesson.'; }
        if (Number(t.engineHealth) < 90) { return 'Engine health must be at least 90% before a loaded boost lesson.'; }
        if (t.limpMode === true || Number(t.faultCount) > 0) { return 'Repair the active fault or limp condition before starting this lesson.'; }
        if (Number(t.ectC) > 112 || Number(t.oilC) > 135) { return 'Let the engine cool before starting this lesson.'; }
        if (Number(t.fuelDiffPressureKpa) < 250) { return 'Differential fuel pressure is below 250 kPa; fix the fuel supply first.'; }
        if (Number(t.injectorDuty) >= 90) { return 'Injector duty is already too high for a safe loaded lesson.'; }
        return '';
      }
      function boostLessonStopped(s) {
        var t = (s && s.t) || {};
        return Math.abs(Number(t.speedKmh) || Number(t.boostSpeedKmh) || 0) <= 1 && (Number(t.tps) || 0) <= 5;
      }
      function boostLessonRestoreIssue(s) {
        if (s && s.gamePaused) { return 'Un-pause the game before restoring the pre-lesson tune.'; }
        if ((s && s.mapBankMutationPending) || (s && s.mapBank && Number(s.mapBank.pending) > 0) ||
            (s && s.applyingTune) || (s && s.workingTunePrompt)) { return 'Finish or cancel the current tune/MAP action before restoring the pre-lesson tune.'; }
        if (!boostLessonStopped(s)) { return 'Stop the vehicle and release the throttle before restoring the pre-lesson tune.'; }
        return '';
      }
      function boostLessonRequestMathOk(s) {
        var t = (s && s.t) || {};
        var baseTarget = Number(t.boostBaseTargetKpa);
        var appliedTrim = Number(t.boostTargetTrimAppliedKpa);
        var requested = Number(t.boostRequestedTargetKpa);
        var gearTrim = Number(t.boostGearTargetTrimKpa);
        var speedTrim = Number(t.boostSpeedTargetTrimKpa);
        var baro = Number(t.baroKpa);
        if (!isFinite(baseTarget) || !isFinite(appliedTrim) || !isFinite(requested) || !isFinite(gearTrim) || !isFinite(speedTrim)) { return false; }
        if (!isFinite(baro) || baro <= 0) { baro = 101.325; }
        var expected = Math.max(baro, Math.min(1000, baseTarget + gearTrim + speedTrim));   // V3.0: 1000, was 600 - mirrors the controller's [baro, mapMeta.boost.max] clamp
        return Math.abs(requested - expected) <= 0.75 && Math.abs(appliedTrim - (expected - baseTarget)) <= 0.75;
      }
      function boostLessonAppliedTrimOk(s, rawTrim) {
        var t = (s && s.t) || {};
        var baseTarget = Number(t.boostBaseTargetKpa);
        var applied = Number(t.boostTargetTrimAppliedKpa);
        var baro = isFinite(Number(t.baroKpa)) ? Number(t.baroKpa) : 101.325;
        if (!isFinite(baseTarget) || !isFinite(applied) || !isFinite(Number(rawTrim))) { return false; }
        var expected = Math.max(baro, Math.min(1000, baseTarget + Number(rawTrim))) - baseTarget;   // V3.0: 1000, was 600 - mirrors the controller clamp
        return Math.abs(applied - expected) <= 0.75;
      }
      function boostLessonExpectedSpeedTrim(s) {
        var st = (s && s.settings) || {}, t = (s && s.t) || {};
        var speed = Number(t.boostSpeedKmh);
        var low = Number(st.boostSpeed1TargetTrimKpa);
        var thirty = Number(st.boostSpeed2TargetTrimKpa);
        if (!isFinite(speed) || !isFinite(low) || !isFinite(thirty) || speed < 0 || speed > 30) { return null; }
        return low + (thirty - low) * (speed / 30);
      }
      function boostLessonLiveFrameOk(s, kind) {
        var st = (s && s.settings) || {}, t = (s && s.t) || {};
        if (st.enabled !== true || !s.controlling || s.otherTunerActive || t.otherTunerActive === true || Number(st.boostMode) !== 2 || st.protection !== true || st.knockControl !== true) { return false; }
        var baro = isFinite(Number(t.baroKpa)) ? Number(t.baroKpa) : 101.325;
        if ((Number(t.rpm) || 0) < 1800 || (Number(t.tps) || 0) < 50 || (Number(t.mapKpa) || 0) <= baro || !boostLessonRequestMathOk(s)) { return false; }
        if ((Number(t.ectC) || 0) < 60 || Number(t.ectC) > 110 || (Number(t.oilC) || 0) < 50 || Number(t.oilC) > 135) { return false; }
        if (Number(t.engineHealth) < 90 || t.limpMode === true || Number(t.faultCount) > 0) { return false; }
        if (Number(t.injectorDuty) >= 90 || Number(t.fuelDiffPressureKpa) < 250 || Number(t.egtC) >= 925) { return false; }
        if (t.isDiesel || Number(t.knockLevel) > 1.2) { return false; }
        if (!isFinite(Number(t.lambdaActual)) || !isFinite(Number(t.lambdaTarget)) || Number(t.lambdaTarget) > 0.95 || Number(t.lambdaActual) > Math.min(Number(t.lambdaTarget) + 0.05, 0.98)) { return false; }
        if (s.tutorial && Number(t.knockCount) > Number(s.tutorial.markKnockCount || 0)) { return false; }
        if (!t.boostTargetBelowSpring && !t.boostFloored && isFinite(Number(t.boostTargetKpa)) && Number(t.mapKpa) > Number(t.boostTargetKpa) + 25) { return false; }
        if (Math.abs(Number(t.boostDutyTrimPct) || 0) > 0.01 || Math.abs((Number(t.boostFeedforwardDuty) || 0) - (Number(t.boostBaseWastegateDuty) || 0)) > 0.75) { return false; }
        var gearTrim = Number(t.boostGearTargetTrimKpa) || 0;
        var speedTrim = Number(t.boostSpeedTargetTrimKpa) || 0;
        var expectedGearTrim = t.boostGearValid === true && Number(t.boostGear) >= 1 && Number(t.boostGear) <= 2
          ? Number(st['boostGear' + Number(t.boostGear) + 'TargetTrimKpa']) : Number(st.boostGear1TargetTrimKpa);
        var gearSignalOk = isFinite(expectedGearTrim) && Math.abs(gearTrim - expectedGearTrim) <= 0.75;
        var expectedSpeedTrim = boostLessonExpectedSpeedTrim(s);
        var speedSignalOk = expectedSpeedTrim !== null && Math.abs(speedTrim - expectedSpeedTrim) <= 0.75;
        if (kind === 'gear') {
          return Number(st.boostShapeMode) === 2 && gearSignalOk &&
            gearTrim <= -2 && Math.abs(speedTrim) <= 0.75 && boostLessonAppliedTrimOk(s, gearTrim);
        }
        if (kind === 'speed') {
          return Number(st.boostShapeMode) === 3 && (Number(t.boostSpeedKmh) || 0) >= 8 && Number(t.boostSpeedKmh) <= 25 && String(t.boostSpeedSource || '').indexOf('Invalid') < 0 && speedSignalOk && speedTrim <= -1 &&
            Math.abs(gearTrim) <= 0.75 && boostLessonAppliedTrimOk(s, speedTrim);
        }
        if (kind === 'combined') {
          return Number(st.boostShapeMode) === 4 && gearSignalOk &&
            (Number(t.boostSpeedKmh) || 0) >= 8 && Number(t.boostSpeedKmh) <= 25 && String(t.boostSpeedSource || '').indexOf('Invalid') < 0 && speedSignalOk && gearTrim <= -2 && speedTrim <= -1 &&
            boostLessonAppliedTrimOk(s, gearTrim + speedTrim);
        }
        return false;
      }
      function boostLessonStableProof(s, kind) {
        if (!s || !s.tutorial) { return false; }
        if (s.tutorial.boostProofKind !== kind) { s.tutorial.boostProofKind = kind; s.tutorial.boostProofStreak = 0; }
        var currentKnockCount = Number(s.t && s.t.knockCount) || 0;
        if (currentKnockCount > Number(s.tutorial.markKnockCount || 0)) { s.tutorial.boostKnockLatched = true; }
        if (s.tutorial.boostKnockLatched) {
          s.tutorial.boostProofStreak = 0;
          // A knock event aborts the sample. Re-arm only after an explicit lift/stop and once the
          // active knock/fault/limp state has cleared; the cumulative controller counter is then the
          // new baseline, so the learner is never trapped on an impossible step.
          if (boostLessonStopped(s) && (Number(s.t && s.t.knockLevel) || 0) <= 0.1 &&
              Number(s.t && s.t.faultCount) <= 0 && s.t && s.t.limpMode !== true) {
            s.tutorial.markKnockCount = currentKnockCount;
            s.tutorial.boostKnockLatched = false;
          }
          return false;
        }
        if (boostLessonLiveFrameOk(s, kind)) { s.tutorial.boostProofStreak = (Number(s.tutorial.boostProofStreak) || 0) + 1; }
        else { s.tutorial.boostProofStreak = 0; }
        return s.tutorial.boostProofStreak >= 30;
      }
      function boostLessonProofStatus(s, kind) {
        var st = (s && s.settings) || {}, t = (s && s.t) || {};
        if ((s && s.otherTunerActive) || t.otherTunerActive === true) { return 'LIFT: disable the other active vehicle tuner before continuing this boost test.'; }
        if (s && s.tutorial && s.tutorial.boostKnockLatched) { return 'ABORTED: knock occurred. Lift, stop, let knock/faults clear, then the step will re-arm automatically.'; }
        if ((Number(t.ectC) || 0) < 60 || (Number(t.oilC) || 0) < 50) { return 'LIVE CHECK: warm coolant to 60 C and oil to 50 C before applying load.'; }
        if (t.limpMode === true || Number(t.faultCount) > 0 || Number(t.engineHealth) < 90) { return 'LIFT: an engine-health, fault or limp check is active.'; }
        if (Number(t.injectorDuty) >= 90 || Number(t.fuelDiffPressureKpa) < 250 || Number(t.egtC) >= 925) { return 'LIFT: fuel-system or temperature safety margin is too low.'; }
        if (Number(t.knockLevel) > 1.2 || Number(t.lambdaTarget) > 0.95 || Number(t.lambdaActual) > Math.min(Number(t.lambdaTarget) + 0.05, 0.98)) { return 'LIFT: knock or gasoline lambda is outside the lesson safety window.'; }
        if ((Number(t.rpm) || 0) < 1800 || (Number(t.tps) || 0) < 50) { return 'LIVE CHECK: hold above 1,800 rpm and 50% throttle in a safe test area.'; }
        if (kind !== 'gear' && (Number(t.boostSpeedKmh) < 8 || Number(t.boostSpeedKmh) > 25)) { return 'LIVE CHECK: stay between 8 and 25 km/h to prove interpolation between the 0 and 30 rows.'; }
        var n = Math.max(0, Math.min(30, Number((s.tutorial || {}).boostProofStreak) || 0));
        return 'LIVE CHECK: hold a clean, steady sample - ' + n + ' / 30 safe frames.';
      }

      // REVIEW FIX 2026-07-27. The diesel "now clean it up under load" gates were a single-frame
      // `smoke < N && rpm > 1800` with no load term at all. model.smoke is instantaneous and diesel
      // fuelling is throttle-proportional, so simply LIFTING OFF above 1,800 rpm drives smoke to 0
      // and AFR to its 99 clamp - the step passed and told the learner "smoke is near zero and AFR
      // pins at your limit" without a single loaded sample ever being taken. That is the exact
      // opposite of what these lessons set out to teach. Require real load and a held sample, the
      // way boostLessonStableProof already does. 20 frames is about one second at the telemetry
      // rate (the boost lesson's 30 frames is quoted as ~1.5 s).
      // The `isDiesel` term matters as much as the load term: model.smoke is published as a flat 0 for
      // every spark engine (oeProTune.lua, non-diesel branch), so without it every "smoke is low" gate
      // passes instantly in a petrol car - and neither diesel lesson declares a requiredVehicle.
      function dieselLoadedProof(s, extraOk) {
        if (!s || !s.tutorial) { return false; }
        var t = s.t || {};
        var loaded = t.isDiesel === true && (Number(t.rpm) || 0) > 1800 && (Number(t.tps) || 0) >= 50;
        if (loaded && extraOk(t, s)) { s.tutorial.dieselProofStreak = (Number(s.tutorial.dieselProofStreak) || 0) + 1; }
        else { s.tutorial.dieselProofStreak = 0; }
        return (Number(s.tutorial.dieselProofStreak) || 0) >= 20;
      }
      // Names the missing condition so a learner is never left staring at a step with no idea what it
      // wants. It reports the LIVE measured values, not just the driving conditions - when a gate is
      // blocked by physics rather than by how the learner is driving, rpm and throttle are by
      // definition already satisfied, so a bare frame counter would tell them nothing.
      function dieselLoadedProofStatus(s, what, pedalHint) {
        var t = (s && s.t) || {};
        if (t.isDiesel !== true) { return 'LIVE CHECK: this lesson needs a DIESEL vehicle - it has no meaning on a spark engine.'; }
        if ((Number(t.rpm) || 0) <= 1800) { return 'LIVE CHECK: hold above 1,800 rpm under load - lifting off makes Smoke read zero without proving anything.'; }
        if ((Number(t.tps) || 0) < 50) { return 'LIVE CHECK: this has to be proved UNDER LOAD. ' + (pedalHint || 'Hold at least 50% throttle in a safe test area.'); }
        var n = Math.max(0, Math.min(20, Number((s.tutorial || {}).dieselProofStreak) || 0));
        return 'LIVE CHECK: ' + what + '. Live: AFR ' + (Number(t.afr) || 0).toFixed(1) +
          ', Smoke ' + Math.round(Number(t.smoke) || 0) +
          ', boost ' + Math.round((Number(t.mapKpa) || 0) - (Number(t.baroKpa) || 101.3)) + ' kPa' +
          ', limiter cut ' + Math.round(Number(t.dieselLimiterCut) || 0) + '%' +
          ' - holding ' + n + ' / 20 frames.';
      }

      function lessonVehicleMatches(required) {
        if (!required) { return true; }
        if (!scope.build || scope.build.loaded !== true) { return false; }
        var model = String((scope.build && scope.build.model) || '').toLowerCase();
        var config = String((scope.build && scope.build.config) || '').replace(/\\/g, '/').split('/').pop().replace(/\.pc$/i, '');
        var expectedModel = String(required.model || '').toLowerCase();
        var expectedConfig = String(required.config || '').replace(/\\/g, '/').split('/').pop().replace(/\.pc$/i, '');
        // refreshBuild stores a display name ("Sport A"), while older/live callers may still
        // supply the raw filename ("sport_A.pc"). Normalize only separators/case, not words.
        config = config.replace(/[\s_]+/g, '').toLowerCase();
        expectedConfig = expectedConfig.replace(/[\s_]+/g, '').toLowerCase();
        return model === expectedModel && config === expectedConfig;
      }
      function transmissionDriveProof(s) {
        if (!s || !s.tutorial) { return false; }
        var t = s.t || {}, speed = Number(t.speedKmh) || 0, gear = Number(t.gearIndex) || 0;
        if (speed < 3) { s.tutorial.transDriveArmed = true; }
        return s.tutorial.transDriveArmed === true && speed >= 45 && gear >= 3;
      }
      function transmissionManagementProof(s) {
        if (!s || !s.tutorial) { return false; }
        var t = s.t || {}, speed = Number(t.speedKmh) || 0, gear = Number(t.gearIndex) || 0;
        if (speed < 3) { s.tutorial.transDriveArmed = true; }
        if (s.tutorial.transDriveArmed === true && t.shiftMgmtActive === true) { s.tutorial.transMgmtSeen = true; }
        return s.tutorial.transMgmtSeen === true && speed >= 45 && gear >= 3;
      }
      function transmissionTorqueLimitProof(s) {
        if (!s || !s.tutorial) { return false; }
        var status = String((s.t && s.t.torqueLimitStatus) || '');
        if (status.indexOf('Limiting ') === 0) { s.tutorial.transLimitSeen = true; }
        return s.tutorial.transLimitSeen === true && status.indexOf('Armed (higher gear)') === 0;
      }
      function transmissionConverterProof(s) {
        if (!s || !s.tutorial) { return false; }
        var t = s.t || {};
        var loadedAtStop = (Number(t.speedKmh) || 0) < 3 && (Number(t.tps) || 0) >= 20 &&
          (Number(t.converterSlipRpm) || 0) >= 300 && (Number(t.converterSlipKw) || 0) >= 1;
        s.tutorial.transConverterStreak = loadedAtStop ? (Number(s.tutorial.transConverterStreak) || 0) + 1 : 0;
        return s.tutorial.transConverterStreak >= 5;
      }

      var LESSONS = [
        { id: 'gauges', difficulty: 'Beginner', desc: 'Read lambda, knock, injector duty and engine health like a tuner.', title: 'Lesson 1 - Reading the gauges', setup: null, steps: [
          { text: "Welcome! This app is a live ECU. First let's find the live readings - click 'Runtime Values' in the tree on the left.", highlight: 'nav:runtime', check: function (s) { return s.activePage === 'runtime'; } },
          { text: "These are your live values. LAMBDA is the air/fuel ratio: 1.00 = ideal, BELOW 1.00 = rich (extra fuel), ABOVE = lean (not enough). Watch 'Lambda Actual' vs 'Lambda Target' while you drive.", manual: true },
          { text: "Now open 'Runtime Gauges' for the at-a-glance view a tuner watches during a pull.", highlight: 'nav:gauges', check: function (s) { return s.activePage === 'gauges'; } },
          { text: "Yellow = caution, red = lift. Knock, lean lambda, high injector duty and low engine health all warn you here. That's the basics - next lesson we fix a broken tune.", manual: true, last: true }
        ] },
        { id: 'rich', difficulty: 'Beginner', desc: 'Diagnose an over-rich tune and lean it back to target for more power.', title: 'Lesson 2 - Fix a rich tune', setup: 'rich', steps: [
          { text: "I've loaded a tune running way too RICH - it's dumping fuel, fouling plugs and making LESS power. Let's diagnose it: open 'Runtime Values'.", highlight: 'nav:runtime', check: function (s) { return s.activePage === 'runtime'; } },
          { text: "Drive and you'll see Lambda Actual sitting well BELOW target (rich). We fix that in the fuel table - open 'Fuel Main'.", highlight: 'nav:fuel', check: function (s) { return s.activePage === 'fuel'; } },
          { text: "Rows are load (MAP), columns are RPM. The cells above 1.00 are adding too much fuel (1.20 = +20%). Select the whole table - click 'All'.", highlight: 'edit:all', check: function (s) { return s.selCount() >= 12; } },
          { text: "Now pull fuel OUT: click the '-' button until the cells drop near 1.00 (neutral - 1.00 means follow the lambda target exactly). I bumped 'Amt' up so each click removes more. Watch the cells go from hot toward neutral.", highlight: 'edit:minus', check: function (s) { var m = fuelMaxAbs(); return m >= 0 && m <= 0.03; } },
          { text: "That's it - the tune is back near target. Do a full-throttle pull (or a dyno) and it'll run smoother and make more power. Rich = wasted fuel; now you can spot and fix it. Lesson complete!", manual: true, last: true }
        ] },
        { id: 'knock', difficulty: 'Intermediate', desc: 'Add ignition timing for power and find where knock begins.', title: 'Lesson 3 - Power & knock', setup: 'stock', steps: [
          { text: "Reset to a clean tune. Power comes from IGNITION TIMING - but too much causes KNOCK (detonation) that kills engines. Open 'Ignition Main'.", highlight: 'nav:ignition', check: function (s) { return s.activePage === 'ignition'; } },
          { text: "Select the whole table - click 'All'.", highlight: 'edit:all', check: function (s) { return s.selCount() >= 12; } },
          { text: "Add a few degrees of timing: click '+' about 5 times (~+4 deg). Do a dyno pull and you should make more power.", highlight: 'edit:plus', check: function (s) { return ignMin() >= 4; } },
          { text: "Now push too far - keep adding timing and watch 'Knock Level' on Runtime Values. With Knock Control ON the ECU pulls timing to save itself; turn it OFF (Knock Control page) + Protection OFF and you can grenade the motor. That's the edge a tuner lives on. Done!", manual: true, last: true }
        ] },
        { id: 'dyno', difficulty: 'Intermediate', desc: 'The real workflow: baseline pull, tune, re-pull and measure the gain.', title: 'Lesson 4 - Tune for power (dyno workflow)', setup: 'stock', steps: [
          { text: "Time for a real tuning session on a clean tune: baseline pull, tune, then re-pull to measure the gain. Open the 'Dyno / AFR' page.", highlight: 'nav:dyno', check: function (s) { return s.activePage === 'dyno'; } },
          { text: "Click START PULL, then in-game floor the throttle from low RPM and hold it to the limiter. This records your CURRENT power as the baseline.", highlight: 'dyno:start', check: function (s) { return (s.dynoPullCount || 0) > (s.tutorial.markPulls || 0); } },
          { text: "That dashed curve is now your baseline - note the peak HP under the graph. That's the number we're going to beat. Now let's make power.", manual: true },
          { text: "The biggest safe gain is ignition timing. Open 'Ignition Main'.", highlight: 'nav:ignition', check: function (s) { return s.activePage === 'ignition'; } },
          { text: "Select the whole table - click 'All'.", highlight: 'edit:all', check: function (s) { return s.selCount() >= 12; } },
          { text: "Add about 3-4 degrees of timing (click '+'). More timing = more power, up to the knock limit - so don't go crazy. (Pro tip: richening high-load cells toward ~0.88 lambda adds a little more too.)", highlight: 'edit:plus', check: function (s) { return ignMin() >= 3; } },
          { text: "Now measure the change. Go back to the 'Dyno / AFR' page.", highlight: 'nav:dyno', check: function (s) { return s.activePage === 'dyno'; } },
          { text: "Click START PULL and do a SECOND full-throttle run. Your new solid curve will overlay the dashed baseline.", highlight: 'dyno:start', check: function (s) { return (s.dynoPullCount || 0) > (s.tutorial.markPulls || 0); } },
          { text: "Compare! The text under the graph shows your new peak and the gain vs baseline (e.g. +6 hp). Hover the curve to scrub HP/torque at any RPM. That's the whole game: measure, change one thing, measure again. Lesson complete!", manual: true, last: true }
        ] },
        { id: 'power', difficulty: 'Intermediate', desc: 'Beyond timing: best-power AFR, cam timing (VVT) and boost.', title: 'Lesson 5 - More ways to make power', setup: 'stock', steps: [
          { text: "Timing isn't the only lever. An engine is an air pump - power comes from AIR + FUEL + SPARK. Last lesson was spark (timing); now let's cover the others: fuel (best-power AFR), cam timing, and boost.", manual: true },
          { text: "FUEL first. Open 'Lambda Target'. Lambda/AFR isn't just rich vs lean - there's a power sweet spot.", highlight: 'nav:lambda', check: function (s) { return s.activePage === 'lambda'; } },
          { text: "Best POWER is around 12.5-13.0 AFR (lambda ~0.86-0.89) at high load - a bit richer than the 14.7 'ideal'. Too rich AND too lean both make less power. Load increases DOWNWARD, so at wide-open throttle the engine runs in the BOTTOM (highest-kPa) row - scroll down to it, select it across the higher-RPM columns, nudge toward ~0.88 and dyno it. You'll find the peak, then it falls off either side.", manual: true },
          { text: "Now CAM TIMING - the Honda favourite. Open 'VVT Control'.", highlight: 'nav:vvt', check: function (s) { return s.activePage === 'vvt'; } },
          { text: "Cam timing changes when the valves open. More intake advance fills the midrange - that VTEC 'kick'. Raise 'Intake advance' toward 35-40 deg, then do a dyno pull and watch the midrange torque wake up.", highlight: 'set:vvtIntakeAdvance', check: function (s) { return (s.settings.vvtIntakeAdvance || 0) >= 32; } },
          { text: "Last one - BOOST (the big turbo lever). Open 'Boost Target'.", highlight: 'nav:boost', check: function (s) { return s.activePage === 'boost'; } },
          { text: "This table stores absolute MAP in kPa. Zero gauge boost is the live barometric pressure (about 101 kPa at sea level); 200 kPa is about 14.3 psi gauge at sea level. The kPa/PSI button converts against the live atmosphere for you. Select the whole table - click 'All'.", highlight: 'edit:all', check: function (s) { return s.selCount() >= 12; } },
          { text: "Now raise the target - click '+' a few times (each click adds boost). On a TURBO car, more boost = more air = more power, as long as fuel and timing keep up so it doesn't knock. (On an NA car this won't make boost - it's just showing you how you'd set it.)", highlight: 'edit:plus', check: function (s) { return boostMaxCell() > (s.tutorial.markBoost || 0) + 4; } },
          { text: "That's the whole picture: AIR (boost + cam), FUEL (best-power AFR) and SPARK (timing) - balanced together, safely. On a turbo car, the 'Dial in boost' lesson takes you through actually dialing the boost in with the control gains. Done!", manual: true, last: true }
        ] },
        { id: 'launch', difficulty: 'Intermediate', desc: 'Set up a 2-step launch limiter (plus anti-lag on turbos) for a hard drag launch.', title: 'Lesson 6 - Launch it (2-step)', setup: 'stock', steps: [
          { text: "Drag launches use a 2-step rev limiter: it holds the engine at a set RPM while you're staged, so you leave in the powerband instead of bogging. Let's set one up - open 'Limiters'.", highlight: 'nav:limits', check: function (s) { return s.activePage === 'limits'; } },
          { text: "Turn ON '2-step launch enabled'.", highlight: 'set:launchEnabled', check: function (s) { return !!s.settings.launchEnabled; } },
          { text: "Set the 'Launch limit' to where the car leaves best - usually 3500-5000 rpm for a street car, higher for a built one. Drag the slider, then we'll try it.", highlight: 'set:launchLimit', manual: true },
          { text: "TURBO cars: you can also open 'Anti-Lag' and enable it for the heat, cracks and flame model. OE requests BeamNG's native anti-lag control when the fitted turbo supports it, so some vehicles retain boost on the line and others do not. NA cars can skip this.", highlight: 'nav:antilag', manual: true },
          { text: "Now try it in-game: hold the brake, floor the throttle - the revs hold and bounce off the 2-step. Release the brake and go. If it spins, lower the launch RPM; if it bogs, raise it. A correct gasoline 2-step can bang or flame without rolling black coal; the visual profile does not measure launch quality. Done!", manual: true, last: true }
        ] },
        { id: 'pops', difficulty: 'Intermediate', desc: 'Build a real overrun burble tune - decel fuel cut off, rich + retarded overrun cells - so it bangs on every lift.', title: 'Pops & Bangs', setup: 'stock', steps: [
          { text: "Pops & bangs, the REAL way. They happen when you LIFT off the throttle: keep raw fuel flowing and retard the spark, and it lights off in the hot exhaust and bangs out the pipe. You build it in the TABLES - no toggle, no magic button, and it works on NA or turbo engines. Open 'Pops & Bangs'.", highlight: 'nav:pops', check: function (s) { return s.activePage === 'pops'; } },
          { text: "Choose 'OE Realistic' for believable training. It keeps gasoline smoke brief and fuel-aware, with black soot reserved for true flooding or diesel. 'OE Show / Coal' is the old exaggerated cloud for screenshots and changes no engine physics.", highlight: 'set:fxStyle', check: function (s) { return Number(s.settings.fxStyle) === 2; } },
          { text: "Step 1 - keep fuel flowing on a lift. Open 'Transient Fuel'.", highlight: 'nav:transient_fuel', check: function (s) { return s.activePage === 'transient_fuel'; } },
          { text: "Turn 'Decel fuel cut' OFF. Normally the ECU cuts fuel when you lift (for economy) - we want raw fuel going out the pipe instead, so it has something to bang with.", highlight: 'set:decelFuelCut', check: function (s) { return !s.settings.decelFuelCut; } },
          { text: "Step 2 - richen the overrun. Open 'Lambda Target'. The overrun is VACUUM (off-throttle) = the TOP rows, the negative-psi ones like -9 and -3 (NOT the bottom - that's boost). Select those top vacuum rows and richen toward ~0.80: type 0.80, hit Fill. Rich raw fuel = bang fuel. Click Next when done.", highlight: 'nav:lambda', manual: true },
          { text: "Step 3 - retard the spark there. Open 'Ignition Main' and pull those SAME top vacuum rows (-9 and -3) negative, about -8 to -12 deg (NOT the bottom/boost rows). Retarded spark finishes burning in the exhaust = the crack. Click Next when done.", highlight: 'nav:ignition', manual: true },
          { text: "Now drive it in 2nd or 3rd, then LIFT above 3000 rpm. Watch Pop strength: richer + more retard makes stronger bangs and more flame potential. Realistic black smoke still needs genuine soot conditions; changing to Show / Coal is cosmetic. That's a real burble tune. Done!", manual: true, last: true }
        ] },
        { id: 'rotidle', difficulty: 'Intermediate', desc: 'BETA - build a drag-scene rotational idle: heavy idle retard + a rich touch so the car lopes, rocks and occasionally barks at a standstill.', title: 'Rotational idle (BETA)', setup: 'stock', steps: [
          { text: "ROTATIONAL IDLE (BETA) - the drag/show 'rowdy idle': the ECU rhythmically cuts ignition at a STANDSTILL so the engine lopes, surges and rocks the car, with an occasional bark and flame lick. Like real life it's ECU-made, NOT a cam - and you build it in the tables. Open 'Rotational Idle (BETA)'.", highlight: 'nav:rot_idle', check: function (s) { return s.activePage === 'rot_idle'; } },
          { text: "Arm 'Rotational idle armed'. Nothing happens yet - the chop comes from your TUNE, and a stock tune idles clean. That's the point: you have to build it.", highlight: 'set:rotIdleEnabled', check: function (s) { return !!s.settings.rotIdleEnabled; } },
          { text: "Step 1 - retard the IDLE cells hard. Open 'Ignition Main': the idle corner is the BOTTOM-LEFT of the table - the lowest RPM column, top vacuum rows. Pull those cells to -10 or MORE (very negative). Heavy idle retard IS what makes a real rotational idle. Click Next when done.", highlight: 'nav:ignition', manual: true },
          { text: "Step 2 - richen the same idle corner a touch. Open 'Lambda Target' and bring those idle cells toward ~0.85. The extra fuel fattens the chop and feeds the occasional bark. Click Next when done.", highlight: 'nav:lambda', manual: true },
          { text: "Now STOP the car, feet off everything, and watch 'Rotational Idle (BETA)': the status should read CHOPPING with your strength %. The tach saws and the body rocks - that's your cut pattern fighting the idle governor, exactly like the real thing. No chop? Look at 'Chop strength' - retard the idle cells more.", highlight: 'nav:rot_idle', manual: true },
          { text: "Tune the character: 'Idle target while chopping' sets where it lopes (real rotational tunes idle 1100-1500); 'Chop depth' sets how hard each cut hits - deeper is rowdier but can STALL a weak engine, back it off if it dies. And notice the barks are OCCASIONAL, like real clips: the lope is constant, the pop is the treat - a hotter idle tune (strength well past 55%) barks more. BETA: chop feel varies per engine. Done!", manual: true, last: true }
        ] },
        { id: 'boost', difficulty: 'Advanced', desc: 'Turbo cars: set a boost target, then tune the control gains so boost hits target cleanly.', title: 'Lesson 7 - Dial in boost (turbo)', setup: 'stock', steps: [
          { text: "TURBO cars (NA can read along). The Boost Target table sets HOW MUCH boost; Boost Control decides HOW it's delivered. Open 'Boost Target'.", highlight: 'nav:boost', check: function (s) { return s.activePage === 'boost'; } },
          { text: "Targets are absolute MAP. Zero gauge boost equals the live barometric pressure; near sea level 200 kPa is about 14.3 psi gauge. At altitude the same absolute target represents more gauge boost, so use the kPa/PSI toggle and watch Barometric pressure. Raise the higher-RPM cells only as far as fuel and knock margin allow, then open 'Boost Control'.", highlight: 'nav:boost_control', check: function (s) { return s.activePage === 'boost_control'; } },
          { text: "Set 'Boost mode' to Closed loop so the ECU chases your target (Open loop = base duty only, no correction). Base duty is the open-loop starting point.", highlight: 'set:boostMode', manual: true },
          { text: "Now tune the gains over a few pulls: raise 'P gain' to tighten control and cut the spool spike, but too high makes boost HUNT/oscillate around target; 'I gain' trims out any steady-state error so it settles right on target. A small spike when flooring it from low rpm is normal turbo spool.", highlight: 'set:boostPGain', manual: true },
          { text: "Do pulls watching Boost (MAP) vs target on Runtime Values. Goal: boost rises smoothly and parks on target with no spike or hunting. That's a dialed boost tune. Done!", manual: true, last: true }
        ] },
        { id: 'boostshaping', difficulty: 'Beginner', desc: 'Safely prove Gear, Speed and Gear + Speed target shaping with live ECU math.', title: 'Verify Boost by Gear / Speed', setup: 'boostshape', temporary: true, requiresTurbo: true, gasolineOnly: true, steps: [
          { text: "This is a TEMPORARY gasoline-turbo boost-control lab. Park first. OE ProTune has backed up your complete working tune and M1-M4 package, disabled power adders, enabled protection, neutralized the duty table, and loaded a low flat target 10 kPa (about 1.45 psi) above the unavoidable wastegate floor. Exit or Finish restores the exact pre-lesson calibration; this lesson never saves over a MAP slot.", manual: true },
          { text: "Open Boost Control. The lesson uses Closed Loop because target-pressure trims need a controller that chases the requested target.", highlight: 'nav:boost_control', check: function (s) { return s.activePage === 'boost_control'; } },
          { text: "Confirm the safe lab setup: ECU, Engine protection and Knock control are ON; Boost mode is Closed loop. Base duty and gains are conservative. Do not raise them during this beginner lesson.", highlight: 'set:boostMode', manual: true },
          { text: "Open Boost by Gear / Speed. The live calculation at the bottom is the proof - and the moment you pick a shaping mode in the next step, a target-math line will spell out Base + each active trim = shaped request, exactly how you'd check it on a real ECU. The active/ramped target may lag on an increase, which is normal.", highlight: 'nav:boost_shaping', check: function (s) { return s.activePage === 'boost_shaping'; } },
          { text: "Select Gear. Gear uses the transmission's reported gear; neutral, reverse or an invalid signal safely falls back to G1.", highlight: 'shape:mode:1', check: function (s) { return Number(s.settings.boostShapeMode) === 2; }, verify: 'Select the Gear button.', verified: 'PASS - Gear shaping is selected.' },
          { text: "Use the minus buttons only: set G1 to at least -7 kPa (about -1 psi) and G2 to at least -3 kPa (about -0.5 psi). Two minus clicks on G1 and one on G2 work in either display mode. Leave G3-G8 and every WG duty trim at 0.", highlight: 'shape:gear-target:1', check: boostLessonGearConfigReady, verify: 'Waiting for negative G1/G2 target trims with all WG duty trims at zero.', verified: 'PASS - the gear table can only reduce the safe base request.' },
          { text: "Warm coolant above 60 C and oil above 50 C. In a safe test area, hold G1 or G2 above 1,800 rpm and 50% throttle for about 1.5 seconds. Lift immediately for knock, lean lambda, low fuel pressure, heat, a fault or limp mode. The check proves ECU request math; it does not demand boost below a physical spring floor.", highlight: 'shape:live', check: function (s) { return boostLessonStableProof(s, 'gear'); }, verify: function (s) { return boostLessonProofStatus(s, 'gear'); }, verified: 'PASS - Gear trim was applied to the requested target for 30 safe frames.' },
          { text: "If Boost control says WASTEGATE FLOOR, the feature is still working: a pneumatic gate cannot make less boost than its spring. TURBO MAXED likewise means the turbo cannot reach a higher request. Those are hardware limits, not failed table math. WG duty trim is an advanced feed-forward tool, so this lesson keeps it at zero.", highlight: 'shape:live', manual: true },
          { text: "Now select Speed. This is NOT traction control: it schedules boost from whole-vehicle ground speed and never compares driven-wheel slip. A wheel-speed fallback can be affected by wheelspin.", highlight: 'shape:mode:2', check: function (s) { return Number(s.settings.boostShapeMode) === 3; }, verify: 'Select the Speed button.', verified: 'PASS - ground-speed shaping is selected.' },
          { text: "Build an unequal ramp so interpolation can be proven: set 0 km/h to at least -7 kPa (about -1 psi), 30 km/h to at least -3 kPa (about -0.5 psi), and leave 60/100/160/240 km/h plus every WG duty trim at 0. The ECU must calculate the values between 0 and 30 rather than merely repeat one cell.", highlight: 'shape:speed-target:0', check: boostLessonSpeedConfigReady, verify: 'Waiting for unequal negative 0/30 km/h targets with every later row and WG duty trim at zero.', verified: 'PASS - the speed table contains a safe interpolation ramp.' },
          { text: "Stay between 8 and 25 km/h, above 1,800 rpm and 50% throttle for about 1.5 seconds. Watch Ground speed, Speed target trim, Base target and shaped request. The check calculates the expected point between the 0 and 30 rows; an invalid speed source will not pass.", highlight: 'shape:live', check: function (s) { return boostLessonStableProof(s, 'speed'); }, verify: function (s) { return boostLessonProofStatus(s, 'speed'); }, verified: 'PASS - the measured ground speed selected the correct interpolated target trim for 30 safe frames.' },
          { text: "Select Gear + Speed. In this mode the two pressure trims add together before the request is clamped to barometric pressure and the ECU's maximum.", highlight: 'shape:mode:3', check: function (s) { return Number(s.settings.boostShapeMode) === 4 && boostLessonAllSafeTrims(s, 'boostGear', 8) && boostLessonAllSafeTrims(s, 'boostSpeed', 6); }, verify: 'Select Gear + Speed without changing a WG duty trim.', verified: 'PASS - combined shaping is selected.' },
          { text: "Repeat the safe loaded sample in G1 or G2 between 8 and 25 km/h. The check must match the reported gear to its exact row, interpolate the speed row, add both trims, clamp the request correctly, and leave feed-forward duty unchanged.", highlight: 'shape:live', check: function (s) { return boostLessonStableProof(s, 'combined'); }, verify: function (s) { return boostLessonProofStatus(s, 'combined'); }, verified: 'PASS - Gear + Speed selected both tables correctly, added them, and preserved base feed-forward duty.' },
          { text: "Verified. Gear schedules torque by transmission gear; Speed schedules it by vehicle ground speed; Gear + Speed adds both. None measures tire slip, so none is traction control. STOP the car and release the throttle before Finish restores the complete pre-lesson tune. To use this for real, reproduce conservative trims in your working tune, test from low boost upward, then Save to the intended M1-M4 slot.", manual: true, last: true }
        ] },
        { id: 'turbotune', difficulty: 'Advanced', desc: 'The big one: bring boost, fuel and timing together for a smooth, safe turbo tune.', title: 'Lesson 8 - Build a safe turbo tune', setup: 'stock', steps: [
          { text: "This is the capstone: make a TURBO car run smooth and safe by balancing three things together - BOOST (how much air), FUEL (how rich), and TIMING (spark). Get them in harmony and it makes power without hurting the engine. (Turbo car best; NA can read along.)", manual: true },
          { text: "Start with BOOST. Open 'Boost Target'. The table uses absolute kPa across RPM: zero gauge is live barometric pressure, and near sea level 150 kPa is ~7 psi while 200 kPa is ~14.3 psi. Bigger isn't better on a stock block.", highlight: 'nav:boost', check: function (s) { return s.activePage === 'boost'; } },
          { text: "Select the whole table - click 'All'.", highlight: 'edit:all', check: function (s) { return s.selCount() >= 12; } },
          { text: "Set a MODEST, FLAT target: type 150 in the value box and click 'Fill'. A flat target is easiest to control; 150 kPa is about 7 psi gauge near sea level, and the PSI view shows the live altitude-correct value. (A flat line here is simply your target across RPM.)", highlight: 'edit:fill', check: function (s) { return boostMaxCell() >= 120 && boostMaxCell() <= 170; } },
          { text: "Now HOW that target is delivered. Open 'Boost Control'. The Boost Target table is the GOAL; Boost Control is the wastegate strategy that hits it. Closed loop chases the target; the P gain is how hard it pushes.", highlight: 'nav:boost_control', check: function (s) { return s.activePage === 'boost_control'; } },
          { text: "Do a pull watching Boost (MAP) chase your 150 target. A small spike when you floor it from LOW rpm is normal turbo spool. RAISE 'P gain' to tighten control and shrink that spike; push it too far and boost will HUNT (bounce around target) - back off then. From already-spooled rpm it should sit flat on 150. Aim for the highest P that stays smooth.", highlight: 'set:boostPGain', manual: true },
          { text: "Now FUEL. Open 'Lambda Target'. Under boost you run RICHER for safety - extra fuel cools the chamber and resists knock.", highlight: 'nav:lambda', check: function (s) { return s.activePage === 'lambda'; } },
          { text: "Select the bottom (high-load) rows - that's where you live on boost - and lower them to about 0.80 (− = richer). Lean on boost is what kills turbo engines, so err rich here.", highlight: 'edit:minus', manual: true },
          { text: "Finally TIMING. Open 'Ignition Main'. Boost packs more air in, which raises cylinder pressure and knock risk - so you pull a little timing where you're on boost.", highlight: 'nav:ignition', check: function (s) { return s.activePage === 'ignition'; } },
          { text: "Select the high-load / high-RPM cells and take out 2-3° (click −). Less timing under boost = safer. You can always add a touch back if a pull shows no knock and you want more.", highlight: 'edit:minus', manual: true },
          { text: "Now do a full pull and check it all came together: boost rises smoothly to ~150 (no spike), lambda is rich (~0.80) on boost, and NO knock. Hit 'Help Me' - it should read healthy. That's the whole craft: boost, fuel and timing balanced so it makes power AND lives. Done!", manual: true, last: true }
        ] },
        { id: 'supercharger', difficulty: 'Advanced', desc: 'Blown (supercharged) cars tune differently from turbos: boost is mechanical, so you tune fuel & timing to it, and the Boost table is a safety CEILING.', title: 'Supercharger tuning (blowers)', setup: 'stock', steps: [
          { text: "SUPERCHARGERS work nothing like turbos. A blower is belt-driven off the crank, so its boost is MECHANICAL - set by the pulley - and it rises with RPM. There is NO wastegate, so the ECU cannot 'target' boost like a turbo. Load a supercharged car to follow along: the vanilla Gavril Barstow 'Kingsnake' config is perfect (the muscle car with the blower sticking out of the hood). Any config marked Supercharged / Blown / Drag works too.", manual: true },
          { text: "See it live. Open 'Boost Control'. On a blown car the Status reads 'Supercharger - XXX kPa (mechanical)' - that IS the boost your pulley is making. And notice: the wastegate gains on this page do NOTHING on a supercharger, because there is no wastegate to control.", highlight: 'nav:boost_control', check: function (s) { return s.activePage === 'boost_control'; } },
          { text: "To actually CHANGE a blower's boost you swap the PULLEY (smaller top pulley = faster blower = more boost) - a mechanical/part change, not an ECU setting. Real life is identical: you can't dial supercharger boost from the laptop. So your tuning job here is FUEL and TIMING, tuned TO the boost the pulley makes.", manual: true },
          { text: "FUEL first. Open 'Lambda Target'. Just like a turbo, under boost you run RICHER (~0.80) - the extra fuel cools the charge and resists knock. Blowers make heat, so never run it lean up top.", highlight: 'nav:lambda', check: function (s) { return s.activePage === 'lambda'; } },
          { text: "Select the bottom (high-load) rows - that's where you live on boost - and richen them toward ~0.80 (click - to go richer). Lean on a blown motor is how you melt it, so err rich here.", highlight: 'edit:minus', manual: true },
          { text: "Now TIMING. Open 'Ignition Main'. More boost = more cylinder pressure = more knock risk, so pull a little timing where you're on boost - exactly like a turbo.", highlight: 'nav:ignition', check: function (s) { return s.activePage === 'ignition'; } },
          { text: "Select the high-load / high-RPM cells and take out a couple of degrees (click -). Do a pull and watch Knock: clean = you can add a touch back for power; knocking = pull more. Dialing fuel + timing to the mechanical boost is where blown power comes from.", highlight: 'edit:minus', manual: true },
          { text: "Last, the Boost table. On a supercharger it is NOT a target - it's a CEILING. Open 'Boost Target'. By default it sits at your blower's natural boost, so it does nothing and you keep full power.", highlight: 'nav:boost', check: function (s) { return s.activePage === 'boost'; } },
          { text: "Want to CAP boost (engine safety, or a class limit)? Lower the top rows BELOW what the blower makes. Now on a pull, if boost climbs past your ceiling the ECU BLEEDS power to hold it under (Boost Control will read 'Over ceiling - bleeding power'). The boost stays mechanical, but the ECU limits power over your cap. Set it back to natural boost for full send.", highlight: 'edit:minus', manual: true },
          { text: "That's the whole craft on a blower: boost is MECHANICAL (pulley) so you can't target it - you tune FUEL and TIMING to the boost it makes, and the Boost table is a safety CEILING you can lower to cap it. Do a full pull, hit 'Help Me', and it should read healthy. Done!", manual: true, last: true }
        ] },
        { id: 'fuel', difficulty: 'Intermediate', desc: 'Size your injectors and see why E85 needs more fuel - the tables aim the mixture, the fuel system delivers it.', title: 'Fueling: injectors & E85', setup: 'fuel', steps: [
          { text: "Your fuel and lambda tables set the AFR TARGET - but the injectors have to physically deliver it. Open 'Fuel Setup' and find the 'Injector duty' readout.", highlight: 'nav:fuel_setup', check: function (s) { return s.activePage === 'fuel_setup'; } },
          { text: "I've fitted small injectors on purpose. Do a full-throttle pull and watch 'Injector duty' climb. Over ~90% you're running OUT of injector - it physically can't flow enough fuel, so the engine leans out up top.", manual: true },
          { text: "Fit bigger injectors: raise 'Injector flow' until the duty has headroom (aim under ~90% at WOT).", highlight: 'set:injectorFlow', check: function (s) { return (s.settings.injectorFlow || 0) >= 550; } },
          { text: "Duty dropped - now the injectors keep up and it holds your target. Do another pull to confirm it's not leaning out up top.", manual: true },
          { text: "Now switch fuel: open the 'Fuel type' selector and pick E85. It resists knock (so you can run more timing/boost) and makes more power - but it needs ~50% more fuel.", highlight: 'set:fuelType', check: function (s) { return (s.settings.fuelType || 1) >= 2; } },
          { text: "Watch 'Injector duty' JUMP on E85 - you may be out of injector again. Size them so duty stays under ~90% at WOT. That's fuel-system sizing: the tables aim the mixture, the injectors and fuel decide if you can actually hit it. Done!", manual: true, last: true }
        ] },
        { id: 'egt', difficulty: 'Beginner', desc: 'Read exhaust gas temperature (EGT) - the heat gauge - and learn what runs an engine hot (lean, pulled timing) and how to cool it.', title: 'Reading EGT (engine heat)', setup: 'stock', steps: [
          { text: "EGT is Exhaust Gas Temperature - how HOT the engine runs. It's a key safety gauge: a LEAN mixture or PULLED-BACK timing runs the exhaust hot enough to melt pistons and valves; running RICHER (and a touch more timing) cools it. Let's watch it move. (Works on any engine.)", manual: true },
          { text: "Open 'Runtime Values' and find the EGT (°C) readout in the Sensors panel on the right.", highlight: 'nav:runtime', check: function (s) { return s.activePage === 'runtime'; } },
          { text: "Do a full-throttle pull and watch EGT climb with load. On petrol, ~900-950°C is the danger zone (meltdown above ~1050). Note where yours sits at WOT.", manual: true },
          { text: "Now make it run HOT on purpose. Open 'Lambda Target'.", highlight: 'nav:lambda', check: function (s) { return s.activePage === 'lambda'; } },
          { text: "Select the high-load rows and LEAN them out (raise lambda toward ~1.0 with the + button = less fuel), then do a pull. Watch EGT climb noticeably HOTTER on the gauge - lean = hot.", highlight: 'edit:plus', manual: true },
          { text: "See it? Now COOL it back: richen those rows (lower lambda toward ~0.82 with the - button) and pull again. EGT drops - rich = cool. That's the main lever.", highlight: 'edit:minus', manual: true },
          { text: "So when EGT climbs too high: ADD FUEL (richen) and/or add a little timing, and back off boost if you run it. Open 'Help Me' - it warns you when EGT is high. Keep it under ~900°C sustained and the engine lives. Done!", highlight: 'nav:diagnose', manual: true, last: true }
        ] },
        { id: 'logread', difficulty: 'Intermediate', desc: 'Record a pull and read it back - stacked lanes, the cursor parameter list, the zoom navigator and event markers - to find exactly what happened.', title: 'Reading a datalog', setup: 'stock', steps: [
          { text: "A datalog records EVERYTHING across a whole pull - RPM, boost, throttle, lambda, knock, EGT - so you can scrub back and see exactly where something happened. Real tuners live in the log. Let's record one and read it. (Logging is on by default.)", manual: true },
          { text: "Do a full-throttle pull. For something interesting to find, lean it out a touch or spray nitrous during the pull so it trips an event.", manual: true },
          { text: "Open 'Log Analysis' to see the live stacked lanes.", highlight: 'nav:logging', check: function (s) { return s.activePage === 'logging'; } },
          { text: "Each lane is its own channel with its own scale - RPM, MAP/boost, throttle, Lambda (target dashed + actual), Knock, EGT, Temps - so you read them all at once without them overlapping. The buttons up top show/hide lanes.", manual: true },
          { text: "Now open 'Log Navigator' to scrub through it.", highlight: 'nav:log_navigator', check: function (s) { return s.activePage === 'log_navigator'; } },
          { text: "CLICK AND DRAG on the lanes to move the cursor - the Parameter List below shows every channel's value at that exact instant. And the bottom 'Navigator' strip is the whole log: drag the window to pan, drag its edges to zoom into the part you care about.", manual: true },
          { text: "Look for the coloured EVENT MARKERS - KNOCK, LEAN, EGT, OVERBOOST etc. drop a labelled line right where they happened, plus a tick on the Navigator strip. That's how you find trouble instantly: spot a tick, zoom to it, read what went wrong, then go fix that cell. Hit 'Export CSV' for the whole log in Excel. Done!", manual: true, last: true }
        ] },
        { id: 'nitrous', difficulty: 'Advanced', desc: 'Set up a nitrous shot SAFELY - it makes big power but needs extra fuel and pulled timing or it knocks and melts.', title: 'Spraying nitrous safely', setup: 'stock', steps: [
          { text: "Nitrous (NOS) is a power SHOT - a burst of extra power on demand. But it spikes cylinder pressure and heat, so it NEEDS extra fuel and pulled timing or it knocks and melts the engine. Let's set one up the safe way - on a car with a REAL N2O kit: like a real ECU, the app only controls hardware the car actually has.", manual: true },
          { text: "Open the 'Nitrous' page (under Idle / MotorSport).", highlight: 'nav:nitrous', check: function (s) { return s.activePage === 'nitrous'; } },
          { text: "First, the HARDWARE: this car needs a nitrous kit fitted. Open the vehicle Parts menu (Ctrl+W), find the Nitrous Oxide System (usually under the engine or intake), install it, and come back. If this car has no N2O option in its parts tree, switch to one that does. (Auto-advances once a kit is detected.)", check: function (s) { return s.base && s.base.hasNitrous === true; } },
          { text: "Turn the ECU side on - click 'Nitrous ECU enrichment'. The KIT makes the power; this switch makes the ECU feed it fuel and timing.", highlight: 'set:nitrousEnabled', check: function (s) { return s.settings.nitrousEnabled === true; } },
          { text: "Check 'Kit rating' on this page - that's the shot size, and it comes from the PART you installed (its jets and bottle), exactly like real life. Want a bigger or smaller shot? Change the kit in the Parts menu, not in the ECU.", manual: true },
          { text: "Now the safety: raise 'Extra fuel' to ~20%. Extra fuel cools the charge and stops the shot going lean - this is what keeps it alive.", highlight: 'set:nitrousFuel', check: function (s) { return (s.settings.nitrousFuel || 0) >= 18; } },
          { text: "And pull some timing: set 'Timing pull' to ~6-8°. Nitrous raises cylinder pressure, so you back the spark off to avoid detonation.", highlight: 'set:nitrousRetard', check: function (s) { return (s.settings.nitrousRetard || 0) >= 5; } },
          { text: "Now spray: ARM the bottle with the car's own N2O control (check 'Arm switch (car)' reads Yes), then hold FULL throttle in gear above your enrichment RPM. Status reads 'SPRAYING' and the power jump is the real kit's torque - with Knock and EGT in check because the ECU fed it fuel and pulled timing. (Auto-advances when it sprays.)", check: function (s) { return s.t && s.t.nitrousActive === true; } },
          { text: "That made power AND stayed safe. To see WHY the prep matters: set 'Extra fuel' and 'Timing pull' to 0 and spray again - watch Knock and EGT spike (don't hold it). Then put them back. That's nitrous: free power if you feed it and pull timing, a grenade if you don't. Done!", manual: true, last: true }
        ] },
        { id: 'dieselbasics', difficulty: 'Advanced', desc: 'Diesel tunes nothing like petrol: no spark or lambda - you add FUEL for power up to a SMOKE LIMIT, watching AFR, smoke and EGT.', title: 'Diesel basics: fuel, smoke & EGT', setup: 'diesel', steps: [
          { text: "Diesel tunes COMPLETELY differently from petrol. There's no spark and no lambda target - a diesel always runs lean, and you tune FUEL QUANTITY to a SMOKE LIMIT while watching EGT (heat). More fuel = more power, but only the fuel the air can burn makes power - the rest is black smoke and heat. (You need to be in a DIESEL vehicle - the app auto-detects it.)", manual: true },
          { text: "Open the 'Diesel Tuning' page (under Fuel).", highlight: 'nav:diesel', check: function (s) { return s.activePage === 'diesel'; } },
          { text: "Check 'Mode' at the bottom of the page - it should read Running/Monitoring, NOT 'Off'. If it says Off you're not in a diesel: load a diesel vehicle (e.g. a Gavril D-Series or Roamer diesel) and come back. (Auto-advances once a diesel is detected.)", check: function (s) { return s.t && s.t.isDiesel === true; } },
          { text: "Now open 'Runtime Gauges'. Notice there's no Lambda gauge - on a diesel you get AFR, SMOKE and EGT instead. That's the diesel toolkit.", highlight: 'nav:gauges', check: function (s) { return s.activePage === 'gauges'; } },
          // V3.0.8 (A1): soot forms against combustion chemistry (~17:1), NOT against the slider. The
          // old wording taught the model's artifact - that moving the calibration screw moved where
          // smoke appears - which is exactly the bug A1 fixed.
          { text: "Do a full-throttle pull and watch them. AFR is air-to-fuel: HIGH = lean and clean, and it DROPS as you add fuel/throttle. Below about 17:1 there is more fuel than the air can burn, so Smoke climbs and EGT heats up - that number is combustion chemistry, not a setting. Your 'Smoke limit' slider is something different: it is where the ECU chooses to STOP delivering fuel. A clean diesel keeps AFR above ~17.", manual: true },
          { text: "Now turn the fuel UP on purpose. Open 'Diesel Tuning' again.", highlight: 'nav:diesel', check: function (s) { return s.activePage === 'diesel'; } },
          { text: "Raise 'Max fuel' well up - to about 170%. This is the main power lever on a diesel: it simply commands more fuel.", highlight: 'set:dieselMaxFuel', check: function (s) { return (s.settings.dieselMaxFuel || 100) >= 150; } },
          { text: "Pull again and hold it. Watch SMOKE climb and EGT spike - because the air (no/low boost yet) can't burn all that fuel. That's 'rolling coal': lots of smoke, not much extra power, and dangerous heat. (Auto-advances when it smokes.)", check: function (s) { return s.t && (s.t.smoke || 0) > 30; } },
          { text: "That's the core of diesel: fuel makes power only up to what the AIR can burn - past that it's just smoke and EGT. Next lesson: add boost + rail pressure so you can fuel that much and actually make the power CLEANLY. Open 'Help Me' anytime - it warns you when you're smoking or running hot. Done!", highlight: 'nav:diagnose', manual: true, last: true }
        ] },
        { id: 'dieselpower', difficulty: 'Advanced', desc: 'Make REAL diesel power: add boost (air) and rail pressure to burn more fuel without smoke, then fine-tune injection timing.', title: 'Diesel power: boost & rail pressure', setup: 'diesel', steps: [
          { text: "Last lesson, dumping fuel just made smoke once the air ran out. To make REAL power you give the engine more AIR and atomise the fuel better - that's BOOST and RAIL PRESSURE. Then you can fuel more WITHOUT smoking. (Diesel vehicle required.)", manual: true },
          { text: "Open 'Diesel Tuning'. With Max fuel still up, do a pull and confirm it's SMOKING (Smoke high, AFR below the limit, EGT hot). That's too much fuel for the air it has.", highlight: 'nav:diesel', check: function (s) { return s.activePage === 'diesel'; } },
          { text: "First, RAIL PRESSURE. Raise it toward ~1900 bar. Higher rail pressure atomises the fuel into a finer mist so more of it burns cleanly - it raises your smoke-free headroom.", highlight: 'set:dieselRailPressure', check: function (s) { return (s.settings.dieselRailPressure || 1200) >= 1700; } },
          { text: "Now the big one - BOOST (air). Open 'Boost Target'. More boost = more air = it can burn more fuel cleanly. Most quick diesels run big or compound turbos for exactly this reason.", highlight: 'nav:boost', check: function (s) { return s.activePage === 'boost'; } },
          { text: "Select the whole table (click 'All'), then raise the target with the + button. On a diesel, boost is your main power ENABLER - it's what lets the extra fuel make power instead of smoke.", highlight: 'edit:all', manual: true },
          { text: "Pull again and watch the gauges: with boost up, AFR climbs back ABOVE the smoke limit even with the fuel high - SMOKE drops and EGT cools. Check the Dyno - the power is REAL this time. Air + fuel together = clean power. (Auto-advances once you hold it clean UNDER LOAD - above 1,800 rpm and 50% throttle for about a second.)", // V3.0.8 (A1): 17 is the soot point and is fixed; the slider is the ECU's clamp, not chemistry.
          // Gating on the slider let limit 12 pass while genuinely smoking and made limit 24 unpassable.
          check: function (s) { return dieselLoadedProof(s, function (t) { return (Number(t.smoke) || 0) < 12 && Number(t.afr) >= 17 && (Number(t.mapKpa) || 0) > (Number(t.baroKpa) || 101.3) + 30; }); }, verify: function (s) { return dieselLoadedProofStatus(s, 'AFR at or above 17, Smoke under 12, and real boost on the gauge'); }, verified: 'PASS - with boost on board, AFR climbed back above the soot point and the smoke went away under load.' },
          { text: "Last touch: injection TIMING. On a diesel the 'Ignition Main' table is injection timing (degrees). Open it - a little more advance can add power and lower EGT; too much makes combustion harsh and noisy. Small changes only.", highlight: 'nav:ignition', check: function (s) { return s.activePage === 'ignition'; } },
          { text: "That's diesel power: FUEL for power, BOOST + RAIL PRESSURE to burn it cleanly, INJECTION TIMING to fine-tune - all watched on AFR, Smoke and EGT (never lambda). Keep AFR above your smoke limit and EGT in check, and you'll make big, clean power. 'Help Me' guides you if it smokes or runs hot. Done!", highlight: 'nav:diagnose', manual: true, last: true }
        ] },
        { id: 'dieselhardware', difficulty: 'Advanced', desc: 'The three things that decide how much fuel a diesel can actually deliver and burn: nozzle size, the smoke limiter, and what a VGT really does.', title: 'Diesel hardware: nozzles, limiter & VGT', setup: 'diesel', steps: [
          { text: "You know how to fuel a diesel. Now the HARDWARE that decides whether that fuel can be delivered at all. Three things: the INJECTOR NOZZLE, the ECU's SMOKE LIMITER, and the TURBO. Open 'Diesel Tuning'. (Diesel vehicle required.)", highlight: 'nav:diesel', check: function (s) { return s.activePage === 'diesel'; } },
          { text: "Turn ON 'Nozzle model (realistic injection)'. Now the injector has a real flow rate and can RUN OUT. Watch the new 'Nozzle duty' readout - stock sits around 70-80% at full fuelling, which is the margin a factory nozzle really has.", highlight: 'set:dieselNozzleEnabled', check: function (s) { return s.settings.dieselNozzleEnabled === true; } },
          { text: "Now wind 'Max fuel' up past ~140% and pull. Nozzle duty goes over 100% and the power STOPS climbing - you are commanding fuel the injector physically cannot flow. This is why big builds fit bigger nozzles; no amount of tuning gets around it.", highlight: 'set:dieselMaxFuel', check: function (s) { return (s.settings.dieselMaxFuel || 100) >= 140; } },
          { text: "Two ways out, both real. Raise 'Nozzle flow' for bigger injectors, OR raise rail pressure - flow through the nozzle rises with the SQUARE ROOT of pressure, so 1200 to 2200 bar is about 1.35x flow, not 1.8x. Try one and watch Nozzle duty fall.", highlight: 'set:dieselNozzleFlow', check: function (s) { return (s.settings.dieselNozzleFlow || 0) > 900 || (s.settings.dieselRailPressure || 1200) >= 1800; } },
          { text: "Now the ECU side. Turn ON 'Smoke limiter'. This is what stops a stock truck rolling coal: the ECU clamps commanded fuel against the air it can measure, BEFORE injecting. Watch 'Limiter fuel cut' - it will start cutting the fuel that used to make smoke.", highlight: 'set:dieselSmokeLimiter', check: function (s) { return s.settings.dieselSmokeLimiter === true; } },
          // V3.0.8 (B1): "power is DOWN" is only true where the limiter is actually cutting. Below
          // that point the cut reads 0% and power is unchanged, so the step now says WHEN to look.
          { text: "Pull again, and go to full throttle - that is where the limiter bites. Smoke is near zero and AFR pins exactly at your smoke limit, but power is DOWN, because the ECU refused the fuel. (Watch 'Limiter fuel cut': at part throttle it reads 0% and costs you nothing - the ECU only steps in when you ask for more fuel than the air can burn.) Here is the lesson: with a limiter fitted, the only way to more power is MORE AIR. Add boost and watch the fuel cut fall away. (Hold it above 1,800 rpm and 50% throttle - this one only counts under load.)", check: function (s) { var lt = s.t || {}; if (lt.isDiesel === true && (Number(lt.dieselLimiterCut) || 0) > 5) { s.tutorial.dieselLimiterSeen = true; } return dieselLoadedProof(s, function (t, sc) { return sc.settings.dieselSmokeLimiter === true && sc.tutorial.dieselLimiterSeen === true; }); }, verify: function (s) { return dieselLoadedProofStatus(s, (s.tutorial && s.tutorial.dieselLimiterSeen) ? 'limiter cut seen - now just hold the loaded sample' : 'the limiter has to actually REFUSE fuel: bog it in a high gear so boost is low while the pedal is down, until Limiter cut climbs past 5%'); }, verified: 'PASS - the smoke limiter was seen refusing fuel under load, holding AFR at your smoke limit.' },
          { text: "Finally, the TURBO. A VGT ('variable geometry') has moveable vanes in the turbine housing. Closing them speeds the exhaust gas up so it spools hard and early; opening them lets the engine breathe at high rpm. That is why a modern diesel has huge low-end torque with no lag.", manual: true },
          { text: "In a real VGT calibration, a vane controller may chase a boost target, but OE ProTune/BeamNG does not expose or simulate a vane-position table. Here, Boost Target is only the desired manifold pressure handled by whatever boost hardware the vehicle actually provides. Open it to shape that pressure target against rpm - do not read the table as a vane schedule.", highlight: 'nav:boost', check: function (s) { return s.activePage === 'boost'; } },
          { text: "Real closed vanes also raise exhaust back-pressure and can increase EGT or choke high-rpm flow, which is why real VGT calibration opens them as airflow rises. Those vane-position and back-pressure effects are not modeled here; use OE's EGT as its combustion/anti-lag estimate, not as proof of a simulated vane position.", highlight: 'nav:runtime', manual: true },
          { text: "That is diesel hardware: the NOZZLE sets what can be delivered, the LIMITER sets what the ECU will allow, and the TURBO sets how much air you have to burn it with. Diagnose which one is holding you back - Nozzle duty, Limiter cut and Smoke each point at a different culprit. Done!", highlight: 'nav:diagnose', manual: true, last: true }
        ] },
        { id: 'rollcoal', difficulty: 'Advanced', desc: 'Make it blow serious black smoke on purpose - then look at the dyno and find out what it actually cost you.', title: 'Rolling coal (and what it costs)', setup: 'diesel', steps: [
          { text: "Let's make some smoke. Black diesel smoke is unburnt fuel: you inject more than the air in the cylinder can burn, and the leftover leaves as soot. So the recipe is simple - LOTS of fuel, NOT much air. First, a clean baseline. Open the Dyno page and do a pull so we have a number to compare against.", highlight: 'nav:dyno', check: function (s) { return s.activePage === 'dyno'; } },
          { text: "Note that peak power - that is the honest, clean number. Now we ruin it. Open 'Diesel Tuning'.", highlight: 'nav:diesel', check: function (s) { return s.activePage === 'diesel'; } },
          { text: "If 'Smoke limiter' is ON, turn it OFF. That is the ECU that clamps fuel to the air available - it exists specifically to stop what we are about to do. A stock truck cannot roll coal because of this.", highlight: 'set:dieselSmokeLimiter', check: function (s) { return s.settings.dieselSmokeLimiter !== true; } },
          // V3.0.8 (A1): this step used to instruct "raise Smoke limit toward 24 - coal engaged", which
          // only worked because the slider was wrongly wired into soot FORMATION. Now that soot forms
          // against the fixed ~17 chemistry point, raising it produces no smoke at all - and with the
          // limiter switched off one step earlier it does nothing whatsoever. Kept as a teaching step
          // because that distinction is worth making explicitly, and made manual since there is no
          // longer a setting to prove.
          { text: "Leave 'Smoke limit (target AFR)' alone - and notice why. That slider is the ECU's DECISION about where to stop delivering fuel; it is not where soot forms. Soot forms at about 17:1 because that is combustion chemistry, and no calibration screw moves it. With the limiter switched off a moment ago, this slider now does precisely nothing. Real coal comes from fuel the air cannot burn - so that is what we are about to command.", highlight: 'set:dieselSmokeLimit', manual: true },
          { text: "And now the fuel. Wind 'Max fuel' right up past 220%. You are commanding far more diesel than the air can burn.", highlight: 'set:dieselMaxFuel', check: function (s) { return (s.settings.dieselMaxFuel || 100) >= 220; } },
          { text: "Now go drive it. Boot it from low RPM in a high gear - that is where boost is lowest, so the air shortage is worst and the smoke is thickest. Watch the Smoke readout climb and look behind you.", check: function (s) { return s.t && (s.t.smoke || 0) > 45; } },
          { text: "That is rolling coal. Every bit of that black cloud is diesel you paid for, leaving the pipe unburnt. Now the part most people skip - go back to the Dyno and pull again.", highlight: 'nav:dyno', check: function (s) { return s.activePage === 'dyno'; } },
          { text: "Compare it to your clean baseline. It went UP - probably a fair bit. That surprises people, so let's be straight about it: coal is not powerless. The fuel that DOES find air still burns and still makes power. That is exactly why rolling-coal trucks feel fast, and why 'more fuel' is such a tempting shortcut.", manual: true },
          { text: "But look at what it cost. Check EGT on Runtime Values - all that late-burning fuel dumps its heat into the exhaust instead of pushing the piston, and that is what melts turbos and pistons. You are also burning far more diesel for each extra horsepower than a clean tune would.", highlight: 'nav:runtime', manual: true },
          { text: "Now the test that settles it. Leave the fuel EXACTLY where it is - do not touch Max fuel - and open 'Boost Target'. We are going to give that same fuel the air it was missing.", highlight: 'nav:boost', check: function (s) { return s.activePage === 'boost'; } },
          { text: "Select the whole table ('All') and raise the boost target hard. Same fuel, more air. Then pull again and watch both numbers.", highlight: 'edit:all', manual: true },
          { text: "MORE power than the smoky pull AND the smoke is gone. Same fuel - the only thing that changed is that it now has enough air to actually burn. That black cloud was never power; it was fuel you failed to burn, and the moment you gave it air it turned into horsepower instead of soot. (Prove it under load. Steady part throttle - about half pedal - above 1,800 rpm works best here: fuel follows the pedal but boost does not, so burying it makes the mixture worse, not better.)", // V3.0.8 (A1): 14.5 is 0.85 x the fixed soot point, not 0.85 x whatever the slider happens to read.
          check: function (s) { return dieselLoadedProof(s, function (t, sc) { return sc.settings.dieselSmokeLimiter !== true && (Number(t.smoke) || 0) < 20 && Number(t.afr) > 14.5 && (Number(t.mapKpa) || 0) > (Number(t.baroKpa) || 101.3) + 25; }); }, verify: function (s) { return dieselLoadedProofStatus(s, (s.settings && s.settings.dieselSmokeLimiter === true) ? 'turn the Smoke limiter back OFF - with it on, Smoke reads zero for free and proves nothing' : 'Smoke well down from the 45+ you just made, AFR climbing back toward the ~17 soot point, and real boost on the gauge', 'Hold steady part throttle - about half pedal - in a safe test area.'); }, verified: 'PASS - same fuel, more air: the smoke fell away and AFR climbed, with the limiter still off.' },
          { text: "So: smoke is not power, it is waste - and a real tuner reads heavy smoke as 'this engine needs more air', not 'this engine is making power'. You can still make it coal on demand for fun. Just know what it is costing you in fuel and EGT. Done!", highlight: 'nav:diagnose', manual: true, last: true }
        ] },
        { id: 'vetable', difficulty: 'Advanced', desc: 'Work in a temporary copy, learn what VE represents, and use fresh Mixture Map evidence to make measured lambda track target safely.', title: 'Speed density: tune a VE table', setup: null, temporary: true, steps: [
          { text: "This lesson creates a temporary working copy and restores your calibration and Mixture Map evidence when you exit. Before driving, confirm the starting tune is safe, the engine is warm, fuel type and stoich AFR are correct, injector flow and deadtime are configured, MAP and wideband readings are trustworthy, fuel pressure is stable, and injector duty has headroom.", manual: true },
          { text: "Open 'Fuel Setup' and switch Fuel equation to MODELLED. In this mode the VE Table is the base fuel calculation.", highlight: 'set:fuelEquationMode', check: function (s) { return Number(s.settings.fuelEquationMode) === 2; } },
          { text: "Open 'Lambda Target'. This table says what mixture you want; VE models how much air the engine actually traps so the ECU can deliver that mixture. VE above 100% can be valid with strong cylinder filling or boost - it is not throttle percentage or a hard ceiling.", highlight: 'nav:lambda', check: function (s) { return s.activePage === 'lambda'; } },
          { text: "Open the VE Table. The live point blends four neighboring cells, so a smooth surface matters. Choose a reachable row nearest to, but not above, live barometric pressure across the midrange, then press '+2%' three times. Raising that region about 6% creates a small, safer rich error for this exercise.", highlight: 'nav:ve', manual: true },
          { text: "Open 'Closed Loop Lambda', set CLL mode to Off, and confirm CLL status also reads Off. This verifies the base-table change without closed-loop correction hiding it.", highlight: 'set:cllMode', check: function (s) { return Number(s.settings.cllMode) === 1 && s.t && s.t.closedLoopActive === false && String(s.t.closedLoopStatus || '').toLowerCase() === 'off'; } },
          { text: "Drive steadily through the changed region at moderate or high load. Lambda Actual should move slightly below Target, which is richer. Lift immediately for knock, low fuel pressure, excessive injector duty, unsafe temperature, or anything else that is not healthy.", check: function (s) { return s.t && Number(s.t.rpm) > 1500 && Number(s.t.tps) > 15 && Number(s.t.lambdaActual) < Number(s.t.lambdaTarget) - 0.025; } },
          { text: "Open Mixture Map and leave Learn on Auto. Gather steady data first; make clean pulls only when the car is safe and you have room. Accepted counts useful samples, Skipped is normal ineligible data, and Rejected means invalid or outlier lambda. The live state names the exact blocker when it is not learning.", highlight: 'nav:mixture_map', check: function (s) { return s.activePage === 'mixture_map'; } },
          { text: "Compare Correction %, VE delta, and Suggested VE - three views of the same recommendation. Use Apply 50% - re-drive only when cells are Ready. It changes only those cells, consumes their evidence, and requires fresh driving before another application.", manual: true },
          { text: "Revisit the region and confirm measured lambda moved toward target. Repeat with partial changes and fresh evidence, then smooth artificial steps without erasing the engine's real shape. Exiting now restores your pre-lesson calibration and Mixture Map evidence.", manual: true, last: true }
        ] },
        { id: 'mixturelearn', difficulty: 'Intermediate', desc: 'Learn what Mixture Map accepts, rejects, and applies to Fuel Main or VE, then verify a controlled correction.', title: 'Using Mixture Map safely', setup: null, temporary: true, steps: [
          { text: "This lesson uses a temporary working copy and restores your calibration and learning data when you exit. Start with a safe tune, warm engine, correct fuel and stoich AFR, configured injectors, trustworthy MAP and wideband readings, stable fuel pressure, and enough injector-duty headroom.", manual: true },
          { text: "Choose the base strategy first. In Traditional mode Mixture Map applies to Fuel Main; in Modelled mode it applies to the VE Table. Open the matching table, then check the 'Applying to' badge so you always know which base map will change.", highlight: 'nav:fuel_setup', check: function (s) { var target = Number((s.settings || {}).fuelEquationMode) === 2 ? 've' : 'fuel'; return s.activePage === target; } },
          { text: "On that table, choose a reachable moderate/high-load midrange region and press '+2%' three times to raise it about 6%. This adds a small, controlled rich error that Mixture Map can measure.", highlight: 'edit:scaleplus', manual: true },
          { text: "Open 'Closed Loop Lambda', set CLL to Off, and verify its status also says Off. For this exercise we want the base-table effect without automatic correction hiding it.", highlight: 'set:cllMode', check: function (s) { return Number(s.settings.cllMode) === 1 && s.t && s.t.closedLoopActive === false && String(s.t.closedLoopStatus || '').toLowerCase() === 'off'; } },
          { text: "Drive steadily through the changed area. Lambda Actual should sit slightly below Target. Lift for knock, low fuel pressure, high injector duty, dangerous temperature, or any sign that the tune is not safe.", check: function (s) { return s.t && Number(s.t.rpm) > 1500 && Number(s.t.tps) > 15 && Number(s.t.lambdaActual) < Number(s.t.lambdaTarget) - 0.025; } },
          { text: "Open Mixture Map and leave Learn on Auto. Hold RPM, MAP, and TPS steady first for clean evidence. Use WOT pulls only when safe, starting before the region you need and avoiding shifts, limiter, and tip-in enrichment.", highlight: 'nav:mixture_map', check: function (s) { return s.activePage === 'mixture_map'; } },
          { text: "Watch the states as you drive. Accepted counts useful samples; Skipped is normal when a frame is not eligible; Rejected is reserved for invalid or outlier lambda. Learning cells show provisional evidence. Evidence expires after 30 minutes, and pull-only cells need two clean agreeing passes before becoming Ready.", manual: true },
          { text: "Review the three views and press Apply 50% - re-drive only when cells are Ready. The button applies half the correction to only those Ready cells in the map named by the badge, consumes that evidence, and requires another drive before applying again.", manual: true },
          { text: "Revisit the same area and confirm lambda moved toward target. Repeat with fresh evidence and smooth only artificial steps, not real surface changes. Exiting now restores your original calibration and learning data.", manual: true, last: true }
        ] },
        { id: 'wgspring', difficulty: 'Advanced', desc: 'The boost hardware truth: the game slider is your wastegate spring, and the ECU can only add boost above it.', title: 'The wastegate spring', setup: 'stock', steps: [
          { text: "The single most misunderstood thing in boost control: your WASTEGATE SPRING sets the minimum boost, and the ECU can only ADD from there - exactly like a real electronic boost controller. In this game, the spring is the boost slider in the game's own Tuning tab (Home key). Turbo car required.", manual: true },
          { text: "Open 'Boost Control'. If you've never touched the Boost Target table on this car, the status reads 'Stock boost' - a base ECU leaves your wastegate alone. That's realism, not laziness.", highlight: 'nav:boost_control', check: function (s) { return s.activePage === 'boost_control'; } },
          { text: "Now ask for the impossible: open Boost Target, select the whole table and set it LOW - around 120 kPa (~3 psi), below what your spring makes.", highlight: 'nav:boost', check: function (s) { return s.activePage === 'boost'; } },
          { text: "Do a pull and watch the Boost Control status: WASTEGATE FLOOR, with your actual spring pressure named. The gate is pegged wide open and boost physically cannot go lower - no duty, gain or margin setting can fix hardware. (Auto-advances when the floor shows.)", check: function (s) { return s.t && String(s.t.boostControlStatus || '').indexOf('FLOOR') >= 0; } },
          { text: "Two real fixes: fit a softer spring (lower the boost slider in the game's Tuning tab, then target up with the ECU), or fit a modern ELECTRIC gate: switch 'Wastegate type' to Electric - a motor-driven gate has full authority and can hold boost below spring. On manual cars, use the fixed rated clutch packages on Transmission Control if added torque exceeds the fitted clutch; never raise native boost just to manipulate clutch sizing.", highlight: 'set:wgType', check: function (s) { return Number(s.settings.wgType) === 2; } },
          { text: "Pull again with the electric gate - boost now drops toward your low target, and if the turbo still makes more than you asked for even fully bypassed, the status says GATE WIDE OPEN honestly. Hardware always gets the last word; the ECU just refuses to lie about it.", manual: true },
          { text: "The takeaway: set the game's boost slider to a believable hardware spring, pick the real gate type your build represents, and let the ECU raise boost from there. TURBO MAXED and WASTEGATE FLOOR show which hardware boundary you hit. Manual clutch capacity is separate and uses explicit fixed Nm packages, so no hidden boost-slider workaround is needed. Done!", manual: true, last: true }
        ] },
        { id: 'wgdutytable', difficulty: 'Advanced', desc: 'Big turbos hunt at high RPM when held below their potential. Calm it with the Wastegate Duty table, like a real EBC.', title: 'Calm a hunting turbo', setup: 'stock', steps: [
          { text: "Big turbo + a target well below what it can make = boost that hunts and wobbles at high RPM. It's real physics: up top the turbo has huge exhaust energy, so tiny wastegate movements swing boost hard. Real boost controllers fix this with a DUTY TABLE - and now, so do you. Best experienced on a big-turbo build.", manual: true },
          { text: "First feel the problem: Boost Control in Closed loop, Boost Target set moderate (well under the 'Turbo max' readout), then do a high-RPM pull watching MAP vs target on Runtime - on a big turbo you'll see it overshoot, dip, overshoot.", highlight: 'nav:boost_control', check: function (s) { return s.activePage === 'boost_control'; } },
          { text: "Open the 'Wastegate Duty' table (WG Duty tab on any table page). Every cell SHIFTS your Base duty slider at that RPM and target: 50 = neutral, below = less duty. The closed loop still lands on your target - this shapes how it gets there.", highlight: 'nav:wgduty', check: function (s) { return s.activePage === 'wgduty'; } },
          { text: "Sculpt where it hunts: select the top-RPM columns and take them DOWN a few points (47-48 to start). Less feedforward duty up top = the integrator stops fighting the exhaust energy spike.", highlight: 'edit:minus', manual: true },
          { text: "Pull again. The high-RPM wobble should calm noticeably - trim the columns a point at a time until boost holds flat to redline. If the SPOOL feels lazy down low, you can also RAISE the low-RPM columns a couple points for a harder hit.", manual: true },
          { text: "That's a real EBC duty table workflow: the target table says WHERE, the duty table says HOW. Pros sculpt duty by gear and RPM on every serious drag build - now you know why. Done!", manual: true, last: true }
        ] },
        { id: 'launchtables', difficulty: 'Advanced', desc: 'Tune the 2-step like a pro: launch RPM, launch fuel, launch retard - the three levers of real launch control.', title: 'Launch control, all three levers', setup: 'stock', steps: [
          { text: "Real launch control has three calibration levers here: WHERE the revs hold (launch limit), HOW MUCH FUEL during the hold (launch fuel), and HOW MUCH TIMING you pull (launch retard). Retard + fuel drives OE's bangs, flames and heat model. Supported native turbo devices may also retain boost, but that is vehicle-dependent.", manual: true },
          { text: "Open Limiters and enable '2-step launch', with the launch limit around 4,000-5,000 rpm.", highlight: 'set:launchEnabled', check: function (s) { return s.settings.launchEnabled === true; } },
          { text: "Baseline feel: stop the car, hold the brake, floor it. The revs hold on the line - that's the 2-step working. Note the sound and (on a turbo) how much boost builds. (Auto-advances on the hold.)", check: function (s) { return s.t && s.t.launchActive === true; } },
          { text: "Now add 'Launch ignition retard' - 8 to 12 degrees. Retard pushes OE's modeled combustion later into the exhaust: more estimated heat and more flame. It does not by itself guarantee compressor speed; native turbo response depends on the fitted vehicle.", highlight: 'set:launchRetard', check: function (s) { return (Number(s.settings.launchRetard) || 0) >= 6; } },
          { text: "Add 'Launch extra fuel' - 10 to 15%. Rich + retarded is the launch recipe: bigger bangs, more flame, and the extra fuel keeps the hold cooler than retard alone.", highlight: 'set:launchFuel', check: function (s) { return (Number(s.settings.launchFuel) || 0) >= 8; } },
          { text: "Hold it again and compare the harder bark, bigger flames and estimated EGT. On a turbo, watch the boost gauge to see whether that vehicle's native device actually retains boost - do not assume it will. Keep holds SHORT: OE models the heat and damage cost because real ALS is hard on parts.", manual: true },
          { text: "The same two settings also feed the rolling anti-lag and the hard-cut limiter bounce - one launch recipe, every limiter event. Tune it to your build: more retard = more show and more heat, more fuel = fatter bangs and a cooler hold. Done!", manual: true, last: true }
        ] },
        { id: 'dynopro', difficulty: 'Intermediate', desc: 'Numbers are only as good as the method: sweep discipline, measured drivetrain loss, and the verified Build Card.', title: 'Dyno like a professional', setup: 'stock', steps: [
          { text: "Two cars, same power, different dyno numbers - because the METHOD differed. Real operators control every variable: gear, sweep rate, temperature, correction. This lesson is that discipline.", manual: true },
          { text: "Open the Dyno and Arm it. Read the status line: it names the exact RPM window a pull must START from - that's why every run is comparable, and why flooring it at high revs 'doesn't register'.", highlight: 'nav:dyno', check: function (s) { return s.activePage === 'dyno'; } },
          { text: "Warm engine, 2nd or 3rd gear, revs into the window, then floor it and hold to the limiter. When it finishes, read the SWEEP grade on the status - the dyno measures torque after engine inertia, so a rocketing 1st-gear run genuinely reads low, same as a real inertia dyno. Chase 'clean pull'. (Auto-advances after a pull.)", check: function (s) { return (s.dynoPullCount || 0) > 0; } },
          { text: "Your first pull auto-saved as the BASELINE - the dotted reference every future pull compares against. Now the loss lesson: open Limiters and read 'Measured drivetrain loss' - that's the REAL loss through this car's drivetrain, measured like a hub dyno. Set the loss slider to match it.", highlight: 'nav:limits', check: function (s) { return s.t && (s.t.drivetrainLossMeasured || 0) > 0 && Math.abs((Number(s.settings.drivetrainLoss) || 15) - s.t.drivetrainLossMeasured) <= 3; } },
          { text: "Now tune something - ONE thing (a couple degrees at WOT, or a lambda touch) - and re-pull in the SAME gear from the SAME window. The overlay shows the gain honestly because everything else stayed equal. That's the whole scientific method of tuning.", manual: true },
          { text: "Open the Build Card: your power now reads DYNO VERIFIED (it survives restarts, with the date), and with the baseline saved you get the BASE to TUNED line with your gain percent. Cards that were only DRIVEN show the automatic ECU PEAK tier instead - real numbers all the way down, dyno-verified is just the gold standard. Done!", highlight: 'nav:buildcard', manual: true, last: true }
        ] },
        { id: 'transmissionbasics', difficulty: 'Beginner', desc: 'Learn converter stall, shift firmness, torque management and low-gear torque limiting on one repeatable factory automatic.', title: 'Automatic transmission tuning',
          requiredVehicle: { model: 'bastion', config: 'sport_A.pc', label: 'Bruckell Bastion Sport 5.7 (A)',
            hint: 'Load Vehicles > Bruckell > Bastion > Sport 5.7 (A) before starting.' },
          temporary: true, setup: 'transmission', steps: [
          { text: "This lesson uses the factory Bruckell Bastion Sport 5.7 (A): rear-wheel drive, V8, torque converter and 8-speed automatic. That exact configuration makes every result repeatable. This is a temporary lab - Finish, Exit and Restart restore the calibration that was loaded before the lesson.", manual: true },
          { text: "Open 'Transmission'. Five controls do five different jobs; we will change one system at a time and prove each change with live data.", highlight: 'nav:trans', check: function (s) { return s.activePage === 'trans'; } },
          { text: "Baseline first. In a safe, open test area, stop completely, select Drive, then accelerate with light-to-medium throttle past 45 km/h. Notice the stock shift feel and watch Gear and Shift time. Do not use traffic or public roads.", check: transmissionDriveProof },
          { text: "Set Shift firmness to 30%. This shortens the commanded gear-change time: the clutches clamp faster, so the shift should feel quicker and harder. It is not a power setting.", highlight: 'set:shiftFirmness', check: function (s) { var v = Number(s.settings && s.settings.shiftFirmness) || 0; return v >= 25 && v <= 35; } },
          { text: "Stop completely and repeat the same light-to-medium-throttle run past 45 km/h. Compare the firmer feel and the Shift time readout with your baseline. Change only one thing at a time when tuning a real build.", check: transmissionDriveProof },
          { text: "Set Shift torque management to 25%. During an actual shift the ECU now pulls some ignition timing, briefly reducing engine torque so the clutches do not have to absorb the full hit.", highlight: 'set:shiftTorqueMgmt', check: function (s) { var v = Number(s.settings && s.settings.shiftTorqueMgmt) || 0; return v >= 20 && v <= 30; } },
          { text: "Stop, then repeat the run past 45 km/h. The lesson watches for torque management becoming active during a shift. Compared with firmness, this changes the engine's torque during the shift rather than the transmission's shift time.", check: transmissionManagementProof },
          { text: "Set Torque limit to 300 Nm and enable 'Limit only in gears 1-2'. This protects the driveline where gear multiplication is highest, then releases full engine torque in higher gears.", highlight: 'set:torqueLimitNm', check: function (s) { var st = s.settings || {}; return Number(st.torqueLimitNm) === 300 && st.torqueLimitLowGears === true; } },
          { text: "From a stop, accelerate through 1st, 2nd and into 3rd. Watch Torque limiter change from 'Limiting 300 Nm (gears 1-2)' to 'Armed (higher gear)'. The cap is protection, not a request for the engine to make 300 Nm.", check: transmissionTorqueLimitProof },
          { text: "Turn the torque limit back Off (0 Nm), then set Converter stall to 2800 rpm. A higher-stall converter lets the engine reach a stronger part of its torque band before the car pushes hard, but the extra slip creates heat.", highlight: 'set:converterStall', check: function (s) { var st = s.settings || {}; return Number(st.torqueLimitNm) === 0 && Number(st.converterStall) === 2800; } },
          { text: "Brief converter test: stop on level ground, select Drive, hold the brake firmly and apply only 25-40% throttle. Watch Converter slip and Slip heat rise. Hold for no more than about 2 seconds, then release the throttle as soon as this step passes. Never do a long or full-throttle brake stall.", check: transmissionConverterProof },
          { text: "Release the throttle and set Converter stall back to 0. Zero means use the converter already fitted to the car; it does not mean zero-rpm stall. This also removes OE's custom-stall lockup fade behavior.", highlight: 'set:converterStall', check: function (s) { return Number(s.settings && s.settings.converterStall) === 0 && (Number(s.t && s.t.transFadePct) || 0) < 1; } },
          { text: "Street starting point: fitted converter (0), 20-30% firmness, 20-30% torque management and no torque cap unless the driveline needs protection. Drag builds may want a converter matched to the engine's torque band, but more stall means more heat. Finish now to restore your complete pre-lesson calibration.", manual: true, last: true }
        ] }
      ];

      // ====================================================================
      // CHALLENGES (V3.0.9 - community request: "make the game load a bad tune and guide me")
      //
      // A challenge IS a lesson. It rides the exact same engine - steps, live check() gates,
      // highlights, the Next/Back/Exit box, and (critically) the temporary-calibration journal
      // that backs up and restores the player's real tune. Nothing about that machinery is
      // duplicated here. What a challenge adds on top:
      //
      //   * requiredVehicle - one exact VANILLA car, so every player's result means the same
      //     thing. We never spawn it: the card stays locked and names the menu path, exactly
      //     like the transmission lesson. Replacing someone's car would bin their working tune.
      //   * setup - a deliberately broken tune, built only from faults this sim really models.
      //   * quiz - the player must NAME the fault before the app will let them fix it. That is
      //     the difference between a tutorial and a challenge: no free answer.
      //   * a score - percent power gained against a baseline measured THIS RUN, on THIS car.
      //     Nothing depends on hand-written per-car HP targets that rot on a game patch.
      //   * medals from ABSOLUTE, verifiable criteria (was the fault actually fixed, was the
      //     diagnosis right first time, did the engine survive) rather than an invented HP
      //     number. The percent gain is the headline; the medal is the workmanship. Every
      //     criterion is shown ticked or not on the result card, so a medal is never a mystery.
      //
      // They are pushed into LESSONS so startLesson/enterStep/tutExit/tutorialTick and the whole
      // rollback path apply unchanged, and deliberately kept OUT of CHAPTERS so they never show
      // up in the lesson picker or inflate its "N/total complete" count.
      // ====================================================================
      var CHALLENGE_CAR = {
        model: 'covet', config: '15gtz_turbo_M.pc',
        label: 'Ibishu Covet 15GTz Turbo (M)',
        hint: 'Load Vehicles > Ibishu > Covet > 15GTz Turbo (M) to unlock the challenges.'
      };

      // Load rows at or above a pressure, resolution-independent: the same challenge has to work
      // on the 6x7 Standard grid and on 16x16 Fine (V3.0.8), where row indices mean nothing.
      // Falls back to the bottom third of the table if no breakpoint reaches minKpa, so a check
      // can never become unsatisfiable and strand a player on a step.
      function challengeLoadRows(minKpa) {
        var rows = [], r;
        if (!scope.mapAxis || !scope.mapAxis.length) { return rows; }
        for (r = 0; r < scope.mapAxis.length; r++) {
          if ((Number(scope.mapAxis[r]) || 0) >= minKpa) { rows.push(r); }
        }
        if (!rows.length) {
          for (r = Math.floor(scope.mapAxis.length * 2 / 3); r < scope.mapAxis.length; r++) { rows.push(r); }
        }
        return rows;
      }
      // Most-ADVANCED cell, signed - deliberately not an absolute deviation. The factory ignition
      // map pulls timing negative under boost on purpose, so "distance from zero" would score a
      // correctly restored table as broken and teach players to delete the boost retard.
      function ignMaxCell() {
        if (!scope.maps || !scope.maps.ignition) { return 99; }
        var m = -99; scope.maps.ignition.forEach(function (row) { row.forEach(function (v) { m = Math.max(m, Number(v) || 0); }); }); return m;
      }
      // Leanest lambda TARGET anywhere in the boosted rows - the number the player has to bring back.
      function lambdaBoostMax() {
        if (!scope.maps || !scope.maps.lambda) { return -1; }
        var m = -1;
        challengeLoadRows(130).forEach(function (r) {
          var row = scope.maps.lambda[r]; if (!row) { return; }
          row.forEach(function (v) { m = Math.max(m, Number(v) || 0); });
        });
        return m;
      }
      // Richest boosted-row target. Paired with lambdaBoostMax it defines a WINDOW, which is what
      // best-power tuning actually is: not "as rich as possible" (the Case 1 mistake) and not "lean
      // for economy" (the Case 4 mistake), but a narrow band either side of ~0.88.
      function lambdaBoostMin() {
        if (!scope.maps || !scope.maps.lambda) { return -1; }
        var m = 99;
        challengeLoadRows(130).forEach(function (r) {
          var row = scope.maps.lambda[r]; if (!row) { return; }
          row.forEach(function (v) { var n = Number(v); if (isFinite(n)) { m = Math.min(m, n); } });
        });
        return m === 99 ? -1 : m;
      }
      // Most-advanced ignition cell in the BOOSTED rows only. The factory map runs these several
      // degrees NEGATIVE on purpose, and recovering that timing is where a stock turbo car's power
      // is actually hiding - but only as far as the fuel's knock resistance allows.
      function ignBoostMax() {
        if (!scope.maps || !scope.maps.ignition) { return -99; }
        var m = -99;
        challengeLoadRows(130).forEach(function (r) {
          var row = scope.maps.ignition[r]; if (!row) { return; }
          row.forEach(function (v) { var n = Number(v); if (isFinite(n)) { m = Math.max(m, n); } });
        });
        return m;
      }
      // Name a load row the way the player is CURRENTLY seeing it. A challenge check that says
      // "the boosted rows" while the table is showing psi leaves someone counting rows and guessing
      // which ones it meant - which is exactly how the 6 psi row got missed in testing. Matches the
      // table header convention: absolute-MAP shown as gauge, (kPa - 100) * 0.145.
      function chRowLabel(r) {
        var kpa = Number(scope.mapAxis && scope.mapAxis[r]);
        if (!isFinite(kpa)) { return 'row ' + (r + 1); }
        return scope.psi ? (((kpa - 100) * 0.145).toFixed(0) + ' psi') : (Math.round(kpa) + ' kPa');
      }
      // Worst offending cell in a map, restricted to rows if given. Returns {row, val} or null.
      function chWorstCell(mapName, rows, worseThan) {
        var m = scope.maps && scope.maps[mapName];
        if (!m) { return null; }
        var best = null;
        (rows || m.map(function (_, i) { return i; })).forEach(function (r) {
          var row = m[r]; if (!row) { return; }
          row.forEach(function (v) {
            var n = Number(v); if (!isFinite(n)) { return; }
            if (!best || worseThan(n, best.val)) { best = { row: r, val: n }; }
          });
        });
        return best;
      }
      function chWorse(a, b) { return a > b; }
      function chRunHealthy(run) { return run && run.minHealth >= 98; }
      // Health during the SCORED pull only. Three of the four repair faults (pegged injector duty,
      // unchecked advance, lean under boost) genuinely hurt the engine while they are still broken,
      // and the challenge FORCES a baseline pull on that broken tune - so a whole-run health floor
      // would mark players down for damage the exercise itself required them to do. What they are
      // accountable for is the state of the engine once their repair is in.
      function chRunHealthyAfterFix(run) {
        return run && run.minHealthAfterFix <= 100 && run.minHealthAfterFix >= 98;
      }
      // Faults on the scored pull only, for the same reason as chRunHealthyAfterFix: a genuinely
      // broken tune can hold a fault code before the player is allowed to touch anything.
      function chRunNoFaults(run) { return run && run.faultsAfterFix === 0; }
      function chRunFirstTry(run) { return run && run.quizWrong === 0; }

      var CHALLENGES = [
        // ---- CASE 1 ------------------------------------------------------
        { id: 'ch_rich', difficulty: 'Beginner', title: 'Case 1 - The fuel smell',
          desc: 'It stinks of fuel, it smokes, and it is slower than stock. Find out why, fix it, prove it on the dyno.',
          setup: 'ch_rich',
          brief: "Customer complaint: \"It smells like fuel, it left a sooty mark on my garage floor, and it feels SLOWER than it did before the last tuner touched it.\"",
          quizWhy: "Rich does not mean powerful. Fuel that cannot find oxygen cannot burn, so it does three things instead: it displaces the air charge, it takes heat out of the burn, and it washes oil off the bores. You get less power, black soot and a fouled engine. Best-power mixture on a petrol engine sits near lambda 0.86-0.89 at high load - richer than the 1.00 ideal, but nowhere near this.",
          criteria: [
            // 0.08 rather than 0.03: the factory table carries 1.02-1.06 in the boosted rows, so a
            // player who restores that real profile has to pass as surely as one who fills a flat 1.00.
            { label: 'Fuel Main brought back to about neutral', test: function () { var m = fuelMaxAbs(); return m >= 0 && m <= 0.08; } },
            { label: 'Diagnosed correctly on the first try', test: function (s, run) { return chRunFirstTry(run); } },
            { label: 'Engine at 98% health or better on the scored pull', test: function (s, run) { return chRunHealthyAfterFix(run); } },
            { label: 'No fault codes on the scored pull', test: function (s, run) { return chRunNoFaults(run); } }
          ],
          steps: [
            { text: "CASE 1. The car in front of you has a tune somebody else wrote. Your job: find what is wrong, explain it, fix it, and prove the fix with a dyno number. Starting a challenge loads that broken tune and resets engine damage to a clean slate - your own calibration is backed up and comes back when you Finish or Exit.", manual: true },
            { text: "Measure it before you touch it. Open the Dyno, arm it, and make ONE clean full-throttle pull in 2nd or 3rd. This is your baseline - the number you have to beat.", highlight: 'nav:dyno', chPhase: 'baseline', check: function (s) { return !!(s.chRun && s.chRun.baselineHp > 0); }, verify: 'Waiting for one completed dyno pull to set the baseline.', verified: 'PASS - baseline recorded.' },
            { text: "Now investigate. Drive it and look: Runtime Values (Lambda Actual against Lambda Target), Runtime Gauges, and Help Me if you want a second opinion. Do NOT change anything yet - a tuner who edits before diagnosing is guessing.", manual: true },
            { text: "So what is wrong with it?", quiz: {
              q: 'Pick the fault. A wrong answer costs you the first-try criterion, so read the live data before you guess.',
              opts: [
                { t: 'The fuel table is adding fuel everywhere - it is running far too rich', ok: true },
                { t: 'The lambda target is set too lean at high load', ok: false, why: 'Check Runtime Values. The TARGET is normal for this boost - it is the MEASURED lambda sitting well below it. Now look at FUEL TRIM: it reads about +20%, and that is the Fuel Main table adding fuel on top of whatever the target asked for.' },
                { t: 'The injectors are too small for the engine', ok: false, why: 'Check injector duty on Runtime Values. Small injectors peg duty at 100% and go LEAN at high rpm. This engine is doing the opposite - it is drowning.' },
                { t: 'There is too much ignition timing', ok: false, why: 'Check Knock Level and the ignition table. Too much timing shows up as knock and ECU retard, not as black smoke and a fuel smell.' }
              ] } },
            { text: "Correct. Rich does not mean powerful. Fuel with no oxygen to burn displaces air, cools the burn and washes the bores - less power, soot, fouled plugs. Best-power mixture at high load is around lambda 0.86-0.89, richer than the 1.00 ideal but nowhere near where this tune is sitting. Now fix it.", manual: true },
            { text: "Open Fuel Main. Rows are load (MAP), columns are RPM. Every cell above 1.00 is a multiplier ADDING fuel on top of whatever the lambda target already asked for - 1.25 means +25%.", highlight: 'nav:fuel', check: function (s) { return s.activePage === 'fuel'; } },
            { text: "Select the whole table - click 'All'.", highlight: 'edit:all', check: function (s) { return s.selCount() >= 12; } },
            { text: "Pull the fuel back out with '-' until the cells sit around 1.00. Neutral means 'add nothing on top, just follow the lambda target' - which is where a healthy fuel table lives. The factory table does keep a token 1.02-1.06 in the boosted rows; anywhere in that region counts as fixed.", highlight: 'edit:minus', check: function () { var m = fuelMaxAbs(); return m >= 0 && m <= 0.08; }, verify: function () { var w = chWorstCell('fuel', null, chWorse); if (!w) { return 'Waiting for the Fuel Main table.'; } return 'Still adding fuel: the ' + chRowLabel(w.row) + ' row is at ' + w.val.toFixed(2) + ' (1.00 is neutral). Bring every cell to 1.08 or below.'; }, verified: 'PASS - the fuel table is back to neutral.' },
            { text: "Prove it. Back to the Dyno and make another clean pull from the same gear and the same rpm window as your baseline - same method, or the comparison is worthless.", highlight: 'nav:dyno', chPhase: 'score', check: function (s) { return !!(s.chRun && s.chRun.bestHp > 0); }, verify: 'Waiting for a scored dyno pull after the fix.', verified: 'PASS - scored pull recorded.' },
            { text: "That is the job: measure, diagnose, explain, fix, measure again. Finish to see your result card and get your own tune back.", manual: true, last: true }
          ] },

        // ---- CASE 2 ------------------------------------------------------
        { id: 'ch_injectors', difficulty: 'Intermediate', title: 'Case 2 - Runs out of breath',
          desc: 'Pulls hard to the midrange, then goes lean and flat on top. The tables look fine. So what ran out?',
          setup: 'ch_injectors',
          brief: "Customer complaint: \"Below 4,000 it feels great. Past that it goes flat and the wideband screams lean. The last guy said the tune is fine.\"",
          quizWhy: "The tables WERE fine. A fuel table is a request, and the injector is what fills it. Once an injector is open 100% of the available time it physically cannot deliver another drop, so the mixture goes lean exactly where the engine wants the most fuel - at high rpm and high load. That is the most dangerous kind of lean, because it happens at peak cylinder pressure.",
          criteria: [
            // No "clean fault log" criterion on this one: the setup itself pegs duty past 103%,
            // which raises an InjectorDuty fault before the player has touched anything. Scoring
            // them on a fault the challenge created would be unwinnable. Duty on the SCORED pull
            // is the honest measure, and it is the lesson besides.
            { label: 'Injectors sized to at least 550 cc/min', test: function (s) { return (Number(s.settings && s.settings.injectorFlow) || 0) >= 550; } },
            { label: 'Diagnosed correctly on the first try', test: function (s, run) { return chRunFirstTry(run); } },
            { label: 'Engine at 98% health or better on the scored pull', test: function (s, run) { return chRunHealthyAfterFix(run); } },
            { label: 'Injector duty stayed under 90% on the scored pull', test: function (s, run) { return run && run.scoreFrames > 0 && run.maxDutyAfterFix < 90; } }
          ],
          steps: [
            { text: "CASE 2. Same drill: diagnose before you touch anything. This one is sneaky - the tune itself is not the problem. Your calibration is backed up and returns when you Finish or Exit.", manual: true },
            { text: "Baseline first. Open the Dyno and make ONE clean full-throttle pull, held all the way to the limiter - the fault only shows up at the top, so a short pull will hide it.", highlight: 'nav:dyno', chPhase: 'baseline', check: function (s) { return !!(s.chRun && s.chRun.baselineHp > 0); }, verify: 'Waiting for one completed dyno pull to set the baseline.', verified: 'PASS - baseline recorded.' },
            { text: "Now drive it and watch Runtime Values through a full pull. Two numbers matter here: Lambda Actual against Lambda Target, and Injector Duty. Watch what duty does as the revs climb.", highlight: 'nav:runtime', check: function (s) { return s.activePage === 'runtime'; } },
            { text: "So what is wrong with it?", quiz: {
              q: 'Both a lean fuel table and undersized injectors read lean. One number tells them apart - find it first.',
              opts: [
                { t: 'The injectors are too small - duty pegs at 100% and it cannot deliver any more fuel', ok: true },
                { t: 'The fuel table is pulling fuel out at high rpm', ok: false, why: 'Open Fuel Main and look - the high-rpm cells are neutral at 1.00, and FUEL TRIM on Runtime Values is near zero. The ECU is not asking for less fuel. Now look at INJECTOR DUTY: it is pinned at 100%, which is something stopping it DELIVERING what it already asked for.' },
                { t: 'The lambda target is too lean up top', ok: false, why: 'Open Lambda Target. The high-load targets are normal. The ECU is requesting a healthy mixture and failing to achieve it - that is a delivery problem, not a request problem.' },
                { t: 'The fuel pump cannot keep up', ok: false, why: 'Close, and the right instinct - but check Fuel Rail and Diff pressure on Runtime Values. Rail pressure is holding. The restriction is further downstream, at the injector itself.' }
              ] } },
            { text: "Correct, and this is the single most useful diagnostic split in fuelling: LEAN WITH DUTY BELOW 100% means the ECU asked for lean - fix the tables. LEAN WITH DUTY AT 100% means the ECU asked correctly and the hardware ran out - fix the hardware. Same symptom on the wideband, completely different repair. Guess wrong and you lean out a healthy tune chasing a hardware limit.", manual: true },
            { text: "Open Fuel Setup. Injector size is measured in cc/min: it is how much fuel one injector flows wide open. Too small and you run out of duty; far too big and idle gets choppy because the pulse gets too short to meter accurately.", highlight: 'nav:fuel_setup', check: function (s) { return s.activePage === 'fuel_setup'; } },
            { text: "Size them properly. Raise 'Injector flow' until duty comes back off the stop on a full pull - about 600 cc/min is right for this engine, and that is what the ECU expects by default. You are aiming to land under roughly 85% duty at peak power: enough margin left to add fuel later without running out all over again.", highlight: 'set:injectorFlow', check: function (s) { return (Number(s.settings && s.settings.injectorFlow) || 0) >= 550; }, verify: 'Waiting for injector flow of 550 cc/min or more.', verified: 'PASS - the injectors can feed the engine now.' },
            { text: "Prove it. Dyno again, same gear, same rpm window, held to the limiter. Watch duty stay off the stop and the top end come back.", highlight: 'nav:dyno', chPhase: 'score', check: function (s) { return !!(s.chRun && s.chRun.bestHp > 0); }, verify: 'Waiting for a scored dyno pull after the fix.', verified: 'PASS - scored pull recorded.' },
            { text: "Remember the split - duty at 100% is a hardware answer, duty below 100% is a tuning answer. Finish for your result card.", manual: true, last: true }
          ] },

        // ---- CASE 3 ------------------------------------------------------
        { id: 'ch_timing', difficulty: 'Intermediate', title: 'Case 3 - The rattle',
          desc: 'Somebody buried the timing chasing power. The knock control is the only reason it is still alive - and it is costing you every horsepower you think you gained.',
          setup: 'ch_timing',
          brief: "Customer complaint: \"My mate added a load of timing and said it would wake it up. It rattles under load and it is no faster. He says that noise is normal.\"",
          quizWhy: "Timing makes power right up to MBT - the point of best torque - and then power FALLS while cylinder pressure and heat keep climbing. Past MBT you are not making power, you are making detonation. Knock control saved this engine by pulling timing back out automatically, which is why it is not faster: you commanded a big number and the ECU quietly refused it. More timing is not more power once you are past the knock limit.",
          criteria: [
            { label: 'No ignition cell left above +4 deg', test: function () { var m = ignMaxCell(); return m > -99 && m <= 4; } },
            { label: 'Diagnosed correctly on the first try', test: function (s, run) { return chRunFirstTry(run); } },
            { label: 'Engine at 98% health or better on the scored pull', test: function (s, run) { return chRunHealthyAfterFix(run); } },
            // Peak knock across the scored pull, not knock at the instant Finish is pressed -
            // otherwise parking the car and clicking Finish passes a tune that still detonates.
            { label: 'Peak knock under 0.5 on the scored pull', test: function (s, run) { return run && run.scoreFrames > 0 && run.maxKnockAfterFix < 0.5; } }
          ],
          steps: [
            { text: "CASE 3. This tune is actively hurting the engine, and the ECU's knock control is the only thing standing between it and a hole in a piston. Your calibration is backed up and returns on Finish or Exit.", manual: true },
            { text: "Baseline pull first - one clean full-throttle run on the Dyno. Listen to it and watch the numbers while it pulls.", highlight: 'nav:dyno', chPhase: 'baseline', check: function (s) { return !!(s.chRun && s.chRun.baselineHp > 0); }, verify: 'Waiting for one completed dyno pull to set the baseline.', verified: 'PASS - baseline recorded.' },
            { text: "Open Runtime Values and make another pull. Watch Knock Level, and watch Ignition Advance - compare the advance the ECU is actually running against what the table is asking for.", highlight: 'nav:runtime', check: function (s) { return s.activePage === 'runtime'; } },
            { text: "So what is wrong with it?", quiz: {
              q: 'The car is not faster despite a big timing number. Why not?',
              opts: [
                { t: 'The ignition table is far past the knock limit, and knock control is pulling most of it back out', ok: true },
                { t: 'It does not have enough timing', ok: false, why: 'Compare two numbers on Runtime Values: IGNITION ANGLE (what the engine is actually running) against the large positive trim Ignition Main is commanding. The gap between them is knock retard - the ECU refusing your timing. Adding more only deepens the knock it is already fighting.' },
                { t: 'It is running too lean', ok: false, why: 'Check Lambda Actual against target on Runtime Values - the mixture is tracking fine. Lean can certainly cause knock, but it is not what is causing it here.' },
                { t: 'The boost is set too high', ok: false, why: 'Check Boost against Boost Target. Boost is on its normal target. The extra cylinder pressure is coming from the spark being far too early, not from extra air.' }
              ] } },
            { text: "Correct. Timing makes power up to MBT and then power FALLS while pressure and heat keep climbing - past that point you are manufacturing detonation, not horsepower. The ECU has been quietly refusing the number it was given, which is exactly why the car is not faster. Knock control is a safety net, not a tuning strategy: a tune that leans on it is one failed sensor away from a scrap engine.", manual: true },
            { text: "Open Ignition Main. This table TRIMS the base map - 0 means stock timing, positive is advance, negative is retard.", highlight: 'nav:ignition', check: function (s) { return s.activePage === 'ignition'; } },
            { text: "Select the whole table - click 'All'.", highlight: 'edit:all', check: function (s) { return s.selCount() >= 12; } },
            { text: "Bring it down with '-' until no cell is above about +4. Note what the factory map does in the boosted rows: it goes NEGATIVE, several degrees of retard, on purpose - more air means more cylinder pressure, so a turbo engine needs LESS spark advance under boost, not more. Restoring that shape (the table's own reset does it too) is a perfectly good repair. From there, find the real limit honestly: a couple of degrees at a time, dyno each step, and stop where power stops rising - which is before knock starts.", highlight: 'edit:minus', check: function () { var m = ignMaxCell(); return m > -99 && m <= 4; }, verify: function () { var w = chWorstCell('ignition', null, chWorse); if (!w) { return 'Waiting for the Ignition Main table.'; } return 'Still over-advanced: the ' + chRowLabel(w.row) + ' row is at ' + w.val.toFixed(1) + ' deg. No cell may sit above +4.'; }, verified: 'PASS - the timing is back in a sane window.' },
            { text: "Prove it. Dyno again, same gear and rpm window. Less commanded timing, more actual power - because now the ECU is not deleting it.", highlight: 'nav:dyno', chPhase: 'score', check: function (s) { return !!(s.chRun && s.chRun.bestHp > 0); }, verify: 'Waiting for a scored dyno pull after the fix.', verified: 'PASS - scored pull recorded.' },
            { text: "If knock control is doing constant work, your tune is wrong - the net is not the plan. Finish for your result card.", manual: true, last: true }
          ] },

        // ---- CASE 4 ------------------------------------------------------
        { id: 'ch_lambda', difficulty: 'Advanced', title: 'Case 4 - Melting it',
          desc: 'It makes decent numbers and the injectors have plenty left. It is also cooking itself every single pull. Find the request that is doing it.',
          setup: 'ch_lambda',
          brief: "Customer complaint: \"Tuner said he leaned it out for economy and it picked up a bit. Now the exhaust glows on a long pull and it has started rattling at the top.\"",
          quizWhy: "This is the one that kills engines while looking healthy on a dyno graph. A lean mixture burns hotter and slower: exhaust temperature climbs, the charge gets more knock-prone, and there is no surplus fuel left to carry heat away from the piston crown and valves. Petrol engines make best power near lambda 0.86-0.89 at high load, and that extra fuel is not waste - it is the cooling system for the combustion chamber.",
          criteria: [
            { label: 'High-load lambda targets richened to 0.92 or below', test: function () { var m = lambdaBoostMax(); return m > 0 && m <= 0.92; } },
            { label: 'Diagnosed correctly on the first try', test: function (s, run) { return chRunFirstTry(run); } },
            { label: 'Engine at 98% health or better on the scored pull', test: function (s, run) { return chRunHealthyAfterFix(run); } },
            { label: 'Peak EGT held under 950 C after the fix', test: function (s, run) { return run && run.scoreFrames > 0 && run.maxEgtAfterFix < 950; } }
          ],
          steps: [
            { text: "CASE 4. The hardest fault to spot, because nothing is obviously broken and the dyno number is not terrible. Your calibration is backed up and returns on Finish or Exit.", manual: true },
            { text: "Baseline pull - one clean full-throttle run on the Dyno, held to the limiter.", highlight: 'nav:dyno', chPhase: 'baseline', check: function (s) { return !!(s.chRun && s.chRun.baselineHp > 0); }, verify: 'Waiting for one completed dyno pull to set the baseline.', verified: 'PASS - baseline recorded.' },
            { text: "Now make a long pull and watch Runtime Values: EGT, Lambda Actual against Lambda Target, and Injector Duty. Duty has headroom, so the hardware is not the limit this time - which means the ECU is getting exactly what it asked for.", highlight: 'nav:runtime', check: function (s) { return s.activePage === 'runtime'; } },
            { text: "So what is wrong with it?", quiz: {
              q: 'It is lean at high load and duty is NOT at 100%. Where does that point?',
              opts: [
                { t: 'The lambda target itself is set too lean at high load - the ECU is doing what it was told', ok: true },
                { t: 'The injectors are too small', ok: false, why: 'Check Injector Duty - it has headroom left. The injectors could deliver more fuel if anything asked them to. Nothing is asking.' },
                { t: 'The fuel pump is failing', ok: false, why: 'Check Fuel Rail and Diff pressure on Runtime Values - both are holding steady. Delivery is healthy; the request is wrong.' },
                { t: 'It has too much boost for the fuel system', ok: false, why: 'Compare Boost against Boost Target - it is on target, and duty has headroom, so the fuel system is coping fine with the air it is being given.' }
              ] } },
            { text: "Correct. Lean burns hotter and slower: EGT climbs, the charge turns knock-prone, and there is no surplus fuel left to pull heat out of the piston crown and the exhaust valve. That extra fuel at high load is not waste - it is the cooling system for the combustion chamber, and this tune deleted it to chase economy in the one place economy does not belong.", manual: true },
            { text: "Open Lambda Target. This table is the REQUEST - what mixture you want, before anything else trims it. Load increases downward, so wide-open throttle lives in the bottom, highest-kPa rows.", highlight: 'nav:lambda', check: function (s) { return s.activePage === 'lambda'; } },
            { text: "Fix EVERY boosted row - that means every row above 0 psi / above 100 kPa, not just the bottom two. Select them across the whole rpm range, type 0.88 and click 'Fill'. That is the best-power, best-cooling window for a petrol engine under boost. Leave the vacuum rows alone: cruise is exactly where a lean target belongs.", highlight: 'edit:fill', check: function () { var m = lambdaBoostMax(); return m > 0 && m <= 0.92; }, verify: function () { var w = chWorstCell('lambda', challengeLoadRows(130), chWorse); if (!w) { return 'Waiting for the Lambda Target table.'; } return 'Still lean: the ' + chRowLabel(w.row) + ' row is at ' + w.val.toFixed(2) + '. Every boosted row needs 0.92 or richer.'; }, verified: 'PASS - every boosted row is back in the safe window.' },
            { text: "Prove it. Dyno again, same gear and window - then watch EGT on the way back down. Cooler AND usually a little stronger, because 0.88 is nearer best-power mixture than 1.02 ever was.", highlight: 'nav:dyno', chPhase: 'score', check: function (s) { return !!(s.chRun && s.chRun.bestHp > 0); }, verify: 'Waiting for a scored dyno pull after the fix.', verified: 'PASS - scored pull recorded.' },
            { text: "Lean at cruise saves fuel. Lean at full load buys you a piston. Finish for your result card.", manual: true, last: true }
          ] },

        // ---- POWER HUNT --------------------------------------------------
        { id: 'ch_power', difficulty: 'Advanced', title: 'Power Hunt - Covet Turbo',
          desc: 'Open challenge. Stock tune, stock block, one little turbo. Make as much power as you can and keep the engine alive.',
          setup: 'ch_power', kind: 'power', failHealth: 90,
          brief: "No fault to find this time. You get a factory tune on a factory engine and one question: how much can you take out of it before it takes itself apart?",
          criteria: [
            { label: 'Engine survived at 98% health or better', test: function (s, run) { return chRunHealthy(run); } },
            { label: 'No fault codes on the scored pull', test: function (s, run) { return chRunNoFaults(run); } },
            { label: 'Gained at least 10% over the stock baseline', test: function (s, run) { return run && run.gainPct >= 10; } },
            { label: 'Gained at least 20% over the stock baseline', test: function (s, run) { return run && run.gainPct >= 20; } }
          ],
          steps: [
            { text: "POWER HUNT. Factory tune, factory block, the standard turbo. Everything the ECU can reach is yours: boost, timing, lambda, cam timing, injectors, limiters. Blow the engine and the run ends where it ends - your own calibration is backed up and returns on Finish or Exit either way.", manual: true },
            { text: "Stock baseline first. Open the Dyno and make ONE clean full-throttle pull in 2nd or 3rd, held to the limiter. Every percent you score is measured against this exact number, so make it a good clean run.", highlight: 'nav:dyno', chPhase: 'baseline', check: function (s) { return !!(s.chRun && s.chRun.baselineHp > 0); }, verify: 'Waiting for one completed stock dyno pull to set the baseline.', verified: 'PASS - stock baseline recorded.' },
            { text: "Now go hunting. There is no step-by-step from here - that is the point. The order that works: BOOST first (more air is the biggest lever on a turbo), FUEL to match it (0.86-0.89 lambda at high load, and check injector duty stays under about 90%), then TIMING last, a couple of degrees at a time, dynoing every step until power stops rising. Watch Knock Level, EGT and Engine Health the whole way. Pull as many times as you like - your best pull counts. Click Next when you are done hunting.", chPhase: 'score', manual: true },
            { text: "Last call. Your best pull is locked in as your score. If the engine is still at full health and fault-free, Finish now and take the medal - pushing for one more percent with a hurt engine is how people lose a whole run.", manual: true, last: true }
          ] },

        // ---- MASTERCLASS -------------------------------------------------
        // The coached version of the Power Hunt, for the player who scored Bronze and has no idea
        // what they left on the table. It goes all the way to a gold-capable tune ON PURPOSE - and
        // that does not hand the Power Hunt away, because a challenge is a temporary session: the
        // tune is rolled back on exit and temporaryLessonMapSaveLocked() blocks every M1-M4 save
        // while one is running. Nothing can be banked here and reloaded there. The player has to
        // rebuild it from what they understood, which is the entire point.
        //
        // The differentiator against Lesson 5 (what the levers do) and Lesson 8 (a SAFE turbo tune)
        // is deliberate and stated in step 1: this is the ORDER, and the STOPPING RULE, aimed at
        // maximum rather than safe, on one known car, with a number at the end.
        { id: 'ch_masterclass', difficulty: 'Advanced', title: 'Power Hunt Masterclass',
          desc: 'Coached, step by step: the exact order a tuner pulls the levers on this car, and how far each one goes before it stops paying.',
          setup: 'ch_power', kind: 'guided', failHealth: 90,
          brief: "The Power Hunt asks the question. This answers it - the whole method, in order, on the same car.",
          criteria: [
            { label: 'Reached +20% or better over the stock baseline', test: function (s, run) { return run && run.gainPct >= 20; } },
            // Personal and self-calibrating - the one number that proves the coaching worked. Passes
            // for a player who has never run the Power Hunt: there is nothing to have beaten yet.
            { label: 'Beat your own Power Hunt best', test: function (s, run) {
              var pb = chBest['ch_power'];
              return !!run && (!pb || run.gainPct > (Number(pb.gainPct) || 0));
            } },
            { label: 'Engine at 98% health or better on the scored pull', test: function (s, run) { return chRunHealthyAfterFix(run); } },
            { label: 'No fault codes on the scored pull', test: function (s, run) { return chRunNoFaults(run); } }
          ],
          steps: [
            { text: "MASTERCLASS. The lessons taught you what each lever DOES. This is different: it is the ORDER a tuner pulls them, and the rule for knowing when each one has stopped paying. Same car as the Power Hunt, same stock starting tune, same scoring - so the number you finish with is directly comparable to your own Power Hunt best.", manual: true },
            { text: "One thing before you start, so you know what you are getting. You do NOT keep this tune. A challenge rolls your calibration back when it ends, and saving to M1-M4 is locked while one is running - so you cannot bank this and reload it in the Power Hunt. You will have to rebuild it from what you understood. That is deliberate: the point is that you can do it again, not that you have a file.", manual: true },
            { text: "Stock baseline. Open the Dyno, make ONE clean full-throttle pull in 2nd or 3rd, held to the limiter. Every percentage from here is measured against this number.", highlight: 'nav:dyno', chPhase: 'baseline', check: function (s) { return !!(s.chRun && s.chRun.baselineHp > 0); }, verify: 'Waiting for one completed stock dyno pull to set the baseline.', verified: 'PASS - stock baseline recorded.' },
            { text: "STEP 1 OF 6 - AIR. Boost first, always, because on a turbo engine it is the only lever that adds air rather than just using the air better. Everything after this is making the most of whatever air you get here. Open Boost Target.", highlight: 'nav:boost', check: function (s) { return s.activePage === 'boost'; } },
            { text: "Select the table and raise the target. The table is ABSOLUTE kPa - zero gauge boost is the live barometric pressure, so about 200 kPa is roughly 14 psi at sea level. Use the kPa/PSI button if you would rather see gauge. Go to about 14-16 psi for now; that is a real step up on a 1.5x block without being silly.", highlight: 'edit:fill', check: function () { return boostMaxCell() >= 200; }, verify: 'Waiting for a boost target of about 200 kPa absolute (roughly 14 psi gauge) or more.', verified: 'PASS - the ECU is now asking for real boost.' },
            { text: "Now the part people skip: go and CHECK it. Do a pull and compare Boost against Boost Target on Runtime Values. If Boost Control reads TURBO MAXED, the compressor is out of breath and the number in the table is fiction - asking for more achieves nothing. If it says WASTEGATE FLOOR, the spring is holding boost ABOVE what you asked. Both are hardware telling you where the ceiling is, and neither is a tuning failure. Whatever it actually delivers is the air you have to work with.", highlight: 'nav:runtime', manual: true },
            { text: "STEP 2 OF 6 - FUEL, and this is the one that surprises people. Open Lambda Target and look at the boosted rows. The factory runs them RICH - around 0.80-0.82. That is not best power. It is a safety margin, deliberately fat so a factory tune survives bad fuel and a hot day.", highlight: 'nav:lambda', check: function (s) { return s.activePage === 'lambda'; } },
            { text: "Best power on petrol is about 0.86-0.89. On this car that means leaning the boosted rows SLIGHTLY from factory - and yes, that is the opposite direction to Case 1, and it stops well short of Case 4. That is the whole idea: there is a WINDOW. Too rich wastes fuel and cools the burn; too lean melts pistons; the peak sits between them, nearer the rich edge. Select the boosted rows and fill 0.88.", highlight: 'edit:fill', check: function () { var hi = lambdaBoostMax(), lo = lambdaBoostMin(); return hi > 0 && lo > 0 && hi <= 0.90 && lo >= 0.84; }, verify: 'Waiting for every boosted-row target to sit inside the best-power window, 0.84 to 0.90.', verified: 'PASS - the boosted rows are in the best-power window.' },
            { text: "Check you can actually feed it. Do a pull and watch Injector Duty on Runtime Values. Under about 85-90% is where you want to be at peak power. If duty is pegged near 100 the ECU is asking for a mixture the injectors cannot deliver and you will go lean at the top - that is Case 2, and the fix is Fuel Setup, not the tables.", highlight: 'nav:runtime', manual: true },
            { text: "STEP 3 OF 6 - SPARK. Open Ignition Main and look at the boosted rows. Factory pulls them several degrees NEGATIVE - more air means more cylinder pressure, so a turbo engine needs less advance under boost. But that retard is sized for pump fuel on a bad day, and some of it is yours to take back.", highlight: 'nav:ignition', check: function (s) { return s.activePage === 'ignition'; } },
            { text: "Bring the boosted cells up to about zero - two degrees at a time, dyno each step, watching Knock Level. THE STOPPING RULE: power climbs with advance, plateaus, and then FALLS while pressure and heat keep rising. Peak torque lands near +7.5 degrees on good fuel and power drops off past it. So stop where the DYNO stops improving, not where the knock starts - the power peak comes first, and if you are tuning to the knock you have already gone past the money.", highlight: 'edit:plus', check: function () { var m = ignBoostMax(); return m > -99 && m >= 0; }, verify: 'Waiting for the boosted ignition rows to come up to about zero.', verified: 'PASS - you have taken back the factory boost retard.' },
            { text: "STEP 4 OF 6 - THE TRAP. Everyone at this point tries to raise the rev limiter for more top end. Open Limiters and read the block near the bottom before you touch it.", highlight: 'nav:limits', check: function (s) { return s.activePage === 'limits'; } },
            { text: "Four rows tell you the whole story. 'Valve float starts at' is where the springs stop following the cam - above it, cylinder filling collapses and the engine loses up to 55% of its power. It keeps pulling, weakly, so it feels WRONG rather than broken, and that is exactly why people chase it with more rpm. 'Engine damage starts at' is the second threshold. They read in the order you meet them: fade first, damage second - and the gap between them is your entire warning band.", highlight: 'nav:limits', manual: true },
            { text: "'Highest limit you can set' is the ceiling the ECU will accept, and 'Valve float wear' is the bill. Wear accrues with TIME SPENT floating, it does NOT reset when you clear damage, and BeamNG's own over-rev will let go of the rods after about ten seconds in the band. So the rule is simple: find where power actually peaks on the dyno and put the limiter a little past it. Revving past float is negative horsepower - you are paying for rpm with power AND parts.", highlight: 'nav:limits', manual: true },
            { text: "STEP 5 OF 6 - CAM. Open VVT Control. Cam timing changes when the valves open, which changes what the engine breathes at different rpm. More intake advance fills the midrange - the Honda 'kick' - and it costs nothing in heat or knock margin, which makes it the cheapest power on the list. Raise 'Intake advance' toward 35-40 and dyno it.", highlight: 'set:vvtIntakeAdvance', check: function (s) { return (Number(s.settings && s.settings.vvtIntakeAdvance) || 0) >= 32; }, verify: 'Waiting for intake advance of 32 deg or more.', verified: 'PASS - cam timing is working the midrange.' },
            { text: "STEP 6 OF 6 - FUEL CHEMISTRY, the lever that reopens all the others. Open Fuel Setup and switch Fuel type to E85. It resists knock nearly twice as well as pump gas, makes about 3% more power on its own, and runs the exhaust about 30 C cooler. It is not a small change - it is the reason drag cars run it.", highlight: 'nav:fuel_setup', check: function (s) { return Number(s.settings && s.settings.fuelType) === 2; }, verify: 'Waiting for Fuel type to be set to E85.', verified: 'PASS - running E85.' },
            { text: "E85 is not free, and here is the bill: it needs about 1.5 times the fuel MASS for the same mixture, so injector duty jumps by roughly half the moment you switch. Do a pull and look at Injector Duty now. If it is near the stop, go to Fuel Setup and size the injectors up until it is back under about 85% - on a training ECU you can, and on a real car this is exactly the moment you buy bigger injectors.", highlight: 'nav:runtime', manual: true },
            { text: "Now spend the knock headroom you just bought. Go back through boost and timing and push both further than pump gas would take - more boost, and the boosted ignition cells up past zero into real positive advance. Dyno every step and keep using the stopping rule: stop when the dyno stops improving. Get the boosted cells to at least +4 and prove it with a pull.", highlight: 'nav:ignition', check: function () { var m = ignBoostMax(); return m > -99 && m >= 4; }, verify: 'Waiting for the boosted ignition rows to reach +4 deg or more on E85.', verified: 'PASS - you are using the fuel you paid for.' },
            { text: "Final pull. Same gear, same rpm window as your stock baseline, held to the limiter. This is your score.", highlight: 'nav:dyno', chPhase: 'score', check: function (s) { return !!(s.chRun && s.chRun.bestHp > 0); }, verify: 'Waiting for the scored dyno pull.', verified: 'PASS - scored pull recorded.' },
            { text: "That is the method: AIR, then FUEL to match it, then SPARK to the power peak, then CAM, then better fuel to reopen the first three - and never past valve float. Six levers, one order, one stopping rule. Now go and do it again in the Power Hunt with nobody telling you the order, because that is the difference between having followed a tune and being able to write one.", manual: true, last: true }
          ] }
      ];

      // A challenge IS a lesson to every code path above: same start, same steps, same rollback.
      // temporary is forced on rather than declared per entry - every challenge writes a tune it
      // did not author, so every challenge must hand the player's own calibration back.
      CHALLENGES.forEach(function (c) {
        c.isChallenge = true;
        c.temporary = true;
        c.requiresTurbo = true;
        c.requiredVehicle = c.requiredVehicle || CHALLENGE_CAR;
        LESSONS.push(c);
      });

      // ---- challenge runtime ------------------------------------------------
      // scope.chRun is the live run; scope.chResult is the card shown after it ends.
      // A run ends three ways: Finish (scored), Exit (abandoned), or the engine dying
      // under the failHealth line - which force-exits so the rollback still happens.
      scope.chRun = null;
      scope.chResult = null;
      scope.challengeList = [];
      var CH_RANK = { none: 0, bronze: 1, silver: 2, gold: 3 };
      var chBest = {};
      try { chBest = JSON.parse(localStorage.getItem('oep_ch_best') || '{}') || {}; } catch (e) { chBest = {}; }
      function saveChBest() { try { localStorage.setItem('oep_ch_best', JSON.stringify(chBest)); } catch (e) {} }

      function refreshChallengeList() {
        var list = [];
        LESSONS.forEach(function (l, i) {
          if (!l.isChallenge) { return; }
          list.push({
            index: i, id: l.id, title: l.title, difficulty: l.difficulty, desc: l.desc,
            kind: l.kind || 'repair', brief: l.brief || '', best: chBest[l.id] || null,
            done: !!doneLessons[l.id]
          });
        });
        scope.challengeList = list;
      }

      function challengeStart(l) {
        if (!l || !l.isChallenge) { scope.chRun = null; return; }
        scope.chResult = null;
        scope.chRun = {
          id: l.id, title: l.title, kind: l.kind || 'repair', criteria: l.criteria || [],
          quizWhy: l.quizWhy || '', failHealth: isFinite(Number(l.failHealth)) ? Number(l.failHealth) : 70,
          phase: 'brief', pulls: 0, baselineHp: 0, baselineRpm: 0, bestHp: 0, bestRpm: 0,
          quizWrong: 0, minHealth: 100, maxKnock: 0, maxEgt: 0, maxEgtAfterFix: 0, maxDutyAfterFix: 0,
          minHealthAfterFix: 101, maxKnockAfterFix: 0, scoreFrames: 0,
          faultsSeen: 0, faultsAfterFix: 0, limpSeen: false, armed: false,
          frozen: false, critResults: null,
          failed: false, failReason: '', ended: false, gainPct: 0
        };
      }

      // One completed dyno pull. Which number it becomes depends on the phase the current
      // step declared, so exploratory pulls between the baseline and the scored run are
      // simply ignored rather than quietly overwriting either one.
      function challengeDynoDone(peak) {
        var run = scope.chRun;
        if (!run || run.ended || !peak || !(peak.hp > 0)) { return; }
        run.pulls++;
        if (run.phase === 'baseline') {
          if (!(run.baselineHp > 0)) { run.baselineHp = peak.hp; run.baselineRpm = peak.hpRpm || 0; }
          return;
        }
        if (run.phase === 'score' && peak.hp > run.bestHp) { run.bestHp = peak.hp; run.bestRpm = peak.hpRpm || 0; }
      }

      function challengeTick(t) {
        var run = scope.chRun;
        if (!run || run.ended || !t) { return; }
        var health = Number(t.engineHealth);
        // ARMING. The setup's applyPreset("stock") clears damage and faults, but it is a queued
        // command - for the first frames telemetry still describes the engine the player arrived
        // with. Scoring those frames would dock a player for damage that predates the challenge,
        // and on an already-hurt engine it would fail the run before the brief finished being
        // read. Nothing is recorded until a healthy engine is actually observed.
        if (!run.armed) {
          if (isFinite(health) && health >= 99) { run.armed = true; }
          return;
        }
        if (isFinite(health)) { run.minHealth = Math.min(run.minHealth, health); }
        var knock = Number(t.knockLevel) || 0;
        if (knock > run.maxKnock) { run.maxKnock = knock; }
        var egt = Number(t.egtC) || 0;
        if (egt > run.maxEgt) { run.maxEgt = egt; }
        var duty = Number(t.injectorDuty) || 0;
        if (run.phase === 'score') {
          run.scoreFrames++;   // evidence that the scored phase was actually driven, not skipped
          if (egt > run.maxEgtAfterFix) { run.maxEgtAfterFix = egt; }
          if (duty > run.maxDutyAfterFix) { run.maxDutyAfterFix = duty; }
          if (isFinite(health)) { run.minHealthAfterFix = Math.min(run.minHealthAfterFix, health); }
          if (knock > run.maxKnockAfterFix) { run.maxKnockAfterFix = knock; }
          if ((Number(t.faultCount) || 0) > run.faultsAfterFix) { run.faultsAfterFix = Number(t.faultCount) || 0; }
        }
        var faults = Number(t.faultCount) || 0;
        if (faults > run.faultsSeen) { run.faultsSeen = faults; }
        if (t.limpMode === true) { run.limpSeen = true; }
        // Run-ending damage. tutExit is the correct way out even here: it is what puts the
        // player's own calibration back. The result card is built by challengeFinish.
        if (isFinite(health) && health < run.failHealth) {
          run.ended = true;
          run.failed = true;
          run.failReason = String(t.damageReason || '') || 'The engine was damaged past the limit for this challenge.';
          scope.tutExit(false);
        }
      }

      // MUST run before the temporary-calibration rollback, which is why tutExit calls it as its
      // very first act. Every challenge is a temporary lesson, so by the time finishTutorialExit
      // runs, restoreTemporaryLesson has already put the player's OWN tune back into scope.maps
      // and scope.settings - and criteria like "is Fuel Main neutral" or "are the injectors sized
      // right" would then be judging a tune the challenge never touched. Freeze the verdicts while
      // the player's repaired calibration is still the live one.
      function challengeFreezeCriteria() {
        var run = scope.chRun;
        if (!run || run.frozen) { return; }
        // gainPct first: the Power Hunt criteria are written against it.
        run.gainPct = (run.baselineHp > 0 && run.bestHp > 0)
          ? ((run.bestHp - run.baselineHp) / run.baselineHp * 100) : 0;
        var out = [];
        (run.criteria || []).forEach(function (c) {
          var ok = false;
          try { ok = !!c.test(scope, run); } catch (e) { ok = false; }
          out.push({ label: c.label, ok: ok });
        });
        run.critResults = out;
        run.frozen = true;
      }

      function challengeFinish(done) {
        var run = scope.chRun;
        if (!run) { return; }
        challengeFreezeCriteria();   // no-op if tutExit already froze them, which is the normal path
        scope.chRun = null;
        // A run the player walked out of before scoring is not a result - no card, no record.
        if (!run.failed && !done) { return; }
        var crit = run.critResults || [];
        var passed = 0, total = crit.length || 1;
        crit.forEach(function (c) { if (c.ok) { passed++; } });
        var medal = 'none';
        if (!run.failed) {
          if (passed >= total) { medal = 'gold'; }
          else if (passed >= total - 1) { medal = 'silver'; }
          else if (passed >= Math.ceil(total / 2)) { medal = 'bronze'; }
        }
        var prev = chBest[run.id] || null;
        var better = !prev || CH_RANK[medal] > (CH_RANK[prev.medal] || 0) ||
          (CH_RANK[medal] === (CH_RANK[prev.medal] || 0) && run.gainPct > (Number(prev.gainPct) || 0));
        if (!run.failed && better) {
          chBest[run.id] = {
            medal: medal, gainPct: Math.round(run.gainPct * 10) / 10,
            hp: Math.round(run.bestHp * 10) / 10, date: new Date().toISOString().slice(0, 10)
          };
          saveChBest();
        }
        refreshChallengeList();
        scope.chResult = {
          id: run.id, title: run.title, medal: medal, failed: run.failed, failReason: run.failReason,
          criteria: crit, passed: passed, total: total,
          gainPct: run.gainPct, baselineHp: run.baselineHp, bestHp: run.bestHp, bestRpm: run.bestRpm,
          // Point a stumped Power Hunt player at the coached version, at the exact moment they are
          // looking at a result they are not happy with.
          suggestMasterclass: run.id === 'ch_power' && (medal === 'bronze' || medal === 'none'),
          minHealth: run.minHealth, maxKnock: run.maxKnock, maxEgt: run.maxEgt,
          faultsSeen: run.faultsSeen, quizWrong: run.quizWrong, pulls: run.pulls,
          best: chBest[run.id] || null, newRecord: !run.failed && better
        };
      }

      scope.chStart = function (index) { scope.startLesson(index); };
      // The Masterclass is never locked - a player who wants the method before the test can have it.
      // It is only SURFACED harder to the person the Power Hunt has already stumped.
      scope.chMasterclassNudge = function () {
        var pb = chBest['ch_power'];
        return !pb || pb.medal === 'bronze' || pb.medal === 'none';
      };
      scope.challengeCarReady = function () { return lessonVehicleMatches(CHALLENGE_CAR); };
      scope.challengeCarHint = function () { return CHALLENGE_CAR.hint; };
      scope.closeChResult = function () { scope.chResult = null; };
      scope.chMedalLabel = function (m) {
        return m === 'gold' ? 'GOLD' : (m === 'silver' ? 'SILVER' : (m === 'bronze' ? 'BRONZE' : 'NO MEDAL'));
      };
      scope.chGainText = function (r) {
        if (!r || !(r.baselineHp > 0) || !(r.bestHp > 0)) { return 'no scored pull'; }
        return (r.gainPct >= 0 ? '+' : '') + r.gainPct.toFixed(1) + '% (' +
          r.baselineHp.toFixed(1) + ' to ' + r.bestHp.toFixed(1) + ' hp)';
      };
      // The quiz gate. A wrong option is counted ONCE - re-clicking the same wrong answer to
      // re-read its explanation must not keep punishing a player who already lost the criterion.
      scope.chAnswer = function (i) {
        var st = curStep(); if (!st || !st.quiz || !st.quiz.opts) { return; }
        var opt = st.quiz.opts[i]; if (!opt) { return; }
        scope.tutorial.quizPick = i;
        if (opt.ok) {
          scope.tutorial.quizWhy = '';
          scope.tutorial.canAdvance = true;
          return;
        }
        if (!scope.tutorial.quizTried) { scope.tutorial.quizTried = {}; }
        if (!scope.tutorial.quizTried[i]) {
          scope.tutorial.quizTried[i] = true;
          if (scope.chRun) { scope.chRun.quizWrong = (Number(scope.chRun.quizWrong) || 0) + 1; }
        }
        scope.tutorial.quizWhy = opt.why || 'Not that one - go back to the live data and look again.';
      };
      scope.chQuiz = function () { var st = curStep(); return (st && st.quiz) || null; };

      var CHAPTERS = [
        { title: 'Chapter 1 - Basics', ids: ['gauges', 'rich', 'knock', 'egt'] },
        { title: 'Chapter 2 - Making power', ids: ['dyno', 'dynopro', 'power', 'fuel', 'logread', 'vetable', 'mixturelearn'] },
        { title: 'Chapter 3 - Forced induction & motorsport', ids: ['launch', 'launchtables', 'pops', 'rotidle', 'boost', 'boostshaping', 'wgspring', 'wgdutytable', 'turbotune', 'supercharger', 'nitrous'] },
        { title: 'Chapter 4 - Diesel', ids: ['dieselbasics', 'dieselpower', 'dieselhardware', 'rollcoal'] },
        { title: 'Chapter 5 - Transmission', ids: ['transmissionbasics'] }
      ];

      var doneLessons = {};
      try { doneLessons = JSON.parse(localStorage.getItem('oep_lessons_done') || '{}') || {}; } catch (e) { doneLessons = {}; }
      function stripLessonNum(t) { return String(t || '').replace(/^Lesson\s+\d+\s*-\s*/, ''); }
      // Stage 4: lesson translations keyed by lesson id (per-entry English fallback).
      // Double-quoted values so the inline 'UI names' (single quotes) are safe.
      var LESSONL = {
        es: {
          egt: { title: "Leer el EGT (calor del motor)", desc: "Lee la temperatura de gases de escape (EGT) - el reloj de calor - y aprende qué calienta el motor (mezcla pobre, menos avance) y cómo enfriarlo.", steps: [
            "El EGT es la Temperatura de Gases de Escape: qué tan CALIENTE va el motor. Es un reloj de seguridad clave: una mezcla POBRE o el avance MUY RETRASADO calientan el escape lo suficiente para derretir pistones y válvulas; ir más RICO (y un poco más de avance) lo enfría. Vamos a verlo. (Funciona en cualquier motor.)",
            "Abre 'Valores en vivo' y busca la lectura EGT (°C) en el panel de Sensores a la derecha.",
            "Haz una pasada a fondo y mira el EGT subir con la carga. En gasolina, ~900-950°C es zona de peligro (fundición arriba de ~1050). Anota dónde queda el tuyo a fondo.",
            "Ahora hazlo correr CALIENTE a propósito. Abre 'Objetivo lambda'.",
            "Selecciona las filas de carga alta y EMPOBRÉCELAS (sube lambda hacia ~1.0 con el botón + = menos combustible), luego haz una pasada. Mira el EGT subir bastante más CALIENTE en el reloj: pobre = caliente.",
            "¿Lo viste? Ahora ENFRÍALO: enriquece esas filas (baja lambda hacia ~0.82 con el botón -) y haz otra pasada. El EGT baja: rico = frío. Esa es la palanca principal.",
            "Así que cuando el EGT suba demasiado: AGREGA COMBUSTIBLE (enriquece) y/o un poco de avance, y baja el boost si lo usas. Abre 'Ayúdame': te avisa cuando el EGT está alto. Mantenlo bajo ~900°C sostenido y el motor vive. ¡Listo!"
          ] },
          logread: { title: "Leer un registro de datos", desc: "Graba una pasada y léela: carriles apilados, la lista de parámetros del cursor, el navegador con zoom y los marcadores de eventos, para hallar exactamente qué pasó.", steps: [
            "Un registro de datos graba TODO durante una pasada completa - RPM, boost, acelerador, lambda, detonación, EGT - para que puedas retroceder y ver exactamente dónde pasó algo. Los tuners viven en el registro. Grabemos uno y leámoslo. (El registro está activo por defecto.)",
            "Haz una pasada a fondo. Para tener algo interesante que encontrar, empobrece un poco o rocía nitroso durante la pasada para que dispare un evento.",
            "Abre 'Análisis de registro' para ver los carriles apilados en vivo.",
            "Cada carril es su propio canal con su propia escala - RPM, MAP/boost, acelerador, Lambda (objetivo punteado + real), Detonación, EGT, Temperaturas - para leerlos todos a la vez sin que se encimen. Los botones de arriba muestran/ocultan carriles.",
            "Ahora abre 'Navegador de registro' para recorrerlo.",
            "HAZ CLIC Y ARRASTRA sobre los carriles para mover el cursor: la Lista de parámetros de abajo muestra el valor de cada canal en ese instante exacto. Y la tira 'Navigator' de abajo es todo el registro: arrastra la ventana para desplazarte, arrastra sus bordes para hacer zoom en la parte que te importa.",
            "Busca los MARCADORES DE EVENTO de colores - KNOCK, LEAN, EGT, OVERBOOST, etc. dejan una línea etiquetada justo donde pasaron, más una marca en la tira del Navegador. Así hallas problemas al instante: ve una marca, haz zoom, lee qué salió mal y ve a corregir esa celda. Usa 'Export CSV' para todo el registro en Excel. ¡Listo!"
          ] },
          nitrous: { title: "Usar nitroso con seguridad", desc: "Configura un shot de nitroso de forma SEGURA: hace mucha potencia pero necesita combustible extra y avance retrasado o detona y se funde.", steps: [
            "El nitroso (NOS) es un SHOT de potencia: una ráfaga de potencia extra a pedido. Pero dispara la presión y el calor del cilindro, así que NECESITA combustible extra y avance retrasado o detona y funde el motor. Vamos a configurarlo de forma segura. (Funciona en cualquier motor: atmosférico o turbo, 4 cilindros o V8.)",
            "Abre la página 'Nitrous' (en Ralentí / Carreras).",
            "Actívalo: haz clic en 'Nitrous enabled'.",
            "Empieza con un shot MODESTO. Pon 'Shot size' en ~20%. Los shots chicos son seguros para aprender; los shots SECOS grandes revientan motores.",
            "Ahora la seguridad: sube 'Extra fuel' a ~20%. El combustible extra enfría la carga y evita que el shot se empobrezca: esto es lo que lo mantiene vivo.",
            "Y retrasa algo de avance: pon 'Timing pull' en ~6-8°. El nitroso sube la presión del cilindro, así que bajas la chispa para evitar detonación.",
            "Ahora conduce: a fondo, supera las RPM de activación (por defecto 3500). El Estado dirá 'Spraying' y sentirás el salto de potencia, con la detonación y el EGT controlados porque le diste combustible y retrasaste el avance. (Avanza solo cuando rocía.)",
            "Eso hizo potencia Y se mantuvo seguro. Para ver POR QUÉ importa la preparación: pon 'Extra fuel' y 'Timing pull' en 0 y rocía otra vez: mira la detonación y el EGT dispararse (no lo sostengas). Luego devuélvelos. Eso es el nitroso: potencia gratis si lo alimentas y retrasas avance, una granada si no. ¡Listo!"
          ] },
          dieselbasics: { title: "Diésel: combustible, humo y EGT", desc: "El diésel se afina totalmente distinto a la gasolina: sin chispa ni objetivo lambda. Agregas COMBUSTIBLE para potencia hasta un LÍMITE DE HUMO, vigilando AFR, humo y EGT.", steps: [
            "El diésel se afina COMPLETAMENTE distinto a la gasolina. No hay chispa ni objetivo lambda: un diésel siempre corre pobre y ajustas la CANTIDAD DE COMBUSTIBLE hasta un LÍMITE DE HUMO, vigilando el EGT (calor). Más combustible = más potencia, pero solo el combustible que el aire puede quemar hace potencia; el resto es humo negro y calor. (Necesitas estar en un vehículo DIÉSEL: la app lo detecta sola.)",
            "Abre la página 'Diesel Tuning' (en Combustible).",
            "Mira 'Mode' abajo en la página: debe decir Running/Monitoring, NO 'Off'. Si dice Off no estás en un diésel: carga un vehículo diésel (p. ej. un Gavril D-Series o Roamer diésel) y vuelve. (Avanza solo al detectar un diésel.)",
            "Ahora abre 'Relojes en vivo'. Fíjate que no hay reloj de Lambda: en un diésel tienes AFR, HUMO y EGT. Esa es la caja de herramientas diésel.",
            "Haz una pasada a fondo y míralos. El AFR es aire/combustible: ALTO = pobre y limpio, y BAJA al agregar combustible/acelerador. Si el AFR cae bajo tu límite de humo, el Humo sube y el EGT se calienta. Un diésel limpio mantiene el AFR por encima del límite.",
            "Ahora sube el combustible a propósito. Abre 'Diesel Tuning' otra vez.",
            "Sube 'Max fuel' bastante, a ~170%. Esta es la palanca principal de potencia en un diésel: simplemente ordena más combustible.",
            "Pisa a fondo y mantén. Mira el HUMO subir y el EGT dispararse, porque el aire (sin/poco boost aún) no puede quemar todo ese combustible. Eso es 'rolling coal': mucho humo, poca potencia extra y calor peligroso. (Avanza solo cuando humea.)",
            "Ese es el corazón del diésel: el combustible hace potencia solo hasta lo que el AIRE puede quemar; pasado eso es solo humo y EGT. Próxima lección: agregar boost + presión de riel para inyectar tanto y hacer la potencia LIMPIA. Abre 'Ayúdame' cuando quieras: te avisa cuando humeas o corres caliente. ¡Listo!"
          ] },
          dieselpower: { title: "Potencia diésel: boost y presión de riel", desc: "Haz potencia diésel REAL: agrega boost (aire) y presión de riel para quemar más combustible sin humo, luego afina la sincronización de inyección.", steps: [
            "La lección pasada, tirar combustible solo hizo humo cuando el aire se acabó. Para hacer potencia REAL le das al motor más AIRE y atomizas mejor el combustible: eso es BOOST y PRESIÓN DE RIEL. Así puedes inyectar más SIN humear. (Requiere vehículo diésel.)",
            "Abre 'Diesel Tuning'. Con el Max fuel aún alto, haz una pasada y confirma que HUMEA (Humo alto, AFR bajo el límite, EGT caliente). Eso es demasiado combustible para el aire que tiene.",
            "Primero, PRESIÓN DE RIEL. Súbela hacia ~1900 bar. Más presión de riel atomiza el combustible en una niebla más fina para que se queme más limpio: sube tu margen sin humo.",
            "Ahora la grande: BOOST (aire). Abre 'Objetivo de boost'. Más boost = más aire = puede quemar más combustible limpio. Los diésel rápidos usan turbos grandes o compuestos justo por esto.",
            "Selecciona toda la tabla ('All'), luego sube el objetivo con el botón +. En un diésel, el boost es tu HABILITADOR de potencia: es lo que deja que el combustible extra haga potencia en vez de humo.",
            "Haz otra pasada y mira los relojes: con boost arriba, el AFR vuelve a subir POR ENCIMA del límite de humo aun con el combustible alto; el HUMO baja y el EGT se enfría. Mira el Dyno: ahora la potencia es REAL. Aire + combustible juntos = potencia limpia. (Avanza solo cuando lo limpias bajo carga.)",
            "Último toque: SINCRONIZACIÓN de inyección. En un diésel la tabla 'Encendido principal' es la sincronización de inyección (grados). Ábrela: un poco más de avance puede sumar potencia y bajar el EGT; demasiado vuelve áspera y ruidosa la combustión. Solo cambios pequeños.",
            "Eso es la potencia diésel: COMBUSTIBLE para potencia, BOOST + PRESIÓN DE RIEL para quemarlo limpio, SINCRONIZACIÓN de inyección para afinar, todo vigilado en AFR, Humo y EGT (nunca lambda). Mantén el AFR sobre tu límite de humo y el EGT controlado, y harás potencia grande y limpia. 'Ayúdame' te guía si humea o se calienta. ¡Listo!"
          ] },
          gauges: { title: "Leer los relojes", desc: "Lee lambda, detonación, ciclo de inyector y salud del motor como un tuner.", steps: [
            "¡Bienvenido! Esta app es una ECU en vivo. Primero encontremos las lecturas en vivo: haz clic en 'Valores en vivo' en el árbol de la izquierda.",
            "Estos son tus valores en vivo. LAMBDA es la relación aire/combustible: 1.00 = ideal, POR DEBAJO de 1.00 = rico (combustible de más), POR ENCIMA = pobre (no alcanza). Observa 'Lambda real' vs 'Objetivo lambda' mientras conduces.",
            "Ahora abre 'Relojes en vivo' para la vista de un vistazo que un tuner mira durante una pasada.",
            "Amarillo = precaución, rojo = levanta. La detonación, lambda pobre, ciclo de inyector alto y salud baja del motor te avisan aquí. Eso es lo básico: en la próxima lección arreglamos un tune malo." ] },
          rich: { title: "Arreglar un tune rico", desc: "Diagnostica un tune demasiado rico y empobrécelo hacia el objetivo para más potencia.", steps: [
            "Cargué un tune corriendo demasiado RICO: está tirando combustible, ensuciando bujías y haciendo MENOS potencia. Diagnostiquémoslo: abre 'Valores en vivo'.",
            "Conduce y verás Lambda real muy POR DEBAJO del objetivo (rico). Eso se arregla en la tabla de combustible: abre 'Combustible principal'.",
            "Las filas son carga (MAP), las columnas son RPM. Los números positivos grandes están añadiendo demasiado combustible. Selecciona toda la tabla: haz clic en 'All'.",
            "Ahora quita combustible: haz clic en el botón '-' hasta que las celdas bajen cerca de 0. (Subí 'Amt' para que cada clic quite más.) Mira las celdas pasar de calientes hacia neutro.",
            "¡Listo! El tune está de nuevo cerca del objetivo. Haz una pasada a fondo (o un dyno) y correrá más suave y hará más potencia. Rico = combustible desperdiciado; ahora sabes detectarlo y arreglarlo. ¡Lección completa!" ] },
          knock: { title: "Potencia y detonación", desc: "Añade avance de encendido para potencia y encuentra dónde empieza la detonación.", steps: [
            "Vuelve a un tune limpio. La potencia viene del AVANCE DE ENCENDIDO, pero demasiado causa DETONACIÓN que mata motores. Abre 'Encendido principal'.",
            "Selecciona toda la tabla: haz clic en 'All'.",
            "Añade unos grados de avance: haz clic en '+' unas 5 veces (~+4°). Haz una pasada de dyno y deberías hacer más potencia.",
            "Ahora pásate: sigue añadiendo avance y observa 'Nivel detonación' en Valores en vivo. Con Control de detonación ON la ECU quita avance para salvarse; apágalo (página Control de detonación) + Protección OFF y puedes reventar el motor. Ese es el filo donde vive un tuner. ¡Listo!" ] },
          dyno: { title: "Ajustar para potencia (flujo de dyno)", desc: "El flujo real: pasada base, ajusta, repite y mide la ganancia.", steps: [
            "Hora de una sesión real con un tune limpio: pasada base, ajusta, luego repite para medir la ganancia. Abre la página 'Dyno / AFR'.",
            "Haz clic en START PULL, luego en el juego pisa a fondo desde bajas RPM y mantén hasta el limitador. Esto graba tu potencia ACTUAL como base.",
            "Esa curva punteada es ahora tu base: anota el HP pico bajo el gráfico. Ese es el número a batir. Ahora hagamos potencia.",
            "La mayor ganancia segura es el avance de encendido. Abre 'Encendido principal'.",
            "Selecciona toda la tabla: haz clic en 'All'.",
            "Añade unos 3-4 grados de avance (clic en '+'). Más avance = más potencia, hasta el límite de detonación, así que no te pases. (Truco: enriquecer las celdas de carga alta hacia ~0.88 lambda añade un poco más.)",
            "Ahora mide el cambio. Vuelve a la página 'Dyno / AFR'.",
            "Haz clic en START PULL y haz una SEGUNDA pasada a fondo. Tu nueva curva sólida se superpondrá a la base punteada.",
            "¡Compara! El texto bajo el gráfico muestra tu nuevo pico y la ganancia vs la base (p. ej. +6 hp). Pasa el cursor por la curva para ver HP/par a cualquier RPM. Ese es todo el juego: mide, cambia una cosa, mide otra vez. ¡Lección completa!" ] },
          power: { title: "Más formas de hacer potencia", desc: "Más allá del avance: AFR de mejor potencia, distribución de levas (VVT) y boost.", steps: [
            "El avance no es la única palanca. Un motor es una bomba de aire: la potencia viene de AIRE + COMBUSTIBLE + CHISPA. La última lección fue chispa (avance); ahora veamos las otras: combustible (AFR de mejor potencia), levas y boost.",
            "Primero COMBUSTIBLE. Abre 'Objetivo lambda'. Lambda/AFR no es solo rico vs pobre: hay un punto dulce de potencia.",
            "La mejor POTENCIA está cerca de 12.5-13.0 AFR (lambda ~0.86-0.89) a carga alta, un poco más rico que el 14.7 'ideal'. Demasiado rico Y demasiado pobre hacen menos potencia. La carga aumenta HACIA ABAJO, así que a fondo el motor corre en la fila INFERIOR (mayor kPa): baja hasta ella, selecciónala en las columnas de RPM altas, acércala a ~0.88 y haz dyno. Encontrarás el pico, y cae a ambos lados.",
            "Ahora las LEVAS, la favorita Honda. Abre 'Control VVT'.",
            "La distribución de levas cambia cuándo abren las válvulas. Más avance de admisión llena el medio régimen, ese 'golpe' VTEC. Sube 'Intake advance' hacia 35-40°, luego haz una pasada de dyno y mira despertar el par del medio régimen.",
            "La última: BOOST (la gran palanca turbo). Abre 'Objetivo de boost'.",
            "Esta tabla guarda MAP absoluto en kPa. Cero boost manométrico es la presión barométrica en vivo (unos 101 kPa al nivel del mar); 200 kPa son unos 14.3 psi al nivel del mar. El botón kPa/PSI lo convierte usando la atmósfera en vivo. Selecciona toda la tabla: haz clic en 'All'.",
            "Ahora sube el objetivo: haz clic en '+' unas veces (cada clic añade boost). En un turbo, más boost = más aire = más potencia, mientras combustible y avance acompañen para que no detone. (En un atmosférico esto no hace boost; solo te muestra cómo lo configurarías.)",
            "Esa es la imagen completa: AIRE (boost + levas), COMBUSTIBLE (AFR de mejor potencia) y CHISPA (avance), balanceados juntos, con seguridad. En un turbo, la lección 'Control de boost' te guía a ajustar el boost con las ganancias de control. ¡Listo!" ] },
          launch: { title: "Largada (2-step)", desc: "Configura un limitador de largada 2-step (más anti-lag en turbos) para una salida fuerte.", steps: [
            "Las largadas usan un limitador 2-step: mantiene el motor a unas RPM fijas mientras estás en posición, para salir en la zona de potencia en vez de ahogarte. Configuremos uno: abre 'Limitadores'.",
            "Activa '2-step launch enabled'.",
            "Pon el 'Launch limit' donde el auto sale mejor, normalmente 3500-5000 rpm para uno de calle, más alto para uno preparado. Arrastra el deslizador y luego lo probamos.",
            "Autos TURBO: abre también 'Anti-Lag' y actívalo; mantiene el turbo soplando en la línea para salir ya con boost. Los atmosféricos pueden saltarse esto.",
            "Ahora pruébalo en el juego: mantén el freno, pisa a fondo; las vueltas se sostienen y rebotan en el 2-step. Suelta el freno y sal. Si patina, baja las RPM de largada; si se ahoga, súbelas. Un 2-step de gasolina bien ajustado puede sonar y lanzar llamas sin soltar humo negro; el perfil visual no mide la calidad de la largada. ¡Listo!" ] },
          boost: { title: "Afinar el boost (turbo)", desc: "Autos turbo: pon un objetivo de boost y ajusta las ganancias para que el boost llegue limpio al objetivo.", steps: [
            "Autos TURBO (los atmosféricos pueden leer). La tabla Objetivo de boost define CUÁNTO boost; Control de boost decide CÓMO se entrega. Abre 'Objetivo de boost'.",
            "Los objetivos son MAP absoluto. Cero boost manométrico equivale a la presión barométrica en vivo; cerca del nivel del mar 200 kPa son unos 14.3 psi. En altura, el mismo objetivo absoluto representa más boost manométrico, así que usa kPa/PSI y mira la presión barométrica. Luego abre 'Control de boost'.",
            "Pon 'Boost mode' en Closed loop para que la ECU persiga tu objetivo (Open loop = solo ciclo base, sin corrección). El ciclo base es el punto de partida en lazo abierto.",
            "Ahora ajusta las ganancias en varias pasadas: sube 'P gain' para apretar el control y recortar el pico de soplado, pero demasiado hace que el boost OSCILE alrededor del objetivo; 'I gain' elimina el error estable para que quede justo en el objetivo. Un pico pequeño al pisar desde bajas rpm es soplado de turbo normal.",
            "Haz pasadas mirando Boost (MAP) vs objetivo en Valores en vivo. Meta: el boost sube suave y se queda en el objetivo sin pico ni oscilación. Ese es un tune de boost afinado. ¡Listo!" ] },
          turbotune: { title: "Construir un tune turbo seguro", desc: "La grande: une boost, combustible y avance para un tune turbo suave y seguro.", steps: [
            "Esta es la lección cumbre: hacer que un TURBO corra suave y seguro balanceando tres cosas juntas: BOOST (cuánto aire), COMBUSTIBLE (qué tan rico) y AVANCE (chispa). En armonía hace potencia sin dañar el motor. (Mejor con turbo; los atmosféricos pueden leer.)",
            "Empieza por el BOOST. Abre 'Objetivo de boost'. La tabla usa kPa absolutos: cero boost manométrico es la presión barométrica en vivo; cerca del nivel del mar 150 kPa son ~7 psi y 200 kPa ~14.3 psi. Más no es mejor en un bloque de serie.",
            "Selecciona toda la tabla: haz clic en 'All'.",
            "Pon un objetivo MODESTO y PLANO: escribe 150 y haz clic en 'Fill'. Es el más fácil de controlar; 150 kPa son unos 7 psi cerca del nivel del mar y la vista PSI muestra el valor corregido por altura. Una línea plana aquí es simplemente tu objetivo según RPM.",
            "Ahora CÓMO se entrega ese objetivo. Abre 'Control de boost'. La tabla Objetivo de boost es la META; Control de boost es la estrategia del wastegate para alcanzarla. Closed loop persigue el objetivo; el P gain es qué tan fuerte empuja.",
            "Haz una pasada mirando Boost (MAP) perseguir tu objetivo de 150. Un pico pequeño al pisar desde BAJAS rpm es soplado normal. SUBE 'P gain' para apretar el control y achicar ese pico; si te pasas, el boost OSCILA alrededor del objetivo, baja entonces. Desde rpm ya sopladas debe quedar plano en 150. Busca el P más alto que siga suave.",
            "Ahora COMBUSTIBLE. Abre 'Objetivo lambda'. Con boost corres más RICO por seguridad: el combustible extra enfría la cámara y resiste la detonación.",
            "Selecciona las filas inferiores (carga alta), ahí vives con boost, y bájalas a unos 0.80 (− = más rico). Pobre con boost es lo que mata motores turbo, así que peca de rico aquí.",
            "Por último el AVANCE. Abre 'Encendido principal'. El boost mete más aire, lo que sube la presión de cilindro y el riesgo de detonación, así que quitas un poco de avance donde tienes boost.",
            "Selecciona las celdas de carga alta / RPM altas y quita 2-3° (clic en −). Menos avance con boost = más seguro. Siempre puedes devolver un poco si una pasada no muestra detonación y quieres más.",
            "Ahora haz una pasada completa y comprueba que todo encajó: el boost sube suave a ~150 (sin pico), lambda está rico (~0.80) con boost y SIN detonación. Pulsa 'Ayúdame'; debería leer sano. Ese es todo el arte: boost, combustible y avance balanceados para que haga potencia Y viva. ¡Listo!" ] },
          fuel: { title: "Combustible: inyectores y E85", desc: "Dimensiona tus inyectores y ve por qué el E85 necesita más combustible: las tablas apuntan la mezcla, el sistema de combustible la entrega.", steps: [
            "Tus tablas de combustible y lambda fijan el OBJETIVO de AFR, pero los inyectores tienen que entregarlo físicamente. Abre 'Config. combustible' y busca la lectura 'Ciclo inyector'.",
            "Puse inyectores pequeños a propósito. Haz una pasada a fondo y mira subir 'Ciclo inyector'. Por encima de ~90% te estás QUEDANDO sin inyector: no puede fluir suficiente combustible, así que el motor se empobrece arriba.",
            "Pon inyectores más grandes: sube 'Injector flow' hasta que el ciclo tenga margen (apunta a menos de ~90% a fondo).",
            "El ciclo bajó; ahora los inyectores acompañan y mantiene tu objetivo. Haz otra pasada para confirmar que no se empobrece arriba.",
            "Ahora cambia el combustible: abre el selector 'Tipo combustible' y elige E85. Resiste la detonación (puedes correr más avance/boost) y hace más potencia, pero necesita ~50% más combustible.",
            "Mira SALTAR 'Ciclo inyector' con E85; puede que te quedes sin inyector otra vez. Dimensiónalos para que el ciclo quede bajo ~90% a fondo. Eso es dimensionar el sistema de combustible: las tablas apuntan la mezcla, los inyectores y el combustible deciden si puedes alcanzarla. ¡Listo!" ] }
        }
      };
      LESSONL.es.vetable = {
        title: "Densidad por velocidad: afinar una tabla VE",
        desc: "Trabaja en una copia temporal, aprende qué representa la VE y usa datos nuevos del Mapa de mezcla para llevar la lambda medida al objetivo con seguridad.",
        steps: [
          "Esta lección crea una copia de trabajo temporal y restaura tu calibración y los datos del Mapa de mezcla al salir. Antes de conducir, confirma que partes de un tune seguro, el motor está caliente, el tipo de combustible y el AFR estequiométrico son correctos, el caudal y el tiempo muerto de los inyectores están bien configurados, los sensores MAP y wideband leen bien, la presión de combustible es estable y el ciclo de inyector tiene margen.",
          "Abre 'Config. combustible' y cambia la ecuación de combustible a 'Modelado'. En este modo la tabla VE es la base del cálculo de combustible.",
          "Revisa 'Objetivo lambda'. Esta tabla indica la mezcla que quieres; la tabla VE modela cuánto aire atrapa realmente el motor para que la ECU pueda entregar esa mezcla. Una VE superior al 100% puede ser válida en un motor con buena eficiencia de llenado o sobrealimentación; no es un porcentaje de acelerador ni un límite duro.",
          "Abre 'Tabla VE'. El punto de operación mezcla cuatro celdas vecinas, así que importa la forma suave de la superficie. Elige una fila alcanzable que sea la más cercana a la presión barométrica en vivo sin superarla, selecciona una zona de RPM medias y pulsa '+2%' tres veces. Ese aumento de aproximadamente 6% debería crear un error rico pequeño y seguro.",
          "Abre 'Lambda lazo cerrado', pon el modo CLL en 'Apagado' y confirma que el estado CLL también indique Off. Así verificas el cambio de la tabla base sin que la corrección en lazo cerrado lo oculte.",
          "Conduce de forma estable con carga moderada o alta por la zona que cambiaste. La lambda real debería quedar un poco por debajo del objetivo, es decir, más rica. Levanta de inmediato si ves detonación, presión de combustible baja, ciclo de inyector excesivo, temperaturas peligrosas o cualquier comportamiento inseguro.",
          "Abre 'Mapa de mezcla' y deja Learn en 'Auto'. Reúne primero datos estables; haz pasadas limpias solo si el auto está seguro y tienes espacio. 'Accepted' son muestras útiles, 'Skipped' son cuadros normales que no cumplían las condiciones y 'Rejected' indica una lectura lambda inválida o atípica. El estado muestra el motivo exacto cuando no aprende.",
          "Compara 'Correction %', 'VE delta' y 'Suggested VE'; son tres vistas de la misma recomendación. Usa 'Apply 50% - re-drive' solo cuando haya celdas Ready. Solo se modifican esas celdas, los datos usados se consumen y debes volver a conducir para reunir evidencia nueva antes de aplicar otra vez.",
          "Vuelve a pasar por la zona y confirma que la lambda medida se acercó al objetivo. Repite con cambios parciales y datos nuevos, luego suaviza cualquier escalón de la tabla sin borrar una forma real del motor. Al salir de la lección se restaurarán tu calibración y tus datos anteriores del Mapa de mezcla."
        ]
      };
      LESSONL.es.mixturelearn = {
        title: "Usar el Mapa de mezcla con seguridad",
        desc: "Aprende qué acepta, qué rechaza y cómo aplicar solo una parte de una corrección a Combustible principal o a la tabla VE.",
        steps: [
          "Esta lección usa una copia temporal y restaura tu calibración y los datos de aprendizaje al salir. Empieza con un tune seguro, motor caliente, combustible y AFR estequiométrico correctos, inyectores bien configurados, MAP y wideband confiables, presión de combustible estable y suficiente margen de ciclo de inyector.",
          "Elige primero la estrategia base. En modo 'Tradicional', el Mapa de mezcla aplica a 'Combustible principal'. En modo 'Modelado', aplica a 'Tabla VE'. Abre la tabla que corresponde y comprueba la insignia 'Applying to' para saber exactamente qué mapa se cambiará.",
          "En esa tabla, elige una zona alcanzable de carga moderada o alta y RPM medias. Pulsa '+2%' tres veces para subirla cerca de 6%. Eso añade un error rico pequeño y controlado que el Mapa de mezcla podrá detectar.",
          "Abre 'Lambda lazo cerrado', pon CLL en 'Apagado' y verifica que el estado CLL también diga Off. Para esta prueba queremos medir el efecto de la tabla base sin una corrección automática que lo esconda.",
          "Conduce de forma estable por la zona modificada. La lambda real debería quedar un poco por debajo del objetivo. Levanta si aparece detonación, presión de combustible baja, ciclo de inyector alto, temperatura peligrosa o cualquier señal de que el tune no es seguro.",
          "Abre 'Mapa de mezcla' y deja Learn en 'Auto'. Mantén primero RPM, MAP y TPS estables para obtener evidencia limpia. Solo usa pasadas a fondo cuando el auto sea seguro, empezando antes de la zona que quieres aprender y evitando cambios de marcha, el limitador y el enriquecimiento de entrada de acelerador.",
          "Mira los estados mientras conduces. 'Accepted' suma muestras útiles; 'Skipped' es normal cuando un cuadro no cumple las condiciones; 'Rejected' se reserva para datos lambda inválidos o atípicos. Las celdas Learning muestran evidencia provisional. Esa evidencia vence después de 30 minutos y una celda aprendida solo durante pasadas necesita dos pasadas limpias que coincidan antes de quedar Ready.",
          "Revisa las tres vistas y pulsa 'Apply 50% - re-drive' solo cuando existan celdas Ready. El botón aplica la mitad de la corrección únicamente a esas celdas del mapa indicado por la insignia, consume la evidencia usada y obliga a volver a conducir antes de otra aplicación.",
          "Vuelve a recorrer la misma zona y confirma que la lambda se acercó al objetivo. Repite con datos nuevos y suaviza solo los escalones artificiales, sin borrar cambios reales de la superficie. Al salir se restaurarán tu calibración y tus datos de aprendizaje originales."
        ]
      };
      LESSONL.es.tour_ve = {
        title: "Tabla VE",
        steps: [
          "VE es eficiencia volumétrica: el modelo de aire atrapado del motor según RPM y carga. En modo Modelado, la ECU combina MAP, datos del motor, Objetivo lambda y el valor VE interpolado para calcular combustible. Objetivo lambda pide una mezcla; VE describe el aire necesario, y valores superiores al 100% pueden ser totalmente válidos.",
          "El punto en vivo mezcla cuatro celdas vecinas. Si el modelo VE es demasiado bajo, la lambda queda pobre; si es demasiado alto, queda rica. Afina una superficie suave con datos de wideband válidos y comprueba siempre si CLL está Off o aplicando una corrección conocida.",
          "Este recorrido no cambia tu calibración. Usa la lección completa de VE para una prueba guiada con un error rico pequeño en una copia temporal, comprobaciones previas, corrección parcial y restauración automática al salir."
        ]
      };
      LESSONL.es.tour_mixture_map = {
        title: "Mapa de mezcla",
        steps: [
          "La insignia 'Applying to' indica el mapa base que recibirá la corrección: Combustible principal en modo Tradicional o Tabla VE en modo Modelado. Antes de aprender, confirma un tune seguro, motor caliente, sensores y sistema de combustible correctos y el estado de CLL que esperas.",
          "Auto acepta conducción estable y pasadas limpias que cumplen sus filtros. 'Accepted' son muestras útiles; 'Skipped' es normal para cuadros no elegibles; 'Rejected' significa una medición inválida o atípica. Las celdas Learning muestran evidencia provisional y Ready indica que ya se puede aplicar.",
          "La recomendación usa la relación entre lambda medida antes del feedback y Objetivo lambda; si solo existe una lectura después del trim, incluye el factor CLL conocido. Cada muestra se reparte entre las cuatro celdas interpoladas y las celdas aprendidas solo en pasadas necesitan dos pasadas que coincidan.",
          "'Apply 50% - re-drive' cambia solo las celdas Ready del mapa indicado, usa la mitad de la corrección y consume esa evidencia. Los datos vencen después de 30 minutos y una edición manual de la tabla base invalida muestras anteriores; vuelve a conducir y verifica antes de aplicar otra vez."
        ]
      };
      LESSONL.fr = LESSONL.fr || {};
      LESSONL.fr.vetable = {
        title: "Calcul en densité-vitesse : régler une table VE",
        desc: "Travaillez sur une copie temporaire, comprenez ce que représente la VE et utilisez de nouvelles données de la Carte de mélange pour ramener la lambda mesurée sur la cible en sécurité.",
        steps: [
          "Cette leçon crée une copie de travail temporaire et restaure votre calibration ainsi que les données de la Carte de mélange à la sortie. Avant de rouler, confirmez que le tune de départ est sûr, que le moteur est chaud, que le carburant et l'AFR stœchiométrique sont corrects, que le débit et le temps mort des injecteurs sont bien réglés, que le MAP et la sonde large bande lisent correctement, que la pression de carburant est stable et que le cycle injecteur garde de la marge.",
          "Ouvrez 'Config. carburant' et passez l'équation de carburant sur 'Modélisé'. Dans ce mode, la table VE est la base du calcul de carburant.",
          "Consultez 'Cible lambda'. Cette table indique le mélange demandé ; la table VE modélise la quantité d'air réellement emprisonnée par le moteur pour que l'ECU puisse fournir ce mélange. Une VE supérieure à 100 % peut être valide avec un très bon remplissage ou une suralimentation ; ce n'est ni un pourcentage d'accélérateur ni une limite stricte.",
          "Ouvrez 'Table VE'. Le point de fonctionnement mélange quatre cellules voisines, donc la régularité de la surface compte. Choisissez une ligne accessible, la plus proche possible de la pression barométrique en direct sans la dépasser, sélectionnez une zone de mi-régime et appuyez trois fois sur '+2%'. Cette hausse d'environ 6 % doit créer une petite erreur riche sans danger.",
          "Ouvrez 'Lambda boucle fermée', réglez le mode CLL sur 'Désactivé' et confirmez que l'état CLL indique aussi Off. Vous pourrez ainsi vérifier la modification de la table de base sans qu'une correction en boucle fermée la masque.",
          "Roulez de façon stable à charge moyenne ou élevée dans la zone modifiée. La lambda mesurée doit passer légèrement sous la cible, donc devenir plus riche. Levez immédiatement le pied en cas de cliquetis, de pression de carburant basse, de cycle injecteur excessif, de température dangereuse ou de tout autre comportement risqué.",
          "Ouvrez 'Carte de mélange' et laissez Learn sur 'Auto'. Recueillez d'abord des données stables ; ne faites des passages propres que si la voiture est sûre et que vous avez assez de place. 'Accepted' compte les échantillons utiles, 'Skipped' correspond aux trames normales qui ne remplissaient pas les conditions et 'Rejected' signale une mesure lambda invalide ou aberrante. L'état affiche la raison exacte lorsqu'aucun apprentissage n'a lieu.",
          "Comparez 'Correction %', 'VE delta' et 'Suggested VE' ; ce sont trois vues de la même recommandation. Utilisez 'Apply 50% - re-drive' seulement lorsqu'il existe des cellules Ready. Seules ces cellules sont modifiées, les données utilisées sont consommées et il faut rouler de nouveau pour créer des données fraîches avant une autre application.",
          "Repassez dans la zone et confirmez que la lambda mesurée s'est rapprochée de la cible. Répétez avec des corrections partielles et des données fraîches, puis lissez les marches artificielles sans effacer une forme réelle du moteur. Votre calibration et vos anciennes données de Carte de mélange seront restaurées à la sortie de la leçon."
        ]
      };
      LESSONL.fr.mixturelearn = {
        title: "Utiliser la Carte de mélange en sécurité",
        desc: "Apprenez ce qu'elle accepte, ce qu'elle rejette et comment appliquer seulement une partie d'une correction à Carburant principal ou à la table VE.",
        steps: [
          "Cette leçon utilise une copie temporaire et restaure votre calibration ainsi que les données d'apprentissage à la sortie. Commencez avec un tune sûr, un moteur chaud, le bon carburant et le bon AFR stœchiométrique, des injecteurs correctement réglés, un MAP et une sonde large bande fiables, une pression de carburant stable et assez de marge de cycle injecteur.",
          "Choisissez d'abord la stratégie de base. En mode 'Traditionnel', la Carte de mélange applique ses corrections à 'Carburant principal'. En mode 'Modélisé', elle les applique à 'Table VE'. Ouvrez la table correspondante et vérifiez le badge 'Applying to' pour savoir exactement quelle cartographie sera modifiée.",
          "Dans cette table, choisissez une zone accessible de charge moyenne ou élevée et de mi-régime. Appuyez trois fois sur '+2%' pour l'augmenter d'environ 6 %. Cela crée une petite erreur riche contrôlée que la Carte de mélange pourra détecter.",
          "Ouvrez 'Lambda boucle fermée', réglez CLL sur 'Désactivé' et vérifiez que l'état CLL affiche aussi Off. Pour cet essai, nous voulons mesurer l'effet de la table de base sans qu'une correction automatique le masque.",
          "Roulez de façon stable dans la zone modifiée. La lambda mesurée doit passer légèrement sous la cible. Levez le pied en cas de cliquetis, de pression de carburant basse, de cycle injecteur élevé, de température dangereuse ou de tout signe indiquant que le tune n'est pas sûr.",
          "Ouvrez 'Carte de mélange' et laissez Learn sur 'Auto'. Commencez par maintenir RPM, MAP et TPS stables pour recueillir des données propres. Ne faites des passages à pleine charge que si la voiture est sûre, en partant avant la zone à apprendre et en évitant les changements de rapport, le limiteur et l'enrichissement de reprise.",
          "Observez les états pendant que vous roulez. 'Accepted' ajoute des échantillons utiles ; 'Skipped' est normal lorsqu'une trame ne remplit pas les conditions ; 'Rejected' est réservé aux données lambda invalides ou aberrantes. Les cellules Learning affichent des données provisoires. Ces données expirent après 30 minutes et une cellule apprise uniquement pendant des passages exige deux passages propres concordants avant de devenir Ready.",
          "Consultez les trois vues et appuyez sur 'Apply 50% - re-drive' uniquement lorsqu'il existe des cellules Ready. Le bouton applique la moitié de la correction à ces seules cellules de la cartographie indiquée par le badge, consomme les données utilisées et impose de rouler de nouveau avant une autre application.",
          "Repassez dans la même zone et confirmez que la lambda s'est rapprochée de la cible. Répétez avec des données fraîches et lissez seulement les marches artificielles sans effacer les variations réelles de la surface. Votre calibration et vos données d'apprentissage d'origine seront restaurées à la sortie."
        ]
      };
      LESSONL.fr.tour_ve = {
        title: "Table VE",
        steps: [
          "VE signifie rendement volumétrique : c'est le modèle de l'air emprisonné par le moteur selon le régime et la charge. En mode Modélisé, l'ECU combine MAP, les données moteur, Cible lambda et la VE interpolée pour calculer le carburant. Cible lambda demande un mélange ; la VE décrit l'air nécessaire, et des valeurs supérieures à 100 % peuvent être parfaitement valides.",
          "Le point en direct mélange quatre cellules voisines. Si le modèle VE est trop bas, la lambda devient pauvre ; s'il est trop haut, elle devient riche. Réglez une surface régulière avec des données large bande valides et vérifiez toujours si CLL est Off ou applique une correction connue.",
          "Cette visite ne modifie pas votre calibration. Utilisez la leçon VE complète pour un essai guidé avec une petite erreur riche sur une copie temporaire, des contrôles préalables, une correction partielle et une restauration automatique à la sortie."
        ]
      };
      LESSONL.fr.tour_mixture_map = {
        title: "Carte de mélange",
        steps: [
          "Le badge 'Applying to' indique la cartographie de base qui recevra la correction : Carburant principal en mode Traditionnel ou Table VE en mode Modélisé. Avant l'apprentissage, confirmez un tune sûr, un moteur chaud, des capteurs et un circuit de carburant corrects, ainsi que l'état CLL attendu.",
          "Auto accepte la conduite stable et les passages propres qui respectent ses filtres. 'Accepted' compte les échantillons utiles ; 'Skipped' est normal pour les trames non admissibles ; 'Rejected' indique une mesure invalide ou aberrante. Les cellules Learning affichent des données provisoires et Ready signifie qu'une correction peut être appliquée.",
          "La recommandation utilise le rapport entre la lambda mesurée avant feedback et Cible lambda ; si seule une mesure après trim existe, elle inclut le facteur CLL connu. Chaque échantillon est réparti entre les quatre cellules interpolées et les cellules apprises uniquement en passage exigent deux passages concordants.",
          "'Apply 50% - re-drive' modifie seulement les cellules Ready de la cartographie indiquée, applique la moitié de la correction et consomme ces données. Les données expirent après 30 minutes et une modification manuelle de la table de base invalide les anciens échantillons ; roulez de nouveau et vérifiez avant une autre application."
        ]
      };
      LESSONL.pt = LESSONL.pt || {};
      LESSONL.pt.vetable = {
        title: "Densidade por velocidade: ajustar uma tabela VE",
        desc: "Trabalhe em uma cópia temporária, entenda o que a VE representa e use dados novos do Mapa de mistura para levar o lambda medido até o alvo com segurança.",
        steps: [
          "Esta lição cria uma cópia de trabalho temporária e restaura sua calibração e os dados do Mapa de mistura ao sair. Antes de dirigir, confirme que o tune base é seguro, o motor está quente, o combustível e o AFR estequiométrico estão corretos, a vazão e o deadtime dos injetores estão bem configurados, os sensores MAP e wideband leem corretamente, a pressão de combustível está estável e o ciclo dos injetores tem margem.",
          "Abra 'Config. combustível' e mude a equação de combustível para 'Modelado'. Nesse modo, a Tabela VE é a base do cálculo de combustível.",
          "Confira 'Alvo lambda'. Essa tabela informa a mistura desejada; a Tabela VE modela quanto ar o motor realmente retém para a ECU conseguir entregar essa mistura. Uma VE acima de 100% pode ser válida em um motor com bom enchimento ou sobrealimentação; ela não é uma porcentagem de acelerador nem um limite rígido.",
          "Abra 'Tabela VE'. O ponto de operação mistura quatro células vizinhas, então uma superfície suave é importante. Escolha uma linha alcançável que fique o mais perto possível da pressão barométrica ao vivo sem passar dela, selecione uma faixa de RPM média e clique em '+2%' três vezes. Esse aumento de cerca de 6% deve criar um pequeno erro rico com segurança.",
          "Abra 'Lambda malha fechada', coloque o modo CLL em 'Desligado' e confirme que o estado CLL também mostra Off. Assim você verifica a mudança da tabela base sem uma correção em malha fechada escondendo o resultado.",
          "Dirija de forma estável em carga moderada ou alta pela região alterada. O lambda medido deve ficar um pouco abaixo do alvo, ou seja, mais rico. Tire o pé imediatamente se houver detonação, baixa pressão de combustível, ciclo de injetor excessivo, temperatura perigosa ou qualquer comportamento inseguro.",
          "Abra 'Mapa de mistura' e deixe Learn em 'Auto'. Colete primeiro dados estáveis; faça puxadas limpas somente se o carro estiver seguro e houver espaço. 'Accepted' conta amostras úteis, 'Skipped' são quadros normais que não atenderam às condições e 'Rejected' indica uma leitura lambda inválida ou fora do padrão. O status mostra o motivo exato quando não há aprendizado.",
          "Compare 'Correction %', 'VE delta' e 'Suggested VE'; são três visualizações da mesma recomendação. Use 'Apply 50% - re-drive' somente quando houver células Ready. Apenas essas células são alteradas, os dados usados são consumidos e você precisa dirigir novamente para coletar evidências novas antes de outra aplicação.",
          "Passe novamente pela região e confirme que o lambda medido chegou mais perto do alvo. Repita com correções parciais e dados novos, depois suavize degraus artificiais sem apagar uma forma real do motor. Sua calibração e os dados anteriores do Mapa de mistura serão restaurados ao sair da lição."
        ]
      };
      LESSONL.pt.mixturelearn = {
        title: "Usar o Mapa de mistura com segurança",
        desc: "Aprenda o que ele aceita, o que rejeita e como aplicar somente parte de uma correção ao Combustível principal ou à Tabela VE.",
        steps: [
          "Esta lição usa uma cópia temporária e restaura sua calibração e os dados de aprendizado ao sair. Comece com um tune seguro, motor quente, combustível e AFR estequiométrico corretos, injetores bem configurados, MAP e wideband confiáveis, pressão de combustível estável e margem suficiente no ciclo dos injetores.",
          "Escolha primeiro a estratégia base. No modo 'Tradicional', o Mapa de mistura aplica em 'Combustível principal'. No modo 'Modelado', aplica em 'Tabela VE'. Abra a tabela correspondente e confira o indicador 'Applying to' para saber exatamente qual mapa será alterado.",
          "Nessa tabela, escolha uma região alcançável de carga moderada ou alta e RPM média. Clique em '+2%' três vezes para aumentá-la em cerca de 6%. Isso cria um pequeno erro rico e controlado que o Mapa de mistura consegue detectar.",
          "Abra 'Lambda malha fechada', coloque CLL em 'Desligado' e confirme que o estado CLL também mostra Off. Neste teste queremos medir o efeito da tabela base sem uma correção automática escondendo o resultado.",
          "Dirija de forma estável pela região alterada. O lambda medido deve ficar um pouco abaixo do alvo. Tire o pé se aparecer detonação, baixa pressão de combustível, ciclo de injetor alto, temperatura perigosa ou qualquer sinal de que o tune não está seguro.",
          "Abra 'Mapa de mistura' e deixe Learn em 'Auto'. Primeiro mantenha RPM, MAP e TPS estáveis para coletar dados limpos. Faça puxadas de aceleração total somente com o carro seguro, começando antes da região que deseja aprender e evitando trocas de marcha, limitador e enriquecimento de entrada do acelerador.",
          "Acompanhe os estados enquanto dirige. 'Accepted' soma amostras úteis; 'Skipped' é normal quando um quadro não atende às condições; 'Rejected' é reservado para dados lambda inválidos ou fora do padrão. As células Learning mostram evidência provisória. Essa evidência expira depois de 30 minutos e uma célula aprendida somente em puxadas precisa de duas puxadas limpas que concordem antes de ficar Ready.",
          "Confira as três visualizações e clique em 'Apply 50% - re-drive' somente quando houver células Ready. O botão aplica metade da correção apenas a essas células do mapa indicado, consome os dados usados e exige que você dirija novamente antes de outra aplicação.",
          "Passe de novo pela mesma região e confirme que o lambda chegou mais perto do alvo. Repita com dados novos e suavize somente degraus artificiais, sem apagar mudanças reais da superfície. Sua calibração e os dados de aprendizado originais serão restaurados ao sair."
        ]
      };
      LESSONL.pt.tour_ve = {
        title: "Tabela VE",
        steps: [
          "VE é eficiência volumétrica: o modelo do ar retido pelo motor em cada RPM e carga. No modo Modelado, a ECU combina MAP, dados do motor, Alvo lambda e o valor VE interpolado para calcular combustível. Alvo lambda pede uma mistura; VE descreve o ar necessário, e valores acima de 100% podem ser totalmente válidos.",
          "O ponto ao vivo mistura quatro células vizinhas. Se o modelo VE estiver baixo demais, o lambda fica pobre; se estiver alto demais, fica rico. Ajuste uma superfície suave com dados de wideband válidos e sempre confira se CLL está Off ou aplicando uma correção conhecida.",
          "Este tour não altera sua calibração. Use a lição completa de VE para um teste guiado com um pequeno erro rico em uma cópia temporária, verificações prévias, correção parcial e restauração automática ao sair."
        ]
      };
      LESSONL.pt.tour_mixture_map = {
        title: "Mapa de mistura",
        steps: [
          "O indicador 'Applying to' mostra o mapa base que receberá a correção: Combustível principal no modo Tradicional ou Tabela VE no modo Modelado. Antes do aprendizado, confirme um tune seguro, motor quente, sensores e sistema de combustível corretos e o estado CLL esperado.",
          "Auto aceita condução estável e puxadas limpas que passam pelos filtros. 'Accepted' conta amostras úteis; 'Skipped' é normal para quadros não elegíveis; 'Rejected' significa uma medição inválida ou fora do padrão. As células Learning mostram dados provisórios e Ready indica que já é possível aplicar.",
          "A recomendação usa a razão entre o lambda medido antes do feedback e o Alvo lambda; se houver somente uma leitura depois do trim, inclui o fator CLL conhecido. Cada amostra é dividida entre as quatro células interpoladas e células aprendidas somente em puxadas precisam de duas puxadas que concordem.",
          "'Apply 50% - re-drive' altera somente as células Ready do mapa indicado, usa metade da correção e consome esses dados. A evidência expira depois de 30 minutos e uma edição manual da tabela base invalida amostras anteriores; dirija novamente e verifique antes de aplicar outra vez."
        ]
      };
      function lessonL(id) { return (scope.lang !== 'en' && LESSONL[scope.lang]) ? LESSONL[scope.lang][id] : null; }
      scope.lTitle = function (l) { var t = lessonL(l.id); return (t && t.title) || l.title; };
      scope.lDesc = function (l) { var t = lessonL(l.id); return (t && t.desc) || l.desc; };
      function refreshLessonList() {
        var byId = {};
        LESSONS.forEach(function (l, i) {
          byId[l.id] = { index: i, id: l.id, title: stripLessonNum(l.title), difficulty: l.difficulty, desc: l.desc, done: !!doneLessons[l.id], requiresTurbo: !!l.requiresTurbo, gasolineOnly: !!l.gasolineOnly };
        });
        // flat list still drives the "N/total complete" count. Challenges live in the same
        // LESSONS array (so they inherit the whole engine) but are scored on their own tab -
        // counting them here would tell a player they had lessons left that no chapter lists.
        scope.lessons = [];
        LESSONS.forEach(function (l, i) {
          if (!l.isChallenge) { scope.lessons.push({ index: i, id: l.id, done: !!doneLessons[l.id] }); }
        });
        refreshChallengeList();
        // grouped into chapters for the picker
        scope.chapters = CHAPTERS.map(function (ch) {
          var items = [], doneN = 0;
          ch.ids.forEach(function (id) { if (byId[id]) { items.push(byId[id]); if (byId[id].done) { doneN++; } } });
          return { title: ch.title, lessons: items, doneN: doneN, total: items.length };
        });
      }
      refreshLessonList();
      scope.lessonsDone = function () { var n = 0; for (var i = 0; i < scope.lessons.length; i++) { if (scope.lessons[i].done) { n++; } } return n; };
      scope.lessonAvailable = function (l) {
        var lesson = l && isFinite(Number(l.index)) ? LESSONS[Number(l.index)] : null;
        if (!lesson) { return true; }
        if (lesson.requiresTurbo && !(scope.base && scope.base.hasTurbocharger === true)) { return false; }
        if (lesson.gasolineOnly && scope.t && scope.t.isDiesel === true) { return false; }
        if (lesson.requiredVehicle && !lessonVehicleMatches(lesson.requiredVehicle)) { return false; }
        return true;
      };
      scope.lessonRequirement = function (l) {
        if (scope.lessonAvailable(l)) { return ''; }
        var lesson = l && isFinite(Number(l.index)) ? LESSONS[Number(l.index)] : null;
        if (lesson && lesson.requiredVehicle && !lessonVehicleMatches(lesson.requiredVehicle)) {
          // Per-entry hint: this used to hard-code the Bastion line, so the second exercise to
          // declare a requiredVehicle would have told players to load the wrong car entirely.
          return lesson.requiredVehicle.hint ||
            ('Load ' + (lesson.requiredVehicle.label || 'the required vehicle') + ' before starting.');
        }
        if (lesson && lesson.gasolineOnly && scope.t && scope.t.isDiesel === true) { return 'Requires a gasoline turbo engine; diesel uses a different AFR/smoke safety workflow.'; }
        return 'Requires a turbocharged vehicle with a controllable wastegate.';
      };

      // Whole-table write, clamped to the table's own meta and resolution-agnostic (6x7 through
      // 16x16). valueFor returning null leaves that cell alone, which is how a fault can be aimed
      // at just the boosted rows without disturbing cruise.
      function chFillMap(name, valueFor) {
        if (!scope.maps || !scope.maps[name]) { return; }
        var meta = (scope.mapMeta && scope.mapMeta[name]) || null;
        scope.maps[name].forEach(function (row, r) {
          var out = row.slice(), changed = false;
          for (var c = 0; c < out.length; c++) {
            var v = valueFor(r, c, out[c]);
            if (v === null || v === undefined) { continue; }
            v = Number(v);
            if (!isFinite(v)) { continue; }
            if (meta) { v = Math.max(Number(meta.min), Math.min(Number(meta.max), v)); }
            if (out[c] !== v) { out[c] = v; changed = true; }
          }
          if (!changed) { return; }
          scope.maps[name][r] = out;
          sendMapRow(name, r, out);
        });
      }

      // Every challenge starts from applyPreset("stock") - factory tables, protection and knock
      // control on, damage cleared - and then injects exactly ONE fault. One fault is the whole
      // point: a player who finds two is guessing, and a run that starts on a half-hurt engine is
      // not a fair score. Command order is safe because callController is FIFO into the vehicle
      // VM: the preset lands first, the fault second. On the UI side pendingMapEcho pins the rows
      // this writes, so the incoming stock echo repaints only the rows the fault did not claim.
      function loadChallengeTune(kind) {
        // Edit step size: the two bulk-reduction repairs start the player on a coarse 'Amt' so
        // undoing a whole-table fault is a handful of clicks, not forty. Same courtesy the rich
        // lesson already extends. They can drop it again for fine work afterwards.
        scope.editAmount = (kind === 'ch_rich' || kind === 'ch_timing') ? 5 : 2;
        callController('applyPreset("stock")');
        function chSet(key, value) {
          if (!scope.settings) { return; }
          scope.settings[key] = value;
          send(key, value === true ? 1 : (value === false ? 0 : value));
        }
        // MIRROR THE PRESET. applyPreset mutates these controller-side, and mid-session broadcasts
        // never adopt settings back into the UI - so without this scope.settings keeps the player's
        // OLD values and the next pushAllSettings (the pause->unpause edge re-pushes every key
        // verbatim) silently writes them back OVER the preset. applyPresetNow has carried this same
        // mirror since V2.9 for exactly that reason; the challenge path was calling the controller
        // without it. For a challenge it is not cosmetic: a player who arrives with Protection and
        // Knock control switched OFF gets them turned on by the preset and then switched off again
        // moments later, which on Case 3 (a flat +11 deg of timing) is the difference between the
        // exercise and a grenaded engine. Assignment only, no send - the controller already did it.
        // injectorFlow is deliberately NOT taken from factoryInjFlow here; the challenge pins it
        // below so every player scores the identical fuel system.
        if (scope.settings) {
          scope.settings.enabled = true;
          scope.settings.protection = true;
          scope.settings.knockControl = true;
          scope.settings.globalFuelTrim = 0;
          scope.settings.globalIgnitionTrim = 0;
          scope.settings.revLimit = 0;
          var chStockRevLim = (scope.base && scope.base.revLimiterRPM) || 7000;
          scope.settings.launchLimit = Math.max(2500, Math.min(5500, chStockRevLim));
          seedMixSettingsMirror();
        }
        // Pin the fuel system so every player scores the same engine. These are the controller's
        // own defaults, not a fault - without them somebody who left E85 or a big injector number
        // in their settings would start a "stock" challenge on a different car to everyone else.
        chSet('fuelType', 1);
        chSet('stoichRatio', 14.7);
        chSet('masterFuel', 8);
        chSet('injectorFlow', 600);
        if (kind === 'ch_rich') {
          chFillMap('fuel', function () { return 1.25; });          // +25% on top of the lambda target
        } else if (kind === 'ch_injectors') {
          chSet('injectorFlow', 200);                               // 3x the stock duty - pegs on top
        } else if (kind === 'ch_timing') {
          chFillMap('ignition', function () { return 11; });        // flat +11, straight past MBT
        } else if (kind === 'ch_lambda') {
          var leanRow = {};
          challengeLoadRows(130).forEach(function (r) { leanRow[r] = true; });
          chFillMap('lambda', function (r) { return leanRow[r] ? 1.03 : null; });   // lean ONLY under boost
        }
        // ch_power injects nothing: the factory tune on a fresh engine IS the challenge.
      }

      function loadLessonTune(kind) {
        if (String(kind).indexOf('ch_') === 0) { loadChallengeTune(kind); return; }
        if (kind === 'rich' && scope.maps && scope.maps.fuel) {
          scope.editAmount = 5;
          scope.maps.fuel.forEach(function (row, r) {
            for (var c = 0; c < row.length; c++) { row[c] = 1.20; }   // scalar: +20% rich
            scope.maps.fuel[r] = row.slice();
            sendMapRow('fuel', r, scope.maps.fuel[r]);
          });
          if (scope.settings) { scope.settings.protection = true; send('protection', 1); }
        } else if (kind === 'stock') {
          scope.editAmount = 2;
          callController('applyPreset("stock")');
        } else if (kind === 'fuel') {
          scope.editAmount = 2;
          callController('applyPreset("stock")');
          // fit small injectors so a WOT pull pegs duty and shows the lean - the lesson fixes it
          if (scope.settings) {
            scope.settings.injectorFlow = 350; send('injectorFlow', 350);
            scope.settings.fuelType = 1; send('fuelType', 1);
          }
        } else if (kind === 'diesel') {
          // clean diesel baseline so the lesson starts predictable (only meaningful on a diesel vehicle)
          scope.editAmount = 2;
          callController('applyPreset("stock")');
          if (scope.settings) {
            scope.settings.dieselMaxFuel = 100; send('dieselMaxFuel', 100);
            scope.settings.dieselRailPressure = 1200; send('dieselRailPressure', 1200);
            scope.settings.dieselSmokeLimit = 17; send('dieselSmokeLimit', 17);
            // REVIEW FIX 2026-07-27: also clear the two hardware toggles. applyPreset("stock") does not
            // touch them, so a learner who ran the hardware lesson (which switches both ON and never
            // switches them back) then replayed the beginner lesson hit a dead end: with the smoke
            // limiter on, AFR is pinned at the limit by construction, so model.smoke is identically 0
            // and the "watch SMOKE climb above 30" step could never pass on any pedal, rpm or boost.
            scope.settings.dieselSmokeLimiter = false; send('dieselSmokeLimiter', 0);
            scope.settings.dieselNozzleEnabled = false; send('dieselNozzleEnabled', 0);
          }
        } else if (kind === 'boostshape' && scope.settings && scope.maps && scope.maps.boost) {
          // Do not use the Stock preset here: it clears faults/damage, which calibration rollback
          // cannot restore. This temporary lab preserves fuel, timing, lambda, axes and every M-slot.
          function lessonSet(key, value) {
            scope.settings[key] = value;
            send(key, value === true ? 1 : (value === false ? 0 : value));
          }
          lessonSet('boostShapeMode', 1);
          lessonSet('nitrousEnabled', false);
          lessonSet('antilagEnabled', false);
          lessonSet('launchEnabled', false);
          lessonSet('rollingEnabled', false);
          lessonSet('hardCut', false);
          lessonSet('flatShiftEnabled', false);
          lessonSet('protection', true);
          lessonSet('knockControl', true);
          lessonSet('knockThreshold', 1.2);
          lessonSet('knockMaxRetard', 8);
          lessonSet('egtLimit', 950);
          lessonSet('boostBaseDuty', 45);
          lessonSet('boostPGain', 0.8);
          lessonSet('boostIGain', 0.12);
          lessonSet('boostOvercut', 25);
          lessonSet('boostRampUpKpaS', 300);
          lessonSet('boostRampDownKpaS', 0);
          lessonSet('boostShiftHoldMs', 250);
          scope.boostGearRows.forEach(function (row) { lessonSet(row.targetKey, 0); lessonSet(row.dutyKey, 0); });
          scope.boostSpeedRows.forEach(function (row) { lessonSet(row.targetKey, 0); lessonSet(row.dutyKey, 0); });

          var baro = isFinite(Number(scope.t && scope.t.baroKpa)) ? Number(scope.t.baroKpa) : 101.325;
          var hardwareFloor = isFinite(Number(scope.t && scope.t.boostHardwareFloorKpa)) ? Number(scope.t.boostHardwareFloorKpa) : baro;
          var boostMeta = (scope.mapMeta && scope.mapMeta.boost) || { min: 60, max: 1000 };   // V3.0 fallback: 1000, was 600 - sync oeProTuneShared
          var safeTarget = Math.max(Number(boostMeta.min) || 60, Math.min(Number(boostMeta.max) || 1000, Math.max(baro, hardwareFloor) + 10));
          scope.maps.boost.forEach(function (row, r) {
            for (var c = 0; c < row.length; c++) { row[c] = safeTarget; }
            scope.maps.boost[r] = row.slice();
            sendMapRow('boost', r, scope.maps.boost[r]);
          });
          if (scope.maps.wgduty) {
            scope.maps.wgduty.forEach(function (row, r) {
              for (var c = 0; c < row.length; c++) { row[c] = 50; }
              scope.maps.wgduty[r] = row.slice();
              sendMapRow('wgduty', r, scope.maps.wgduty[r]);
            });
          }
          lessonSet('boostMode', 2);
          lessonSet('enabled', true);
        } else if (kind === 'transmission' && scope.settings) {
          // Preserve the rest of the loaded tune. The temporary-lesson journal restores these
          // five transmission settings on every normal exit and after an interrupted lesson.
          function transmissionLessonSet(key, value) {
            scope.settings[key] = value;
            send(key, value === true ? 1 : (value === false ? 0 : value));
          }
          transmissionLessonSet('converterStall', 0);
          transmissionLessonSet('shiftFirmness', 0);
          transmissionLessonSet('shiftTorqueMgmt', 0);
          transmissionLessonSet('torqueLimitNm', 0);
          transmissionLessonSet('torqueLimitLowGears', false);
        }
      }

      var currentHighlight = null;
      function clearHighlight() {
        currentHighlight = null;
        var nodes = element[0].querySelectorAll('.oep-blink');
        for (var i = 0; i < nodes.length; i++) { nodes[i].classList.remove('oep-blink'); }
      }
      function applyHighlight(token) {
        clearHighlight();
        if (!token) { return; }
        currentHighlight = token;
        var tries = 0;
        (function tryApply() {
          if (currentHighlight !== token) { return; }   // a newer highlight took over
          var all = element[0].querySelectorAll('[data-tut]');
          var found = 0;
          for (var i = 0; i < all.length; i++) {
            if (all[i].getAttribute('data-tut') === token) { all[i].classList.add('oep-blink'); found++; }
          }
          // the target may live in a page that hasn't rendered yet - retry briefly
          if (!found && tries < 8) { tries++; setTimeout(tryApply, 120); }
        })();
      }

      function curStep() { var l = scope.tutorial.lesson; return l && l.steps[scope.tutorial.stepIndex]; }
      function enterStep() {
        var st = curStep(); if (!st) { return; }
        scope.tutorial.canAdvance = !!st.manual;
        scope.tutorial.markPulls = scope.dynoPullCount || 0;
        scope.tutorial.markBoost = boostMaxCell();
        scope.tutorial.markKnockCount = Number(scope.t && scope.t.knockCount) || 0;
        scope.tutorial.boostProofKind = null;
        scope.tutorial.boostProofStreak = 0;
        scope.tutorial.boostKnockLatched = false;
        scope.tutorial.dieselProofStreak = 0;   // REVIEW FIX 2026-07-27: per-step, like the boost streak
        scope.tutorial.dieselLimiterSeen = false;
        scope.tutorial.transDriveArmed = false;
        scope.tutorial.transMgmtSeen = false;
        scope.tutorial.transLimitSeen = false;
        scope.tutorial.transConverterStreak = 0;
        scope.tutorial.quizPick = null;      // challenge quiz: a fresh step asks its question clean
        scope.tutorial.quizWhy = '';
        scope.tutorial.quizTried = {};
        // Challenge scoring phase. Declared per step so a dyno pull is filed as the baseline or
        // as the scored run according to where the player actually is, never by pull order.
        if (st.chPhase && scope.chRun && !scope.chRun.ended) { scope.chRun.phase = st.chPhase; }
        if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; }
        if (st.page) { scope.setPage(st.page); }
        setTimeout(function () { applyHighlight(st.highlight); }, 90);
      }

      // ====================================================================
      // MICRO-TOURS (V2_3): 2-3 step "what does this page do?" walkthroughs for EVERY page, launched
      // from the "?" button in the top bar. They ride the whole tutorial engine (same box, highlights,
      // Next/Back), but every step is manual so a casual can click through in 20 seconds. Full lessons
      // stay for the big arcs; tours give the "I just want to know what this does" answer users asked for.
      var TOURS = {
        diagnose: { title: 'Help Me (Tune Doctor)', steps: [
          { text: "The Tune Doctor reads your LIVE data plus the last ~30 seconds of driving and tells you, in plain English, what your tune is doing wrong - knock, lean, overboost, hot temps - worst first.", manual: true },
          { text: "Every card has a FIX: click one and it jumps you to the exact page (often the exact control) to change. Do a hard pull, come back here, and work the list top to bottom. Green card = healthy tune.", manual: true }
        ] },
        runtime: { title: 'Runtime Values', steps: [
          { text: "The ECU's live vital signs, grouped like a real PCLink runtime grid. RED rows are alarmed: lambda far off target, injectors maxed, knock, limp. Glance here first when something feels wrong.", manual: true },
          { text: "The two to watch on a pull: Lambda Actual vs Target (mixture doing what you asked?) and Knock Level. Fuel Rail vs Diff pressure shows the pump keeping up. Runtime is read-only - fixes happen in the tables.", manual: true }
        ] },
        gauges: { title: 'Runtime Gauges', steps: [
          { text: "Round tuner-software dials with a needle and colored warn/danger arcs (switch to compact Bars with the toggle above). The zones follow YOUR settings - the knock gauge uses your knock threshold, EGT uses your EGT limit, RPM uses your rev limit.", manual: true },
          { text: "On a diesel the lambda/knock gauges swap for AFR, Smoke and EGT automatically. Keep this page up on a second look while doing dyno pulls.", manual: true }
        ] },
        dyno: { title: 'Dyno / AFR', steps: [
          { text: "Your measuring tool. Arm it, do a full-throttle pull from low RPM to redline, and it plots CRANK torque and HP - plus AFR and boost across the pull (toggle them under the graph). Wheel power on the Runtime page is MEASURED through the drivetrain separately, like a hub dyno.", manual: true },
          { text: "'Save as baseline' keeps a dotted reference curve; your next pull overlays it and shows the GAIN. That's the whole tuning loop: measure, change ONE thing, measure again. Auto pull mode re-arms after every run.", manual: true },
          { text: "Measure like a pro: the status grades every pull's SWEEP RATE. Torque is read after engine inertia - exactly like a real inertia dyno - so a 1st-gear rocket run genuinely reads low. Pull in 2nd or 3rd, same gear every time, and your numbers become comparable between sessions.", manual: true }
        ] },
        buildcard: { title: 'Build Card', steps: [
          { text: "A spec sheet read LIVE from the car's fitted parts: engine, induction, block strength (from the jbeam's own torque rating - the same number the damage model uses), fuel, nitrous, peak boost seen. First open after a spawn scans your installed mods for part names - big mod folders pause a few seconds, once.", manual: true },
          { text: "Power has three honesty tiers: DYNO VERIFIED (green - from a pull you did, survives restarts), ECU PEAK (blue - the best sustained power the ECU measured while you just DROVE, automatic), and RATED (the mod's claim). Do a dyno baseline and the card also shows BASE vs TUNED with your gain % - that's the flex line. Tune status tells you if added hardware still needs tuning.", manual: true }
        ] },
        logging: { title: 'Log Analysis', steps: [
          { text: "A rolling recorder (4 samples/sec) of everything: rpm, MAP, lambda target vs actual, knock, EGT, temps, duty. PCLink-style stacked lanes; toggle lanes on/off with the buttons.", manual: true },
          { text: "Export CSV writes the log to your userfolder for Excel - Open folder jumps straight to it. Use logs to find what happened at a moment you FELT: 'it stumbled at 5k' becomes visible data.", manual: true }
        ] },
        log_navigator: { title: 'Log Navigator', steps: [
          { text: "The same log, but with a cursor and zoom for surgical review. Drag on the mini-map to zoom a window; arrows step the cursor sample by sample with exact values in the readout.", manual: true },
          { text: "The Jump buttons find your next problem instantly: next knock event, next lean moment, next misfire, next injector-duty peak. That's how a pro reviews a pull - by event, not by scrolling.", manual: true }
        ] },
        tunes: { title: 'Tune Files', steps: [
          { text: "Everything about saving and loading. Switchable Maps hold four live performance calibrations with shared hardware and safety setup. Presets are starting points; Full factory reset wipes the complete ECU. STORE ECU autosaves the working package.", manual: true },
          { text: "Quick-save backups are four recovery copies per car, separate from the M1-M4 buttons. The Library stores up to 20 named complete tune packages per car.", manual: true },
          { text: "A compact Discord tune code carries the active MAP only. Export complete tune file preserves all four Switchable Maps in a readable file. Both formats only open in OE ProTune.", manual: true },
          { text: "Tunes belong to the ENGINE they were built on. Swap engines and the ECU loads a fresh base tune instead of force-feeding the old one (a gas map on a diesel means a dead engine, here and in real life); swap the original engine back and your tune returns automatically.", manual: true }
        ] },
        notes: { title: 'Calibration Notes', steps: [
          { text: "A tuner's diary, per car. Write what you changed and WHY - future-you will thank you. Snapshot captures the live numbers (rpm, lambda, knock, power) into a note with one click.", manual: true }
        ] },
        sensor_setup: { title: 'Sensor Setup', steps: [
          { text: "The ECU only knows what its sensors TELL it. This page calibrates MAP, TPS, IAT, ECT and lambda. BeamNG does not provide one universal manifold-pressure sensor for every mod, so OE ProTune labels its MAP honestly as a load + boost model and references it to live barometric pressure.", manual: true },
          { text: "Try it: drag 'Lambda bias' and watch the ECU lambda drift away from true lambda - a biased wideband quietly wrecks closed-loop fuel. A good tune on bad calibration behaves like a bad tune. Set biases back to 0 when done!", highlight: 'set:sensorLambdaBias', manual: true }
        ] },
        axis_setup: { title: 'Axis Setup', steps: [
          { text: "Columns are RPM. Load rows use the MAP axis in Speed Density mode or the Alpha-N TPS axis for Fuel, Ignition, Lambda and VE when Alpha-N is active. Boost Target and WG Duty always use MAP. More breakpoints where the engine lives gives finer control.", manual: true },
          { text: "Raised your rev limiter past stock? Extend the top RPM value so you can tune there. The Alpha-N axis always has one TPS breakpoint per table row. MAP/RPM grid changes preserve the active calibrated surfaces by interpolation; directly changing Alpha-N breakpoints deliberately changes the throttle angles where those existing row values apply.", manual: true },
          // V3.0.7: Table resolution. Placed before the Alpha-N step so the count is understood
          // before the strategy that reuses the same row count for its TPS axis.
          { text: "Table resolution sets how many cells you get. Standard 6 x 7 is the default and nothing changes unless you switch. Detailed 12 x 12 and Fine 16 x 16 give you resolution where the engine actually lives, the way a real standalone does - but your tune is REFINED onto the new breakpoints, so the shape is kept while every cell in between is interpolated rather than tuned by you. Re-check the map afterwards, and expect a bigger grid to need far more driving before the Mixture Map can fill it. Switching up keeps a snapshot so you can come straight back.", manual: true },
          // V3.0.7 (CosmicEgg's report): the mod detected ITBs and never once explained the setting
          // that exists for them. Ends on the honest limitation rather than overselling it.
          { text: "WHEN TO USE ALPHA-N: individual throttle bodies. With no shared plenum there is nothing to hold vacuum, so once each throttle is part way open its runner sits at atmospheric - your whole MAP load axis collapses into the top row or two and stops telling you anything. Fit ITBs in game and you can watch it happen: open Runtime, squeeze the pedal, and MAP pins near barometric by about a third of throttle travel. Everything above that lands in one row. That is why ITB builds index fuel and ignition on throttle position instead, and it is close to mandatory there - switch Load source to Alpha-N (TPS) on Fuel Setup and the rows become throttle angle, which stays usable across the whole range. A big-duration cam on a single throttle body has the same problem for a different reason: high overlap makes low-rpm MAP non-monotonic, so idle can read HIGHER pressure than a light cruise that is flowing far more air. On a plenum turbo, pure Alpha-N is the wrong call - one throttle angle covers everything from zero boost mid-spool to full target.", manual: true }
        ] },
        tune_compare: { title: 'Tune Compare', steps: [
          { text: "Save a baseline of your current tune, make changes, and this page lists exactly what differs - settings and table cells. 'Restore' puts the baseline back if the experiment went badly.", manual: true }
        ] },
        limits: { title: 'Limiters', steps: [
          { text: "All the rev-limit tricks. Rev limit: 0 = stock; lower it to protect an engine, or raise it only up to this engine's 'Raise ceiling'. The separate Mechanical RPM threshold comes from BeamNG's physical engine limit; OE over-rev damage starts there, not at the original ECU limiter.", highlight: 'set:revLimit', manual: true },
          { text: "2-step launch holds rpm on the line (with bark + flames); Flat-shift holds revs through clutch-in full-throttle shifts; Rolling anti-lag (bind its key in Options > Controls) locks revs wherever you press it - any engine, and turbos also hold boost; Hard cut makes the main limiter BOUNCE with bangs.", manual: true },
          { text: "'Pops / flames intensity' controls sound and flame aggression; the Pops & Bangs page separately chooses BeamNG Stock, OE Realistic, or the exaggerated OE Show / Coal smoke profile. None of those visual choices changes power, lambda, EGT or traction.", highlight: 'set:popsLevel', manual: true },
          { text: "Drivetrain loss: wheel power is MEASURED through the real drivetrain now - watch 'Measured drivetrain loss' fill in under load. The slider is just your crank-estimate factor: do a pull, read the measured number, set the slider to match. That's real dyno-shop literacy.", highlight: 'set:drivetrainLoss', manual: true }
        ] },
        protection: { title: 'Protection', steps: [
          { text: "The safety net. Protection cuts power on knock/overboost; Knock control pulls timing automatically when it detects detonation. Turn them OFF and nothing saves the engine - that's how the Bad Lean demo kills motors.", manual: true },
          { text: "EGT limit sets where the exhaust-heat warning and limp bite. Block strength (read from the fitted block part) is how much abuse the bottom end takes - a stock block grenades where a billet one shrugs.", highlight: 'set:egtLimit', manual: true }
        ] },
        faults: { title: 'Faults', steps: [
          { text: "The ECU's fault memory - codes LATCH when a problem happens (Lean, Knock, Overboost, InjectorDuty...) so a mid-pull problem is still visible after you stop. Each code explains itself and its fix.", manual: true },
          { text: "Clear faults wipes the memory (like clearing DTCs with a scan tool); Reset damage repairs accumulated engine damage. Limp mode is the big one: the ECU cutting power to save the motor - fix the cause, then clear.", manual: true }
        ] },
        fuel_setup: { title: 'Fuel Setup', steps: [
          { text: "Load source chooses what the table rows mean. Speed Density (MAP) is the normal/default strategy and is what every older tune uses. Alpha-N (TPS) is for engines whose manifold pressure is a poor load signal, especially large cams or individual throttle bodies.", highlight: 'set:loadSource', manual: true },
          { text: "When Alpha-N is selected, Fuel Main, Ignition, Lambda Target and VE rows become throttle-position breakpoints. Boost Target and Wastegate Duty stay MAP-based. Switching strategies does not invent a safe tune: re-check those four tables, and be especially careful on boosted engines because the same throttle angle can produce different boost.", manual: true },
          { text: "The fuel SYSTEM - the tables aim the mixture, this page decides if it can be delivered. Injector flow is the big one: watch Injector duty on a pull; past ~90-100% you're out of injector and it leans out.", highlight: 'set:injectorFlow', manual: true },
          { text: "Fuel type changes everything: E85 and Methanol M1 resist knock (more timing/boost) but need way more fuel - M1 needs ~2.3x the injector. Race Gas C16 gives knock headroom with normal fuel volume. Rail vs Diff pressure shows the pump's headroom.", highlight: 'set:fuelType', manual: true }
        ] },
        fuel: { title: 'Fuel Main', steps: [
          { text: "A fine-trim MULTIPLIER on top of Lambda Target: 1.00 = follow the target exactly, 1.05 = +5% fuel in that cell. Most tuning happens in Lambda Target - use this table for spot corrections (e.g. one cell that logs lean).", manual: true },
          { text: "Table basics work everywhere: drag to select cells, +/- to nudge, type a value and Fill, Smooth to blend neighbors, Interp to gradient across a selection. The cyan cell is where the engine is RIGHT NOW.", manual: true }
        ] },
        lambda: { title: 'Lambda Target', steps: [
          { text: "THE mixture command - what AFR you're asking for in every cell. Rows are MAP/load (vacuum on top, boost at the bottom), columns are RPM. Rule of thumb: ~1.00 cruise, ~0.88 best power, ~0.80 under real boost (rich = cool = knock-safe).", manual: true },
          { text: "The λ/AFR button in the top bar switches the display to AFR numbers if you think in 12.5:1 terms. Richen the TOP vacuum rows + retard ignition there and you've built pops; too lean at the bottom rows under boost and you've built a grenade.", manual: true }
        ] },
        wgduty: { title: 'Wastegate Duty', steps: [
          { text: "A real EBC duty table: each cell SHIFTS your Base duty slider at that RPM and target (50 = neutral, above adds duty, below removes it). The closed loop still lands on your Boost Target - this shapes how it gets there.", manual: true },
          { text: "The move it exists for: big turbos hunt at high RPM because exhaust energy explodes up top - sculpt the high-RPM columns DOWN a few points and the oscillation calms, exactly like dialing a duty table on a real boost controller.", manual: true }
        ] },
        ve: { title: 'VE Table', steps: [
          { text: "VE is volumetric efficiency: the engine's trapped-air model by RPM and load. In Modelled mode the ECU combines MAP, engine data, Lambda Target, and interpolated VE to calculate fuel. Lambda Target requests a mixture; VE describes the air needed to deliver it, and values above 100% can be completely valid.", manual: true },
          { text: "The live point blends four neighboring cells. A VE model that is too low runs lean; too high runs rich. Tune a smooth surface from valid wideband data, and always know whether CLL is Off or applying a known correction.", manual: true },
          { text: "This quick tour does not change your calibration. Use the full VE lesson for a guided test with a small rich error in a temporary working copy, preflight checks, a partial correction, and automatic restoration when you exit.", manual: true }
        ] },
        cll: { title: 'Closed Loop Lambda', steps: [
          { text: "CLL corrects measured lambda toward target when its gates allow. STFT carries the fast residual correction; LTFT learns that same total requirement and gradually replaces STFT, so the two do not duplicate one error. This trainer defaults to open-loop at high TPS/MAP; some real wideband systems instead use limited high-load authority.", manual: true },
          { text: "The gates are coolant temperature, a fixed 1200 RPM minimum, and tunable TPS and MAP ceilings. Stoich only does not chase rich targets; Always on target does. Watch CLL status, STFT and LTFT together: as LTFT learns, STFT should return toward 0%.", manual: true }
        ] },
        cyl_trims: { title: 'Individual Cylinder Trims', steps: [
          { text: "Per-cylinder fuel correction, like a real ECU compensates for uneven airflow between cylinders. V8s get all 8 trims; smaller engines show 6.", manual: true },
          { text: "The teaching: a WIDE spread between cylinders destabilizes combustion - drag one trim to -10 and another to +10 and watch the Misfire status. Real engines want the spread tight.", manual: true }
        ] },
        mixture_map: { title: 'Mixture Map', steps: [
          { text: "The 'Applying to' badge names the base map that will receive a correction: Fuel Main in Traditional mode or VE Table in Modelled mode. Before learning, verify a safe warm tune, correct sensor and fuel-system data, and the CLL state you expect.", manual: true },
          { text: "Auto accepts steady driving and clean pulls that pass its filters. Accepted counts useful samples; Skipped is normal for ineligible frames; Rejected means an invalid or outlier measurement. Learning cells show provisional evidence, and Ready means Apply can use it.", manual: true },
          { text: "The recommendation uses pre-feedback measured lambda divided by Lambda Target; when only a post-trim reading exists it includes the known CLL fuel factor. Each sample is shared across four interpolated cells, and pull-only cells need two agreeing passes.", manual: true },
          { text: "Apply 50% - re-drive changes only Ready cells in the named map, uses half the correction, and consumes that evidence. Evidence expires after 30 minutes, and a manual base-table edit invalidates old samples. Re-drive and verify before applying again.", manual: true }
        ] },
        startup_warmup: { title: 'Startup / Warmup', steps: [
          { text: "Cold engines need extra fuel: cranking enrich to catch, post-start to keep it lit, warmup taper until coolant reaches your end temp. Too little = cold stumble; too much = rich fouling. This is why your lambda reads rich for the first minute - that's normal.", manual: true }
        ] },
        transient_fuel: { title: 'Transient Fuel', steps: [
          { text: "Fuel for CHANGES: tip-in enrichment (the accelerator-pump squirt) stops the lean bog when you stab the throttle; wall wetting models fuel clinging to the ports; accel decay is how fast the squirt fades.", manual: true },
          { text: "Decel fuel cut saves fuel on a closed throttle - and it's THE switch for pops: cut ON = quiet lifts, cut OFF = raw fuel in the hot exhaust for your overrun tune to ignite. Lift-off fuel sets how hard the cut is.", highlight: 'set:decelFuelCut', manual: true }
        ] },
        diesel: { title: 'Diesel Tuning', steps: [
          { text: "Diesels have no spark and no lambda target - the pedal IS the fuel command, and the limit is SMOKE: fuel beyond what the air can burn just makes soot and heat. Max fuel is the 'turn it up' lever; the smoke limit sets your AFR floor.", manual: true },
          { text: "EGT is THE diesel gauge: too much fuel for the air melts pistons. More boost = more air = cleaner and cooler at the same power. Rail pressure buys cleaner combustion. Watch AFR, Smoke and EGT together - that's diesel tuning.", manual: true }
        ] },
        ignition: { title: 'Ignition Main', steps: [
          { text: "Spark timing TRIMS on top of the base map: + advance = more power up to MBT (~+7°, where torque peaks) - past it power FALLS even before knock, like a real engine. - retard = safety (and heat out the exhaust). The factory seed already pulls timing under boost - that's your knock margin.", manual: true },
          { text: "Two famous tricks live here: retard the TOP vacuum rows for pops and bangs, and retard the IDLE corner hard for rotational idle. Advance under boost is where power hides - and where engines die. Add in small steps, watch Knock.", manual: true }
        ] },
        knock_control: { title: 'Knock Control', steps: [
          { text: "Detonation protection: when knock crosses your threshold, the ECU pulls up to Max retard of timing, then recovers it at the recovery rate. The knock gauge and counter show every event.", manual: true },
          { text: "Lower threshold = paranoid (catches everything, may pull power for noise); higher = trusting (real knock gets through). Fuel matters more than any setting: E85/C16/M1 raise how much timing and boost you can run before knock exists at all.", highlight: 'set:knockThreshold', manual: true }
        ] },
        boost: { title: 'Boost Target', steps: [
          { text: "What boost you're asking for, per cell, in absolute kPa. Zero gauge boost is the live barometric pressure; near sea level 200 kPa is about 14.3 psi gauge. The kPa/PSI button converts using live baro. On a turbo the wastegate chases this; on a supercharger it is a safety ceiling over mechanical boost. Until you edit the table, stock boost stays untouched.", manual: true },
          { text: "Shape it like a pro: lower targets at low rpm for traction, full by mid-range. Every kPa you add needs fuel and timing margin to go with it - boost is the air side of a three-way balance.", manual: true }
        ] },
        boost_control: { title: 'Boost Control', steps: [
          { text: "HOW the target gets hit (turbo only - superchargers are mechanical, gains here do nothing). Until you EDIT the Boost Target table, the ECU leaves the wastegate completely alone ('Stock boost' status) - a base ECU doesn't change the boost your car was built for.", manual: true },
          { text: "The hardware truth: the game's Tuning-tab boost slider IS your wastegate spring. The ECU can only ADD boost above the spring, like a real boost controller - target below it and the status reads WASTEGATE FLOOR (the real fix: a softer spring = lower that slider). Target more than the turbo can flow and it reads TURBO MAXED. Hardware always wins; the ECU just tells you honestly.", manual: true },
          { text: "Closed loop chases the active target like a real EBC; Base duty is spool feel, P gain tightens control, and I gain removes steady error. Use Boost by Gear / Speed for explicit PCLink-style target and feed-forward corrections. The legacy option exists only so old 60/80% tunes do not change.", highlight: 'set:boostPGain', manual: true }
        ] },
        boost_shaping: { title: 'Boost by Gear / Speed', steps: [
          { text: "The Boost Target table remains your base aim. Gear and speed Target Trim cells add or subtract pressure in the unit selected at the top of the tuner. Negative values soften torque; positive values ask for more boost and need fuel, timing and hardware headroom.", manual: true },
          { text: "WG Duty Trim changes feed-forward as a percentage: a 60% base with -30% trim becomes 42%. It works in Open Loop and Closed Loop. The closed loop still corrects toward the final target.", manual: true },
          { text: "Speed is true BeamNG ground speed and is linearly interpolated between rows. Gear-up hold delays only an increase, Target rise rate ramps the increase, and reductions can be immediate for safety. Watch Base → Shaped → Active before making a pull.", manual: true },
          { text: "Want to prove it safely? Open Tutorial, Chapter 3, then 'Verify Boost by Gear / Speed'. That temporary hands-on lesson checks Gear, Speed and combined request math without saving over your tune.", highlight: 'shape:live', manual: true }
        ] },
        idle_control: { title: 'Idle Speed Control', steps: [
          { text: "Closed-loop idle: the ECU holds your target rpm with a virtual idle valve, plus optional ignition correction for fast catch-up. Cammed/ITB builds idle lumpy until this is dialed - and a race cam stays a little lumpy by nature.", manual: true }
        ] },
        antilag: { title: 'Anti-Lag', steps: [
          { text: "Turbo-only (a blower has no turbine). Off-throttle above 3200 rpm OE models exhaust heat, pressure pulses and occasional flame, and requests the fitted turbo's native anti-lag control when available. Actual retained boost is vehicle-dependent. In OE Realistic a warm gasoline engine makes only brief pale haze, not diesel-style coal. Real ALS is brutal on parts, and OE models that cost.", manual: true },
          { text: "Extra fuel sets how hard it works (and how much it bangs). Watch the heat readout - long anti-lag sessions cook the turbo. The rolling anti-lag (hold-a-key rev hold) lives on the Limiters page.", highlight: 'set:antilagFuel', manual: true }
        ] },
        pops: { title: 'Pops & Bangs', steps: [
          { text: "Pops come from your TUNE, not a toggle: decel fuel cut OFF (Transient Fuel), richen the top vacuum rows of Lambda Target, retard Ignition there. The Pop strength meter shows how hard your tune bangs - most are sound and pressure, with occasional flame and faint haze.", manual: true },
          { text: "Flame & smoke profile selects BeamNG Stock, OE Realistic, or OE Show / Coal. Realistic is fuel-aware and keeps gasoline/E85/methanol mostly clean; Show / Coal deliberately restores the old dense clouds. Smoke amount scales the requested visual output until the profile safety cap and changes no tuning values.", highlight: 'set:fxStyle', manual: true }
        ] },
        rot_idle: { title: 'Rotational Idle (BETA)', steps: [
          { text: "The drag-scene rowdy idle: rhythmic ignition cuts at a standstill make the engine lope and rock the car, with occasional barks. Like the pops it's tune-driven: retard the IDLE cells hard (-10+), richen them a touch, arm it, stop the car.", manual: true },
          { text: "There's a full lesson for this in Tutorial > Chapter 3. Chop depth sets how hard each cut hits (too deep stalls weak engines); barks are RARE like real clips - the lope is constant, the pop is the treat. BETA: feel varies per engine.", highlight: 'set:rotIdleChop', manual: true }
        ] },
        nitrous: { title: 'Nitrous', steps: [
          { text: "A power shot that arms at high rpm + wide-open throttle. The shot adds cylinder pressure - spray without enough Extra fuel and Timing pull and it knocks and melts. The safety levers ARE the tune.", manual: true },
          { text: "Watch EGT and Knock on a spray. Bigger kit (see 'Kit rating' - it comes from the installed part) = more of both levers needed. There's a full lesson in Chapter 3 if you want the guided version.", highlight: 'set:nitrousFuel', manual: true }
        ] },
        vvt: { title: 'VVT Control', steps: [
          { text: "Variable cam timing: intake advance fills the midrange - the VTEC-style hit. It tracks a target in its active window (1600-5600 rpm) and adds real power where it's working. More advance = stronger midrange; watch the dyno curve change.", highlight: 'set:vvtIntakeAdvance', manual: true }
        ] },
        ethrottle: { title: 'E-Throttle', steps: [
          { text: "Drive-by-wire pedal maps: Aggressive gives more throttle per pedal at part-throttle (feels snappier, same WOT), Progressive the opposite. This changes real torque delivery, not just a curve on a screen.", manual: true },
          { text: "The safety side: if the throttle stops tracking the pedal (tolerance setting), the ECU cuts power - that's a real e-throttle safety strategy. Cable mode bypasses all of it.", highlight: 'set:ethrottleMode', manual: true }
        ] },
        triggers: { title: 'Trigger Scope', steps: [
          { text: "Crank/cam signal health - diagnosis practice. Switch the pattern to 'Noisy crank' or 'No cam sync' and the engine starts misfiring randomly: that's what a failing trigger feels like, and why a scope is the first tool on a mystery misfire. Set it back to Good pattern when done.", highlight: 'set:triggerPattern', manual: true },
          { text: "Base timing lives here for the same reason it does in PCLink: it's a trigger calibration. Set it to YOUR engine's spec base advance (NA street engines commonly ~16°, boosted ~12° - the auto defaults) and the displayed ignition angle and logs read like the real engine's numbers. It calibrates the DISPLAY only - power, knock and EGT still come from your trims, so cranking it up never adds power.", highlight: 'set:baseTiming', manual: true }
        ] },
        can_setup: { title: 'CAN Setup', steps: [
          { text: "Training/status only - configures a simulated CAN bus like you would on a real Link (dash streams, CAN lambda). Nothing here changes how the car drives; it teaches the config workflow.", manual: true }
        ] },
        aux_outputs: { title: 'Aux Outputs', steps: [
          { text: "The ECU's switched outputs: fan at your coolant temp, fuel pump prime, shift light rpm, check-engine on faults. The shift light, check-engine and fuel pump are published as real electrics (oep_shiftlight, oep_checkengine, oep_fuelpump) - custom dashboards and mods can bind them.", highlight: 'set:auxShiftLight', manual: true }
        ] }
      };
      scope.hasTour = function () { return !!TOURS[scope.activePage]; };
      scope.startTour = function (pageId) {
        var pid = pageId || scope.activePage;
        var t = TOURS[pid]; if (!t) { return; }
        if (temporaryLessonSession && scope.tutorial.lesson && scope.tutorial.lesson.id === 'boostshaping') {
          var tourRestoreIssue = boostLessonRestoreIssue(scope);
          if (tourRestoreIssue) { setToast(tourRestoreIssue); return; }
        }
        if (temporaryLessonSession) {
          if (!restoreTemporaryLesson(true, function () { scope.startTour(pid); })) { return; }
          return;
        }
        scope.showWelcome = false;
        scope.tutorial.active = true;
        scope.tutorial.lessonIndex = -1;   // not a real lesson: restart re-runs the tour instead
        scope.tutorial.tourPage = pid;
        scope.tutorial.lesson = { id: 'tour_' + pid, title: t.title, steps: t.steps };
        scope.tutorial.stepIndex = 0;
        scope.tutorial.canAdvance = false;
        enterStep();
      };

      scope.openTutorialMenu = function () { scope.tutChapter = null; scope.showWelcome = true; };
      scope.openChapter = function (i) { scope.tutChapter = i; };
      scope.backToChapters = function () { scope.tutChapter = null; };
      scope.closeWelcome = function () { scope.showWelcome = false; try { localStorage.setItem('oep_seen_welcome', '1'); } catch (e) {} };
      scope.startLesson = function (i) {
        // Lessons stay game-side: the temporary-lesson sandbox journals THIS device's
        // localStorage and its identity-guarded callController wrappers ('if obj ... end end')
        // do not fit the browser shim's fixed-wrapper parser.
        if (IS_MIRROR) { setToast('Lessons run on the game PC'); return; }
        var l = LESSONS[i]; if (!l) { return; }
        // Restarting or choosing another lesson must first roll back the existing exercise,
        // otherwise the already-modified training tune would become the new backup.
        if (temporaryLessonSession && scope.tutorial.lesson && scope.tutorial.lesson.id === 'boostshaping') {
          var lessonRestoreIssue = boostLessonRestoreIssue(scope);
          if (lessonRestoreIssue) { setToast(lessonRestoreIssue); return; }
        }
        if (temporaryLessonSession) {
          if (!restoreTemporaryLesson(true, function () {
            // A later eligibility/preflight/backup failure must not leave the old lesson overlay
            // alive after its rollback journal has been authoritatively consumed.
            clearHighlight();
            scope.tutorial.active = false;
            scope.tutorial.lesson = null;
            scope.startLesson(i);
          })) { return; }
          return;
        }
        if (l.requiresTurbo && !(scope.base && scope.base.hasTurbocharger === true)) {
          setToast('This lesson requires a turbocharged vehicle with a controllable wastegate');
          return;
        }
        if (l.requiredVehicle && !lessonVehicleMatches(l.requiredVehicle)) {
          setToast('Load ' + l.requiredVehicle.label + ' before starting this lesson');
          if (scope.refreshBuild) { scope.refreshBuild(); }
          return;
        }
        if (l.id === 'boostshaping') {
          var preflightIssue = boostLessonPreflightIssue(scope);
          if (preflightIssue) { setToast(preflightIssue); return; }
        }
        if (l.temporary && !beginTemporaryLessonSession()) { return; }
        scope.showWelcome = false;
        ensureControlling();   // lessons need the ECU actually controlling the car
        try { localStorage.setItem('oep_seen_welcome', '1'); } catch (e) {}
        scope.tutorial.active = true;
        scope.tutorial.lessonIndex = i;
        scope.tutorial.lesson = l;
        scope.tutorial.stepIndex = 0;
        scope.tutorial.canAdvance = false;
        challengeStart(l);   // clears any previous run; no-op for a plain lesson
        if (l.setup) { loadLessonTune(l.setup); }
        enterStep();
      };
      scope.tutStepText = function () {
        var st = curStep(); if (!st) { return ''; }
        var l = scope.tutorial.lesson, tl = l && lessonL(l.id);
        if (tl && tl.steps && tl.steps[scope.tutorial.stepIndex] !== undefined) { return tl.steps[scope.tutorial.stepIndex]; }
        return st.text;
      };
      scope.tutTitle = function () {
        var l = scope.tutorial.lesson; if (!l) { return ''; }
        var tl = lessonL(l.id);
        return (tl && tl.title) || stripLessonNum(l.title);
      };
      scope.tutProgress = function () { var l = scope.tutorial.lesson; return l ? (scope.tutorial.stepIndex + 1) + ' / ' + l.steps.length : ''; };
      scope.tutCanNext = function () {
        var l = scope.tutorial.lesson;
        return scope.tutorial.canAdvance && (!l || !l.requiredVehicle || lessonVehicleMatches(l.requiredVehicle));
      };
      scope.tutVerifyText = function () {
        var st = curStep(); if (!st || !st.verify) { return ''; }
        if (scope.tutorial.canAdvance) { return st.verified || 'PASS - this step is verified.'; }
        try { return typeof st.verify === 'function' ? st.verify(scope) : st.verify; } catch (e) { return 'Waiting for a valid live check.'; }
      };
      scope.tutNextLabel = function () { var l = scope.tutorial.lesson; return (l && scope.tutorial.stepIndex >= l.steps.length - 1) ? 'Finish' : 'Next'; };
      scope.tutNext = function () {
        var l = scope.tutorial.lesson; if (!l) { return; }
        if (l.requiredVehicle && !lessonVehicleMatches(l.requiredVehicle)) {
          setToast('Return to ' + l.requiredVehicle.label + ' to continue this lesson');
          return;
        }
        if (!scope.tutorial.canAdvance) { return; }
        if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; }
        if (scope.tutorial.stepIndex >= l.steps.length - 1) { scope.tutExit(true); return; }
        scope.tutorial.stepIndex++;
        scope.tutorial.canAdvance = false;
        enterStep();
      };
      scope.tutSkip = function () {
        // Skipping a challenge's diagnosis step is allowed, but it is not free: without this it
        // was a way to bypass the quiz and still bank the "first try" criterion.
        var st = curStep();
        if (st && st.quiz && scope.chRun && !scope.chRun.ended && !scope.tutorial.canAdvance) {
          scope.chRun.quizWrong = (Number(scope.chRun.quizWrong) || 0) + 1;
        }
        scope.tutorial.canAdvance = true; scope.tutNext();
      };
      scope.tutBack = function () { if (scope.tutorial.stepIndex > 0) { scope.tutorial.stepIndex--; scope.tutorial.canAdvance = false; enterStep(); } };
      scope.tutRestart = function () {
        if (scope.tutorial.lessonIndex >= 0) { scope.startLesson(scope.tutorial.lessonIndex); }
        else if (scope.tutorial.tourPage) { scope.startTour(scope.tutorial.tourPage); }   // micro-tours restart too
      };
      function finishTutorialExit(done, restoredTemporary) {
        clearHighlight();
        // Build the result card BEFORE the lesson reference is dropped. A failed run arrives here
        // with done=false (challengeTick force-exits), so it is scored on the failure path too.
        challengeFinish(done);
        if (done && scope.tutorial.lesson && scope.tutorial.lesson.id) {
          doneLessons[scope.tutorial.lesson.id] = true;
          try { localStorage.setItem('oep_lessons_done', JSON.stringify(doneLessons)); } catch (e) {}
          refreshLessonList();
          var doneWord = scope.tutorial.lesson.isChallenge ? 'Challenge complete' : 'Lesson complete';
          setToast(restoredTemporary ? (doneWord + ' - your pre-challenge calibration was restored') : (doneWord + ' - nice work!'));
        } else if (restoredTemporary) {
          setToast('Your pre-lesson calibration and Mixture Map evidence were restored');
        }
        scope.tutorial.active = false;
        scope.tutorial.lesson = null;
      }
      scope.tutExit = function (done) {
        // FIRST, before any rollback: score the challenge against the calibration that is still
        // live. restoreTemporaryLesson below swaps the player's own tune back in.
        challengeFreezeCriteria();
        if (scope.tutorial.lesson && scope.tutorial.lesson.id === 'boostshaping' && temporaryLessonSession) {
          var exitRestoreIssue = boostLessonRestoreIssue(scope);
          if (exitRestoreIssue) { setToast(exitRestoreIssue); return; }
        }
        if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; }
        if (temporaryLessonSession) {
          if (!restoreTemporaryLesson(true, function () { finishTutorialExit(done, true); })) { return; }
          return;
        }
        finishTutorialExit(done, false);
      };
      function tutorialTick() {
        if (!scope.tutorial.active) { return; }
        var lesson = scope.tutorial.lesson;
        if (lesson && lesson.requiredVehicle && !lessonVehicleMatches(lesson.requiredVehicle)) { return; }
        var st = curStep(); if (!st || !st.check || scope.tutorial.canAdvance) { return; }
        var ok = false; try { ok = st.check(scope); } catch (e) {}
        if (ok) {
          scope.tutorial.canAdvance = true;
          if (!advanceTimer) { advanceTimer = setTimeout(function () { scope.$applyAsync(function () { advanceTimer = null; scope.tutNext(); }); }, 800); }
        }
      }

      scope.$on('$destroy', function () {
        linkDestroyed = true;
        deferredStateBeforeGameSessionNonce = null;
        if (gameSessionNonceGateTimer) { clearTimeout(gameSessionNonceGateTimer); gameSessionNonceGateTimer = null; }
        if (gameSessionNonceRetryTimer) { clearTimeout(gameSessionNonceRetryTimer); gameSessionNonceRetryTimer = null; }
        // Closing/removing the UI is also an exit path. If the bridge cannot restore at this
        // instant, the persistent journal remains and loadAutosave() recovers it next time.
        if (temporaryLessonSession && (!scope.tutorial.lesson || scope.tutorial.lesson.id !== 'boostshaping' || !boostLessonRestoreIssue(scope))) {
          restoreTemporaryLesson(true);
        }
        if (!IS_MIRROR) {
          // The trusted game UI is the crash-recovery persistence authority for
          // remote edits. Flush its last ECU echo before teardown, then close the
          // write gate so a browser cannot keep tuning only in volatile vehicle
          // memory after this app has been removed. The gauge dash remains online.
          // Flush pending remote and v2 working-draft crash recovery before teardown. This preserves
          // the live top-level draft only; saved M1-M4 bodies still change exclusively through their
          // explicit Save buttons, while Store ECU remains the whole-package acknowledgement.
          var destroyRecoveryPending = !!remoteStateAutosaveTimer || !!workingDraftAutosaveTimer ||
            Object.keys(pendingSettingEcho).length > 0 || Object.keys(pendingMapEcho).length > 0 ||
            scope.storeRequired === true || !!(scope.mapBank && scope.mapBank.workingDirty === true);
          if (destroyRecoveryPending && scope.carName && scope.maps && scope.settings) { saveAutosave(); }
          if (typeof bngApi !== 'undefined' && bngApi.engineLua) {
            bngApi.engineLua('if oeProTuneRemote and oeProTuneRemote.setWriteEnabled then oeProTuneRemote.setWriteEnabled(false) end');
          }
        }
        clearInterval(pollTimer);
        if (remoteAddrHideTimer) { clearTimeout(remoteAddrHideTimer); remoteAddrHideTimer = null; }
        if (remoteStateAutosaveTimer) { clearTimeout(remoteStateAutosaveTimer); }
        if (workingDraftAutosaveTimer) { clearTimeout(workingDraftAutosaveTimer); }
        if (frArmTimer) { clearTimeout(frArmTimer); }
        if (frValveTimer) { clearTimeout(frValveTimer); }
        if (mapBankSaveArmTimer) { clearTimeout(mapBankSaveArmTimer); }
        if (mapBankClearArmTimer) { clearTimeout(mapBankClearArmTimer); }
        if (pendingMapBankMutationTimer) { clearTimeout(pendingMapBankMutationTimer); pendingMapBankMutationTimer = null; }
        pendingMapBankMutation = null;
        scope.mapBankMutationPending = false;
        clearWorkingTunePrompt();
        pendingWorkingTuneContinuation = null;
        if (advanceTimer) { clearTimeout(advanceTimer); }
        if (toastTimer) { clearTimeout(toastTimer); }
        if (applyTuneTimer) { clearTimeout(applyTuneTimer); }
        if (scope.knockFlashTimer) { clearTimeout(scope.knockFlashTimer); }
        Object.keys(mirrorSendTimers).forEach(function (key) { clearTimeout(mirrorSendTimers[key]); });
        mirrorSendTimers = {};
        element.off('mouseup', endDrag);
        element.off('mouseleave', endDrag);
        document.removeEventListener('mousemove', winMove, true);
        document.removeEventListener('mouseup', winUp, true);
        if (browserKbPointerDown) { document.removeEventListener('mousedown', browserKbPointerDown, true); }
        if (browserKbWindowBlur) { window.removeEventListener('blur', browserKbWindowBlur, false); }
        if (studioMessageHandler) { window.removeEventListener('message', studioMessageHandler, false); }
      });

      loadNotes();

      // V3.0.9 RETURN TO WHERE YOU WERE. Same teardown as the collapse fix near the top of this link
      // function: a trip to the parts menu (or ESC, or photomode) destroys this scope, so activePage
      // and selectedMap reverted to Runtime/Fuel and you lost your place mid-tune.
      // Placed LAST on purpose. setPage() is not a plain assignment - it runs per-page setup and
      // reaches redrawSurface (defined ~3500), refreshMixUi, redrawDyno and redrawLog, all of which
      // are declared AFTER setPage itself. Calling it any earlier in link() throws on undefined.
      (function restoreLastPage() {
        var page = null, map = null;
        try { page = localStorage.getItem('oep_page'); map = localStorage.getItem('oep_map'); } catch (e) {}
        // Restore the table first: setPage overwrites selectedMap for a map page, but if the stored
        // page is Runtime/Dyno/Logging this is what makes the Tuning tab return to the right table.
        if (map && scope.isMapPage(map)) { scope.selectedMap = map; }
        // A WHITELIST, not a blanket restore, because entering a page is not free. 'buildcard' kicks
        // off the heavy mod scan that setPage deliberately defers until user-initiated; 'mixture_map'
        // and 'axis_setup' read telemetry and axes that have not arrived on the first frame. The six
        // map editors plus Dyno and Logging only ever redraw a canvas, which is safe against the empty
        // data present at startup. Anything else falls through and leaves the default Runtime page.
        if (page && (scope.isMapPage(page) || page === 'dyno' || page === 'logging')) {
          scope.setPage(page);
        }
      })();
    }
  };

  // help text kept out of the directive scope to keep it tidy
}]);

var HELP_TEXT =
  'OE PROTUNE ECU (BeamNG training build)\n\n' +
  'This is a PCLink-style training ECU that layers a simulated fuel/ignition/boost/lambda calibration on top of any BeamNG combustion engine (naturally aspirated or turbo). It teaches cause and effect: lean it out under load and it knocks, misfires, loses power and eventually grenades — just like a real motor, but you can hit Reset.\n\n' +
  'WORKFLOW\n' +
  '1. Spawn a car, open this app. It connects automatically.\n' +
  '2. Load a preset on Tune Files (Stock / Stage 1 / Aggressive / Bad lean demo).\n' +
  '3. Watch Runtime Values / Gauges during a pull.\n' +
  '4. Edit the Fuel / Ignition / Boost / Lambda tables — drag to select multiple cells, then +/- , scale %, smooth or interpolate.\n' +
  '5. Use Dyno / AFR for before/after power, Logging to review a pull, and Mixture Map for ratio-based fuel learning. Apply consumes its evidence, so re-drive before applying again.\n' +
  '6. Store saves the calibration; Reset vehicle repairs the engine.\n\n' +
  'TABLES: rows are MAP/load (kPa), columns are RPM. Fuel Main is a 1.00-centered multiplier; Ignition is a degree trim; VE is the airflow model used in Modelled mode. Boost is a kPa target (turbo only). Lambda is the air/fuel target.\n\n' +
  'NA cars: the Boost table and Boost Control / Anti-Lag have no effect; everything else applies.\n\n' +
  'This is a training tool, not real Link firmware. HP/torque come from the engine itself; the simulated channels are for learning.';
