/* OE ProTune - LINK MXS/MXG Strada digital dash recreation (widget skin).
 * Faithful in-sim recreation of the Link Engine Management digital displays
 * (MXS 5" / MXG 7" Strada, Street & Race versions, powered by AiM) rendered
 * from live OE ProTune ECU telemetry, matched against the official product
 * photography: faceted bezel with raised shift-light blister, full-circle
 * banded tach with in-band blue numerals, gear keystone, italic racing
 * numerals, blue caps labels, colored telltales, orange status banner.
 * Uses the supplied Link mark and bundled offline display fonts.
 *
 * API (consumed by oepWidgets.js):
 *   OEPLinkDash.defaults() / .aspect(cfg) / .draw(cv,t,st,cfg) / .click(cv,x,y,st,cfg)
 *   OEPLinkDash.channels / .channelOrder / .pages
 */
(function (global) {
  'use strict';

  var KPA2PSI = 0.1450377;
  var NIGHT_BASE_ALPHA = 0.42;
  // Link/AiM pages are user-configured templates, not fixed model pages.  The
  // photographed Street ring page, high-contrast Race page, and vertical
  // bar-graph page are exposed.
  var PAGES = ['ring', 'race', 'bars'];
  // The real MXG page uses AiM's wide, squared display lettering rather than a
  // generic condensed UI font.  Keep the font files local: the remote dash must
  // still work on a phone/tablet with no internet connection.
  var ASSET_ROOT = '/ui/modules/apps/OEProTune/assets/';
  var FONT_STYLE_ID = 'oep-link-mxg-fonts';
  // LinkECU asset convention: drop custom fonts into /assets/ using these names.
  // If the custom files are missing, the browser falls back to the bundled files
  // already shipped in assets/ (e.g. mxg-numeric.ttf), so the dash never breaks.
  function loadFonts() {
    if (!global.document || global.document.getElementById(FONT_STYLE_ID)) { return; }
    var fontStyle = global.document.createElement('style');
    fontStyle.id = FONT_STYLE_ID;
    fontStyle.textContent =
      '@font-face{font-family:"Link Itaca";src:url("' + ASSET_ROOT + 'itaca-wide-demibold.woff2") format("woff2");font-display:swap;}' +
      '@font-face{font-family:"Link Display";src:url("' + ASSET_ROOT + 'link-display.woff2") format("woff2");font-display:swap;}' +
      '@font-face{font-family:"MXG Numeric";src:url("' + ASSET_ROOT + 'mxg-numeric.ttf") format("truetype");font-display:swap;}';
    global.document.head.appendChild(fontStyle);
  }
  loadFonts();
  var F = '"Link Itaca", "MXG Numeric", "Arial Narrow", Arial, sans-serif';
  // AiM names Microgramma D Bold Extended in the official RaceStudio manual.
  // The bundled Michroma face is the closest redistributable upright metric
  // match.  The old Link Display face was much too italic and caused the
  // otherwise-correct text boxes to look stretched and crowded.
  var NF = '"MXG Numeric", "Link Itaca", "Arial Narrow", Arial, sans-serif';
  var LF = '"MXG Numeric", "Link Itaca", "Arial Narrow", Arial, sans-serif';
  var NUM = function (px) { return '700 ' + Math.round(px) + 'px ' + NF; };
  var LAB = function (px) { return '700 ' + Math.round(px) + 'px ' + LF; };
  var C_LABEL = '#5f9bd8', C_UNIT = '#38c9ec', C_VALUE = '#f4f7fb', C_RED = '#ff4136', C_AMBER = '#ffb84d';

  function psiOn() { try { return localStorage.getItem('oepdash_psi') !== '0'; } catch (e) { return true; } }
  function tempFOn() { try { return localStorage.getItem('oepdash_tempF') === '1'; } catch (e) { return false; } }
  function cToF(c) { return c * 9 / 5 + 32; }
  function n(v) { v = Number(v); return isFinite(v) ? v : 0; }
  function own(o, k) { return !!o && Object.prototype.hasOwnProperty.call(o, k); }
  function anyTrue(t, keys) {
    for (var i = 0; i < keys.length; i++) { if (own(t, keys[i]) && !!t[keys[i]]) { return true; } }
    return false;
  }

  // ---- channel table (AiM-style labels; values from OE ProTune telemetry) ----
  var CHANNELS = {
    ect:    { label: 'WATER TEMP', unit: function () { return tempFOn() ? '°F' : '°C'; }, d: 0, temp: true, v: function (t) { return n(t.ectC); },  warn: 104, alarm: 112 },
    oilt:   { label: 'OIL TEMP',   unit: function () { return tempFOn() ? '°F' : '°C'; }, d: 0, temp: true, v: function (t) { return n(t.oilC); },  warn: 120, alarm: 135 },
    iat:    { label: 'AIR TEMP',   unit: function () { return tempFOn() ? '°F' : '°C'; }, d: 0, temp: true, v: function (t) { return n(t.iatC); },  warn: 50,  alarm: 70 },
    egt:    { label: 'EGT',        unit: function () { return tempFOn() ? '°F' : '°C'; }, d: 0, temp: true, v: function (t) { return n(t.egtC); },  warn: 850, alarm: 950 },
    oilp:   { label: 'OIL PRESS',  unit: 'bar', d: 1, v: function (t) {
      var raw = t.oilPressureBar;
      if (raw == null) { raw = t.oilPressBar; }
      if (raw != null && raw !== '' && isFinite(Number(raw))) { return Number(raw); }
      raw = t.oilPressureKpa;
      return raw != null && raw !== '' && isFinite(Number(raw)) ? Number(raw) / 100 : NaN;
    } },
    fuelp:  { label: 'FUEL PRESS', unit: 'bar', d: 1, v: function (t) { return n(t.fuelDiffPressureKpa) / 100; }, warnLow: 2.5, alarmLow: 2.0, barMax: 6 },
    boost:  { label: 'BOOST',      unit: function () { return psiOn() ? 'psi' : 'kPa'; }, d: function () { return psiOn() ? 1 : 0; }, v: function (t) { var b = n(t.mapKpa) - (n(t.baroKpa) || 100); return psiOn() ? b * KPA2PSI : b; }, barMax: 30 },
    lambda: { label: 'LAMBDA',     unit: 'λ',   d: 2, v: function (t) { return n(t.lambdaActual); }, barMax: 1.3 },
    afr:    { label: 'AFR',        unit: 'afr', d: 1, v: function (t) { return n(t.lambdaActual) * (n(t.stoichRatio) || 14.7); }, barMax: 20 },
    duty:   { label: 'INJ DUTY',   unit: '%',   d: 0, v: function (t) { return n(t.injectorDuty); }, warn: 80, alarm: 92 },
    knock:  { label: 'KNOCK RTD',  unit: '°',   d: 1, v: function (t) { return n(t.knockRetard); }, warn: 0.1, alarm: 5 },
    ign:    { label: 'IGN ADV',    unit: '°',   d: 1, v: function (t) { return n(t.ignitionAdvance); } },
    tps:    { label: 'TPS',        unit: '%',   d: 0, v: function (t) { return n(t.tps); } },
    batt:   { label: 'ENG HEALTH', unit: '%',   d: 0, v: function (t) { return n(t.engineHealth); }, warnLow: 80, alarmLow: 50, barMax: 100 }
  };
  var CH_ORDER = ['ect', 'oilt', 'iat', 'egt', 'oilp', 'fuelp', 'boost', 'lambda', 'afr', 'duty', 'knock', 'ign', 'tps', 'batt'];

  function chUnit(c) { return typeof c.unit === 'function' ? c.unit() : c.unit; }
  function chDec(c) { return typeof c.d === 'function' ? c.d() : c.d; }
  // display-only conversion: temp channels store native °C; render in °F when the shared flag is on
  function chDisp(c, v) { return (c && c.temp && tempFOn()) ? cToF(v) : v; }
  function chState(key, t) {
    var c = CHANNELS[key]; if (!c) { return 0; }
    var v = c.v(t);
    if (c.alarm != null && v >= c.alarm) { return 2; }
    if (c.alarmLow != null && v <= c.alarmLow) { return 2; }
    if (c.warn != null && v >= c.warn) { return 1; }
    if (c.warnLow != null && v <= c.warnLow) { return 1; }
    return 0;
  }

  function defaults() {
    return {
      model: '7',            // '7' = MXG 7", '5' = MXS 5"
      version: 'street',     // MXG 7 Street is the reference hardware; Race remains selectable
      page: 'ring',
      brightness: 'auto',    // 'day' | 'night' | 'auto' (auto follows the car's lights)
      brightnessLevel: 50,   // 0-100 LCD backlight level in night/auto-night
      shiftStartPct: null,   // null = auto 3,000 RPM first-LED threshold per car; number = legacy % override
      shiftColors: 'std',    // 'std' = product-photo RGB | 'classic' green/orange/red | 'red'
      boxes: ['ect', 'fuelp', 'oilp'],
      skinVersion: 7,
      showReference: false,
      showGlare: true,
      showPixelGrid: true,
      // ---- device-menu settings (PARITY 2026-07-29) ----
      // These are what the on-device menu actually writes. skinVersion is NOT
      // bumped for them: sanitizeDashCfg fills any key missing from a saved cfg
      // straight from these defaults, so old configs upgrade silently. Bumping
      // would run the migration branch and needlessly reset user preferences.
      timeFormat: '24H',            // real device default
      dateFormat: 'MM/DD/YYYY',
      timeSync: 'by PC CLOCK',      // real row reads "by GPS Track"; we have no GPS
      nightVision: 'AUTOMATIC',     // AUTOMATIC | MANUAL
      dayBrightLevel: 100,
      nightThreshold: 20,
      videoInput: 'VIDEO 1',
      videoFormat: 'NTSC',
      videoBrightness: 50,
      videoContrast: 50,
      trackMode: 'AUTOMATIC',
      trackList: 'NEAREST',
      // ---- shift lights ----
      // 'FILL'  = a LED stays on once its threshold is exceeded
      // 'POINT' = a LED stays on only until a higher one lights (AiM's second
      //           sequence mode)
      shiftSeq: 'FILL',
      shiftPerGear: false
    };
  }
  function aspect(cfg) { return (cfg && cfg.model === '5') ? 1.75 : (1000 / 540); }

  var logo = new Image(), logoOk = false;
  logo.onload = function () { logoOk = true; };
  logo.src = ASSET_ROOT + 'link-logo.svg';

  // Official imagery is used only as a source mask for the tiny lamps.  This
  // preserves the exact ISO telltale silhouettes and frosted shift-light glow
  // while all values, states, backgrounds and interaction remain live canvas.
  var telltaleRef = new Image(), telltaleRefOk = false;
  telltaleRef.onload = function () { telltaleRefOk = true; };
  telltaleRef.src = ASSET_ROOT + 'aim-mxg-page-reference.jpg';
  var streetRef = new Image(), streetRefOk = false;
  streetRef.onload = function () { streetRefOk = true; };
  streetRef.src = ASSET_ROOT + 'link-mxg7-street-reference.jpg';
  var refMaskCache = {}, lampCoverCache = {};
  var AIM_ICON_NAMES = ['abs','battery','brake_warning','check_engine','coolant_temp','door_open','fuel_low','high_beam','left_turn','low_beam','mil','oil_pressure','parking_brake','right_turn','stability','stability_off','washer_fluid','wiper'];
  var aimIcons = {}, aimTintCache = {};
  AIM_ICON_NAMES.forEach(function (name) {
    var im = new Image(); im.src = ASSET_ROOT + 'aim-icons/' + name + '.png'; aimIcons[name] = im;
  });
  function tintedAimIcon(name, color) {
    var key = name + '|' + color, src = aimIcons[name];
    if (aimTintCache[key]) { return aimTintCache[key]; }
    if (!global.document || !src || !src.complete || !src.naturalWidth) { return null; }
    try {
      var oc = global.document.createElement('canvas'); oc.width = 64; oc.height = 64;
      var ox = oc.getContext('2d'); ox.fillStyle = color; ox.fillRect(0, 0, 64, 64);
      ox.globalCompositeOperation = 'destination-in'; ox.drawImage(src, 0, 0, 64, 64);
      // The RaceStudio picker capture contributes a one-pixel tile baseline;
      // it is UI chrome, not part of the automotive symbol.
      ox.globalCompositeOperation = 'source-over'; ox.clearRect(0, 62, 64, 2);
      aimTintCache[key] = oc; return oc;
    } catch (e) { return null; }
  }
  function drawAimIcon(ctx, name, x, y, size, color, alpha, glow) {
    var mask = tintedAimIcon(name, color); if (!mask) { return false; }
    ctx.save(); ctx.globalAlpha = (alpha == null ? 1 : alpha) * LEDDIM;
    if (glow) { ctx.shadowColor = color; ctx.shadowBlur = size * 0.32; }
    ctx.drawImage(mask, x - size / 2, y - size / 2, size, size); ctx.restore(); return true;
  }
  function refMask(img, key, sx, sy, sw, sh, mode) {
    if (refMaskCache[key]) { return refMaskCache[key]; }
    if (!global.document || !img || !img.complete) { return null; }
    try {
      var oc = global.document.createElement('canvas'); oc.width = Math.ceil(sw); oc.height = Math.ceil(sh);
      var ox = oc.getContext('2d'); ox.drawImage(img, sx, sy, sw, sh, 0, 0, oc.width, oc.height);
      var im = ox.getImageData(0, 0, oc.width, oc.height), d = im.data;
      for (var pi = 0; pi < d.length; pi += 4) {
        var mxp = Math.max(d[pi], d[pi+1], d[pi+2]);
        var mnp = Math.min(d[pi], d[pi+1], d[pi+2]);
        var signal;
        if (mode === 'color') {
          signal = Math.max(0, Math.min(1, (mxp - mnp - 18) / 70)) * Math.max(0, Math.min(1, (mxp - 38) / 120));
        } else {
          signal = Math.max(0, Math.min(1, (mxp - 24) / 66));
        }
        d[pi+3] = Math.round(d[pi+3] * signal);
      }
      ox.clearRect(0, 0, oc.width, oc.height); ox.putImageData(im, 0, 0);
      refMaskCache[key] = oc; return oc;
    } catch (e) { return null; }
  }
  function coverRefLamp(ctx, key, sx, sy, sw, sh, dx, dy, dw, dh, color) {
    var cacheKey=key+'|'+color, cover=lampCoverCache[cacheKey];
    if (!cover && global.document) {
      var mask=refMask(streetRef,key,sx,sy,sw,sh,'light');
      if (mask) {
        cover=global.document.createElement('canvas'); cover.width=mask.width; cover.height=mask.height;
        var cx=cover.getContext('2d'); cx.fillStyle=color; cx.fillRect(0,0,cover.width,cover.height);
        cx.globalCompositeOperation='destination-in'; cx.drawImage(mask,0,0);
        lampCoverCache[cacheKey]=cover;
      }
    }
    if (cover) {
      // Reapply the alpha mask to extinguish the photograph's baked glow; the
      // surrounding bezel pixels remain untouched because the mask is sparse.
      ctx.drawImage(cover,dx,dy,dw,dh); ctx.drawImage(cover,dx,dy,dw,dh); ctx.drawImage(cover,dx,dy,dw,dh);
    }
  }

  function fmt(v, d) { return isFinite(v) ? v.toFixed(d) : '--'; }
  function tDisp(c) { return tempFOn() ? Math.round(cToF(c)) + '°F' : Math.round(c) + '°C'; }   // native °C -> display string
  function pad2(x) { return (x < 10 ? '0' : '') + x; }
  function lapFmt(ms) {
    if (ms == null) { return '-:--.--'; }
    var cs = Math.round(ms / 10), m = Math.floor(cs / 6000), r = cs % 6000;
    return m + ':' + pad2(Math.floor(r / 100)) + '.' + pad2(r % 100);
  }
  // PARITY 2026-07-29: now honours the Date Time page's Time Format row. Name is
  // historical - it renders 24h too.
  function clock12(t, cfg) {
    if (t && t.clockText) { return String(t.clockText); }
    var d = new Date(), h = d.getHours();
    if (cfg && cfg.timeFormat === '24H') { return pad2(h) + ':' + pad2(d.getMinutes()); }
    var ap = h >= 12 ? 'PM' : 'AM';
    h = h % 12; if (h === 0) { h = 12; }
    return h + ':' + pad2(d.getMinutes()) + ' ' + ap;
  }
  function dateStr(cfg) {
    var d = new Date(), M = pad2(d.getMonth() + 1), D = pad2(d.getDate()), Y = d.getFullYear();
    var f = cfg && cfg.dateFormat;
    if (f === 'DD/MM/YYYY') { return D + '/' + M + '/' + Y; }
    if (f === 'YYYY/MM/DD') { return Y + '/' + M + '/' + D; }
    return M + '/' + D + '/' + Y;
  }
  /* PARITY 2026-07-29: the idle banner used to read "STEERING WHEEL POS", which
   * is not live dash text on any real unit - it reads as transcribed off a
   * RaceStudio configuration screen. With no alarm or notice up, show a real
   * value: the live ECU map slot, falling back to the car name. */
  function idleBanner(t) {
    if (t && t.mapSlotLabel) { return String(t.mapSlotLabel).toUpperCase(); }
    if (t && t.carName) { return String(t.carName).toUpperCase(); }
    return '';
  }
  function rr(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); }

  // ---- visual fidelity helpers (Phase 1) --------------------------------
  function drawReferenceOverlay(ctx, D) {
    if (!streetRefOk) { return; }
    ctx.save();
    ctx.globalAlpha = 0.35;
    ctx.globalCompositeOperation = 'source-over';
    ctx.drawImage(streetRef, 231, 126, 755, 470, D.scrX, D.scrY, D.scrW, D.scrH);
    ctx.restore();
  }
  function drawGlare(ctx, D) {
    // Diagonal glass reflection across the LCD.
    ctx.save();
    ctx.globalCompositeOperation = 'screen';
    var g = ctx.createLinearGradient(D.scrX, D.scrY, D.scrX + D.scrW, D.scrY + D.scrH);
    g.addColorStop(0, 'rgba(255,255,255,0)');
    g.addColorStop(0.35, 'rgba(255,255,255,0.03)');
    g.addColorStop(0.50, 'rgba(255,255,255,0.07)');
    g.addColorStop(0.65, 'rgba(255,255,255,0.03)');
    g.addColorStop(1, 'rgba(255,255,255,0)');
    ctx.fillStyle = g;
    ctx.beginPath();
    ctx.moveTo(D.scrX, D.scrY + D.scrH * 0.25);
    ctx.lineTo(D.scrX + D.scrW * 0.65, D.scrY);
    ctx.lineTo(D.scrX + D.scrW, D.scrY + D.scrH * 0.15);
    ctx.lineTo(D.scrX + D.scrW, D.scrY + D.scrH * 0.55);
    ctx.lineTo(D.scrX + D.scrW * 0.35, D.scrY + D.scrH);
    ctx.lineTo(D.scrX, D.scrY + D.scrH * 0.85);
    ctx.closePath();
    ctx.fill();
    ctx.restore();
  }
  function drawPixelGrid(ctx, D) {
    // Subtle LCD pixel-grid overlay; scaled so it looks right at any widget size.
    ctx.save();
    ctx.globalCompositeOperation = 'source-over';
    ctx.globalAlpha = 0.045;
    ctx.strokeStyle = '#000';
    ctx.lineWidth = 0.5;
    var step = Math.max(2, Math.min(5, Math.round(D.scrW / 160)));
    ctx.beginPath();
    for (var x = D.scrX; x <= D.scrX + D.scrW; x += step) { ctx.moveTo(x, D.scrY); ctx.lineTo(x, D.scrY + D.scrH); }
    for (var y = D.scrY; y <= D.scrY + D.scrH; y += step) { ctx.moveTo(D.scrX, y); ctx.lineTo(D.scrX + D.scrW, y); }
    ctx.stroke();
    ctx.restore();
  }
  function drawBezelScrews(ctx, sx, sy) {
    // Four corner screw heads on the outer bezel (real MXG hardware detail).
    var r = 5.5 * sx;
    [[82, 78], [918, 78], [80, 462], [920, 462]].forEach(function (p) {
      ctx.beginPath(); ctx.arc(p[0], p[1], r, 0, Math.PI * 2);
      var g = ctx.createRadialGradient(p[0] - r * 0.3, p[1] - r * 0.3, r * 0.2, p[0], p[1], r);
      g.addColorStop(0, '#9a9b9c'); g.addColorStop(1, '#2a2b2c');
      ctx.fillStyle = g; ctx.fill(); ctx.strokeStyle = '#111'; ctx.lineWidth = 1; ctx.stroke();
      ctx.beginPath(); ctx.moveTo(p[0] - r * 0.45, p[1]); ctx.lineTo(p[0] + r * 0.45, p[1]);
      ctx.strokeStyle = '#151617'; ctx.lineWidth = 1.5; ctx.stroke();
    });
  }
  function limRpm(t) { return n(t.activeRevLimit) || n(t.commandedRevLimit) || 8000; }
  function night(cfg, t) {
    if (cfg.brightness === 'day') { return false; }
    if (cfg.brightness === 'night') { return true; }
    /* AUTOMATIC. The real unit reads an ambient light sensor; the car's own
     * lighting state is the closest honest proxy.
     * PARITY 2026-07-29: this used to test the TRUTHINESS of three keys and fall
     * through to the wall clock when all three were falsy - so driving with the
     * lights OFF after 19:00 local dimmed the dash. Presence of the key is the
     * signal that we have lighting telemetry; its value then decides day/night. */
    if (t && (own(t, 'headlights') || own(t, 'lowBeam') || own(t, 'lightState'))) {
      return !!(t.headlights || t.lowBeam || (Number(t.lightState) || 0) > 0.5);
    }
    var h = new Date().getHours(); return (h >= 19 || h < 7);
  }
  function nightAlpha(cfg) {
    var level = Math.max(0, Math.min(100, Number(cfg.brightnessLevel) || 50));
    return NIGHT_BASE_ALPHA * (1 - level / 100);
  }
  /* PARITY 2026-07-29: on the real device the backlight level governs the display
   * AND the LEDs together. Only the LCD used to dim. LEDDIM is multiplied into
   * every LED/telltale alpha; draw() sets it once per frame.
   * A canvas `filter` would have been one line, but drawMxgShiftLights() sets and
   * clears ctx.filter internally for its lamp masks and would clobber it. */
  var LEDDIM = 1;
  function ledDimFor(cfg, t) {
    if (!night(cfg, t)) { return 1; }
    var level = Math.max(0, Math.min(100, Number(cfg.brightnessLevel) || 50));
    return 0.35 + 0.65 * (level / 100);
  }

  // ---- per-car shift-light profile storage ----
  function safeLsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
  function safeLsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
  function carNameToken(t) {
    return String(t && t.carName || 'unknown').replace(/[^a-zA-Z0-9]/g, '_').slice(0, 40);
  }
  // The V3.0.2 engine fingerprint is a long path (E2:G:mainEngine:/frame/slot/engine_part) whose
  // DISCRIMINATING part is at the END. Truncating it to 40 chars threw that end away, so every
  // engine option of a car produced the same key and shared one shift profile. Hash the whole
  // string instead: the readable head stays for debuggability, the hash carries the identity.
  function fpToken(fp) {
    var s = String(fp == null ? 'na' : fp) || 'na';
    var h = 5381;
    for (var i = 0; i < s.length; i++) { h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; }
    return s.replace(/[^a-zA-Z0-9\-]/g, '_').slice(0, 24) + '_' + h.toString(36);
  }
  function legacyFpToken(fp) {   // the pre-fix truncation, kept solely to find existing profiles
    return String(fp == null ? 'na' : fp).replace(/[^a-zA-Z0-9\-]/g, '_').slice(0, 40);
  }
  function carKey(t) { return carNameToken(t) + '_' + fpToken(t && t.engineFp); }
  function shiftProfileKey(t) { return 'oepdash_linkshift_' + carKey(t); }
  // Ordered list of keys this car's profile may already live under, newest scheme first.
  function shiftProfileKeyCandidates(t) {
    var name = carNameToken(t), out = [shiftProfileKey(t)];
    out.push('oepdash_linkshift_' + name + '_' + legacyFpToken(t && t.engineFp));
    var legacyFp = t && (t.engineFpLegacy || (t.base && t.base.engineFpLegacy));
    if (legacyFp) { out.push('oepdash_linkshift_' + name + '_' + legacyFpToken(legacyFp)); }
    return out;
  }
  // Read through the older key schemes once and adopt the result under the current key, so a shift
  // profile set up before this build is not silently replaced by defaults.
  function readShiftProfileRaw(t) {
    var keys = shiftProfileKeyCandidates(t);
    for (var i = 0; i < keys.length; i++) {
      var raw = safeLsGet(keys[i]);
      if (raw != null) {
        if (i > 0) { safeLsSet(keys[0], raw); }
        return raw;
      }
    }
    return null;
  }
  function loadShiftProfile(t, cfg) {
    var lim = limRpm(t);
    // Real Link ECUs use absolute RPM thresholds. Default first LED = 3,000 RPM.
    var autoFirst = Math.min(100, Math.max(20, 3000 / lim * 100));
    var first = (cfg && cfg.shiftStartPct != null) ? Number(cfg.shiftStartPct) : null;
    if (!isFinite(first) || first <= 0) { first = autoFirst; }
    var last = 100;
    var saved = null;
    var raw = readShiftProfileRaw(t);
    if (raw) { try { saved = JSON.parse(raw); } catch (e) {} }
    if (saved && isFinite(saved.firstPct)) { first = Math.max(20, Math.min(100, Number(saved.firstPct))); }
    if (saved && isFinite(saved.lastPct)) { last = Math.max(20, Math.min(100, Number(saved.lastPct))); }
    if (last < first) { last = first; }
    // Optional absolute per-LED table, and an optional per-gear set. Both are
    // sanitised here so a hand-edited or stale entry can never reach the drawer.
    var rpmTable = null, perGear = null, gi, gk;
    if (saved && Object.prototype.toString.call(saved.rpm) === '[object Array]' && saved.rpm.length === 10) {
      rpmTable = [];
      for (gi = 0; gi < 10; gi++) {
        var rv = Number(saved.rpm[gi]);
        rpmTable.push(isFinite(rv) && rv > 0 ? rv : (lim * (first + (last - first) * (gi / 9)) / 100));
      }
    }
    if (saved && saved.perGear && typeof saved.perGear === 'object') {
      perGear = {};
      for (gk in saved.perGear) {
        if (!own(saved.perGear, gk)) { continue; }
        var arr = saved.perGear[gk];
        if (Object.prototype.toString.call(arr) !== '[object Array]' || arr.length !== 10) { continue; }
        var clean = [], okRow = true;
        for (gi = 0; gi < 10; gi++) {
          var v2 = Number(arr[gi]);
          if (!isFinite(v2) || v2 <= 0) { okRow = false; break; }
          clean.push(v2);
        }
        if (okRow) { perGear[gk] = clean; }
      }
    }
    return { firstPct: first, lastPct: last, rpm: rpmTable, perGear: perGear };
  }
  /* ---- COUNTERS / odometers (PARITY 2026-07-29) ----------------------
   * The real COUNTERS page carries one non-resettable System odometer and four
   * user odometers, each independently start/stop-able and resettable, with an
   * asterisk marking a running one. Ours used to show MAX RPM and MAX SPEED -
   * session peaks masquerading as odometers, and they already live on the Data
   * Recall page where they belong.
   * Distance is integrated from speed; System prefers the vehicle's own
   * odometer electric when one is published. */
  function odoKey(t) { return 'oepdash_odo_' + carKey(t); }
  function loadOdo(t) {
    var o = null;
    try { o = JSON.parse(safeLsGet(odoKey(t)) || 'null'); } catch (e) { o = null; }
    if (!o || typeof o !== 'object') { o = {}; }
    var u = Object.prototype.toString.call(o.u) === '[object Array]' ? o.u : [];
    var run = Object.prototype.toString.call(o.run) === '[object Array]' ? o.run : [];
    var out = { sys: Number(o.sys) || 0, u: [], run: [] };
    for (var i = 0; i < 4; i++) { out.u.push(Number(u[i]) || 0); out.run.push(!!run[i]); }
    return out;
  }
  function saveOdo(t, o) {
    if (!t || !t.carName) { return; }
    safeLsSet(odoKey(t), JSON.stringify(o));
  }
  function tickOdo(t, st) {
    var now = Date.now();
    if (!st._odo) { st._odo = loadOdo(t); st._odoAt = now; st._odoSave = now; }
    var dt = (now - (st._odoAt || now)) / 1000;
    st._odoAt = now;
    // A long gap means the game was paused or the app was not compositing -
    // integrating across it would invent kilometres.
    if (dt > 0 && dt < 2) {
      var v = n(t.speedKmh) / 3.6;
      if (v > 0.3) {
        var d = v * dt;
        for (var i = 0; i < 4; i++) { if (st._odo.run[i]) { st._odo.u[i] += d; } }
        if (!own(t, 'odometer')) { st._odo.sys += d; }
      }
    }
    if (own(t, 'odometer')) { st._odo.sys = n(t.odometer); }
    if (now - (st._odoSave || 0) > 5000) { st._odoSave = now; saveOdo(t, st._odo); }
  }

  function saveShiftProfile(t, profile) {
    if (!t || !t.carName) { return; }
    var blob = { firstPct: Number(profile.firstPct) || 80, lastPct: Number(profile.lastPct) || 100 };
    /* MERGE, don't replace. OE ProTune's widget (oepWidgets.js) only knows about
     * firstPct/lastPct, so a slider drag there would otherwise silently destroy
     * a per-LED or per-gear table authored in the standalone dash. Carry the
     * tables through unless this call explicitly supplies new ones. */
    var prev = null;
    try { prev = JSON.parse(readShiftProfileRaw(t) || 'null'); } catch (e) { prev = null; }
    var rpmIn = Object.prototype.toString.call(profile.rpm) === '[object Array]' && profile.rpm.length === 10 ? profile.rpm : null;
    var gearIn = profile.perGear && typeof profile.perGear === 'object' ? profile.perGear : null;
    if (rpmIn) { blob.rpm = rpmIn; }
    else if (prev && Object.prototype.toString.call(prev.rpm) === '[object Array]' && prev.rpm.length === 10) { blob.rpm = prev.rpm; }
    if (gearIn) { blob.perGear = gearIn; }
    else if (prev && prev.perGear && typeof prev.perGear === 'object') { blob.perGear = prev.perGear; }
    safeLsSet(shiftProfileKey(t), JSON.stringify(blob));
  }
  /* PARITY 2026-07-29: thresholds used to be TWO percentages interpolated
   * linearly across all ten LEDs, so LED 6 could not be moved on its own. The
   * real unit sets an absolute RPM per LED, optionally per engaged gear.
   * The interpolated pair is kept as the fallback: a profile with no explicit
   * table still behaves exactly as before, so nobody's saved setup changes. */
  function shiftThresholds(t, cfg, gearOverride) {
    var lim = limRpm(t), prof = loadShiftProfile(t, cfg);
    var g = gearOverride != null ? gearOverride : Math.round(n(t.gearIndex));
    if (cfg && cfg.shiftPerGear && prof.perGear && g > 0 && prof.perGear[g]) {
      return prof.perGear[g].slice(0, 10);
    }
    if (prof.rpm && prof.rpm.length === 10) { return prof.rpm.slice(0); }
    var first = lim * (prof.firstPct / 100), last = lim * (prof.lastPct / 100);
    var out = [];
    for (var i = 0; i < 10; i++) { out.push(first + (last - first) * (i / 9)); }
    return out;
  }
  // How many LEDs are lit at this RPM, honouring the sequence mode.
  //   FILL  - every LED whose threshold is passed stays on   (default)
  //   POINT - only the highest LED whose threshold is passed  (AiM's mode b)
  function shiftLitMask(rpm, thresholds, cfg) {
    var count = 0, i;
    for (i = 0; i < 10; i++) { if (rpm >= thresholds[i]) { count = i + 1; } }
    var point = cfg && cfg.shiftSeq === 'POINT';
    var mask = [];
    for (i = 0; i < 10; i++) { mask.push(point ? (i === count - 1) : (i < count)); }
    return { count: count, on: mask };
  }

  // ---- alarms / status states ----
  function alarms(t) {
    var out = [];
    if (String(t.faults || 'None') !== 'None' && String(t.faults || '') !== '') { out.push({ id: 'engine', msg: 'CHECK ENGINE: ' + t.faults, sev: 2 }); }
    if (n(t.knockRetard) > 0.1) { out.push({ id: 'knock', msg: 'KNOCK DETECTED ' + fmt(n(t.knockRetard), 1) + '°', sev: 2 }); }
    if (chState('ect', t) === 2) { out.push({ id: 'ect', msg: 'WATER TEMP HIGH ' + tDisp(n(t.ectC)), sev: 2 }); }
    else if (chState('ect', t) === 1) { out.push({ id: 'ect', msg: 'WATER TEMP ' + tDisp(n(t.ectC)), sev: 1 }); }
    if (chState('oilt', t) >= 1) { out.push({ id: 'oilt', msg: 'OIL TEMP ' + tDisp(n(t.oilC)), sev: chState('oilt', t) }); }
    if (t.fuelDiffPressureKpa != null && chState('fuelp', t) >= 1) { out.push({ id: 'fuel', msg: 'FUEL PRESSURE LOW', sev: chState('fuelp', t) }); }
    if (oilPressureWarning(t)) { out.push({ id: 'oilp', msg: 'OIL PRESS LOW', sev: 2 }); }
    if (Number(t.batteryVoltage) > 0 && Number(t.batteryVoltage) < 12.0) { out.push({ id: 'batt', msg: 'BATTERY LOW ' + fmt(n(t.batteryVoltage), 1) + 'V', sev: 2 }); }
    if (t.boostMaxed) { out.push({ id: 'boost', msg: 'BOOST TARGET NOT MET', sev: 1 }); }
    if (n(t.mapSlotPending) > 0) { out.push({ id: 'map', msg: 'MAP ' + Math.round(n(t.mapSlotPending)) + ' PENDING - ' + String(t.mapSwitchBlockReason || 'lift throttle').toUpperCase(), sev: 1 }); }
    if (t.limpMode) { out.push({ id: 'limp', msg: 'LIMP MODE', sev: 2 }); }
    if (chState('duty', t) >= 1) { out.push({ id: 'inj', msg: 'INJECTOR DUTY HIGH ' + Math.round(n(t.injectorDuty)) + '%', sev: chState('duty', t) }); }
    if (t.limiterActive) { out.push({ id: 'lim', msg: String(t.limiterStatus || 'LIMITER').toUpperCase(), sev: 1 }); }
    if (t.launchActive) { out.push({ id: 'launch', msg: '', sev: 0 }); }
    if (t.rollingActive) { out.push({ id: 'rolling', msg: '', sev: 0 }); }
    if (t.flatShiftActive) { out.push({ id: 'flatshift', msg: '', sev: 0 }); }
    return out;
  }
  // PARITY 2026-07-29: used to return 1 for a severity-0 entry, so "present but
  // inert" was indistinguishable from "alarming" to every caller. alarms()
  // pushes launch/rolling/flatShift at sev 0 purely as state markers.
  function hasAlarm(list, id) { for (var i = 0; i < list.length; i++) { if (list[i].id === id) { return list[i].sev >= 1 ? list[i].sev : 0; } } return 0; }
  function bannerAlarms(list) { var out = []; for (var i = 0; i < list.length; i++) { if (list[i].sev >= 1) { out.push(list[i]); } } return out; }
  function oilPressureWarning(t) {
    if (anyTrue(t, ['oilPressureLow', 'lowOilPressure', 'oilPressureWarning'])) { return true; }
    var v = CHANNELS.oilp.v(t);
    return isFinite(v) && n(t.rpm) > 500 && v < 0.8;
  }

  // ---- telltale icon painter ----
  function icon(ctx, kind, x, y, s, color, glow) {
    ctx.save(); ctx.translate(x, y); ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = Math.max(1, s * 0.1); ctx.lineJoin = 'round'; ctx.lineCap = 'round';
    if (glow) { ctx.shadowColor = color; ctx.shadowBlur = s * 0.8; }
    var u = s / 2;
    if (kind === 'turnL' || kind === 'turnR') {
      var d = kind === 'turnL' ? -1 : 1;
      ctx.beginPath(); ctx.moveTo(d * u, 0); ctx.lineTo(d * u * 0.1, -u * 0.8); ctx.lineTo(d * u * 0.1, -u * 0.35); ctx.lineTo(-d * u, -u * 0.35); ctx.lineTo(-d * u, u * 0.35); ctx.lineTo(d * u * 0.1, u * 0.35); ctx.lineTo(d * u * 0.1, u * 0.8); ctx.closePath(); ctx.fill();
    } else if (kind === 'beamHi' || kind === 'beamLo') {
      ctx.beginPath(); ctx.arc(u * 0.25, 0, u * 0.62, Math.PI * 0.5, Math.PI * 1.5); ctx.closePath(); ctx.fill();
      for (var i = -1; i <= 1; i++) {
        ctx.beginPath();
        if (kind === 'beamHi') { ctx.moveTo(-u * 0.15, i * u * 0.42); ctx.lineTo(-u, i * u * 0.42); }
        else { ctx.moveTo(-u * 0.15, i * u * 0.42 + u * 0.1); ctx.lineTo(-u, i * u * 0.42 + u * 0.28); }
        ctx.stroke();
      }
    } else if (kind === 'engine') {
      ctx.beginPath(); ctx.moveTo(-u * .95, -u * .18); ctx.lineTo(-u * .72, -u * .18);
      ctx.lineTo(-u * .53, -u * .52); ctx.lineTo(u * .27, -u * .52); ctx.lineTo(u * .48, -u * .31);
      ctx.lineTo(u * .83, -u * .31); ctx.lineTo(u * .83, u * .45); ctx.lineTo(u * .52, u * .45);
      ctx.lineTo(u * .32, u * .68); ctx.lineTo(-u * .55, u * .68); ctx.lineTo(-u * .76, u * .38);
      ctx.lineTo(-u * .95, u * .38); ctx.closePath(); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(-u * .28, -u * .78); ctx.lineTo(u * .25, -u * .78); ctx.lineTo(u * .25, -u * .52); ctx.stroke();
    } else if (kind === 'oil') {
      ctx.beginPath(); ctx.moveTo(-u, -u * .22); ctx.lineTo(u * .28, -u * .22); ctx.lineTo(u * .7, u * .18);
      ctx.lineTo(u * .7, u * .55); ctx.lineTo(-u * .78, u * .55); ctx.lineTo(-u * .78, u * .18); ctx.lineTo(-u, u * .18); ctx.closePath(); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(-u * .5, -u * .62); ctx.lineTo(u * .18, -u * .62); ctx.lineTo(u * .18, -u * .22); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(u * .82, -u * .18); ctx.quadraticCurveTo(u * 1.08, u * .18, u * .84, u * .43); ctx.quadraticCurveTo(u * .58, u * .18, u * .82, -u * .18); ctx.stroke();
    } else if (kind === 'temp') {
      ctx.beginPath(); ctx.arc(0, u * .42, u * .34, -Math.PI * .25, Math.PI * 1.25); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(-u * .17, u * .18); ctx.lineTo(-u * .17, -u * .72); ctx.quadraticCurveTo(0, -u, u * .17, -u * .72); ctx.lineTo(u * .17, u * .18); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(0, -u * .42); ctx.lineTo(0, u * .42); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(-u * .88, u * .82); ctx.quadraticCurveTo(-u * .58, u * .56, -u * .28, u * .82); ctx.quadraticCurveTo(0, u * 1.05, u * .3, u * .82); ctx.quadraticCurveTo(u * .58, u * .56, u * .88, u * .82); ctx.stroke();
    } else if (kind === 'batt') {
      rr(ctx, -u * .82, -u * .48, u * 1.64, u * 1.05, u * .1); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(-u * .55, -u * .72); ctx.lineTo(-u * .22, -u * .72); ctx.lineTo(-u * .22, -u * .48);
      ctx.moveTo(u * .22, -u * .48); ctx.lineTo(u * .22, -u * .72); ctx.lineTo(u * .55, -u * .72); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(-u * .58, u * .05); ctx.lineTo(-u * .16, u * .05); ctx.moveTo(-u * .37, -u * .16); ctx.lineTo(-u * .37, u * .27);
      ctx.moveTo(u * .18, u * .05); ctx.lineTo(u * .6, u * .05); ctx.stroke();
    } else if (kind === 'fuel') {
      rr(ctx, -u * 0.7, -u * 0.75, u * 1.0, u * 1.55, u * 0.1); ctx.fill();
      ctx.beginPath(); ctx.moveTo(u * 0.45, -u * 0.2); ctx.lineTo(u * 0.75, -u * 0.45); ctx.lineTo(u * 0.75, u * 0.55); ctx.stroke();
    } else if (kind === 'warn') {
      ctx.beginPath(); ctx.moveTo(0, -u * 0.85); ctx.lineTo(u * 0.9, u * 0.7); ctx.lineTo(-u * 0.9, u * 0.7); ctx.closePath(); ctx.stroke();
      ctx.fillRect(-u * 0.09, -u * 0.35, u * 0.18, u * 0.6); ctx.fillRect(-u * 0.09, u * 0.38, u * 0.18, u * 0.18);
    } else if (kind === 'brake' || kind === 'park') {
      ctx.beginPath(); ctx.arc(0, 0, u * 0.72, 0, 6.2832); ctx.stroke();
      ctx.beginPath(); ctx.arc(-u * 0.18, 0, u * 1.02, Math.PI * 0.62, Math.PI * 1.38); ctx.stroke();
      ctx.beginPath(); ctx.arc(u * 0.18, 0, u * 1.02, -Math.PI * 0.38, Math.PI * 0.38); ctx.stroke();
      ctx.font = '800 ' + Math.round(s * 0.68) + 'px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
      ctx.fillText(kind === 'park' ? 'P' : '!', 0, u * 0.05);
    } else if (kind === 'defog') {
      ctx.beginPath(); ctx.moveTo(-u, u * 0.72); ctx.quadraticCurveTo(-u * 0.72, -u * 0.78, 0, -u * 0.86); ctx.quadraticCurveTo(u * 0.72, -u * 0.78, u, u * 0.72); ctx.stroke();
      for (var di = -1; di <= 1; di++) {
        ctx.beginPath(); ctx.moveTo(di * u * 0.55, u * 0.48); ctx.lineTo(di * u * 0.55, -u * 0.28);
        ctx.lineTo(di * u * 0.55 - u * 0.18, -u * 0.04); ctx.stroke();
      }
    } else if (kind === 'belt') {
      ctx.beginPath(); ctx.arc(-u * 0.28, -u * 0.62, u * 0.22, 0, 6.2832); ctx.fill();
      ctx.beginPath(); ctx.moveTo(-u * 0.3, -u * 0.32); ctx.lineTo(-u * 0.32, u * 0.42); ctx.lineTo(-u * 0.78, u * 0.92);
      ctx.moveTo(-u * 0.28, 0); ctx.lineTo(u * 0.62, u * 0.92); ctx.moveTo(u * 0.38, -u * 0.78); ctx.lineTo(u * 0.38, u * 0.22); ctx.lineTo(u * 0.9, u * 0.22); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(-u * 0.82, -u * 0.86); ctx.lineTo(u * 0.82, u * 0.88); ctx.stroke();
    } else if (kind === 'P') {
      ctx.font = '800 ' + Math.round(s * 1.05) + 'px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(kind, 0, 0);
    } else if (kind === 'ABS') {
      ctx.beginPath(); ctx.arc(0, 0, u * .72, 0, 6.2832); ctx.stroke();
      ctx.beginPath(); ctx.arc(-u * .2, 0, u * 1.05, Math.PI * .62, Math.PI * 1.38); ctx.stroke();
      ctx.beginPath(); ctx.arc(u * .2, 0, u * 1.05, -Math.PI * .38, Math.PI * .38); ctx.stroke();
      ctx.font = '800 ' + Math.round(s * .42) + 'px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('ABS', 0, u * .03);
    } else if (kind === 'TC') {
      ctx.beginPath(); ctx.moveTo(-u * .8, -u * .15); ctx.lineTo(-u * .5, -u * .58); ctx.lineTo(u * .4, -u * .58); ctx.lineTo(u * .72, -u * .15);
      ctx.lineTo(u * .92, u * .05); ctx.lineTo(u * .92, u * .5); ctx.lineTo(u * .58, u * .5);
      ctx.moveTo(-u * .58, u * .5); ctx.lineTo(-u * .92, u * .5); ctx.lineTo(-u * .92, u * .05); ctx.closePath(); ctx.stroke();
      ctx.beginPath(); ctx.arc(-u * .53, u * .48, u * .16, 0, 6.2832); ctx.arc(u * .53, u * .48, u * .16, 0, 6.2832); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(-u * .8, u * .86); ctx.quadraticCurveTo(-u * .45, u * .58, -u * .1, u * .86); ctx.quadraticCurveTo(u * .22, u * 1.08, u * .62, u * .83); ctx.stroke();
    } else if (kind === 'wiper') {
      ctx.beginPath(); ctx.moveTo(-u, -u * .2); ctx.quadraticCurveTo(0, u * 1.25, u, -u * .2); ctx.lineTo(u * .75, u * .68); ctx.lineTo(-u * .75, u * .68); ctx.closePath(); ctx.stroke();
      [-.62, 0, .62].forEach(function (dx) { ctx.beginPath(); ctx.moveTo(dx * u, -u * .58); ctx.lineTo(dx * u * .9, -u * .9); ctx.stroke(); });
    }
    ctx.restore();
  }

  // ---- shift strip (photo default: cyan/blue/magenta/purple/red/red/orange/orange/white/white) ----
  var SHIFT_PRESETS = {
    std:     ['#32d6f4', '#32d6f4', '#d345d7', '#d345d7', '#ff2632', '#ff2632', '#ffad22', '#ffad22', '#f8fbff', '#f8fbff'],
    classic: ['#22cc44', '#22cc44', '#22cc44', '#ffa321', '#ffa321', '#ffa321', '#ff3222', '#ff3222', '#3a66ff', '#3a66ff'],
    red:     ['#ff3222', '#ff3222', '#ff3222', '#ff3222', '#ff3222', '#ff3222', '#ff3222', '#ff3222', '#ff3222', '#ff3222']
  };
  function drawShiftStrip(ctx, x, y, w, t, cfg) {
    var N = 10, rpm = n(t.rpm), lim = limRpm(t);
    var thresholds = shiftThresholds(t, cfg), cols = SHIFT_PRESETS[cfg.shiftColors] || SHIFT_PRESETS.std;
    var seq5 = shiftLitMask(rpm, thresholds, cfg);
    var flash = rpm >= lim - 30 && (Math.floor(Date.now() / 90) % 2 === 0);
    var r = w / N * 0.30;
    for (var i = 0; i < N; i++) {
      var cx = x + w / N * (i + 0.5), on = flash || seq5.on[i];
      ctx.beginPath(); ctx.arc(cx, y, r, 0, 6.2832);
      if (flash) { ctx.fillStyle = '#3a66ff'; ctx.shadowColor = '#3a66ff'; ctx.shadowBlur = r * 2.4; }
      else if (on) { ctx.fillStyle = cols[i]; ctx.shadowColor = cols[i]; ctx.shadowBlur = r * 2.2; }
      else { ctx.fillStyle = '#101114'; ctx.shadowBlur = 0; }
      ctx.fill(); ctx.shadowBlur = 0;
      ctx.strokeStyle = '#050607'; ctx.lineWidth = Math.max(1, r * 0.18); ctx.stroke();
    }
  }

  // ---- bezel alarm LEDs (street icons wired to sim states; race = numbered LEDs) ----
  var STREET_L = [{ k: 'turnL', c: '#33e04a', a: 'left' }, { k: 'beamHi', c: '#2f7bff', a: 'high' }, { k: 'beamLo', c: '#33e04a', a: 'low' }, { k: 'engine', c: '#ffb321', a: 'engine' }];
  var STREET_R = [{ k: 'turnR', c: '#33e04a', a: 'right' }, { k: 'oil', c: '#ff3b30', a: 'oilp' }, { k: 'temp', c: '#ff3b30', a: 'ect' }, { k: 'fuel', c: '#ffb321', a: 'fuelLow' }];
  var RACE_MAP = ['knock', 'ect', 'oilt', 'fuel', 'engine', 'lim', 'boost', 'inj'];
  var RACE_COLS = ['#1e70ef', '#32d6f4', '#39db55', '#f8fbff', '#f8fbff', '#d345d7', '#ff2632', '#ffad22'];
  function streetLit(id, t, al) {
    var flash = Math.floor(Date.now() / 200) % 2 === 0;
    if (id === 'left') { return !!t.leftTurnSignal || (Number(t.turnSignal) || 0) < -0.5; }
    if (id === 'right') { return !!t.rightTurnSignal || (Number(t.turnSignal) || 0) > 0.5; }
    if (id === 'high') { return !!t.highBeam; }
    if (id === 'low') { return !!t.lowBeam && !t.highBeam; }
    // PARITY 2026-07-29: the oil-pressure and MIL lamps used to be forced to
    // blink at ~2.5 Hz. Both are steady lamps on a real car; only the turn
    // signals and the low-fuel telltale on some ECUs ever flash.
    if (id === 'oilp') { return oilPressureWarning(t); }
    if (id === 'fuelLow') { return Number(t.fuelLevel) < 0.10 || hasAlarm(al, 'fuel') >= 1; }
    if (id === 'parking') { return !!t.parkingBrake; }
    // PARITY 2026-07-29: this is the ISO brake FAULT lamp, not a pedal
    // indicator - it used to light every time you touched the brake. Nothing in
    // BeamNG or ProTune models a brake-circuit fault, so it stays dark unless a
    // producer appears.
    if (id === 'brake') { return !!t.brakeFault; }
    if (id === 'abs') { return !!t.absActive; }
    if (id === 'esc') { return !!t.escActive; }
    if (id === 'door') { return !!t.doorOpen || (Array.isArray(t.doors) && t.doors.some(function (d) { return !!d; })); }
    if (id === 'wiper') { return !!t.wiperOn; }
    if (id === 'batt') { return Number(t.batteryVoltage) > 0 && Number(t.batteryVoltage) < 12.0; }
    if (id === 'engine') { return hasAlarm(al, 'engine') >= 1 || !!t.oepCheckEngine; }
    return !!hasAlarm(al, id);
  }
  function telltaleOn(id, t, al) {
    if (id === 'battery') { return Number(t.batteryVoltage) > 0 && Number(t.batteryVoltage) < 12.0; }
    if (id === 'check_engine') { return !!t.oepCheckEngine || hasAlarm(al, 'engine') >= 1; }
    if (id === 'oil_pressure') { return oilPressureWarning(t); }
    // PARITY 2026-07-29: was a duplicate of the wiper lamp - both read wiperOn,
    // so they were always identical. Washer level is not modelled anywhere.
    if (id === 'washer_fluid') { return !!t.washerLow; }
    if (id === 'brake_warning') { return !!t.brakeFault; }   // fault lamp, not the pedal - see streetLit
    if (id === 'parking_brake') { return !!t.parkingBrake; }
    if (id === 'abs') { return !!t.absActive; }
    if (id === 'coolant_temp') { return hasAlarm(al, 'ect') >= 1; }
    if (id === 'stability') { return !!t.escActive; }
    // PARITY 2026-07-29: was `!escActive && speed > 5`, i.e. permanently lit any
    // time you drove without ESC actively intervening. The real lamp means ESC
    // has been switched OFF, which nothing publishes.
    if (id === 'stability_off') { return !!t.escDisabled; }
    if (id === 'wiper') { return !!t.wiperOn; }
    if (id === 'door_open') { return !!t.doorOpen || streetLit('door', t, al); }
    if (id === 'fuel_low') { return Number(t.fuelLevel) < 0.10; }
    // PARITY 2026-07-29: the fall-through used to return t.showcase, so any
    // unrecognised icon id silently became a demo flag. Callers already OR in
    // showcase themselves where a lamp test is wanted.
    return false;
  }

  function drawBezelLeds(ctx, D, t, cfg, al) {
    var i, e, lit;
    if (cfg.version === 'street') {
      for (i = 0; i < 4; i++) {
        e = STREET_L[i]; lit = streetLit(e.a, t, al);
        icon(ctx, e.k, D.bezL / 2, D.scrY + D.scrH * (0.14 + i * 0.22), D.u * 1.25, lit ? e.c : 'rgba(255,255,255,.09)', !!lit);
      }
      for (i = 0; i < 4; i++) {
        e = STREET_R[i]; lit = streetLit(e.a, t, al);
        icon(ctx, e.k, D.W - D.bezL / 2, D.scrY + D.scrH * (0.14 + i * 0.22), D.u * 1.25, lit ? e.c : 'rgba(255,255,255,.09)', !!lit);
      }
    } else {
      for (i = 0; i < 8; i++) {
        var left = i < 4, idx = left ? i : i - 4;
        var x = left ? D.bezL / 2 : D.W - D.bezL / 2;
        var y = D.scrY + D.scrH * (0.12 + idx * 0.22);
        lit = hasAlarm(al, RACE_MAP[i]);
        ctx.save(); ctx.translate(x, y); ctx.rotate(left ? -0.28 : 0.28);
        rr(ctx, -D.u * 0.9, -D.u * 0.3, D.u * 1.8, D.u * 0.6, D.u * 0.25);
        ctx.fillStyle = lit ? RACE_COLS[i] : 'rgba(255,255,255,.08)';
        if (lit) { ctx.shadowColor = RACE_COLS[i]; ctx.shadowBlur = D.u * 1.4; }
        ctx.fill(); ctx.shadowBlur = 0; ctx.restore();
        ctx.fillStyle = lit ? 'rgba(0,0,0,.75)' : 'rgba(255,255,255,.35)';
        ctx.font = '700 ' + Math.round(D.u * 0.55) + 'px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
        ctx.fillText(String(i + 1), x, y);
      }
    }
  }

  function chValue(key, t, st) {
    var c = CHANNELS[key];
    if (!c) { return { v: NaN, mode: 'live', c: { label: String(key || '?'), unit: '', d: 0 } }; }
    var mode = (st.boxMode && st.boxMode[key]) || 'live';
    var v = c.v(t), mm = st.minmax[key];
    if (mode === 'max' && mm) { v = mm.max; } else if (mode === 'min' && mm) { v = mm.min; }
    return { v: v, mode: mode, c: c };
  }

  // =====================================================================
  // PAGE: 7" ring — matched to the MXG product photo
  // =====================================================================
  function drawRingPageLegacy(ctx, D, t, st, cfg, al) {
    var X = D.scrX, Y = D.scrY, W = D.scrW, H = D.scrH;
    // dark navy gradient screen
    var bg = ctx.createLinearGradient(0, Y, 0, Y + H);
    bg.addColorStop(0, '#101c30'); bg.addColorStop(0.45, '#0b1424'); bg.addColorStop(1, '#080f1c');
    ctx.fillStyle = bg; ctx.fillRect(X, Y, W, H);

    var bannerH = H * 0.105, iconH = H * 0.085;
    var bodyH = H - bannerH - iconH;

    // LAP (top-left) - white italic numerals, blue caps label
    ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
    ctx.fillStyle = C_VALUE; ctx.font = NUM(H * 0.125);
    ctx.fillText(String(st.lap.n), X + W * 0.02, Y + H * 0.125);
    ctx.fillStyle = C_LABEL; ctx.font = LAB(H * 0.045);
    ctx.fillText('LAP', X + W * 0.022, Y + H * 0.175);

    // LAP TIME (top-right)
    ctx.textAlign = 'right';
    ctx.fillStyle = C_VALUE; ctx.font = NUM(H * 0.125);
    ctx.fillText(lapFmt(st.lap.showMs), X + W * 0.985, Y + H * 0.125);
    ctx.fillStyle = C_LABEL; ctx.font = LAB(H * 0.045);
    ctx.fillText('LAP TIME', X + W * 0.985, Y + H * 0.175);

    // ---- full-circle banded tach ----
    var cx = X + W * 0.315, cy = Y + bodyH * 0.565, R = bodyH * 0.475;
    var lim = limRpm(t), maxK = Math.max(8, Math.ceil((lim + 500) / 1000)), rpm = n(t.rpm);
    // numerals 1..maxK from 7:30 clockwise round to 5:30 (gap at the bottom)
    var a0 = Math.PI * 0.78, a1 = Math.PI * 2.42;
    var ang = function (k) { return a0 + (a1 - a0) * (k - 1) / (maxK - 1); };
    var band = R * 0.20, Rmid = R - band / 2;

    // band base
    ctx.beginPath(); ctx.arc(cx, cy, Rmid, 0, 6.2832);
    ctx.strokeStyle = '#242f44'; ctx.lineWidth = band; ctx.stroke();
    // blue progress fill along the band up to the needle
    var kNow = Math.max(1, Math.min(maxK, rpm / 1000));
    if (rpm > 120) {
      ctx.beginPath(); ctx.arc(cx, cy, Rmid, a0, ang(kNow));
      ctx.strokeStyle = 'rgba(72,124,196,.42)'; ctx.lineWidth = band; ctx.stroke();
    }
    // redline outer edge marks
    var kRed = lim / 1000;
    ctx.beginPath(); ctx.arc(cx, cy, R - band * 0.12, ang(Math.max(1, kRed)), a1);
    ctx.strokeStyle = C_RED; ctx.lineWidth = band * 0.22; ctx.stroke();
    // fine minor ticks across the band (every 200 rpm)
    var k;
    for (k = 1; k <= (maxK - 1) * 5; k++) {
      var kv = 1 + k / 5, a = ang(kv);
      var t1x = cx + Math.cos(a) * (R - band * 0.28), t1y = cy + Math.sin(a) * (R - band * 0.28);
      var t2x = cx + Math.cos(a) * (R - band * 0.05), t2y = cy + Math.sin(a) * (R - band * 0.05);
      ctx.beginPath(); ctx.moveTo(t1x, t1y); ctx.lineTo(t2x, t2y);
      ctx.strokeStyle = kv * 1000 >= lim ? 'rgba(255,65,54,.8)' : 'rgba(190,205,225,.5)';
      ctx.lineWidth = Math.max(1, R * 0.008); ctx.stroke();
    }
    // in-band numerals: blue italic, red in the redline
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    for (k = 1; k <= maxK; k++) {
      var na2 = ang(k);
      var nx = cx + Math.cos(na2) * (Rmid - band * 0.12), ny = cy + Math.sin(na2) * (Rmid - band * 0.12);
      ctx.fillStyle = k * 1000 >= lim ? C_RED : '#4f8fd8';
      ctx.font = NUM(R * 0.19);
      ctx.fillText(String(k), nx, ny);
    }
    // interior glossy panel
    var Rin = R - band - R * 0.02;
    var ig = ctx.createLinearGradient(0, cy - Rin, 0, cy + Rin);
    ig.addColorStop(0, '#1d2f4e'); ig.addColorStop(0.5, '#101c33'); ig.addColorStop(1, '#0b1526');
    ctx.beginPath(); ctx.arc(cx, cy, Rin, 0, 6.2832); ctx.fillStyle = ig; ctx.fill();
    ctx.strokeStyle = 'rgba(120,160,210,.18)'; ctx.lineWidth = Math.max(1, R * 0.012); ctx.stroke();
    // needle: slim red pointer riding the band
    var nA = ang(kNow);
    ctx.save(); ctx.translate(cx, cy); ctx.rotate(nA);
    ctx.beginPath(); ctx.moveTo(R - band * 1.3, -R * 0.05); ctx.lineTo(R + band * 0.12, 0); ctx.lineTo(R - band * 1.3, R * 0.05); ctx.closePath();
    ctx.fillStyle = '#f23127'; ctx.shadowColor = '#f23127'; ctx.shadowBlur = R * 0.07; ctx.fill(); ctx.shadowBlur = 0;
    ctx.restore();

    // gear keystone (wider at the TOP, like the product)
    var gw = R * 0.72, gh = R * 0.60, gy0 = cy - R * 0.72;
    ctx.beginPath();
    ctx.moveTo(cx - gw / 2, gy0); ctx.lineTo(cx + gw / 2, gy0);
    ctx.lineTo(cx + gw * 0.36, gy0 + gh); ctx.lineTo(cx - gw * 0.36, gy0 + gh); ctx.closePath();
    var gg = ctx.createLinearGradient(0, gy0, 0, gy0 + gh);
    gg.addColorStop(0, '#4f93e6'); gg.addColorStop(1, '#173f80');
    ctx.fillStyle = gg; ctx.fill();
    ctx.strokeStyle = 'rgba(160,200,245,.35)'; ctx.lineWidth = Math.max(1, R * 0.012); ctx.stroke();
    var gearIdx = Math.round(n(t.gearIndex));
    var gearTxt = gearIdx > 0 ? String(gearIdx) : (gearIdx < 0 ? 'R' : 'N');
    ctx.fillStyle = '#ffffff'; ctx.font = NUM(gh * 0.78); ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText(gearTxt, cx, gy0 + gh * 0.52);

    // RPM digits + separator
    ctx.fillStyle = C_VALUE; ctx.font = NUM(R * 0.335); ctx.textBaseline = 'alphabetic';
    ctx.fillText(String(Math.round(rpm)), cx, cy + R * 0.24);
    ctx.strokeStyle = 'rgba(120,160,210,.35)'; ctx.lineWidth = Math.max(1, R * 0.012);
    ctx.beginPath(); ctx.moveTo(cx - R * 0.42, cy + R * 0.31); ctx.lineTo(cx + R * 0.42, cy + R * 0.31); ctx.stroke();

    // speed box
    var sw = R * 0.86, sh = R * 0.40, sx = cx - sw / 2, sy = cy + R * 0.37;
    rr(ctx, sx, sy, sw, sh, sh * 0.14);
    var sg = ctx.createLinearGradient(0, sy, 0, sy + sh); sg.addColorStop(0, '#3b7bd4'); sg.addColorStop(1, '#164788');
    ctx.fillStyle = sg; ctx.fill();
    ctx.strokeStyle = 'rgba(160,200,245,.3)'; ctx.lineWidth = Math.max(1, R * 0.01); ctx.stroke();
    ctx.fillStyle = '#ffffff'; ctx.font = NUM(sh * 0.62); ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic';
    ctx.fillText(String(Math.round(n(t.speedKmh))), cx, sy + sh * 0.62);
    ctx.font = LAB(sh * 0.2); ctx.fillStyle = '#bcd6f2';
    ctx.fillText('km/h', cx, sy + sh * 0.88);

    // ---- right channel boxes ----
    var bx = X + W * 0.60, bw = W * 0.385, bt = Y + H * 0.205, bh = (bodyH - H * 0.225) / 3;
    var boxes = cfg.boxes || [];
    st._boxZones = [];
    for (k = 0; k < 3; k++) {
      var key = boxes[k] || 'ect', info = chValue(key, t, st), c = info.c;
      var by = bt + k * bh;
      if (k > 0) { ctx.strokeStyle = 'rgba(90,130,180,.35)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx + bw, by); ctx.stroke(); }
      var stv = chState(key, t);
      var vc = stv === 2 ? C_RED : (stv === 1 ? C_AMBER : C_VALUE);
      ctx.textAlign = 'right'; ctx.textBaseline = 'alphabetic';
      ctx.fillStyle = vc; ctx.font = NUM(bh * 0.60);
      ctx.fillText(fmt(chDisp(c, info.v), chDec(c)), bx + bw * 0.80, by + bh * 0.60);
      ctx.fillStyle = C_UNIT; ctx.font = LAB(bh * 0.22); ctx.textAlign = 'left';
      ctx.fillText(chUnit(c), bx + bw * 0.825, by + bh * 0.60);
      ctx.fillStyle = C_LABEL; ctx.font = LAB(bh * 0.185); ctx.textAlign = 'right';
      ctx.fillText((info.mode !== 'live' ? info.mode.toUpperCase() + ' – ' : '') + c.label, bx + bw, by + bh * 0.85);
      st._boxZones.push({ x: bx, y: by, w: bw, h: bh, key: key });
    }

    // ---- telltale strip (characteristic colors, dim when inactive) ----
    var iy = Y + H - bannerH - iconH / 2;
    /* PARITY 2026-07-29: three of these were dead.  'oil' looked up alarm id
     * 'oil' but alarms() pushes 'oilp', so the oil lamp could never light; the
     * wiper and ABS lamps were wired to null and were dark by construction.
     * `s:` names a live vehicle state resolved through streetLit(); `a:` stays
     * an alarm-list id. */
    var strip = [
      { k: 'batt', a: 'limp', c: '#ff3b30' }, { k: 'engine', a: 'engine', c: '#c8ccd2' }, { k: 'oil', a: 'oilp', c: '#ff3b30' },
      { k: 'wiper', s: 'wiper', c: '#ffd23a' }, { k: 'warn', a: 'knock', c: '#e8ecf2' }, { k: 'P', s: 'parking', c: '#e8ecf2' },
      { k: 'ABS', s: 'abs', c: '#e8ecf2' }, { k: 'temp', a: 'ect', c: '#ff3b30' }, { k: 'TC', s: 'esc', c: '#ffb321' }, { k: 'fuel', a: 'fuel', c: '#ffb321' }
    ];
    for (k = 0; k < strip.length; k++) {
      var it = strip[k];
      var lit2 = it.s ? (streetLit(it.s, t, al) ? 1 : 0) : (it.a ? hasAlarm(al, it.a) : 0);
      if (t.showcase) { lit2 = 1; }
      ctx.globalAlpha = lit2 ? 1 : 0.26;
      icon(ctx, it.k, X + W * (0.05 + k * 0.058), iy, iconH * 0.72, it.c, !!lit2);
      ctx.globalAlpha = 1;
    }

    // ---- orange banner ----
    var byy = Y + H - bannerH;
    ctx.fillStyle = '#f28a00'; ctx.fillRect(X, byy, W, bannerH);
    ctx.fillStyle = '#d97a00'; ctx.fillRect(X, byy, W * 0.185, bannerH);
    ctx.fillStyle = '#0d0800'; ctx.textBaseline = 'middle'; ctx.textAlign = 'left';
    ctx.font = LAB(bannerH * 0.58);
    var link = String(t.status || '') === 'Controlling' || String(t.status || '') === 'Monitoring';
    ctx.fillText('ECU: ' + (link ? 'GOOD' : 'WAIT'), X + W * 0.014, byy + bannerH * 0.56);
    var ba = bannerAlarms(al);
    var msg = ba.length ? ba.map(function (a) { return a.msg; }).join('   •   ') : (String(t.carName || 'OE PROTUNE').toUpperCase() + '   •   LINK MXS/MXG STRADA');
    ctx.save(); ctx.beginPath(); ctx.rect(X + W * 0.195, byy, W * 0.60, bannerH); ctx.clip();
    ctx.font = LAB(bannerH * 0.56);
    var mw = ctx.measureText(msg).width, span2 = W * 0.60;
    var off = mw > span2 ? (Date.now() / 18) % (mw + span2 + W * 0.01) : 0;
    ctx.fillText(msg, X + W * 0.205 + (mw > span2 ? span2 - off : 0), byy + bannerH * 0.56);
    ctx.restore();
    ctx.textAlign = 'right'; ctx.font = LAB(bannerH * 0.58);
    ctx.fillText(clock12(), X + W * 0.988, byy + bannerH * 0.56);

    st._lapZone = { x: X, y: Y, w: W * 0.4, h: H * 0.19 };
  }

  // Native 800x480 Link/AiM MXG page.  The fixed coordinate system is
  // intentional: it preserves the proportions of the physical display when
  // the floating widget is resized.
  function tracePoly(ctx, points) {
    ctx.beginPath();
    ctx.moveTo(points[0][0], points[0][1]);
    for (var i = 1; i < points.length; i++) { ctx.lineTo(points[i][0], points[i][1]); }
    ctx.closePath();
  }

  function mxgPaintText(ctx, value, x, y, font, color, align, strokeWidth, scaleX, maxWidth) {
    ctx.save();
    ctx.translate(x, y); ctx.scale(scaleX || 1, 1);
    ctx.font = font; ctx.fillStyle = color; ctx.textAlign = align || 'left'; ctx.textBaseline = 'alphabetic';
    ctx.lineJoin = 'round';
    var textValue = String(value), textMax = maxWidth ? maxWidth / (scaleX || 1) : 0;
    if (textMax && ctx.measureText(textValue).width > textMax) {
      var suffix = '...', keep = textValue;
      while (keep.length && ctx.measureText(keep + suffix).width > textMax) { keep = keep.slice(0, -1); }
      textValue = keep + suffix;
    }
    if (strokeWidth) {
      ctx.strokeStyle = color; ctx.lineWidth = strokeWidth;
      ctx.strokeText(textValue, 0, 0);
    }
    ctx.fillText(textValue, 0, 0);
    ctx.restore();
  }

  function mxgPaintTrackedText(ctx, value, x, y, font, color, strokeWidth, scaleX, tracking) {
    var chars = String(value).split(''), widths = [], total = 0, i;
    ctx.save(); ctx.translate(x, y); ctx.scale(scaleX || 1, 1); ctx.font = font;
    for (i = 0; i < chars.length; i++) { widths[i] = ctx.measureText(chars[i]).width; total += widths[i]; }
    total += Math.max(0, chars.length - 1) * (tracking || 0) / (scaleX || 1);
    var cursor = -total / 2; ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
    ctx.fillStyle = color; ctx.strokeStyle = color; ctx.lineWidth = strokeWidth || 0; ctx.lineJoin = 'round';
    for (i = 0; i < chars.length; i++) {
      if (strokeWidth) { ctx.strokeText(chars[i], cursor, 0); }
      ctx.fillText(chars[i], cursor, 0); cursor += widths[i] + (tracking || 0) / (scaleX || 1);
    }
    ctx.restore();
  }

  // Production Link page reconstructed over the authorized 800x480 reference.
  // The reference supplies only fixed trim (the segmented tach annuli, glass
  // facets and scale artwork). Every changing value, label, telltale and status
  // field is repainted from live telemetry below.
  function drawRingPageTarget(ctx, D, t, st, cfg, al) {
    var X = D.scrX, Y = D.scrY, W = D.scrW, H = D.scrH;
    ctx.save();
    ctx.translate(X, Y); ctx.scale(W / 800, H / 480);
    ctx.beginPath(); ctx.rect(0, 0, 800, 480); ctx.clip();
    ctx.drawImage(streetRef,231,126,755,470,0,0,800,480);

    // Clear and reconstruct only the live header regions. Their compact width
    // leaves the photographed tach ring untouched.
    var head = ctx.createLinearGradient(0, 0, 0, 109);
    head.addColorStop(0, '#356a8f'); head.addColorStop(1, '#245a7d');
    ctx.fillStyle = head;
    tracePoly(ctx, [[0,0],[220,0],[142,44],[68,97],[0,97]]); ctx.fill();
    ctx.fillRect(500, 0, 300, 109);
    ctx.fillStyle = 'rgba(121,173,204,.10)';
    tracePoly(ctx, [[0,0],[190,0],[132,37],[0,72]]); ctx.fill();
    tracePoly(ctx, [[500,0],[800,0],[800,65],[650,109],[572,109]]); ctx.fill();
    ctx.fillStyle = 'rgba(4,27,49,.20)';
    tracePoly(ctx, [[645,0],[800,0],[800,109],[606,109]]); ctx.fill();

    // Restore the complete fixed annulus after clearing the LAP field so the
    // circular band remains continuous rather than ending at a patch edge.
    ctx.save(); ctx.beginPath(); ctx.arc(264,239,242,0,Math.PI*2); ctx.arc(264,239,157,0,Math.PI*2,true); ctx.clip('evenodd');
    ctx.drawImage(streetRef,231,126,755,470,0,0,800,480); ctx.restore();

    // Repaint the live center as one continuous circular field, avoiding
    // rectangular value patches and preserving the target's ring proportions.
    ctx.save(); ctx.beginPath(); ctx.rect(0, 0, 500, 373); ctx.clip();
    var core = ctx.createLinearGradient(0, 73, 0, 373);
    core.addColorStop(0, '#252728'); core.addColorStop(.58, '#20272b'); core.addColorStop(1, '#17639a');
    ctx.beginPath(); ctx.arc(264, 239, 157, 0, Math.PI * 2); ctx.fillStyle = core; ctx.fill();
    ctx.strokeStyle = '#111a20'; ctx.lineWidth = 2.5; ctx.stroke();
    ctx.fillStyle = 'rgba(255,255,255,.065)';
    tracePoly(ctx, [[106,95],[294,40],[444,148],[328,373],[101,373]]); ctx.fill();
    ctx.fillStyle = 'rgba(41,144,203,.10)';
    tracePoly(ctx, [[106,290],[342,77],[455,192],[373,373],[106,373]]); ctx.fill();
    ctx.restore();

    // Factory keystone and independently centered live gear character.
    var gearFill = ctx.createLinearGradient(0, 44, 0, 153);
    gearFill.addColorStop(0, '#395a74'); gearFill.addColorStop(1, '#4d9ad4');
    ctx.beginPath(); ctx.moveTo(175, 69); ctx.quadraticCurveTo(264, 42, 349, 69);
    ctx.lineTo(311, 144); ctx.quadraticCurveTo(309, 147, 304, 147);
    ctx.lineTo(221, 147); ctx.quadraticCurveTo(216, 147, 214, 143); ctx.closePath();
    ctx.fillStyle = gearFill; ctx.fill(); ctx.strokeStyle = '#123f61'; ctx.lineWidth = 4; ctx.stroke();
    var gearIdx = Math.round(n(t.gearIndex));
    var gearTxt = gearIdx > 0 ? String(gearIdx) : (gearIdx < 0 ? 'R' : 'N');
    mxgPaintText(ctx, gearTxt, 264, 133, NUM(75), '#f8fbff', 'center', 2.0, 1.11);
    mxgPaintTrackedText(ctx, String(Math.round(Math.max(0,n(t.rpm)))), 264, 230, NUM(43), '#fff', 2.0, 1.13, 5);
    ctx.beginPath(); ctx.moveTo(143, 253); ctx.lineTo(386, 253);
    ctx.strokeStyle = 'rgba(145,171,185,.72)'; ctx.lineWidth = 4; ctx.stroke();
    mxgPaintTrackedText(ctx, String(Math.round(n(t.speedKmh))), 261.5, 312, NUM(43), '#fff', 2.0, 1.10, 0);
    mxgPaintText(ctx, 'km/h', 264, 339, LAB(15), '#75a8c7', 'center', .3);

    mxgPaintText(ctx, String(st.lap.n || 0), 5, 49, NUM(34), '#ffffff', 'left', 1.0, 1.14);
    mxgPaintText(ctx, 'LAP', 5, 73, LAB(15), '#7fb2d0', 'left', .2, 1.15);
    mxgPaintText(ctx, st.lap.showMs == null ? '--:--.--' : lapFmt(st.lap.showMs), 789, 49, NUM(35), '#ffffff', 'right', 1.0, 1.24, 300);
    mxgPaintText(ctx, 'LAP TIME', 789, 73, LAB(15), '#7fb2d0', 'right', .2, 1.15);

    // Three target-width channel panes. Configuration and min/max cycling stay
    // live; the clean reference's normal labels are used in live mode.
    ctx.fillStyle = '#053f6b'; ctx.fillRect(500, 109, 300, 93);
    ctx.fillStyle = '#06406c'; ctx.fillRect(500, 202, 300, 93);
    ctx.fillStyle = '#053f6b'; ctx.fillRect(500, 295, 300, 78);
    ctx.fillStyle = 'rgba(1,27,48,.36)';
    tracePoly(ctx, [[645,109],[800,109],[800,373],[503,373]]); ctx.fill();
    ctx.strokeStyle = '#15527b'; ctx.lineWidth = 3;
    [109,202,295].forEach(function (yy) { ctx.beginPath(); ctx.moveTo(500,yy); ctx.lineTo(800,yy); ctx.stroke(); });
    var boxes = cfg.boxes || ['ect','oilp','var'];
    var rows = [{top:109,value:142,label:168,h:93},{top:202,value:234,label:260,h:93},{top:295,value:330,label:352,h:78}];
    st._boxZones = [];
    for (var bi = 0; bi < 3; bi++) {
      var bkey = boxes[bi] || (bi === 0 ? 'ect' : (bi === 1 ? 'oilp' : 'var'));
      var info = chValue(bkey,t,st), chan = info.c, row = rows[bi], severity = chState(bkey,t);
      var valueColor = severity === 2 ? '#ff3137' : (severity === 1 ? '#ffb428' : '#ffffff');
      var rawValue = chDisp(chan,info.v), shown = fmt(rawValue,chDec(chan)), unit = chUnit(chan);
      if (!isFinite(rawValue)) { shown = '--'; }
      var longValue = String(shown).length >= 4;
      var valueScale = longValue ? 1.30 : (String(shown).indexOf('.') >= 0 ? 1.25 : 1.12);
      mxgPaintText(ctx, shown, longValue ? 740 : 742, row.value, NUM(34), valueColor, 'right', 1.1, valueScale);
      var unitScale=String(unit).indexOf('°')>=0?.97:1.16;
      mxgPaintText(ctx, unit, 747, row.value - (bi + 1), LAB(17), '#64aad5', 'left', 0, unitScale);
      var label = (info.mode !== 'live' ? info.mode.toUpperCase() + ' - ' : '') + chan.label;
      var labelScale=label==='WATER TEMP'?1.25:(label==='OIL PRESS'?1.30:(label==='VAR'?1.21:1.27));
      mxgPaintText(ctx, label, 788, row.label, LAB(14), '#64aad5', 'right', .2, labelScale);
      st._boxZones.push({x:X+(500/800)*W,y:Y+(row.top/480)*H,w:(300/800)*W,h:(row.h/480)*H,key:bkey});
    }

    // Target strip height is 74 px, followed by a 32 px orange banner.
    if (t.showcase) {
      ctx.drawImage(streetRef,231,491,755,75,0,373,800,74);
    } else {
      ctx.fillStyle = '#080a0b'; ctx.fillRect(0,373,800,74);
      ctx.strokeStyle = '#222729'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0,373); ctx.lineTo(800,373); ctx.stroke();
      var strip = [
        { k:'batt', aim:'battery', c:'#f31f27' }, { k:'engine', aim:'check_engine', c:'#f31f27' }, { k:'oil', aim:'oil_pressure', c:'#f31f27' },
        { k:'wiper', aim:'washer_fluid', c:'#f0ce21' }, { k:'brake', aim:'brake_warning', c:'#f31f27' }, { k:'park', aim:'parking_brake', c:'#f0ce21' },
        { k:'ABS', aim:'abs', c:'#f0ce21' }, { k:'temp', aim:'coolant_temp', c:'#f31f27' }, { k:'TC', aim:'stability', c:'#f0ce21' },
        { k:'TC', aim:'stability_off', c:'#f0ce21' }, { k:'defog', aim:'wiper', c:'#f0ce21' }, { k:'belt', aim:'door_open', c:'#f49b22' }
      ];
      var stripX=[36,103,169,235,301,367,433,499,564,630,695,762];
      var aimSizes=[61,59,59,64,61,61,59,65,63,63,63,61];
      for (var si=0;si<strip.length;si++) {
        var on=telltaleOn(strip[si].aim,t,al);
        var col=on?strip[si].c:'#464b4e';
        if (!drawAimIcon(ctx,strip[si].aim,stripX[si],407,aimSizes[si],col,on?1:.92,!!on)) {
          ctx.globalAlpha=on?1:.42; icon(ctx,strip[si].k,stripX[si],407,33,col,!!on); ctx.globalAlpha=1;
        }
      }
    }

    ctx.fillStyle='#080a0b'; ctx.fillRect(0,447,800,2);
    ctx.fillStyle='#fc4c07'; ctx.fillRect(1,449,798,29);
    ctx.fillStyle='#ff7628'; ctx.fillRect(1,449,798,2);
    ctx.fillStyle='rgba(255,139,69,.16)';
    tracePoly(ctx,[[1,449],[230,449],[197,478],[1,478]]); ctx.fill();
    ctx.fillStyle='#090a0b'; ctx.fillRect(0,478,800,2);
    var linkGood=String(t.status||'')==='Controlling'||String(t.status||'')==='Monitoring'||!!t.controlling;
    var activeAlarms=bannerAlarms(al);
    var noticeText=st._notice&&st._notice.until>Date.now()?st._notice.text:'';
    var bannerAlarm = rotatingBanner(st, activeAlarms);
    var centerText=noticeText||(bannerAlarm?bannerAlarm.msg:idleBanner(t));
    var statusFont='700 15px '+F;
    mxgPaintText(ctx,'ECU: '+(linkGood?'OK':'WAIT'),10,468,statusFont,'#fff','left',.2,1.82,175);   // PARITY 2026-07-29: was mislabelled GPS
    mxgPaintText(ctx,centerText,405,468,statusFont,'#fff','center',.2,1.75,340);
    mxgPaintText(ctx,clock12(t,cfg),791,468,statusFont,'#fff','right',.2,2.07,145);
    ctx.restore();
    st._lapZone={x:X,y:Y,w:W*(155/800),h:H*(95/480)};
  }

  function drawRingPage(ctx, D, t, st, cfg, al) {
    if (cfg && cfg.model === '5') { drawRingPageLegacy(ctx, D, t, st, cfg, al); return; }

    var X = D.scrX, Y = D.scrY, W = D.scrW, H = D.scrH;
    ctx.save();
    ctx.translate(X, Y); ctx.scale(W / 800, H / 480);
    ctx.beginPath(); ctx.rect(0, 0, 800, 480); ctx.clip();

    // Clean factory-blue LCD field. The photographed page uses horizontal
    // channel bands; the old diagonal glass polygons read as a gray veil and
    // are intentionally absent here.
    var lcd = ctx.createLinearGradient(0, 0, 0, 373);
    lcd.addColorStop(0, '#06416d'); lcd.addColorStop(0.34, '#04375f'); lcd.addColorStop(1, '#022b4d');
    ctx.fillStyle = lcd; ctx.fillRect(0, 0, 800, 373);
    ctx.fillStyle = '#063e68'; ctx.fillRect(0, 0, 800, 58);
    ctx.fillStyle = '#04375f'; ctx.fillRect(0, 58, 800, 51);
    ctx.fillStyle = '#022c4d'; ctx.fillRect(504, 0, 296, 58);
    ctx.fillStyle = '#04406e'; ctx.fillRect(504, 58, 296, 51);
    ctx.fillStyle = '#032c4c'; ctx.fillRect(504, 109, 296, 41);
    ctx.fillStyle = '#04406e'; ctx.fillRect(504, 150, 296, 52);
    ctx.fillStyle = '#022b4b'; ctx.fillRect(504, 202, 296, 41);
    ctx.fillStyle = '#04406c'; ctx.fillRect(504, 243, 296, 52);
    ctx.fillStyle = '#032846'; ctx.fillRect(504, 295, 296, 41);
    ctx.fillStyle = '#04406f'; ctx.fillRect(504, 336, 296, 37);
    ctx.strokeStyle = '#14547e'; ctx.lineWidth = 3;
    [109, 202, 295].forEach(function (yy) { ctx.beginPath(); ctx.moveTo(504, yy); ctx.lineTo(800, yy); ctx.stroke(); });

    // Full circular tachometer, clipped by the bottom telltale bar exactly as
    // on the stock MXG race page.
    ctx.save(); ctx.beginPath(); ctx.rect(0, 0, 800, 373); ctx.clip();
    var tachX = 264, tachY = 245;
    ctx.beginPath(); ctx.arc(tachX, tachY, 242, 0, Math.PI * 2);
    ctx.fillStyle = '#060809'; ctx.fill(); ctx.strokeStyle = '#111517'; ctx.lineWidth = 2; ctx.stroke();

    // Thin segmented outer band, broad black numeral band, gray separator,
    // and blue inner ring. These radii are registered to the target LCD.
    ctx.beginPath(); ctx.arc(tachX, tachY, 234.5, 0, Math.PI * 2);
    ctx.strokeStyle = '#34393a'; ctx.lineWidth = 13; ctx.stroke();
    ctx.beginPath(); ctx.arc(tachX, tachY, 228, 0, Math.PI * 2);
    ctx.fillStyle = '#030506'; ctx.fill();
    var separator = ctx.createLinearGradient(0, 60, 0, 395);
    separator.addColorStop(0, '#34393a'); separator.addColorStop(0.55, '#65696a'); separator.addColorStop(1, '#85898a');
    ctx.beginPath(); ctx.arc(tachX, tachY, 186, 0, Math.PI * 2);
    ctx.fillStyle = separator; ctx.fill();
    ctx.beginPath(); ctx.arc(tachX, tachY, 173, 0, Math.PI * 2);
    ctx.fillStyle = '#0874b5'; ctx.fill();

    for (var tick = 0; tick <= 80; tick++) {
      var ta = (146 + (tick / 80) * 260) * Math.PI / 180;
      var major = tick % 10 === 0, mid = tick % 5 === 0;
      var r1 = major ? 224 : (mid ? 227 : 229), r2 = 240;
      ctx.beginPath();
      ctx.moveTo(tachX + Math.cos(ta) * r1, tachY + Math.sin(ta) * r1);
      ctx.lineTo(tachX + Math.cos(ta) * r2, tachY + Math.sin(ta) * r2);
      ctx.strokeStyle = major ? '#2380b7' : (mid ? '#9ba0a0' : '#777c7d');
      ctx.lineWidth = major ? 3 : (mid ? 2 : 1.35); ctx.lineCap = 'butt'; ctx.stroke();
    }

    var core = ctx.createLinearGradient(0, 74, 0, 404);
    core.addColorStop(0, '#000102'); core.addColorStop(0.42, '#010509');
    core.addColorStop(0.58, '#010b14'); core.addColorStop(0.72, '#032d4c');
    core.addColorStop(0.84, '#045f9f'); core.addColorStop(1, '#087fc2');
    ctx.beginPath(); ctx.arc(tachX, tachY, 159, 0, Math.PI * 2); ctx.fillStyle = core; ctx.fill();
    ctx.strokeStyle = '#061923'; ctx.lineWidth = 2; ctx.stroke();

    var rpm = Math.max(0, n(t.rpm)), limit = limRpm(t);
    var tachMax = Math.max(8000, Math.ceil(limit / 1000) * 1000);
    var rpmScaleAnchors = [
      [0,85,348,'0'], [2000,57,244,'2'], [3000,87,141,'3'], [4000,158,65,'4'],
      [5000,265,38,'5'], [6000,364,65,'6'], [7000,445,149,'7'], [8000,471,246,'8']
    ];
    var markerRpm = Math.max(0, Math.min(tachMax, rpm));
    var markerA;
    if (tachMax <= 8000) {
      var ai = 1;
      while (ai < rpmScaleAnchors.length - 1 && markerRpm > rpmScaleAnchors[ai][0]) { ai++; }
      var alo = rpmScaleAnchors[ai - 1], ahi = rpmScaleAnchors[ai];
      var a0 = Math.atan2(alo[2] - tachY, alo[1] - tachX);
      var a1 = Math.atan2(ahi[2] - tachY, ahi[1] - tachX);
      if (a0 < 0) { a0 += Math.PI * 2; } if (a1 < 0) { a1 += Math.PI * 2; }
      if (a1 < a0) { a1 += Math.PI * 2; }
      var af = (markerRpm - alo[0]) / Math.max(1, ahi[0] - alo[0]);
      markerA = a0 + (a1 - a0) * af;
    } else {
      markerA = (148 + Math.max(0, Math.min(1, markerRpm / tachMax)) * 213) * Math.PI / 180;
    }
    // Live inward-pointing needle: the base rides the segmented band and the
    // tip points into the scale. It is painted before the blue numerals.
    var tipX = tachX + Math.cos(markerA) * 188, tipY = tachY + Math.sin(markerA) * 188;
    var baseX = tachX + Math.cos(markerA) * 222, baseY = tachY + Math.sin(markerA) * 222;
    var px = -Math.sin(markerA) * 10.5, py = Math.cos(markerA) * 10.5;
    tracePoly(ctx, [[tipX,tipY],[baseX+px,baseY+py],[baseX-px,baseY-py]]);
    ctx.fillStyle = '#f32931'; ctx.shadowColor = '#f32931'; ctx.shadowBlur = 5; ctx.fill(); ctx.shadowBlur = 0;
    ctx.strokeStyle = '#a90f19'; ctx.lineWidth = 2; ctx.stroke();
    ctx.restore();

    // Fixed upright labels, independently center-anchored to the photo.
    var scaleLabels = rpmScaleAnchors.map(function (p) { return [p[1], p[2], p[3]]; });
    if (tachMax > 8000) {
      scaleLabels = [[85,348,'0']];
      var maxK = Math.round(tachMax / 1000);
      for (var lk = 2; lk <= maxK; lk++) {
        var la = (148 + (lk / maxK) * 213) * Math.PI / 180;
        scaleLabels.push([tachX + Math.cos(la) * 207, tachY + Math.sin(la) * 207, String(lk)]);
      }
    }
    ctx.font = NUM(23); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3d9bd2';
    ctx.strokeStyle = '#4ca1d5'; ctx.lineWidth = 0.45; ctx.lineJoin = 'round';
    scaleLabels.forEach(function (p) {
      ctx.save(); ctx.translate(p[0], p[1]); ctx.scale(1.20, 1);
      ctx.strokeText(p[2], 0, 0); ctx.fillText(p[2], 0, 0); ctx.restore();
    });

    // Factory keystone. Its outline was already close; the live character is
    // independently narrowed and lowered so N and the numbered gears all sit
    // optically centered without changing the badge footprint.
    var gearFill = ctx.createLinearGradient(0, 44, 0, 154);
    gearFill.addColorStop(0, '#3f8fc4'); gearFill.addColorStop(1, '#1c6596');
    ctx.beginPath(); ctx.moveTo(184, 70); ctx.quadraticCurveTo(264, 44, 349, 70);
    ctx.lineTo(310, 150); ctx.quadraticCurveTo(308, 154, 302, 154);
    ctx.lineTo(225, 154); ctx.quadraticCurveTo(220, 154, 218, 150); ctx.closePath();
    ctx.fillStyle = gearFill; ctx.fill();
    ctx.strokeStyle = '#123f61'; ctx.lineWidth = 4; ctx.stroke();
    var gearIdx = Math.round(n(t.gearIndex));
    var gearTxt = gearIdx > 0 ? String(gearIdx) : (gearIdx < 0 ? 'R' : 'N');
    mxgPaintText(ctx, gearTxt, 264, 136, NUM(81), '#f8fbff', 'center', 1.5, 0.95);
    mxgPaintTrackedText(ctx, String(Math.round(rpm)), 264, 230, NUM(46), '#ffffff', 1.6, 1.06, 5);
    ctx.beginPath(); ctx.moveTo(144, 253); ctx.lineTo(386, 253); ctx.strokeStyle = 'rgba(145,171,185,.72)'; ctx.lineWidth = 3; ctx.stroke();
    mxgPaintTrackedText(ctx, String(Math.round(n(t.speedKmh))), 264, 312, NUM(46), '#ffffff', 1.5, 1.10, 0);
    mxgPaintText(ctx, 'km/h', 264, 340, LAB(13), '#75a8c7', 'center', 0.2, 0.94);

    mxgPaintText(ctx, String(st.lap.n || 0), 6, 49, NUM(36), '#ffffff', 'left', 1.0, 1.10);
    mxgPaintText(ctx, 'LAP', 6, 73, LAB(15), '#7fb2d0', 'left', 0.2, 1.15);
    mxgPaintText(ctx, st.lap.showMs == null ? '--:--.--' : lapFmt(st.lap.showMs), 789, 49, NUM(35), '#ffffff', 'right', 1.0, 1.24, 300);
    mxgPaintText(ctx, 'LAP TIME', 789, 73, LAB(15), '#7fb2d0', 'right', 0.2, 1.15);

    var boxes = cfg.boxes || ['ect', 'fuelp', 'var'];
    var rows = [{ top:109, value:142, label:168, h:93 }, { top:202, value:234, label:260, h:93 }, { top:295, value:330, label:352, h:78 }];
    st._boxZones = [];
    for (var bi = 0; bi < 3; bi++) {
      var bkey = boxes[bi] || (bi === 0 ? 'ect' : (bi === 1 ? 'fuelp' : 'var'));
      var info = chValue(bkey, t, st), chan = info.c, row = rows[bi], severity = chState(bkey, t);
      var valueColor = severity === 2 ? '#ff3137' : (severity === 1 ? '#ffb428' : '#ffffff');
      var shown = fmt(chDisp(chan, info.v), chDec(chan)), unit = chUnit(chan);
      var longValue = String(shown).length >= 4;
      var valueScale = longValue ? 1.30 : (String(shown).indexOf('.') >= 0 ? 1.25 : 1.12);
      mxgPaintText(ctx, shown, longValue ? 740 : 742, row.value, NUM(34), valueColor, 'right', 1.1, valueScale);
      mxgPaintText(ctx, unit, 750, row.value - bi, LAB(16), '#64aad5', 'left', 0, 1.02);
      var label = (info.mode !== 'live' ? info.mode.toUpperCase() + ' - ' : '') + chan.label;
      var labelScale = label === 'WATER TEMP' ? 1.17 : (label === 'OIL PRESS' ? 1.25 : (label === 'VAR' ? 1.10 : 1.16));
      mxgPaintText(ctx, label, 789, row.label, LAB(15), '#64aad5', 'right', 0.2, labelScale);
      st._boxZones.push({
        x: X + (504 / 800) * W, y: Y + (row.top / 480) * H,
        w: (296 / 800) * W, h: (row.h / 480) * H, key: bkey
      });
    }

    // Warning/telltale strip.  The icon locations and spacing follow the photo;
    // their active state now comes from live simulator telemetry + OEProTune alarms.
    ctx.fillStyle = '#07090a'; ctx.fillRect(0, 373, 800, 77);
    ctx.strokeStyle = '#24292b'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, 373); ctx.lineTo(800, 373); ctx.stroke();
    var strip = [
      { k:'batt', aim:'battery', c:'#f31f27' },
      { k:'engine', aim:'check_engine', c:'#f31f27' },
      { k:'oil', aim:'oil_pressure', c:'#f31f27' },
      { k:'wiper', aim:'washer_fluid', c:'#f0ce21' },
      { k:'brake', aim:'brake_warning', c:'#f31f27' },
      { k:'park', aim:'parking_brake', c:'#f0ce21' },
      { k:'ABS', aim:'abs', c:'#f0ce21' },
      { k:'temp', aim:'coolant_temp', c:'#f31f27' },
      { k:'TC', aim:'stability', c:'#f0ce21' },
      { k:'TC', aim:'stability_off', c:'#f0ce21' },
      { k:'defog', aim:'wiper', c:'#f0ce21' },
      { k:'belt', aim:'door_open', c:'#f49b22' }
    ];
    var stripX = [36,103,169,235,301,367,433,499,564,630,695,762];
    var stripSourceX = [50,111,173,234,294,355,416,493,555,620,682,758];
    var sourceLit = { 0:true, 3:true, 7:true, 11:true };
    var aimSizes = [56,54,54,58,55,55,54,60,58,58,58,56];
    for (var si = 0; si < strip.length; si++) {
      var on = telltaleOn(strip[si].aim, t, al);
      var aimDrawn = drawAimIcon(ctx, strip[si].aim, stripX[si], 412, aimSizes[si], on ? strip[si].c : '#25282a', on ? 1 : 0.92, !!on);
      if (aimDrawn) { continue; }
      if (telltaleRefOk) {
        var cropW = si === 8 || si === 9 ? 58 : 52;
        var tm = refMask(telltaleRef, 'telltale-' + si, stripSourceX[si] - cropW / 2, 381, cropW, 62, 'light');
        var drawW = cropW * 1.18, drawH = 68, drawX = stripX[si] - drawW / 2, drawY = 378;
        ctx.save(); ctx.globalCompositeOperation = 'screen';
        try { ctx.filter = 'grayscale(1) brightness(.55)'; } catch (te0) {}
        ctx.globalAlpha = 0.72;
        if (tm) { ctx.drawImage(tm, drawX, drawY, drawW, drawH); }
        try { ctx.filter = 'none'; } catch (te1) {}
        if (on && sourceLit[si]) {
          ctx.globalAlpha = 1;
          if (tm) { ctx.drawImage(tm, drawX, drawY, drawW, drawH); }
        }
        ctx.restore();
        if (on && !sourceLit[si]) {
          icon(ctx, strip[si].k, stripX[si], 412, 31, strip[si].c, true);
        }
      } else {
        ctx.globalAlpha = on ? 1 : 0.20;
        icon(ctx, strip[si].k, stripX[si], 412, 32, on ? strip[si].c : '#54595b', !!on);
      }
    }
    ctx.globalAlpha = 1;

    ctx.fillStyle = '#f04f24'; ctx.fillRect(0, 451, 800, 29);
    ctx.fillStyle = '#ff7a3e'; ctx.fillRect(0, 451, 800, 2);
    var linkGood = String(t.status || '') === 'Controlling' || String(t.status || '') === 'Monitoring' || !!t.controlling;
    var activeAlarms = bannerAlarms(al);
    var noticeText = st._notice && st._notice.until > Date.now() ? st._notice.text : '';
    var bannerAlarm = rotatingBanner(st, activeAlarms);
    var centerText = noticeText || (bannerAlarm ? bannerAlarm.msg : idleBanner(t));
    // PARITY 2026-07-29: this cell used to read "GPS: GOOD/WAIT" while showing
    // t.controlling - the ECU link flag. There is no GPS anywhere in this
    // codebase, and the Strada has no GPS receiver without a GPS09 module. Label
    // it as what it actually is.
    mxgPaintText(ctx, 'ECU: ' + (linkGood ? 'OK' : 'WAIT'), 11, 474, LAB(14), '#ffffff', 'left', 0.25, 1.45, 175);
    mxgPaintText(ctx, centerText, 400, 474, LAB(14), '#ffffff', 'center', 0.25, 1.45, 340);
    mxgPaintText(ctx, clock12(t, cfg), 789, 474, LAB(14), '#ffffff', 'right', 0.25, 1.45, 145);
    ctx.restore();
    st._lapZone = { x: X, y: Y, w: W * (155 / 800), h: H * (95 / 480) };
  }

  // =====================================================================
  // PAGE: Race layout (Link MXG Race style)
  // =====================================================================
  function drawRacePage(ctx, D, t, st, cfg) {
    var X = D.scrX, Y = D.scrY, W = D.scrW, H = D.scrH;
    var bg = ctx.createLinearGradient(0, Y, 0, Y + H);
    bg.addColorStop(0, '#101c30'); bg.addColorStop(1, '#080f1c');
    ctx.fillStyle = bg; ctx.fillRect(X, Y, W, H);

    var lim = limRpm(t), rpm = n(t.rpm), rpmFrac = Math.max(0, Math.min(1, rpm / Math.max(1, lim)));
    var bh = H * 0.12, bx = X + W * 0.03, bw = W * 0.94, byy = Y + H * 0.04;
    rr(ctx, bx, byy, bw, bh, 4); ctx.fillStyle = '#1b2334'; ctx.fill();
    ctx.strokeStyle = '#14547e'; ctx.lineWidth = Math.max(1, H * 0.005); ctx.stroke();
    var barW = bw * rpmFrac;
    rr(ctx, bx, byy, barW, bh, 4);
    var barCol = rpm >= lim - 300 ? '#e0342a' : (rpm >= lim * 0.85 ? '#e0a52e' : '#4d78c4');
    ctx.fillStyle = barCol; ctx.fill();
    ctx.fillStyle = C_VALUE; ctx.font = NUM(bh * 0.55); ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
    ctx.fillText(Math.round(rpm), bx + bw - 8, byy + bh / 2);
    ctx.textAlign = 'left'; ctx.fillStyle = C_LABEL; ctx.font = LAB(bh * 0.28);
    ctx.fillText('RPM /' + Math.round(lim), bx + 8, byy + bh / 2);

    var gearIdx = Math.round(n(t.gearIndex)), gearTxt = gearIdx > 0 ? String(gearIdx) : (gearIdx < 0 ? 'R' : 'N');
    var midY = Y + H * 0.38;
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillStyle = '#ffffff'; ctx.font = NUM(H * 0.40);
    ctx.fillText(gearTxt, X + W * 0.5, midY);
    ctx.fillStyle = C_VALUE; ctx.font = NUM(H * 0.20);
    ctx.fillText(String(Math.round(n(t.speedKmh))), X + W * 0.5, midY + H * 0.24);
    ctx.fillStyle = C_LABEL; ctx.font = LAB(H * 0.06);
    ctx.fillText('km/h', X + W * 0.5, midY + H * 0.38);

    var cellH = H * 0.14, gap = W * 0.04, top = Y + H * 0.80;
    var cells = [cfg.boxes && cfg.boxes[0] || 'ect', cfg.boxes && cfg.boxes[1] || 'fuelp'];
    st._boxZones = [];
    var cellW = (W - gap * 3) / 2;
    for (var i = 0; i < 2; i++) {
      var key = cells[i], info = chValue(key, t, st), c = info.c, stv = chState(key, t);
      var cx = X + gap + i * (cellW + gap);
      rr(ctx, cx, top, cellW, cellH, 5); ctx.fillStyle = 'rgba(0,0,0,.25)'; ctx.fill();
      ctx.strokeStyle = '#14547e'; ctx.lineWidth = Math.max(1, H * 0.005); ctx.stroke();
      ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = C_LABEL; ctx.font = LAB(cellH * 0.28);
      ctx.fillText((info.mode !== 'live' ? info.mode.toUpperCase() + ' ' : '') + c.label, cx + cellW * 0.08, top + cellH * 0.12);
      ctx.textAlign = 'right'; ctx.textBaseline = 'top';
      ctx.fillStyle = stv === 2 ? C_RED : (stv === 1 ? C_AMBER : C_VALUE); ctx.font = NUM(cellH * 0.48);
      ctx.fillText(fmt(chDisp(c, info.v), chDec(c)) + ' ' + chUnit(c), cx + cellW * 0.92, top + cellH * 0.42);
      st._boxZones.push({ x: cx, y: top, w: cellW, h: cellH, key: key });
    }
    st._lapZone = null;
  }

  // =====================================================================
  // PAGE: vertical bar-graph layout
  // =====================================================================
  function drawBarsPage(ctx, D, t, st, cfg, al) {
    var X = D.scrX, Y = D.scrY, W = D.scrW, H = D.scrH;
    var bg = ctx.createLinearGradient(0, Y, 0, Y + H);
    bg.addColorStop(0, '#101c30'); bg.addColorStop(1, '#080f1c');
    ctx.fillStyle = bg; ctx.fillRect(X, Y, W, H);

    var lim = limRpm(t), rpm = n(t.rpm);
    var bh = H * 0.12, bx = X + W * 0.03, bw = W * 0.94, byy = Y + H * 0.04;
    rr(ctx, bx, byy, bw, bh, 4); ctx.fillStyle = '#1b2334'; ctx.fill();
    ctx.strokeStyle = '#14547e'; ctx.lineWidth = Math.max(1, H * 0.005); ctx.stroke();
    var f = Math.max(0, Math.min(1, rpm / Math.max(1, lim)));
    rr(ctx, bx, byy, bw * f, bh, 4);
    ctx.fillStyle = rpm >= lim - 300 ? '#e0342a' : (rpm >= lim * 0.85 ? '#e0a52e' : '#4d78c4'); ctx.fill();
    ctx.fillStyle = C_VALUE; ctx.font = NUM(bh * 0.55); ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
    ctx.fillText(Math.round(rpm), bx + bw - 8, byy + bh / 2);
    ctx.textAlign = 'left'; ctx.fillStyle = C_LABEL; ctx.font = LAB(bh * 0.28);
    ctx.fillText('RPM /' + Math.round(lim), bx + 8, byy + bh / 2);

    var keys = (cfg.boxes || ['ect', 'oilt', 'fuelp']).concat(['lambda', 'boost']).slice(0, 5);
    var rowH = (H * 0.74) / 5, top = Y + H * 0.20;
    st._boxZones = [];
    for (var i = 0; i < keys.length; i++) {
      var key = keys[i], info = chValue(key, t, st), c = info.c;
      var ry = top + i * rowH, stv = chState(key, t);
      ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
      ctx.fillStyle = C_LABEL; ctx.font = LAB(rowH * 0.30);
      ctx.fillText((info.mode !== 'live' ? info.mode.toUpperCase() + ' ' : '') + c.label, X + W * 0.03, ry + rowH * 0.32);
      var rbx = X + W * 0.34, rbw = W * 0.38, rbh = rowH * 0.38;
      rr(ctx, rbx, ry + rowH * 0.32, rbw, rbh, 3); ctx.fillStyle = '#0b1a2e'; ctx.fill();
      ctx.strokeStyle = '#14547e'; ctx.lineWidth = Math.max(1, H * 0.004); ctx.stroke();
      var hi = c.barMax != null ? c.barMax : (c.alarm != null ? c.alarm * 1.15 : 100);
      var frac = isFinite(info.v) ? Math.max(0, Math.min(1, info.v / hi)) : 0;
      rr(ctx, rbx, ry + rowH * 0.32, rbw * frac, rbh, 3);
      ctx.fillStyle = stv === 2 ? '#e0342a' : (stv === 1 ? '#e0a52e' : '#4d78c4'); ctx.fill();
      ctx.textAlign = 'right';
      ctx.fillStyle = stv === 2 ? C_RED : C_VALUE; ctx.font = NUM(rowH * 0.42);
      ctx.fillText(fmt(chDisp(c, info.v), chDec(c)) + ' ' + chUnit(c), X + W * 0.97, ry + rowH * 0.42);
      st._boxZones.push({ x: X, y: ry, w: W, h: rowH, key: key });
    }
    st._lapZone = null;
  }

  // =====================================================================
  // frame: faceted bezel + shift blister + chin
  // =====================================================================
  function geom(cv) {
    var W = cv.width, Hh = cv.height;
    var bezT = Hh * 0.155, bezB = Hh * 0.145, bezL = W * 0.068;
    return { W: W, H: Hh, u: Hh * 0.045, bezL: bezL,
      scrX: bezL, scrY: bezT, scrW: W - bezL * 2, scrH: Hh - bezT - bezB };
  }

  function bezelPath(ctx, W, H) {
    // faceted MXG silhouette: chamfered top corners, slight taper, wide chin
    var r = H * 0.045;
    ctx.beginPath();
    ctx.moveTo(W * 0.115, H * 0.028);
    ctx.lineTo(W * 0.885, H * 0.028);
    ctx.lineTo(W * 0.972, H * 0.115);
    ctx.lineTo(W, H * 0.60);
    ctx.quadraticCurveTo(W, H * 0.96, W * 0.955, H * 0.985);
    ctx.lineTo(W * 0.045, H * 0.985);
    ctx.quadraticCurveTo(0, H * 0.96, 0, H * 0.60);
    ctx.lineTo(W * 0.028, H * 0.115);
    ctx.closePath();
    return r;
  }

  function drawLegacy(cv, t, st, cfg) {
    t = t || {}; st.minmax = st.minmax || {}; st.boxMode = st.boxMode || {}; st.lap = st.lap || { n: 0, t0: null, showMs: null };
    if (!st._boot) { st._boot = { start: Date.now(), until: Date.now() + 1200 }; }
    for (var i = 0; i < CH_ORDER.length; i++) {
      var key = CH_ORDER[i], v = CHANNELS[key].v(t);
      if (isFinite(v) && (n(t.rpm) > 0)) {
        var mm = st.minmax[key] || (st.minmax[key] = { min: v, max: v });
        if (v < mm.min) { mm.min = v; } if (v > mm.max) { mm.max = v; }
      }
    }
    /* PARITY 2026-07-29: showMs used to be BOTH the running stopwatch and the
     * completed-lap readout, so a finished lap survived exactly one frame and
     * could never be read. runMs is the live timer; showMs now holds the last
     * completed lap until the next one finishes (real units hold it too). */
    if (st.lap.t0 != null) { st.lap.runMs = Date.now() - st.lap.t0; }
    if (st.lap.showMs == null && st.lap.runMs != null) { st.lap.showMs = st.lap.runMs; }

    var ctx = cv.getContext('2d'), D = geom(cv);
    if (st._transition && !st._transition.snap) {
      st._transition.snap = captureScreen(ctx, D.scrX, D.scrY, D.scrW, D.scrH);
    }
    ctx.clearRect(0, 0, D.W, D.H);

    // body
    bezelPath(ctx, D.W, D.H);
    var bz = ctx.createLinearGradient(0, 0, 0, D.H);
    bz.addColorStop(0, '#2b2d31'); bz.addColorStop(0.12, '#1a1c1f'); bz.addColorStop(0.8, '#101114'); bz.addColorStop(1, '#0a0b0c');
    ctx.fillStyle = bz; ctx.fill();
    ctx.strokeStyle = '#000'; ctx.lineWidth = Math.max(1, D.H * 0.006); ctx.stroke();

    // shift-light blister (raised tab, top center)
    var tabW = D.W * 0.46, tabX = D.W / 2 - tabW / 2, tabH = D.H * 0.085, tabY = D.H * 0.004;
    rr(ctx, tabX, tabY, tabW, tabH, tabH * 0.45);
    var tg = ctx.createLinearGradient(0, tabY, 0, tabY + tabH);
    tg.addColorStop(0, '#232529'); tg.addColorStop(1, '#0e0f11');
    ctx.fillStyle = tg; ctx.fill();
    ctx.strokeStyle = '#000'; ctx.lineWidth = 1; ctx.stroke();

    // screen recess
    rr(ctx, D.scrX - 3, D.scrY - 3, D.scrW + 6, D.scrH + 6, 4);
    ctx.fillStyle = '#020304'; ctx.fill();

    var al = alarms(t);
    var activeBanners = bannerAlarms(al);
    updateAlarmPopup(st, activeBanners);
    var progress = transitionProgress(st);
    var page = progress.page || st.page || cfg.page || 'ring';
    st.page = page;

    var bootActive = st._boot && st._boot.until > Date.now();
    if (bootActive) {
      drawBootSplash(ctx, D, st);
    } else if (progress.active && st._transition && st._transition.snap) {
      ctx.save(); ctx.globalAlpha = 1 - progress.p;
      ctx.drawImage(st._transition.snap, D.scrX, D.scrY, D.scrW, D.scrH);
      ctx.restore();
      ctx.save(); ctx.globalAlpha = progress.p;
      if (page === 'race') { drawRacePage(ctx, D, t, st, cfg); }
      else if (page === 'bars') { drawBarsPage(ctx, D, t, st, cfg, al); }
      else { drawRingPage(ctx, D, t, st, cfg, al); }
      ctx.restore();
    } else {
      if (page === 'race') { drawRacePage(ctx, D, t, st, cfg); }
      else if (page === 'bars') { drawBarsPage(ctx, D, t, st, cfg, al); }
      else { drawRingPage(ctx, D, t, st, cfg, al); }
    }

    if (cfg.showReference || st.showReference) { drawReferenceOverlay(ctx, D); }
    drawAlarmPopup(ctx, D, st);

    if (night(cfg, t)) { ctx.fillStyle = 'rgba(2,4,10,' + nightAlpha(cfg) + ')'; ctx.fillRect(D.scrX, D.scrY, D.scrW, D.scrH); }

    // Phase 1 visual fidelity overlays (legacy 5" shell).
    if (cfg.showGlare !== false) { drawGlare(ctx, D); }
    if (cfg.showPixelGrid !== false) { drawPixelGrid(ctx, D); }

    // LEDs on the blister
    drawShiftStrip(ctx, tabX + tabW * 0.06, tabY + tabH * 0.52, tabW * 0.88, t, cfg);
    drawBezelLeds(ctx, D, t, cfg, al);

    // bezel button labels (photo: MENU/<< >> left, MEM OK / VIEW QUIT right)
    ctx.fillStyle = 'rgba(255,255,255,.5)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.font = 'italic 700 ' + Math.round(D.u * 0.58) + 'px ' + F;
    ctx.fillText('MENU', D.bezL / 2, D.H * 0.60);
    ctx.fillText('«', D.bezL / 2, D.H * 0.655);
    ctx.fillText('»', D.bezL / 2, D.H * 0.73);
    ctx.fillText('MEM', D.W - D.bezL / 2, D.H * 0.585);
    ctx.fillText('OK', D.W - D.bezL / 2, D.H * 0.635);
    ctx.fillText('VIEW', D.W - D.bezL / 2, D.H * 0.71);
    ctx.fillText('QUIT', D.W - D.bezL / 2, D.H * 0.76);
    st._btnZones = [
      { id: 'menu', x: 0, y: D.H * 0.56, w: D.bezL, h: D.H * 0.12 },
      { id: 'prev', x: 0, y: D.H * 0.68, w: D.bezL, h: D.H * 0.10 },
      { id: 'mem',  x: D.W - D.bezL, y: D.H * 0.56, w: D.bezL, h: D.H * 0.10 },
      { id: 'next', x: D.W - D.bezL, y: D.H * 0.66, w: D.bezL, h: D.H * 0.13 }
    ];

    // chin logo (placeholder pending official Link assets)
    if (logoOk) {
      var lh = D.H * 0.16, lw = lh * (logo.width / logo.height);
      ctx.save(); ctx.globalCompositeOperation = 'screen';   // black PNG background drops onto the dark chin
      ctx.drawImage(logo, D.W / 2 - lw / 2, D.H - D.H * 0.155, lw, lh);
      ctx.restore();
    } else {
      ctx.fillStyle = '#c8ccd2'; ctx.font = 'italic 800 ' + Math.round(D.H * 0.05) + 'px ' + F;
      ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
      ctx.fillText('LINK', D.W / 2, D.H - D.H * 0.07);
    }
  }

  function mxgBodyPath(ctx) {
    ctx.beginPath();
    ctx.moveTo(195, 3); ctx.lineTo(805, 3); ctx.lineTo(936, 76);
    ctx.quadraticCurveTo(974, 95, 987, 151); ctx.lineTo(997, 380);
    ctx.quadraticCurveTo(999, 430, 965, 462); ctx.lineTo(914, 510);
    ctx.quadraticCurveTo(891, 538, 858, 538); ctx.lineTo(114, 538);
    ctx.quadraticCurveTo(88, 536, 66, 518); ctx.lineTo(25, 482);
    ctx.quadraticCurveTo(2, 468, 2, 430); ctx.lineTo(2, 190);
    ctx.quadraticCurveTo(3, 149, 21, 115); ctx.lineTo(76, 62);
    ctx.quadraticCurveTo(124, 26, 210, 3); ctx.closePath();
  }

  function mxgLens(ctx, x, y, w, h, color, lit, slant) {
    var s = slant || 3;
    tracePoly(ctx, [[x+s,y],[x+w,y],[x+w-s,y+h],[x,y+h]]);
    ctx.save();
    ctx.fillStyle = color; ctx.globalAlpha = (lit ? 1 : 0.23) * LEDDIM;
    if (lit) { ctx.shadowColor = color; ctx.shadowBlur = 13; }
    ctx.fill(); ctx.shadowBlur = 0; ctx.globalAlpha = (lit ? 0.52 : 0.20) * LEDDIM;
    ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1; ctx.stroke();
    ctx.restore();
    // translucent lens highlight
    tracePoly(ctx, [[x+s+2,y+2],[x+w-3,y+2],[x+w-s-5,y+h*.46],[x+3,y+h*.46]]);
    ctx.save(); ctx.globalAlpha = LEDDIM;
    ctx.fillStyle = lit ? 'rgba(255,255,255,.25)' : 'rgba(255,255,255,.07)'; ctx.fill();
    ctx.restore();
  }

  function drawMxgShiftLights(ctx, t, cfg) {
    var colors = SHIFT_PRESETS[cfg.shiftColors] || SHIFT_PRESETS.std;
    var rpm = n(t.rpm), limit = limRpm(t);
    var thresholds = shiftThresholds(t, cfg);
    var seq = shiftLitMask(rpm, thresholds, cfg);
    var count = seq.count;
    if (t.auxShiftLightOn) { count = Math.max(count, 8); }
    if (t.showcase) { count = 10; }
    var flash = rpm >= limit - 30 && Math.floor(Date.now() / 90) % 2 === 0;
    if (streetRefOk && cfg.shiftColors === 'std' && !flash) {
      // AiM's frosted lamps are soft pools of light, not hard rectangular
      // blocks. Draw the official lamp masks with screen blending, revealing
      // one tenth of the reference strip for each active stage.
      ctx.save(); ctx.globalCompositeOperation = 'screen';
      try { ctx.filter = 'grayscale(1) brightness(.20)'; } catch (e0) {}
      ctx.globalAlpha = 0.85 * LEDDIM;
      var shiftBase = refMask(streetRef, 'shift-base', 365, 55, 465, 50, 'light');
      if (shiftBase) { ctx.drawImage(shiftBase, 320, 24, 365, 26); }
      try { ctx.filter = 'none'; } catch (e1) {}
      ctx.globalAlpha = LEDDIM;
      for (var ri = 0; ri < count; ri++) {
        var shiftSeg = refMask(streetRef, 'shift-' + ri, 365 + ri * 46.5, 55, 47, 50, 'light');
        if (shiftSeg) { ctx.drawImage(shiftSeg, 320 + ri * 36.5, 24, 37, 26); }
      }
      ctx.restore(); return;
    }
    var x0 = 309, y = 30, gap = 3.5, total = 384, w = (total - gap * 9) / 10;
    for (var i = 0; i < 10; i++) {
      var lit = flash || (t.showcase || t.auxShiftLightOn ? i < count : seq.on[i]);
      mxgLens(ctx, x0 + i * (w + gap), y, w, 20, flash ? '#3a66ff' : colors[i], lit, 2.5);
    }
  }

  function drawMxgSideLeds(ctx, t, cfg, al) {
    var ys = [152, 189, 228, 270], i, lit;
    if (cfg.version === 'street') {
      var leftPos = [[97,152],[87,189],[78,228],[70,270]];
      var rightPos = [[902,153],[909,189],[917,228],[930,271]];
      var sourceCrops = [
        [80,192,79,53], [67,235,80,61], [56,282,82,62], [48,335,79,62],
        [1040,195,77,53], [1046,240,87,54], [1062,282,70,62], [1081,335,63,62]
      ];
      var leftAim = ['left_turn','high_beam','low_beam','mil'];
      var rightAim = ['right_turn','oil_pressure','coolant_temp','fuel_low'];
      var streetSizes = [48,50,50,48];
      for (i = 0; i < 4; i++) {
        var le = STREET_L[i], re = STREET_R[i];
        var ll = !!t.showcase || streetLit(le.a, t, al), rl = !!t.showcase || streetLit(re.a, t, al);
        var la = drawAimIcon(ctx, leftAim[i], leftPos[i][0], leftPos[i][1], streetSizes[i], le.c, ll ? 1 : 0.52, !!ll);
        var ra = drawAimIcon(ctx, rightAim[i], rightPos[i][0], rightPos[i][1], streetSizes[i], re.c, rl ? 1 : 0.52, !!rl);
        if (la && ra) {
          // exact 64x64 AiM stock glyphs loaded
        } else if (streetRefOk) {
          ctx.save(); ctx.globalCompositeOperation = 'screen'; ctx.globalAlpha = (ll ? 1 : 0.10) * LEDDIM;
          var lc = sourceCrops[i], lp = leftPos[i];
          var lm = refMask(streetRef, 'street-l-' + i, lc[0], lc[1], lc[2], lc[3], 'color');
          if (lm) { ctx.drawImage(lm, lp[0] - lc[2] * .42, lp[1] - lc[3] * .41, lc[2] * .84, lc[3] * .82); }
          ctx.globalAlpha = rl ? 1 : 0.10;
          var rc = sourceCrops[i + 4], rp = rightPos[i];
          var rm = refMask(streetRef, 'street-r-' + i, rc[0], rc[1], rc[2], rc[3], 'color');
          if (rm) { ctx.drawImage(rm, rp[0] - rc[2] * .42, rp[1] - rc[3] * .41, rc[2] * .84, rc[3] * .82); }
          ctx.restore();
        } else {
          ctx.globalAlpha = (ll ? 1 : 0.16) * LEDDIM; icon(ctx, le.k, leftPos[i][0], leftPos[i][1], 34, ll ? le.c : '#5a5e60', !!ll);
          ctx.globalAlpha = (rl ? 1 : 0.16) * LEDDIM; icon(ctx, re.k, rightPos[i][0], rightPos[i][1], 34, rl ? re.c : '#5a5e60', !!rl);
        }
      }
      ctx.globalAlpha = 1; return;
    }
    var lx = [91,81,73,61], rx = [905,916,928,938], ry = [165,199,234,270];
    for (i = 0; i < 4; i++) {
      lit = hasAlarm(al, RACE_MAP[i]);
      mxgLens(ctx, lx[i] - 35, ry[i] - 13, 70, 27, RACE_COLS[i], !!lit, 5);
      mxgPaintText(ctx, String(i + 1), lx[i] + 47, ry[i] + 8, NUM(16), '#f1f1f1', 'center');
      lit = hasAlarm(al, RACE_MAP[i + 4]);
      mxgPaintText(ctx, String(i + 5), rx[i] - 47, ry[i] + 8, NUM(16), '#f1f1f1', 'center');
      mxgLens(ctx, rx[i] - 35, ry[i] - 13, 70, 27, RACE_COLS[i + 4], !!lit, 5);
    }
  }

  // ---- boot / transition / alarm-popup helpers -------------------------
  function captureScreen(ctx, x, y, w, h) {
    if (!global.document) { return null; }
    try {
      var data = ctx.getImageData(Math.round(x), Math.round(y), Math.round(w), Math.round(h));
      var oc = global.document.createElement('canvas');
      oc.width = data.width; oc.height = data.height;
      oc.getContext('2d').putImageData(data, 0, 0);
      return oc;
    } catch (e) { return null; }
  }
  function startTransition(st, cfg, fromPage, toPage, snapCanvas, D) {
    st._transition = {
      from: fromPage, to: toPage, start: Date.now(), duration: 180,
      snap: snapCanvas || null
    };
  }
  function transitionProgress(st) {
    if (!st._transition) { return { active: false, page: st.page || 'ring', p: 1 }; }
    var elapsed = Date.now() - st._transition.start;
    if (elapsed >= st._transition.duration) {
      var tp = st._transition.to;
      st._transition = null;
      return { active: false, page: tp, p: 1 };
    }
    return { active: true, page: st._transition.to, p: elapsed / st._transition.duration };
  }
  function drawBootSplash(ctx, D, st) {
    var X = D.scrX, Y = D.scrY, W = D.scrW, H = D.scrH;
    ctx.fillStyle = '#020304'; ctx.fillRect(X, Y, W, H);
    var elapsed = Date.now() - st._boot.start;
    var progress = Math.max(0, Math.min(1, elapsed / (st._boot.until - st._boot.start)));
    var logoH = Math.min(H * 0.22, W * 0.18), logoW = logoH * (logoOk && logo.height ? logo.width / logo.height : 2.1);
    var lx = X + W / 2 - logoW / 2, ly = Y + H * 0.38 - logoH / 2;
    if (logoOk) {
      ctx.save(); ctx.globalAlpha = 0.95;
      ctx.drawImage(logo, lx, ly, logoW, logoH); ctx.restore();
    } else {
      ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
      ctx.fillStyle = '#f5f6f7'; ctx.font = 'oblique 800 ' + Math.round(H * 0.14) + 'px ' + F;
      ctx.fillText('LINK', X + W / 2, Y + H * 0.38);
    }
    var barW = W * 0.45, barH = Math.max(4, H * 0.014), barX = X + (W - barW) / 2, barY = Y + H * 0.58;
    rr(ctx, barX, barY, barW, barH, barH / 2); ctx.fillStyle = '#1b2334'; ctx.fill();
    rr(ctx, barX, barY, barW * progress, barH, barH / 2); ctx.fillStyle = '#4d78c4'; ctx.fill();
  }
  function rotatingBanner(st, activeAlarms) {
    if (!activeAlarms.length) { return null; }
    if (!st._alarmCycle || st._alarmCycle.lastSet + 1800 < Date.now() || st._alarmCycle.idx >= activeAlarms.length) {
      var idx = 0;
      if (st._alarmCycle && st._alarmCycle.idx < activeAlarms.length) { idx = (st._alarmCycle.idx + 1) % activeAlarms.length; }
      st._alarmCycle = { idx: idx, lastSet: Date.now() };
    }
    return activeAlarms[st._alarmCycle.idx];
  }
  function updateAlarmPopup(st, activeAlarms) {
    st._alarmPopupShown = st._alarmPopupShown || {};
    var now = Date.now(), activeIds = {}, newSev2 = null;
    for (var i = 0; i < activeAlarms.length; i++) {
      var a = activeAlarms[i];
      if (a.sev >= 2) {
        activeIds[a.id] = true;
        if (!newSev2 && !st._alarmPopupShown[a.id]) { newSev2 = a; }
      }
    }
    // Clear shown flags for alarms that have gone away so they can re-trigger later.
    Object.keys(st._alarmPopupShown).forEach(function (id) { if (!activeIds[id]) { delete st._alarmPopupShown[id]; } });
    if (newSev2) {
      st._alarmPopup = { id: newSev2.id, text: newSev2.msg, until: now + 1500 };
      st._alarmPopupShown[newSev2.id] = true;
    } else if (st._alarmPopup && st._alarmPopup.until < now && !activeIds[st._alarmPopup.id]) {
      st._alarmPopup = null;
    }
  }
  function drawAlarmPopup(ctx, D, st) {
    if (!st._alarmPopup || st._alarmPopup.until < Date.now()) { return; }
    var X = D.scrX, Y = D.scrY, W = D.scrW, H = D.scrH;
    var pad = Math.max(8, W * 0.03), h = H * 0.16, y = Y + H * 0.42;
    ctx.save(); ctx.globalAlpha = 0.92;
    rr(ctx, X + pad, y, W - pad * 2, h, 6); ctx.fillStyle = '#a81c15'; ctx.fill();
    ctx.strokeStyle = '#ff4136'; ctx.lineWidth = Math.max(1, H * 0.005); ctx.stroke();
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillStyle = '#ffffff'; ctx.font = LAB(Math.min(H * 0.07, W * 0.05));
    ctx.fillText(st._alarmPopup.text, X + W / 2, y + h / 2);
    ctx.restore();
  }

  // ---- native MX Strada button screens ---------------------------------
  // The four bezel keys are multifunction softkeys.  AiM's MX Strada guide
  // shows these screens as a brushed-grey panel with black vertical key tabs;
  // keeping them on the LCD also makes the browser remote behave like the
  // hardware instead of opening an unrelated web popup.
  function mxgUiBackground(ctx) {
    var g = ctx.createLinearGradient(0, 0, 800, 480);
    g.addColorStop(0, '#666768'); g.addColorStop(.48, '#4e5051'); g.addColorStop(1, '#676869');
    ctx.fillStyle = g; ctx.fillRect(0, 0, 800, 480);
    ctx.save(); ctx.globalAlpha = .08; ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1;
    for (var x = 2; x < 800; x += 7) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x - 45, 480); ctx.stroke(); }
    ctx.restore();
  }

  function mxgSoftTab(ctx, x, y, w, h, label, active) {
    rr(ctx, x, y, w, h, 8); ctx.fillStyle = active ? '#e7191f' : '#050607'; ctx.fill();
    ctx.fillStyle = '#f6f6f6'; ctx.font = LAB(12); ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    var letters = String(label || '').split(''), step = Math.min(17, (h - 16) / Math.max(1, letters.length));
    var yy = y + h / 2 - (letters.length - 1) * step / 2;
    for (var i = 0; i < letters.length; i++) { ctx.fillText(letters[i], x + w / 2, yy + i * step); }
  }

  function mxgMenuGlyph(ctx, kind, x, y, w, h) {
    ctx.save(); ctx.translate(x, y); ctx.lineWidth = 4; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
    if (kind === 0) {
      ctx.fillStyle = '#f06a20'; ctx.fillRect(7, 29, 48, 42); ctx.fillStyle = '#fff'; ctx.font = NUM(22); ctx.textAlign = 'center'; ctx.fillText('24', 31, 59);
      ctx.beginPath(); ctx.arc(54, 24, 22, 0, Math.PI * 2); ctx.fillStyle = '#ecebe7'; ctx.fill(); ctx.strokeStyle = '#202224'; ctx.stroke();
      ctx.beginPath(); ctx.moveTo(54,24); ctx.lineTo(54,9); ctx.moveTo(54,24); ctx.lineTo(67,30); ctx.stroke();
    } else if (kind === 1) {
      ctx.beginPath(); ctx.arc(40, 30, 22, Math.PI * .13, Math.PI * .87, true); ctx.quadraticCurveTo(17,50,28,57); ctx.lineTo(52,57); ctx.quadraticCurveTo(63,50,62,35); ctx.fillStyle = '#ffd334'; ctx.fill(); ctx.strokeStyle = '#5a4b10'; ctx.stroke();
      ctx.strokeStyle = '#ededed'; ctx.lineWidth = 8; ctx.beginPath(); ctx.moveTo(20,74); ctx.lineTo(60,74); ctx.stroke();
    } else if (kind === 2) {
      ctx.fillStyle = '#d51d1d'; ctx.fillRect(4, 18, 26, 63); ctx.fillStyle = 'rgba(245,245,245,.65)'; tracePoly(ctx, [[30,22],[78,6],[78,94],[30,78]]); ctx.fill();
    } else if (kind === 3) {
      ctx.strokeStyle = '#e8e8e8'; ctx.beginPath(); ctx.moveTo(18,82); ctx.lineTo(18,14); ctx.stroke();
      var s = 14; for (var cy = 0; cy < 4; cy++) { for (var cx = 0; cx < 4; cx++) { ctx.fillStyle = (cx + cy) % 2 ? '#fff' : '#151515'; ctx.fillRect(20 + cx*s, 14 + cy*s, s, s); } }
    } else if (kind === 4) {
      ctx.fillStyle = '#aaa'; rr(ctx, 8, 8, 64, 80, 4); ctx.fill(); ctx.fillStyle = '#f0f0f0'; ctx.font = LAB(12); ctx.textAlign = 'center';
      ['0:52.00','5.9 Km','1:13.13','2.4 Km'].forEach(function (v, i) { ctx.fillText(v, 40, 28 + i * 16); });
    } else if (kind === 5) {
      ctx.strokeStyle = '#17191b'; ctx.lineWidth = 7; ctx.beginPath(); ctx.moveTo(16,22); ctx.bezierCurveTo(68,0,75,35,50,47); ctx.bezierCurveTo(18,63,17,89,67,78); ctx.stroke();
      ctx.strokeStyle = '#ec1f24'; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(59,67); ctx.lineTo(71,78); ctx.stroke();
    } else {
      ctx.fillStyle = '#111'; rr(ctx, 5, 18, 70, 52, 5); ctx.fill(); ctx.strokeStyle = '#777'; ctx.stroke();
      ctx.fillStyle = '#fff'; ctx.font = LAB(16); ctx.textAlign = 'center'; ctx.fillText('Ver. 04', 40, 49);
    }
    ctx.restore();
  }

  /* ====================================================================
   * DECLARATIVE DEVICE-MENU MODEL           (PARITY 2026-07-29)
   *
   * The drawer and the click handler each used to carry their own copy of the
   * menu, and they disagreed: the drawer painted 2-3 rows for sections 3..6
   * while click() hardcoded `sectionRows = s===1?3:(s===0?3:1)`, trapping the
   * cursor on row 0 in four of seven sections. One table now drives both, so a
   * row that is drawn is always reachable.
   *
   * The real device has SIX icons, not seven (AiM MX 1.2+1.3 Strada user guide
   * p.7):   row 1  Date Time | Backlight | Video In | Counters
   *         row 2  GPS and Tracks | System Info
   * `LAP TIME SETUP` was invented and is gone.
   *
   * Row kinds:
   *   opts   enum cycled with CHANGE / DEC / INC
   *   pick   numeric value chosen from a list
   *   act    fires a callback
   *   odo    a Counters row (see the odometer block above)
   *   (none) read-only
   * `when(cfg)` hides a row - the real BACKLIGHT page swaps its whole row set
   * between AUTOMATIC and MANUAL, which is why this exists.
   * ==================================================================== */
  var PCT5 = [20, 40, 60, 80, 100];
  var PCT10 = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
  var PRODUCT = { logger: 'LINK MXG 7"', fw: '', boot: '' };
  function setProduct(p) {
    if (!p) { return; }
    for (var k in p) { if (own(p, k)) { PRODUCT[k] = String(p[k]); } }
  }
  // A real unit has a serial from the factory. Mint one per install and keep it.
  function serialNo() {
    var s = safeLsGet('oepdash_serial');
    if (!s) {
      var h = 5381, seed = String(Date.now()) + '|' + Math.random();
      for (var i = 0; i < seed.length; i++) { h = ((h << 5) + h + seed.charCodeAt(i)) >>> 0; }
      s = String(100000 + (h % 900000));
      safeLsSet('oepdash_serial', s);
    }
    return s;
  }
  function cycleOpt(arr, cur, dir) {
    var i = arr.indexOf(cur);
    if (i < 0) { i = 0; } else { i = (i + (dir < 0 ? arr.length - 1 : 1)) % arr.length; }
    return arr[i];
  }
  function distStr(m) {
    var km = (Number(m) || 0) / 1000;
    return (km >= 1000 ? Math.round(km) : km.toFixed(1)) + ' km';
  }

  function menuSections(cfg, t, st) {
    var odo = st._odo || { sys: 0, u: [0, 0, 0, 0], run: [false, false, false, false] };
    var usr = [];
    for (var ui = 0; ui < 4; ui++) {
      (function (idx) {
        usr.push({ label: 'Usr ' + (idx + 1) + ':', odo: idx,
          get: function () { return distStr(odo.u[idx]) + (odo.run[idx] ? ' *' : ''); } });
      })(ui);
    }
    return [
      { title: 'DATE TIME', glyph: 0, rows: [
        { label: 'Time Format:', key: 'timeFormat', opts: ['24H', '12H'] },
        { label: 'Date Format', key: 'dateFormat', opts: ['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY/MM/DD'] },
        // The real row reads "by GPS Track". There is no GPS here and the Strada
        // has none without a GPS09, so name the source we actually use.
        { label: 'Date Time Sync', key: 'timeSync', opts: ['by PC CLOCK', 'Disabled'] }
      ]},
      { title: 'BACKLIGHT', glyph: 1, rows: [
        { label: 'Night Vision', key: 'nightVision', opts: ['AUTOMATIC', 'MANUAL'],
          after: function (c) { c.brightness = c.nightVision === 'AUTOMATIC' ? 'auto' : (c.brightness === 'auto' ? 'day' : c.brightness); } },
        { label: 'Day Bright Lev.:', key: 'dayBrightLevel', pick: PCT5, unit: '%',
          when: function (c) { return c.nightVision !== 'MANUAL'; } },
        { label: 'Night Bright Lev.:', key: 'brightnessLevel', pick: PCT5, unit: '%',
          when: function (c) { return c.nightVision !== 'MANUAL'; } },
        { label: 'Threshold:', key: 'nightThreshold', pick: PCT5, unit: '%',
          when: function (c) { return c.nightVision !== 'MANUAL'; } },
        { label: 'Night Mode', key: 'brightness', opts: ['day', 'night'], upper: true,
          when: function (c) { return c.nightVision === 'MANUAL'; } },
        { label: 'Brightness', key: 'brightnessLevel', pick: PCT5, unit: '%',
          when: function (c) { return c.nightVision === 'MANUAL'; } }
      ]},
      { title: 'VIDEO IN', glyph: 2, rows: [
        { label: 'Input', key: 'videoInput', opts: ['VIDEO 1', 'VIDEO 2'] },
        { label: 'Format', key: 'videoFormat', opts: ['NTSC', 'PAL'] },
        { label: 'Brightness', key: 'videoBrightness', pick: PCT10, unit: '%' },
        { label: 'Contrast', key: 'videoContrast', pick: PCT10, unit: '%' },
        // No sim can render a game camera into a CEF canvas. NO SIGNAL is
        // exactly what a real unit with nothing wired shows - a truthful blank
        // beats an invented feed.
        { label: 'Signal', get: function () { return 'NO SIGNAL'; } }
      ]},
      { title: 'COUNTERS', glyph: 4, rows: [
        { label: 'System', get: function () { return distStr(odo.sys); } }
      ].concat(usr) },
      { title: 'GPS AND TRACKS', glyph: 5, rows: [
        { label: 'Mode', key: 'trackMode', opts: ['AUTOMATIC', 'MANUAL'] },
        { label: 'Track List Type', key: 'trackList', opts: ['NEAREST', 'ALL', 'CUSTOM'] },
        // Honest: lap timing on a real Strada needs the optional GPS09 module.
        { label: 'Tracks Info', get: function () { return 'NO GPS MODULE'; } }
      ]},
      { title: 'SYSTEM INFO', glyph: 6, rows: [
        { label: 'Logger', get: function () { return PRODUCT.logger; } },
        { label: 'Serial N.:', get: function () { return serialNo(); } },
        { label: 'Fw Version', get: function () { return PRODUCT.fw || '-'; } },
        { label: 'Boot Version', get: function () { return PRODUCT.boot || '-'; } }
      ]}
    ];
  }
  function visibleRows(sec, cfg) {
    var out = [];
    for (var i = 0; i < sec.rows.length; i++) {
      var r = sec.rows[i];
      if (!r.when || r.when(cfg)) { out.push(r); }
    }
    return out;
  }
  function rowValue(r, cfg, t, st) {
    if (r.get) { return String(r.get(cfg, t, st)); }
    var v = cfg[r.key];
    if (r.opts) { return String(r.upper ? String(v).toUpperCase() : v); }
    if (r.pick) { return String(v) + (r.unit || ''); }
    return '-';
  }
  function rowEditable(r) { return !!(r && (r.opts || r.pick || r.act || r.odo != null)); }
  function rowChange(r, cfg, t, st, dir) {
    if (!r) { return; }
    if (r.opts) { cfg[r.key] = cycleOpt(r.opts, cfg[r.key], dir); }
    else if (r.pick) { cfg[r.key] = cycleOpt(r.pick, Number(cfg[r.key]), dir); }
    else if (r.act) { r.act(cfg, t, st, dir); }
    if (r.after) { r.after(cfg, t, st); }
  }
  /* The four keys are multifunction softkeys - the real unit relabels them per
   * screen. This used to be hardcoded three times in three drawers. */
  function tabsFor(st, row) {
    if (st.uiMode === 'menu') { return { lt: 'PREV', lb: 'NEXT', rt: 'ENTER', rb: 'EXIT', rtOn: false }; }
    if (st.uiMode === 'recall') {
      return { lt: 'PREV', lb: 'NEXT', rt: st.recallPage === 'tests' ? 'PAGE' : 'TESTS', rb: 'EXIT', rtOn: true };
    }
    if (st.uiMode === 'submenu') {
      if (st.editing) {
        // Counters rows reset rather than decrement - the real page offers
        // activate and reset, not a numeric spinner.
        var isOdo = row && row.odo != null;
        return { lt: isOdo ? 'RESET' : 'DEC', lb: isOdo ? 'START' : 'INC', rt: 'DESEL', rb: 'EXIT', rtOn: true };
      }
      var ed = rowEditable(row);
      // SYSTEM INFO has nothing editable, so it is EXIT-only on the real unit.
      return { lt: 'PREV', lb: 'NEXT', rt: ed ? 'CHANGE' : '', rb: 'EXIT', rtOn: false };
    }
    return null;
  }
  // Softkey tabs used to be painted but unclickable - the only hit zones were
  // the bezel strips. Record where they land so click() can route them.
  function paintTabs(ctx, D, st, row) {
    var tb = tabsFor(st, row); if (!tb) { return; }
    var sx = D.scrW / 800, sy = D.scrH / 480;
    var geo = [
      { id: 'menu', x: 0,   y: 15,  label: tb.lt, on: false },
      { id: 'next', x: 0,   y: 251, label: tb.lb, on: false },
      { id: 'mem',  x: 765, y: 15,  label: tb.rt, on: tb.rtOn },
      { id: 'view', x: 765, y: 251, label: tb.rb, on: false }
    ];
    st._tabZones = [];
    for (var i = 0; i < geo.length; i++) {
      var g = geo[i];
      if (!g.label) { continue; }
      mxgSoftTab(ctx, g.x, g.y, 35, 214, g.label, g.on);
      st._tabZones.push({ id: g.id, x: D.scrX + g.x * sx, y: D.scrY + g.y * sy, w: 35 * sx, h: 214 * sy });
    }
  }

  function drawMxgMainMenu(ctx, D, t, st, cfg) {
    var secs = menuSections(cfg, t, st);
    ctx.save(); ctx.translate(D.scrX, D.scrY); ctx.scale(D.scrW / 800, D.scrH / 480); ctx.beginPath(); ctx.rect(0,0,800,480); ctx.clip();
    mxgUiBackground(ctx);
    // Corrected tile geometry: 118 px on a 166.5 px pitch at x 94/260/427/594,
    // rows at y 73 and 234. The old 112 px tiles at 78/242/406/570 sat ~16 px
    // left and 15 px high of the photographed layout.
    var pos = [[94,73],[260,73],[427,73],[594,73],[94,234],[260,234]];
    var selected = Math.max(0, Math.min(secs.length - 1, Number(st.menuIndex) || 0));
    for (var i = 0; i < secs.length && i < pos.length; i++) {
      var px = pos[i][0], py = pos[i][1];
      rr(ctx, px, py, 118, 118, 8);
      var tg = ctx.createLinearGradient(0, py, 0, py+118); tg.addColorStop(0,'#555758'); tg.addColorStop(1,'#37393a');
      ctx.fillStyle = tg; ctx.fill();
      if (i === selected) { ctx.strokeStyle='#fff'; ctx.lineWidth=3; ctx.stroke(); }
      mxgMenuGlyph(ctx, secs[i].glyph, px+18, py+11, 82, 96);
    }
    ctx.beginPath(); ctx.moveTo(68,412); ctx.lineTo(732,412); ctx.strokeStyle='#f2f2f2'; ctx.lineWidth=2; ctx.stroke();
    mxgPaintText(ctx, secs[selected].title, 400, 451, LAB(24), '#fff', 'center', 0.3, 1.0);
    ctx.restore();
    paintTabsScaled(ctx, D, st, null);
  }

  // paints the tabs inside the LCD's coordinate space while recording hit zones
  // in canvas space
  function paintTabsScaled(ctx, D, st, row) {
    ctx.save(); ctx.translate(D.scrX, D.scrY); ctx.scale(D.scrW / 800, D.scrH / 480);
    paintTabs(ctx, D, st, row);
    ctx.restore();
  }

  function drawMxgSubmenu(ctx, D, t, st, cfg) {
    var secs = menuSections(cfg, t, st);
    var si = Math.max(0, Math.min(secs.length - 1, Number(st.menuSection) || 0));
    var sec = secs[si];
    var rows = visibleRows(sec, cfg);
    var activeRow = Math.max(0, Math.min(rows.length - 1, Number(st.subIndex) || 0));
    st.subIndex = activeRow;

    ctx.save(); ctx.translate(D.scrX, D.scrY); ctx.scale(D.scrW / 800, D.scrH / 480); ctx.beginPath(); ctx.rect(0,0,800,480); ctx.clip();
    mxgUiBackground(ctx);
    mxgPaintText(ctx, sec.title, 400, 42, LAB(27), '#fff', 'center', .3);
    ctx.strokeStyle='#f1f1f1'; ctx.lineWidth=2; ctx.beginPath(); ctx.moveTo(72,390); ctx.lineTo(728,390); ctx.stroke();

    var step = rows.length > 4 ? 60 : 74, top = rows.length > 4 ? 96 : 112;
    for (var i = 0; i < rows.length; i++) {
      var yy = top + i * step, isAct = i === activeRow;
      if (isAct && st.editing) {
        // an editable row under edit gets the inverse highlight the real unit uses
        rr(ctx, 72, yy - 26, 656, 36, 4); ctx.fillStyle = 'rgba(239,41,41,.30)'; ctx.fill();
      }
      var col = isAct ? '#ef2929' : '#f0f0f0';
      mxgPaintText(ctx, rows[i].label, 82, yy, LAB(20), col, 'left', 0.2);
      mxgPaintText(ctx, rowValue(rows[i], cfg, t, st), 716, yy, LAB(20), col, 'right', 0.2);
    }
    mxgPaintText(ctx, clock12(t, cfg), 82, 430, LAB(20), '#fff', 'left', 0.2);
    mxgPaintText(ctx, dateStr(cfg), 716, 430, LAB(20), '#fff', 'right', 0.2);
    ctx.restore();
    paintTabsScaled(ctx, D, st, rows[activeRow]);
  }

  /* PARITY 2026-07-29: this screen used to fabricate its content. "BEST:" and
   * the BEST LAPS column both printed st.lap.showMs - the RUNNING stopwatch -
   * so no best lap could exist; the lap table had one row carrying the live
   * instantaneous RPM and speed rather than that lap's values; and the session
   * line was the literal "TODAY: BEAMNG SESSION". It now reads the real lap
   * store built by the lap key, and prints '--' when there is nothing yet. */
  function lapRows(st) { return (st.lap && st.lap.laps) || []; }
  function bestLapMs(st) { return st.lap && st.lap.best != null ? st.lap.best : null; }
  function drawMxgRecall(ctx, D, t, st, cfg) {
    ctx.save(); ctx.translate(D.scrX, D.scrY); ctx.scale(D.scrW / 800, D.scrH / 480); ctx.beginPath(); ctx.rect(0,0,800,480); ctx.clip();
    mxgUiBackground(ctx);
    var laps = lapRows(st), best = bestLapMs(st);
    if (st.recallPage === 'tests') {
      mxgPaintText(ctx,'TEST SESSIONS',400,42,LAB(27),'#fff','center',.3);
      var session = [
        'TODAY  ' + dateStr(cfg),
        'LAPS: ' + String(laps.length),
        'BEST: ' + (best != null ? lapFmt(best) : '--:--.--')
      ];
      for (var si=0;si<session.length;si++) { mxgPaintText(ctx,session[si],80,118+si*65,LAB(22),si===0?'#ef2026':'#fff','left',.2); }
    } else {
      mxgPaintText(ctx,'TODAY  '+clock12(t, cfg),400,38,LAB(25),'#fff','center',.3);
      ctx.strokeStyle='#f4f4f4'; ctx.lineWidth=2; ctx.beginPath(); ctx.moveTo(75,61); ctx.lineTo(725,61); ctx.moveTo(75,158); ctx.lineTo(725,158); ctx.stroke();
      mxgPaintText(ctx,'MAX RPM',260,94,LAB(18),'#fff','center',.2); mxgPaintText(ctx,'MAX SPEED',545,94,LAB(18),'#fff','center',.2);
      mxgPaintText(ctx,String(Math.round(st.peakRpm||n(t.rpm))),260,132,NUM(28),'#fff','center',.3);
      mxgPaintText(ctx,String(Math.round(st.peakSpeed||n(t.speedKmh)))+' km/h',545,132,NUM(28),'#fff','center',.3);
      var headers=[['LAP',95],['BEST LAPS',295],['RPM',500],['km/h',665]];
      headers.forEach(function(h){mxgPaintText(ctx,h[0],h[1],190,LAB(17),'#fff','center',.2);});
      ctx.beginPath(); ctx.moveTo(75,207); ctx.lineTo(725,207); ctx.stroke();
      // three best laps, scrolled by PREV/NEXT (st.recallIndex used to be inert)
      var sorted = laps.slice().sort(function (a, b) { return a.ms - b.ms; });
      var off = Math.max(0, Math.min(Math.max(0, sorted.length - 3), Number(st.recallIndex) || 0));
      st.recallIndex = off;
      for (var r = 0; r < 3; r++) {
        var L = sorted[off + r], yy = 252 + r * 46;
        mxgPaintText(ctx, L ? String(L.n) : '-', 95, yy, LAB(20), r === 0 ? '#ef2026' : '#fff', 'center', .2);
        mxgPaintText(ctx, L ? lapFmt(L.ms) : '--:--.--', 295, yy, LAB(20), r === 0 ? '#ef2026' : '#fff', 'center', .2);
        mxgPaintText(ctx, L && L.maxRpm ? String(Math.round(L.maxRpm)) : '-', 500, yy, LAB(20), '#fff', 'center', .2);
        mxgPaintText(ctx, L && L.maxKmh ? String(Math.round(L.maxKmh)) : '-', 665, yy, LAB(20), '#fff', 'center', .2);
      }
    }
    ctx.restore();
    paintTabsScaled(ctx, D, st, null);
  }

  function drawMxgDeviceUi(ctx, D, t, st, cfg) {
    if (st.uiMode === 'menu') { drawMxgMainMenu(ctx,D,t,st,cfg); }
    else if (st.uiMode === 'submenu') { drawMxgSubmenu(ctx,D,t,st,cfg); }
    else if (st.uiMode === 'recall') { drawMxgRecall(ctx,D,t,st,cfg); }
  }

  function draw(cv, t, st, cfg) {
    t = t || {}; cfg = cfg || defaults(); st = st || {};
    if (!st._boot) { st._boot = { start: Date.now(), until: Date.now() + 1200 }; }
    if (PAGES.indexOf(st.page || cfg.page || 'ring') < 0) { st.page = 'ring'; cfg.page = 'ring'; }
    if (cfg.model === '5') { drawLegacy(cv, t, st, cfg); return; }

    st.minmax = st.minmax || {}; st.boxMode = st.boxMode || {};
    st.lap = st.lap || { n: 0, t0: null, showMs: null };
    if (['live','menu','submenu','recall'].indexOf(st.uiMode) < 0) { st.uiMode = 'live'; }
    if (st.uiMode !== 'submenu') { st.editing = null; }
    st.peakRpm = Math.max(Number(st.peakRpm) || 0, n(t.rpm));
    st.peakSpeed = Math.max(Number(st.peakSpeed) || 0, n(t.speedKmh));
    for (var i = 0; i < CH_ORDER.length; i++) {
      var key = CH_ORDER[i], value = CHANNELS[key].v(t);
      if (isFinite(value) && n(t.rpm) > 0) {
        var mm = st.minmax[key] || (st.minmax[key] = { min:value, max:value });
        if (value < mm.min) { mm.min = value; } if (value > mm.max) { mm.max = value; }
      }
    }
    /* PARITY 2026-07-29: showMs used to be BOTH the running stopwatch and the
     * completed-lap readout, so a finished lap survived exactly one frame and
     * could never be read. runMs is the live timer; showMs now holds the last
     * completed lap until the next one finishes (real units hold it too). */
    if (st.lap.t0 != null) { st.lap.runMs = Date.now() - st.lap.t0; }
    if (st.lap.showMs == null && st.lap.runMs != null) { st.lap.showMs = st.lap.runMs; }
    // per-lap peaks, so Data Recall can print real max RPM / km/h per lap
    if (st.lap.t0 != null) {
      st.lap.curMaxRpm = Math.max(Number(st.lap.curMaxRpm) || 0, n(t.rpm));
      st.lap.curMaxKmh = Math.max(Number(st.lap.curMaxKmh) || 0, n(t.speedKmh));
    }
    tickOdo(t, st);

    var ctx = cv.getContext('2d'), W = cv.width, H = cv.height, sx = W / 1000, sy = H / 540;
    if (st._transition && !st._transition.snap) {
      var Dpre = { scrX: W * 0.184, scrY: H * (73 / 540), scrW: W * 0.644, scrH: H * (386 / 540) };
      st._transition.snap = captureScreen(ctx, Dpre.scrX, Dpre.scrY, Dpre.scrW, Dpre.scrH);
    }
    ctx.clearRect(0, 0, W, H);
    ctx.save(); ctx.scale(sx, sy);
    mxgBodyPath(ctx);
    var body = ctx.createLinearGradient(80, 0, 850, 540);
    body.addColorStop(0, '#343536'); body.addColorStop(0.28, '#1a1b1c'); body.addColorStop(0.68, '#090a0b'); body.addColorStop(1, '#030405');
    ctx.fillStyle = body; ctx.fill(); ctx.strokeStyle = '#3a3c3e'; ctx.lineWidth = 2; ctx.stroke();
    ctx.fillStyle = 'rgba(255,255,255,.045)'; tracePoly(ctx, [[208,7],[758,7],[902,71],[811,119],[187,119],[64,91]]); ctx.fill();
    ctx.fillStyle = 'rgba(255,255,255,.025)'; tracePoly(ctx, [[2,349],[310,538],[144,538],[88,522],[25,482],[2,468]]); ctx.fill();
    ctx.fillStyle = 'rgba(0,0,0,.42)'; tracePoly(ctx, [[694,3],[760,3],[930,76],[987,151],[997,380],[852,209]]); ctx.fill();

    rr(ctx, 177, 62, 656, 408, 5);
    var frame = ctx.createLinearGradient(0, 62, 0, 470); frame.addColorStop(0, '#111519'); frame.addColorStop(.45, '#020405'); frame.addColorStop(1, '#1c2022');
    ctx.fillStyle = frame; ctx.fill(); ctx.strokeStyle = '#060708'; ctx.lineWidth = 7; ctx.stroke();
    tracePoly(ctx, [[298,22],[710,22],[739,55],[283,55]]); ctx.fillStyle = '#080a0b'; ctx.fill(); ctx.strokeStyle = '#383b3e'; ctx.lineWidth = 2; ctx.stroke();
    tracePoly(ctx, [[313,27],[696,27],[715,50],[294,50]]); ctx.fillStyle = '#181b1d'; ctx.fill();
    ctx.restore();

    var D = { W:W, H:H, u:H * 0.045, bezL:W * 0.184,
      scrX:W * 0.184, scrY:H * (73 / 540), scrW:W * 0.644, scrH:H * (386 / 540) };
    var al = alarms(t);
    var activeBanners = bannerAlarms(al);
    updateAlarmPopup(st, activeBanners);
    var progress = transitionProgress(st);
    var page = progress.page || st.page || cfg.page || 'ring';
    if (PAGES.indexOf(page) < 0) { page = 'ring'; }
    st.page = page;

    var bootActive = st._boot && st._boot.until > Date.now();
    function drawCurrentPage() {
      if (page === 'race') { drawRacePage(ctx, D, t, st, cfg); }
      else if (page === 'bars') { drawBarsPage(ctx, D, t, st, cfg, al); }
      else { drawRingPage(ctx, D, t, st, cfg, al); }
    }
    if (bootActive) {
      drawBootSplash(ctx, D, st);
    } else if (progress.active && st._transition && st._transition.snap) {
      ctx.save(); ctx.globalAlpha = 1 - progress.p;
      ctx.drawImage(st._transition.snap, D.scrX, D.scrY, D.scrW, D.scrH);
      ctx.restore();
      ctx.save(); ctx.globalAlpha = progress.p;
      drawCurrentPage();
      ctx.restore();
    } else {
      drawCurrentPage();
    }
    if (st.uiMode !== 'live') {
      st._boxZones = []; st._lapZone = null;
      drawMxgDeviceUi(ctx, D, t, st, cfg);
    }

    if (cfg.showReference || st.showReference) { drawReferenceOverlay(ctx, D); }
    drawAlarmPopup(ctx, D, st);

    if (night(cfg, t)) { ctx.fillStyle = 'rgba(2,4,10,' + nightAlpha(cfg) + ')'; ctx.fillRect(D.scrX, D.scrY, D.scrW, D.scrH); }

    // Phase 1 visual fidelity overlays.
    if (cfg.showGlare !== false) { drawGlare(ctx, D); }
    if (cfg.showPixelGrid !== false) { drawPixelGrid(ctx, D); }

    ctx.save(); ctx.scale(sx, sy);
    drawBezelScrews(ctx, sx, sy);
    LEDDIM = ledDimFor(cfg, t);
    drawMxgShiftLights(ctx, t, cfg); drawMxgSideLeds(ctx, t, cfg, al);
    LEDDIM = 1;

    // Hardware labels are silk-screened beside two metallic edge buttons per
    // side.  The older face dots were not present on the real MXG enclosure.
    var flashId = st._buttonFlash && st._buttonFlash.until > Date.now() ? st._buttonFlash.id : '';
    ctx.textBaseline = 'middle'; ctx.font = '700 10px ' + NF;
    ctx.textAlign = 'left'; ctx.fillStyle = flashId === 'menu' ? '#ff6a2b' : '#f0f1f2'; ctx.fillText('MENU', 22, 348); ctx.fillText('<<', 24, 361);
    ctx.fillStyle = flashId === 'next' ? '#ff6a2b' : '#f0f1f2'; ctx.fillText('>>', 25, 415);
    ctx.textAlign = 'right'; ctx.fillStyle = flashId === 'mem' ? '#ff6a2b' : '#f0f1f2'; ctx.fillText('MEM', 976, 341); ctx.fillText('OK', 976, 353);
    ctx.fillStyle = flashId === 'view' ? '#ff6a2b' : '#f0f1f2'; ctx.fillText('VIEW', 976, 411); ctx.fillText('QUIT', 976, 423);
    var buttonMetal = ctx.createLinearGradient(0, 0, 8, 0); buttonMetal.addColorStop(0, '#8b8c8d'); buttonMetal.addColorStop(.5, '#3f4142'); buttonMetal.addColorStop(1, '#17191a');
    [[0,342,'menu'],[0,402,'next'],[992,342,'mem'],[992,402,'view']].forEach(function (p) {
      rr(ctx, p[0], p[1], 8, 23, 4); ctx.fillStyle = buttonMetal; ctx.fill(); ctx.strokeStyle = flashId === p[2] ? '#ff6a2b' : '#0b0c0d'; ctx.lineWidth = flashId === p[2] ? 2 : 1; ctx.stroke();
    });

    if (logoOk) {
      ctx.save(); ctx.globalAlpha = .92;
      try { ctx.filter = 'grayscale(1) brightness(4.5)'; } catch (e) {}
      var logoH = 54, logoW = logoH * ((logo.width && logo.height) ? logo.width / logo.height : 2.103);
      ctx.drawImage(logo, 500 - logoW / 2, 471, logoW, logoH); ctx.restore();
    } else {
      mxgPaintText(ctx, 'LINK', 500, 505, 'oblique 800 28px ' + F, '#f5f6f7', 'center', 0.5);
    }
    ctx.restore();

    function z(x, y, w, h) { return { x:x*sx, y:y*sy, w:w*sx, h:h*sy }; }
    st._btnZones = [
      Object.assign({ id:'menu' }, z(0, 325, 177, 67)),
      Object.assign({ id:'next' }, z(0, 392, 177, 77)),
      Object.assign({ id:'mem' }, z(833, 325, 167, 60)),
      Object.assign({ id:'view' }, z(833, 385, 167, 84))
    ];
  }

  function inZone(z, x, y) { return z && x >= z.x && x <= z.x + z.w && y >= z.y && y <= z.y + z.h; }
  function mxgPress(st, id, text) {
    var now = Date.now(); st._buttonFlash = { id:id, until:now + 180 };
    if (text) { st._notice = { text:text, until:now + 900 }; }
  }
  function click(cv, x, y, st, cfg) {
    var i; st = st || {}; cfg = cfg || defaults();
    if (['live','menu','submenu','recall'].indexOf(st.uiMode) < 0) { st.uiMode = 'live'; }
    /* PARITY 2026-07-29: the on-screen softkey tabs were painted but dead - the
     * only hit zones were the bezel strips, so tapping a visible PREV/NEXT/
     * CHANGE/EXIT tab did nothing. The tabs carry the same ids as the physical
     * keys, so routing them through the same list is all it takes. Tabs go
     * FIRST: they sit inside the LCD and must win over any page hotspot. */
    var zones = (st.uiMode !== 'live' && st._tabZones ? st._tabZones : []).concat(st._btnZones || []);
    if (zones.length) {
      for (i = 0; i < zones.length; i++) {
        var b = zones[i];
        if (inZone(b, x, y)) {
          if (st.uiMode === 'live') {
            if (b.id === 'menu') {
              st.uiMode = 'menu'; st.menuIndex = Math.max(0, Math.min(6, Number(st.menuIndex) || 0));
              mxgPress(st, b.id, 'DEVICE MENU'); return 'menu';
            }
            if (b.id === 'next' || b.id === 'prev') {
              // Cycle through the factory page layouts (ring -> race -> bars -> ring).
              var pages = PAGES, idx = pages.indexOf(st.page || cfg.page || 'ring');
              var oldPage = pages[idx] || 'ring';
              idx = (idx + (b.id === 'next' ? 1 : pages.length - 1)) % pages.length;
              st._transition = { from: oldPage, to: pages[idx], start: Date.now(), duration: 180, snap: null };
              st.page = cfg.page = pages[idx];
              var pageName = st.page === 'ring' ? 'STREET' : (st.page === 'race' ? 'RACE' : 'BARS');
              mxgPress(st, b.id, 'PAGE: ' + pageName + ' (' + (idx + 1) + '/' + pages.length + ')');
              return b.id;
            }
            if (b.id === 'mem') {
              st.uiMode = 'recall'; st.recallPage = 'today'; st.recallIndex = 0;
              mxgPress(st, b.id, 'DATA RECALL'); return 'mem';
            }
            /* PARITY 2026-07-29: this used to print "VIEW 1 / 1" - a view list
             * the code does not have - and do nothing. AiM's alarm ending
             * conditions include "a button is pushed", so make this the
             * acknowledge key: it clears the popup and lets a still-active
             * alarm re-arm rather than asserting a feature we lack. */
            st._alarmPopup = null; st._alarmPopupShown = {}; st._alarmCycle = null;
            mxgPress(st, b.id, 'ALARMS ACK'); return 'view';
          }
          if (st.uiMode === 'menu') {
            var nSec = menuSections(cfg, {}, st).length;
            if (b.id === 'menu') { st.menuIndex = ((Number(st.menuIndex)||0) + nSec - 1) % nSec; mxgPress(st,b.id); return 'prev'; }
            if (b.id === 'next') { st.menuIndex = ((Number(st.menuIndex)||0) + 1) % nSec; mxgPress(st,b.id); return 'next'; }
            if (b.id === 'mem') { st.menuSection = Number(st.menuIndex)||0; st.subIndex = 0; st.editing = null; st.uiMode = 'submenu'; mxgPress(st,b.id); return 'enter'; }
            st.uiMode = 'live'; st.editing = null; mxgPress(st,b.id,'LIVE VIEW'); return 'quit';
          }
          if (st.uiMode === 'submenu') {
            /* PARITY 2026-07-29: row count now comes from the same table the
             * drawer uses, so every drawn row is reachable. The old hardcoded
             * `sectionRows` allowed exactly 1 row in four of seven sections. */
            var secs = menuSections(cfg, {}, st);
            var sec = secs[Math.max(0, Math.min(secs.length - 1, Number(st.menuSection) || 0))];
            var rows = visibleRows(sec, cfg);
            var ri = Math.max(0, Math.min(rows.length - 1, Number(st.subIndex) || 0));
            var row = rows[ri];

            // ---- field EDIT mode: the real 4-key cycle ----
            // SELECT makes a row editable, DEC/INC change it, DESEL commits.
            if (st.editing) {
              if (b.id === 'menu' || b.id === 'next') {
                var dir = b.id === 'next' ? 1 : -1;
                if (row && row.odo != null) {
                  st._odo = st._odo || { sys: 0, u: [0,0,0,0], run: [false,false,false,false] };
                  if (dir > 0) { st._odo.run[row.odo] = !st._odo.run[row.odo]; mxgPress(st, b.id, st._odo.run[row.odo] ? 'COUNTER RUNNING' : 'COUNTER STOPPED'); }
                  else { st._odo.u[row.odo] = 0; mxgPress(st, b.id, 'COUNTER RESET'); }
                } else {
                  rowChange(row, cfg, {}, st, dir);
                  mxgPress(st, b.id, String(row ? row.label : '') + ' ' + rowValue(row, cfg, {}, st));
                }
                return 'change';
              }
              if (b.id === 'mem') { st.editing = null; mxgPress(st, b.id, 'DESELECTED'); return 'desel'; }
              st.editing = null; st.uiMode = 'menu'; mxgPress(st, b.id); return 'back';
            }

            if (b.id === 'menu') { st.subIndex = (ri + rows.length - 1) % Math.max(1, rows.length); mxgPress(st,b.id); return 'prev'; }
            if (b.id === 'next') { st.subIndex = (ri + 1) % Math.max(1, rows.length); mxgPress(st,b.id); return 'next'; }
            if (b.id === 'mem') {
              if (!rowEditable(row)) { mxgPress(st, b.id); return false; }   // SYSTEM INFO is EXIT-only
              st.editing = { row: ri };
              mxgPress(st, b.id, 'SELECT: ' + String(row.label).toUpperCase());
              return 'select';
            }
            st.uiMode = 'menu'; mxgPress(st,b.id); return 'back';
          }
          if (st.uiMode === 'recall') {
            if (b.id === 'menu') { st.recallIndex = Math.max(0,(Number(st.recallIndex)||0)-1); mxgPress(st,b.id); return 'prev'; }
            if (b.id === 'next') { st.recallIndex = (Number(st.recallIndex)||0)+1; mxgPress(st,b.id); return 'next'; }
            if (b.id === 'mem') { st.recallPage = st.recallPage === 'tests' ? 'today' : 'tests'; mxgPress(st,b.id); return 'page'; }
            st.uiMode = 'live'; mxgPress(st,b.id,'LIVE VIEW'); return 'quit';
          }
        }
      }
    }
    if (st._boxZones) {
      for (i = 0; i < st._boxZones.length; i++) {
        var z = st._boxZones[i];
        if (inZone(z, x, y)) {
          var m = (st.boxMode && st.boxMode[z.key]) || 'live';
          st.boxMode = st.boxMode || {};
          st.boxMode[z.key] = m === 'live' ? 'max' : (m === 'max' ? 'min' : 'live');
          mxgPress(st,'mem',String(st.boxMode[z.key]).toUpperCase()+' RECALL'); return 'box';
        }
      }
    }
    if (inZone(st._lapZone, x, y)) {
      st.lap = st.lap || { n: 0, t0: null, showMs: null };
      st.lap.laps = st.lap.laps || [];
      if (st.lap.t0 == null) { st.lap.t0 = Date.now(); st.lap.n = Math.max(1, st.lap.n); st.lap.runMs = 0; }
      else {
        var lapMs = Date.now() - st.lap.t0;
        st.lap.showMs = lapMs;                       // held until the NEXT lap completes
        st.lap.laps.push({ n: st.lap.n, ms: lapMs,
          maxRpm: Number(st.lap.curMaxRpm) || 0, maxKmh: Number(st.lap.curMaxKmh) || 0 });
        if (st.lap.best == null || lapMs < st.lap.best) { st.lap.best = lapMs; }
        st.lap.n++; st.lap.t0 = Date.now(); st.lap.runMs = 0;
        st.lap.curMaxRpm = 0; st.lap.curMaxKmh = 0;
      }
      st._notice = { text:'LAP '+String(st.lap.n), until:Date.now()+900 }; return 'lap';
    }
    return false;
  }

  global.OEPLinkDash = {
    defaults: defaults, aspect: aspect, draw: draw, click: click,
    channels: CHANNELS, channelOrder: CH_ORDER, pages: PAGES,
    setProduct: setProduct, menuSections: menuSections,
    loadOdo: loadOdo, saveOdo: saveOdo,
    carKey: carKey, shiftProfileKey: shiftProfileKey,
    loadShiftProfile: loadShiftProfile, saveShiftProfile: saveShiftProfile,
    shiftThresholds: shiftThresholds
  };
})(window);
