-- DualApex Ghost - v39.4  (Rev-8 in-game feedback:
--   #1 GHOST JUMPS ON UN-PAUSE. AC returns a HUGE dt on the first frame after un-pausing —
--      the entire paused wall-clock interval. isPausedNow() already freezes replayT DURING
--      the pause, but that first resumed frame is not paused, so the whole spike landed in
--      replayT in one go and the ghost teleported several car lengths. dt is now clamped.
--   #2 GHOST TRAVELS SIDEWAYS IN REPLAY. The heading is smoothed toward the travel direction
--      at 20% per frame (approachAngle k=0.2), which is right for live driving but means the
--      heading LAGS the position whenever elapsed jumps — scrubbing a replay, or resuming.
--      The car ends up pointing where it was, not where it is going: sideways. The heading
--      now SNAPS whenever elapsed moves more than half a second between frames, and keeps
--      smoothing the rest of the time.
--   #3 The map panel is taller (158 -> 210px).)
-- DualApex Ghost - v39.3  (STICKER PROBE. Goal: put DualApex branding on the ghost even when
--   it loads as the driver's OWN car. Today applyDualApexLivery only runs on the Z4 fallback,
--   because it maps dualapex_skin/<material>.png onto meshes BY MATERIAL NAME (the Z4's body
--   is CHAS_ext) and every car model names its materials differently - so one livery cannot
--   apply across cars. Two possible routes, and this probe tells us which is open:
--     (a) attach a textured quad/decal to the ghost at runtime -> needs scene-graph creation
--     (b) per-car skins keyed on that car's real body material -> needs material names
--   The probe reports the model's available methods AND the loaded car's material names,
--   printed in the panel as 'sticker api:'. Read it, then we build the route that exists.
--   Purely diagnostic: nothing is drawn or changed.
-- DualApex Ghost - v39.2  (ghost 2-3x too fast in replay on multi-lap sessions.
--   splinePosition alone does NOT identify a moment when you drive more than one lap: the
--   recorder keeps strictly increasing sp, so it captured ONLY THE FIRST lap (typically the
--   slow out-lap) and rejected everything after the wrap. Replaying a later, faster lap then
--   read timings recorded during a much slower one, so the ghost's clock raced. The
--   Nordschleife looked perfect because it was a single BTG run - one lap, no ambiguity.
--   The key is now lapCount + splinePosition, which is strictly increasing across a whole
--   session and identifies a moment exactly. Falls back to sp alone if lapCount is
--   unavailable, which is the previous behaviour.)
-- DualApex Ghost - v39.1  (delta frozen during replay: the pose block correctly set g.gap
--   from the recorded map, but updateGapLeaderboard then ran later in the same frame and
--   overwrote it with a value computed from replayT - which is frozen while a replay plays.
--   The overwrite is now skipped during replay, so the delta shows the gap that actually
--   existed at that moment and moves as you scrub.)
-- DualApex Ghost - v39  (REPLAY, FOURTH FOUNDATION - and the first one keyed on something
--   AC states directly rather than something inferred.
--   Why the previous three failed:
--     v35.5/v36  keyed on a CONSTANT GAP  -> wrong, the gap grows through the lap
--     v36.1      keyed on POSITION GEOMETRY -> jittered, nearest-point is ambiguous
--     v38/v38.1  keyed on replayFrames    -> that counter keeps advancing DURING replay
--                (8579 while driving, 10888 in replay), so it is a moving position in a
--                rolling buffer, not a stable address for a moment in time
--   v39 keys on car.splinePosition: AC's own normalised lap progress. Not inferred, not a
--   buffer index, monotonic within a lap, and already read elsewhere in this app. While
--   driving we record splinePosition -> (ghost elapsed, gap); during replay we read the
--   replayed car's splinePosition and look it straight up.
--   LIMIT: one lap's worth. Recording stops when splinePosition wraps past start/finish.
-- DualApex Ghost - v38.1  (Rev-7 round 3:
--   B FREEZE AT THE BRIDGE: loadKN5() ran the first time the ghost STARTED, and in BTG mode
--     that is the moment you cross gate A - at speed. A synchronous ~1s model load there
--     stalls the frame and can spit the car off. The mesh is now preloaded when you ARM,
--     while you are still slow or stationary, so the gate crossing costs nothing.
--   C1 REPLAY POSITION: fmapLookup CLAMPED to the first/last recorded frame, so any replay
--     moment before the ghost run returned the earliest elapsed and parked the ghost near
--     its start. It now returns nil outside the recorded range and the ghost is hidden -
--     that moment genuinely predates the run, so there is nothing truthful to draw.
--   C2 REPLAY DELTA stuck at ~6s: the gap was never recomputed during replay. The gap is
--     now recorded INTO the frame map alongside the elapsed time, so replay shows the gap
--     that actually existed at that moment rather than the last live value.
-- DualApex Ghost - v38  (Rev-7 round 2:
--   #4 THE TILT WAS ONLY HALF-FIXED. v37 clamped the UP vector, but PITCH lives in the
--      FORWARD vector (fY = front probe height - back probe height). A front probe landing
--      on a barrier still pitched the car ~45 degrees nose-up regardless of the up clamp.
--      Both the forward and right vectors are now limited to 30 degrees BEFORE the cross
--      product, so pitch and roll are bounded at the source.
--   #1/#3 REPLAY, DONE PROPERLY. The probe confirmed this build exposes replayCurrentFrame
--      and replayFrames, so the ghost no longer infers anything from position. While you
--      drive, the app records AC's own replay frame number against the ghost's elapsed
--      time; during replay it reads replayCurrentFrame and looks the time straight up.
--      A time-to-time map: exact, monotonic, immune to position noise, and scrubbing /
--      rewind / fast-forward all work because AC's frame counter already handles them.
--   #5 logo is drawn as TEXT again (no image file), styled to mirror the wordmark.
-- DualApex Ghost - v37.1  (#5 logo: drawn at a FIXED 132x26 regardless of the file's real
--   shape, which squashed it - now scaled to a 26px height preserving aspect ratio, with a
--   transparent-background PNG required (a white-background file will still show a white
--   box - that is the asset, not the code). Plus: replayProbe() was defined in v37 but never
--   CALLED, so the replay diagnostics were invisible. It now prints in the panel.)
-- DualApex Ghost - v37  (Rev-7 feedback. TWO real fixes and one honest retreat:
--   #4 TILT CLAMP: surfaceBasisAt aligned the ghost to the track surface with NO limit, so
--      a probe point landing on a barrier or bank produced a 45-degree car. Now clamped to
--      30 degrees from vertical (keeps real banking incl. the Karussell, rejects artefacts).
--   #1/#3 REPLAY: the ghost is HIDDEN during AC replay instead of being shown wrong. Three
--      attempts at inferring its replay position have now failed - constant gap froze it
--      (v35.5), constant gap put it off-screen (v36), and position lookup made it jitter and
--      teleport (v36.1). Deriving TIME from POSITION every frame is unstable by construction:
--      position noise becomes time noise. Hiding is honest; a teleporting 45-degree ghost is
--      not. A probe reports which replay APIs this CSP build exposes so it can be rebuilt on
--      AC's own replay clock instead of guesswork.
-- DualApex Ghost - v36.1  (REPLAY SYNC done right: v36 offset the ghost by a CONSTANT gap,
--   but in BTG mode the gap starts at 0 at the Bridge and grows - so the ghost appeared up
--   the road before the gate. Now the app LOGS where the ghost really was at each point of
--   its line while you drive, and the replay replays that history. Before the gate, nothing
--   was logged, so the ghost correctly holds at its start until the replayed car crosses.)
-- DualApex Ghost - v36  (REPLAY SYNC: during AC replay the ghost is driven from the player's
--   REPLAYED position (offset by the gap captured when replay started) instead of freezing,
--   so it moves and scrubs with the replay. Uses a replay-only, scrub-safe index search.
--   Note: if you were N seconds behind, the ghost is N seconds up the road in the replay too
--   - that is where it actually was. True PAUSE still freezes everything.)
-- DualApex Ghost - v35.9  (ROOT FIX for the delta: it was computed as playerT - sample.t,
--   but playerT's anchor is gate A in BTG mode while sample.t is always from the ghost's lap
--   start - two different origins, so the delta just echoed the lap clock (+25.3s with the
--   ghost 30m ahead). Gap is now measured purely on the ghost's own timeline: how long ago
--   the ghost passed your current position. Loop-wrapped; search seeded on the ghost.)
-- DualApex Ghost - v35.8  (FIX: gap/delta no longer clamps at 6.0s. The v35.4 fallback searched
--   +-60 samples (=6s at 10Hz) so the delta could never exceed 6s. Replaced with a monotonic
--   forward tracker (any gap size, still loop-safe, nIdx reset at S/F in every mode).)
-- DualApex Ghost - v35.7  (reverted the v35.5 replay-follow experiment that pinned the ghost
--   ~6s off-screen; ghost now simply holds during PAUSE/REPLAY. Transparency confirmed not
--   achievable on this CSP build (no setOpacity / no blend-mode / emissive ignored) - livery
--   + name-tag distinguish the ghost; true see-through is post-beta custom-render work.)
-- DualApex Ghost - v35.6  (#2 TRUE transparency via the livery texture's OWN alpha channel:
--   with CHAS_ext.png exported ~60% from GIMP, the ghost body is moved into the transparent
--   pass + forced to alpha-blend so that alpha is actually drawn. This build has no
--   setOpacity and ignores ksEmissive, but honors diffuse alpha. No-op on an opaque texture.)
-- DualApex Ghost - v35.5  (#1b floating ghost label shows the BTG time (hero metric on
--   gated tracks) not the full-lap time; #2 during AC REPLAY the ghost now TRACKS the
--   player's replayed position so it's SHOWN + moves with the replay, instead of freezing
--   at one spot and vanishing. Normal play + true PAUSE unchanged. All guarded.)
-- DualApex Ghost - v35.4  (CRITICAL FIX B: gap-to-ghost delta exploded to -578s near the
--   finish - the closed-loop S/F point is BOTH start and finish, so the lost-window global
--   scan grabbed the START sample. Now re-seeds the search by TIME (playerT) then refines
--   geometrically. Also: op:N on this build (no setOpacity) so the ghost uses a STRONGER
--   green emissive tint - v35.3's value washed out in daylight.)
-- DualApex Ghost - v35.3  (#2 transparency: PROBE the CSP build's mesh API + print the
--   result at the FRONT of the ghost-body status line (my v35.2 tag got truncated off the
--   panel edge); apply setOpacity if present, else a subtle green emissive as a guaranteed
--   distinguisher. Leaderboard BTG-missing + off-track are BACKEND (bulk-path metadata).)
-- DualApex Ghost - v35.2  (Rev-6 feedback: #1 leaderboard now uses ONE consistent row
--   format for every entry (no more mixed BTG-left / lap-first) with room for full names;
--   #2 real opacity call for the translucent ghost + a [ghostFX:*] status stamp so we can
--   confirm from a screenshot which CSP method applied. PAUSE/REPLAY freeze already works.)
-- DualApex Ghost - v35.1  (#C: leaderboard rows are fixed-width so long driver names
--   (e.g. HurryUpShrimp) stay aligned and both the BTG and full-lap times stay on-screen.)
-- DualApex Ghost - v35  (in-game feedback pass:
--   #1 ghost is TRANSLUCENT by default so it reads as a ghost, not a real car (the livery
--      texture itself is an asset drop: dualapex_skin/CHAS_ext.png - apply path unchanged);
--   #2 header uses the real DualApex wordmark image (dualapex-logo.png in the app folder),
--      with a graceful fallback to the drawn ring+wordmark if the file/API is unavailable;
--   #4 leaderboard rows put BTG on the LEFT (hero metric, never clipped), full lap on the right;
--   #5 STOP / re-arm / pitting now stop the BTG "running" clock (no more runaway timer);
--   #6 AC replay is treated like pause so the ghost no longer re-drives when you hit REPLAY.)
-- DualApex Ghost - v34  (validity diagnostics: live "this lap: valid / INVALIDATED at N%"
--   readout naming which CSP field answered, so we can tell a real cut from an over-eager
--   flag. AC's "Best lap" panel does not filter on validity - these are different things.)
-- DualApex Ghost - v33  (invalid laps are announced: AC invalidates for track limits or
--   a pit out-lap, and the leaderboard only accepts valid=1 - the app now says so
--   instead of letting a fast lap disappear silently.)
-- DualApex Ghost - v32  (pause fix: ghost wheels no longer spin while the sim is
--   paused - the mesh-pose call sat outside the pause guard and integrated a live dt.)
-- DualApex Ghost - v31  (FLAGSHIP CAR: default ghost body is now the Kunos BMW Z4 GT3
--   (base content, present in every install); E92 kept as last-resort. Livery override
--   folder unchanged - name files after the body material (Z4 body = CHAS_ext.png).)
-- DualApex Ghost - v30  (drift fix v2: CENTERED tangent, no more corner-cut yaw;
--   BTG gates drawn on the minimap (s/f dot muted in BTG mode); picker rows show
--   the server-stamped BTG time once the backend bake is applied.)
-- DualApex Ghost - v29  (yaw fishtail fix: 1.2s tangent window + calmer smoothing;
--   Snap-to-me invalidates the gap frame until the next anchor (no more -389s deltas);
--   BTG start is the DEFAULT launch mode whenever gates exist for the circuit.)
-- DualApex Ghost - v28  (picker rows show LAP TIME + delta on every line; in BTG
--   mode the GAP clock anchors at gate A (not AC's s/f) with a mode-aware hint;
--   launch buttons resized so Now / Start-line / BTG start all fit the panel.)
-- DualApex Ghost - v27  (BTG AS THE FRAME on gated tracks: new launch mode 'At BTG
--   start' - ghost materialises at gate A WITH you and re-syncs there every lap; each
--   ghost row shows its BTG time. Plus Rev3 #1: full-lap dashed line replaced by a
--   chase ribbon (last 25s of ghost path, truncated at YOUR position, consumed as you
--   drive over it).)
-- DualApex Ghost - v26  (BTG FIX: wrap-aware gate crossing - a ghost starting ON
--   gate A's plane (s/f-anchored lap + gate marked at the line) was never detected as
--   crossing; the scan now walks the closed loop with wrapped time, with a <30m
--   nearest-approach fallback, and reports WHICH gate misses and by how far.)
-- DualApex Ghost - v25  (DUALAPEX DEFAULT CAR: fallback body is now the Kunos BMW M3
--   E92 (present in every AC install), with texture-override livery support from
--   <app>/dualapex_skin/ (material-named .png/.dds, default-body only) and a
--   'Dump E92 materials' button feeding the livery authoring workflow.)
-- DualApex Ghost - v24  (REAL CAR GHOSTS: each ghost loads the actual car from its
--   lap metadata - installed cars indexed once from content/cars (cached), name-matched,
--   largest-KN5 heuristic for the body; honest amber fallback to the Z4 with the reason
--   shown when the viewer does not own the car. Three ghosts can be three different cars.)
-- DualApex Ghost - v23  (SEGMENT TIMING: in-game gate capture [Mark A/Mark B/Save],
--   BTG for the Ring community - times BOTH player and ghosts through the same two
--   gates, live clock between gates, latched result with delta vs the fastest ghost.
--   Gates persist in segments.json, matched to the circuit.)
-- DualApex Ghost - v22  (Rev2 fixes: wheel-spin FIXED - v21 checked findNodes()~=nil but
--   CSP returns an EMPTY reference on a miss, so rotation no-opped silently; now size-checked
--   with 3 filter variants + WHEEL* wildcard fallback + on-screen wheels status. Default
--   launch mode = At start-line. Ghost off-track accuracy = pipeline-side, tracked separately.)
-- DualApex Ghost - v21  (LAUNCH PREP: probe removed; [All laps] picker toggle so you can
--   load up to 3 of ANY laps incl. your own; big colour delta HUD w/ gaining/losing trend;
--   ghost wheel-spin + shadow attempt (guarded); recorder distance guard (no more partial
--   'laps'); API -> https://dualapex.com; no baked-in identity; portable APP_DIR)
-- DualApex Ghost - v20  (v19 + bindable Snap hotkey: snap the ghost to you WHILE DRIVING, not just via the on-screen button)
-- DualApex Ghost - v19  (v18 + "Snap ghost to me": respawn the ghost at your current track position to learn a line after a crash)
-- DualApex Ghost - v18  (v17 + surface-follow orientation [pitch/roll to tarmac] + Catmull-Rom path smoothing for sparse/long laps)
-- DualApex Ghost - v17  (v16 + s/f re-anchor sync: in "At start-line" the ghost launches FROM the s/f line and re-syncs every lap)
-- v12 adds recorder wiring to the WINDOW UI ONLY: it arms the engine's existing
--   per-frame recorder, and on each finalised lap uploads it to the backend with
--   the gap vs the chased ghost shown as a toast. Picker UI = v11 unchanged.
--   Engine (replay/mesh/gap/minimap + HTTP browser) is byte-for-byte v9.5.
-- DualApex Ghost — v9.5  (multi-ghost; no stale local auto-load + Clear-ALL)
-- ============================================================================
-- WHAT'S NEW vs v9.2: a GHOST BROWSER at the top of the app window. It lists
--   tracks and laps from the DualApex API (https://dualapex.com) and loads
--   any ghost in-game over HTTP — no more dropping a local test_ac.json. The
--   local-file loader still works as a fallback. Everything else (mesh, sim
--   recorder, telemetry, delta, minimap, live tuning) is unchanged from v9.2.
-- ============================================================================
-- ============================================================================
-- WHAT'S NEW vs v8: instead of (only) drawing a wireframe with render.debugLine
--   (which CSP limits to debug shapes), v9 loads a REAL car KN5 into AC's scene
--   graph via ac.SceneReference and moves it along the pre-aligned ghost path.
--   This is the documented way to show actual geometry from a Lua app.
--
-- HOW IT WORKS:
--   * On first PLAY (once only), we find AC's cars node (carsRoot), create a
--     holder node under it, and loadKN5() a car body into the holder.
--   * Each frame (in script.update, NOT in draw3D) we setPosition()/setOrientation()
--     the holder to the aligned ghost pose. The model then renders itself as part
--     of AC's normal rendering — solid, shaded, on the tarmac.
--   * If the KN5 fails to load, we AUTOMATICALLY fall back to the v8 wireframe so
--     the ghost is never invisible. The UI reports exactly which path is active.
--
-- ALL v8 LOGIC PRESERVED: pause-safe replay clock, tarmac-pinning via
--   physics.raycastTrack, 0.6s-lookahead heading w/ smoothing, SF-sync, race line.
--
-- LIVE TUNING (no AC restart needed — these just change values read each frame):
--   * Flip forward  : if the car faces backwards, flip its look direction.
--   * Nudge Y +/-   : raise/lower the model to sit right on the tarmac.
--   * Solid/Ghost   : toggle translucent "ghost" look vs solid car.
--   * Mesh/Wire     : force wireframe even if the mesh loaded (for comparison).
-- ============================================================================

-- ---- CONFIG -----------------------------------------------------------------
-- v21: derive the app folder from the script location (portable across installs).
local APP_DIR
pcall(function()
  if ac.getFolder and ac.FolderID and ac.FolderID.ScriptOrigin then
    local p = ac.getFolder(ac.FolderID.ScriptOrigin)
    if type(p) == "string" and #p > 0 then
      p = p:gsub("\\", "/")
      if not p:match("/$") then p = p .. "/" end
      APP_DIR = p
    end
  end
end)
APP_DIR = APP_DIR or "apps/lua/dualapex_ghost/"
-- KN5 to load as the ghost car. Path is relative to AC root OR absolute.
-- Default: the base Kunos BMW Z4 GT3 body (not DLC/encrypted -> should load).
-- If this fails, try a different car's main .kn5, or an absolute path.
local CAR_KN5      = "content/cars/bmw_z4_gt3/bmw_z4_gt3.kn5"   -- v31: DualApex FLAGSHIP ghost body (Kunos base content - every install has it)
local CAR_KN5_LAST = "content/cars/bmw_m3_e92/bmw_m3_e92.kn5"   -- last-resort body if the Z4 is missing

-- ===== v24: per-ghost car model resolution ===================================
-- A ghost is a REPLAY: it needs the right 3D model, not physics. We index the
-- viewer's installed cars (content/cars/*/ui/ui_car.json) once, cache it next
-- to the app, and match each ghost's car name to a local KN5. If the viewer
-- does not own the car we fall back to the Z4 and SAY SO in the panel.
local CARIDX_FILE_NAME = "carindex.json"
local carIdx = { map=nil, built=false, err=nil, resolved={} }

local function carNorm(s)
  if not s then return "" end
  return tostring(s):lower():gsub("[^a-z0-9]", "")
end

local function scanList(dir, mask)
  local out = {}
  local ok, r = pcall(function() return io.scanDir(dir, mask) end)
  if not ok or r == nil then return out end
  if type(r) == "table" then
    for i = 1, #r do out[#out+1] = tostring(r[i]) end
  elseif type(r) == "function" then
    pcall(function() for f in r do out[#out+1] = tostring(f) end end)
  end
  return out
end

local function carsRootDir()
  local d
  pcall(function()
    if ac.getFolder and ac.FolderID and ac.FolderID.ContentCars then
      d = ac.getFolder(ac.FolderID.ContentCars)
    end
  end)
  if type(d) == "string" and #d > 0 then
    d = d:gsub("\\", "/")
    if not d:match("/$") then d = d .. "/" end
    return d
  end
  return APP_DIR .. "../../../content/cars/"
end

local function pickKN5(folderPath, folderName)
  -- prefer <folder>.kn5; else the largest .kn5 (LOD0); else any
  local files = scanList(folderPath, "*.kn5")
  if #files == 0 then return nil end
  for i = 1, #files do
    if files[i]:lower() == (folderName:lower() .. ".kn5") then return files[i] end
  end
  local best, bestSz = files[1], -1
  for i = 1, #files do
    local sz = -1
    pcall(function()
      local a = io.getAttributes(folderPath .. files[i])
      if a and a.fileSize then sz = tonumber(a.fileSize) or -1 end
    end)
    if sz > bestSz then best, bestSz = files[i], sz end
  end
  return best
end

local function buildCarIndex()
  carIdx.built = true
  local root = carsRootDir()
  local ok, okDir = pcall(function() return io.dirExists(root) end)
  if ok and okDir == false then carIdx.err = "cars dir not found"; carIdx.map = {}; return end
  local map = {}
  local folders = scanList(root, "*")
  for i = 1, #folders do
    local fn = folders[i]
    if fn ~= "." and fn ~= ".." then
      local fp = root .. fn .. "/"
      local uiName
      pcall(function()
        local raw = io.load(fp .. "ui/ui_car.json")
        if raw and #raw > 2 then
          local okP, u = pcall(JSON.parse, raw)
          if okP and u and u.name then uiName = tostring(u.name) end
        end
      end)
      local kn5 = pickKN5(fp, fn)
      if kn5 then
        local rec = { path = "content/cars/" .. fn .. "/" .. kn5, folder = fn }
        map[carNorm(fn)] = map[carNorm(fn)] or rec
        if uiName then map[carNorm(uiName)] = map[carNorm(uiName)] or rec end
      end
    end
  end
  carIdx.map = map
  -- persist (best effort; readable rebuild next session if this fails)
  local parts = {}
  for k, v in pairs(map) do
    parts[#parts+1] = string.format('"%s":{"path":"%s","folder":"%s"}', k, v.path, v.folder)
  end
  pcall(function() io.save(APP_DIR .. CARIDX_FILE_NAME, "{" .. table.concat(parts, ",") .. "}") end)
end

local function loadCarIndexCache()
  local ok, raw = pcall(io.load, APP_DIR .. CARIDX_FILE_NAME)
  if not ok or type(raw) ~= "string" or #raw < 4 then return false end
  local okP, m = pcall(JSON.parse, raw)
  if okP and type(m) == "table" and next(m) ~= nil then carIdx.map = m; carIdx.built = true; return true end
  return false
end

local function resolveCarKN5(carName)
  local key = carNorm(carName)
  if #key < 2 then return nil, nil end
  local hit = carIdx.resolved[key]
  if hit ~= nil then if hit == false then return nil, nil end return hit.path, hit.folder end
  if not carIdx.built then
    if not loadCarIndexCache() then buildCarIndex() end
  end
  local m = carIdx.map or {}
  local rec = m[key]
  if not rec then
    -- containment match: shortest indexed name that contains, or is contained in, the query
    local bestK
    for k, _ in pairs(m) do
      if #k >= 4 and (k:find(key, 1, true) or key:find(k, 1, true)) then
        if bestK == nil or #k < #bestK then bestK = k end
      end
    end
    if bestK then rec = m[bestK] end
  end
  if not rec then
    -- one auto-rebuild per session: heals "installed a mod mid-session / stale cache"
    if not carIdx.rebuilt then carIdx.rebuilt = true; buildCarIndex(); rec = (carIdx.map or {})[key] end
  end
  carIdx.resolved[key] = rec or false
  if rec then return rec.path, rec.folder end
  return nil, nil
end
-- ===== end v24 car resolution ================================================

-- ===== v25: DualApex livery for the default ghost body =======================
-- Drop texture overrides into <app>/dualapex_skin/: each file is named after
-- the KN5 material it replaces (e.g. Carpaint.png / Carpaint.dds). PNG and DDS
-- both work. Applied ONLY to the DualApex default body - resolved real cars
-- keep their own skins. Use the "Dump E92 materials" button (gates panel) to
-- get the exact material names.
local SKIN_DIR = APP_DIR .. "dualapex_skin/"
local function applyDualApexLivery(model)
  local files = {}
  for _, mask in ipairs({ "*.dds", "*.png" }) do
    local part = scanList(SKIN_DIR, mask)
    for i = 1, #part do files[#files+1] = part[i] end
  end
  local applied = 0
  for i = 1, #files do
    local f = files[i]
    local mat = f:gsub("%.[DdPp][DdNn][SsGg]$", "")
    pcall(function()
      local meshes = model:findMeshes("material:" .. mat)
      if meshes ~= nil then
        meshes:setMaterialTexture("txDiffuse", SKIN_DIR .. f)
        applied = applied + 1
      end
    end)
  end
  return applied, #files
end
-- ===== end v25 livery =========================================================
local HOLDER_NAME = "dualapex_ghost_holder"
local API_BASE = "https://dualapex.com"   -- v21: same-origin production API over HTTPS

-- v39.5 version check. Deliberately on a GLOBAL table and NOT file-scope locals: this
-- chunk already declares 199 locals and Lua's hard limit is 200 per chunk. Three extra
-- locals took it to 202, which is a COMPILE error - the app silently fails to load with
-- no traceback at all. Anything added here must not declare new top-level locals.
DA = DA or {}
DA.version = "39.23"
DA.upd = { checked = false, latest = nil, url = nil, notes = nil,
           busy = false, msg = nil, luaUrl = nil, fullReinstall = false }

-- v39.9 panel state. On DA, not as file-scope locals: this chunk sits at 199 of Lua's
-- limit of 200 per chunk and going over is a silent COMPILE failure.
DA.diag = false          -- developer diagnostics hidden by default
DA.saved = { at = 0, text = nil }   -- brief "lap saved" confirmation
-- v39.10: manual yaw trim for the ghost body. Two guesses at the sideways ghost have
-- failed and reading the code has not found it, so this does two jobs at once: it lets
-- the ghost be made correct RIGHT NOW by dialling the offset, and the value that fixes it
-- tells us what the cause is. A constant that works means a model/axis problem; if no
-- constant works, the error is dynamic and lives in the heading itself.
DA.hdgOffset = 0
-- -----------------------------------------------------------------------------

-- v9.4: MULTI-GHOST model (up to MAX_GHOSTS loaded at once, gap leaderboard)
local MAX_GHOSTS = 3
-- each slot: { samples, n, duration, hasRPM, hasGear, label, gid,
--              meshHolder, meshModel, meshOk, displayHeading, gapValid, gap, gap_m, nIdx }
local ghosts = {}                 -- array of loaded ghost slots
local function primary() return ghosts[1] end   -- the "active" ghost for single-ghost UI bits
-- `ghost` shim: legacy single-ghost code (local-file load) writes here; we mirror into ghosts[1].
local ghost = { loaded=false, samples=nil, err=nil, n=0, duration=0 }
local playing = false
local mode = "SFSYNC"   -- v22: default to At start-line (feedback Rev2 #3)
local armed = false
local replayT = 0
local wasReplay = false   -- v36: rising-edge detect for AC replay (capture the gap once)
local lastDtSeen = 0

local lastSpline = nil
local sfCrossings = 0
local lastSplineSeen = -1

-- v9.1: telemetry + live delta + mini-map state
local playerT     = 0       -- player's elapsed lap time (s), reset at player SF cross
local pLastSpline = nil      -- player spline tracker (independent of checkSFCross)
local pCrossed    = false    -- has the player crossed SF at least once?
local gmap        = { x0=0, x1=0, z0=0, z1=0, ready=false }  -- minimap bounds

-- v9.2: sim lap recorder state (captures the PLAYER's sim lap for real-vs-sim compare)
local REC_HZ   = 25            -- samples/sec written to file (decimated from frame rate)
local REC_DT   = 1.0/REC_HZ
local REC_DIR  = APP_DIR       -- recorded laps are written here
local rec = {
  on         = false,  -- recorder armed?
  lastReject = nil,    -- v21: why the last finished lap was NOT banked (nil = none)
  clock      = 0,      -- pause-safe monotonic seconds
  lastSpl    = nil,    -- spline tracker for SF detection
  lastSample = 0,      -- clock at last captured sample
  cur        = nil,    -- { t0, samples={} } lap currently being recorded
  last       = nil,    -- { time, n, samples } most recent complete lap
  best       = nil,    -- { time, n, samples } fastest complete lap
  times      = {},     -- every complete lap time (for the on-screen list)
  track      = "",     -- filled lazily from the sim
  car        = "",
  saveMsg    = nil,
}

-- mesh state
-- (legacy single `mesh` table removed in v9.4 — each ghost slot owns its mesh)
-- live-tunable knobs
local forwardFlip = 1      -- 1 or -1 : direction the car's nose points
local yOffset     = 0.0    -- metres : raise/lower model onto tarmac
local ghostLook   = true   -- v35 #1: translucent by DEFAULT so the ghost reads as a ghost, not a real AC car
local forceWire   = false  -- draw wireframe even if mesh is available

local function atan2(y,x) if math.atan2 then return math.atan2(y,x) end return math.atan(y,x) end
local function approachAngle(cur,target,k)
  if cur==nil then return target end
  local diff=((target-cur+180)%360)-180
  return cur+diff*k
end

-- v37: report which replay fields this CSP build exposes, so the replay ghost can be
-- rebuilt on AC's own clock rather than inferred from position. Shown in the status line.
-- v38: AC's own replay clock.
--   replayFrames        = frames recorded so far (the WRITE head, advances while driving)
--   replayCurrentFrame  = playback position (the READ head, meaningful during replay)
-- While driving we append (writeHead -> ghost elapsed); during replay we binary-search that
-- map with the read head and interpolate. No position matching anywhere, so no jitter.
local function acReplayFrames()
  local ok, sim = pcall(ac.getSim)
  if not ok or not sim then return nil end
  local v; local okv = pcall(function() v = sim.replayFrames end)
  return (okv and type(v) == "number") and v or nil
end

local function acReplayCurrentFrame()
  local ok, sim = pcall(ac.getSim)
  if not ok or not sim then return nil end
  local v; local okv = pcall(function() v = sim.replayCurrentFrame end)
  return (okv and type(v) == "number") and v or nil
end

local FMAP_MAX = 240000   -- ~11 hours at 6 fps of samples; a hard stop on memory growth

-- v39: keyed on splinePosition (0..1 lap progress). Strictly increasing, so a wrap past
-- start/finish simply stops the recording - one lap's worth, which is what a replay of a
-- lap needs. A tiny minimum step keeps the table small without losing resolution: 0.00002
-- of the Nordschleife is about 0.4m.
local SP_MIN_STEP = 0.00002


local function fmapAppend(g, sp, elapsed, gap)
  if not sp or sp <= 0 then return end
  local n = g.fN or 0
  if n > 0 and sp <= g.fSp[n] + SP_MIN_STEP then return end
  if n >= FMAP_MAX then return end
  n = n + 1
  g.fSp[n] = sp; g.fElap[n] = elapsed; g.fGap[n] = gap
  g.fN = n
end

-- Returns elapsed, gap - or nil when the frame falls OUTSIDE the recorded range.
-- v38.1 C1: it used to CLAMP to the first/last entry, which drew the ghost near its start
-- for every replay moment before the run began. Outside the range there is nothing
-- truthful to draw, so the caller hides the ghost instead of guessing.
-- Returns elapsed, gap - or nil when this spline position was never recorded (the replay
-- is showing a moment outside the ghost run). Nothing is clamped: outside the range there
-- is nothing truthful to draw, so the caller hides the ghost.
local function fmapLookup(g, sp)
  local n = g.fN or 0
  if n == 0 or not sp then return nil end
  if sp < g.fSp[1] or sp > g.fSp[n] then return nil end
  if sp == g.fSp[1] then return g.fElap[1], g.fGap[1] end
  if sp == g.fSp[n] then return g.fElap[n], g.fGap[n] end
  local lo, hi = 1, n
  while lo < hi - 1 do
    local mid = math.floor((lo + hi) / 2)
    if g.fSp[mid] <= sp then lo = mid else hi = mid end
  end
  local s0, s1 = g.fSp[lo], g.fSp[hi]
  local e0, e1 = g.fElap[lo], g.fElap[hi]
  if s1 == s0 then return e0, g.fGap[lo] end
  local t = (sp - s0) / (s1 - s0)
  local gp = nil
  local g0, g1 = g.fGap[lo], g.fGap[hi]
  if g0 and g1 then gp = g0 + (g1 - g0) * t elseif g0 then gp = g0 end
  return e0 + (e1 - e0) * t, gp
end

local function replayProbe()
  local ok, sim = pcall(ac.getSim)
  if not ok or not sim then return "sim?" end
  local out = {}
  for _, k in ipairs({"replayCurrentFrame", "replayFrames", "replayPlaySpeed",
                      "replayPosition", "replayTimeMultiplier"}) do
    local v; local okv = pcall(function() v = sim[k] end)
    if okv and v ~= nil then out[#out+1] = k .. "=" .. tostring(v) end
  end
  if #out == 0 then return "none" end
  return table.concat(out, " ")
end

local function isReplayNow()
  -- true only while AC replay is actively PLAYING (not merely paused within replay)
  local ok, sim = pcall(ac.getSim)
  return (ok and sim and sim.isReplayActive == true and sim.isPaused ~= true) or false
end
local function isPausedNow()
  local ok, sim = pcall(ac.getSim)
  if ok and sim then
    if sim.isPaused == true then return true end
    if sim.isReplayActive == true then return true end   -- v35 #6: freeze the ghost while AC replay is playing
    if sim.isInMainMenu == true then return true end
    if sim.isSessionStarted == false then return true end
  end
  return false
end

-- v9.4 forward declarations (defined later; addGhost references them earlier)
local computeMapBounds, makeGhostSlot, syncPrimaryMirror
local daSegUpdate   -- v23: segment (BTG) timing tick, assigned in the segment section
local daGhostBTG    -- v27: ghost BTG lookup for UI defined before the segment engine
local daSegDef      -- v30: active segment def for the minimap (nil when other circuit)

-- v20: snap-to-me needs a forward decl (it's defined later, once the replay/ghost
-- helpers exist) so script.update can poll a BINDABLE HOTKEY for it. The hotkey
-- works while driving with the window closed. ac.ControlButton is guarded so older
-- CSP builds without it simply fall back to the on-screen button.
local snapGhostToMe
local snapHotkey = nil
pcall(function() snapHotkey = ac.ControlButton('DUALAPEX_SNAP_TO_ME') end)

local function bracket(g,t)
  local s,n=g.samples,g.n
  if t<=s[1].t then return 1 end
  if t>=s[n].t then return n-1 end
  local lo,hi=1,n
  while lo+1<hi do local m=math.floor((lo+hi)/2); if s[m].t<=t then lo=m else hi=m end end
  return lo
end

-- v18: smoothing/orientation toggles (flip a value + reload the script to A/B in-game)
local FOLLOW_SURFACE = true   -- v18: pitch/roll the ghost onto the track surface (fixes "level car on a hill")
local SMOOTH_SPLINE  = true   -- v18: Catmull-Rom interp between sparse (e.g. 10Hz) samples (fixes jaggy / corner-cut)

-- v18: one uniform Catmull-Rom interpolated scalar across knots p0..p3 at f in [0,1] (segment p1->p2)
local function crScalar(p0,p1,p2,p3,f)
  local f2=f*f; local f3=f2*f
  return 0.5*((2*p1) + (-p0+p2)*f + (2*p0-5*p1+4*p2-p3)*f2 + (-p0+3*p1-3*p2+p3)*f3)
end

local function posAt(g,t)
  local s,n=g.samples,g.n
  local lo=bracket(g,t); local a,b=s[lo],s[math.min(lo+1,n)]
  local span=b.t-a.t; local f=(span>0) and math.max(0,math.min(1,(t-a.t)/span)) or 0
  if SMOOTH_SPLINE and n>=4 and lo>=2 and lo<=n-2 then
    local p0,p1,p2,p3=s[lo-1],s[lo],s[lo+1],s[lo+2]
    local x =crScalar(p0.x,p1.x,p2.x,p3.x,f)
    local y =crScalar((p0.y or 0),(p1.y or 0),(p2.y or 0),(p3.y or 0),f)
    local z =crScalar(p0.z,p1.z,p2.z,p3.z,f)
    local sp=crScalar((p0.speed or 0),(p1.speed or 0),(p2.speed or 0),(p3.speed or 0),f)
    return x,y,z,sp
  end
  return a.x+(b.x-a.x)*f, (a.y or 0)+((b.y or 0)-(a.y or 0))*f, a.z+(b.z-a.z)*f, (a.speed or 0)+((b.speed or 0)-(a.speed or 0))*f
end

local LOOKAHEAD_S=1.2   -- v29: longer tangent window - kills yaw fishtailing from mm-scale zigzag left by the corridor clamp
-- returns heading degrees AND the raw (dx,dz) travel direction for ghost slot g
local function travelAt(g,t)
  -- v30: CENTERED difference (+/-0.35s). v29's long forward chord CUT corners,
  -- pointing the car inside every tight bend = the "unintentional drift".
  -- A centered chord approximates the true tangent at t without the cut.
  local half = 0.35
  local tA = t - half; if tA < 0 then tA = 0 end
  local tB = t + half; if tB > g.samples[g.n].t then tB = g.samples[g.n].t end
  local x1,_,z1 = posAt(g, tA)
  local x2,_,z2 = posAt(g, tB)
  local dx,dz = x2-x1, z2-z1
  if (dx*dx+dz*dz) > 0.09 then return math.deg(atan2(dx,dz)), dx, dz end
  return nil, 0, 0
end

-- ---- v9.1 helpers: telemetry / minimap / delta -----------------------------
-- ghost telemetry at time t: speed always; rpm/gear only if present in the data
local function telemetryAt(g,t)
  local s,n=g.samples,g.n
  local lo=bracket(g,t); local a,b=s[lo],s[math.min(lo+1,n)]
  local span=b.t-a.t; local f=(span>0) and math.max(0,math.min(1,(t-a.t)/span)) or 0
  local spd=(a.speed or 0)+((b.speed or 0)-(a.speed or 0))*f
  local rpm=nil; if a.rpm~=nil then rpm=a.rpm+((b.rpm or a.rpm)-a.rpm)*f end
  local gear=nil; if a.gear~=nil then gear=(f<0.5) and a.gear or (b.gear or a.gear) end
  return spd, rpm, gear
end

-- compute the ghost-path bounding box once (used to fit the minimap)
function computeMapBounds()
  if #ghosts==0 then return end
  local minx,maxx,minz,maxz=1e18,-1e18,1e18,-1e18
  for gi=1,#ghosts do
    local g=ghosts[gi]; local s=g.samples
    if s then for i=1,g.n do
      local x,z=s[i].x,s[i].z
      if x<minx then minx=x end; if x>maxx then maxx=x end
      if z<minz then minz=z end; if z>maxz then maxz=z end
    end end
  end
  gmap.x0,gmap.x1,gmap.z0,gmap.z1=minx,maxx,minz,maxz; gmap.ready=true
end

-- world (x,z) -> screen point inside the minimap box [p0 .. p0+(W,H)], uniform scale.
-- v39.6: the z term used to be (z1 - wz), which mirrored every layout vertically against
-- the real circuit. AC's world axes are left-handed, so looking down at the X/Z plane the
-- screen-y term must GROW with wz, not shrink. Every map drawing - path, s/f dot, BTG
-- gates, ghost markers, your own car - goes through here, so this one term fixes all of it.
local function mapPt(wx, wz, p0, W, H, pad)
  local sx=gmap.x1-gmap.x0; local sz=gmap.z1-gmap.z0
  local span=math.max(sx,sz); if span<=0 then span=1 end
  local scale=(math.min(W,H)-2*pad)/span
  local ox=(W - sx*scale)/2; local oz=(H - sz*scale)/2
  return vec2(p0.x + ox + (wx-gmap.x0)*scale, p0.y + oz + (wz - gmap.z0)*scale)
end

-- nearest ghost sample index to world (px,pz): windowed around deltaIdx, global fallback
-- v35.9: index of the GHOST's own current position, straight from its playback clock.
-- Unambiguous (no geometry involved), so it makes a reliable seed for the player search.
local function ghostPlayIdx(g)
  local s,n=g.samples,g.n
  if not s or not n or n<1 then return 1 end
  local e=replayT
  local dur=g.duration
  if dur and dur>0 then e = e % dur end
  local lo,hi=1,n
  while lo<hi do
    local mid=math.floor((lo+hi)/2)
    if (s[mid].t or 0) < e then lo=mid+1 else hi=mid end
  end
  return lo
end

local function nearestIdxForGhost(g, px, pz)
  local s,n=g.samples,g.n
  if not s or not n or n<1 then return nil, 0 end
  local function d2(i) local dx,dz=s[i].x-px,s[i].z-pz; return dx*dx+dz*dz end
  -- v35.9 FIX: seed the search on the GHOST's current position (its own playback clock)
  -- instead of the player's lap clock. Window reaches well BEHIND the ghost and a little
  -- AHEAD, so a real gap of any size is found and nothing clamps it; and because the window
  -- is centred on the ghost, the loop's shared S/F point can only match when the ghost is
  -- genuinely near its lap start (this is what killed the -578s and the 6s-stick bugs).
  local gi=ghostPlayIdx(g)
  local lo=math.max(1, gi-1500)   -- player up to ~150s BEHIND the ghost
  local hi=math.min(n, gi+400)    -- ... or ~40s AHEAD of it
  local bestI,bestD=lo,d2(lo)
  local i=lo
  while i<=hi do                  -- coarse pass (every 5th sample) - cheap per frame
    local d=d2(i); if d<bestD then bestD=d; bestI=i end
    i=i+5
  end
  local a=math.max(lo,bestI-6); local b=math.min(hi,bestI+6)
  for j=a,b do local d=d2(j); if d<bestD then bestD=d; bestI=j end end
  g.nIdx=bestI
  return bestI, math.sqrt(bestD)
end

-- v9.4 GAP LEADERBOARD: for each loaded ghost, compute the player's time gap
-- at the player's current track position. gap = playerT - ghost.t(nearest point).
-- positive => player is BEHIND that ghost. Updates g.gap / g.gap_m / g.gapValid.
-- v36: index search used ONLY during AC replay. A replay can be scrubbed anywhere on the
-- lap, so this keeps continuity with the previous frame when the player is still near that
-- stretch (cheap + immune to the S/F overlap), and only falls back to a global coarse scan
-- when the position jumps (a seek). Separate from nearestIdxForGhost so live driving is
-- untouched.
local function replayIdxForGhost(g, px, pz)
  local s,n=g.samples,g.n
  if not s or not n or n<1 then return nil end
  local function d2(i) local dx,dz=s[i].x-px,s[i].z-pz; return dx*dx+dz*dz end
  local seed=g.rIdx
  if seed and seed>=1 and seed<=n then
    local lo=math.max(1,seed-400); local hi=math.min(n,seed+400)
    local bI,bD=lo,d2(lo)
    for i=lo+1,hi do local d=d2(i); if d<bD then bD=d; bI=i end end
    if bD<=400 then g.rIdx=bI; return bI end   -- within ~20m: keep continuity
  end
  local bestI,bestD=1,d2(1)
  local i=1
  while i<=n do local d=d2(i); if d<bestD then bestD=d; bestI=i end; i=i+10 end
  local a=math.max(1,bestI-12); local b=math.min(n,bestI+12)
  for j=a,b do local d=d2(j); if d<bestD then bestD=d; bestI=j end end
  g.rIdx=bestI
  return bestI
end

local function updateGapLeaderboard(px, pz)
  if not playing then for i=1,#ghosts do ghosts[i].gapValid=false end return end
  for i=1,#ghosts do
    local g=ghosts[i]
    if g.samples and g.n and g.n>1 then
      local idx,gap_m = nearestIdxForGhost(g, px, pz)
      if idx then
        g.gapValid=true
        -- v35.9 ROOT FIX: measure the gap on the GHOST's OWN timeline - "how long ago did
        -- the ghost pass the point I'm at now". The old formula subtracted playerT, whose
        -- anchor differs by mode (gate A in BTG mode vs S/F otherwise) while sample.t is
        -- always from the ghost's lap start - two different origins, so the delta ended up
        -- echoing the lap clock (+25.3s with the ghost 30m ahead). Wrapped across the loop
        -- so crossing S/F can't flip the sign.
        local _ge = replayT
        local _dur = g.duration
        if _dur and _dur>0 then _ge = _ge % _dur end
        local _gp = _ge - (g.samples[idx].t or 0)
        if _dur and _dur>0 then
          if _gp > _dur*0.5 then _gp = _gp - _dur elseif _gp < -_dur*0.5 then _gp = _gp + _dur end
        end
        -- v39.1: do NOT overwrite the gap while a replay is playing. This value is derived
        -- from replayT, which is frozen during replay, so writing it here pinned the delta
        -- at whatever it read when the replay started. The pose block has already set g.gap
        -- from the recorded spline map earlier this frame - that is the truthful number.
        if not isReplayNow() then g.gap = _gp end   -- seconds (+ = you behind)
        g.gap_m = gap_m
        -- v36.1: while LIVE driving, log where the ghost actually WAS as you pass each
        -- point of its line. Replay then replays that recorded history instead of assuming
        -- a constant gap - which was wrong in BTG mode, where the gap starts at 0 at the
        -- gate and grows, so a constant offset put the ghost up the road before the Bridge.
        if not isReplayNow() then
          if not g.eHist then g.eHist = {} end
          g.eHist[idx] = _ge
        end
      else
        g.gapValid=false
      end
    else
      g.gapValid=false
    end
  end
end

-- ============================================================================
-- v9.2  SIM LAP RECORDER  (player's lap -> JSON for the real-vs-sim compare)
-- ============================================================================
local function fmtTime(t)
  if not t then return "--:--" end
  local m=math.floor(t/60); local s=t-m*60
  return string.format("%d:%06.3f", m, s)
end

-- best-effort track / car id for the file metadata (all guarded; never fatal)
local function recMeta()
  local trk,car="unknown","unknown"
  pcall(function() trk=ac.getTrackID() or trk end)
  pcall(function() car=ac.getCarID(0) or car end)
  return tostring(trk), tostring(car)
end

-- one telemetry sample from the player car (sim has inputs the GPS lap can't)
local function recSample(car, t)
  local p=car.position
  local lk=car.look; local hdg=0
  if lk then hdg=math.deg(atan2(lk.x, lk.z)) end
  return {
    t=t, x=p.x, y=p.y, z=p.z,
    spd=car.speedKmh or 0, spl=car.splinePosition or 0,
    gas=car.gas or 0, brk=car.brake or 0, rpm=car.rpm or 0,
    gear=car.gear or 0, str=car.steer or 0, hdg=hdg,
  }
end

-- serialise one recorded lap to DualApex sim-lap JSON and write it to disk
local function recSaveLap(lap, tag)
  if not lap or not lap.samples or #lap.samples<2 then return false,"nothing to save" end
  local head=string.format(
    '{"format":"dualapex_sim_lap","version":1,"track":"%s","car":"%s","lap_time_s":%.3f,"rate_hz":%d,'
    ..'"channels":["t","x","y","z","spd","spl","gas","brk","rpm","gear","str","hdg"],"n":%d,"samples":[',
    rec.track, rec.car, lap.time, REC_HZ, #lap.samples)
  local body={}
  for i=1,#lap.samples do
    local s=lap.samples[i]
    body[i]=string.format(
      '{"t":%.3f,"x":%.2f,"y":%.2f,"z":%.2f,"spd":%.2f,"spl":%.4f,"gas":%.3f,"brk":%.3f,"rpm":%.0f,"gear":%d,"str":%.3f,"hdg":%.1f}',
      s.t,s.x,s.y,s.z,s.spd,s.spl,s.gas,s.brk,s.rpm,s.gear,s.str,s.hdg)
  end
  local json=head..table.concat(body,",").."]}"
  local path=REC_DIR.."simlap_"..(tag or "last")..".json"
  local ok=pcall(function() io.save(path, json) end)
  if ok then return true, path else return false, "io.save failed (check folder write access)" end
end

-- per-frame recorder: SF detection, fixed-rate sampling, SF->SF lap cut
local function recUpdate(dt, car)
  if not rec.on then return end
  if not isPausedNow() then rec.clock=rec.clock+(dt or 0) end
  if not car then return end
  if rec.track=="" then rec.track, rec.car = recMeta() end
  local sp=car.splinePosition
  if sp~=nil then
    if rec.lastSpl~=nil and rec.lastSpl>0.9 and sp<0.1 then
      -- finished a lap: bank it if it looks like a real SF->SF lap
      if rec.cur and #rec.cur.samples>5 then
        local lt=rec.clock-rec.cur.t0
        -- v21 plausibility guard: a real lap must roughly cover the track length.
        -- Kills partial "laps" (pit teleports, session resets) that used to bank
        -- as impossible times (e.g. a 1:09 Nordschleife) and pollute the boards.
        local distOK, why = true, nil
        pcall(function()
          local sim = ac.getSim()
          local tl = sim and sim.trackLengthM
          if type(tl)=="number" and tl>500 then
            if (rec.cur.dist or 0) < 0.85*tl then
              distOK=false
              why=string.format("partial lap ignored (%.1fkm of %.1fkm)", (rec.cur.dist or 0)/1000, tl/1000)
            end
          end
        end)
        if lt>5 and lt<1200 and distOK then
          local lap={ time=lt, n=#rec.cur.samples, samples=rec.cur.samples }
          rec.last=lap
          rec.times[#rec.times+1]=lt
          if (not rec.best) or lt<rec.best.time then rec.best=lap end
          rec.lastReject=nil
        elseif not distOK then
          rec.lastReject=why
        end
      end
      rec.cur={ t0=rec.clock, samples={} }       -- start the next lap
      rec.lastSample=rec.clock-REC_DT            -- capture the first sample promptly
    end
    rec.lastSpl=sp
  end
  -- sample at the fixed rate while a lap is in progress
  if rec.cur and not isPausedNow() and (rec.clock-rec.lastSample)>=REC_DT then
    rec.lastSample=rec.clock
    local s = recSample(car, rec.clock-rec.cur.t0)
    local prev = rec.cur.samples[#rec.cur.samples]
    if prev then
      local dx,dy,dz = s.x-prev.x, s.y-prev.y, s.z-prev.z
      rec.cur.dist = (rec.cur.dist or 0) + math.sqrt(dx*dx+dy*dy+dz*dz)
    end
    rec.cur.samples[#rec.cur.samples+1]=s
  end
end

-- raycast down to the real track surface at (x,z). Returns ground Y, or fallback.
local function groundYAt(x, z, fallback)
  local ok, hit = pcall(function()
    return physics.raycastTrack(vec3(x, (fallback or 0)+50, z), vec3(0,-1,0), 200)
  end)
  if ok and hit and hit > 0 then
    return ((fallback or 0)+50) - hit
  end
  return fallback or 0
end

-- ============================================================================
-- SOLID MESH via ac.SceneReference
-- ============================================================================
local function disposeHolderNamed(nm)
  local ok, existing = pcall(function() return ac.findNodes("name:"..nm) end)
  if ok and existing then pcall(function() existing:dispose() end) end
end

-- load a KN5 mesh for ONE ghost slot, into its own uniquely-named holder.
local function loadMeshForGhost(g, slotIndex)
  g.meshTried = true
  local okR, carsRoot = pcall(function() return ac.findNodes("carsRoot:yes") end)
  if not okR or not carsRoot then g.meshStatus="FAIL: carsRoot not found"; g.meshOk=false; return end
  local nm = HOLDER_NAME.."_"..slotIndex
  disposeHolderNamed(nm)
  local okH, holder = pcall(function() return carsRoot:createBoundingSphereNode(nm, 4.0) end)
  if not okH or not holder then g.meshStatus="FAIL: holder node"; g.meshOk=false; return end
  g.meshHolder = holder
  -- v24/v25: resolve THIS ghost's real car; fall back to the DualApex E92,
  -- then the Z4 as a last resort. Honest labels either way.
  local kn5, carFolder = nil, nil
  if g.car then kn5, carFolder = resolveCarKN5(g.car) end
  local usingFallback = (kn5 == nil)
  if usingFallback then kn5 = CAR_KN5 end
  local okL, model = pcall(function() return holder:loadKN5(kn5) end)
  if (not okL or model==nil) and not usingFallback then
    usingFallback = true
    okL, model = pcall(function() return holder:loadKN5(CAR_KN5) end)
  end
  if not okL or model==nil then
    okL, model = pcall(function() return holder:loadKN5(CAR_KN5_LAST) end)
  end
  if not okL or model==nil then g.meshStatus="FAIL: loadKN5 nil"; g.meshOk=false; g.meshModel=nil; return end
  g.meshModel = model; g.meshOk = true
  local liveryNote = ""
  -- v39.5: the livery used to apply ONLY on the fallback body, so a ghost whose car
  -- resolved to an INSTALLED Z4 GT3 loaded that car's own stock skin instead of ours.
  -- Now it also applies when the resolved model IS a Z4 GT3. Deliberately narrow: the
  -- artwork is UV-mapped to the Z4's CHAS_ext material. Plain find, no pattern magic.
  if usingFallback or (carFolder ~= nil and tostring(carFolder):find("z4_gt3", 1, true) ~= nil) then
    local okLv, ap, tot = pcall(applyDualApexLivery, model)
    if okLv and tot and tot > 0 then liveryNote = string.format(" +livery %d file(s)", ap) end
  end
  if usingFallback and g.car and #tostring(g.car) > 0 then
    g.meshStatus = "DualApex Z4 GT3 (" .. tostring(g.car) .. " not installed)" .. liveryNote
  elseif usingFallback then
    g.meshStatus = "DualApex Z4 GT3" .. liveryNote
  elseif carFolder then
    g.meshStatus = "model: " .. carFolder .. liveryNote
  else
    g.meshStatus = "OK"
  end
  -- v39.3 STICKER PROBE (diagnostic only). Two questions in one line:
  --   1. can this build CREATE geometry at runtime? -> a decal quad on the bodywork
  --   2. what are THIS car's material names?        -> per-car skins become possible
  -- Kept inline: the main chunk is close to Lua's 200-local limit.
  do
    local have = {}
    for _, mname in ipairs({"createNode", "createMeshQuad", "createQuad", "createMesh",
                            "createBoundingBox", "findMeshes", "ensureUniqueMaterials",
                            "setMaterialTexture", "setMaterialProperty", "getMaterials",
                            "materials", "at", "size", "name", "clone"}) do
      local okT, isFn = pcall(function() return type(model[mname]) == "function" end)
      if okT and isFn then have[#have+1] = mname end
    end
    -- try a few ways to enumerate materials; report the first that yields names
    local mats, how = nil, "none"
    pcall(function()
      local r = model:findMeshes("{ }")
      if r and #r > 0 then
        local names = {}
        for i = 1, math.min(#r, 400) do
          local nm
          pcall(function() nm = r:at(i):materialName() end)
          if nm and #tostring(nm) > 0 then names[tostring(nm)] = true end
        end
        local list = {}
        for k in pairs(names) do list[#list+1] = k end
        if #list > 0 then table.sort(list); mats = list; how = "findMeshes+materialName" end
      end
    end)
    if not mats then
      pcall(function()
        local ms = model:getMaterials()
        if ms and #ms > 0 then
          local list = {}
          for i = 1, math.min(#ms, 400) do list[#list+1] = tostring(ms[i]) end
          mats = list; how = "getMaterials"
        end
      end)
    end
    local matTxt
    if mats then
      local shown = {}
      for i = 1, math.min(#mats, 8) do shown[#shown+1] = mats[i] end
      matTxt = string.format("%d mats via %s: %s%s", #mats, how,
                             table.concat(shown, " "), (#mats > 8) and " ..." or "")
    else
      matTxt = "materials: could not enumerate"
    end
    g.stickerProbe = "api[" .. table.concat(have, ",") .. "] " .. matTxt
  end

  -- v35.3 #2: transparency is CSP-build-dependent and has resisted two tries, so:
  -- (a) probe which methods the model exposes, (b) apply the best opacity lever after
  -- ensureUniqueMaterials, (c) fall back to a subtle green emissive as a guaranteed
  -- distinguisher, (d) PREPEND a short probe tag (front of the line = never truncated)
  -- so one screenshot tells us exactly what this build supports.
  local _fx = ""
  if ghostLook then
    -- v35.6 #2: TRUE transparency from the livery texture's OWN alpha (CHAS_ext.png ~60%
    -- from GIMP). No setOpacity here + ksEmissive ignored, but a diffuse alpha IS honored:
    -- move the body into the transparent pass and force alpha-blend so the alpha is drawn.
    -- Safe/no-op with a fully opaque texture, so it never breaks a solid livery.
    local function _has(n) local ok,r=pcall(function() return type(model[n])=="function" end); return ok and r end
    if _has("ensureUniqueMaterials") then pcall(function() model:ensureUniqueMaterials() end) end
    pcall(function() model:setTransparent(true) end)
    local bm = "N"
    if _has("setMaterialsBlendMode") then
      if not pcall(function() model:setMaterialsBlendMode(render.BlendMode.AlphaBlend) end) then
        pcall(function() model:setMaterialsBlendMode(1) end)
      end
      bm = "Y"
    end
    _fx = string.format("[fx alpha bm:%s] ", bm)
  end
  g.meshStatus = _fx .. tostring(g.meshStatus or "")
  -- v21: ask the injected model to cast shadows (API name varies by CSP build; all guarded no-ops if absent)
  pcall(function() model:setShadows(true) end)
  pcall(function() model:setShadowsCasting(true) end)
  pcall(function() holder:setShadows(true) end)
  -- v22: find the wheel nodes so updateGhostMeshPose can spin them from ghost speed.
  -- CRITICAL (v21 bug): findNodes NEVER returns nil on a miss - it returns an EMPTY
  -- reference, and :rotate() on an empty set is a silent no-op. Must check :size().
  local function refCount(n)
    if n == nil then return 0 end
    local c = 0
    pcall(function() c = n:size() or 0 end)
    if c == 0 then pcall(function() if n.empty and not n:empty() then c = 1 end end) end
    return c
  end
  g.wheelNodes = {}
  for _, wn in ipairs({"WHEEL_LF","WHEEL_RF","WHEEL_LR","WHEEL_RR"}) do
    pcall(function()
      for _, flt in ipairs({ wn, "{ "..wn.." }", "name:"..wn }) do
        local n = model:findNodes(flt)
        if refCount(n) > 0 then g.wheelNodes[#g.wheelNodes+1] = n; return end
      end
    end)
  end
  -- wildcard fallback: grab all WHEEL* nodes as one group (same axis/angle anyway)
  if #g.wheelNodes == 0 then
    pcall(function()
      for _, flt in ipairs({ "WHEEL?", "WHEEL*", "{ WHEEL? }" }) do
        local n = model:findNodes(flt)
        if refCount(n) > 0 then g.wheelNodes[1] = n; g.wheelGroup = refCount(n); return end
      end
    end)
  end
  g.wheelCount = 0
  for i = 1, #g.wheelNodes do g.wheelCount = g.wheelCount + refCount(g.wheelNodes[i]) end
  g.wheelSpinFails = 0
  pcall(function() holder:setVisible(false) end)
end

-- v18: sample the track surface around the car (front/rear + left/right of XZ
-- along heading) and return look(forward, pitched to slope) + up(surface normal).
-- Uses the same physics.raycastTrack path as groundYAt. Flat fallback if disabled.
local function surfaceBasisAt(gx, gz, gY, lx, lz)
  if not FOLLOW_SURFACE then return vec3(lx,0,lz), vec3(0,1,0) end
  local L,W = 1.45, 0.80                 -- ~half wheelbase / half track (m)
  local rx,rz = lz, -lx                  -- right = forward rotated -90 in XZ
  local yf = groundYAt(gx+lx*L, gz+lz*L, gY)
  local yb = groundYAt(gx-lx*L, gz-lz*L, gY)
  local yl = groundYAt(gx+rx*W, gz+rz*W, gY)
  local yr = groundYAt(gx-rx*W, gz-rz*W, gY)
  local fX,fY,fZ = lx*(2*L), (yf-yb), lz*(2*L)   -- forward, lying in the surface
  local rX,rY,rZ = rx*(2*W), (yl-yr), rz*(2*W)   -- right,   lying in the surface
  -- v38 #4: bound PITCH and ROLL at the source. v37 clamped only the up vector, but pitch
  -- lives here in fY (front probe height minus back probe height): a front probe landing on
  -- a barrier or kerb wall still produced a ~45 degree nose-up car whatever the up vector
  -- said. TAN30 of the horizontal span is the most either axis may tilt.
  local TAN30 = 0.5774
  local fLim = (2*L) * TAN30
  local rLim = (2*W) * TAN30
  if fY >  fLim then fY =  fLim elseif fY < -fLim then fY = -fLim end
  if rY >  rLim then rY =  rLim elseif rY < -rLim then rY = -rLim end
  local uX = fY*rZ - fZ*rY                        -- up = forward x right
  local uY = fZ*rX - fX*rZ
  local uZ = fX*rY - fY*rX
  if uY < 0 then uX,uY,uZ = -uX,-uY,-uZ end       -- keep it pointing up
  local fm=math.sqrt(fX*fX+fY*fY+fZ*fZ); if fm<1e-6 then fm=1 end
  local um=math.sqrt(uX*uX+uY*uY+uZ*uZ); if um<1e-6 then um=1 end
  -- v37 #4: CLAMP THE TILT. The four ground probes sit ~1.45m fore/aft and ~0.80m either
  -- side; if one lands on a barrier, kerb wall or steep bank the height delta is huge and
  -- the car ends up pitched or rolled ~45 degrees. Nothing limited that before. Real
  -- circuit banking tops out well under 30 degrees (the Karussell is about 20), so beyond
  -- MAXTILT we blend back toward world-up rather than snapping - a gradual correction
  -- reads as suspension travel, a snap reads as a glitch.
  local ux, uy, uz = uX/um, uY/um, uZ/um
  local COS_MAX, SIN_MAX = 0.86603, 0.50000   -- 30 degrees from vertical
  if uy < COS_MAX then
    -- Cap the ANGLE at 30 degrees while KEEPING the direction of lean, rather than
    -- softening toward flat: softening still left a 45-degree probe at ~37 degrees, which
    -- still reads as broken. This is continuous - at exactly 30 nothing changes, so there
    -- is no snap as the ghost crosses the threshold.
    local h = math.sqrt(ux*ux + uz*uz)
    if h > 1e-6 then
      local k = SIN_MAX / h
      ux, uz = ux*k, uz*k
    end
    uy = COS_MAX
  end
  return vec3(fX/fm,fY/fm,fZ/fm), vec3(ux,uy,uz)
end

-- position/orient ONE ghost's holder to its current pose. Called from script.update.
local WHEEL_SPIN_R = 0.33   -- v21: rolling radius (m) for visual wheel spin
local function updateGhostMeshPose(g, elapsed, dt)
  if not g.meshOk or not g.meshHolder then return end
  if forceWire then pcall(function() g.meshHolder:setVisible(false) end); return end
  local gx,gy,gz = posAt(g, elapsed)
  local groundY  = groundYAt(gx, gz, gy)
  local hdg = travelAt(g, elapsed)
  -- v39.4 #2: smoothing the heading at 20%/frame is right while driving, but when elapsed
  -- JUMPS — scrubbing a replay, or resuming after a pause — the heading lags far behind the
  -- position and the car visibly travels SIDEWAYS. Detect the jump and snap instead.
  local _prev = g.poseElapsed
  local _jump = (_prev == nil)
  if not _jump and g.duration and g.duration > 0 then
    local _d = math.abs(elapsed - _prev)
    if _d > g.duration * 0.5 then _d = g.duration - _d end   -- wrap across the lap
    _jump = _d > 0.5
  elseif not _jump then
    _jump = math.abs(elapsed - _prev) > 0.5
  end
  g.poseElapsed = elapsed
  -- Prefer the heading stored on the racing line. It is exact at any t, so it needs no
  -- smoothing and no jump handling - those existed only to hide the runtime chord's lag.
  local _lineH = DA.headingAt(g, elapsed)
  if _lineH ~= nil then
    -- hdgsmooth6: ease rather than apply raw. The stored heading is exact in TIME but
    -- inherits the sample noise, and heading noise is atan(error / sample spacing) --
    -- so the same error yaws the car about three times harder at 90 km/h than at 250,
    -- which is why the slow section after the bridge looked far worse while measuring
    -- CLEANER than the rest of the lap. Jumps still snap, so a scrub stays correct.
    -- hdgdrive7: ease only while DRIVING. In a replay the stored heading is exact at
    -- any moment and easing merely lags it, which points the body off its direction
    -- of travel because the ghost can advance faster than the easing follows.
    if _jump or g.displayHeading == nil or (DA_RPC ~= nil and DA_RPC.f0 ~= nil) then
      g.displayHeading = _lineH
    else
      g.displayHeading = approachAngle(g.displayHeading, _lineH, 0.25)
    end
  elseif hdg ~= nil then
    if _jump then g.displayHeading = hdg
    else g.displayHeading = approachAngle(g.displayHeading, hdg, 0.2) end
  end

  -- v39.9: point the car along the direction it VISIBLY MOVED since the last frame,
  -- not along the direction the sample trajectory says it should be going. The two
  -- disagree whenever position and heading advance on different clocks - which is what
  -- replay does - and the car then travels sideways. Deriving from the DRAWN positions
  -- makes that impossible by construction: whatever the timing, the car faces where it
  -- is actually going. Falls back to the trajectory heading below roughly walking pace,
  -- where the frame-to-frame step is too small to give a stable direction.
  do
    local px, pz = g.lastDrawX, g.lastDrawZ
    g.dbgJump = _jump
    g.dbgTraj = hdg
    g.dbgLine = _lineH
    g.dbgMove = nil
    g.dbgStep = nil
    if px ~= nil then
      local mdx, mdz = gx - px, gz - pz
      local m2 = mdx*mdx + mdz*mdz
      g.dbgStep = math.sqrt(m2)
      if m2 > 0.0064 then g.dbgMove = math.deg(atan2(mdx, mdz)) end
      -- Only used as a fallback when the line heading is unavailable (older ghosts with
      -- no stored heading). When the line heading exists it is exact and wins.
      if _lineH == nil and (not _jump) and m2 > 0.0064 then
        g.displayHeading = approachAngle(g.displayHeading or g.dbgMove, g.dbgMove, 0.5)
      end
    end
    g.lastDrawX, g.lastDrawZ = gx, gz
  end

  local hd = (g.displayHeading or 0) + (DA.hdgOffset or 0)
  local lx, lz = math.sin(math.rad(hd))*forwardFlip, math.cos(math.rad(hd))*forwardFlip
  pcall(function()
    g.meshHolder:setPosition(vec3(gx, groundY + yOffset, gz))
    local look_, up_ = surfaceBasisAt(gx, gz, gy, lx, lz)   -- v18: pitch/roll to track surface
    g.meshHolder:setOrientation(look_, up_)
    g.meshHolder:setVisible(true)
  end)
  -- v21: spin the wheel nodes from ghost speed (visual only; silently degrades)
  if g.wheelNodes and #g.wheelNodes > 0 and (g.wheelSpinFails or 0) < 8 and dt and dt > 0 then
    local _,_,_,spd = posAt(g, elapsed)
    local dAng = ((spd or 0) / 3.6 / WHEEL_SPIN_R) * dt      -- rad this frame
    if dAng > 0.0001 and dAng < 3.0 then
      for wi = 1, #g.wheelNodes do
        local okW = pcall(function() g.wheelNodes[wi]:rotate(vec3(1,0,0), dAng) end)
        if not okW then g.wheelSpinFails = (g.wheelSpinFails or 0) + 1 end
      end
    end
  end
end

local function hideGhostMesh(g)
  if g and g.meshHolder then pcall(function() g.meshHolder:setVisible(false) end) end
end
local function hideAllMeshes()
  for i=1,#ghosts do hideGhostMesh(ghosts[i]) end
end
-- fully remove a ghost slot (dispose its mesh, drop it from the list)
local function removeGhost(slotIndex)
  local g=ghosts[slotIndex]; if not g then return end
  if g.meshHolder then pcall(function() g.meshHolder:dispose() end) end
  table.remove(ghosts, slotIndex)
  syncPrimaryMirror()      -- keep `ghost` mirror pointing at the new slot 1 (or cleared)
  computeMapBounds()
end

-- wipe every loaded ghost (dispose meshes, clear minimap)
local function clearAllGhosts()
  for i=#ghosts,1,-1 do removeGhost(i) end
  playing=false
end

-- ============================================================================
-- WIREFRAME (v8 fallback — used if mesh failed OR forceWire)
-- ============================================================================
local function rot(x,z,deg) local r=math.rad(deg); local c,s=math.cos(r),math.sin(r); return x*c-z*s, x*s+z*c end
local WHEEL_R = 0.33
local function drawCarWire(gp, headingDeg, col, wcol)
  local function pt(fx,fy,fz) local rx,rz=rot(fx,fz,headingDeg); return gp+vec3(rx,fy,rz) end
  local bW,bL = 1.0, 2.25
  local floorY = 0.30
  local fl,fr,rr,rl = pt(-bW,floorY,bL), pt(bW,floorY,bL), pt(bW,floorY,-bL), pt(-bW,floorY,-bL)
  render.debugLine(fl,fr,col); render.debugLine(fr,rr,col); render.debugLine(rr,rl,col); render.debugLine(rl,fl,col)
  local topY=floorY+0.45
  local tfl,tfr,trr,trl = pt(-bW,topY,bL), pt(bW,topY,bL), pt(bW,topY,-bL), pt(-bW,topY,-bL)
  render.debugLine(tfl,tfr,col); render.debugLine(tfr,trr,col); render.debugLine(trr,trl,col); render.debugLine(trl,tfl,col)
  render.debugLine(fl,tfl,col); render.debugLine(fr,tfr,col); render.debugLine(rr,trr,col); render.debugLine(rl,trl,col)
  local cY=topY+0.5
  local cfl,cfr,crr,crl = pt(-0.65,cY,0.4), pt(0.65,cY,0.4), pt(0.55,cY,-0.95), pt(-0.55,cY,-0.95)
  render.debugLine(cfl,cfr,col); render.debugLine(cfr,crr,col); render.debugLine(crr,crl,col); render.debugLine(crl,cfl,col)
  render.debugLine(pt(-0.85,topY,0.55),cfl,col); render.debugLine(pt(0.85,topY,0.55),cfr,col)
  render.debugLine(pt(0.8,topY,-1.05),crr,col); render.debugLine(pt(-0.8,topY,-1.05),crl,col)
  render.debugLine(fl,pt(-0.55,floorY,bL+0.35),col); render.debugLine(fr,pt(0.55,floorY,bL+0.35),col)
  render.debugLine(pt(-0.55,floorY,bL+0.35),pt(0.55,floorY,bL+0.35),col)
  render.debugLine(pt(-1.0,topY+0.35,-bL-0.15),pt(1.0,topY+0.35,-bL-0.15),col)
  render.debugLine(pt(-0.9,topY,-bL+0.05),pt(-0.9,topY+0.35,-bL-0.15),col)
  render.debugLine(pt(0.9,topY,-bL+0.05),pt(0.9,topY+0.35,-bL-0.15),col)
  local function wheel(wx,wz)
    local seg=10
    for k=0,seg-1 do
      local a1=(k/seg)*2*math.pi; local a2=((k+1)/seg)*2*math.pi
      local y1=WHEEL_R+math.sin(a1)*WHEEL_R; local z1=math.cos(a1)*WHEEL_R
      local y2=WHEEL_R+math.sin(a2)*WHEEL_R; local z2=math.cos(a2)*WHEEL_R
      local pa=pt(wx-0.16, y1, wz+z1); local pb=pt(wx-0.16, y2, wz+z2)
      local pc=pt(wx+0.16, y1, wz+z1); local pd=pt(wx+0.16, y2, wz+z2)
      render.debugLine(pa,pb,wcol); render.debugLine(pc,pd,wcol)
      if k%2==0 then render.debugLine(pa,pc,wcol) end
    end
  end
  wheel(-0.95, 1.45); wheel(0.95, 1.45); wheel(-0.95, -1.45); wheel(0.95, -1.45)
end

local function checkSFCross()
  local car=ac.getCar(0); if not car then return false end
  local sp=car.splinePosition; if sp==nil then return false end
  lastSplineSeen=sp
  local crossed=false
  if lastSpline~=nil and lastSpline>0.9 and sp<0.1 then crossed=true; sfCrossings=sfCrossings+1 end
  lastSpline=sp
  return crossed
end

-- ============================================================================
-- CALLBACKS
-- ============================================================================
function script.update(dt)
  if DA and DA.upd and not DA.upd.checked then pcall(DA.checkForUpdate) end
  -- v39.4 #1: AC hands back a huge dt on the FIRST frame after un-pausing (the whole paused
  -- interval). isPausedNow() freezes replayT while paused, but that resumed frame is not
  -- paused, so the spike went straight into replayT and the ghost jumped several car
  -- lengths. 0.1s is ~6 frames at 60fps; anything beyond that is a resume artefact rather
  -- than real elapsed time, so it is dropped instead of integrated.
  if dt and dt > 0.1 then dt = 0 end
  lastDtSeen = dt or -1
  -- v20: poll the snap hotkey every frame so it fires while DRIVING, window closed
  if snapHotkey then
    local ok_, pressed_ = pcall(function() return snapHotkey:pressed() end)
    if ok_ and pressed_ then pcall(snapGhostToMe) end
  end
  if daSegUpdate then pcall(daSegUpdate, dt) end   -- v23: BTG segment clock
  local nG = #ghosts
  if playing and nG>0 and not isPausedNow() then
    replayT = replayT + (dt or 0)
  end
  -- move EACH loaded ghost's mesh (scene-graph manipulation belongs in update)
  if playing and nG>0 then
    -- v38 #1/#3: drive the replay ghost from AC's OWN replay clock.
    -- While driving we append (replayFrames -> ghost elapsed) per ghost. During replay we
    -- read replayCurrentFrame and look the elapsed straight up, interpolating between the
    -- two bracketing samples. This is a TIME->TIME map, so there is no position matching and
    -- therefore none of the jitter that sank v36.1 - and scrubbing, rewind and fast-forward
    -- all work because AC's own frame counter already handles them.
    -- If the map has no entry for this frame (a lap driven before v38, or a replay that
    -- reaches back past what we recorded) the ghost is HIDDEN rather than shown at a
    -- guessed position.
    local _replay = isReplayNow()
    wasReplay = _replay
    -- v39.16: advance the ghost by AC's replay frame counter. During an in-session
    -- replay the player's lap-progress value is frozen, so the v39 key never changes
    -- and the ghost stands still; the frame counter does move. Keyed on the replay
    -- being ACTIVE rather than on it not being paused, because arriving via pause can
    -- leave that flag set and skip the branch entirely. One global table rather than
    -- locals: the main chunk is near Lua's limit.
    DA_RPC = DA_RPC or { fps = 33.3 }
    local _rpAct, _rpF = false, nil
    pcall(function()
      local s = ac.getSim()
      if s then
        -- rpgate2: a paused replay may not set the active flag, and pausing
        -- alone is harmless here because the counter does not advance then,
        -- so the ghost holds position exactly as a pause should leave it.
        _rpAct = (s.isReplayActive == true) or (s.isPaused == true)
        if type(s.replayCurrentFrame) == "number" then _rpF = s.replayCurrentFrame end
        -- rpanchor4: the write head is the present moment. It grows while driving and
        -- holds during a replay, so it is what the pause instant corresponds to.
        if type(s.replayFrames) == "number" then DA_RPC.w = s.replayFrames end
      end
    end)
    if _rpAct and _rpF then
      -- rprate3: anchor only. The rate is NOT measured here: AC's 2x rewind, fast
      -- forward and slow motion change how fast the counter moves through a replay,
      -- so frames per second of real time measures the playback speed rather than
      -- the rate the replay was recorded at, and the ghost inherits that error.
      -- rpanchor4: anchor to the write head, NOT to the read head. A replay
      -- starts its counter at the beginning of the buffer, so anchoring on that
      -- put the ghost a whole buffer length out of position.
      if DA_RPC.f0 == nil then
        DA_RPC.f0 = DA_RPC.w or _rpF; DA_RPC.t0 = replayT; DA_RPC.lf = _rpF
      end
      DA_RPC.lf = _rpF
      -- rpsmooth5: the counter is an integer stepping at the recording rate while the
      -- game renders far faster, so the raw value advances in visible jumps and holds
      -- between them. Ease toward it instead. A large jump means the timeline was
      -- dragged, so snap for that and stay responsive.
      local _tgt = (DA_RPC.t0 or 0) + (_rpF - DA_RPC.f0) / (DA_RPC.fps or 33.3)
      if DA_RPC.sm == nil or math.abs(_tgt - DA_RPC.sm) > 0.5 then
        DA_RPC.sm = _tgt
      else
        DA_RPC.sm = DA_RPC.sm + (_tgt - DA_RPC.sm) * math.min(1, (dt or 0) * 12)
      end
    else
      DA_RPC.f0 = nil; DA_RPC.lf = nil; DA_RPC.sm = nil
      -- rprate3: while driving, the write head advances at exactly the recording
      -- rate against real time, with no playback control involved. That is the
      -- honest place to measure it, and it is kept for use during replay.
      pcall(function()
        local s3 = ac.getSim()
        local wf = s3 and s3.replayFrames
        if type(wf) == "number" and dt and dt > 0 then
          if DA_RPC.wf then
            -- Average over a whole second. The counter is an integer, so at a 33 Hz
            -- recording rate with a 60 fps render it steps by 0 or 1 per frame, and
            -- dividing only on the frames where it moved measures the RENDER rate
            -- instead -- it read 60 against a true 33 in test.
            local _dw = wf - DA_RPC.wf
            if _dw >= 0 then
              DA_RPC.aw = (DA_RPC.aw or 0) + _dw
              DA_RPC.at = (DA_RPC.at or 0) + dt
              if DA_RPC.at >= 1.0 then
                local _fp = DA_RPC.aw / DA_RPC.at
                if _fp > 5 and _fp < 200 then DA_RPC.fps = DA_RPC.fps * 0.7 + _fp * 0.3 end
                DA_RPC.aw = 0; DA_RPC.at = 0
              end
            end
          end
          DA_RPC.wf = wf
        end
      end)
    end
    -- v39: one read of the player's own lap progress serves both directions. During replay
    -- this is the REPLAYED car's position, which is exactly the moment we need to match.
    -- v39.2: session-wide monotonic key. lapCount is an integer and splinePosition is
    -- 0..1, so (lap + sp) rises continuously across lap boundaries and identifies one
    -- MOMENT IN THE SESSION rather than one point on the track. Without the lap term every
    -- lap collapses onto the same keys, the first lap recorded wins, and replaying a later
    -- faster lap reads out-lap timings - which is what made the ghost run 2-3x too fast.
    -- Inlined rather than a local function: the main chunk is near Lua's 200-local limit.
    local _sp = nil
    pcall(function()
      local c = ac.getCar(0)
      if c and c.splinePosition ~= nil then
        local lap = c.lapCount
        if type(lap) == "number" and lap >= 0 then _sp = lap + c.splinePosition
        else _sp = c.splinePosition end
      end
    end)
    for i=1,nG do
      local g=ghosts[i]
      local elapsed, show = replayT + (g.gaOff or 0), true
      if _rpAct and _rpF and DA_RPC.f0 then
        elapsed = (DA_RPC.sm or DA_RPC.t0 or 0) + (g.gaOff or 0)
      elseif _replay then
        local e, gp = nil, nil
        if _sp then e, gp = fmapLookup(g, _sp) end
        if e then
          elapsed = e
          if gp then g.gap = gp end   -- v38.1 C2: the gap AS IT WAS at this replay moment
        else
          show = false                -- outside the recorded run: draw nothing
        end
      end
      if g.duration and g.duration>0 then elapsed = elapsed % g.duration end
      if not _replay and _sp then fmapAppend(g, _sp, elapsed, g.gap) end
      if g.meshOk then
        if show then
          local _wdt = isPausedNow() and 0 or dt
          updateGhostMeshPose(g, elapsed, _wdt)
        elseif g.meshHolder then
          pcall(function() g.meshHolder:setVisible(false) end)
        end
      end
    end

  elseif not playing then
    hideAllMeshes()
  end
  -- v9.4: player lap clock + GAP LEADERBOARD vs every loaded ghost
  if nG>0 then
    pcall(function()
      local car=ac.getCar(0)
      if not car then return end
      local sp=car.splinePosition
      if sp~=nil then
        if pLastSpline~=nil and pLastSpline>0.9 and sp<0.1 then
          for i=1,nG do ghosts[i].nIdx=1 end   -- v35.8: reset the gap tracker at S/F in EVERY mode so the monotonic search re-acquires from the start of the ghost line
          if mode ~= "GATEA" then   -- v28: in BTG mode the delta anchor is gate A, not s/f
            playerT=0; pCrossed=true   -- player crossed SF -> anchor the lap clock
            if mode=="SFSYNC" and playing then replayT=0
              for gi5=1,#ghosts do ghosts[gi5].gaOff = 0 end end  -- re-sync ghost to s/f each lap (hotlap convention)
          end
        end
        pLastSpline=sp
      end
      if not isPausedNow() then playerT = playerT + (dt or 0) end
      local pp=car.position
      if pp then updateGapLeaderboard(pp.x, pp.z) end
    end)
  end
  -- v9.2: capture the player's sim lap (runs whether or not a ghost is playing)
  pcall(function() recUpdate(dt, ac.getCar(0)) end)
end

-- distinct tint per ghost slot for the line + label (mesh stays its real livery)
local GHOST_TINTS = {
  rgbm(0, 5, 0, 1),    -- slot 1: green
  rgbm(5, 3, 0, 1),    -- slot 2: amber
  rgbm(0, 3, 5, 1),    -- slot 3: cyan
}
function script.draw3D()
  if not playing or #ghosts==0 then return end
  render.setDepthMode(render.DepthMode.Off)
  for gi=1,#ghosts do
    local g=ghosts[gi]
    if g.samples and g.n and g.n>1 then
      local tint = GHOST_TINTS[gi] or rgbm(3,3,3,1)
      local lineCol = rgbm(tint.r*0.12, tint.g*0.12, tint.b*0.12, 0.3)
      local elapsed = replayT + (g.gaOff or 0)
      if g.duration and g.duration>0 then elapsed = elapsed % g.duration end
      -- v27: chase ribbon - the ghost line exists only from YOUR position (capped
      -- at the last TRAIL_S seconds of ghost path) up to the ghost, and is
      -- consumed as you drive over it. (Rev3 #1: no more full-lap dashed line.)
      local s=g.samples
      local TRAIL_S = 25
      local gi2 = g.trailIdx or 1
      if s[gi2] and s[gi2].t > elapsed then gi2 = 1 end
      while gi2 < g.n and s[gi2+1].t <= elapsed do gi2 = gi2 + 1 end
      g.trailIdx = gi2
      local tStart = elapsed - TRAIL_S
      local pIdx = g.nIdx
      if pCrossed and pIdx and s[pIdx] then
        local behind = elapsed - s[pIdx].t
        if behind < 0 then behind = behind + (g.duration or 0) end
        if behind >= 0 and behind < TRAIL_S then tStart = elapsed - behind end
      end
      local ri = gi2
      local drawn = 0
      while ri > 1 and s[ri-1].t >= tStart and drawn < 400 do
        local gy1=groundYAt(s[ri-1].x, s[ri-1].z, s[ri-1].y)
        local gy2=groundYAt(s[ri].x, s[ri].z, s[ri].y)
        render.debugLine(vec3(s[ri-1].x,gy1+0.05,s[ri-1].z), vec3(s[ri].x,gy2+0.05,s[ri].z), lineCol)
        ri = ri - 1; drawn = drawn + 1
      end
      local gx,gy,gz,gspd=posAt(g, elapsed)
      local groundY = groundYAt(gx, gz, gy)
      local gp=vec3(gx, groundY, gz)
      local hdg=travelAt(g, elapsed)
      if hdg~=nil then g.displayHeading=approachAngle(g.displayHeading,hdg,0.12) end   -- v29: calmer yaw
      if (not g.meshOk) or forceWire then
        drawCarWire(gp, g.displayHeading or 0, tint, rgbm(tint.r*0.6,tint.g*0.6,tint.b*0.6,1))
      end
      -- v35.5 #1b: label shows the ghost's BTG (hero metric on gated tracks) instead of
      -- the full-lap time baked into g.label, + live gap.
      local _dn = g.label or ("Ghost "..gi)
      local _sp = _dn:find("  ", 1, true)          -- split "driver  laptime" on the gap
      if _sp then _dn = _dn:sub(1, _sp-1) end
      local _btg = nil
      if daGhostBTG then pcall(function() _btg = daGhostBTG(g) end) end
      local lbl
      if _btg and _btg > 0 then lbl = _dn .. "  BTG " .. fmtTime(_btg)
      else lbl = g.label or ("Ghost "..gi) end
      if g.gapValid then lbl = lbl..string.format("  %+.1fs", g.gap) end
      render.debugText(gp+vec3(0,2.7,0), lbl, tint, 1.3)
    end
  end
  render.setDepthMode(render.DepthMode.Default)
end

local function startPlaying()
  -- v9.5: no auto local-file load; if nothing is loaded, PLAY simply does nothing

  -- lazily load a mesh for each ghost that doesn't have one yet
  for i=1,#ghosts do
    local g=ghosts[i]
    if not g.meshTried then loadMeshForGhost(g, i) end
    g.displayHeading=nil; g.lastDrawX=nil; g.lastDrawZ=nil
  end
  playing=true; replayT=0
  for i=1,#ghosts do ghosts[i].gaOff = 0 end
  for i=1,#ghosts do
    ghosts[i].eHist = {}; ghosts[i].rIdx = nil
    ghosts[i].fSp = {}; ghosts[i].fElap = {}; ghosts[i].fGap = {}; ghosts[i].fN = 0   -- v39: fresh spline map per run
  end
end

-- ============================================================================
-- v9.3  GHOST BROWSER  (list tracks/laps from the API, load a ghost in-game)
-- ----------------------------------------------------------------------------
-- Placed AFTER ghost/playing/gmap/mesh locals so all references resolve to them.
-- Uses CSP web.get (confirmed working). Endpoints:
--   GET /api/tracks                    -> tracks that have ghosts
--   GET /api/leaderboard?track=<name>  -> laps for one track (driver/car/time/ghost_id)
--   GET /api/ghost/<ghost_id>          -> full ghost JSON
-- Selecting a lap fetches its ghost and loads it via loadGhostFromJSON(), which
-- populates the SAME ghost.* fields the local-file path uses, so replay/mesh/
-- telemetry/delta/minimap all work unchanged.
-- ============================================================================
local browser = {
  open=false, tracks=nil, tracksErr=nil, tracksLoading=false,
  selTrack=nil, laps=nil, lapsErr=nil, lapsLoading=false,
  loadingGhostId=nil, loadMsg=nil,
  allLaps=false,   -- v21: false = one PB per driver, true = every lap (load several of your own)
}

-- v21: track-name normalisation (moved up from the v10 UI block so the circuit
-- guard in addGhostFromJSON can see these locals; Lua locals are not hoisted).
--   lower-case, fold a few accents, drop ks_/rt_/acu_/ac_ prefixes, keep a-z0-9
local function normTrack(s)
  if not s then return "" end
  s = tostring(s):lower()
  s = s:gsub("\195\188", "u"):gsub("\195\182", "o"):gsub("\195\164", "a")  -- ü ö ä
  s = s:gsub("\195\159", "ss"):gsub("\195\169", "e"):gsub("\195\168", "e") -- ß é è
  s = s:gsub("^ks_", ""):gsub("^rt_", ""):gsub("^acu_", ""):gsub("^ac_", "")
  s = s:gsub("[^a-z0-9]", "")
  return s
end

-- two normalised names "match" if either is a (>=3 char) substring of the other
local function tracksMatch(a, b)
  if #a < 3 or #b < 3 then return false end
  if a == b then return true end
  if a:find(b, 1, true) then return true end
  if b:find(a, 1, true) then return true end
  return false
end

-- minimal URL encoder (track names may contain spaces/hyphens/accents)
local function urlEncode(s)
  if s==nil then return "" end
  return (tostring(s):gsub("[^%w%-%_%.%~]", function(c) return string.format("%%%02X", string.byte(c)) end))
end

-- tolerant HTTP GET across the CSP signatures we probed; calls cb(ok, status, body)
local function httpGet(url, cb)
  local function unpack2(a, b)
    if type(a)=="table" and b==nil then return (a.status and a.status<400), a.status, a.body or a.text or a.response end
    if (a==nil or a==false) and type(b)=="table" then return (b.status and b.status<400), b.status, b.body or b.text or b.response end
    if type(a)=="string" and type(b)=="table" then return (b.status and b.status<400), b.status, b.body or b.text or b.response end
    return false, nil, "bad response"
  end
  if type(web)~="table" or type(web.get)~="function" then cb(false, nil, "web.get unavailable on this build"); return end
  local ok = pcall(function() web.get(url, function(a,b) local s,st,bd=unpack2(a,b); cb(s,st,bd) end) end)
  if not ok then
    local ok2 = pcall(function() web.get(url, {}, function(a,b) local s,st,bd=unpack2(a,b); cb(s,st,bd) end) end)
    if not ok2 then cb(false, nil, "web.get threw on this build") end
  end
end

-- shared loader: populate ghost.* from a ghost JSON STRING (file + HTTP both feed this)
-- build a ghost slot from parsed JSON (no mesh yet; mesh loads lazily on play)
-- v39.12: the ghost's heading is a property of the RACING LINE, so compute it once from
-- the line itself and store it on every sample. Deriving it at runtime from a +/-0.35 s
-- chord made it depend on playback timing, which is why it drifted in replay and looked
-- wrong when paused: at a standstill the runtime chord has nothing to measure. A stored
-- per-sample heading cannot drift, cannot lag, and is identical paused, playing or
-- scrubbing. Centred on the neighbouring samples, so it is the true local tangent.
DA.precomputeHeadings = function(sm, n)
  if not sm or n < 2 then return end
  for i = 1, n do
    local a = sm[math.max(1, i - 1)]
    local b = sm[math.min(n, i + 1)]
    local dx, dz = b.x - a.x, b.z - a.z
    if (dx*dx + dz*dz) < 1e-6 then
      -- stationary pair (pit lane, standing start): widen until there is a direction
      local j = i + 2
      while j <= n and (dx*dx + dz*dz) < 1e-6 do
        dx, dz = sm[j].x - a.x, sm[j].z - a.z; j = j + 1
      end
    end
    sm[i].hdg = ((dx*dx + dz*dz) > 1e-6) and math.deg(atan2(dx, dz)) or (sm[math.max(1,i-1)].hdg or 0)
  end
end

-- heading at time t: interpolate the STORED sample headings, wrap-safe.
DA.headingAt = function(g, t)
  local sm, n = g.samples, g.n
  if not sm or n < 2 or sm[1].hdg == nil then return nil end
  local lo = bracket(g, t)
  local a, b = sm[lo], sm[math.min(lo + 1, n)]
  local span = b.t - a.t
  local f = (span > 0) and math.max(0, math.min(1, (t - a.t) / span)) or 0
  local d = ((b.hdg - a.hdg + 180) % 360) - 180      -- shortest way round
  return a.hdg + d * f
end

function makeGhostSlot(parsed, label, gid)
  DA.precomputeHeadings(parsed.samples, #parsed.samples)
  local g = {
    samples  = parsed.samples,
    n        = #parsed.samples,
    duration = parsed.lap_time_s or parsed.samples[#parsed.samples].t or 0,
    hasRPM   = (parsed.samples[1].rpm  ~= nil),
    hasGear  = (parsed.samples[1].gear ~= nil),
    hasOBD   = (parsed.has_obd==true) or (parsed.samples[1].gas~=nil),
    label    = label or "Ghost",
    gid      = gid,
    car      = parsed.car,   -- v24: real car name from the lap metadata
    meshTried=false, meshOk=false, meshHolder=nil, meshModel=nil, meshStatus="not attempted",
    fSp={}, fElap={}, fGap={}, fN=0,   -- v39: player splinePosition -> ghost elapsed + gap
    displayHeading=nil, gapValid=false, gap=0, gap_m=0, nIdx=1,
  }
  return g
end

-- keep the legacy single-ghost `ghost` mirror pointed at slot 1 (for recorder/min UI)
function syncPrimaryMirror()
  local g=ghosts[1]
  if g then
    ghost.samples=g.samples; ghost.n=g.n; ghost.duration=g.duration
    ghost.hasRPM=g.hasRPM; ghost.hasGear=g.hasGear; ghost.loaded=true; ghost.err=nil
  else
    ghost.samples=nil; ghost.n=0; ghost.duration=0; ghost.loaded=false
  end
end

-- ADD a ghost (up to MAX_GHOSTS). Returns ok,msg. Does NOT auto-start playback.
local function addGhostFromJSON(raw, label, gid)
  if not raw or #raw==0 then return false, "empty response" end
  local okP, parsed = pcall(JSON.parse, raw)
  if not okP or not parsed then return false, "JSON parse failed" end
  if not parsed.samples or #parsed.samples<2 then return false, "no samples in ghost" end
  -- v21 circuit guard: refuse a ghost recorded on a DIFFERENT circuit than the one
  -- you're driving (the chase-target path used to happily load a Donington lap onto
  -- the Nordschleife -> the "impossible 1:09"). Only enforced when both sides are
  -- known; ghosts without a track field load as before.
  if parsed.track and #tostring(parsed.track) > 0 and browser and browser.detected then
    local gt = normTrack(parsed.track)
    -- loose match: substring either way (same rule the picker uses) OR a shared
    -- prefix of >=8 chars (tolerates naming variants like donington_national vs
    -- Donington Park; different circuits diverge in the first characters).
    local function prefixLen(a, b)
      local n = math.min(#a, #b)
      for i = 1, n do if a:byte(i) ~= b:byte(i) then return i - 1 end end
      return n
    end
    local function looseMatch(a, b)
      if #a < 3 or #b < 3 then return false end
      if tracksMatch(a, b) then return true end
      return prefixLen(a, b) >= 8
    end
    if #gt >= 3
      and not looseMatch(gt, browser.curIdNorm or "")
      and not looseMatch(gt, browser.curNameNorm or "")
      and not looseMatch(gt, browser.curFullNorm or "") then
      return false, "wrong circuit - that lap is from " .. tostring(parsed.track)
    end
  end
  -- de-dupe: same gid already loaded?
  if gid then for i=1,#ghosts do if ghosts[i].gid==gid then return false, "already loaded" end end end
  if #ghosts>=MAX_GHOSTS then return false, "max "..MAX_GHOSTS.." ghosts" end
  ghosts[#ghosts+1] = makeGhostSlot(parsed, label, gid)
  syncPrimaryMirror()
  gmap.ready=false; computeMapBounds()
  return true, "added"
end

-- legacy single-ghost loader (local file / replace-all): clears slots, loads one.
local function loadGhostFromJSON(raw, label)
  if not raw or #raw==0 then return false, "empty response" end
  local okP, parsed = pcall(JSON.parse, raw)
  if not okP or not parsed then return false, "JSON parse failed" end
  if not parsed.samples or #parsed.samples<2 then return false, "no samples in ghost" end
  -- clear existing slots + their meshes
  for i=#ghosts,1,-1 do removeGhost(i) end
  ghosts[1]=makeGhostSlot(parsed, label or "Ghost", nil)
  syncPrimaryMirror()
  gmap.ready=false; computeMapBounds()
  playing=false
  return true, (label or "loaded")
end

-- Replace this script with the published build. USER-CONFIRMED, never silent: an app that
-- rewrites its own code unasked is exactly what sim racers are wary of, and a visible
-- button costs nothing in adoption. Uses httpGet, NOT web.get directly - web.get's
-- callback shape varies between CSP builds, which is why httpGet's normaliser exists.
-- Safety is blunt rather than clever: https only, a size range, and a marker that must be
-- present. If any check fails nothing is written and the running file is untouched.
DA.applyUpdate = function()
  if DA.upd.busy then return end
  local u = DA.upd.luaUrl
  if not u or u == "" then DA.upd.msg = "no update URL from server"; return end
  if u:sub(1, 8) ~= "https://" then DA.upd.msg = "refused: not https"; return end
  DA.upd.busy = true
  DA.upd.msg = "downloading v" .. tostring(DA.upd.latest) .. "..."
  httpGet(u, function(ok, status, body)
    DA.upd.busy = false
    if not ok or status ~= 200 or type(body) ~= "string" then
      DA.upd.msg = "download failed - get it from dualapex.com"
      return
    end
    if #body < 40000 or #body > 2000000 then
      DA.upd.msg = "bad download (" .. tostring(#body) .. " bytes) - nothing written"
      return
    end
    if not body:find("DualApex Ghost", 1, true) or not body:find("DA.version", 1, true) then
      DA.upd.msg = "that file is not the app - nothing written"
      return
    end
    local okBak = pcall(function()
      local cur = io.load(APP_DIR .. "dualapex_ghost.lua")
      if cur and #cur > 0 then io.save(APP_DIR .. "dualapex_ghost.lua.bak_v" .. DA.version, cur) end
    end)
    local okW = pcall(function() io.save(APP_DIR .. "dualapex_ghost.lua", body) end)
    if okW then
      DA.upd.msg = "updated to v" .. tostring(DA.upd.latest) .. " - RESTART Assetto Corsa"
      if not okBak then DA.upd.msg = DA.upd.msg .. " (no backup)" end
    else
      DA.upd.msg = "could not write - get it from dualapex.com"
    end
  end)
end

-- Ask the server whether a newer build exists. Fire-and-forget: any failure leaves
-- DA.upd untouched and the panel just shows the current version. A broken update check
-- must never stop someone driving.
DA.checkForUpdate = function()
  if DA.upd.checked then return end
  DA.upd.checked = true
  httpGet(API_BASE .. "/api/app_version", function(ok, status, body)
    if not ok or status ~= 200 or type(body) ~= "string" then return end
    local okP, parsed = pcall(JSON.parse, body)
    if not okP or type(parsed) ~= "table" then return end
    -- Compare NUMERICALLY, part by part. A plain inequality offered "Update to v39.9"
    -- to someone already on 39.11, and a string compare is worse still: "39.9" > "39.11"
    -- lexically, so every 39.1x build would be told to downgrade.
    local v = tostring(parsed.version or "")
    local function _newer(a, b)          -- is a strictly newer than b?
      local ai, bi = {}, {}
      for x in tostring(a):gmatch("%d+") do ai[#ai+1] = tonumber(x) end
      for x in tostring(b):gmatch("%d+") do bi[#bi+1] = tonumber(x) end
      for i = 1, math.max(#ai, #bi) do
        local p, q = ai[i] or 0, bi[i] or 0
        if p ~= q then return p > q end
      end
      return false
    end
    if v ~= "" and _newer(v, DA.version) then
      DA.upd.latest = v
      DA.upd.url    = tostring(parsed.url or "")
      DA.upd.luaUrl = tostring(parsed.lua_url or "")
      -- Some releases change manifest.ini or the skin, which a .lua swap cannot deliver.
      -- The server says so and we send the user to the full zip rather than pretending.
      DA.upd.fullReinstall = (parsed.lua_only == false)
    end
  end)
end

local function browserFetchTracks()
  browser.tracksLoading=true; browser.tracksErr=nil
  httpGet(API_BASE.."/api/tracks", function(ok, status, body)
    browser.tracksLoading=false
    if not ok then browser.tracksErr="tracks: "..tostring(status).." "..tostring(body); return end
    local okP, parsed = pcall(JSON.parse, body)
    if not okP or not parsed or not parsed.tracks then browser.tracksErr="tracks: bad JSON"; return end
    browser.tracks=parsed.tracks
    if #parsed.tracks==0 then browser.tracksErr="no tracks have ghosts yet" end
  end)
end

local function browserFetchLaps(trackName)
  browser.selTrack=trackName; browser.laps=nil; browser.lapsErr=nil; browser.lapsLoading=true
  httpGet(API_BASE.."/api/leaderboard?track="..urlEncode(trackName).."&limit=50", function(ok, status, body)
    browser.lapsLoading=false
    if not ok then browser.lapsErr="laps: "..tostring(status).." "..tostring(body); return end
    local okP, parsed = pcall(JSON.parse, body)
    if not okP or not parsed or not parsed.entries then browser.lapsErr="laps: bad JSON"; return end
    browser.laps=parsed.entries
    if #parsed.entries==0 then browser.lapsErr="no laps on this track yet" end
  end)
end

local function browserAddGhost(entry)
  browser.loadingGhostId=entry.ghost_id; browser.loadMsg=nil
  httpGet(API_BASE.."/api/ghost/"..tostring(entry.ghost_id), function(ok, status, body)
    browser.loadingGhostId=nil
    if not ok then browser.loadMsg="load failed: "..tostring(status).." "..tostring(body); return end
    local label=(entry.driver or "?").."  "..(entry.lap_time_str or "?")
    local okLoad, msg = addGhostFromJSON(body, label, entry.ghost_id)
    if okLoad then
      browser.loadMsg="added: "..label.."   ("..#ghosts.."/"..MAX_GHOSTS..")  -> press PLAY"
    else
      browser.loadMsg="not added: "..tostring(msg)
    end
  end)
end

-- ============================================================================
-- v10 UI  — DualApex in-car panel  (branded, current-circuit aware)
-- ----------------------------------------------------------------------------
-- Full rewrite of the window UI only. Everything above (parse/replay/mesh/gap/
-- minimap engine + HTTP browser) is UNCHANGED. This block:
--   * shows ONLY laps for the circuit you're actually on (auto-detected from AC)
--   * clean DualApex header, a live gap readout, a clear high-contrast map,
--     and one prominent PLAY control
--   * removes the live-tuning / debug / sim-recorder clutter from the panel
-- Reliability: uses only the CSP `ui` primitives already proven on this build;
-- fonts + colored text + sized buttons degrade gracefully (each is pcall-guarded
-- and self-balances its push/pop), and every section is isolated so one failure
-- can't blank the whole window. ASCII-only strings (no glyph fonts assumed).
-- ============================================================================

local function daNowHud() local t = 0; pcall(function() t = os.clock() end); return t end

-- ---- palette (DualApex web identity) ---------------------------------------
local COL = {
  ink    = rgbm(0.918, 0.957, 0.929, 1),   -- #EAF4ED  primary text
  mut    = rgbm(0.541, 0.639, 0.588, 1),   -- #8AA396  secondary text
  green  = rgbm(0.212, 0.886, 0.478, 1),   -- #36E27A  brand / "real" line
  amber  = rgbm(1.000, 0.710, 0.290, 1),   -- #FFB54A  behind
  red    = rgbm(1.000, 0.420, 0.420, 1),   -- #FF6B6B
  cyan   = rgbm(0.300, 0.700, 0.950, 1),   -- player
  border = rgbm(0.176, 0.271, 0.216, 1),   -- panel borders
  inert  = rgbm(0.114, 0.157, 0.133, 1),   -- unselected button bg
  mapBg  = rgbm(0.035, 0.063, 0.047, 1),   -- map card fill
  track  = rgbm(0.640, 0.800, 0.710, 0.95),-- primary ghost line on map
  dark   = rgbm(0.030, 0.060, 0.040, 1),   -- text on a green button
  white  = rgbm(0.960, 0.990, 0.965, 1),
}
-- per-slot ghost tint (matches the in-world line tint + slot order)
local TINT = {
  rgbm(0.30, 0.85, 0.45, 1),   -- 1 green
  rgbm(0.97, 0.72, 0.27, 1),   -- 2 amber
  rgbm(0.34, 0.72, 0.96, 1),   -- 3 cyan
}
local PANEL_W = 300
-- working panel width for this frame: bounded to [200, PANEL_W] so the layout
-- can never overflow a pinned-narrow window nor collapse if availableSpaceX is odd.
local CURW = PANEL_W
local function panelW()
  local w = PANEL_W
  pcall(function()
    if ui.availableSpaceX then
      local a = ui.availableSpaceX()
      if type(a) == "number" and a > 120 then w = math.max(200, math.min(PANEL_W, a)) end
    end
  end)
  return w
end

-- ---- font handles (resolved once, may be nil on some builds) ----------------
local FONT_TITLE, FONT_SMALL, FONT_MONO
pcall(function() FONT_TITLE = ui.Font.Title end)
pcall(function() FONT_SMALL = ui.Font.Small end)
pcall(function() FONT_MONO  = ui.Font.Monospace end)

-- ---- bulletproof ui helpers (never leave the style/font stack unbalanced) ---
local function withFont(f, fn)
  if f == nil then pcall(fn); return end
  local pushed = false
  pcall(function() ui.pushFont(f); pushed = true end)
  pcall(fn)
  if pushed then pcall(ui.popFont) end
end

local function tcol(text, col)
  local pushed = false
  pcall(function() ui.pushStyleColor(ui.StyleColor.Text, col); pushed = true end)
  pcall(function() ui.text(text) end)
  if pushed then pcall(function() ui.popStyleColor(1) end) end
end

local function vspace(px, py) pcall(function() ui.dummy(vec2(px or 1, py or 6)) end) end
local function vsep() pcall(ui.separator) end

local function safe(fn)
  local ok = pcall(fn)
  if not ok then pcall(function() ui.text("  (panel section unavailable)") end) end
end

-- plain auto/sized button -> returns clicked
local function btnW(label, w)
  local clicked = false
  local ok = pcall(function() clicked = ui.button(label, vec2(w or CURW, 0)) end)
  if not ok then pcall(function() clicked = ui.button(label) end) end
  return clicked
end

-- colored button (bg + text) -> returns clicked; degrades to a default button
local function btnColor(label, bg, fg, w, h)
  local clicked = false
  local pushed = 0
  pcall(function()
    ui.pushStyleColor(ui.StyleColor.Button,        bg); pushed = pushed + 1
    ui.pushStyleColor(ui.StyleColor.ButtonHovered, bg); pushed = pushed + 1
    ui.pushStyleColor(ui.StyleColor.ButtonActive,  bg); pushed = pushed + 1
    ui.pushStyleColor(ui.StyleColor.Text,          fg); pushed = pushed + 1
  end)
  local ok = pcall(function() clicked = ui.button(label, vec2(w or CURW, h or 0)) end)
  if not ok then pcall(function() clicked = ui.button(label) end) end
  for _ = 1, pushed do pcall(function() ui.popStyleColor(1) end) end
  return clicked
end

-- stroke a rectangle with 4 lines (drawRect not assumed on every build)
local function strokeRect(p0, p1, col, th)
  th = th or 1
  pcall(function()
    ui.drawLine(vec2(p0.x, p0.y), vec2(p1.x, p0.y), col, th)
    ui.drawLine(vec2(p1.x, p0.y), vec2(p1.x, p1.y), col, th)
    ui.drawLine(vec2(p1.x, p1.y), vec2(p0.x, p1.y), col, th)
    ui.drawLine(vec2(p0.x, p1.y), vec2(p0.x, p0.y), col, th)
  end)
end

-- ---- car normalisation (for the [My car] filter) ---------------------------
-- lower-case, drop ks_/rt_/etc folder prefixes, keep a-z0-9. So the in-sim car
-- id "ks_bmw_z4_gt3" and the uploaded strings "BMW Z4 GT3"/"Z4 GT3"/"Z4_GT3"
-- all reduce to comparable tokens.
local function normCar(s)
  if not s then return "" end
  s = tostring(s):lower()
  s = s:gsub("^ks_", ""):gsub("^rt_", ""):gsub("^acu_", ""):gsub("^ac_", "")
  s = s:gsub("[^a-z0-9]", "")
  return s
end
local function carMatch(a, b)
  if not a or not b or #a < 3 or #b < 3 then return false end
  if a == b then return true end
  if a:find(b, 1, true) then return true end
  if b:find(a, 1, true) then return true end
  return false
end

-- ---- search box capability (text input is the least-portable ui bit) --------
local HAS_INPUT      = (type(ui) == "table") and (type(ui.inputText) == "function")
local HAS_INPUT_HINT = (type(ui) == "table") and (type(ui.inputTextWithHint) == "function")
-- returns the (possibly edited) text; order-agnostic about CSP's return shape
local function readSearch(cur)
  local out = cur or ""
  pcall(function()
    local a, b
    if HAS_INPUT_HINT then a, b = ui.inputTextWithHint("##dasearch", "Search driver", cur or "")
    else                   a, b = ui.inputText("##dasearch", cur or "") end
    if type(a) == "string" then out = a elseif type(b) == "string" then out = b end
  end)
  return out
end

-- ---- concise leaderboard fetch (one personal-best per driver, ranked) -------
-- Uses the new mode=pb endpoint. limit high (per-driver rows are cheap); the
-- list is trimmed/searched/car-filtered client-side for instant response.
local function fetchLapsConcise(trackName)
  browser.selTrack = trackName
  browser.laps = nil; browser.lapsErr = nil; browser.lapsLoading = true
  local modeQ = browser.allLaps and "" or "&mode=pb"   -- v21: All-laps view drops the one-PB-per-driver collapse
  local url = API_BASE .. "/api/leaderboard?track=" .. urlEncode(trackName) .. modeQ .. "&limit=250"
  httpGet(url, function(ok, status, body)
    browser.lapsLoading = false
    if not ok then browser.lapsErr = "laps: " .. tostring(status) .. " " .. tostring(body); return end
    local okP, parsed = pcall(JSON.parse, body)
    if not okP or not parsed or not parsed.entries then browser.lapsErr = "laps: bad JSON"; return end
    browser.laps = parsed.entries
  end)
end

-- ---- current-circuit detection ---------------------------------------------
-- (normTrack/tracksMatch moved above the ghost loader in v21 -- the circuit
--  guard in addGhostFromJSON needs them in scope.)

local function detectCurrentTrack()
  if browser.detected then return end
  local id, nm, full = "", "", ""
  pcall(function() id = ac.getTrackID() or "" end)
  pcall(function() if ac.getTrackName then nm = ac.getTrackName() or "" end end)
  pcall(function() if ac.getTrackFullID then full = ac.getTrackFullID("/") or "" end end)
  browser.curId, browser.curName, browser.curFull = id, nm, full
  browser.curIdNorm   = normTrack(id)
  browser.curNameNorm = normTrack(nm)
  browser.curFullNorm = normTrack(full)
  pcall(function() if ac.getCarID then browser.myCarNorm = normCar(ac.getCarID(0) or "") end end)
  -- only latch once we truly know the circuit (avoid caching an empty first frame)
  if #browser.curIdNorm > 0 or #browser.curNameNorm > 0 or #browser.curFullNorm > 0 then
    browser.detected = true
  end
end

-- once the track registry has loaded, work out which entries are THIS circuit
local function resolveMatches()
  if not browser.tracks then return end
  if browser.matches then return end          -- already resolved this session
  detectCurrentTrack()
  if not browser.detected then return end      -- circuit not known yet; try again next frame
  local out = {}
  for _, t in ipairs(browser.tracks) do
    local rn = normTrack(t.track)
    if tracksMatch(rn, browser.curIdNorm)
    or tracksMatch(rn, browser.curNameNorm)
    or tracksMatch(rn, browser.curFullNorm) then
      out[#out + 1] = t
    end
  end
  browser.matches = out
  -- exactly one circuit matched -> auto-load its laps so the panel is ready
  if #out == 1 and not browser.autoFetched then
    browser.autoFetched = true
    fetchLapsConcise(out[1].track)
  end
end

-- fetch registry once + run detection/resolution (cheap; all guarded by flags)
local function uiEnsureData()
  if not browser.tracks and not browser.tracksLoading and not browser.tracksErr then
    browserFetchTracks()
  end
  detectCurrentTrack()
  resolveMatches()
end

local function lapIsLoaded(gid)
  for i = 1, #ghosts do if ghosts[i].gid == gid then return true end end
  return false
end

-- =====================================================================
-- SECTION: header  (mark + wordmark + circuit)
-- =====================================================================
local function uiHeader()
  local p0 = ui.getCursor()
  -- v38 #5: the wordmark is drawn as TEXT, not loaded from a file. The image route needed a
  -- transparent, correctly-proportioned PNG and kept rendering as a white box; text always
  -- looks right, scales with the UI and needs no asset. "Dual" in ink, "Apex" in apex-green,
  -- italic where the build supports it, matching the site wordmark.
  do
    local italic = false
    pcall(function() ui.pushDWriteFont("Segoe UI;Weight=Bold;Italic=1"); italic = true end)
    if not italic then
      withFont(FONT_TITLE, function()
        tcol("Dual ", COL.ink); ui.sameLine(0, 0); tcol("Apex", COL.green)
      end)
    else
      tcol("Dual ", COL.ink); ui.sameLine(0, 0); tcol("Apex", COL.green)
      pcall(function() ui.popDWriteFont() end)
    end
  end
  -- green accent rule under the wordmark
  local r = ui.getCursor()
  pcall(function() ui.drawLine(vec2(r.x, r.y + 2), vec2(r.x + CURW, r.y + 2), COL.green, 2) end)
  vspace(1, 7)
  -- detected circuit
  local label = "(detecting...)"
  if browser.curName and #browser.curName > 0 then label = browser.curName
  elseif browser.curId and #browser.curId > 0 then label = browser.curId end
  withFont(FONT_SMALL, function() tcol("CIRCUIT", COL.mut) end)
  ui.sameLine(0, 8)
  tcol(label, COL.ink)
end

-- =====================================================================
-- SECTION: loaded ghosts + live gap
-- =====================================================================
local function fmtGap(g)
  if not pCrossed or not g.gapValid then return "-", COL.mut end
  local v = g.gap or 0
  if v >  0.05 then return string.format("+%.2fs", v), COL.amber end
  if v < -0.05 then return string.format("%.2fs",  v), COL.green end
  return "even", COL.mut
end

local function uiGhosts()
  withFont(FONT_SMALL, function()
    tcol(string.format("GHOSTS   %d / %d", #ghosts, MAX_GHOSTS), COL.mut)
  end)
  if #ghosts == 0 then
    tcol("No ghost loaded - pick a lap below.", COL.ink)
    return
  end
  -- order: fastest ghost relative to you first (largest +gap) once the clock runs
  local order = {}
  for i = 1, #ghosts do order[#order + 1] = i end
  if pCrossed then
    table.sort(order, function(a, b)
      local ga, gb = ghosts[a], ghosts[b]
      if ga.gapValid ~= gb.gapValid then return ga.gapValid end
      return (ga.gap or 0) > (gb.gap or 0)
    end)
  end
  for _, i in ipairs(order) do
    local g    = ghosts[i]
    local tint = TINT[i] or COL.mut
    local cp   = ui.getCursor()
    pcall(function() ui.drawRectFilled(vec2(cp.x, cp.y + 3), vec2(cp.x + 10, cp.y + 13), tint) end)
    ui.dummy(vec2(14, 15)); ui.sameLine(0, 4)
    tcol(tostring(g.label or ("Ghost " .. i)), COL.ink)
    ui.sameLine(0, 8)
    local gtxt, gcolr = fmtGap(g)
    withFont(FONT_MONO, function() tcol(gtxt, gcolr) end)
    if daGhostBTG then
      local b = daGhostBTG(g)
      if b then
        ui.sameLine(0, 8)
        withFont(FONT_SMALL, function() tcol("BTG " .. fmtTime(b), COL.mut) end)
      end
    end
    ui.sameLine(0, 8)
    if ui.button("x##rm" .. i) then removeGhost(i); break end
  end
  if not pCrossed then
    withFont(FONT_SMALL, function()
      tcol((mode == "GATEA") and "Cross gate A to start the gap clock."
                              or "Cross start/finish to start the gap clock.", COL.mut)
    end)
  end
  -- v34: live lap-validity readout (AC's own flag, verbatim)
  withFont(FONT_SMALL, function()
    if daValidRaw == false then
      tcol("this lap: INVALIDATED by AC"
        .. (daBadAtPct and (" at " .. daBadAtPct .. "%") or "")
        .. "  [" .. daValidField .. "]", COL.amber)
    elseif daValidRaw == true then
      tcol("this lap: valid  [" .. daValidField .. "]", COL.mut)
    else
      tcol("this lap: validity unknown  [" .. daValidField .. "]", COL.mut)
    end
  end)
end

-- =====================================================================
-- SECTION: delta HUD  (v21 - the number that matters, big and colour-coded)
-- =====================================================================
local deltaTrend = { at=0, gap=nil, dir=0 }   -- dir: -1 closing, +1 losing, 0 flat
local function targetSlot()
  local best, slot = nil, nil
  for i = 1, #ghosts do
    local g = ghosts[i]
    if g and g.samples then
      local d = g.duration or 1e9
      if best == nil or d < best then best = d; slot = i end
    end
  end
  return slot
end
local function uiDelta()
  if #ghosts == 0 or not playing then return end
  local si = targetSlot(); if not si then return end
  local g = ghosts[si]
  withFont(FONT_SMALL, function()
    tcol("DELTA", COL.mut); ui.sameLine(0, 8)
    tcol("vs " .. tostring(g.label or "ghost"), COL.mut)
  end)
  if not pCrossed or not g.gapValid then
    withFont(FONT_SMALL, function() tcol("cross start/finish to arm the clock", COL.mut) end)
    vspace(1, 2); return
  end
  local v = g.gap or 0
  -- refresh the trend once a second: closing (gap shrinking) or losing (growing)
  local now = daNowHud()
  if deltaTrend.gap == nil then deltaTrend.gap = v; deltaTrend.at = now end
  if (now - deltaTrend.at) >= 1.0 then
    local dg = v - deltaTrend.gap
    if     dg < -0.03 then deltaTrend.dir = -1
    elseif dg >  0.03 then deltaTrend.dir =  1
    else                   deltaTrend.dir =  0 end
    deltaTrend.gap = v; deltaTrend.at = now
  end
  local txt, col
  if     v >  0.05 then txt = string.format("+%.2f", v); col = COL.amber
  elseif v < -0.05 then txt = string.format("%.2f",  v); col = COL.green
  else                  txt = "0.00";                    col = COL.ink end
  withFont(FONT_TITLE, function() tcol(txt .. " s", col) end)
  ui.sameLine(0, 10)
  if     deltaTrend.dir < 0 then withFont(FONT_SMALL, function() tcol("closing", COL.green) end)
  elseif deltaTrend.dir > 0 then withFont(FONT_SMALL, function() tcol("losing",  COL.amber) end)
  else                           withFont(FONT_SMALL, function() tcol("holding", COL.mut)   end) end
  vspace(1, 2)
end

-- =====================================================================
-- SECTION: segment timing  (v23 - BTG: community gate-to-gate times)
-- Gates are DEFINED IN-GAME by the driver (Mark A at the bridge, Mark B at
-- the gate), persisted to segments.json next to the app, matched to the
-- circuit. The app then times BOTH the player and every ghost through them.
-- =====================================================================
local SEG_FILE = APP_DIR .. "segments.json"
local seg = {
  def=nil, showSetup=false, capA=nil, capB=nil, saveMsg=nil,
  clock=0, prevA=nil, prevB=nil, aClock=nil, cool=0,
  lastResult=nil, ghostCache={},
}

local function segSer(d)
  return string.format(
    '{"name":"%s","track":"%s","ax":%.3f,"az":%.3f,"adx":%.5f,"adz":%.5f,"bx":%.3f,"bz":%.3f,"bdx":%.5f,"bdz":%.5f}',
    d.name or "BTG", d.track or "", d.ax,d.az,d.adx,d.adz, d.bx,d.bz,d.bdx,d.bdz)
end

local function segLoad()
  local ok, raw = pcall(io.load, SEG_FILE)
  if not ok or type(raw)~="string" or #raw==0 then return end
  local okP, d = pcall(JSON.parse, raw)
  if okP and d and d.ax and d.az and d.bx and d.bz and d.adx then seg.def = d end
end
segLoad()

local function segActive()
  if not seg.def then return false end
  local st = normTrack(seg.def.track or "")
  local cur = (browser and (browser.curNameNorm or browser.curIdNorm)) or ""
  if #st < 3 or #cur < 3 then return true end
  if tracksMatch(st, cur) then return true end
  local n = math.min(#st, #cur, 8)
  return st:sub(1, n) == cur:sub(1, n)
end

local function gateS(px, pz, gx, gz, gdx, gdz)
  return (px-gx)*gdx + (pz-gz)*gdz
end
local function gateLat(px, pz, gx, gz, gdx, gdz)
  return math.abs(-(px-gx)*gdz + (pz-gz)*gdx)
end

-- v26: wrap-aware gate crossing on a CLOSED lap. The old scan needed a
-- negative->positive plane transition inside one linear pass, so a ghost that
-- STARTS on gate A's plane (s/f-anchored ghosts + a gate marked at the line)
-- never "crossed". Two passes with wrapped timestamps catch seam crossings;
-- nearest-approach (<30m) is the fallback for odd geometry.
local function gateCrossT(S, dur, gx, gz, gdx, gdz, fromT)
  local n = #S
  local pv, pt
  for vi = 1, 2 * n do
    local i = ((vi - 1) % n) + 1
    local s = S[i]
    local t = s.t + (vi > n and dur or 0)
    local sv = gateS(s.x, s.z, gx, gz, gdx, gdz)
    if pv ~= nil and pv < 0 and sv >= 0
       and gateLat(s.x, s.z, gx, gz, gdx, gdz) < 30 then
      local f = pv / (pv - sv)
      local tc = pt + f * (t - pt)
      if fromT == nil or tc > fromT + 1.0 then return tc end
    end
    pv, pt = sv, t
  end
  local bd, bi
  for i = 1, n do
    local dx, dz = S[i].x - gx, S[i].z - gz
    local dd = dx * dx + dz * dz
    if bd == nil or dd < bd then bd, bi = dd, i end
  end
  if bd ~= nil and bd < 900 then
    local tc = S[bi].t
    if fromT ~= nil and tc <= fromT + 1.0 then tc = tc + dur end
    if fromT == nil or tc > fromT + 1.0 then return tc end
  end
  return nil
end

local function gateMissM(S, gx, gz)
  local bd
  for i = 1, #S do
    local dx, dz = S[i].x - gx, S[i].z - gz
    local dd = dx * dx + dz * dz
    if bd == nil or dd < bd then bd = dd end
  end
  return bd and math.sqrt(bd) or 1e9
end

-- ghost BTG from its samples (cached per slot label+gid); on failure caches a
-- diagnostic string instead of a number
local function ghostBTG(g)
  if not seg.def or not g or not g.samples then return nil end
  local key = tostring(g.gid or "") .. "|" .. tostring(g.label or "")
  local c = seg.ghostCache[key]
  if c ~= nil then
    if type(c) == "number" then return c end
    return nil, c ~= false and c or nil
  end
  local d = seg.def
  local S = g.samples
  local dur = g.duration or (S[#S].t or 0)
  local tA = gateCrossT(S, dur, d.ax, d.az, d.adx, d.adz)
  local tB = tA and gateCrossT(S, dur, d.bx, d.bz, d.bdx, d.bdz, tA)
  if tA and tB and tB > tA + 1.0 and (tB - tA) < dur * 1.2 then
    local out = tB - tA
    seg.ghostCache[key] = out
    return out
  end
  local mA = gateMissM(S, d.ax, d.az)
  local mB = gateMissM(S, d.bx, d.bz)
  local why
  if mA > 30 then why = string.format("gate A is %dm off the ghost path", mA)
  elseif mB > 30 then why = string.format("gate B is %dm off the ghost path", mB)
  else why = "gates on path but no valid A->B order" end
  seg.ghostCache[key] = why
  return nil, why
end
daGhostBTG = ghostBTG
daSegDef = function() if seg.def ~= nil and segActive() then return seg.def end return nil end

-- v27: ghost's own gate-A crossing time (for gate-A-synced launch)
local function ghostGateA(g)
  if not seg.def or not g or not g.samples then return nil end
  local key = "A|" .. tostring(g.gid or "") .. "|" .. tostring(g.label or "")
  local c = seg.ghostCache[key]
  if c ~= nil then if type(c) == "number" then return c end return nil end
  local d = seg.def
  local dur = g.duration or (g.samples[#g.samples].t or 0)
  local tA = gateCrossT(g.samples, dur, d.ax, d.az, d.adx, d.adz)
  if tA then tA = tA % math.max(dur, 1e-6) end
  seg.ghostCache[key] = tA or "no A crossing"
  return tA
end

daSegUpdate = function(dt)
  if not seg.def or not segActive() then return end
  if not (dt and dt > 0) then return end
  local car = ac.getCar(0)
  if not car or not car.position then return end
  if isPausedNow() then return end
  -- v35 #5: pitting mid-segment abandons the BTG attempt (can't set a BTG through the pits)
  if car.isInPitlane == true then seg.aClock = nil end
  seg.clock = seg.clock + dt
  seg.cool = math.max(0, seg.cool - dt)
  local d = seg.def
  local px, pz = car.position.x, car.position.z
  local sA = gateS(px, pz, d.ax, d.az, d.adx, d.adz)
  local sB = gateS(px, pz, d.bx, d.bz, d.bdx, d.bdz)
  if seg.prevA ~= nil and seg.cool <= 0
     and seg.prevA < 0 and sA >= 0
     and gateLat(px, pz, d.ax, d.az, d.adx, d.adz) < 30 then
    local f = seg.prevA / (seg.prevA - sA)
    seg.aClock = (seg.clock - dt) + f * dt
    seg.cool = 10
    -- v27: gate-A launch - the ghost materialises AT the bridge with you and
    -- re-syncs there every lap (BTG hotlap convention)
    if mode == "GATEA" then
      local si = targetSlot()
      local tA0 = si and ghostGateA(ghosts[si]) or nil
      if armed and not playing then startPlaying() end
      if tA0 and (playing or armed) then replayT = tA0 end
      -- v39.15: the clock is shared, so each ghost carries its own offset from
      -- the reference slot. Without this only the target slot sat at the bridge
      -- and the others started as far into their own laps as their gate times
      -- differ -- half a minute for a slow lap, kilometres on this circuit.
      if tA0 then
        for gi4 = 1, #ghosts do
          local tAg = ghostGateA(ghosts[gi4])
          ghosts[gi4].gaOff = tAg and (tAg - tA0) or 0
        end
      end
      armed = false
      -- v28: in BTG mode the GAP clock anchors at gate A too (not AC's s/f)
      playerT = 0; pCrossed = true
      for gi3 = 1, #ghosts do ghosts[gi3].nIdx = 1 end
    end
  end
  if seg.aClock and seg.prevB ~= nil and seg.cool <= 0
     and seg.prevB < 0 and sB >= 0
     and gateLat(px, pz, d.bx, d.bz, d.bdx, d.bdz) < 30 then
    local f = seg.prevB / (seg.prevB - sB)
    local tB = (seg.clock - dt) + f * dt
    local you = tB - seg.aClock
    local gb = nil
    local si = targetSlot()
    if si then gb = ghostBTG(ghosts[si]) end
    seg.lastResult = { you = you, ghost = gb,
                       glabel = si and tostring(ghosts[si].label or "ghost") or nil }
    seg.aClock = nil
    seg.cool = 10
  end
  seg.prevA = sA; seg.prevB = sB
end

local function uiSegment()
  -- v29: gates for this circuit -> BTG is the default launch frame (one-shot)
  if not seg.modeDefaulted and seg.def ~= nil and segActive() then
    seg.modeDefaulted = true
    if mode == "SFSYNC" and not playing and not armed then mode = "GATEA" end
  end
  local d = seg.def
  local active = d ~= nil and segActive()
  -- header row: label + setup toggle
  withFont(FONT_SMALL, function()
    tcol((d and (d.name or "BTG") or "SEGMENT") .. " TIMING", COL.mut)
    ui.sameLine(0, 8)
    if btnW(seg.showSetup and "close setup" or "gates", 92) then seg.showSetup = not seg.showSetup end
  end)
  if active then
    local si = targetSlot()
    if si then
      local gb, why = ghostBTG(ghosts[si])
      withFont(FONT_SMALL, function()
        if gb then tcol(string.format("ghost  %s", fmtTime(gb)), COL.mut)
        else tcol(why or "ghost lap does not cross both gates", COL.mut) end
      end)
    end
    if seg.aClock then
      withFont(FONT_MONO, function()
        tcol(string.format("running  %s", fmtTime(seg.clock - seg.aClock)), COL.ink)
      end)
    elseif seg.lastResult then
      local r = seg.lastResult
      withFont(FONT_MONO, function()
        if r.ghost then
          local dl = r.you - r.ghost
          local col = (dl <= 0) and COL.green or COL.amber
          tcol(string.format("you %s   %+0.2fs vs %s", fmtTime(r.you), dl, r.glabel or "ghost"), col)
        else
          tcol(string.format("you %s", fmtTime(r.you)), COL.ink)
        end
      end)
    else
      withFont(FONT_SMALL, function() tcol("cross gate A to start the clock", COL.mut) end)
    end
  elseif d then
    withFont(FONT_SMALL, function() tcol("gates saved for another circuit", COL.mut) end)
  end
  if seg.showSetup then
    withFont(FONT_SMALL, function()
      tcol("drive to each landmark, mark it, save. 30m gate width, one-time setup.", COL.mut)
    end)
    local car = ac.getCar(0)
    local function mark()
      if not car or not car.position then return nil end
      local lk = car.look
      local dx, dz = (lk and lk.x or 0), (lk and lk.z or 0)
      local n = math.sqrt(dx*dx + dz*dz)
      if n < 1e-6 then dx, dz, n = 0, 1, 1 end
      return { x = car.position.x, z = car.position.z, dx = dx/n, dz = dz/n }
    end
    if btnW("Mark A @ car", 110) then seg.capA = mark(); seg.saveMsg = nil end
    ui.sameLine(0, 4)
    if btnW("Mark B @ car", 110) then seg.capB = mark(); seg.saveMsg = nil end
    ui.sameLine(0, 4)
    if btnW("Save", 60) then
      if seg.capA and seg.capB then
        local nd = { name = "BTG",
          track = (browser and (browser.curNameNorm or browser.curIdNorm)) or "",
          ax = seg.capA.x, az = seg.capA.z, adx = seg.capA.dx, adz = seg.capA.dz,
          bx = seg.capB.x, bz = seg.capB.z, bdx = seg.capB.dx, bdz = seg.capB.dz }
        local ok = pcall(function() io.save(SEG_FILE, segSer(nd)) end)
        if ok then
          seg.def = nd; seg.ghostCache = {}; seg.lastResult = nil; seg.aClock = nil
          seg.saveMsg = "saved to segments.json"
        else
          seg.saveMsg = "io.save FAILED (folder write access?)"
        end
      else
        seg.saveMsg = "mark BOTH gates first"
      end
    end
    ui.sameLine(0, 4)
    if btnW("Clear", 60) then
      seg.capA = nil; seg.capB = nil; seg.def = nil; seg.ghostCache = {}
      seg.lastResult = nil; seg.aClock = nil
      pcall(function() io.save(SEG_FILE, "") end)
      seg.saveMsg = "cleared"
    end
    withFont(FONT_SMALL, function()
      tcol(string.format("A: %s   B: %s   %s",
        seg.capA and "set" or (seg.def and "saved" or "-"),
        seg.capB and "set" or (seg.def and "saved" or "-"),
        seg.saveMsg or ""), COL.mut)
    end)
    -- v25: one-off helper for the DualApex livery authoring workflow
    if btnW("Dump body materials", 150) then
      local okD, props = pcall(function() return ac.collectKN5MaterialProperties(CAR_KN5, "?") end)
      if okD and props ~= nil then
        local okS = pcall(function() io.save(APP_DIR .. "materials_dump.txt", tostring(props)) end)
        seg.saveMsg = okS and "materials_dump.txt written - send it over" or "dump save FAILED"
      else
        seg.saveMsg = "material dump API unavailable on this CSP"
      end
    end
  end
  vspace(1, 2)
end

-- =====================================================================
-- SECTION: lap picker (current circuit only, with escape hatch)
-- =====================================================================
local LIST_CAP = 12   -- rows shown before "+N more" (search reveals the rest)

local function nameHit(driver, needle)
  if not needle or #needle == 0 then return true end
  return (tostring(driver):lower():find(needle:lower(), 1, true)) ~= nil
end

-- ranked, one-PB-per-driver list with gap-to-leader. Client-side car + name
-- filters; caps to LIST_CAP unless searching.
local function drawConciseList()
  local all = browser.laps or {}
  local filtered = {}
  for _, e in ipairs(all) do
    local carOK = true
    if browser.carOnly and browser.myCarNorm and #browser.myCarNorm > 0 then
      carOK = carMatch(normCar(e.car or ""), browser.myCarNorm)
    end
    if carOK and nameHit(e.driver or "", browser.search) then
      filtered[#filtered + 1] = e
    end
  end

  if #filtered == 0 then
    local msg = "No laps yet."
    if browser.search and #browser.search > 0 then msg = "No driver matches \"" .. browser.search .. "\"."
    elseif browser.carOnly then msg = "No laps in your car here yet." end
    withFont(FONT_SMALL, function() tcol("  " .. msg, COL.mut) end)
    return
  end

  local leader    = filtered[1].lap_time or 0
  local searching = (browser.search and #browser.search > 0)
  local cap       = searching and 25 or LIST_CAP
  local full      = (#ghosts >= MAX_GHOSTS)
  local shown     = 0
  -- v35.2 #1: the board is in BTG mode if ANY entry has a BTG (gated track) -> one layout for all
  local btg_mode = false
  for _, ee in ipairs(filtered) do if ee.btg_str then btg_mode = true; break end end
  for i, e in ipairs(filtered) do
    if shown >= cap then break end
    shown = shown + 1
    -- v28: every row shows its LAP TIME and the delta to the fastest
    -- v35.2 #1: ONE consistent format for every row + room for full names (HurryUpShrimp
    -- = 13 chars, fits in 14) so nothing crops. On gated tracks BTG is the hero on the LEFT
    -- and the full lap sits on the RIGHT; the car is dropped from the row (there's an
    -- All cars / My car filter right above the list).
    local dname = tostring(e.driver or "?")
    -- v39.6: the car is back on the row. It was dropped in v35.2 purely for width, but
    -- "who drove what" is most of the point on a board mixing real cars. Driver trimmed
    -- 14 -> 12 to pay for part of it; BTG mode is left alone because that row already
    -- carries four columns and would wrap.
    if #dname > 12 then dname = dname:sub(1, 12) end
    local ecar = tostring(e.car or "")
    if ecar == "" or ecar == "Unknown" then ecar = "-" end
    if #ecar > 14 then ecar = ecar:sub(1, 14) end
    local row
    if btg_mode then
      if #dname > 14 then dname = dname:sub(1, 14) end
      row = string.format("P%-2d %-9s %-14s %s",
        i, tostring(e.btg_str or "--:--"), dname, tostring(e.lap_time_str or ""))
    else
      local cell
      if i == 1 then cell = string.format("%-9s %-7s", tostring(e.lap_time_str or "?"), "-")
      else cell = string.format("%-9s +%.3f", tostring(e.lap_time_str or "?"),
                                (e.lap_time or 0) - leader) end
      row = string.format("P%-2d %s %-12s %s", i, cell, dname, ecar)
    end
    local loaded = lapIsLoaded(e.ghost_id)
    local busy   = (browser.loadingGhostId == e.ghost_id)
    if busy then
      withFont(FONT_MONO, function() tcol("  .. " .. row, COL.mut) end)
    elseif loaded then
      withFont(FONT_MONO, function() tcol("  ok " .. row, COL.green) end)
    elseif full then
      withFont(FONT_MONO, function() tcol("  -- " .. row, COL.mut) end)
    else
      local clicked = false
      withFont(FONT_MONO, function() clicked = btnW(row .. "##lap" .. tostring(e.ghost_id)) end)
      if clicked then browserAddGhost(e) end
    end
  end
  if (not searching) and #filtered > shown then
    withFont(FONT_SMALL, function()
      tcol(string.format("  +%d more - type a name to find them", #filtered - shown), COL.mut)
    end)
  end
end

-- search field + [All cars]/[My car] toggle + the ranked list. Shared by the
-- single-match, multi-match and browse-all branches.
local function uiFilterBarAndList()
  if HAS_INPUT or HAS_INPUT_HINT then
    pcall(function() ui.pushItemWidth(math.max(120, CURW - 86)) end)
    local nv = readSearch(browser.search or "")
    pcall(function() ui.popItemWidth() end)
    if nv ~= (browser.search or "") then browser.search = nv end
    ui.sameLine(0, 6)
    if browser.search and #browser.search > 0 then
      if ui.button("clear##das") then browser.search = "" end
    else
      withFont(FONT_SMALL, function() tcol("find a driver", COL.mut) end)
    end
  end
  if browser.myCarNorm and #browser.myCarNorm > 0 then
    local allSel = not browser.carOnly
    if btnColor("All cars", allSel and COL.green or COL.inert, allSel and COL.dark or COL.mut, 92, 0) then
      browser.carOnly = false
    end
    ui.sameLine(0, 4)
    local mySel = browser.carOnly == true
    if btnColor("My car", mySel and COL.green or COL.inert, mySel and COL.dark or COL.mut, 92, 0) then
      browser.carOnly = true
    end
  end
  -- v21: PB-per-driver vs every-lap toggle. "All laps" is how you load 2-3 of
  -- YOUR OWN laps (the PB view collapses each driver to one row, so a solo
  -- driver could never fill the 3 ghost slots).
  local pbSel = not browser.allLaps
  if btnColor("Best per driver", pbSel and COL.green or COL.inert, pbSel and COL.dark or COL.mut, 132, 0) then
    if browser.allLaps then browser.allLaps = false; if browser.selTrack then fetchLapsConcise(browser.selTrack) end end
  end
  ui.sameLine(0, 4)
  local alSel = browser.allLaps == true
  if btnColor("All laps", alSel and COL.green or COL.inert, alSel and COL.dark or COL.mut, 92, 0) then
    if not browser.allLaps then browser.allLaps = true; if browser.selTrack then fetchLapsConcise(browser.selTrack) end end
  end
  vspace(1, 2)
  if browser.lapsLoading then
    tcol("  loading laps...", COL.mut)
  elseif browser.lapsErr then
    withFont(FONT_SMALL, function() tcol("  " .. tostring(browser.lapsErr), COL.mut) end)
  elseif browser.laps then
    drawConciseList()
  end
  if browser.loadMsg then
    withFont(FONT_SMALL, function() tcol("  " .. tostring(browser.loadMsg), COL.mut) end)
  end
end

local function uiPicker()
  if #ghosts >= MAX_GHOSTS then
    withFont(FONT_SMALL, function() tcol("All 3 ghost slots are full.", COL.mut) end)
    return
  end

  if browser.tracksLoading then tcol("Finding laps for this circuit...", COL.mut); return end
  if browser.tracksErr then
    tcol("Can't reach the DualApex server.", COL.amber)
    withFont(FONT_SMALL, function() tcol(tostring(browser.tracksErr), COL.mut) end)
    if btnW("Retry") then
      browser.tracksErr = nil; browser.tracks = nil; browser.matches = nil
      browser.selTrack = nil; browser.laps = nil; browser.autoFetched = false
      browserFetchTracks()
    end
    return
  end
  if not browser.tracks then tcol("Loading...", COL.mut); return end

  resolveMatches()
  local m = browser.matches or {}

  -- escape hatch: browse every circuit
  if browser.showAll then
    withFont(FONT_SMALL, function() tcol("ALL CIRCUITS", COL.mut) end)
    for _, t in ipairs(browser.tracks) do
      local sel = (browser.selTrack == t.track)
      if btnW(((sel and "> ") or "") .. string.format("%s   (%d)", tostring(t.track), t.laps or 0)
              .. "##all" .. tostring(t.track)) then
        fetchLapsConcise(t.track)
      end
    end
    if browser.selTrack then
      vspace(1, 4)
      withFont(FONT_SMALL, function() tcol("LEADERBOARD - " .. browser.selTrack, COL.mut) end)
      uiFilterBarAndList()
    end
    vspace(1, 4)
    if btnW("Back to this circuit") then browser.showAll = false end
    return
  end

  -- no laps for the circuit we're on
  if #m == 0 then
    tcol("No laps on this circuit yet.", COL.ink)
    withFont(FONT_SMALL, function()
      tcol("Upload a lap on the DualApex site to", COL.mut)
      tcol("race it here as a ghost.", COL.mut)
    end)
    return
  end

  -- more than one layout matched (co-located 'Ring configs etc.)
  if #m > 1 then
    withFont(FONT_SMALL, function() tcol("THIS CIRCUIT - pick a layout", COL.mut) end)
    for _, t in ipairs(m) do
      local sel = (browser.selTrack == t.track)
      if btnW(((sel and "> ") or "") .. string.format("%s   (%d)", tostring(t.track), t.laps or 0)
              .. "##lay" .. tostring(t.track)) then
        fetchLapsConcise(t.track)
      end
    end
    vspace(1, 2)
  else
    withFont(FONT_SMALL, function() tcol("LEADERBOARD - " .. m[1].track, COL.mut) end)
    if browser.selTrack ~= m[1].track and not browser.lapsLoading then
      fetchLapsConcise(m[1].track)
    end
  end

  uiFilterBarAndList()
end

-- =====================================================================
-- SECTION: map  (high-contrast track + you + ghosts)
-- =====================================================================
local function uiMap()
  if #ghosts == 0 then
    withFont(FONT_SMALL, function() tcol("MAP - load a ghost to see the track.", COL.mut) end)
    return
  end
  withFont(FONT_SMALL, function() tcol("MAP", COL.mut) end)

  if not gmap.ready then computeMapBounds() end
  local W, H, pad = CURW, 210, 12   -- v39.4 #3: taller map panel
  local p0 = ui.getCursor()
  ui.dummy(vec2(W, H))                 -- reserve the card's footprint
  local p1 = vec2(p0.x + W, p0.y + H)
  pcall(function() ui.drawRectFilled(p0, p1, COL.mapBg) end)
  strokeRect(p0, p1, COL.border, 1)

  -- ghost path(s): primary bright, others a faint tint
  for gi = 1, #ghosts do
    local g, s = ghosts[gi], ghosts[gi].samples
    local lineCol = (gi == 1) and COL.track or rgbm(TINT[gi].r, TINT[gi].g, TINT[gi].b, 0.45)
    local th = (gi == 1) and 2 or 1
    if s then
      for i = 1, g.n - 1, 2 do
        pcall(function()
          ui.drawLine(mapPt(s[i].x, s[i].z, p0, W, H, pad),
                      mapPt(s[i + 1].x, s[i + 1].z, p0, W, H, pad), lineCol, th)
        end)
      end
    end
  end

  -- start/finish ring (primary ghost, first sample)
  pcall(function()
    local s = ghosts[1].samples
    if s then
      local sfCol = (mode == "GATEA") and COL.mut or COL.ink
      ui.drawCircle(mapPt(s[1].x, s[1].z, p0, W, H, pad), 4, sfCol, 12, 2)
    end
    -- v30: BTG gates on the map (A = ring, B = small dot), when defined for this circuit
    if daSegDef and daSegDef() then
      local dd = daSegDef()
      pcall(function()
        ui.drawCircle(mapPt(dd.ax, dd.az, p0, W, H, pad), 5, COL.green, 14, 2)
        ui.drawCircleFilled(mapPt(dd.bx, dd.bz, p0, W, H, pad), 3, COL.green)
      end)
    end
  end)

  -- live ghost dots
  if playing then
    for gi = 1, #ghosts do
      local g, s = ghosts[gi], ghosts[gi].samples
      if s then
        local el = replayT + (g.gaOff or 0)
        if g.duration and g.duration > 0 then el = el % g.duration end
        local gx, _, gz = posAt(g, el)
        pcall(function() ui.drawCircleFilled(mapPt(gx, gz, p0, W, H, pad), 4, TINT[gi] or COL.mut) end)
      end
    end
  end

  -- player dot (bright core + ring so it reads against the ghost dots)
  pcall(function()
    local car = ac.getCar(0)
    if car and car.position then
      local pp = mapPt(car.position.x, car.position.z, p0, W, H, pad)
      ui.drawCircleFilled(pp, 4.5, COL.white)
      ui.drawCircle(pp, 6.5, COL.cyan, 14, 2)
    end
  end)

  -- legend (cursor is already below the reserved card after the dummy)
  withFont(FONT_SMALL, function()
    local l1 = ui.getCursor()
    pcall(function() ui.drawCircleFilled(vec2(l1.x + 4, l1.y + 8), 4, COL.white) end)
    ui.dummy(vec2(11, 15)); ui.sameLine(0, 2); tcol("you", COL.mut)
    ui.sameLine(0, 12)
    local l2 = ui.getCursor()
    pcall(function() ui.drawRectFilled(vec2(l2.x, l2.y + 4), vec2(l2.x + 10, l2.y + 12), COL.track) end)
    ui.dummy(vec2(12, 15)); ui.sameLine(0, 2); tcol("racing line", COL.mut)
  end)
end

-- =====================================================================
-- SECTION: transport  (PLAY / STOP / progress / launch mode)
-- =====================================================================
-- v19: snap the (primary) ghost to the player's CURRENT track position and play
-- on from there. Use after a crash to study the line through the next section
-- without redriving the whole lap. Reuses nearestIdxForGhost (geometric nearest).
local SNAP_LEAD_S = 0.0     -- seconds to place the ghost AHEAD of you (0 = side-by-side)
local snapMsg = nil
function snapGhostToMe()
  local g = primary()
  if not g or not g.samples or (g.n or 0) < 2 then snapMsg = "no ghost loaded"; return end
  local car = ac.getCar(0)
  if not car or not car.position then snapMsg = "no car position"; return end
  if not playing then startPlaying() end                 -- make it visible/playing first
  local idx = nearestIdxForGhost(g, car.position.x, car.position.z)
  if not idx then snapMsg = "no nearby point on the ghost line"; return end
  local t = g.samples[idx].t + SNAP_LEAD_S
  if g.duration and g.duration > 0 then t = t % g.duration end
  replayT = t                 -- shared replay clock -> primary ghost jumps to your spot
  g.displayHeading = nil       -- re-derive heading instantly (no swing on the jump)
  g.lastDrawX, g.lastDrawZ = nil, nil   -- and forget where it was last drawn
  g.nIdx = idx                 -- keep the gap-search seed consistent
  pCrossed = false             -- v29: a teleport invalidates the gap frame; re-anchor at the next s/f or gate A
  snapMsg = "snapped to your position (gap re-arms at the next line)"
end

local function uiTransport()
  if #ghosts == 0 then
    withFont(FONT_SMALL, function() tcol("Load a ghost above, then press PLAY.", COL.mut) end)
    return
  end

  if mode == "ABSOLUTE" then
    local label = playing and "RESTART" or "PLAY GHOST"
    withFont(FONT_TITLE, function()
      if btnColor(label, COL.green, COL.dark, CURW, 30) then startPlaying() end
    end)
  else
    if not playing then
      local isGA = (mode == "GATEA")
      local label = armed and (isGA and "ARMED - CROSS GATE A" or "ARMED - CROSS START/FINISH")
                          or (isGA and "ARM GATE-A SYNC" or "ARM SF-SYNC")
      local bg    = armed and COL.amber or COL.green
      if btnColor(label, bg, COL.dark, CURW, 28) then
        armed = true
        -- v38.1 B: load the ghost body NOW, while the car is slow or stopped. loadKN5 is
        -- synchronous and takes ~1s; it used to run at the moment the ghost STARTED, which
        -- in BTG mode is the gate-A crossing at full speed - a frame stall right there can
        -- put the player into the barriers.
        for _i = 1, #ghosts do
          local _g = ghosts[_i]
          if not _g.meshTried then pcall(loadMeshForGhost, _g, _i) end
        end
      end
    else
      if btnColor("PLAYING - press to re-arm", COL.green, COL.dark, CURW, 28) then
        playing = false; hideAllMeshes(); armed = true; seg.aClock = nil  -- v35 #5: re-arm resets the BTG running clock
        for _i = 1, #ghosts do                                   -- v38.1 B: keep meshes warm
          local _g = ghosts[_i]
          if not _g.meshTried then pcall(loadMeshForGhost, _g, _i) end
        end
      end
    end
  end

  if playing then
    -- v39.13 B: STOP is the definitive stop -- it ends playback AND clears the
    -- ghosts, so stopping is one press instead of STOP then the per-ghost x.
    -- Soft stop lives in the re-arm button directly above.
    -- The clear helper already sets playing=false.
    if btnW("STOP", CURW) then clearAllGhosts(); hideAllMeshes(); seg.aClock = nil end  -- v35 #5: STOP stops the BTG running clock
    -- v39.14 slot body status: one line per ghost slot, shown whether or not the
    -- model loaded. The block below is gated on a successful load, so a slot that
    -- fell back to wireframe never reported its reason -- hidden by the failure it
    -- was meant to explain. Amber when the body did not load. Diagnostic only.
    if DA.diag then
      for _si = 1, #ghosts do
        local _sg = ghosts[_si]
        if _sg then
          local _stxt = tostring(_sg.meshStatus or "not attempted")
          local _scol = _sg.meshOk and COL.mut or COL.amber
          withFont(FONT_SMALL, function()
            tcol("  slot " .. _si .. " (" .. tostring(_sg.car or "?") .. ") - " .. _stxt, _scol)
          end)
        end
      end
    end
    -- v22: wheel-spin status for the primary ghost (diagnostic; small + muted)
    do
      -- v39.9: these four lines are developer diagnostics with no meaning to a driver.
      -- Kept, because they have earned their place while debugging, but behind a toggle
      -- that is off by default so the panel is not a wall of internals.
      local g0 = ghosts[1]
      if g0 and g0.meshOk and DA.diag then
        local wc = g0.wheelCount or 0
        local msg = (wc > 0) and ("ghost wheels: spinning (" .. wc .. " nodes)")
                             or  "ghost wheels: nodes not found on this model"
        if (g0.wheelSpinFails or 0) >= 8 then msg = "ghost wheels: rotate API rejected (disabled)" end
        withFont(FONT_SMALL, function() tcol("  " .. msg, COL.mut) end)
        if g0.meshStatus and g0.meshStatus ~= "OK" then
          local mcol = g0.meshStatus:find("fallback", 1, true) and COL.amber or COL.mut
          withFont(FONT_SMALL, function() tcol("  ghost body - " .. g0.meshStatus, mcol) end)
        end
        -- v37.1: replay API probe. v37 defined replayProbe() but never called it, so the
        -- diagnostics were invisible. Shown always (not only during replay) so it can be
        -- read without hunting: the values that matter are the ones while a replay plays.
        do
          local okP, rp = pcall(replayProbe)
          withFont(FONT_SMALL, function()
            tcol("  replay api: " .. (okP and tostring(rp) or "probe failed"),
                 (okP and rp ~= "none") and COL.green or COL.amber)
            local _fa, _fp2 = "?", "?"
            pcall(function()
              local s2 = ac.getSim()
              if s2 then _fa = tostring(s2.isReplayActive); _fp2 = tostring(s2.isPaused) end
            end)
            tcol(string.format("  ghost clock: active=%s paused=%s anchor=%s fps=%.1f",
                 _fa, _fp2, tostring(DA_RPC and DA_RPC.f0 or "nil"),
                 (DA_RPC and DA_RPC.fps) or 0), COL.amber)
          end)
        end
        if g0.stickerProbe then
          withFont(FONT_SMALL, function()
            tcol("  sticker api: " .. tostring(g0.stickerProbe), COL.mut)
          end)
        end
        -- v39.10: the sideways ghost has survived two fixes aimed at guesses. This shows
        -- the three numbers that decide which way the car points, so the cause can be READ
        -- instead of inferred:
        --   shown = the heading the mesh is actually set to
        --   traj  = what the sample trajectory says (centred +/-0.35 s chord)
        --   move  = the direction it actually moved since the last drawn frame
        --   step  = how far it moved that frame; jump = the elapsed-jump detector firing
        -- If shown tracks traj but move disagrees, the trajectory is wrong. If jump is
        -- true every frame, the movement path is being skipped. If move and traj agree but
        -- the car still looks wrong, the mesh's own forward axis is the problem.
        do
          ui.text("  ghost yaw trim: " .. tostring(DA.hdgOffset or 0) .. " deg")
          if btnW("-15", 46) then DA.hdgOffset = (DA.hdgOffset or 0) - 15 end
          ui.sameLine()
          if btnW("-5", 42)  then DA.hdgOffset = (DA.hdgOffset or 0) - 5 end
          ui.sameLine()
          if btnW("0", 38)   then DA.hdgOffset = 0 end
          ui.sameLine()
          if btnW("+5", 42)  then DA.hdgOffset = (DA.hdgOffset or 0) + 5 end
          ui.sameLine()
          if btnW("+15", 46) then DA.hdgOffset = (DA.hdgOffset or 0) + 15 end
        end
        do
          local _hs = string.format("  heading: shown %.0f  line %s  move %s  step %.2fm%s",
            g0.displayHeading or 0,
            g0.dbgLine and string.format("%.0f", g0.dbgLine) or "-",
            g0.dbgMove and string.format("%.0f", g0.dbgMove) or "-",
            g0.dbgStep or 0,
            g0.dbgJump and "  JUMP" or "")
          withFont(FONT_SMALL, function() tcol(_hs, COL.amber) end)
        end
      end
      if btnW(DA.diag and "hide diagnostics" or "diagnostics", 120) then
        DA.diag = not DA.diag
      end
    end
    if btnW("Snap ghost to me", CURW) then snapGhostToMe() end
    if snapHotkey then
      withFont(FONT_SMALL, function() tcol("  snap hotkey (fires while driving) - click to bind:", COL.mut) end)
      if not pcall(function() snapHotkey:control(vec2(CURW, 24)) end) then
        pcall(function() snapHotkey:control() end)
      end
    end
    if snapMsg then withFont(FONT_SMALL, function() tcol("  " .. snapMsg, COL.mut) end) end
    local g = ghosts[1]
    if g and g.duration and g.duration > 0 then
      local el   = replayT % g.duration
      local frac = math.max(0, math.min(1, el / g.duration))
      local p0   = ui.getCursor()
      local W, H = CURW, 6
      ui.dummy(vec2(W, H))
      pcall(function()
        ui.drawRectFilled(p0, vec2(p0.x + W, p0.y + H), COL.inert)
        ui.drawRectFilled(p0, vec2(p0.x + W * frac, p0.y + H), COL.green)
      end)
      withFont(FONT_MONO, function() tcol(string.format("%.1f / %.0fs", el, g.duration), COL.mut) end)
    end
  end

  -- launch mode (secondary)
  vspace(1, 4)
  withFont(FONT_SMALL, function() tcol("LAUNCH", COL.mut) end)
  ui.sameLine(0, 8)
  -- v28: compact labels so all three modes fit the panel width
  local hasGate = (seg.def ~= nil and segActive())
  local bw = hasGate and 78 or 110
  local nowSel = (mode == "ABSOLUTE")
  if btnColor("Now", nowSel and COL.green or COL.inert, nowSel and COL.dark or COL.mut, bw, 0) then
    mode = "ABSOLUTE"; armed = false
  end
  ui.sameLine(0, 4)
  local sfSel = (mode == "SFSYNC")
  if btnColor(hasGate and "Start-line" or "At start-line", sfSel and COL.green or COL.inert, sfSel and COL.dark or COL.mut, hasGate and 92 or 132, 0) then
    mode = "SFSYNC"
  end
  if hasGate then
    ui.sameLine(0, 4)
    local gaSel = (mode == "GATEA")
    if btnColor((seg.def.name or "BTG") .. " start", gaSel and COL.green or COL.inert, gaSel and COL.dark or COL.mut, 92, 0) then
      mode = "GATEA"
    end
  end

  vspace(1, 4)
  -- v39.13 B: "Clear all ghosts" removed -- STOP now does exactly this.
end

-- =====================================================================
-- WINDOW
-- =====================================================================
-- =====================================================================
-- SECTION: sim-lap recorder wiring (Phase 2a)
-- The engine already records the player's lap into rec.* every frame
-- (recUpdate, gated on rec.on). This block arms it, and when a lap
-- finalises it uploads to /api/sim_lap and shows the gap in-game.
-- Everything here is additive + pcall-guarded; the engine is untouched.
-- =====================================================================
local function daNow() local t = 0; pcall(function() t = os.clock() end); return t end

-- v21: no baked-in identity. Priority: paired account (device token) overrides
-- everything server-side; otherwise the stored name below / your AC profile name.
local DA_NAME = ""

-- persistent config: display name + recorder on/off (ac.storage; guarded).
local daCfg = { da_driver = "", da_record = true, da_device_token = "", da_handle = "" }
pcall(function()
  if ac and ac.storage then
    local s = ac.storage{ da_driver = "", da_record = true, da_device_token = "", da_handle = "" }
    if s then daCfg = s end
  end
end)
local function daCfgGet(k, def)
  local v = def
  pcall(function() if daCfg[k] ~= nil then v = daCfg[k] end end)
  return v
end
local function daCfgSet(k, val) pcall(function() daCfg[k] = val end) end

-- arm the engine recorder per config (default on)
pcall(function() rec.on = (daCfgGet("da_record", true) ~= false) end)

-- player display name: stored override -> AC profile name -> "Player"
local function daPlayerName()
  if type(DA_NAME) == "string" and #DA_NAME > 0 then return DA_NAME end
  local n = daCfgGet("da_driver", "")
  if type(n) == "string" and #n > 0 then return n end
  local acn = nil
  pcall(function() if ac.getDriverName then acn = ac.getDriverName(0) end end)
  if type(acn) == "string" and #acn > 0 then return acn end
  return "Player"
end

-- best-effort Steam ID (future native-app account linking); nil if unavailable
local function daSteamID()
  local sid = nil
  pcall(function() if ac.getUserSteamID then sid = ac.getUserSteamID() end end)
  if type(sid) == "string" and #sid > 0 then return sid end
  if type(sid) == "number" then return tostring(sid) end
  return nil
end

-- durable device token from a claimed pairing code (nil until this game is paired)
local function daDeviceToken()
  local t = daCfgGet("da_device_token", "")
  if type(t) == "string" and #t > 0 then return t end
  return nil
end

-- ghost being chased = fastest currently-loaded ghost (lowest duration)
local function daTargetGid()
  local gid, best = nil, nil
  for i = 1, #ghosts do
    local g = ghosts[i]
    if g and g.gid then
      local d = g.duration or 1e9
      if best == nil or d < best then best = d; gid = g.gid end
    end
  end
  return gid
end

-- resolved circuit name for this lap (matches the registry/real-lap track string)
local function daTrackName()
  if browser.selTrack and #tostring(browser.selTrack) > 0 then return tostring(browser.selTrack) end
  if browser.matches and #browser.matches == 1 then return tostring(browser.matches[1].track) end
  if browser.curName and #tostring(browser.curName) > 0 then return tostring(browser.curName) end
  return nil
end

-- player car id (AC folder id; the server normalises it to real-lap car strings)
local function daCarId()
  local c = ""
  pcall(function() if ac.getCarID then c = ac.getCarID(0) or "" end end)
  return c
end

-- JSON string encode (quote + escape); driver/track/car may contain quotes
local function jstr(s)
  s = tostring(s or "")
  s = s:gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\n", " "):gsub("\r", " "):gsub("\t", " ")
  return '"' .. s .. '"'
end
local function jstrOrNull(s)
  if s == nil then return "null" end
  if type(s) == "string" and #s == 0 then return "null" end
  return jstr(s)
end

-- build the /api/sim_lap body; remap engine sample keys spd->speed, hdg->heading
-- so the stored trace matches the ghost format ({t,x,y,z,speed,heading}).
local function buildSimLapBody(lap, meta)
  -- v39.7: the recorder captures twelve channels but this only ever sent six, so
  -- gas/brk/rpm/gear/str/spl never left the sim PC and every stored sim lap was
  -- position-only. That made a real-vs-sim INPUT comparison impossible - the whole
  -- point of the telemetry. The extra fields use the same names the real-lap ghosts
  -- use (gas/brk/rpm/gear) so both sides line up without translation.
  -- has_inputs lets the server and the site tell "pedal was at zero" apart from
  -- "this lap predates the fix".
  local s = lap.samples
  local hasIn = (s[1] ~= nil and s[1].gas ~= nil)
  local head = string.format(
    '{"driver":%s,"track":%s,"lap_time":%.3f,"car":%s,"steam_id":%s,"target_ghost_id":%s,"valid":%s,"device_token":%s,"has_inputs":%s,"samples":[',
    jstr(meta.driver), jstr(meta.track), lap.time, jstr(meta.car),
    jstrOrNull(meta.steam_id), jstrOrNull(meta.target_ghost_id),
    (meta.valid and "true" or "false"), jstrOrNull(meta.device_token),
    (hasIn and "true" or "false"))
  local parts = {}
  for i = 1, #s do
    local p = s[i]
    if hasIn then
      parts[i] = string.format(
        '{"t":%.3f,"x":%.2f,"y":%.2f,"z":%.2f,"speed":%.2f,"heading":%.1f,"gas":%.3f,"brk":%.3f,"rpm":%d,"gear":%d,"str":%.3f,"spl":%.5f}',
        p.t or 0, p.x or 0, p.y or 0, p.z or 0, p.spd or 0, p.hdg or 0,
        p.gas or 0, p.brk or 0, math.floor(p.rpm or 0), math.floor(p.gear or 0),
        p.str or 0, p.spl or 0)
    else
      parts[i] = string.format('{"t":%.3f,"x":%.2f,"y":%.2f,"z":%.2f,"speed":%.2f,"heading":%.1f}',
        p.t or 0, p.x or 0, p.y or 0, p.z or 0, p.spd or 0, p.hdg or 0)
    end
  end
  return head .. table.concat(parts, ",") .. "]}"
end

-- defensive JSON POST (engine only ships web.get). Mirrors the engine's response
-- shape handling; tries the common CSP POST signatures. Server is content-type
-- tolerant, so a missing header is fine.
local function httpPostJSON(url, body, cb)
  cb = cb or function() end
  local function unpack2(a, b)
    if type(a) == "table" and b == nil then return (a.status and a.status < 400), a.status, a.body or a.text or a.response end
    if (a == nil or a == false) and type(b) == "table" then return (b.status and b.status < 400), b.status, b.body or b.text or b.response end
    if type(a) == "string" and type(b) == "table" then return (b.status and b.status < 400), b.status, b.body or b.text or b.response end
    return false, nil, "bad response"
  end
  if type(web) ~= "table" then cb(false, nil, "web unavailable"); return end
  local hdr = { ["Content-Type"] = "application/json" }
  if type(web.post) == "function" then
    if pcall(function() web.post(url, hdr, body, function(a, b) cb(unpack2(a, b)) end) end) then return end
    if pcall(function() web.post(url, body, function(a, b) cb(unpack2(a, b)) end) end) then return end
  end
  if type(web.request) == "function" then
    if pcall(function() web.request("POST", url, hdr, body, function(a, b) cb(unpack2(a, b)) end) end) then return end
  end
  cb(false, nil, "no usable web POST on this build")
end

-- recorder runtime state
local daPostedCount  = 0
local daPosting      = false
local daPostingSince = 0
local daLapBad       = false
-- v34: live validity diagnostics. AC's "Best lap" panel does NOT filter on validity,
-- so a lap can show there and still be invalidated. We surface the raw flag, which
-- API field it came from, and where on the lap it flipped.
local daValidRaw     = nil     -- true/false/nil (nil = neither field exists)
local daValidField   = "none"  -- which CSP field answered
local daBadAtPct     = nil     -- spline position where it went invalid
local daToast        = nil
local daStatus       = nil

local function daGapStr(g)
  if g == nil then return "" end
  if g < 0 then return string.format("%.3f", g) end
  return string.format("+%.3f", g)
end

-- called every frame (from the wrapped script.update AND windowMain; the
-- daPostedCount guard makes each lap upload exactly once).
local function daRecorderTick(dt)
  if not rec.on then return end
  if daPosting and (daNow() - daPostingSince > 15.0) then
    daPosting = false; daStatus = "upload timed out (no response)"
  end
  -- accumulate validity for the in-progress lap (best-effort flag; default valid)
  pcall(function()
    local car = ac.getCar(0)
    if car ~= nil then
      local lv = nil
      if car.isLapValid ~= nil then lv = car.isLapValid; daValidField = "isLapValid"
      elseif car.currentLapValid ~= nil then lv = car.currentLapValid; daValidField = "currentLapValid"
      else daValidField = "none (no field)" end
      daValidRaw = lv
      if lv == false then
        if not daLapBad and car.splinePosition ~= nil then
          daBadAtPct = math.floor((car.splinePosition or 0) * 100 + 0.5)
        end
        daLapBad = true
      end
    end
  end)
  local n = #rec.times
  if n > daPostedCount and not daPosting then
    local lap = rec.last
    local validThisLap = (not daLapBad)
    daLapBad = false
    -- v33: an invalidated lap is silently dropped by the leaderboard (valid=1 filter).
    -- Say so, loudly, instead of letting a fast lap vanish without explanation.
    if not validThisLap then
      daStatus = "lap NOT counted - AC invalidated it"
        .. (daBadAtPct and (" at " .. daBadAtPct .. "% of the lap") or "")
    end
    daBadAtPct = nil
    if not (lap and lap.samples and #lap.samples >= 2) then daPostedCount = n; return end
    pcall(uiEnsureData)
    local meta = {
      driver          = daPlayerName(),
      car             = daCarId(),
      track           = daTrackName(),
      steam_id        = daSteamID(),
      target_ghost_id = daTargetGid(),
      valid           = validThisLap,
      device_token    = daDeviceToken(),
    }
    if not (meta.track and #tostring(meta.track) > 0) then
      daStatus = "lap not uploaded (circuit not resolved yet)"; daPostedCount = n; return
    end
    daPostedCount = n
    daPosting = true; daPostingSince = daNow()
    daStatus = "uploading lap..."
    local body = buildSimLapBody(lap, meta)
    httpPostJSON(API_BASE .. "/api/sim_lap", body, function(ok, status, resp)
      daPosting = false
      if not ok then
        daStatus = "upload failed (" .. tostring(status) .. ")"
        daToast = { l1 = "Lap NOT saved", l2 = "could not reach dualapex.com (" .. tostring(status) .. ")",
                    col = COL.amber, at = daNow() }
        return
      end
      local okP, parsed = pcall(JSON.parse, resp)
      if okP and parsed and parsed.lap_time_str then
        if parsed.gap ~= nil and parsed.matched then
          -- v39.9: the toast confirmed a lap but never said WHERE it went, so there was
          -- no visible proof it reached DualApex. The gap still leads, because that is
          -- what the driver wants first; the destination follows it.
          daToast = { l1 = "Lap " .. tostring(parsed.lap_time_str) .. " saved to DualApex",
                      l2 = daGapStr(parsed.gap) .. "s vs " .. tostring(parsed.target_lap_time_str or "target")
                           .. "  -  on the leaderboard",
                      col = (parsed.gap < 0) and COL.green or COL.amber, at = daNow() }
          daStatus = "saved - gap " .. daGapStr(parsed.gap) .. "s"
        else
          daToast = { l1 = "Lap " .. tostring(parsed.lap_time_str) .. " saved to DualApex",
                      l2 = "on the leaderboard at dualapex.com",
                      col = COL.green, at = daNow() }
          daStatus = "saved (no target ghost loaded)"
        end
      else
        daStatus = "saved (response unreadable)"
      end
    end)
  end
end

-- mesh-load retry: the engine draws a ghost as wireframe when its car model
-- fails to load (commonly the first cold load of a session). Re-attempt any
-- failed load a moment later (the model is cached by then) -- one ghost at a
-- time and capped, so it can never hitch the frame rate.
local daMeshFrame = 0
local DA_MESH_RETRY_EVERY = 45    -- frames between attempts (~0.75s @ 60fps)
local DA_MESH_RETRY_CAP   = 4     -- max retries per ghost, then leave as wire
local function daMeshRetry()
  daMeshFrame = daMeshFrame + 1
  if (daMeshFrame % DA_MESH_RETRY_EVERY) ~= 0 then return end
  for i = 1, #ghosts do
    local g = ghosts[i]
    if g and g.meshTried and (not g.meshOk) then
      g.daMeshRetries = g.daMeshRetries or 0
      if g.daMeshRetries < DA_MESH_RETRY_CAP then
        g.daMeshRetries = g.daMeshRetries + 1
        pcall(function() loadMeshForGhost(g, i) end)
        return   -- only one retry per cycle to avoid a re-load pile-up
      end
    end
  end
end

-- brief in-panel banner after a lap uploads (shows the gap)
local function uiToast()
  if not daToast then return end
  if (daNow() - (daToast.at or 0)) > 8.0 then daToast = nil; return end
  local p0 = ui.getCursor()
  local w = CURW
  pcall(function() ui.drawRectFilled(vec2(p0.x, p0.y), vec2(p0.x + w, p0.y + 36), COL.mapBg) end)
  strokeRect(vec2(p0.x, p0.y), vec2(p0.x + w, p0.y + 36), daToast.col or COL.green, 1)
  ui.dummy(vec2(w, 4))
  withFont(FONT_SMALL, function() tcol("  " .. tostring(daToast.l1), COL.ink) end)
  withFont(FONT_MONO,  function() tcol("  " .. tostring(daToast.l2), daToast.col or COL.green) end)
  vspace(1, 4)
end

-- ============================================================================
-- ACCOUNT PAIRING (v15): claim a one-time code from the website so laps attach
--   to the player's DualApex account. The backend overrides the upload driver
--   name to the account handle whenever a device token is present.
-- ============================================================================
local daPairBusy  = false
local daPairMsg   = nil      -- { text = string, ok = bool }
local daPairInput = ""

local function daClaimPair(rawCode)
  local code = tostring(rawCode or ""):gsub("[^%w]", ""):upper()
  if #code < 8 then daPairMsg = { text = "Enter the 8-character code from the website", ok = false }; return end
  if #code > 8 then code = code:sub(1, 8) end
  daPairBusy = true; daPairMsg = { text = "Pairing...", ok = true }
  local body = string.format('{"code":%s,"label":%s}', jstr(code), jstr("Assetto Corsa"))
  httpPostJSON(API_BASE .. "/api/pair/claim", body, function(ok, status, resp)
    daPairBusy = false
    if not ok then
      local m = "Pairing failed (" .. tostring(status or "?") .. ")"
      if status == 404 then m = "Invalid or expired code - get a fresh one on the website"
      elseif status == 400 then m = "That code does not look right (needs 8 characters)" end
      daPairMsg = { text = m, ok = false }; return
    end
    local okP, parsed = pcall(JSON.parse, resp)
    if okP and parsed and type(parsed.token) == "string" and #parsed.token > 0 then
      daCfgSet("da_device_token", parsed.token)
      daCfgSet("da_handle", parsed.handle or "")
      daPairInput = ""
      daPairMsg = { text = "Paired - laps now upload to " .. tostring(parsed.handle or "your account"), ok = true }
    else
      daPairMsg = { text = "Pairing failed (unexpected response)", ok = false }
    end
  end)
end

-- v16: load the chase target you picked on the website (Chase -> Beat it) as a ghost.
-- Reuses the engine's HTTP + ghost loader; works for your real lap, a friend's, or a creator's.
local daChase = { busy = false, msg = nil }
local function daLoadChaseTarget()
  local tok = daDeviceToken()
  if not tok then daChase.msg = { text = "Pair this game first (Account above)", ok = false }; return end
  daChase.busy = true; daChase.msg = { text = "Looking up your chase target...", ok = true }
  httpGet(API_BASE .. "/api/account/chase_target?token=" .. urlEncode(tok), function(ok, status, body)
    if not ok then
      daChase.busy = false
      daChase.msg = { text = "Couldn't reach the server (" .. tostring(status) .. ")", ok = false }
      return
    end
    local okP, parsed = pcall(JSON.parse, body)
    if not okP or not parsed then
      daChase.busy = false; daChase.msg = { text = "Unexpected response", ok = false }; return
    end
    if not parsed.set then
      daChase.busy = false
      daChase.msg = { text = "No target set. On the website: Chase -> Beat it.", ok = false }
      return
    end
    local label = tostring(parsed.label or "chase target")
    local lid = tostring(parsed.load_id or parsed.load_path or label)
    httpGet(API_BASE .. tostring(parsed.load_path), function(ok2, status2, body2)
      daChase.busy = false
      if not ok2 then daChase.msg = { text = "Load failed (" .. tostring(status2) .. ")", ok = false }; return end
      local okLoad, msg = addGhostFromJSON(body2, label, lid)
      if okLoad then
        daChase.msg = { text = "Loaded " .. label .. " - press PLAY", ok = true }
      else
        daChase.msg = { text = "Not loaded: " .. tostring(msg), ok = false }
      end
    end)
  end)
end

local daAcctOpen = false
local function uiAccount()
  withFont(FONT_SMALL, function()
    local tok = daDeviceToken()
    if tok then
      local h = daCfgGet("da_handle", "")
      if type(h) == "string" and #h > 0 then
        tcol("Account: paired - laps upload to " .. h, COL.green)
      else
        tcol("Account: paired - laps upload to your DualApex account", COL.green)
      end
      if btnW("Load chase target", CURW) and not daChase.busy then daLoadChaseTarget() end
      if daChase.msg then tcol("  " .. daChase.msg.text, daChase.msg.ok and COL.green or COL.red) end
      if ui.button(daAcctOpen and "close##acct" or "manage##acct") then daAcctOpen = not daAcctOpen end
      if daAcctOpen then
        if btnW("Unpair this game", CURW) then
          daCfgSet("da_device_token", ""); daCfgSet("da_handle", "")
          daPairMsg = { text = "Unpaired - laps upload under your local name again", ok = true }
          daAcctOpen = false
        end
      end
    else
      tcol("Account: not paired", COL.mut)
      ui.sameLine(0, 6)
      if ui.button(daAcctOpen and "close##acct" or "pair##acct") then daAcctOpen = not daAcctOpen end
      if daAcctOpen then
        tcol("On the website: Account -> Pair your game -> copy the code.", COL.mut)
        if (HAS_INPUT or HAS_INPUT_HINT) then
          pcall(function() ui.pushItemWidth(math.max(120, CURW - 20)) end)
          local cur = daPairInput or ""
          pcall(function()
            local a, b
            if HAS_INPUT_HINT then a, b = ui.inputTextWithHint("##dapair", "Pairing code", cur)
            else a, b = ui.inputText("##dapair", cur) end
            if type(a) == "string" then daPairInput = a elseif type(b) == "string" then daPairInput = b end
          end)
          pcall(function() ui.popItemWidth() end)
          if btnW(daPairBusy and "Pairing..." or "Pair this game", CURW) and not daPairBusy then
            daClaimPair(daPairInput)
          end
        else
          tcol("(text input unavailable on this build)", COL.mut)
        end
      end
    end
    if daPairMsg then tcol("  " .. daPairMsg.text, daPairMsg.ok and COL.green or COL.red) end
  end)
end

-- minimal recorder status/settings bar (kept quiet; bottom of the panel)
local daNameEdit = false
local function uiRecorderBar()
  withFont(FONT_SMALL, function()
    local on = (rec.on == true)
    if btnColor(on and "REC on" or "REC off", on and COL.green or COL.inert, on and COL.dark or COL.mut, 72, 0) then
      rec.on = not rec.on; daCfgSet("da_record", rec.on)
      if rec.on then pcall(function() rec.clock = 0; rec.cur = nil; rec.lastSpl = nil end) end
    end
    ui.sameLine(0, 6)
    tcol("you: " .. daPlayerName(), COL.mut)
    if (HAS_INPUT or HAS_INPUT_HINT) then
      ui.sameLine(0, 6)
      if ui.button(daNameEdit and "done##nm" or "edit##nm") then daNameEdit = not daNameEdit end
    end
  end)
  if daNameEdit and (HAS_INPUT or HAS_INPUT_HINT) then
    pcall(function() ui.pushItemWidth(math.max(120, CURW - 20)) end)
    local cur = daCfgGet("da_driver", "")
    local nv = cur
    pcall(function()
      local a, b
      if HAS_INPUT_HINT then a, b = ui.inputTextWithHint("##daname", "Your DualApex name", cur)
      else a, b = ui.inputText("##daname", cur) end
      if type(a) == "string" then nv = a elseif type(b) == "string" then nv = b end
    end)
    pcall(function() ui.popItemWidth() end)
    if nv ~= cur then daCfgSet("da_driver", nv) end
  end
  local stat = daStatus or ("v" .. DA.version .. " - recorder " .. ((rec.on == true) and "armed" or "off"))
  if DA.upd.latest then stat = stat .. "   |  v" .. tostring(DA.upd.latest) .. " available" end
  if rec.lastReject then stat = tostring(rec.lastReject) end
  withFont(FONT_SMALL, function() tcol("  " .. stat, COL.mut) end)
  -- Update control. Only drawn when the server reports a different build, so it is
  -- invisible in the normal case rather than another permanent button.
  if DA.upd.latest and not DA.upd.msg then
    if DA.upd.fullReinstall then
      withFont(FONT_SMALL, function() tcol("  full reinstall needed - dualapex.com", COL.amber) end)
    elseif btnW("Update to v" .. tostring(DA.upd.latest), 190) then
      pcall(DA.applyUpdate)
    end
  end
  if DA.upd.msg then
    withFont(FONT_SMALL, function() tcol("  " .. tostring(DA.upd.msg), COL.green) end)
  end
end


-- v39.13: Back to pits / Restart Session do not pause the sim, so the recorder
-- used to sample straight through the teleport and bank the pit-box samples as
-- part of the lap (speed 0, gear 0, idle rpm, one repeated splinePosition).
-- The v21 distance guard could not see it: a contaminated lap still covered 93%
-- of the track. Detect the teleport itself instead.
DA_PIT = DA_PIT or { spl = nil, stop = 0, latch = false }

function daPitOrRestart(dt, car)
  if not car then return nil end
  if isPausedNow() then return nil end   -- pausing is already handled correctly
  local why = nil

  local sp = car.splinePosition
  if sp ~= nil then
    if DA_PIT.spl ~= nil then
      local d = sp - DA_PIT.spl
      if d < -0.5 then d = d + 1 end     -- ordinary start/finish wrap
      -- 0.03 of a 3.1 km lap is ~94 m in one frame: far beyond any real frame
      -- gap, far below the 0.09 and 0.69 jumps a pit teleport actually made.
      if d > 0.03 or d < -0.03 then why = "teleported" end
    end
    DA_PIT.spl = sp
  end

  local spd = car.speedKmh or 0
  if spd < 2 then DA_PIT.stop = DA_PIT.stop + (dt or 0) else DA_PIT.stop = 0 end
  if why == nil and DA_PIT.stop > 1.0 then why = "car stopped" end

  if why == nil then
    pcall(function()
      if car.isInPitlane == true or car.isInPit == true then why = "in the pits" end
    end)
  end

  if why == nil then DA_PIT.latch = false; return nil end
  if DA_PIT.latch then return nil end    -- fire once per episode, not per frame
  DA_PIT.latch = true

  if rec and rec.cur then
    rec.cur = nil
    rec.lastSpl = nil
    rec.lastReject = "lap discarded - " .. why
  end
  -- stopkeep9: only tear playback down when the car is no longer where the ghost
  -- thinks it is. A teleport or a pit entry means exactly that. Merely standing
  -- still does not -- the ghost's position is still valid and you have just
  -- stopped beside it, so hiding it was wrong. The lap in progress is still
  -- discarded above in every case, so recordings stay as clean as before.
  if (why ~= "car stopped") and (playing or armed) then
    playing = false
    armed = true                          -- feedback A: re-arm, so the ghost
    replayT = 0                           -- restarts from start/finish
    pcall(function() for gi6=1,#ghosts do ghosts[gi6].gaOff = 0 end end)
    pcall(hideAllMeshes)
    pcall(function() seg.aClock = nil end)
  end
  return why
end

function script.windowMain(dt)
  -- engine: SF-sync arm trigger must fire every frame, independent of the UI
  pcall(function()
    local crossed = checkSFCross()
    if crossed and mode == "SFSYNC" and armed then startPlaying(); armed = false end
  end)

  -- v39.13: runs BEFORE the recorder tick so a discarded lap takes no further
  -- samples this frame.
  pcall(function() daPitOrRestart(dt, ac.getCar(0)) end)

  CURW = panelW()
  uiEnsureData()
  pcall(daRecorderTick, dt)

  safe(uiToast)
  safe(uiHeader)
  vspace(1, 6)
  safe(uiDelta)
  safe(uiSegment)
  safe(uiGhosts)
  vsep()
  safe(uiPicker)
  vsep()
  safe(uiMap)
  vsep()
  safe(uiTransport)
  vsep()
  safe(uiRecorderBar)
  vsep()
  safe(uiAccount)
end


-- wrap the engine's per-frame update so finalised laps upload even when the
-- DualApex window is closed (the engine's recUpdate already runs inside it; the
-- daPostedCount guard ensures each lap uploads exactly once across both hooks).
do
  local _da_engineUpdate = script.update
  if type(_da_engineUpdate) == "function" then
    script.update = function(dt)
      _da_engineUpdate(dt)
      pcall(daRecorderTick, dt)
      pcall(daMeshRetry)
    end
  end
end
