-- OE ProTune public V3 / internal build 3.2.7 - game-engine side server
--
-- Serves the complete browser experience to the machine or LAN (second monitor, laptop,
-- phone, tablet on the same private local network):
--   dash.html   - the read-only gauge dash (default page)
--   logstudio.html - browser-only recorder, run verifier and comparison workspace
--   remote.html - the FULL ProTune app mirror (read-only until the in-game gate
--                 is open and this peer claims the writer lease)
--   studio.html - professional shell reusing the same mirror, dash and recorder
-- Built EXACTLY like the game's own external-UI feature (ui/extApp.lua): ONE
-- native BNGWebWSServer serves pages to plain HTTP browsers and pushes live data
-- over WebSocket. A bind ladder + TCP probe picks the address phones can reach.
--
-- Lifecycle IS the switch (extApp pattern): extensions.load starts the server,
-- extensions.unload stops it. The in-game app toggles + reads getStatus().
--
-- READ-ONLY BY DEFAULT, ENFORCED HERE. V1 clients retain telemetry + ping/pong,
-- but their old raw {t="ctl", c="..."} messages are never executed. V2 editing
-- uses an in-game master gate, one automatically claimed peer-bound writer lease,
-- typed command schemas and controller acknowledgements. The mirror stays fresh
-- because THIS side polls the vehicle for state.
--
-- Protocol (server -> browser): JSON envelopes { hook = <name>, data = <table> }
--   OEProTuneState / OEProTuneTelemetry / OEProTuneDyno / OEProTuneKnock /
--   OEProTuneBlown - the exact five events the in-game app listens to, built by
--   the vehicle controller and forwarded here via queueGameEngineLua -> push().
--   OEPRemoteStatus - server-side extras (pause flag, player vehicle id) so the
--   mirror's bngApi shim can answer the app's engineLua probes from cache.
--   OEPRemoteTuneCatalog - sanitized saved-tune labels/references published by
--   the trusted game UI; calibration bodies and filesystem paths never cross WS.

local M = {}

local PORT = 8236          -- the game's own server: loopback ONLY (BeamNG sandbox forbids LAN binds)
local LAN_PORT = 8237      -- the Windows relay's LAN listen port (v3). DIFFERENT from PORT on purpose:
                           -- a wildcard 0.0.0.0 relay ON PORT itself would seize the game's own
                           -- loopback:PORT bind (the exclusive-bind conflict that broke v1). A second
                           -- port lets the relay wildcard-bind (LAN:LAN_PORT -> loopback:PORT) with
                           -- zero conflict, and a wildcard follows the PC's IP so DHCP changes no
                           -- longer need a re-run.
local PROTOCOL = "oeprotune-dash-v1"
local PROTOCOL_VERSION = 2
local DASH_PAGE = "/ui/modules/apps/OEProTune/dash.html"
local MAX_FRAME_BYTES = 4096
local MAX_PENDING = 64
local MAX_RECENT_IDS = 128
local LEASE_SECONDS = 12
local HEARTBEAT_SECONDS = 4
local HOLD_SECONDS = 0.75
local COMMAND_TIMEOUT = 8
local FACTORY_RESET_ACK_TIMEOUT = 4 -- trusted UI must verify its recovery marker before vehicle Lua is queued
-- BeamNG's Lua sandbox restricts game networking to loopback ("connect restricted" /
-- "bind restricted" in libbeamng, verified 0.38.6) - the game CANNOT serve the LAN itself.
-- The one-time setup tool below uses Windows' built-in port forwarding (netsh portproxy)
-- to relay LAN:8237 -> loopback:8236, and saves the PC's address so the page + QR can
-- show it (address discovery is sandboxed too). Verified end-to-end through a relay.
local SETUP_DIR = "/settings/oeProTune/"
local LAN_CFG = SETUP_DIR .. "remotedash_lan.json"
local SETUP_STATUS = SETUP_DIR .. "remotedash_setup_status.json"
local SETUP_BAT = SETUP_DIR .. "EnableRemoteDash_LAN.bat"
local SETUP_PS1 = SETUP_DIR .. "EnableRemoteDash_LAN.ps1"
local REVIVE_BAT = SETUP_DIR .. "ReviveRemoteDash.bat"

local server = nil
local serverHealthy = false
local serverBindingUsable = false
-- REVIEW FIX 2026-07-29. The startup bind probe is a HINT, not a verdict. These let a false negative
-- heal instead of latching "SERVER COULD NOT BIND ITS PORT" for a whole session (see onUpdate).
local bindProven = false        -- a real browser completed a handshake: the port is bound, full stop
local bindRecheckTimer = 0
local bindRecheckLeft = 0
local nativeFailures = 0
local chosenAddress = nil
local lanAddresses = {}     -- ranked, private IPv4 candidates; never rendered unless the player reveals them
local setupToolsReady = false
local clients = 0
local latestByHook = {}   -- hook -> encoded envelope (player vehicle only; replayed to new connections)
local latestState = nil
local tuneCatalog = nil   -- trusted game-UI metadata only; never contains tune payloads or file paths
local tuneCatalogGeneration = 0
local tuneCatalogPublisherId = nil
local tuneCatalogToken = nil
local pokeTimer = 0       -- periodic re-poke: controllers re-init on part swaps / respawns and lose the flag
local stateTimer = 0      -- server-side state poll while browsers are connected
local statusTimer = 0     -- OEPRemoteStatus cadence
local watchTimer = 0      -- LAN relay watchdog cadence (detects a silently-dropped portproxy socket)
local lanReachable = nil  -- watchdog verdict: nil = can't tell, true = phones can reach us, false = relay DOWN
local everReachable = false -- latch: only trust a 'false' AFTER we've seen a real 'true', so a machine where
                            -- luasocket can't probe its own LAN IP shows 'unknown', never a false alarm
local writeEnabled = false       -- trusted in-game master gate; never sufficient by itself
local dangerousEnabled = false   -- trusted in-game per-session gate for resets/operational controls
local remoteClock = 0            -- accumulated GE real-frame time; independent of simulation pause
local statusRevision = 0
local pending = {}               -- session-scoped controller id -> originating peer until terminal result
local pendingCount = 0
local pendingFactoryResetIntent = nil -- single two-phase reset waiting for trusted-UI persistence acknowledgement
local peers = {}                 -- peerId -> rate-limit / abuse state
local broadcastStatus            -- forward declaration (auth helpers call it)
local releaseWriter              -- forward declaration (native/lifecycle helpers call it)

local auth = {
  generation = 0,
  writer = nil
}

local PUSH_HOOKS = {
  OEProTuneState = true,
  OEProTuneTelemetry = true,
  OEProTuneDyno = true,
  OEProTuneKnock = true,
  OEProTuneBlown = true
}

-- Validation data (V2.9.7): map meta, setting ranges and the bool/integer-enum sets come from
-- lua/common/oeProTuneShared.lua - the SAME file the vehicle controller requires - so this typed
-- boundary can no longer drift out of sync with the ECU (the old hand-copied tables did once:
-- popsLevel integer-forcing rejected the UI's half-steps). The vehicle still validates again on
-- receipt; this stays a pre-filter with typed errors (reject here, clamp in-game - by design).
local OEP_SHARED = require("oeProTuneShared")
local MAP_META = OEP_SHARED.mapMeta
local SETTING_RANGES = OEP_SHARED.settingRanges
local BOOL_SETTINGS = OEP_SHARED.boolSettings
local INTEGER_SETTINGS = OEP_SHARED.enumSettings

local NO_ARG_METHODS = {
  resetMaps = true, factoryReset = true, resetAxes = true, clearFaults = true,
  resetDamage = true, armDyno = true, cancelDyno = true
}

local DESTRUCTIVE_METHODS = {
  resetMaps = true, resetMap = true, factoryReset = true, applyPreset = true,
  setAxis = true, resetAxes = true, clearFaults = true, resetDamage = true,
  loadSavedTune = true
}

local PRESETS = { stock = true, stage1 = true, aggressive = true, bad = true }
local TUNE_SOURCES = { slot = true, library = true, file = true }
local MAX_TUNE_CATALOG_ENTRIES = 256
local MAX_TUNE_INDEX = 4095

local function bumpStatusRevision()
  statusRevision = statusRevision + 1
end

local function finiteNumber(v)
  return type(v) == "number" and v == v and v ~= math.huge and v ~= -math.huge
end

local function integer(v)
  return finiteNumber(v) and v == math.floor(v)
end

local function currentPlayerId()
  if not (be and be.getPlayerVehicleID) then return -1 end
  local ok, id = pcall(function() return be:getPlayerVehicleID(0) end)
  return (ok and type(id) == "number") and id or -1
end

local function simulationPaused()
  if not (simTimeAuthority and simTimeAuthority.getPause) then return false end
  local ok, value = pcall(function() return simTimeAuthority.getPause() end)
  return ok and value and true or false
end

local function playerVehicle()
  if not (be and be.getPlayerVehicle) then return nil end
  local ok, veh = pcall(function() return be:getPlayerVehicle(0) end)
  return ok and veh or nil
end

local function vehicleById(id)
  if not be or not integer(id) then return nil end
  if be.getObjectByID then
    local ok, veh = pcall(function() return be:getObjectByID(id) end)
    if ok and veh then return veh end
  end
  local okCount, count = pcall(function() return be:getObjectCount() end)
  if not okCount then return nil end
  for i = 0, count - 1 do
    local okObj, obj = pcall(function() return be:getObject(i) end)
    if okObj and obj and obj.getID then
      local okId, objId = pcall(function() return obj:getID() end)
      if okId and objId == id then return obj end
    end
  end
  return nil
end

local function noteNativeFailure(where, err)
  nativeFailures = nativeFailures + 1
  if nativeFailures == 1 or nativeFailures == 3 then
    log("E", "oeProTuneRemote", tostring(where) .. " failed: " .. tostring(err))
  end
  if nativeFailures >= 3 then
    serverHealthy = false
    if releaseWriter then releaseWriter("server_error") end
  end
end

local function safeSendRaw(peerId, payload)
  if not server then return false end
  local ok, err = pcall(function() server:sendData(peerId, payload) end)
  if not ok then noteNativeFailure("sendData", err) end
  return ok
end

local function safeBroadcastRaw(payload)
  if not server or clients <= 0 then return false end
  local ok, err = pcall(function() server:broadcastData(payload) end)
  if not ok then noteNativeFailure("broadcastData", err) end
  return ok
end

local function sendHook(peerId, hook, data)
  local ok, encoded = pcall(jsonEncode, { hook = hook, data = data })
  if not ok or type(encoded) ~= "string" then return false end
  return safeSendRaw(peerId, encoded)
end

local function hasOnlyKeys(t, allowed)
  if type(t) ~= "table" then return false end
  for k in pairs(t) do if not allowed[k] then return false end end
  return true
end

local function arrayLength(t)
  if type(t) ~= "table" then return nil end
  local count, maxIndex = 0, 0
  for k in pairs(t) do
    if not integer(k) or k < 1 then return nil end
    count = count + 1
    if k > maxIndex then maxIndex = k end
  end
  if count ~= maxIndex then return nil end
  return maxIndex
end

local function validId(id)
  return type(id) == "string" and #id >= 1 and #id <= 48 and id:match("^[%w%._:%-]+$") ~= nil
end

local function validControllerId(id)
  return type(id) == "string" and #id >= 3 and #id <= 96 and id:match("^[%w%._:%-]+$") ~= nil
end

local function validAuthorityToken(value)
  return type(value) == "string" and #value >= 1 and #value <= 96 and value:match("^[%w%._:%-]+$") ~= nil
end

local function errorResult(id, method, vehicleId, code, field, message)
  return {
    id = validId(id) and id or "invalid",
    ok = false,
    error = message or code or "rejected",
    errorCode = code or "rejected",
    field = field or "",
    method = type(method) == "string" and method or "",
    phase = "rejected",
    vehicleId = vehicleId
  }
end

local function peerFor(peerId)
  local p = peers[peerId]
  if not p then
    p = { strikes = 0, quarantinedUntil = 0 }
    peers[peerId] = p
  end
  return p
end

local function allowRate(p, key, rate, burst, cost)
  local b = p[key]
  if not b then b = { tokens = burst, at = remoteClock }; p[key] = b end
  local elapsed = math.max(0, remoteClock - b.at)
  b.tokens = math.min(burst, b.tokens + elapsed * rate)
  b.at = remoteClock
  cost = cost or 1
  if b.tokens < cost then return false end
  b.tokens = b.tokens - cost
  return true
end

local function addStrike(peerId)
  local p = peerFor(peerId)
  p.strikes = p.strikes + 1
  if p.strikes >= 3 then
    p.quarantinedUntil = remoteClock + 30
    p.strikes = 0
  end
end

local function sendAuth(peerId, id, phase, okValue, errorCode, extra)
  local authenticated = auth.writer ~= nil and auth.writer.peerId == peerId
  local data = {
    id = validId(id) and id or "auth",
    phase = phase,
    ok = okValue == true,
    error = errorCode or "",
    errorCode = errorCode or "",
    message = errorCode or "",
    authenticated = authenticated,
    writerConnected = auth.writer ~= nil,
    protocolVersion = PROTOCOL_VERSION,
    leaseMs = LEASE_SECONDS * 1000,
    heartbeatEveryMs = HEARTBEAT_SECONDS * 1000
  }
  for k, v in pairs(extra or {}) do data[k] = v end
  sendHook(peerId, "OEPRemoteAuth", data)
end

local function commandArgsError(code, field, message)
  return nil, code, field, message
end

local function validateCommand(method, args)
  if type(method) ~= "string" or #method > 32 or not method:match("^[%a_][%w_]*$") then
    return commandArgsError("unknown_method", "method", "Unknown remote method")
  end
  if type(args) ~= "table" then
    return commandArgsError("type", "args", "args must be an object")
  end

  if method == "setSetting" then
    if not hasOnlyKeys(args, { key = true, value = true }) or type(args.key) ~= "string" or args.value == nil then
      return commandArgsError("shape", "args", "Expected {key,value}")
    end
    local key, value = args.key, args.value
    if BOOL_SETTINGS[key] then
      if type(value) ~= "boolean" then return commandArgsError("type", "args.value", "Expected boolean") end
      return { key = key, value = value }
    end
    if key == "revLimit" then
      if not finiteNumber(value) then return commandArgsError("type", "args.value", "Expected finite number") end
      if value == 0 then return { key = key, value = 0 } end
      local base = latestState and latestState.base
      if type(base) ~= "table" then return commandArgsError("state_unavailable", "args.value", "Vehicle state unavailable") end
      local lo = (tonumber(base.idleRPM) or 800) + 700
      local hi = tonumber(base.revLimitCeilingRPM) or tonumber(base.revLimiterRPM) or 11000
      if value < lo or value > hi then return commandArgsError("range", "args.value", "Rev limit is outside this engine's safe range") end
      return { key = key, value = value }
    end
    local range = SETTING_RANGES[key]
    if not range then return commandArgsError("unknown_setting", "args.key", "Unknown setting") end
    if not finiteNumber(value) then return commandArgsError("type", "args.value", "Expected finite number") end
    if INTEGER_SETTINGS[key] and not integer(value) then return commandArgsError("type", "args.value", "Expected integer") end
    if value < range[1] or value > range[2] then return commandArgsError("range", "args.value", "Setting is outside its allowed range") end
    return { key = key, value = value }
  end

  if method == "setMapCell" then
    if not hasOnlyKeys(args, { map = true, row = true, col = true, value = true }) then
      return commandArgsError("shape", "args", "Expected {map,row,col,value}")
    end
    local meta = MAP_META[args.map]
    if not meta then return commandArgsError("enum", "args.map", "Unknown map") end
    if not integer(args.row) or not integer(args.col) then return commandArgsError("type", "args.row", "Row and column must be integers") end
    if not finiteNumber(args.value) or args.value < meta.min or args.value > meta.max then
      return commandArgsError("range", "args.value", "Map value is outside its allowed range")
    end
    local rows = latestState and latestState.mapAxis
    local cols = latestState and latestState.rpmAxis
    if type(rows) ~= "table" or type(cols) ~= "table" then return commandArgsError("state_unavailable", "args", "Vehicle state unavailable") end
    if args.row < 1 or args.row > #rows or args.col < 1 or args.col > #cols then
      return commandArgsError("range", "args.row", "Map coordinate is outside the active table")
    end
    return { map = args.map, row = args.row, col = args.col, value = args.value }
  end

  if method == "setMapRow" then
    if not hasOnlyKeys(args, { map = true, row = true, values = true }) then
      return commandArgsError("shape", "args", "Expected {map,row,values}")
    end
    local meta = MAP_META[args.map]
    if not meta then return commandArgsError("enum", "args.map", "Unknown map") end
    if not integer(args.row) then return commandArgsError("type", "args.row", "Row must be an integer") end
    local rows = latestState and latestState.mapAxis
    local cols = latestState and latestState.rpmAxis
    if type(rows) ~= "table" or type(cols) ~= "table" then return commandArgsError("state_unavailable", "args", "Vehicle state unavailable") end
    if args.row < 1 or args.row > #rows then return commandArgsError("range", "args.row", "Row is outside the active table") end
    local n = arrayLength(args.values)
    if not n or n ~= #cols then return commandArgsError("length", "args.values", "Row must contain exactly one value per RPM column") end
    local values = {}
    for i = 1, n do
      local value = args.values[i]
      if not finiteNumber(value) or value < meta.min or value > meta.max then
        return commandArgsError("range", "args.values", "Row contains an out-of-range value")
      end
      values[i] = value
    end
    return { map = args.map, row = args.row, values = values }
  end

  if method == "setAxis" then
    if not hasOnlyKeys(args, { which = true, values = true }) or (args.which ~= "rpm" and args.which ~= "map" and args.which ~= "alpha") then
      return commandArgsError("shape", "args", "Expected {which,values}")
    end
    local n = arrayLength(args.values)
    -- V3.0.7: bounds come from the shared file, not a literal. This gate and the controller's own
    -- R.setAxisValues must accept exactly the same lengths or a legitimate wide-grid tune edits
    -- fine in-game and is rejected from the browser Studio.
    if not n or n < OEP_SHARED.axisLimits.min or n > OEP_SHARED.axisLimits.max then
      return commandArgsError("length", "args.values", "Axis requires "
        .. OEP_SHARED.axisLimits.min .. "-" .. OEP_SHARED.axisLimits.max .. " values")
    end
    if args.which == "alpha" then
      local rows = latestState and latestState.mapAxis
      if type(rows) ~= "table" then return commandArgsError("state_unavailable", "args", "Vehicle state unavailable") end
      if n ~= #rows then return commandArgsError("length", "args.values", "Alpha-N axis must contain exactly one value per table row") end
    end
    local values, previous = {}, nil
    for i = 1, n do
      local value = args.values[i]
      if not finiteNumber(value) then return commandArgsError("type", "args.values", "Axis values must be finite numbers") end
      if args.which == "rpm" then
        if value < 100 or value > 30000 then return commandArgsError("range", "args.values", "RPM axis values must be from 100 to 30000") end
      elseif args.which == "alpha" then
        if value < 0 or value > 100 then return commandArgsError("range", "args.values", "Alpha-N axis values must be from 0 to 100 percent TPS") end
      elseif value < 5 or value > MAP_META.boost.max then
        -- V3.0: top follows the shared meta (1000 kPa, was a hardcoded 600) so a browser Studio can
        -- build the same 130 psi axes the in-game editor can
        return commandArgsError("range", "args.values", "MAP axis values must be from 5 to " .. MAP_META.boost.max .. " kPa")
      end
      if previous and value <= previous then return commandArgsError("order", "args.values", "Axis values must be strictly increasing") end
      values[i], previous = value, value
    end
    return { which = args.which, values = values }
  end

  if method == "resetMap" then
    if not hasOnlyKeys(args, { map = true }) or not MAP_META[args.map] then
      return commandArgsError("enum", "args.map", "Unknown map")
    end
    return { map = args.map }
  end

  if method == "applyPreset" then
    if not hasOnlyKeys(args, { id = true }) or not PRESETS[args.id] then
      return commandArgsError("enum", "args.id", "Unknown preset")
    end
    return { id = args.id }
  end

  -- Server-only persistence bridge. The browser may select one metadata entry
  -- from the trusted game UI's current catalog, but it can never submit a tune,
  -- a path, or arbitrary persistence data over the WebSocket.
  if method == "loadSavedTune" then
    if not hasOnlyKeys(args, { source = true, index = true, catalogGeneration = true }) then
      return commandArgsError("shape", "args", "Expected {source,index,catalogGeneration}")
    end
    if type(args.source) ~= "string" or not TUNE_SOURCES[args.source] then
      return commandArgsError("enum", "args.source", "Unknown saved-tune source")
    end
    if not integer(args.index) or args.index < 0 or args.index > MAX_TUNE_INDEX then
      return commandArgsError("range", "args.index", "Saved-tune index is outside its allowed range")
    end
    if not integer(args.catalogGeneration) or args.catalogGeneration < 1 or args.catalogGeneration > 2147483647 then
      return commandArgsError("range", "args.catalogGeneration", "Invalid tune-catalog generation")
    end
    return { source = args.source, index = args.index, catalogGeneration = args.catalogGeneration }
  end

  if method == "setDynoAuto" then
    if not hasOnlyKeys(args, { enabled = true, gear = true, startRpm = true }) or type(args.enabled) ~= "boolean" then
      return commandArgsError("shape", "args", "Expected {enabled,gear,startRpm}")
    end
    if not integer(args.gear) or args.gear < 0 or args.gear > 32 then return commandArgsError("range", "args.gear", "Gear must be 0-32") end
    if not integer(args.startRpm) or args.startRpm < 0 or args.startRpm > 11000 then return commandArgsError("range", "args.startRpm", "Start RPM must be 0-11000") end
    return { enabled = args.enabled, gear = args.gear, startRpm = args.startRpm }
  end

  if method == "setControl" then
    if not hasOnlyKeys(args, { enabled = true }) or type(args.enabled) ~= "boolean" then
      return commandArgsError("shape", "args", "Expected {enabled:boolean}")
    end
    return { enabled = args.enabled }
  end

  if method == "setRollingHold" then
    if not hasOnlyKeys(args, { held = true }) or type(args.held) ~= "boolean" then
      return commandArgsError("shape", "args", "Expected {held:boolean}")
    end
    return { held = args.held }
  end

  if method == "selectMapBank" then
    if not hasOnlyKeys(args, { index = true }) then
      return commandArgsError("shape", "args", "Expected {index}")
    end
    if not integer(args.index) then
      return commandArgsError("type", "args.index", "Map index must be an integer")
    end
    if args.index < 1 or args.index > 4 then
      return commandArgsError("range", "args.index", "Map index must be from 1 to 4")
    end
    return { index = args.index }
  end

  if method == "cycleMapBank" then
    if not hasOnlyKeys(args, { direction = true }) then
      return commandArgsError("shape", "args", "Expected {direction}")
    end
    if args.direction ~= -1 and args.direction ~= 1 then
      return commandArgsError("enum", "args.direction", "Map direction must be -1 or 1")
    end
    return { direction = args.direction }
  end

  if NO_ARG_METHODS[method] then
    if not hasOnlyKeys(args, {}) then return commandArgsError("shape", "args", "Expected an empty args object") end
    return {}
  end

  return commandArgsError("unknown_method", "method", "Unknown remote method")
end

-- Threat-model note: setSetting{key='enabled', value=1} can re-arm a Connected-but-master-disabled
-- ECU with only the write gate (no dangerous gate). Accepted by design: the master enable is an
-- ordinary tune checkbox that rides tune loads, taking CONTROL (setControl on) stays gated, and a
-- write-gated peer can already repaint every map/setting - gating this one key adds no real wall.
local function isDangerous(method, args)
  if DESTRUCTIVE_METHODS[method] then return "destructive" end
  if method == "armDyno" then return "operational" end
  if method == "setDynoAuto" and args.enabled == true then return "operational" end
  if method == "setControl" and args.enabled == true then return "operational" end
  if method == "setRollingHold" and args.held == true then return "operational" end
  if method == "selectMapBank" or method == "cycleMapBank" then return "operational" end
  return nil
end

local function catalogContains(ref)
  if type(tuneCatalog) ~= "table" or tuneCatalog.vehicleId ~= currentPlayerId() then
    return false, "catalog_unavailable", "Saved-tune catalog is unavailable for this vehicle"
  end
  if ref.catalogGeneration ~= tuneCatalog.catalogGeneration then
    return false, "stale_catalog", "Saved-tune list changed; refresh and try again"
  end
  for _, entry in ipairs(tuneCatalog.entries or {}) do
    if entry.source == ref.source and entry.index == ref.index then return true end
  end
  return false, "unknown_tune", "Saved tune is no longer available"
end

local function queueControllerCommand(veh, id, method, args)
  if not veh or not veh.queueLuaCommand or type(serialize) ~= "function" then return false, "vehicle_unavailable" end
  local okArgs, encodedArgs = pcall(serialize, args)
  if not okArgs or type(encodedArgs) ~= "string" then return false, "serialize_failed" end
  local command = 'local c = controller.getController("oeProTune") or rawget(_G, "oeProTuneExt") '
    .. 'if c and c.remoteCommand then c.remoteCommand(' .. string.format("%q", id) .. ','
    .. string.format("%q", method) .. ',' .. encodedArgs .. ') end'
  local okQueue, err = pcall(function() veh:queueLuaCommand(command) end)
  return okQueue, err
end

local function releaseMomentariesFor(vehicleId, cancelDyno)
  local veh = vehicleById(vehicleId)
  if not veh or not veh.queueLuaCommand then return end
  local command = 'local c = controller.getController("oeProTune") or rawget(_G, "oeProTuneExt") '
    .. 'if c then '
    .. 'if c.releaseMomentaryInputs then c.releaseMomentaryInputs() elseif c.setRollingHold then c.setRollingHold(0) end '
    .. (cancelDyno and 'if c.setDynoAuto then c.setDynoAuto(false,0,0) end if c.cancelDyno then c.cancelDyno() end ' or '')
    .. 'end'
  pcall(function() veh:queueLuaCommand(command) end)
end

local function rememberResult(writer, result)
  if not writer or not validId(result.id) then return end
  if not writer.recentIds[result.id] then
    writer.recentOrder[#writer.recentOrder + 1] = result.id
  end
  writer.recentIds[result.id] = result
  while #writer.recentOrder > MAX_RECENT_IDS do
    local old = table.remove(writer.recentOrder, 1)
    writer.recentIds[old] = nil
  end
end

local function clearPendingGeneration(generation)
  for id, item in pairs(pending) do
    if item.generation == generation then
      pending[id] = nil
      pendingCount = math.max(0, pendingCount - 1)
    end
  end
  if pendingFactoryResetIntent and pendingFactoryResetIntent.generation == generation then
    pendingFactoryResetIntent = nil
  end
end

releaseWriter = function(reason)
  local writer = auth.writer
  if not writer then return end
  releaseMomentariesFor(writer.vehicleId, writer.ownsDyno == true)
  clearPendingGeneration(writer.generation)
  auth.writer = nil
  dangerousEnabled = false
  bumpStatusRevision()
  if broadcastStatus then broadcastStatus() end
end

local function handleClaim(peerId, msg, legacyPair)
  local id = msg.id
  local currentId = currentPlayerId()
  -- Cached V3.0/V3.1 mirror pages may still send `pair` with a `pin` member. It is
  -- accepted as a claim alias, but the value is deliberately ignored.
  local allowed = legacyPair
    and { t = true, v = true, id = true, pin = true, vehicleId = true }
    or { t = true, v = true, id = true, vehicleId = true }
  if not validId(id) or not hasOnlyKeys(msg, allowed) then
    addStrike(peerId)
    sendAuth(peerId, id, "rejected", false, "invalid_claim")
    return
  end
  local p = peerFor(peerId)
  if not allowRate(p, "claimBucket", 1, 3, 1) then sendAuth(peerId, id, "rejected", false, "rate_limited"); return end
  if not writeEnabled then sendAuth(peerId, id, "rejected", false, "editing_disabled"); return end
  if auth.writer and auth.writer.peerId ~= peerId then sendAuth(peerId, id, "rejected", false, "writer_busy"); return end
  if not integer(msg.vehicleId) or msg.vehicleId ~= currentId then sendAuth(peerId, id, "rejected", false, "wrong_vehicle", { vehicleId = currentId }); return end
  if auth.writer and auth.writer.peerId == peerId then
    -- Idempotent re-claim from the bound socket: renew, never overwrite pending/dead-man state.
    auth.writer.leaseUntil = remoteClock + LEASE_SECONDS
    sendAuth(peerId, id, legacyPair and "paired" or "claimed", true, nil, { vehicleId = currentId, writer = true })
    return
  end

  auth.generation = auth.generation + 1
  auth.writer = {
    peerId = peerId,
    vehicleId = currentId,
    generation = auth.generation,
    sessionTag = string.format("%06d-%06d", math.random(0, 999999), math.random(0, 999999)),
    leaseUntil = remoteClock + LEASE_SECONDS,
    recentIds = {}, recentOrder = {},
    ownsDyno = false, rollingHeld = false, rollingDeadline = 0
  }
  dangerousEnabled = false
  bumpStatusRevision()
  sendAuth(peerId, id, legacyPair and "paired" or "claimed", true, nil, { vehicleId = currentId, writer = true })
  if broadcastStatus then broadcastStatus() end
end

local function handleHeartbeat(peerId, msg)
  local writer = auth.writer
  local currentId = currentPlayerId()
  if not validId(msg.id) then addStrike(peerId); return end
  if not writer or writer.peerId ~= peerId then sendAuth(peerId, msg.id, "rejected", false, "not_writer"); return end
  if not integer(msg.vehicleId) or msg.vehicleId ~= writer.vehicleId or msg.vehicleId ~= currentId then
    sendAuth(peerId, msg.id, "rejected", false, "wrong_vehicle", { vehicleId = currentId })
    releaseWriter("wrong_vehicle")
    return
  end
  writer.leaseUntil = remoteClock + LEASE_SECONDS
  sendAuth(peerId, msg.id, "heartbeat", true, nil, { vehicleId = currentId, writer = true })
end

local function handleCommand(peerId, msg)
  local id, method = msg.id, msg.method
  local currentId = currentPlayerId()
  local writer = auth.writer
  local function reject(code, field, message)
    sendHook(peerId, "OEPRemoteCommandResult", errorResult(id, method, currentId, code, field, message))
  end

  if not validId(id) or not hasOnlyKeys(msg, { t = true, v = true, id = true, method = true, args = true, vehicleId = true }) then
    addStrike(peerId); reject("invalid_command", "", "Malformed command"); return
  end
  if writer and writer.peerId == peerId and writer.recentIds[id] then
    sendHook(peerId, "OEPRemoteCommandResult", writer.recentIds[id])
    return
  end
  if not writeEnabled then reject("editing_disabled", "", "Remote editing is disabled"); return end
  if not writer or writer.peerId ~= peerId or remoteClock >= writer.leaseUntil then reject("not_writer", "", "Writer lease is not active"); return end
  if not integer(msg.vehicleId) or msg.vehicleId ~= writer.vehicleId or msg.vehicleId ~= currentId then
    reject("wrong_vehicle", "vehicleId", "Player vehicle changed"); releaseWriter("wrong_vehicle"); return
  end
  local p = peerFor(peerId)
  -- Throttle, don't punish: only the current writer can reach this line (lease checked above),
  -- and striking a legitimate fast slider drag into quarantine would drop its heartbeats and
  -- kill the lease mid-tune. Strikes stay reserved for malformed/unauthenticated traffic.
  if not allowRate(p, "commandBucket", 20, 30, 1) then reject("rate_limited", "", "Too many commands"); return end
  if pendingCount >= MAX_PENDING then reject("server_busy", "", "Too many pending commands"); return end
  if simulationPaused() then reject("simulation_paused", "", "Resume the simulation before sending commands"); return end

  local normalized, code, field, message = validateCommand(method, msg.args)
  if not normalized then reject(code, field, message); return end
  if method == "loadSavedTune" then
    local found, catalogCode, catalogMessage = catalogContains(normalized)
    if not found then reject(catalogCode, "args", catalogMessage); return end
  end
  local dangerousClass = isDangerous(method, normalized)
  if dangerousClass then
    if not dangerousEnabled then reject("dangerous_disabled", "method", "Dangerous remote actions are disabled"); return end
    if dangerousClass == "destructive" then
      if not allowRate(p, "destructiveBucket", 0.5, 2, 1) then reject("rate_limited", "method", "Destructive action rate limit reached"); return end
    elseif not allowRate(p, "operationalBucket", 8, 16, 1) then
      reject("rate_limited", "method", "Operational control rate limit reached"); return
    end
  end

  local controllerId = writer.sessionTag .. ":" .. id
  local uiTuneRequest = method == "loadSavedTune"
  local uiFactoryResetIntent = method == "factoryReset"
  if (uiTuneRequest or uiFactoryResetIntent) and not (guihooks and guihooks.trigger) then
    reject("ui_unavailable", "", "Trusted game UI is unavailable")
    return
  end
  if uiFactoryResetIntent and pendingFactoryResetIntent then
    reject("factory_reset_pending", "method", "Another Factory Reset is waiting for the trusted game UI")
    return
  end
  if not uiTuneRequest and not uiFactoryResetIntent then
    local veh = playerVehicle()
    local queued, queueError = queueControllerCommand(veh, controllerId, method, normalized)
    if not queued then reject("queue_failed", "", tostring(queueError or "Vehicle command queue unavailable")); return end
  end

  writer.leaseUntil = remoteClock + LEASE_SECONDS
  if method == "armDyno" or (method == "setDynoAuto" and normalized.enabled) then writer.ownsDyno = true end
  if method == "cancelDyno" or (method == "setDynoAuto" and not normalized.enabled) then writer.ownsDyno = false end
  if method == "setRollingHold" then
    writer.rollingHeld = normalized.held
    writer.rollingDeadline = normalized.held and (remoteClock + HOLD_SECONDS) or 0
  end

  local pendingItem = {
    clientId = id,
    peerId = peerId, generation = writer.generation, vehicleId = currentId,
    method = method,
    expiresAt = remoteClock + (uiFactoryResetIntent and FACTORY_RESET_ACK_TIMEOUT or COMMAND_TIMEOUT),
    awaitingFactoryResetIntent = uiFactoryResetIntent
  }
  pending[controllerId] = pendingItem
  pendingCount = pendingCount + 1
  if uiTuneRequest then
    local dispatched, dispatchError = pcall(function()
      guihooks.trigger("OEProTuneRemoteTuneRequest", {
        requestId = controllerId,
        source = normalized.source,
        index = normalized.index,
        catalogGeneration = normalized.catalogGeneration,
        vehicleId = currentId,
        publisherId = tuneCatalogPublisherId,
        catalogToken = tuneCatalogToken
      })
    end)
    if not dispatched then
      pending[controllerId] = nil
      pendingCount = math.max(0, pendingCount - 1)
      reject("ui_dispatch_failed", "", tostring(dispatchError or "Could not reach the trusted game UI"))
      return
    end
  elseif uiFactoryResetIntent then
    -- Two-phase destructive commit: reserve exactly one request and ask the trusted in-game UI
    -- to persist+verify its crash-recovery marker. Only factoryResetIntentResult() may consume
    -- this capability and queue vehicle Lua; guihooks.trigger returning successfully is not an ack.
    pendingFactoryResetIntent = {
      requestId = controllerId,
      vehicleId = currentId,
      peerId = peerId,
      generation = writer.generation,
      args = normalized
    }
    local dispatched, dispatchError = pcall(function()
      guihooks.trigger("OEProTuneRemoteFactoryResetIntent", {
        requestId = controllerId,
        vehicleId = currentId
      })
    end)
    if not dispatched then
      pendingFactoryResetIntent = nil
      pending[controllerId] = nil
      pendingCount = math.max(0, pendingCount - 1)
      reject("ui_dispatch_failed", "", tostring(dispatchError or "Could not reach the trusted game UI"))
      return
    end
  end
  local result = { id = id, ok = true, error = "", method = method, phase = "queued", vehicleId = currentId }
  rememberResult(writer, result)
  sendHook(peerId, "OEPRemoteCommandResult", result)
end

local function commandResult(vehId, result)
  if not integer(vehId) or type(result) ~= "table" or not validControllerId(result.id) then return end
  if result.phase ~= "applied" and result.phase ~= "rejected" then return end
  local item = pending[result.id]
  local writer = auth.writer
  if not item or not writer or item.peerId ~= writer.peerId or item.generation ~= writer.generation then return end
  if item.vehicleId ~= vehId or currentPlayerId() ~= vehId or result.method ~= item.method then return end

  local out = {
    id = item.clientId,
    ok = result.phase == "applied" and result.ok ~= false,
    error = type(result.error) == "string" and result.error or "",
    errorCode = type(result.errorCode) == "string" and result.errorCode or nil,
    field = type(result.field) == "string" and result.field or nil,
    method = result.method,
    phase = result.phase,
    vehicleId = vehId,
    calibrationRevision = result.calibrationRevision,
    calibrationEpoch = result.calibrationEpoch
  }
  pending[result.id] = nil
  pendingCount = math.max(0, pendingCount - 1)
  if pendingFactoryResetIntent and pendingFactoryResetIntent.requestId == result.id then
    pendingFactoryResetIntent = nil
  end
  rememberResult(writer, out)
  sendHook(writer.peerId, "OEPRemoteCommandResult", out)
end

-- Trusted game-UI acknowledgement for the destructive half of a remote Factory Reset.
-- The UI must call this only after it has synchronously persisted and read-verified the
-- recovery tombstone. requestId is the opaque id from OEProTuneRemoteFactoryResetIntent;
-- both it and vehicleId must match the one retained capability exactly.
local function factoryResetIntentResult(requestId, vehId, ok, err)
  if not validControllerId(requestId) or not integer(vehId) then return false, "invalid_ack" end
  local gate = pendingFactoryResetIntent
  if not gate or gate.requestId ~= requestId or gate.vehicleId ~= vehId then return false, "stale_ack" end
  if gate.ackConsumed then return false, "duplicate_ack" end

  local item = pending[requestId]
  if not item or item.method ~= "factoryReset" or item.vehicleId ~= vehId or
      item.peerId ~= gate.peerId or item.generation ~= gate.generation or
      item.awaitingFactoryResetIntent ~= true then
    pendingFactoryResetIntent = nil
    return false, "stale_ack"
  end

  local function rejectAck(code, message)
    pendingFactoryResetIntent = nil
    pending[requestId] = nil
    pendingCount = math.max(0, pendingCount - 1)
    local activeWriter = auth.writer
    if activeWriter and activeWriter.peerId == item.peerId and activeWriter.generation == item.generation then
      local rejected = errorResult(item.clientId, item.method, item.vehicleId, code, "", message)
      rememberResult(activeWriter, rejected)
      sendHook(item.peerId, "OEPRemoteCommandResult", rejected)
    end
    return false, code
  end

  if ok ~= true and ok ~= 1 then
    local message = type(err) == "string" and err:sub(1, 160) or "The trusted game UI could not verify the Factory Reset recovery marker"
    return rejectAck("factory_reset_intent_rejected", message)
  end

  -- Consume before queueing: a duplicated/replayed UI ack can never enqueue a second reset.
  gate.ackConsumed = true
  local writer = auth.writer
  if not writeEnabled or not dangerousEnabled or not writer or writer.peerId ~= gate.peerId or
      writer.generation ~= gate.generation or remoteClock >= writer.leaseUntil then
    return rejectAck("authorization_expired", "Remote Factory Reset authorization expired before the recovery marker was confirmed")
  end
  if currentPlayerId() ~= vehId then
    return rejectAck("wrong_vehicle", "Player vehicle changed before Factory Reset confirmation")
  end
  if simulationPaused() then
    return rejectAck("simulation_paused", "Resume the simulation and request Factory Reset again")
  end

  local queued, queueError = queueControllerCommand(vehicleById(vehId), requestId, "factoryReset", gate.args or {})
  if not queued then
    return rejectAck("queue_failed", tostring(queueError or "Vehicle command queue unavailable"):sub(1, 160))
  end
  item.awaitingFactoryResetIntent = false
  item.expiresAt = remoteClock + COMMAND_TIMEOUT
  return true
end

-- Trusted game-UI completion for a reference-based saved-tune load. This feeds
-- the same pending/idempotency/result path as a controller-confirmed command.
local function tuneLoadResult(requestId, vehId, ok, err)
  local accepted = ok == true or ok == 1
  commandResult(vehId, {
    id = requestId,
    ok = accepted,
    error = accepted and "" or tostring(err or "tune_load_failed"):sub(1, 160),
    method = "loadSavedTune",
    phase = accepted and "applied" or "rejected"
  })
end

local function clearTuneCatalog()
  tuneCatalog = nil
  tuneCatalogPublisherId = nil
  tuneCatalogToken = nil
  latestByHook["OEPRemoteTuneCatalog"] = nil
end

-- Called only by the trusted in-game app. Strictly flatten and sanitize its
-- catalog before caching/broadcasting it; tune bodies and paths are rejected by
-- the exact-key checks and therefore never enter the remote transport.
local function updateTuneCatalog(catalog)
  if type(catalog) ~= "table" or not hasOnlyKeys(catalog, {
    vehicleId = true, publisherId = true, catalogToken = true, entries = true
  }) then return false, "invalid_catalog" end
  local vehicleId = catalog.vehicleId
  if not integer(vehicleId) or vehicleId < 0 or vehicleId ~= currentPlayerId() then return false, "wrong_vehicle" end
  if not validAuthorityToken(catalog.publisherId) then return false, "invalid_publisher" end
  if not validAuthorityToken(catalog.catalogToken) then return false, "invalid_catalog_token" end
  local count = arrayLength(catalog.entries)
  if count == nil or count > MAX_TUNE_CATALOG_ENTRIES then return false, "invalid_entries" end

  local entries, seen = {}, {}
  for i = 1, count do
    local entry = catalog.entries[i]
    if type(entry) ~= "table" or not hasOnlyKeys(entry, { source = true, index = true, name = true }) then return false, "invalid_entry" end
    if type(entry.source) ~= "string" or not TUNE_SOURCES[entry.source] then return false, "invalid_source" end
    if not integer(entry.index) or entry.index < 0 or entry.index > MAX_TUNE_INDEX then return false, "invalid_index" end
    if type(entry.name) ~= "string" then return false, "invalid_name" end
    local key = entry.source .. ":" .. tostring(entry.index)
    if seen[key] then return false, "duplicate_entry" end
    seen[key] = true
    local name = entry.name:gsub("[%z\1-\31\127]", " "):match("^%s*(.-)%s*$")
    if name == "" or #name > 160 then name = entry.source .. " " .. tostring(entry.index) end
    entries[#entries + 1] = { source = entry.source, index = entry.index, name = name }
  end

  tuneCatalogGeneration = tuneCatalogGeneration + 1
  tuneCatalog = { vehicleId = vehicleId, catalogGeneration = tuneCatalogGeneration, entries = entries }
  tuneCatalogPublisherId = catalog.publisherId
  tuneCatalogToken = catalog.catalogToken
  local encodedOk, encoded = pcall(jsonEncode, { hook = "OEPRemoteTuneCatalog", data = tuneCatalog })
  if not encodedOk or type(encoded) ~= "string" then clearTuneCatalog(); return false, "encode_failed" end
  latestByHook["OEPRemoteTuneCatalog"] = encoded
  safeBroadcastRaw(encoded)
  return true, tuneCatalogGeneration
end

-- tell one vehicle's ProTune controller to start/stop building remote pushes
local function pokeVehicle(veh, on)
  if not veh or not veh.queueLuaCommand then return end
  pcall(function() veh:queueLuaCommand(
    'local c = controller.getController("oeProTune") or rawget(_G, "oeProTuneExt") '
    .. 'if c and c.setRemoteDash then c.setRemoteDash(' .. (on and 'true' or 'false') .. ') end'
  ) end)
end

local function pokeAllVehicles(on)
  if not be then return end
  local okCount, count = pcall(function() return be:getObjectCount() end)
  if not okCount then return end
  for i = 0, count - 1 do
    local okObj, obj = pcall(function() return be:getObject(i) end)
    if okObj then pokeVehicle(obj, on) end
  end
end

local function setPlayerStreaming(on)
  if on and clients > 0 then pokeVehicle(playerVehicle(), true)
  else pokeAllVehicles(false) end
end

-- ask the player vehicle's controller to republish its full state (maps/settings);
-- this is how the mirror stays current WITHOUT the browser being allowed to ask
local function pollPlayerState()
  local veh = playerVehicle()
  if not veh or not veh.queueLuaCommand then return end
  pcall(function() veh:queueLuaCommand(
    'local c = controller.getController("oeProTune") or rawget(_G, "oeProTuneExt") '
    .. 'if c and c.requestState then c.requestState() end'
  ) end)
end

-- called by the vehicle controller (queueGameEngineLua) with a hook name + its data table
local function push(vehId, hook, data)
  if clients <= 0 or not PUSH_HOOKS[hook] or type(data) ~= "table" then return end
  local playerId = currentPlayerId()
  if playerId < 0 or vehId ~= playerId then return end
  data.vehId = vehId -- telemetry/dyno also need an explicit ID for BeamMP/player filtering
  local ok, encoded = pcall(jsonEncode, { hook = hook, data = data })
  if not ok or type(encoded) ~= "string" then return end
  if hook == "OEProTuneState" then latestState = data end
  latestByHook[hook] = encoded
  safeBroadcastRaw(encoded)
end

-- a probe result only counts as LAN-reachable when it's a REAL address: the loopback
-- adapter (127.x) passes its own probe even on a loopback-only bind, which is exactly
-- how the first version of this ladder got fooled on Orlando's machine
local function ipv4Parts(addr)
  addr = tostring(addr or "")
  local a, b, c, d = addr:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
  a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
  if not (a and b and c and d) then return nil end
  if a > 255 or b > 255 or c > 255 or d > 255 then return nil end
  return a, b, c, d
end

-- Only AUTO-ADVERTISE RFC1918 addresses. Besides avoiding APIPA/public addresses, this deliberately
-- excludes the 100.64/10 range commonly used by overlay VPNs (for example Tailscale), which a
-- normal phone on the home WiFi cannot route to and which should never be shown on stream.
local function isPrivateLanAddress(addr)
  local a, b = ipv4Parts(addr)
  if not a then return false end
  return a == 10 or (a == 172 and b >= 16 and b <= 31) or (a == 192 and b == 168)
end

-- A weaker gate used ONLY for an address the elevated setup helper already proved sits on a
-- Windows "Private" connection profile. Plenty of real users (university halls, offices, some ISPs
-- in PR) get a non-RFC1918 IPv4 straight onto the NIC; 3.0.1 served them and an RFC1918-only rule
-- silently took phone access away. Still refuses the ranges that are never a peer-reachable LAN:
-- loopback, APIPA link-local, multicast/reserved, this-network, broadcast, and CGNAT 100.64/10
-- (the overlay-VPN range the RFC1918 rule was written to exclude in the first place).
local function isRoutableLanAddress(addr)
  local a, b = ipv4Parts(addr)
  if not a then return false end
  if a == 0 or a == 127 or a >= 224 then return false end
  if a == 169 and b == 254 then return false end
  if a == 100 and b >= 64 and b <= 127 then return false end
  return true
end

-- Set only when an accepted setup marker vouched for a non-RFC1918 address on a Windows "Private"
-- connection profile. It widens what this session may advertise to that same campus/office LAN,
-- including after a DHCP change, without ever widening it for a machine that has no such marker.
local lanVouchNonPrivate = false

local function lanAddressAllowed(ip)
  if isPrivateLanAddress(ip) then return true end
  return lanVouchNonPrivate and isRoutableLanAddress(ip)
end

local function isLikelyVirtualAdapter(description)
  local d = tostring(description or ""):lower()
  for _, token in ipairs({
    "virtual", "hyper-v", "vethernet", "vmware", "virtualbox", "vpn", "tailscale",
    "wireguard", "openvpn", "tap-", "tunnel", "docker", "wsl", "loopback", "bluetooth"
  }) do
    if d:find(token, 1, true) then return true end
  end
  return false
end

-- LuaSocket (ships with the game); tries every module path the game's own code uses
local socketMod = nil
local function getSocket()
  if socketMod then return socketMod end
  for _, name in ipairs({ "socket", "socket.socket", "libs/luasocket/socket/socket" }) do
    local ok, mod = pcall(require, name)
    if ok and type(mod) == "table" and mod.tcp then
      socketMod = mod
      return socketMod
    end
  end
  return nil
end

local function realAdapterIps()
  local out, seen, notes = {}, {}, {}
  local function add(ip)
    ip = tostring(ip or "")
    if lanAddressAllowed(ip) and not seen[ip] then
      seen[ip] = true
      out[#out + 1] = ip
    end
  end
  -- the engine's adapter API first (when it works it also names VM adapters to skip)...
  local okA, addresses = pcall(BNGWebWSServer.getNetworkAdapterAddresses)
  if okA and type(addresses) == "table" then
    if #addresses == 0 then notes[#notes + 1] = "engine API: empty list" end
    for _, addr in ipairs(addresses) do
      local desc = tostring(addr.description or addr.name or addr.interfaceName or "")
      if not isLikelyVirtualAdapter(desc) then add(addr.ipv4Addr) end
    end
  else
    notes[#notes + 1] = "engine API failed: " .. tostring(addresses)
  end
  -- ...but on some machines it reports ONLY loopback (Orlando's did - the whole reason the
  -- dash kept coming up PC-only), so ALSO ask LuaSocket:
  local socket = getSocket()
  if socket then
    -- UDP routing trick: setpeername sends nothing, it just makes the OS pick the outbound
    -- adapter, and getsockname reveals that adapter's address. Works offline too.
    local okU, udp = pcall(socket.udp)
    if okU and udp then
      local okP, resP, errP = pcall(function() return udp:setpeername("8.8.8.8", 80) end)
      if okP and resP then
        local okN, ip = pcall(function() return udp:getsockname() end)
        if okN then add(ip) else notes[#notes + 1] = "udp getsockname failed" end
      else
        notes[#notes + 1] = "udp setpeername: " .. tostring(okP and errP or resP)
      end
      pcall(function() udp:close() end)
    else
      notes[#notes + 1] = "socket.udp: " .. tostring(udp)
    end
    -- hostname resolution catches extra adapters on multi-NIC machines
    if socket.dns then
      local okH, host = pcall(socket.dns.gethostname)
      if okH and host then
        local okR, ip1, info = pcall(socket.dns.toip, host)
        if okR and (ip1 or info) then
          add(ip1)
          if type(info) == "table" and type(info.ip) == "table" then
            for _, ip in ipairs(info.ip) do add(ip) end
          end
        else
          notes[#notes + 1] = "dns.toip failed"   -- never interpolate the hostname: it is often the user's real name
        end
      else
        notes[#notes + 1] = "dns.gethostname failed"
      end
    end
  else
    notes[#notes + 1] = "luasocket unavailable"
  end
  -- last resort: ask Windows itself. ipconfig prints every adapter; grab plausible
  -- IPv4s (locale-proof: match bare dotted quads, filter masks/broadcast/loopback).
  -- Dead candidates are harmless - the bind ladder probes each and moves on.
  if #out == 0 then
    local okP, ph = pcall(io.popen, "ipconfig")
    if okP and ph then
      local okRead, txt = pcall(ph.read, ph, "*a")
      pcall(ph.close, ph)
      if okRead and type(txt) == "string" then
        for ip in txt:gmatch("(%d+%.%d+%.%d+%.%d+)") do
          if #out < 4 then add(ip) end
        end
      else
        notes[#notes + 1] = "ipconfig read failed"
      end
    else
      notes[#notes + 1] = "io.popen unavailable: " .. tostring(ph)
    end
  end
  if #notes > 0 then
    log("I", "oeProTuneRemote", "discovery notes: " .. table.concat(notes, "; "))
  end
  return out
end

local function addLanAddress(ip, list, seen)
  ip = tostring(ip or "")
  if lanAddressAllowed(ip) and not seen[ip] then
    seen[ip] = true
    list[#list + 1] = ip
  end
end

local function rankedLanAddresses(cfg, liveIps, currentIp)
  local out, seen = {}, {}
  local live = liveIps or {}
  local liveSet = {}
  for _, ip in ipairs(live) do liveSet[tostring(ip)] = true end
  local cfgIp = (type(cfg) == "table") and tostring(cfg.ip or "") or ""

  -- V3.0.3 DHCP FIX: the saved setup address is ranked first only while it is STILL one of this
  -- session's live adapter addresses. After a new DHCP lease or a move to another network it is a
  -- dead address, and ranking it first put a dead address in the URL and the QR while the relay
  -- self-test - which probes the RELAY, not the address - still reported PASS, so no banner fired.
  -- 3.0.1 deliberately preferred the freshly detected address for exactly this reason.
  if cfgIp ~= "" and liveSet[cfgIp] then addLanAddress(cfgIp, out, seen) end
  addLanAddress(currentIp, out, seen)
  for _, ip in ipairs(live) do addLanAddress(ip, out, seen) end

  -- Saved values LAST. They still matter: on many machines the game sandbox blocks every discovery
  -- path and realAdapterIps() legitimately returns nothing, in which case these are the only
  -- addresses we have. They just must never outrank an address we can see right now.
  if type(cfg) == "table" then
    addLanAddress(cfgIp, out, seen)
    if type(cfg.candidateIps) == "table" then
      for _, ip in ipairs(cfg.candidateIps) do addLanAddress(ip, out, seen) end
    end
  end
  return out
end

local function promoteLanAddress(ip)
  local out, seen = {}, {}
  addLanAddress(ip, out, seen)
  for _, existing in ipairs(lanAddresses or {}) do addLanAddress(existing, out, seen) end
  lanAddresses = out
end

-- V5 is written only after the elevated helper verifies the active Private adapter, IP Helper,
-- port-proxy listener, firewall rule, loopback server and outside-in relay connection.
--
-- V3.0.3 UPGRADE SAFETY: a v4 marker is ACCEPTED, not rejected. The Windows objects a v4 setup
-- installed (the 0.0.0.0:LAN_PORT portproxy and the Private/LocalSubnet firewall rule) are exactly
-- the ones v5 installs - v5 only adds verification of things that were already true. Rejecting v4
-- outright took working phone access away from every existing user on update and demanded an
-- elevated re-run before it came back. A v4 marker is flagged `legacy` so the UI can offer the
-- verified re-run as an invitation instead of an outage.
--
-- The address gate is also relaxed for a v5 marker only: the elevated helper independently proves
-- the Windows connection profile is Private, so a non-RFC1918 campus/corporate LAN it vouched for
-- is trustworthy. Live auto-discovery stays RFC1918-only (see realAdapterIps).
local function readLanConfig()
  local ok, cfg = pcall(jsonReadFile, LAN_CFG)
  if not (ok and type(cfg) == "table" and cfg.relayPort == LAN_PORT
      and cfg.firewallScope == "private-local") then return nil end
  if cfg.ver == 5 and cfg.setupOk == true then
    if isPrivateLanAddress(cfg.ip) then
      cfg.legacy = false
      return cfg
    end
    if cfg.profile == "Private" and isRoutableLanAddress(cfg.ip) then
      lanVouchNonPrivate = true   -- widen this session to the LAN the elevated helper vouched for
      cfg.legacy = false
      return cfg
    end
    return nil
  end
  if cfg.ver == 4 and isPrivateLanAddress(cfg.ip) then
    cfg.legacy = true
    return cfg
  end
  return nil
end

local setupMessages = {
  ready = "Setup verified",
  setup_files_missing = "Setup files could not be created",
  no_adapter = "No active private IPv4 network adapter was found",
  public_network = "Windows network profile is not Private",
  ip_helper = "Windows IP Helper could not be started",
  portproxy = "Windows relay rule could not be created",
  firewall = "Windows firewall rule could not be verified",
  relay_listener = "Windows relay is not listening on port 8237",
  local_server = "The game dashboard server is not responding on this PC",
  relay_test = "The Windows relay could not reach the game dashboard",
  relay_restarted = "Windows relay restarted and verified",
  relay_restart_failed = "Windows relay restart failed",
  save_failed = "Setup worked but Windows could not save the result file - check that the OE ProTune settings folder is writable",
  legacy_setup = "Working from an earlier setup - re-run the setup tool when convenient for the full verified check",
  unexpected = "Setup stopped because Windows returned an unexpected error"
}

local function readSetupStatus()
  local ok, value = pcall(jsonReadFile, SETUP_STATUS)
  if not (ok and type(value) == "table") then return nil end
  local code = tostring(value.code or "")
  if not setupMessages[code] then code = "unexpected" end
  return {
    ok = value.ok == true,
    code = code,
    message = setupMessages[code],
    checkedAtUtc = tostring(value.checkedAtUtc or ""):sub(1, 40)
  }
end

local function buildStatus()
  local hasLan = lanAddressAllowed(chosenAddress)     -- a safe LAN IP was detected for this PC
  local cfg = readLanConfig()
  local setupStatus = readSetupStatus()
  local running = server ~= nil and serverHealthy
  -- V3.0.3: the LIVE config outranks the status file. remotedash_setup_status.json is a record of
  -- the last setup RUN, not of the current state - it survives long after the config it described,
  -- so letting it win produced "Setup verified" on the status row while the banner below demanded
  -- setup. A present, accepted config is direct evidence; the status file is only consulted when
  -- there is no config to speak for itself.
  local setupCode = (not setupToolsReady and "setup_files_missing")
    or (cfg and cfg.legacy and "legacy_setup")
    or (cfg and "ready")
    or (setupStatus and setupStatus.code)
    or ""
  local setupMessage = setupMessages[setupCode] or ""
  local urls = {}
  if running then
    for _, ip in ipairs(lanAddresses) do
      if lanAddressAllowed(ip) then urls[#urls + 1] = "http://" .. ip .. ":" .. LAN_PORT end
    end
  end
  if #urls == 0 and running and hasLan then urls[1] = "http://" .. tostring(chosenAddress) .. ":" .. LAN_PORT end
  local st = {
    loaded = true, -- getStatus exists only while this extension is loaded; stays true even when bind/startup failed
    running = running,
    healthy = running,
    recoveryNeeded = not running,
    -- PC access NEVER needs the relay: the game serves itself on loopback. This is the address the
    -- in-game page should open/copy - it can't fail. (The old code advertised the LAN IP here, so a
    -- dead relay made even the game PC show connection-refused.)
    pcUrl = running and ("http://127.0.0.1:" .. PORT) or "",
    url = running and ("http://127.0.0.1:" .. PORT) or "",   -- back-compat alias (older callers read .url)
    -- Phone access goes through the relay on LAN_PORT. Only meaningful once setup ran (lanConfigured).
    lanUrl = (running and hasLan) and ("http://" .. tostring(chosenAddress) .. ":" .. LAN_PORT) or "",
    lanUrls = urls,
    lanAddressCount = #urls,
    lanPort = LAN_PORT,
    port = PORT,
    clients = clients,
    lanConfigured = cfg ~= nil,
    lanReachable = lanReachable,               -- live watchdog: true / false / nil(unknown)
    setupToolsReady = setupToolsReady,
    setupCode = setupCode,
    setupMessage = setupMessage,
    setupCheckedAtUtc = setupStatus and setupStatus.checkedAtUtc or "",
    setupDir = SETUP_DIR,
    protocolVersion = PROTOCOL_VERSION,
    writeEnabled = writeEnabled,
    writerConnected = auth.writer ~= nil,
    dangerousEnabled = dangerousEnabled,
    revision = statusRevision
  }
  return st
end

broadcastStatus = function()
  if not server then return end
  local paused = false
  if simTimeAuthority and simTimeAuthority.getPause then
    local okPause, value = pcall(function() return simTimeAuthority.getPause() end)
    paused = okPause and value and true or false
  end
  local st = buildStatus()
  -- Browser clients already know the one host they connected to. Do not broadcast the game PC's
  -- other private addresses, setup path, or local setup diagnostics over WebSocket.
  st.lanUrl = nil
  st.lanUrls = nil
  st.lanAddressCount = nil
  st.setupDir = nil
  st.setupCode = nil
  st.setupMessage = nil
  st.setupCheckedAtUtc = nil
  st.setupToolsReady = nil
  st.paused = paused
  st.playerVehId = currentPlayerId()
  local ok, encoded = pcall(jsonEncode, { hook = "OEPRemoteStatus", data = st })
  if ok and type(encoded) == "string" then
    latestByHook["OEPRemoteStatus"] = encoded
    safeBroadcastRaw(encoded)
  end
end

-- BIND LADDER (0.38.6 quirk, found on Orlando's machine): the game's own extApp passes 'any' as
-- the listen address, but on this build that comes up bound to 127.0.0.1 ONLY - phones and tablets
-- get connection refused while the PC itself works. So try real bind addresses in order and keep
-- the FIRST one whose LAN probe passes: createOrGetWS already probes every physical adapter with
-- an actual WebSocket connection and only returns a non-'localhost' chosenAddress when one
-- answered, so 'chosenAddress ~= localhost' IS the "phones can reach this" signal.
-- WebSocket self-test (the game's own wsUtils.testWSConnection pattern): connect a
-- client to our own server over loopback, send a magic token, and PUMP BOTH ENDS
-- between checks - the server only processes I/O in update(), so a blocking probe
-- would deadlock against a healthy server (that false alarm shipped once; never again).
-- Only a real, correctly-bound BNGWebWSServer echoes the token back through the peer
-- queue, so a stale portproxy relay looping to itself can't false-pass either.
local function wsSelfTest(srv)
  if not (BNGWSClient and BNGWSClient.getOrCreate) then return true end   -- cannot test: don't block on it
  local magic = "oeprotune-selftest-" .. tostring(math.random(100000, 999999))
  local okC, client = pcall(BNGWSClient.getOrCreate, "127.0.0.1", PORT, "/", PROTOCOL)
  if not okC or not client then return false end
  pcall(function() client:sendData(magic) end)
  local seen = false
  for _ = 1, 8 do
    pcall(function() client:update() end)
    pcall(function() srv:update() end)
    local okE, events = pcall(function() return srv:getPeerEvents() end)
    if okE and type(events) == "table" then
      for _, e in ipairs(events) do
        if e.type == "D" and e.msg == magic then seen = true end
      end
    end
    if seen then break end
  end
  pcall(function() BNGWSClient.destroy(client) end)
  return seen
end

-- plain TCP connect probe: tests a listening socket exactly the way a phone
-- would, with a real timeout (getSocket defined up with realAdapterIps)
local function tcpProbe(ip, port, timeout)
  local socket = getSocket()
  if not socket then return false end
  local ok, sck = pcall(socket.tcp)
  if not ok or not sck then return false end
  sck:settimeout(timeout or 0.8)
  local res = sck:connect(ip, port)
  pcall(function() sck:close() end)
  return res ~= nil
end

-- cheap current-LAN-IP lookup: the UDP routing trick ONLY (no io.popen, no DNS), so it's safe to
-- call every watchdog tick to follow a mid-session DHCP IP change. Returns a real IP or nil.
local function primaryLanIp()
  local socket = getSocket()
  if not socket then return nil end
  local okU, udp = pcall(socket.udp)
  if not (okU and udp) then return nil end
  local ip
  local okP, resP = pcall(function() return udp:setpeername("8.8.8.8", 80) end)
  if okP and resP then
    local okN, got = pcall(function() return udp:getsockname() end)
    if okN then ip = got end
  end
  pcall(function() udp:close() end)
  return lanAddressAllowed(ip) and ip or nil
end

-- drop the one-time LAN setup tool + a "revive" helper next to the config so the in-game page can
-- point at them. v4: the relay LISTENS on 0.0.0.0:LAN_PORT (every address -> survives DHCP IP
-- changes) and forwards to the game on 127.0.0.1:PORT. Using a SECOND port is what makes the
-- wildcard bind safe: a wildcard on PORT itself would seize the game's own loopback:PORT bind (the
-- v1 exclusive-bind conflict: UI said RUNNING while nothing listened). The firewall permits only
-- Windows Private-profile LocalSubnet peers. Both files need admin.
local function crlf(text)
  text = tostring(text or ""):gsub("^\r?\n", "")
  return text:gsub("\r?\n", "\r\n")
end

local function writeGeneratedFile(path, marker, contents)
  local okRead, existing = pcall(readFile, path)
  if okRead and type(existing) == "string" and existing:find(marker, 1, true) then return true end
  local okWrite = pcall(writeFile, path, contents)
  if not okWrite then return false end
  local okVerify, written = pcall(readFile, path)
  return okVerify and type(written) == "string" and written:find(marker, 1, true) ~= nil
end

local function ensureSetupTool()
  local okDir = pcall(function()
    if not FS:directoryExists(SETUP_DIR) then FS:directoryCreate(SETUP_DIR, true) end
  end)
  if not okDir then return false end

  local bat = crlf([[
@echo off
setlocal
rem OE ProTune Remote Dash - privacy-safe auto-elevating setup launcher v5.1.
set "OEP_SETUP_SELF=%~f0"
set "OEP_STATUS=%~dp0remotedash_setup_status.json"
set "OEP_SETUP_DIR=%~dp0"
rem The sentinel makes a second elevation attempt structurally impossible. Without it, any machine
rem where fltmc fails EVEN WHEN ELEVATED (stripped/debloated images) re-launched itself forever,
rem stacking UAC prompts the user could not escape.
if "%~1"=="--oep-elevated" goto :run
fltmc >nul 2>&1
if not errorlevel 1 goto :run
echo Requesting administrator permission...
powershell -NoProfile -ExecutionPolicy Bypass -Command "try { Start-Process -FilePath $env:OEP_SETUP_SELF -ArgumentList '--oep-elevated' -Verb RunAs } catch { exit 1 }"
if errorlevel 1 (
  echo.
  echo Administrator permission was refused or cancelled. Setup made no changes.
  echo If no prompt appeared, right-click this file and choose "Run as administrator".
  pause
)
exit /b

:run
fltmc >nul 2>&1
if errorlevel 1 (
  echo.
  echo Could not confirm administrator rights on this PC.
  echo Right-click this file and choose "Run as administrator", then let it continue.
  pause
  exit /b 1
)
rem Note the status file's previous state so a script Windows refused to run can be told apart
rem from a script that ran and reported a real problem.
if exist "%OEP_STATUS%" (set "OEP_HADSTATUS=1") else (set "OEP_HADSTATUS=0")
del "%OEP_STATUS%" >nul 2>&1
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0EnableRemoteDash_LAN.ps1"
set "OEP_SETUP_EXIT=%errorlevel%"
if not exist "%OEP_STATUS%" (
  rem The script never got far enough to write its own result: almost always Group Policy blocking
  rem script FILES. Retry by piping the body in, which policy does not gate.
  echo.
  echo Windows blocked the setup script file. Retrying in a mode Group Policy allows...
  type "%~dp0EnableRemoteDash_LAN.ps1" | powershell -NoProfile -Command -
  set "OEP_SETUP_EXIT=%errorlevel%"
)
echo.
if "%OEP_SETUP_EXIT%"=="0" (
  echo Return to OE ProTune and toggle the Remote Dashboard server OFF and ON.
) else (
  if not exist "%OEP_STATUS%" (
    echo Windows would not run the setup script on this PC ^(PowerShell script execution is
    echo disabled by policy^). Nothing was changed. Ask whoever manages this PC to allow it,
    echo or use the PC address instead - phone access is the only part affected.
  ) else (
    echo Follow the safe error shown above, then run this setup again.
  )
)
pause
exit /b %OEP_SETUP_EXIT%
]])

  local ps1 = crlf([[
# OE ProTune Remote Dash - verified privacy-safe LAN setup v5.1.
# This file is generated by the mod. It never prints, logs, or copies the PC's address.
$ErrorActionPreference = 'Stop'
# $PSScriptRoot is EMPTY when the body is piped in via stdin (the Group Policy fallback path in
# the .bat), so the launcher also exports the folder. Both paths must resolve identically.
$scriptDir = if ($PSScriptRoot) { $PSScriptRoot } elseif ($env:OEP_SETUP_DIR) { $env:OEP_SETUP_DIR } else { (Get-Location).Path }
$configPath = Join-Path $scriptDir 'remotedash_lan.json'
$statusPath = Join-Path $scriptDir 'remotedash_setup_status.json'
$script:failureCode = 'unexpected'
$script:configWritten = $false
$safeMessages = @{
  ready = 'Setup verified. The private-network relay is ready.'
  no_adapter = 'No active physical private IPv4 adapter was found. Disconnect VPNs and check Ethernet or Wi-Fi.'
  public_network = 'The selected Windows network is not Private. Change it to Private in Windows Settings, then run setup again.'
  ip_helper = 'Windows IP Helper could not be started, so the relay socket cannot be created. It may be blocked by a debloat/privacy script.'
  save_failed = 'Setup worked, but the result file could not be saved. Check that the OE ProTune settings folder is writable.'
  portproxy = 'Windows could not create or verify the Remote Dashboard relay.'
  firewall = 'Windows could not create or verify the Private/LocalSubnet firewall rule.'
  relay_listener = 'The relay rule exists, but Windows is not listening on the phone-access port.'
  local_server = 'The OE ProTune dashboard server is not responding. Enable it in-game and run setup again.'
  relay_test = 'The Windows relay could not reach the in-game dashboard server.'
  unexpected = 'Windows returned an unexpected setup error.'
}

function Write-SetupStatus([bool]$ok, [string]$code) {
  $status = [ordered]@{
    ok = $ok
    code = $code
    checkedAtUtc = [DateTime]::UtcNow.ToString('o')
    ver = 5
  }
  $status | ConvertTo-Json -Compress | Set-Content -LiteralPath $statusPath -Encoding ascii
}

# Plain regex + integer ranges rather than [Net.IPAddress]::TryParse, so the whole script still
# runs under PowerShell Constrained Language Mode (no .NET type calls).
function Get-IPv4Octets([string]$address) {
  if ($address -notmatch '^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$') { return $null }
  $o = @([int]$Matches[1], [int]$Matches[2], [int]$Matches[3], [int]$Matches[4])
  foreach ($x in $o) { if ($x -gt 255) { return $null } }
  return $o
}

function Test-PrivateIPv4([string]$address) {
  $b = Get-IPv4Octets $address
  if ($null -eq $b) { return $false }
  return ($b[0] -eq 10) -or
    ($b[0] -eq 172 -and $b[1] -ge 16 -and $b[1] -le 31) -or
    ($b[0] -eq 192 -and $b[1] -eq 168)
}

# Widened gate for LANs that are not RFC1918 but are still a real peer-reachable local network
# (university halls, offices, some ISPs). Only ever accepted together with a Private Windows
# profile. Excludes what can never be a LAN peer: loopback, APIPA, multicast/reserved, and the
# CGNAT 100.64/10 range used by overlay VPNs such as Tailscale.
function Test-RoutableLanIPv4([string]$address) {
  $b = Get-IPv4Octets $address
  if ($null -eq $b) { return $false }
  if ($b[0] -eq 0 -or $b[0] -eq 127 -or $b[0] -ge 224) { return $false }
  if ($b[0] -eq 169 -and $b[1] -eq 254) { return $false }
  if ($b[0] -eq 100 -and $b[1] -ge 64 -and $b[1] -le 127) { return $false }
  return $true
}

function Test-TcpPort([string]$address, [int]$port, [int]$timeoutMs = 1500) {
  $client = New-Object Net.Sockets.TcpClient
  try {
    $task = $client.ConnectAsync($address, $port)
    if (-not $task.Wait($timeoutMs)) { return $false }
    return $client.Connected
  } catch {
    return $false
  } finally {
    $client.Dispose()
  }
}

function Test-RelayListener {
  try {
    return @(Get-NetTCPConnection -State Listen -LocalPort __LAN_PORT__ -ErrorAction Stop).Count -gt 0
  } catch {
    return ((netstat -ano -p tcp) -match '^\s*TCP\s+\S+:__LAN_PORT__\s+\S+\s+LISTENING').Count -gt 0
  }
}

try {
  # The game's own server is checked FIRST, before the marker is touched and before any netsh
  # change. Running setup with BeamNG closed is an ordinary mistake, and when this probe came last
  # it aborted AFTER deleting a perfectly good marker - a precautionary re-run destroyed working
  # phone access. Nothing below this point runs unless there is something to relay to.
  $script:failureCode = 'local_server'
  if (-not (Test-TcpPort '127.0.0.1' __PORT__)) { throw 'local_server' }

  # Matched against the DRIVER description only. The adapter Name is user-editable, and someone who
  # renamed their NIC "VPN box" or "Docker rig" should not lose phone access over it.
  $virtualPattern = '(?i)(virtual|hyper-v|vethernet|vmware|virtualbox|tailscale|wireguard|openvpn|tap-|tunnel|docker|wsl|loopback|bluetooth)'
  $routeMetric = @{}
  foreach ($route in @(Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue)) {
    if ($route.NextHop -eq '0.0.0.0') { continue }
    $metric = [int]$route.RouteMetric
    if (-not $routeMetric.ContainsKey($route.InterfaceIndex) -or $metric -lt $routeMetric[$route.InterfaceIndex]) {
      $routeMetric[$route.InterfaceIndex] = $metric
    }
  }

  $candidates = @()
  foreach ($adapter in @(Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceOperationalStatus -eq 1 })) {
    $hasDefaultRoute = $routeMetric.ContainsKey($adapter.InterfaceIndex)
    # A "virtual-looking" adapter is PENALISED, never excluded. On a Hyper-V host the real LAN IP
    # lives on a vEthernet external switch, and excluding it outright left those users with no
    # phone access at all. If traffic actually leaves through it, it is the LAN adapter whatever
    # its driver is called - so an adapter owning the default route is never penalised.
    $virtualPenalty = 0
    if (-not $hasDefaultRoute -and $adapter.InterfaceDescription -match $virtualPattern) { $virtualPenalty = 1000000 }
    $profile = Get-NetConnectionProfile -InterfaceIndex $adapter.InterfaceIndex -ErrorAction SilentlyContinue | Select-Object -First 1
    $ipInterface = Get-NetIPInterface -AddressFamily IPv4 -InterfaceIndex $adapter.InterfaceIndex -ErrorAction SilentlyContinue | Select-Object -First 1
    $baseMetric = if ($hasDefaultRoute) { $routeMetric[$adapter.InterfaceIndex] } else { 100000 }
    $interfaceMetric = if ($ipInterface) { [int]$ipInterface.InterfaceMetric } else { 10000 }
    $adapterText = "$($adapter.Name) $($adapter.InterfaceDescription)"
    foreach ($ip in @(Get-NetIPAddress -AddressFamily IPv4 -InterfaceIndex $adapter.InterfaceIndex -AddressState Preferred -ErrorAction SilentlyContinue)) {
      $isRfc1918 = Test-PrivateIPv4 $ip.IPAddress
      if ($isRfc1918 -or (Test-RoutableLanIPv4 $ip.IPAddress)) {
        $candidates += [pscustomobject]@{
          IPAddress = $ip.IPAddress
          InterfaceIndex = $adapter.InterfaceIndex
          Profile = if ($profile) { [string]$profile.NetworkCategory } else { 'Unknown' }
          AddressRank = if ($isRfc1918) { 0 } else { 1 }
          Score = $baseMetric + $interfaceMetric + $virtualPenalty
          Kind = if ($adapterText -match '(?i)(wi-fi|wireless|wlan|802\.11)') { 'WiFi' } elseif ($adapterText -match '(?i)(ethernet|gbe|network connection)') { 'Ethernet' } else { 'Physical' }
        }
      }
    }
  }

  # Private profile first, then a home-router RFC1918 address ahead of a campus/office one.
  $selected = $candidates |
    Sort-Object @{Expression={ if ($_.Profile -eq 'Private') { 0 } else { 1 } }}, AddressRank, Score, IPAddress |
    Select-Object -First 1
  if (-not $selected) {
    $script:failureCode = 'no_adapter'
    throw 'no_adapter'
  }
  if ($selected.Profile -ne 'Private') {
    $script:failureCode = 'public_network'
    throw 'public_network'
  }

  # The relay socket is created by IP Helper, not by netsh - netsh only writes a registry rule and
  # reports success either way. Debloat/"privacy tweak" scripts routinely disable iphlpsvc, and it
  # has a HARD dependency on WinHttpAutoProxySvc, so with WPAD disabled it can never start and
  # Windows reports only a bare error 1068. Repair both, in dependency order.
  $script:failureCode = 'ip_helper'
  foreach ($svcName in @('WinHttpAutoProxySvc', 'iphlpsvc')) {
    $svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue
    if (-not $svc) { if ($svcName -eq 'iphlpsvc') { throw 'ip_helper' } else { continue } }
    if ((Get-Service -Name $svcName).StartType -eq 'Disabled') {
      Set-Service -Name $svcName -StartupType Manual -ErrorAction SilentlyContinue
    }
    if ($svc.Status -ne 'Running') {
      Start-Service -Name $svcName -ErrorAction SilentlyContinue
      try { $svc.WaitForStatus('Running', [TimeSpan]::FromSeconds(8)) } catch {}
    }
  }
  # Leave IP Helper set to start by itself, or the relay silently dies at the next reboot and the
  # user is told phone access "dropped" for no visible reason.
  if ((Get-Service -Name iphlpsvc).StartType -ne 'Automatic') {
    Set-Service -Name iphlpsvc -StartupType Automatic -ErrorAction SilentlyContinue
  }
  if ((Get-Service -Name iphlpsvc).Status -ne 'Running') { throw 'ip_helper' }

  # Delete only OE ProTune's historical ports. Never reset or disturb unrelated portproxy rules.
  foreach ($line in @(netsh interface portproxy show v4tov4)) {
    if ($line -match '^\s*(\d{1,3}(?:\.\d{1,3}){3})\s+(\d+)\s+(\d{1,3}(?:\.\d{1,3}){3})\s+(\d+)\s*$') {
      $listenAddress = $Matches[1]
      $listenPort = [int]$Matches[2]
      if ($listenPort -eq __PORT__ -or $listenPort -eq __LAN_PORT__) {
        netsh interface portproxy delete v4tov4 "listenaddress=$listenAddress" "listenport=$listenPort" | Out-Null
      }
    }
  }

  $script:failureCode = 'portproxy'
  netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=__LAN_PORT__ connectaddress=127.0.0.1 connectport=__PORT__ | Out-Null
  if ($LASTEXITCODE -ne 0) { throw 'portproxy' }
  $proxyVerified = $false
  foreach ($line in @(netsh interface portproxy show v4tov4)) {
    if ($line -match '^\s*0\.0\.0\.0\s+__LAN_PORT__\s+127\.0\.0\.1\s+__PORT__\s*$') { $proxyVerified = $true }
  }
  if (-not $proxyVerified) { throw 'portproxy' }

  $script:failureCode = 'firewall'
  netsh advfirewall firewall delete rule name="OE ProTune Remote Dash" | Out-Null
  netsh advfirewall firewall add rule name="OE ProTune Remote Dash" dir=in action=allow protocol=TCP localport=__LAN_PORT__ profile=private remoteip=LocalSubnet edge=no | Out-Null
  if ($LASTEXITCODE -ne 0) { throw 'firewall' }
  $firewall = Get-NetFirewallRule -DisplayName 'OE ProTune Remote Dash' -ErrorAction SilentlyContinue |
    Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' -and ([string]$_.Profile -match 'Private') } |
    Select-Object -First 1
  if (-not $firewall) { throw 'firewall' }
  $portFilter = $firewall | Get-NetFirewallPortFilter -ErrorAction SilentlyContinue
  $addressFilter = $firewall | Get-NetFirewallAddressFilter -ErrorAction SilentlyContinue
  if (-not ($portFilter.Protocol -eq 'TCP' -and [string]$portFilter.LocalPort -eq '__LAN_PORT__')) { throw 'firewall' }
  if ([string]$addressFilter.RemoteAddress -notmatch 'LocalSubnet') { throw 'firewall' }

  $script:failureCode = 'relay_listener'
  $listenerReady = $false
  for ($attempt = 0; $attempt -lt 8 -and -not $listenerReady; $attempt++) {
    Start-Sleep -Milliseconds 350
    $listenerReady = Test-RelayListener
  }
  if (-not $listenerReady) { throw 'relay_listener' }

  $script:failureCode = 'local_server'
  if (-not (Test-TcpPort '127.0.0.1' __PORT__)) { throw 'local_server' }

  $script:failureCode = 'relay_test'
  if (-not (Test-TcpPort $selected.IPAddress __LAN_PORT__)) { throw 'relay_test' }

  $candidateIps = @($selected.IPAddress)
  foreach ($candidate in @($candidates | Sort-Object AddressRank, Score, IPAddress)) {
    if ($candidate.Profile -eq 'Private' -and $candidateIps -notcontains $candidate.IPAddress) {
      $candidateIps += $candidate.IPAddress
    }
  }
  # Every gate above has passed. From here a failure is a FILE problem, not a network problem, and
  # must say so instead of blaming the relay that just answered.
  $script:failureCode = 'save_failed'
  $checked = (Get-Date).ToUniversalTime().ToString('o')
  $config = [ordered]@{
    ip = $selected.IPAddress
    candidateIps = $candidateIps
    adapterKind = $selected.Kind
    relayPort = __LAN_PORT__
    ver = 5
    setupOk = $true
    firewallScope = 'private-local'
    profile = 'Private'
    listenerOk = $true
    relayOk = $true
    checkedAtUtc = $checked
  }
  # Write beside the marker and swap in only once the bytes are on disk, so an interrupted or
  # unwritable save can never leave the user with no marker and a relay that works fine.
  $tempPath = "$configPath.tmp"
  $config | ConvertTo-Json -Depth 3 -Compress | Set-Content -LiteralPath $tempPath -Encoding ascii
  Move-Item -LiteralPath $tempPath -Destination $configPath -Force
  $script:configWritten = $true
  Write-SetupStatus $true 'ready'
  Write-Host ''
  Write-Host 'SUCCESS: Remote Dashboard phone access was verified.' -ForegroundColor Green
  Write-Host 'No network address was printed or copied. Reveal it only inside OE ProTune when needed.'
  exit 0
} catch {
  Remove-Item -LiteralPath "$configPath.tmp" -Force -ErrorAction SilentlyContinue
  # NEVER delete a marker this run did not write. The previous one describes Windows rules that are
  # still installed and still working; throwing it away turned a failed precautionary re-run into
  # an outage. (If we got far enough to write our own, it is verified-good and also stays.)
  $code = if ($safeMessages.ContainsKey($script:failureCode)) { $script:failureCode } else { 'unexpected' }
  try { Write-SetupStatus $false $code } catch {}
  Write-Host ''
  Write-Host ('SETUP STOPPED: ' + $safeMessages[$code]) -ForegroundColor Red
  if ($script:configWritten) {
    Write-Host 'Your existing phone-access setup was left untouched.'
  }
  Write-Host 'No network address was printed, logged, or copied.'
  exit 1
}
]])
  ps1 = ps1:gsub("__LAN_PORT__", tostring(LAN_PORT)):gsub("__PORT__", tostring(PORT))

  -- One-click fix for the common post-sleep/network-change relay drop. It also auto-elevates and
  -- verifies that Windows recreated the listener before claiming success.
  local revive = crlf([[
@echo off
setlocal
rem OE ProTune Remote Dash - privacy-safe auto-elevating relay revive v2.1.
set "OEP_REVIVE_SELF=%~f0"
if "%~1"=="--oep-elevated" goto :run
fltmc >nul 2>&1
if not errorlevel 1 goto :run
echo Requesting administrator permission...
powershell -NoProfile -ExecutionPolicy Bypass -Command "try { Start-Process -FilePath $env:OEP_REVIVE_SELF -ArgumentList '--oep-elevated' -Verb RunAs } catch { exit 1 }"
if errorlevel 1 (
  echo.
  echo Administrator permission was refused or cancelled. No changes were made.
  pause
)
exit /b

:run
fltmc >nul 2>&1
if errorlevel 1 (
  echo.
  echo Could not confirm administrator rights. Right-click this file and choose
  echo "Run as administrator".
  pause
  exit /b 1
)
echo Rebuilding the Remote Dashboard relay...
rem Poll for up to ~5s. IP Helper recreates the portproxy listener asynchronously and regularly
rem takes longer than a second; a single 800ms sleep reported RELAY RESTART FAILED on a relay that
rem came up perfectly well a moment later. The setup script budgets 2.8s for this same event.
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; Restart-Service iphlpsvc -Force; for($i=0;$i -lt 12;$i++){ Start-Sleep -Milliseconds 400; if(Get-NetTCPConnection -State Listen -LocalPort __LAN_PORT__ -ErrorAction SilentlyContinue){ exit 0 } }; exit 2"
if errorlevel 1 (
  echo.
  echo RELAY RESTART FAILED. Run EnableRemoteDash_LAN.bat again.
  pause
  exit /b 1
)
echo.
echo SUCCESS: The relay listener is running. No network address was displayed or copied.
pause
exit /b 0
]])
  revive = revive:gsub("__LAN_PORT__", tostring(LAN_PORT))

  local okBat = writeGeneratedFile(SETUP_BAT, "setup launcher v5.1", bat)
  local okPs1 = writeGeneratedFile(SETUP_PS1, "verified privacy-safe LAN setup v5.1", ps1)
  local okRevive = writeGeneratedFile(REVIVE_BAT, "relay revive v2.1", revive)
  return okBat and okPs1 and okRevive
end

-- REVIEW FIX 2026-07-29 (follow-up). Picking the phone/LAN address used to live INSIDE the
-- wsSelfTest success branch only. When that probe threw a false negative on 0.39 the server still
-- came up and the deferred re-check restored `serverHealthy`, but lanAddresses was never populated -
-- so the app showed "Server RUNNING" together with "PHONE ACCESS CAN'T START - no safe
-- private-network address was found this session", and Screens connected stayed 0. Health and the
-- LAN address have to recover together, so this is now callable from both paths.
-- The verified setup-selected physical Private adapter stays first. Additional safe private
-- candidates are retained so the player can try another address without exposing them by default.
-- Note realAdapterIps() legitimately returns nothing on many machines (the game sandbox blocks both
-- the UDP-routing trick and io.popen - see the "discovery notes" log line); rankedLanAddresses falls
-- back to the saved marker address in that case, which is why this still yields a usable URL.
local function selectLanAddress(cfg)
  lanAddresses = rankedLanAddresses(cfg, realAdapterIps(), nil)
  return lanAddresses[1]
end

-- REVIEW FIX 2026-07-29 (follow-up). A recovered bind must also recover what the blocked-port branch
-- SKIPPED: the phone/LAN address. Without this the app showed "Server RUNNING" (health restored by the
-- deferred probe) next to "PHONE ACCESS CAN'T START - no safe private-network address was found this
-- session", with Screens connected stuck at 0 - health and the address recovering independently.
-- Only acts when the startup verdict really was blocked-port, i.e. chosenAddress is still the
-- localhost placeholder; a normal successful start already chose the right address and must not be
-- clobbered. Also re-arms the relay watchdog so "Windows relay self-test" stops sitting on CHECKING.
local function promoteBlockedPortBind(reason)
  log("I", "oeProTuneRemote", tostring(reason) .. " - discarding the startup 'port blocked' verdict as a false negative")
  if chosenAddress ~= nil and chosenAddress ~= "localhost" then return end
  local cfg = readLanConfig()
  if not cfg then return end   -- no verified LAN setup: nothing to restore, PC-only is correct
  local ip = selectLanAddress(cfg)
  if not ip then
    log("W", "oeProTuneRemote", "bind recovered but no private LAN address could be selected - phone access stays PC-only")
    return
  end
  chosenAddress = ip
  lanReachable = nil
  watchTimer = 0
  log("I", "oeProTuneRemote", "phone/LAN address selected after bind recovery; relay self-test will re-run")
  if broadcastStatus then broadcastStatus() end
end

local function startServer()
  setupToolsReady = ensureSetupTool()
  lanAddresses = {}
  local mk = function (addr)
    local ok, srv = pcall(BNGWebWSServer.getOrCreate, addr, PORT, "./", PROTOCOL, DASH_PAGE, false)
    return ok and srv or nil
  end
  -- LAN setup tool already ran (any version): Windows relays LAN->loopback for us. The game server
  -- itself always binds loopback here; phones reach it through the relay. THE path on sandboxed
  -- builds (0.38.6+).
  local cfg = readLanConfig()
  if cfg then
    local srv = mk("any")
    if srv then
      -- HEALTH CHECK: getOrCreate can return a server object whose bind silently failed
      -- (seen when the v1 wildcard relay held the port) - never report RUNNING on a dead
      -- port. The self-test pumps the server while probing, so a healthy server passes.
      if wsSelfTest(srv) then
        local ip = selectLanAddress(cfg)
        log("I", "oeProTuneRemote", "loopback server + verified Windows relay; private LAN address selected")
        return srv, ip, "portproxy"
      end
      -- REVIEW FIX 2026-07-29: downgraded from "E" to "W" and reworded. This probe demands a full
      -- WebSocket handshake plus echo inside 8 tight pump iterations with no wall-clock time between
      -- them, so it is a race against the native socket scheduler - not proof of anything. It is
      -- retried on a real clock in onUpdate, and any real browser connect overrides it outright.
      log("W", "oeProTuneRemote", "PORT " .. PORT .. " did not answer the startup self-test - the port may be held by an old wildcard relay rule, OR the probe was simply too early. Re-checking on a real clock; if a browser connects, this verdict is discarded. If the dashboard stays unreachable, re-run EnableRemoteDash_LAN.bat as administrator, then toggle the server.")
      -- keep the server so the UI can show the setup banner instead of a bare "Off"
      return srv, "localhost", "blocked-port"
    end
  end
  local ips = realAdapterIps()
  lanAddresses = rankedLanAddresses(nil, ips, nil)
  log("I", "oeProTuneRemote", "safe private adapters discovered: " .. tostring(#ips))
  -- no addresses discoverable at all: keep an UNVERIFIED wildcard bind alive instead of
  -- tearing it down - if the engine honors it, phones can connect even though we can't
  -- name (or verify) the address ourselves
  if #ips == 0 then
    local srv = mk("0.0.0.0")
    if srv then
      log("W", "oeProTuneRemote", "keeping unverified wildcard bind; verified phone setup is still required")
      return srv, "localhost", "0.0.0.0"
    end
  end
  -- CALIBRATE the probe first: an 'any' bind listens on loopback at minimum (netstat-verified
  -- on the machine this was debugged on), so if we can't even connect to 127.0.0.1 the PROBE
  -- mechanism is broken - not the bind - and probe verdicts must not be trusted.
  local calib = mk("any")
  if not calib then
    log("E", "oeProTuneRemote", "getOrCreate failed outright on port " .. PORT .. " (port in use by another program?)")
    return nil
  end
  local probeWorks = tcpProbe("127.0.0.1", PORT)
  log("I", "oeProTuneRemote", "probe calibration on loopback: " .. (probeWorks and "working" or ("BROKEN (luasocket " .. (getSocket() and "loaded" or "unavailable") .. ") - will trust explicit binds")))

  if probeWorks then
    -- wildcard binds first (serve every adapter if honored), then each adapter directly
    local candidates = { "0.0.0.0", "any" }
    for _, ip in ipairs(ips) do candidates[#candidates + 1] = ip end
    pcall(BNGWebWSServer.destroy, calib)
    for _, cand in ipairs(candidates) do
      local srv = mk(cand)
      if srv then
        for _, ip in ipairs(ips) do
          if tcpProbe(ip, PORT) then
            log("I", "oeProTuneRemote", "a private adapter accepted the bind probe")
            promoteLanAddress(ip)
            return srv, ip, cand   -- phones can reach this
          end
        end
        log("I", "oeProTuneRemote", "bind candidate was not reachable; trying the next safe candidate")
        pcall(BNGWebWSServer.destroy, srv)
      else
        log("I", "oeProTuneRemote", "bind candidate creation failed; trying the next safe candidate")
      end
    end
  elseif ips[1] then
    -- probe is unusable: bind the primary adapter EXPLICITLY and trust it (the engine honors
    -- explicit addresses - its own c2 extension binds "127.0.0.1" and gets exactly that)
    pcall(BNGWebWSServer.destroy, calib)
    local srv = mk(ips[1])
    if srv then
      log("I", "oeProTuneRemote", "trusting an explicit private-adapter bind (probe unavailable)")
      promoteLanAddress(ips[1])
      return srv, ips[1], ips[1]
    end
  end

  -- nothing LAN-reachable: still serve this machine
  local srv = mk("any")
  if srv then return srv, "localhost", "any" end
  return nil
end

local function onExtensionLoaded()
  local bindUsed
  server, chosenAddress, bindUsed = startServer()
  serverBindingUsable = server ~= nil and bindUsed ~= "blocked-port"
  serverHealthy = serverBindingUsable
  -- REVIEW FIX 2026-07-29: arm the deferred re-check whenever the startup probe was unconvinced.
  bindProven = false
  bindRecheckTimer = 2
  bindRecheckLeft = (server ~= nil and not serverBindingUsable) and 5 or 0
  nativeFailures = 0
  remoteClock = 0
  writeEnabled = false
  dangerousEnabled = false
  auth.writer = nil
  auth.generation = 0
  clients = 0 -- wsSelfTest is synthetic and must never count as a browser
  peers, pending, pendingCount = {}, {}, 0
  pendingFactoryResetIntent = nil
  latestByHook = {}
  latestState = nil
  tuneCatalogGeneration = 0
  clearTuneCatalog()
  if server then
    local hasLan = lanAddressAllowed(chosenAddress)
    log("I", "oeProTuneRemote", "Remote Dash up - PC: http://127.0.0.1:" .. PORT
      .. (hasLan and "  |  private LAN relay ready (address hidden)"
                  or "  |  THIS PC ONLY - run EnableRemoteDash_LAN.bat (as admin) for phone access")
      .. " (bind '" .. tostring(bindUsed) .. "')")
    -- No controller streaming until the first browser connects.
    setPlayerStreaming(false)
    broadcastStatus()
  else
    log("E", "oeProTuneRemote", "failed to create Remote Dash server on port " .. PORT)
  end
end

local function onExtensionUnloaded()
  releaseWriter("extension_unloaded")
  writeEnabled = false
  dangerousEnabled = false
  setPlayerStreaming(false)
  if server then
    local okDestroy, err = pcall(BNGWebWSServer.destroy, server)
    if not okDestroy then noteNativeFailure("destroy", err) end
    server = nil
  end
  serverHealthy = false
  serverBindingUsable = false
  bindProven = false            -- REVIEW FIX 2026-07-29: a stop must re-arm the probe, not inherit a verdict
  bindRecheckTimer = 0
  bindRecheckLeft = 0
  latestByHook = {}
  latestState = nil
  clearTuneCatalog()
  clients = 0
  peers, pending, pendingCount = {}, {}, 0
  pendingFactoryResetIntent = nil
  lanReachable = nil
  everReachable = false
  watchTimer = 0
  lanAddresses = {}
end

local function onVehicleSwitched()
  -- fresh car: drop the old car's cached envelopes so new connections don't see stale maps
  releaseWriter("vehicle_switched")
  setPlayerStreaming(false)
  latestByHook = {}
  latestState = nil
  clearTuneCatalog()
  if server and clients > 0 then
    setPlayerStreaming(true)
    pollPlayerState()
  end
  broadcastStatus()
end

local function onUpdate(dt)
  if not server then return end

  local delta = tonumber(dt) or 0
  if delta > 0 then remoteClock = remoteClock + delta end

  -- REVIEW FIX 2026-07-29. The load-time wsSelfTest completes a WebSocket handshake + echo inside 8
  -- tight pump iterations with no wall-clock time, so a slower or rescheduled native socket layer
  -- (exactly what a BeamNG update can change) fails it while real browsers connect perfectly. That
  -- verdict then latched for the entire session, because serverBindingUsable was computed once at
  -- load and re-asserted every frame just below - so the banner's own advice, toggle the server off
  -- and on, could never clear it. Re-check on a real clock instead.
  --
  -- Deliberately tcpProbe and NOT wsSelfTest: wsSelfTest drains server:getPeerEvents() and discards
  -- everything it does not recognise, so calling it here would swallow real browser connect events.
  -- A plain loopback TCP connect proves a listener exists and touches nothing.
  if bindRecheckLeft > 0 and not serverBindingUsable and not bindProven then
    bindRecheckTimer = bindRecheckTimer - delta
    if bindRecheckTimer <= 0 then
      bindRecheckTimer = 2
      bindRecheckLeft = bindRecheckLeft - 1
      if tcpProbe("127.0.0.1", PORT, 0.4) then
        serverBindingUsable = true
        bindRecheckLeft = 0
        promoteBlockedPortBind("deferred loopback probe")
      end
    end
  end

  local okUpdate, updateErr = pcall(function() server:update() end)
  if okUpdate then
    nativeFailures = 0
    -- bindProven outranks the startup probe: see the peer-connect handler below.
    serverHealthy = serverBindingUsable or bindProven
  else
    noteNativeFailure("update", updateErr)
  end

  local events = {}
  local okEvents, result = pcall(function() return server:getPeerEvents() end)
  if okEvents and type(result) == "table" then events = result
  elseif not okEvents then noteNativeFailure("getPeerEvents", result) end

  for _, evt in ipairs(events) do
    if evt.type == "C" then
      local p = peerFor(evt.peerId)
      if not p.connected then
        p.connected = true
        clients = clients + 1
        -- REVIEW FIX 2026-07-29: a browser just completed a WebSocket handshake against this server.
        -- That is direct proof the port is bound and serving, and it outranks anything the startup
        -- probe concluded. Without this the app could report "Screens connected: 3" and "SERVER COULD
        -- NOT BIND ITS PORT" on the same screen - and because every address/QR button is gated on
        -- remote.healthy, the false verdict also hid the buttons for opening the dashboard.
        if not bindProven then
          bindProven = true
          bindRecheckLeft = 0
          if not serverBindingUsable then
            serverBindingUsable = true
            promoteBlockedPortBind("a browser connected")
          end
        end
      end
      if clients == 1 then setPlayerStreaming(true) end
      sendAuth(evt.peerId, "hello", "hello", true, nil, {
        vehicleId = currentPlayerId(), writeEnabled = writeEnabled,
        writerConnected = auth.writer ~= nil, dangerousEnabled = dangerousEnabled
      })
      -- replay the latest of everything so a fresh browser paints instantly
      -- (state first - the mirror needs maps/settings before telemetry makes sense)
      for _, hook in ipairs({ "OEPRemoteStatus", "OEProTuneState", "OEPRemoteTuneCatalog", "OEProTuneTelemetry", "OEProTuneDyno" }) do
        if latestByHook[hook] then safeSendRaw(evt.peerId, latestByHook[hook]) end
      end
      pollPlayerState()   -- and ask the car for a fresh state right away
    elseif evt.type == "DC" then
      if auth.writer and auth.writer.peerId == evt.peerId then releaseWriter("disconnected") end
      local p = peers[evt.peerId]
      if p and p.connected then clients = math.max(0, clients - 1) end
      peers[evt.peerId] = nil
      if clients == 0 then
        setPlayerStreaming(false)
        latestState = nil
        for hook in pairs(PUSH_HOOKS) do latestByHook[hook] = nil end
      end
    elseif evt.type == "D" then
      local p = peerFor(evt.peerId)
      local msg = evt.msg
      if remoteClock >= (p.quarantinedUntil or 0) and type(msg) == "string" then
        if #msg > MAX_FRAME_BYTES then
          addStrike(evt.peerId)
        elseif msg == "ping" then
          if allowRate(p, "generalBucket", 30, 60, 1) then safeSendRaw(evt.peerId, "pong") else addStrike(evt.peerId) end
        elseif msg ~= "" and allowRate(p, "generalBucket", 30, 60, 1) then
          local okJson, data = pcall(jsonDecode, msg)
          if okJson and type(data) == "table" and data.v == PROTOCOL_VERSION then
            if data.t == "claim" then handleClaim(evt.peerId, data, false)
            elseif data.t == "pair" then handleClaim(evt.peerId, data, true) -- cached-page compatibility alias
            elseif data.t == "heartbeat" then handleHeartbeat(evt.peerId, data)
            elseif data.t == "cmd" then handleCommand(evt.peerId, data)
            else addStrike(evt.peerId) end
          elseif okJson and type(data) == "table" and data.t == "ctl" then
            -- V1 control is intentionally read-only. Never parse or execute its raw Lua string.
          else
            addStrike(evt.peerId)
          end
        else
          addStrike(evt.peerId)
        end
      end
    end
  end

  local writer = auth.writer
  if writer then
    if currentPlayerId() ~= writer.vehicleId then
      releaseWriter("wrong_vehicle")
      writer = nil
    elseif remoteClock >= writer.leaseUntil then
      releaseWriter("lease_expired")
      writer = nil
    elseif writer.rollingHeld and remoteClock >= writer.rollingDeadline then
      releaseMomentariesFor(writer.vehicleId, false)
      writer.rollingHeld = false
      writer.rollingDeadline = 0
    end
  end

  for controllerId, item in pairs(pending) do
    if remoteClock >= item.expiresAt then
      if not item.awaitingFactoryResetIntent and simulationPaused() then
        -- Vehicle Lua may be intentionally suspended; do not claim failure and then apply on resume.
        item.expiresAt = remoteClock + COMMAND_TIMEOUT
      else
        pending[controllerId] = nil
        pendingCount = math.max(0, pendingCount - 1)
        if pendingFactoryResetIntent and pendingFactoryResetIntent.requestId == controllerId then
          pendingFactoryResetIntent = nil
        end
        local activeWriter = auth.writer
        if activeWriter and activeWriter.generation == item.generation and activeWriter.peerId == item.peerId then
          local waitingForIntent = item.awaitingFactoryResetIntent == true
          local timedOut = errorResult(item.clientId, item.method, item.vehicleId,
            waitingForIntent and "factory_reset_intent_timeout" or "controller_timeout", "",
            waitingForIntent and "Trusted game UI did not confirm the Factory Reset recovery marker" or "Controller did not confirm the command")
          rememberResult(activeWriter, timedOut)
          sendHook(item.peerId, "OEPRemoteCommandResult", timedOut)
        end
      end
    end
  end

  -- re-poke every few seconds: a controller that re-initialized (part change,
  -- respawn, newly spawned car) starts with the remote flag off
  pokeTimer = pokeTimer + (dt or 0)
  if pokeTimer >= 3 then
    pokeTimer = 0
    if clients > 0 then pokeVehicle(playerVehicle(), true) end
  end

  if clients > 0 then
    -- keep the mirror's maps/settings fresh server-side (the browser never asks)
    stateTimer = stateTimer + (dt or 0)
    if stateTimer >= 2 then
      stateTimer = 0
      pollPlayerState()
    end
  end

  -- pause flag + player vehicle id for the mirror's bngApi shim
  statusTimer = statusTimer + (dt or 0)
  if statusTimer >= 1 then
    statusTimer = 0
    broadcastStatus()
  end

  -- LAN RELAY WATCHDOG (V3.0): the game serves loopback only; phones depend on the Windows relay,
  -- which can silently drop its socket after sleep or a network change (rule stays in the registry,
  -- nothing listens). We can't see the relay from inside the sandbox except by probing our own LAN
  -- address outside-in with luasocket. Probing our OWN IP means a dead relay returns
  -- connection-refused instantly (no timeout hang), and a live probe only opens+closes one TCP
  -- connection to the relay.
  -- LATCH (everReachable): report 'false' only AFTER a real 'true', so a machine where luasocket
  -- can't reach the self-IP at all stays 'unknown' (nil) instead of false-alarming.
  watchTimer = watchTimer + (dt or 0)
  if watchTimer >= 8 then
    watchTimer = 0
    -- Reachability comes from the PROBE ALONE. We must NOT treat clients>0 as proof the relay is up:
    -- the game binds loopback, so a browser opened on the game PC itself (127.0.0.1 - the advertised
    -- "second monitor" use) counts as a client and is indistinguishable from a phone via the relay.
    -- Trusting it would hide a real relay-down - the exact false-OK this watchdog exists to catch.
    -- Short timeout so a black-holed IP can't hitch the frame; probing our OWN IP normally returns
    -- instantly (accept when the relay is up, connection-refused/RST when it's down).
    if getSocket() and lanAddressAllowed(chosenAddress) then
      if tcpProbe(chosenAddress, LAN_PORT, 0.25) then
        everReachable = true
        lanReachable = true
      else
        -- The current address failed. Our IP may have changed (DHCP renew / WiFi roam), so re-detect
        -- the live default-route IP and retry ONCE before declaring the relay down - a single-NIC IP
        -- change then self-heals (URL/QR follow it). We only SWITCH when the newly-detected address
        -- actually probes, so a multi-homed PC (Ethernet + WiFi) is never flipped off a still-working
        -- adapter just because the default route is on the other NIC.
        local live = primaryLanIp()
        if live and live ~= chosenAddress and tcpProbe(live, LAN_PORT, 0.25) then
          chosenAddress = live
          promoteLanAddress(live)
          everReachable = true
          lanReachable = true
        elseif everReachable then
          lanReachable = false
        else
          lanReachable = nil
        end
      end
    else
      lanReachable = nil
    end
  end

end

M.onExtensionLoaded = onExtensionLoaded
M.onExtensionUnloaded = onExtensionUnloaded
M.onVehicleSwitched = onVehicleSwitched
M.onUpdate = onUpdate
M.push = push
M.commandResult = commandResult
M.updateTuneCatalog = updateTuneCatalog
M.tuneLoadResult = tuneLoadResult
M.factoryResetIntentResult = factoryResetIntentResult
M.getStatus = function() return buildStatus() end
-- Open the local dashboard in the system DEFAULT browser, from the game PC. The engine global
-- openWebBrowser (what the stock Options screen calls) does NOT fire in every gameplay context -
-- field-confirmed dead from the in-game app - so the reliable path is a Windows .url internet
-- shortcut opened with its default handler (= the browser), using the same writeFile + Engine.Platform
-- primitives the relay .bat setup already uses successfully. Loopback address only (nothing sensitive).
-- Returns a short status string the UI toasts; the UI copies the URL as a last resort if this fails.
-- Open the dashboard, folder-reveal method (field-chosen): openWebBrowser is a silent no-op on this
-- build, and BeamNG gameplay exposes no true launch API - only Engine.Platform.openFile, which REVEALS
-- a file in Explorer. So write a one-file .html launcher (meta-refresh straight to the loopback dash,
-- confirmed to work when opened) and reveal it; the user double-clicks OpenDashboard to launch it.
M.openDashInBrowser = function()
  local url = "http://127.0.0.1:" .. tostring(PORT)
  local html = '<!doctype html><meta charset="utf-8"><title>Open OE ProTune Dashboard</title>' ..
    '<meta http-equiv="refresh" content="0;url=' .. url .. '">' ..
    '<body style="font:15px Arial;background:#111;color:#eee;padding:24px">Opening the OE ProTune dashboard&hellip;<br><br>' ..
    'If it does not load, click here: <a style="color:#4ea3ff" href="' .. url .. '">' .. url .. '</a></body>'
  local ok = pcall(function()
    if FS and not FS:directoryExists(SETUP_DIR) then FS:directoryCreate(SETUP_DIR, true) end
    writeFile(SETUP_DIR .. "OpenDashboard.html", html)
    Engine.Platform.openFile(SETUP_DIR .. "OpenDashboard.html")
  end)
  pcall(function() writeFile(SETUP_DIR .. "opendash_debug.txt", "url=" .. url .. "\nfolder-reveal ok=" .. tostring(ok)) end)
  return ok and "folder" or "failed"
end
-- REMOTE EDITING gate, flipped by the in-game Remote Dash page. Off by default every load
-- (never persisted server-side) so a shared-WiFi session can't leave editing open unnoticed.
M.setWriteEnabled = function(on)
  local want = (on == true or on == 1)
  if want == writeEnabled then broadcastStatus(); return end
  writeEnabled = want
  if writeEnabled then
    dangerousEnabled = false
    bumpStatusRevision()
  else
    releaseWriter("editing_disabled")
    dangerousEnabled = false
    bumpStatusRevision()
  end
  log("I", "oeProTuneRemote", "remote editing " .. (writeEnabled and "ENABLED" or "disabled"))
  broadcastStatus()
end

M.setDangerousEnabled = function(on)
  local want = (on == true or on == 1) and writeEnabled and auth.writer ~= nil
  if want == dangerousEnabled then broadcastStatus(); return end
  dangerousEnabled = want
  if not dangerousEnabled and auth.writer then
    releaseMomentariesFor(auth.writer.vehicleId, auth.writer.ownsDyno == true)
    auth.writer.ownsDyno = false
    auth.writer.rollingHeld = false
    auth.writer.rollingDeadline = 0
  end
  bumpStatusRevision()
  log("I", "oeProTuneRemote", "dangerous remote actions " .. (dangerousEnabled and "ENABLED" or "disabled"))
  broadcastStatus()
end

M.revokeWriter = function()
  releaseWriter("revoked_by_game")
end

return M
