-- StreamAuditX — by StrmrX (strmrx.com)
-- Reads what OBS already knows but never tells you, and reports it in clear, everyday language:
-- an OBS Health score + every finding as  what's wrong  ->  why you'd care  ->  the exact click-path to fix.
-- Filter by mode: Quick (safe to go live?), Deep (everything), or by category (Audio, Performance, …).
-- Nothing is ever changed. This panel only looks and explains.
--
-- SETUP (one time):
--   1. Tools > Scripts > + > pick this file
--   2. Click "Open StreamAuditX" — it opens in your browser, OR
--   3. For a panel inside OBS: View > Docks > Custom Browser Docks…
--      Name: StreamAuditX    URL: (see "Panel inside OBS" in the script description)

local obs = obslua
local bit = require("bit")

local HTML_PATH = script_path() .. "streamauditx.html"
local HTML_PATH_BURIED = script_path() .. "streamauditx-buried.html"
local SCAN_MS = 8000   -- how often the panel refreshes while loaded (kept light; heavy checks are off this timer)

-- ── no-console Windows plumbing ────────────────────────────
-- Everything filesystem-ish here used to shell out to cmd.exe (dir/copy/del/start/findstr).
-- OBS is a GUI app, so EVERY cmd spawn flashes a visible console window and blocks the UI
-- thread — a button click fired several in a row, which read as lag plus scary "am I being
-- hacked?" window flashes. Talk to the Windows API directly via LuaJIT ffi instead: zero
-- child processes, zero windows. The old cmd calls survive only as a fallback for the
-- (unexpected) case where ffi isn't available.
local ffi_ok, ffi = pcall(require, "ffi")
SX_OS = (ffi_ok and ffi) and ffi.os or "Windows"   -- "Windows" / "Linux" / "OSX"; collectors gate on this
local winapi = nil
if ffi_ok and ffi then
  local ok_def = pcall(function()
    ffi.cdef[[
typedef struct { unsigned long dwLowDateTime; unsigned long dwHighDateTime; } SX_FILETIME;
typedef struct {
  unsigned long dwFileAttributes;
  SX_FILETIME ftCreationTime; SX_FILETIME ftLastAccessTime; SX_FILETIME ftLastWriteTime;
  unsigned long nFileSizeHigh; unsigned long nFileSizeLow;
  unsigned long dwReserved0; unsigned long dwReserved1;
  char cFileName[260]; char cAlternateFileName[14];
} SX_FIND_DATAA;
void* FindFirstFileA(const char* pattern, SX_FIND_DATAA* out);
int FindNextFileA(void* h, SX_FIND_DATAA* out);
int FindClose(void* h);
int GetDiskFreeSpaceExA(const char* path, unsigned long long* freeUser, unsigned long long* total, unsigned long long* freeAll);
int CreateDirectoryA(const char* path, void* attrs);
int CopyFileA(const char* src, const char* dst, int failIfExists);
int DeleteFileA(const char* path);
void* ShellExecuteA(void* hwnd, const char* verb, const char* file, const char* params, const char* dir, int show);
typedef struct { unsigned long dwLength; unsigned long dwMemoryLoad;
  unsigned long long ullTotalPhys; unsigned long long ullAvailPhys;
  unsigned long long ullTotalPageFile; unsigned long long ullAvailPageFile;
  unsigned long long ullTotalVirtual; unsigned long long ullAvailVirtual;
  unsigned long long ullAvailExtendedVirtual; } SX_MEMSTATEX;
int GlobalMemoryStatusEx(SX_MEMSTATEX* m);
int GetSystemTimes(SX_FILETIME* idleT, SX_FILETIME* kernelT, SX_FILETIME* userT);
typedef struct { unsigned long CStatus; double doubleValue; } SX_PDH_FMT_VALUE;
typedef struct { char* szName; SX_PDH_FMT_VALUE FmtValue; } SX_PDH_FMT_ITEM;
long PdhOpenQueryA(const char* src, uintptr_t userData, void** query);
long PdhAddEnglishCounterA(void* query, const char* path, uintptr_t userData, void** counter);
long PdhCollectQueryData(void* query);
long PdhGetFormattedCounterArrayA(void* counter, unsigned long format, unsigned long* bufSize, unsigned long* itemCount, void* buffer);
long PdhCloseQuery(void* query);
long RegGetValueA(void* hKey, const char* subKey, const char* value, unsigned long flags, unsigned long* type, void* data, unsigned long* dataSize);
void* CreateFileW(const unsigned short* name, unsigned long access, unsigned long share, void* sec, unsigned long disp, unsigned long flags, void* tmpl);
int DeviceIoControl(void* h, unsigned long code, void* inbuf, unsigned long insize, void* outbuf, unsigned long outsize, unsigned long* ret, void* overlapped);
int CloseHandle(void* h);
unsigned long GetLogicalDrives(void);
typedef struct { unsigned long DiskNumber; long long StartingOffset; long long ExtentLength; } SX_DISK_EXTENT;
typedef struct { unsigned long NumberOfDiskExtents; SX_DISK_EXTENT Extents[16]; } SX_VOLUME_DISK_EXTENTS;
unsigned int mach_host_self(void);
int host_statistics(unsigned int host, int flavor, unsigned int* info, unsigned int* count);
int host_statistics64(unsigned int host, int flavor, unsigned int* info, unsigned int* count);
int sysctlbyname(const char* name, void* oldp, size_t* oldlenp, void* newp, size_t newlen);
void* EvtQuery(void* session, const char* path, const char* query, unsigned long flags);
int EvtNext(void* results, unsigned long count, void** events, unsigned long timeout, unsigned long flags, unsigned long* returned);
int EvtRender(void* context, void* fragment, unsigned long flags, unsigned long bufferSize, void* buffer, unsigned long* bufferUsed, unsigned long* propertyCount);
int EvtClose(void* object);
]]
  end)
  if ok_def and SX_OS == "Windows" then
    local ok_shell, shell32 = pcall(ffi.load, "shell32")
    winapi = { shell32 = ok_shell and shell32 or nil }
  end
end

-- List files (never directories) matching dir..glob. Returns {{name=,mtime=},...} unsorted,
-- or nil when the API door is closed (caller falls back to cmd).
local function win_list(dir, glob)
  if not winapi then return nil end
  local out = {}
  local okl = pcall(function()
    local fd = ffi.new("SX_FIND_DATAA")
    local h = ffi.C.FindFirstFileA(dir .. glob, fd)
    if h == ffi.cast("void*", -1) then return end
    repeat
      if bit.band(tonumber(fd.dwFileAttributes), 16) == 0 then   -- 16 = FILE_ATTRIBUTE_DIRECTORY
        local name = ffi.string(fd.cFileName)
        if name ~= "" then
          out[#out + 1] = { name = name,
            mtime = tonumber(fd.ftLastWriteTime.dwHighDateTime) * 4294967296 + tonumber(fd.ftLastWriteTime.dwLowDateTime) }
        end
      end
    until ffi.C.FindNextFileA(h, fd) == 0
    ffi.C.FindClose(h)
  end)
  if not okl then return nil end
  return out
end

-- List SUBDIRECTORIES of dir (dir must end with a backslash). {{name=,mtime=},...};
-- empty when the winapi door is closed.
function win_list_dirs(dir)
  local out = {}
  if not winapi then return out end
  pcall(function()
    local fd = ffi.new("SX_FIND_DATAA")
    local h = ffi.C.FindFirstFileA(dir .. "*", fd)
    if h == ffi.cast("void*", -1) then return end
    repeat
      if bit.band(tonumber(fd.dwFileAttributes), 16) ~= 0 then
        local name = ffi.string(fd.cFileName)
        if name ~= "." and name ~= ".." then
          out[#out + 1] = { name = name,
            mtime = tonumber(fd.ftLastWriteTime.dwHighDateTime) * 4294967296 + tonumber(fd.ftLastWriteTime.dwLowDateTime) }
        end
      end
    until ffi.C.FindNextFileA(h, fd) == 0
    ffi.C.FindClose(h)
  end)
  return out
end

-- Open a file or URL with the user's default browser/app, silently (no console flash).
local function sx_open(target)
  if winapi and winapi.shell32 then
    local okx = pcall(function() winapi.shell32.ShellExecuteA(nil, "open", target, nil, nil, 1) end)
    if okx then return end
  end
  os.execute('start "" "' .. target .. '"')
end

-- ── NVIDIA GPU collector (NVML) ────────────────────────────
-- Deep GPU telemetry straight from the driver's own library (nvml.dll ships with
-- every NVIDIA driver, lives in System32): utilization, VRAM, temperature, encoder
-- load, power draw and throttle reasons. Pure in-process ffi — no subprocess, no
-- console window, ever. Probed ONCE at load: a PC without an NVIDIA GPU (or any
-- hiccup at all) just leaves nvml nil and every GPU field stays absent. AMD/Intel
-- and the cross-vendor fallback come as their own collectors later.
local nvml = nil          -- { lib=, dev= } once the door is open
local function nvml_init()
  if not (ffi_ok and ffi) then return end
  pcall(function()
    ffi.cdef[[
typedef void* sxNvmlDevice_t;
typedef struct { unsigned int gpu; unsigned int memory; } sxNvmlUtilization_t;
typedef struct { unsigned long long total; unsigned long long free; unsigned long long used; } sxNvmlMemory_t;
int nvmlInit_v2(void);
int nvmlShutdown(void);
int nvmlDeviceGetHandleByIndex_v2(unsigned int index, sxNvmlDevice_t* device);
int nvmlDeviceGetUtilizationRates(sxNvmlDevice_t device, sxNvmlUtilization_t* utilization);
int nvmlDeviceGetMemoryInfo(sxNvmlDevice_t device, sxNvmlMemory_t* memory);
int nvmlDeviceGetTemperature(sxNvmlDevice_t device, int sensorType, unsigned int* temp);
int nvmlDeviceGetEncoderUtilization(sxNvmlDevice_t device, unsigned int* utilization, unsigned int* samplingPeriodUs);
int nvmlDeviceGetPowerUsage(sxNvmlDevice_t device, unsigned int* milliwatts);
int nvmlDeviceGetCurrentClocksThrottleReasons(sxNvmlDevice_t device, unsigned long long* reasons);
]]
  end)   -- a reload can hit "already defined"; the types still exist, so carry on
  pcall(function()
    local lib   -- nvml.dll on Windows; libnvidia-ml on Linux (same API, same fields)
    for _, nm in ipairs({ "nvml", "nvidia-ml", "libnvidia-ml.so.1" }) do
      local okl, l = pcall(ffi.load, nm)
      if okl then lib = l; break end
    end
    if not lib then return end
    if lib.nvmlInit_v2() ~= 0 then return end
    local dev = ffi.new("sxNvmlDevice_t[1]")
    if lib.nvmlDeviceGetHandleByIndex_v2(0, dev) ~= 0 then lib.nvmlShutdown(); return end
    nvml = { lib = lib, dev = dev[0] }
  end)
end
local function nvml_close()
  if nvml then pcall(function() nvml.lib.nvmlShutdown() end); nvml = nil end
end

-- One GPU reading per health sample. Every field is -1 (absent) unless its own call
-- succeeds, so a partial driver answer still yields whatever it CAN measure.
-- gpu_thr is the driver's throttle-reason bitmask with the two "normal state" bits
-- (idle 0x1, app clock setting 0x2) cleared. Note 0x4 (software power cap) is routine
-- boost behavior on most cards; the genuinely-bad bits are 0x8 (external slowdown),
-- 0x20/0x40 (thermal) and 0x80 (power brake) — detection below keys on those.
local function nvml_read()
  local g = { gpu = -1, gpu_temp = -1, vram_pct = -1, vram_gb = -1, gpu_enc = -1, gpu_pow = -1, gpu_thr = -1 }
  if not nvml then return g end
  pcall(function()
    local u = ffi.new("sxNvmlUtilization_t")
    if nvml.lib.nvmlDeviceGetUtilizationRates(nvml.dev, u) == 0 then g.gpu = tonumber(u.gpu) end
    local m = ffi.new("sxNvmlMemory_t")
    if nvml.lib.nvmlDeviceGetMemoryInfo(nvml.dev, m) == 0 then
      local used, tot = tonumber(m.used), tonumber(m.total)
      if tot and tot > 0 then
        g.vram_pct = math.floor(100 * used / tot + 0.5)
        g.vram_gb = math.floor(used / 1073741824 * 10 + 0.5) / 10
      end
    end
    local tmp = ffi.new("unsigned int[1]")
    if nvml.lib.nvmlDeviceGetTemperature(nvml.dev, 0, tmp) == 0 then g.gpu_temp = tonumber(tmp[0]) end
    local enc, per = ffi.new("unsigned int[1]"), ffi.new("unsigned int[1]")
    if nvml.lib.nvmlDeviceGetEncoderUtilization(nvml.dev, enc, per) == 0 then g.gpu_enc = tonumber(enc[0]) end
    local mw = ffi.new("unsigned int[1]")
    if nvml.lib.nvmlDeviceGetPowerUsage(nvml.dev, mw) == 0 then g.gpu_pow = math.floor(tonumber(mw[0]) / 1000 + 0.5) end
    local th = ffi.new("unsigned long long[1]")
    if nvml.lib.nvmlDeviceGetCurrentClocksThrottleReasons(nvml.dev, th) == 0 then
      g.gpu_thr = bit.band(tonumber(th[0]) or 0, bit.bnot(3))
    end
  end)
  return g
end

-- ── Sustained-saturation flags (deterministic, free-dock honest) ─────────
-- Raised only after ~32s continuously over threshold (4 samples in a row) and cleared
-- the same way, so a single spike never flips anything. cpu = whole-PC CPU pegged;
-- gpu = GPU compute pegged; hot = thermal or hardware throttling (the serious
-- gpu_thr bits, or a genuinely hot core). The dock meter surfaces these live, and
-- the server emits the matching system.*.saturated events from the same series.
local SAT_RUN = 4
local sx_sat = { cpu_run = 0, gpu_run = 0, hot_run = 0, cpu = false, gpu = false, hot = false }
local function sat_mark(k, over)
  if over then
    sx_sat[k .. "_run"] = math.min(SAT_RUN, sx_sat[k .. "_run"] + 1)
    if sx_sat[k .. "_run"] >= SAT_RUN and not sx_sat[k] then
      sx_sat[k] = true
      print("StreamAuditX: sustained load flagged (" .. k .. ")")
    end
  else
    sx_sat[k .. "_run"] = math.max(0, sx_sat[k .. "_run"] - 1)
    if sx_sat[k .. "_run"] == 0 and sx_sat[k] then sx_sat[k] = false end
  end
end
local function sx_health_events(h)   -- h = the sample just taken (health_last)
  if not h then return end
  sat_mark("cpu", (h.cpu_sys or -1) >= 92)
  sat_mark("gpu", (h.gpu or -1) >= 97)
  local thr = math.max(0, h.gpu_thr or 0)
  sat_mark("hot", bit.band(thr, 0xE8) ~= 0 or (h.gpu_temp or -1) >= 87 or (h.cpu_temp or -1) >= 95)
end

-- ── PC health sampler ──────────────────────────────────────
-- Feather-light: two Windows API calls plus OBS's own counters every HEALTH_MS, appended
-- as one JSON line to a local file. Runs on its OWN timer, so auto-pause-on-stream (which
-- stops the audit scans to free CPU) does NOT stop it: recording the PC's behavior DURING
-- the stream is the whole point, the Expert Read lines these samples up against frame
-- loss minute by minute. Manual Pause stops this too; that promise stays absolute.
-- Nothing leaves the PC unless the user runs a read.
local HEALTH_MS = 8000
local health_path = nil    -- %APPDATA%\StreamAuditX\health.jsonl (set in script_load)
local health_last = nil    -- newest sample, drives the live dock meter
local cpu_prev = nil       -- previous GetSystemTimes totals, for delta CPU%
local obs_cpu_info = nil   -- obslua os_cpu_usage_info handle (OBS process CPU%)
-- Dock health dashboard state, ONE table on purpose (top-level locals are scarce).
-- The functions that fill it live after the archive helpers they need; they are
-- fields on this table so they are reachable from everywhere without new locals.
sxdash = {
  buf = {},                -- in-memory sample ring (~6h at 8s), seeded from health.jsonl at load
  BUF_MAX = 2700,
  js_path = nil,           -- script_path() .. "streamauditx-health.js" (set in script_load)
  pick = nil,              -- packed series for the picked streamed session (slow scan)
  trend = nil, tcache = {},-- across-streams stat rows + per-sidecar cache (slow scan)
  FREE_KEEP = 3,           -- archived streams a free/unknown member keeps (premium = ARCHIVE_KEEP)
  man_path = nil,          -- %APPDATA%\StreamAuditX\premium-sessions.txt (protected-session manifest)
  vm_path = nil,           -- %APPDATA%\StreamAuditX\vm-latest.txt (shared VoiceMeeter config copy)
  kept = 0,                -- archived streams currently on disk (drives the history messaging)
}

-- ── cross-vendor + cross-OS collectors ─────────────────────
-- Phase 6: the sampler works on ANY rig, not just NVIDIA-on-Windows.
--   Windows without NVML (AMD, Intel, any GPU at all): the OS's own PDH
--     "GPU Engine" / "GPU Adapter Memory" performance counters give utilization,
--     encoder load and VRAM on every vendor (no temperature there; absent stays -1).
--   Linux: /proc/stat + /proc/meminfo + hwmon for CPU/RAM/CPU temp, and the
--     amdgpu sysfs files for GPU (NVIDIA on Linux comes through NVML above).
--   Mac: CPU/RAM via host_statistics/sysctl; deep GPU is deferred on purpose
--     (Apple Silicon telemetry needs elevated powermetrics; spec decision #5).
-- Every door is probed once, everything is pcall'd, and a metric that cannot be
-- measured stays -1: a collector may degrade to silence, never to a lie. The pure
-- parsing/assembly logic lives on sxdash (cpu_from_stat, mem_from_meminfo,
-- gpu_from_pdh, vram_from_pdh) so the off-OBS dev harness unit-tests it.
sxcol = {
  pdh = nil,        -- { lib=, q=, cg=, cm=, primed= } once the PDH door is open
  vram_total = -1,  -- whole-card GB from the registry, for the PDH vram % readout
  lin = { prev = nil, hwmon = nil, card = nil, ghw = nil },   -- Linux probe cache
  mac = { prev = nil, total_gb = -1, page = 4096 },
}

function sxcol.slurp(path)   -- tiny whole-file read for /proc and /sys pseudo-files
  local f = io.open(path, "r")
  if not f then return nil end
  local d = f:read("*a")
  f:close()
  if d == nil or d == "" then return nil end
  return d
end

function sxcol.slurp_bin(path)   -- whole-file BINARY read: Report.wer is UTF-16LE, and Windows
  local f = io.open(path, "rb")  -- text mode would stop dead at any stray 0x1A byte in it
  if not f then return nil end
  local d = f:read("*a")
  f:close()
  if d == nil or d == "" then return nil end
  return d
end

-- Windows PDH fallback: one query held open for the script's life, collected every
-- sampler tick. Rate counters only have a value from the SECOND collection, so the
-- first tick after load reports absent and everything is real from ~8s in.
function sxcol.pdh_init()
  if sxcol.pdh or not (ffi_ok and ffi) or SX_OS ~= "Windows" then return end
  pcall(function()
    local pdh = ffi.load("pdh")
    local q = ffi.new("void*[1]")
    if pdh.PdhOpenQueryA(nil, 0, q) ~= 0 then return end
    local cg, cm = ffi.new("void*[1]"), ffi.new("void*[1]")
    local okg = pdh.PdhAddEnglishCounterA(q[0], "\\GPU Engine(*)\\Utilization Percentage", 0, cg) == 0
    local okm = pdh.PdhAddEnglishCounterA(q[0], "\\GPU Adapter Memory(*)\\Dedicated Usage", 0, cm) == 0
    if not okg and not okm then pdh.PdhCloseQuery(q[0]); return end
    pdh.PdhCollectQueryData(q[0])
    sxcol.pdh = { lib = pdh, q = q[0], cg = okg and cg[0] or nil, cm = okm and cm[0] or nil, primed = false }
  end)
  -- Whole-card VRAM size for the % readout: the display driver writes it to the
  -- registry at install time (qwMemorySize under the display-adapter class key).
  pcall(function()
    local adv = ffi.load("advapi32")
    local HKLM = ffi.cast("void*", 0x80000002)
    local best = 0
    for i = 0, 15 do
      local sub = string.format("SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}\\%04d", i)
      local val = ffi.new("unsigned long long[1]")
      local sz = ffi.new("unsigned long[1]", 8)
      if adv.RegGetValueA(HKLM, sub, "HardwareInformation.qwMemorySize", 0x40, nil, val, sz) == 0 then
        local b = tonumber(val[0]) or 0
        if b > best then best = b end
      end
    end
    if best > 0 then sxcol.vram_total = best / 1073741824 end
  end)
end

function sxcol.pdh_read()
  local g = { gpu = -1, gpu_temp = -1, vram_pct = -1, vram_gb = -1, gpu_enc = -1, gpu_pow = -1, gpu_thr = -1 }
  local p = sxcol.pdh
  if not p then return g end
  pcall(function()
    if p.lib.PdhCollectQueryData(p.q) ~= 0 then return end
    if not p.primed then p.primed = true; return end
    local MORE = bit.tobit(0x800007D2)   -- PDH_MORE_DATA as the signed long the call returns
    local function arr(counter)
      if not counter then return nil end
      local szb, cnt = ffi.new("unsigned long[1]", 0), ffi.new("unsigned long[1]", 0)
      if p.lib.PdhGetFormattedCounterArrayA(counter, 0x200, szb, cnt, nil) ~= MORE then return nil end
      if tonumber(szb[0]) == 0 or tonumber(cnt[0]) == 0 then return nil end
      local buf = ffi.new("char[?]", tonumber(szb[0]))
      if p.lib.PdhGetFormattedCounterArrayA(counter, 0x200, szb, cnt, buf) ~= 0 then return nil end
      local it = ffi.cast("SX_PDH_FMT_ITEM*", buf)
      local out = {}
      for i = 0, tonumber(cnt[0]) - 1 do
        local st = tonumber(it[i].FmtValue.CStatus)
        if (st == 0 or st == 1) and it[i].szName ~= nil then   -- VALID_DATA or NEW_DATA
          out[#out + 1] = { name = ffi.string(it[i].szName), val = tonumber(it[i].FmtValue.doubleValue) or 0 }
        end
      end
      return out
    end
    local eng = arr(p.cg)
    if eng then g.gpu, g.gpu_enc = sxdash.gpu_from_pdh(eng) end
    local mem = arr(p.cm)
    if mem then g.vram_gb, g.vram_pct = sxdash.vram_from_pdh(mem, sxcol.vram_total) end
  end)
  return g
end

function sxcol.pdh_close()
  pcall(function()
    if sxcol.pdh then sxcol.pdh.lib.PdhCloseQuery(sxcol.pdh.q); sxcol.pdh = nil end
  end)
end

-- ── Windows tuning verification (phase 7) ──────────────────
-- A web checklist can only ADVISE ("you should turn off the hidden game
-- recorder"). Running inside OBS we can VERIFY: read the actual Windows state
-- straight from the registry over ffi (zero subprocesses, zero console flashes)
-- and either report "this is off, here is the exact path" or a quiet pass.
-- nil = the value could not be read; the classifier stays silent about it,
-- never guesses. Pure classification lives in sxdash.tuning_classify.
function sxcol.reg_dword(root, sub, name)
  if not (ffi_ok and ffi) or SX_OS ~= "Windows" then return nil end
  local out
  pcall(function()
    if not sxcol.adv then sxcol.adv = ffi.load("advapi32") end
    local val = ffi.new("unsigned long[1]")
    local sz = ffi.new("unsigned long[1]", 4)
    if sxcol.adv.RegGetValueA(ffi.cast("void*", root), sub, name, 0x10, nil, val, sz) == 0 then
      out = tonumber(val[0])
    end
  end)
  return out
end

function sxcol.reg_str(root, sub, name)
  if not (ffi_ok and ffi) or SX_OS ~= "Windows" then return nil end
  local out
  pcall(function()
    if not sxcol.adv then sxcol.adv = ffi.load("advapi32") end
    local buf = ffi.new("char[256]")
    local sz = ffi.new("unsigned long[1]", 256)
    if sxcol.adv.RegGetValueA(ffi.cast("void*", root), sub, name, 0x2, nil, buf, sz) == 0 then
      out = ffi.string(buf)
    end
  end)
  return out
end

-- 64-bit registry value (REG_QWORD), for HardwareInformation.qwMemorySize (whole-card VRAM).
function sxcol.reg_qword(root, sub, name)
  if not (ffi_ok and ffi) or SX_OS ~= "Windows" then return nil end
  local out
  pcall(function()
    if not sxcol.adv then sxcol.adv = ffi.load("advapi32") end
    local val = ffi.new("unsigned long long[1]")
    local sz = ffi.new("unsigned long[1]", 8)
    if sxcol.adv.RegGetValueA(ffi.cast("void*", root), sub, name, 0x40, nil, val, sz) == 0 then
      out = tonumber(val[0])
    end
  end)
  return out
end

-- Every installed display adapter (name + whole-card VRAM), read straight from the
-- display-adapter class key. A rig with more than one card is a top, often-missed cause
-- of instability: OBS can render on the weak integrated chip, or Windows bounces the
-- render between two cards and a graphics crash follows. The server compares this list
-- against the adapter OBS actually loaded (from the log) to catch the wrong-GPU case and
-- to let the read know the rig has two cards. Windows-only; nil elsewhere. Never a
-- subprocess, never a console flash - the same ffi registry door the tuning check uses.
function sxcol.gpu_list()
  if SX_OS ~= "Windows" then return nil end
  local HKLM = 0x80000002
  local base = "SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}\\"
  local out = {}
  for i = 0, 15 do
    local sub = base .. string.format("%04d", i)
    local name = sxcol.reg_str(HKLM, sub, "DriverDesc")
    if name and name ~= "" then
      local g = { name = name }
      local vb = sxcol.reg_qword(HKLM, sub, "HardwareInformation.qwMemorySize")
      if vb and vb > 0 then g.vram_gb = math.floor(vb / 1073741824 * 10 + 0.5) / 10 end
      out[#out + 1] = g
    end
  end
  return out
end

-- ── Run-as-administrator door (phase 8) ─────────────────────────────────────────
-- Live elevation truth + the always-run-as-admin compat flag. IsUserAnAdmin answers
-- for THIS process (the log line only knows the last launch); the HKCU
-- AppCompatFlags\Layers value is the exact setting the Compatibility tab's "Run this
-- program as an administrator" checkbox writes, so the one-click fix and the manual
-- click-path are literally the same mechanism (the checkbox shows ticked after our
-- write, and unticking it is the undo). MECHANISM SSOT: docs/run-as-admin-mechanism.md
-- (shared with tools/run-obs-as-admin; change the doc first, then both + both pins).
-- Wide (W) APIs throughout so a unicode
-- install path can't corrupt the value name. Same discipline as every door: lazy
-- cdef in its own pcall (a parse failure here can never take down the other doors),
-- everything pcall'd, nil = door closed for good.
local SX_LAYERS_SUB = "Software\\Microsoft\\Windows NT\\CurrentVersion\\AppCompatFlags\\Layers"
local sx_admin_cdef = nil            -- nil = not tried; true/false after
local function admin_cdef()
  if sx_admin_cdef ~= nil then return sx_admin_cdef end
  sx_admin_cdef = false
  if not (ffi_ok and ffi) or SX_OS ~= "Windows" then return false end
  sx_admin_cdef = pcall(function() ffi.cdef[[
int IsUserAnAdmin();
unsigned long GetModuleFileNameW(void* mod, unsigned short* out, unsigned long size);
long RegGetValueW(void* hKey, const unsigned short* subKey, const unsigned short* value, unsigned long flags, unsigned long* type, void* data, unsigned long* dataSize);
long RegSetKeyValueW(void* hKey, const unsigned short* subKey, const unsigned short* valueName, unsigned long type, const void* data, unsigned long cbData);
]] end) and true or false
  return sx_admin_cdef
end

-- Is THIS OBS process elevated? true/false, or nil when the door is closed.
-- Elevation can't change while a process lives, so the answer is cached forever.
function sxcol.is_admin()
  if sxcol.admin_cached ~= nil then return sxcol.admin_cached end
  if not admin_cdef() or not (winapi and winapi.shell32) then return nil end
  local ok, v = pcall(function() return winapi.shell32.IsUserAnAdmin() ~= 0 end)
  if ok then sxcol.admin_cached = v end
  return sxcol.admin_cached
end

-- Full path of the running exe (obs64.exe) as a borrowed wide buffer, cached.
function sxcol.exe_path_w()
  if sxcol.exe_w ~= nil then return sxcol.exe_w end
  if not admin_cdef() then return nil end
  pcall(function()
    local buf = ffi.new("unsigned short[2048]")
    local n = tonumber(ffi.C.GetModuleFileNameW(nil, buf, 2048))
    if n and n > 0 and n < 2048 then sxcol.exe_w = buf end
  end)
  return sxcol.exe_w
end

-- Is the always-run-as-admin flag already set for this exe?
-- true / false, nil = couldn't read (door closed or registry said something odd).
function sxcol.admin_flag()
  if not admin_cdef() then return nil end
  local out = nil
  pcall(function()
    if not sxcol.adv then sxcol.adv = ffi.load("advapi32") end
    local exe = sxcol.exe_path_w()
    if not exe then return end
    local subs = sxdash.wstr(SX_LAYERS_SUB)   -- keep alive: the cast below borrows it
    local subw = ffi.cast("const unsigned short*", ffi.cast("const char*", subs))
    local buf = ffi.new("char[2048]")
    local sz = ffi.new("unsigned long[1]", 2048)
    local rc = sxcol.adv.RegGetValueW(ffi.cast("void*", 0x80000001), subw, exe, 0x2, nil, buf, sz)
    if rc == 0 then
      local flagw = sxdash.wstr("RUNASADMIN")
      out = ffi.string(buf, tonumber(sz[0]) or 0):find(flagw:sub(1, 20), 1, true) ~= nil
    elseif rc == 2 then                        -- ERROR_FILE_NOT_FOUND: no value for this exe
      out = false
    end
  end)
  return out
end

-- The one-click fix: merge RUNASADMIN into this exe's Layers value (HKCU, the
-- user's own hive, so the write itself needs NO elevation). Idempotent; keeps any
-- flags already there (e.g. HIGHDPIAWARE). Returns true when the flag is in place.
function sxcol.admin_flag_write()
  if not admin_cdef() then return false end
  local okall = false
  pcall(function()
    if not sxcol.adv then sxcol.adv = ffi.load("advapi32") end
    local exe = sxcol.exe_path_w()
    if not exe then return end
    local subs = sxdash.wstr(SX_LAYERS_SUB)
    local subw = ffi.cast("const unsigned short*", ffi.cast("const char*", subs))
    local cur = ""
    local buf = ffi.new("char[2048]")
    local sz = ffi.new("unsigned long[1]", 2048)
    if sxcol.adv.RegGetValueW(ffi.cast("void*", 0x80000001), subw, exe, 0x2, nil, buf, sz) == 0 then
      cur = ffi.string(buf, tonumber(sz[0]) or 0):gsub("%z", "")   -- flags are plain ASCII
    end
    local merged = sxdash.admin_layers_merge(cur)
    if not merged then okall = true return end     -- already set: nothing to do
    local dataw = sxdash.wstr(merged)              -- utf16 bytes incl. terminator
    local datap = ffi.cast("const char*", dataw)
    local rc = sxcol.adv.RegSetKeyValueW(ffi.cast("void*", 0x80000001), subw, exe, 1, datap, (#merged + 1) * 2)
    okall = (rc == 0)
  end)
  return okall
end

function sxcol.win_tuning()
  if SX_OS ~= "Windows" then return nil end
  local HKLM, HKCU = 0x80000002, 0x80000001
  return {
    scheme   = sxcol.reg_str(HKLM, "SYSTEM\\CurrentControlSet\\Control\\Power\\User\\PowerSchemes", "ActivePowerScheme"),
    overlay  = sxcol.reg_str(HKLM, "SYSTEM\\CurrentControlSet\\Control\\Power\\User\\PowerSchemes", "ActiveOverlayAcPowerScheme"),
    hags     = sxcol.reg_dword(HKLM, "SYSTEM\\CurrentControlSet\\Control\\GraphicsDrivers", "HwSchMode"),
    dvr_app  = sxcol.reg_dword(HKCU, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\GameDVR", "AppCaptureEnabled"),
    dvr_hist = sxcol.reg_dword(HKCU, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\GameDVR", "HistoricalCaptureEnabled"),
    dvr_cfg  = sxcol.reg_dword(HKCU, "System\\GameConfigStore", "GameDVR_Enabled"),
    game_mode = sxcol.reg_dword(HKCU, "SOFTWARE\\Microsoft\\GameBar", "AutoGameModeEnabled"),
    do_mode  = sxcol.reg_dword(HKLM, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\DeliveryOptimization\\Config", "DODownloadMode"),
  }
end

-- WER (Windows Error Reporting) crash records - phase 2 of PC crash/hardware visibility.
-- Windows keeps a Report.wer file for EVERY application, game, and driver crash on the machine,
-- which is visibility the OBS log can never give ("OBS did not crash, but your game did, twice,
-- mid-session"). Read the newest reports from the four WER folders, parse each (UTF-16LE INI-ish
-- Key=Value; pure parse on sxdash so the off-OBS harness tests it), keep exe/module BASENAMES +
-- exception code + time ONLY (no paths, no usernames ever leave the parse), collapse repeats of
-- the same app+module, 30-day window. File reads only - no new ffi, everything degrades to nil.
function sxcol.wer_reports()
  if SX_OS ~= "Windows" then return nil end
  local subs = {}
  for _, base in ipairs({ os.getenv("ProgramData"), os.getenv("LOCALAPPDATA") }) do
    if base and base ~= "" then
      for _, leaf in ipairs({ "\\Microsoft\\Windows\\WER\\ReportArchive\\", "\\Microsoft\\Windows\\WER\\ReportQueue\\" }) do
        local dir = base .. leaf
        for _, d in ipairs(win_list_dirs(dir)) do
          subs[#subs + 1] = { path = dir .. d.name .. "\\Report.wer", mtime = d.mtime }
        end
      end
    end
  end
  if #subs == 0 then return nil end
  table.sort(subs, function(a, b) return (a.mtime or 0) > (b.mtime or 0) end)
  local reps = {}
  for i = 1, math.min(#subs, 15) do
    local raw = sxcol.slurp_bin(subs[i].path)
    local rep = raw and sxdash.wer_parse(raw) or nil
    if rep then
      rep.when_epoch = rep.when_epoch or sxdash.filetime_epoch(subs[i].mtime)
      reps[#reps + 1] = rep
    end
  end
  if #reps == 0 then return nil end
  return sxdash.wer_collapse(reps, os.time())
end

-- Windows Event Log (System channel) - phase 3 of PC crash/hardware visibility. Where the
-- genuinely serious machine records live, none of them visible to the OBS log or WER:
-- Kernel-Power 41 (the PC lost power / hard-reset), EventLog 6008 (dirty shutdown), WHEA
-- hardware errors, disk/Ntfs drive errors, Display 4101 (a GPU driver TDR reset - the
-- fingerprint of dual-GPU switching), and BugCheck 1001 (a bluescreen). New wevtapi ffi door.
-- ⚠️ This runs inside the user's LIVE OBS: every step is pcall'd, every handle is closed on
-- every path, the render buffer is fixed-size, batches are small, and wevtapi being absent or
-- locked down = nil (silence, never a lie). wevtapi has NO ANSI entry points, so the channel
-- and query strings are widened to UTF-16LE (sxdash.wstr) before crossing; the cdef says
-- char* but the API only ever sees the wide bytes we built.
function sxcol.win_events()
  if SX_OS ~= "Windows" or not (ffi_ok and ffi) then return nil end
  if sxcol.wevt == nil then
    local okw, w = pcall(ffi.load, "wevtapi")
    sxcol.wevt = (okw and w) or false
  end
  if not sxcol.wevt then return nil end
  local TAIL = " and TimeCreated[timediff(@SystemTime) <= 2592000000]" .. string.rep("]", 2)
  local function xq(prov, ids)
    return "*[System[Provider[@Name='" .. prov .. "'] and (" .. ids .. ")" .. TAIL
  end
  local queries = {
    xq("Microsoft-Windows-Kernel-Power", "EventID=41"),
    xq("EventLog", "EventID=6008"),
    xq("Microsoft-Windows-WHEA-Logger", "EventID=1 or EventID=17 or EventID=18 or EventID=19"),
    xq("disk", "EventID=7 or EventID=11 or EventID=51 or EventID=153"),
    xq("Ntfs", "EventID=55"),
    xq("Display", "EventID=4101"),
    xq("Microsoft-Windows-WER-SystemErrorReporting", "EventID=1001"),
  }
  local chan = sxdash.wstr("System")
  local found = {}
  for _, q in ipairs(queries) do
    pcall(function()
      local h = sxcol.wevt.EvtQuery(nil, chan, sxdash.wstr(q), 0x201)   -- ChannelPath | ReverseDirection (newest first)
      if h == nil then return end
      local evs = ffi.new("void*[8]")
      local ret = ffi.new("unsigned long[1]")
      local buf = ffi.new("char[65536]")
      local used = ffi.new("unsigned long[1]")
      local props = ffi.new("unsigned long[1]")
      local got = 0
      while got < 12 do
        if sxcol.wevt.EvtNext(h, 8, evs, 500, 0, ret) == 0 then break end
        local n = tonumber(ret[0]) or 0
        if n == 0 then break end
        for i = 0, n - 1 do
          if got < 12 then
            pcall(function()
              if sxcol.wevt.EvtRender(nil, evs[i], 1, 65536, buf, used, props) ~= 0 then
                local xml = ffi.string(buf, tonumber(used[0]) or 0):gsub("%z", "")
                local row = sxdash.winevt_parse(xml)
                if row then found[#found + 1] = row; got = got + 1 end
              end
            end)
          end
          sxcol.wevt.EvtClose(evs[i])
        end
      end
      sxcol.wevt.EvtClose(h)
    end)
  end
  if #found == 0 then return nil end
  local rows = sxdash.winevt_collapse(found)
  sxdash.winevt_attach_drives(rows)
  return rows
end

-- Linux CPU / RAM / CPU temp off the kernel's own pseudo-files. The hwmon probe
-- runs once and remembers which sensor is the CPU package (false = none found).
function sxcol.linux_cpu_ram()
  local cpu, ramp, ramf, ctemp
  pcall(function()
    local stat = sxcol.slurp("/proc/stat")
    if stat then
      local c, prev = sxdash.cpu_from_stat(stat, sxcol.lin.prev)
      sxcol.lin.prev = prev
      cpu = c
    end
    local mi = sxcol.slurp("/proc/meminfo")
    if mi then ramp, ramf = sxdash.mem_from_meminfo(mi) end
    if sxcol.lin.hwmon == nil then
      sxcol.lin.hwmon = false
      for i = 0, 12 do
        local base = "/sys/class/hwmon/hwmon" .. i
        local nm = sxcol.slurp(base .. "/name")
        if nm and sxdash.is_cpu_hwmon(nm) then sxcol.lin.hwmon = base .. "/temp1_input"; break end
      end
    end
    if sxcol.lin.hwmon then
      local t = tonumber((sxcol.slurp(sxcol.lin.hwmon) or ""):match("%d+"))
      if t and t > 0 then ctemp = math.floor(t / 1000 + 0.5) end
    end
  end)
  return cpu, ramp, ramf, ctemp
end

-- Linux GPU via the amdgpu driver's sysfs files (utilization + VRAM + temp +
-- power). Intel exposes no busy % this way; its fields stay honestly absent.
function sxcol.sysfs_gpu()
  local g = { gpu = -1, gpu_temp = -1, vram_pct = -1, vram_gb = -1, gpu_enc = -1, gpu_pow = -1, gpu_thr = -1 }
  pcall(function()
    if sxcol.lin.card == nil then
      sxcol.lin.card = false
      for i = 0, 3 do
        local base = "/sys/class/drm/card" .. i .. "/device"
        if sxcol.slurp(base .. "/gpu_busy_percent") then sxcol.lin.card = base; break end
      end
      if sxcol.lin.card then
        for i = 0, 12 do
          local hb = sxcol.lin.card .. "/hwmon/hwmon" .. i
          if sxcol.slurp(hb .. "/temp1_input") then sxcol.lin.ghw = hb; break end
        end
      end
    end
    local base = sxcol.lin.card
    if not base then return end
    local busy = tonumber((sxcol.slurp(base .. "/gpu_busy_percent") or ""):match("%d+"))
    if busy then g.gpu = math.min(100, busy) end
    local used = tonumber((sxcol.slurp(base .. "/mem_info_vram_used") or ""):match("%d+"))
    local tot = tonumber((sxcol.slurp(base .. "/mem_info_vram_total") or ""):match("%d+"))
    if used and tot and tot > 0 then
      g.vram_pct = math.floor(100 * used / tot + 0.5)
      g.vram_gb = math.floor(used / 1073741824 * 10 + 0.5) / 10
    end
    if sxcol.lin.ghw then
      local t = tonumber((sxcol.slurp(sxcol.lin.ghw .. "/temp1_input") or ""):match("%d+"))
      if t and t > 0 then g.gpu_temp = math.floor(t / 1000 + 0.5) end
      local pw = tonumber((sxcol.slurp(sxcol.lin.ghw .. "/power1_average") or ""):match("%d+"))
      if pw and pw > 0 then g.gpu_pow = math.floor(pw / 1000000 + 0.5) end
    end
  end)
  return g
end

-- Mac CPU / RAM via the kernel's own counters (no subprocess, no console, ever).
function sxcol.mac_cpu_ram()
  local cpu, ramp, ramf
  if not (ffi_ok and ffi) then return nil, nil, nil end
  pcall(function()
    local host = ffi.C.mach_host_self()
    local ticks = ffi.new("unsigned int[8]")
    local cnt = ffi.new("unsigned int[1]", 4)
    if ffi.C.host_statistics(host, 3, ticks, cnt) == 0 then   -- HOST_CPU_LOAD_INFO
      local busy = tonumber(ticks[0]) + tonumber(ticks[1]) + tonumber(ticks[3])   -- user + system + nice
      local total = busy + tonumber(ticks[2])
      local pv = sxcol.mac.prev
      if pv and total > pv.total and busy >= pv.busy then
        cpu = math.floor(100 * (busy - pv.busy) / (total - pv.total) + 0.5)
        if cpu < 0 then cpu = 0 elseif cpu > 100 then cpu = 100 end
      end
      sxcol.mac.prev = { busy = busy, total = total }
    end
    if sxcol.mac.total_gb < 0 then
      local v, len = ffi.new("unsigned long long[1]", 0), ffi.new("size_t[1]", 8)
      if ffi.C.sysctlbyname("hw.memsize", v, len, nil, 0) == 0 and tonumber(v[0]) > 0 then
        sxcol.mac.total_gb = tonumber(v[0]) / 1073741824
      end
      local pg, plen = ffi.new("unsigned long long[1]", 0), ffi.new("size_t[1]", 8)
      if ffi.C.sysctlbyname("hw.pagesize", pg, plen, nil, 0) == 0 and tonumber(pg[0]) > 0 then
        sxcol.mac.page = tonumber(pg[0])
      end
    end
    if sxcol.mac.total_gb > 0 then
      local vm = ffi.new("unsigned int[64]")
      local vc = ffi.new("unsigned int[1]", 38)
      if ffi.C.host_statistics64(host, 4, vm, vc) == 0 then   -- HOST_VM_INFO64: 0 free, 2 inactive
        local avail = (tonumber(vm[0]) + tonumber(vm[2])) * sxcol.mac.page
        local total = sxcol.mac.total_gb * 1073741824
        ramp = math.floor(100 * (1 - avail / total) + 0.5)
        if ramp < 0 then ramp = 0 elseif ramp > 100 then ramp = 100 end
        ramf = math.floor(avail / 1073741824 * 10 + 0.5) / 10
      end
    end
  end)
  return cpu, ramp, ramf
end

-- The one GPU door the sampler calls: NVML when the NVIDIA library answered at
-- load, else the OS fallback for this platform. Same field shape either way.
function sxcol.gpu_read()
  if nvml then return nvml_read() end
  if SX_OS == "Windows" then return sxcol.pdh_read() end
  if SX_OS == "Linux" then return sxcol.sysfs_gpu() end
  return { gpu = -1, gpu_temp = -1, vram_pct = -1, vram_gb = -1, gpu_enc = -1, gpu_pow = -1, gpu_thr = -1 }
end

local function ft_num(ft)
  return tonumber(ft.dwHighDateTime) * 4294967296 + tonumber(ft.dwLowDateTime)
end

-- ── Per-leg output counters (multistream legs) ─────────────────────────────
-- The OBS log prints ONE cumulative "Total frames output" per leg at session end, so a
-- read can never tell WHEN a leg dropped or whether one platform (not the PC) was the
-- bottleneck. libobs knows live: every streaming leg (an SE.Live extra output, the
-- vertical canvas, Enhanced Broadcasting's multitrack output) is an obs_output with its
-- own total/dropped counters. Scripts don't get obs_enum_outputs, so this is an ffi door
-- into the already-loaded obs library — same discipline as the wevtapi door:
-- ⚠️ runs inside the user's LIVE OBS. Everything is pcall'd; the enum callback is cast
-- ONCE (LuaJIT callback slots are a finite resource) and its body is fully pcall'd too —
-- an error escaping an ffi callback would abort OBS, not just the script. The callback
-- only READS the borrowed output pointer libobs lends during enumeration (never kept,
-- never released). Any failure closes the door for good (false) = silence, never a lie.
local sx_legs = { lib = nil, cb = nil, acc = nil }   -- lib: nil = unprobed, false = door closed
local function legs_init()
  sx_legs.lib = false
  if not (ffi_ok and ffi) then return end
  pcall(function()
    ffi.cdef[[
typedef struct sx_obs_output sx_obs_output_t;
void obs_enum_outputs(bool (*enum_proc)(void *, sx_obs_output_t *), void *param);
const char *obs_output_get_name(const sx_obs_output_t *output);
bool obs_output_active(const sx_obs_output_t *output);
uint32_t obs_output_get_flags(const sx_obs_output_t *output);
int obs_output_get_total_frames(const sx_obs_output_t *output);
int obs_output_get_frames_dropped(const sx_obs_output_t *output);
]]
  end)   -- a reload can hit "already defined"; the types still exist, so carry on
  pcall(function()
    local lib   -- obs.dll is already in-process on Windows; Mac/Linux ship versioned names
    for _, nm in ipairs({ "obs", "obs.0.dylib", "libobs.so.0" }) do
      local okl, l = pcall(ffi.load, nm)
      if okl then lib = l; break end
    end
    if not lib then return end
    local cb = ffi.cast("bool (*)(void *, sx_obs_output_t *)", function(_, out)
      pcall(function()
        local a = sx_legs.acc
        if a and #a < 8 and out ~= nil and sx_legs.lib.obs_output_active(out) then
          -- OBS_OUTPUT_SERVICE (1<<3): a real streaming leg — never the recording,
          -- replay buffer or Virtual Camera, whose counters would poison the timeline.
          if bit.band(tonumber(sx_legs.lib.obs_output_get_flags(out)) or 0, 8) ~= 0 then
            local nm = sx_legs.lib.obs_output_get_name(out)
            a[#a + 1] = { name = nm ~= nil and ffi.string(nm) or "?",
                          total = tonumber(sx_legs.lib.obs_output_get_total_frames(out)) or -1,
                          drop  = tonumber(sx_legs.lib.obs_output_get_frames_dropped(out)) or -1 }
          end
        end
      end)
      return true   -- keep enumerating no matter what
    end)
    sx_legs.lib, sx_legs.cb = lib, cb
  end)
end

-- All active streaming legs right now, {{name=,total=,drop=},...}; nil when fewer than
-- one answers or the door is closed. Called from the health sampler every HEALTH_MS.
function sxcol.leg_stats()
  if sx_legs.lib == nil then legs_init() end
  if not (sx_legs.lib and sx_legs.cb) then return nil end
  sx_legs.acc = {}
  local ok = pcall(function() sx_legs.lib.obs_enum_outputs(sx_legs.cb, nil) end)
  local legs = sx_legs.acc
  sx_legs.acc = nil
  if not ok or #legs == 0 then return nil end
  return legs
end

local function health_sample()
  pcall(function()
    local t = os.time()
    local cpu_sys, ram_pct, ram_free_gb, cpu_temp
    if SX_OS == "Linux" then
      cpu_sys, ram_pct, ram_free_gb, cpu_temp = sxcol.linux_cpu_ram()
    elseif SX_OS == "OSX" then
      cpu_sys, ram_pct, ram_free_gb = sxcol.mac_cpu_ram()
    elseif winapi then
      local idle, kern, user = ffi.new("SX_FILETIME"), ffi.new("SX_FILETIME"), ffi.new("SX_FILETIME")
      if ffi.C.GetSystemTimes(idle, kern, user) ~= 0 then
        local i, b = ft_num(idle), ft_num(kern) + ft_num(user)   -- kernel time includes idle
        if cpu_prev then
          local di, db = i - cpu_prev.i, b - cpu_prev.b
          if db > 0 then cpu_sys = math.floor(100 * (db - di) / db + 0.5) end
        end
        cpu_prev = { i = i, b = b }
      end
      local ms = ffi.new("SX_MEMSTATEX")
      ms.dwLength = ffi.sizeof("SX_MEMSTATEX")
      if ffi.C.GlobalMemoryStatusEx(ms) ~= 0 then
        ram_pct = tonumber(ms.dwMemoryLoad)
        ram_free_gb = math.floor(tonumber(ms.ullAvailPhys) / 1073741824 * 10 + 0.5) / 10
      end
    end
    local cpu_obs
    if obs_cpu_info then
      pcall(function() cpu_obs = math.floor((obs.os_cpu_usage_info_query(obs_cpu_info) or 0) * 10 + 0.5) / 10 end)
    end
    local lagged, total, live = 0, 0, false
    pcall(function() lagged = obs.obs_get_lagged_frames() or 0; total = obs.obs_get_total_frames() or 0 end)
    pcall(function() live = obs.obs_frontend_streaming_active() and true or false end)
    -- The OTHER two loss types, tracked apart from render lag because each has a
    -- different fix: network drops (connection can't carry the bitrate) come from the
    -- streaming output; encoder skips (encoder can't keep up) from the video mix.
    -- -1 = counter unavailable, distinct from a true 0 ("measured, no loss").
    local net_drop, net_total, enc_skip, enc_total = -1, -1, -1, -1
    pcall(function()
      local out = obs.obs_frontend_get_streaming_output()
      if out ~= nil then
        net_drop  = obs.obs_output_get_frames_dropped(out) or -1
        net_total = obs.obs_output_get_total_frames(out) or -1
        obs.obs_output_release(out)   -- frontend getter returns a new reference; never leak it
      end
    end)
    pcall(function()
      local v = obs.obs_get_video()   -- borrowed pointer, no release
      if v ~= nil then
        enc_skip  = obs.video_output_get_skipped_frames(v) or -1
        enc_total = obs.video_output_get_total_frames(v) or -1
      end
    end)
    -- Multistream legs: per-output cumulative counters, recorded only when two or more
    -- service outputs run at once (a single-output stream is already net_drop/net_total,
    -- and single streamers' rows stay exactly as before — zero growth).
    local legs_txt = nil
    pcall(function()
      local legs = sxcol.leg_stats()
      if legs and #legs >= 2 then legs_txt = sxdash.legs_json(legs) end
    end)
    local g = sxcol.gpu_read()
    health_last = { t = t, cpu_sys = cpu_sys, cpu_obs = cpu_obs, ram_pct = ram_pct, ram_free_gb = ram_free_gb,
                    cpu_temp = cpu_temp, lagged = lagged, total = total, net_drop = net_drop, net_total = net_total,
                    enc_skip = enc_skip, enc_total = enc_total, live = live,
                    gpu = g.gpu, gpu_temp = g.gpu_temp, vram_pct = g.vram_pct, vram_gb = g.vram_gb,
                    gpu_enc = g.gpu_enc, gpu_pow = g.gpu_pow, gpu_thr = g.gpu_thr }
    sx_health_events(health_last)   -- deterministic sustained-saturation flags off the fresh sample
    if cpu_sys == nil and ram_pct == nil and cpu_obs == nil then return end   -- measured nothing, write nothing
    -- keep the in-memory ring the dashboard charts from (trimmed in chunks, not every sample)
    sxdash.buf[#sxdash.buf + 1] = health_last
    if #sxdash.buf > sxdash.BUF_MAX + 300 then
      local keep = {}
      for i = #sxdash.buf - sxdash.BUF_MAX + 1, #sxdash.buf do keep[#keep + 1] = sxdash.buf[i] end
      sxdash.buf = keep
    end
    if health_path then
      local f = io.open(health_path, "a")
      if f ~= nil then
        f:write(string.format('{"t":%d,"cpu_sys":%s,"cpu_obs":%s,"ram_pct":%s,"ram_free_gb":%s,"lagged":%d,"total":%d,"net_drop":%d,"net_total":%d,"enc_skip":%d,"enc_total":%d,"gpu":%d,"gpu_temp":%d,"vram_pct":%d,"vram_gb":%s,"gpu_enc":%d,"gpu_pow":%d,"gpu_thr":%d,"cpu_temp":%s,"live":%s%s}\n',
          t, tostring(cpu_sys or -1), tostring(cpu_obs or -1), tostring(ram_pct or -1),
          tostring(ram_free_gb or -1), lagged, total, net_drop, net_total, enc_skip, enc_total,
          g.gpu, g.gpu_temp, g.vram_pct, tostring(g.vram_gb), g.gpu_enc, g.gpu_pow, g.gpu_thr,
          tostring(cpu_temp or -1), live and "true" or "false",
          legs_txt and (',"legs":' .. legs_txt) or ""))
        f:close()
      end
    end
  end)
end

-- Slice the sampler series to an epoch window [lo, hi], thinned to at most ~1200 lines so a
-- long stream stays a small upload. Returns "" when there is nothing in the window.
local function sx_slice_health(lo, hi)
  if not health_path then return "" end
  local kept = {}
  local okh = pcall(function()
    for line in io.lines(health_path) do
      local t = tonumber(line:match('"t":(%d+)'))
      if t and t >= lo and t <= hi then kept[#kept + 1] = line end
    end
  end)
  if not okh or #kept == 0 then return "" end
  if #kept > 1200 then
    local step, thin = #kept / 1200, {}
    local at = 1
    while math.floor(at) <= #kept do thin[#thin + 1] = kept[math.floor(at)]; at = at + step end
    kept = thin
  end
  return table.concat(kept, "\n")
end

-- Keep the rolling file from growing forever: called once at load, keeps the newest tail.
local function health_rotate()
  pcall(function()
    if not health_path then return end
    local f = io.open(health_path, "r")
    if f == nil then return end
    local sz = f:seek("end")
    if sz and sz > 4000000 then
      f:seek("set", sz - 1000000)
      local tail = f:read("*a")
      f:close()
      tail = tail:gsub("^[^\n]*\n", "")   -- drop the first partial line
      local w = io.open(health_path, "w")
      if w ~= nil then w:write(tail) w:close() end
    else
      f:close()
    end
  end)
end

-- The live dock meter. Simple headroom score: full marks until CPU or RAM run hot.
local function health_meter()
  local h = health_last
  if not h or (h.cpu_sys == nil and h.ram_pct == nil) then
    return '<div class="meter soon"><span class="m-name">PC health</span><span class="m-tag">warming up</span></div>'
  end
  local score = 100
  if h.cpu_sys then score = score - math.max(0, h.cpu_sys - 70) * 2 end
  if h.ram_pct then score = score - math.max(0, h.ram_pct - 80) * 2 end
  if h.gpu and h.gpu >= 0 then score = score - math.max(0, h.gpu - 85) * 2 end
  if h.gpu_temp and h.gpu_temp >= 0 then score = score - math.max(0, h.gpu_temp - 83) * 3 end
  if h.cpu_temp and h.cpu_temp >= 0 then score = score - math.max(0, h.cpu_temp - 90) * 3 end
  if score < 0 then score = 0 end
  score = math.floor(score + 0.5)
  local color = (score >= 70) and "#6fd394" or ((score >= 40) and "#ffc24d" or "#ff6b6b")
  local bits = {}
  if h.cpu_sys then bits[#bits + 1] = "CPU " .. h.cpu_sys .. "%" end
  if h.ram_pct then bits[#bits + 1] = "RAM " .. h.ram_pct .. "%" end
  if h.gpu and h.gpu >= 0 then bits[#bits + 1] = "GPU " .. h.gpu .. "%" end
  if h.gpu_temp and h.gpu_temp >= 0 then bits[#bits + 1] = h.gpu_temp .. "&deg;C" end
  if h.cpu_temp and h.cpu_temp >= 0 then bits[#bits + 1] = "CPU " .. h.cpu_temp .. "&deg;C" end
  -- Honest live flags: only after ~32s continuously over threshold, never off one spike.
  local flag = sx_sat.hot and "GPU running hot" or (sx_sat.gpu and "GPU maxed out" or (sx_sat.cpu and "CPU maxed out" or nil))
  -- ids let the dashboard JS keep this meter live from streamauditx-health.js,
  -- including DURING a stream when the audit scan (and this HTML) is paused.
  return '<div class="meter live"><span class="m-dot"></span><span class="m-name">PC health</span>' ..
    '<span class="m-bar"><i id="pcHealthBar" style="width:' .. score .. '%;background:' .. color .. '"></i></span>' ..
    '<span class="m-val" id="pcHealthVal">' .. table.concat(bits, " &middot; ") ..
    (flag and (' &middot; <b style="color:#ffc24d">' .. flag .. '</b>') or '') .. '</span></div>'
end

-- ── tiny helpers ───────────────────────────────────────────

local function esc(s)
  s = tostring(s or "")
  s = s:gsub("&", "&amp;"):gsub("<", "&lt;"):gsub(">", "&gt;")
  return s
end

-- esc for use inside a double-quoted HTML attribute (also encodes quotes)
local function esc_attr(s)
  return (esc(s):gsub('"', "&quot;"):gsub("'", "&#39;"))
end

function safe(fn)
  local ok, err = pcall(fn)
  if not ok then print("StreamAuditX detector skipped: " .. tostring(err)) end
end

function read_file(path)
  local f = io.open(path, "r")
  if not f then return nil end
  local c = f:read("*a")
  f:close()
  return c
end

-- Enumerate files in a directory matching a glob, NEWEST FIRST (mirrors the OBS-log discovery
-- `dir /b /o-d`). `dir` must end with a backslash. Returns a list of filenames (cap optional).
function list_newest(dir, glob, cap)
  local files = win_list(dir, glob)
  if files then
    table.sort(files, function(a, b) return a.mtime > b.mtime end)
    local out = {}
    local n = cap and math.min(cap, #files) or #files
    for i = 1, n do out[i] = files[i].name end
    return out
  end
  -- fallback: no ffi — the old cmd door (flashes a console window)
  local out = {}
  local p = io.popen('cmd /c dir /b /o-d "' .. dir .. glob .. '" 2>nul')
  if p then
    for line in p:lines() do
      if line and line ~= "" then out[#out + 1] = line end
      if cap and #out >= cap then break end
    end
    p:close()
  end
  return out
end

-- Read the newest log in `dir` matching `glob`, capped to the last `maxb` bytes (recent activity,
-- same tail-cap as the OBS log). Returns name, raw (or nil, nil when there is nothing to read).
function newest_log_capped(dir, glob, maxb)
  local files = list_newest(dir, glob, 1)
  if #files == 0 then return nil, nil end
  local name = files[1]
  local raw = read_file(dir .. name)
  if not raw or raw == "" then return nil, nil end
  if maxb and #raw > maxb then raw = raw:sub(-maxb) end
  return name, raw
end

-- TikTok LIVE Studio collector. This is the external-capture multistream door: OBS renders to the OBS
-- Virtual Camera, TikTok LIVE Studio captures that camera and streams it to TikTok, so the OBS log
-- cannot see the TikTok leg. We read TikTok LIVE Studio's own Electron logs but keep ONLY the
-- streaming-relevant lines (encode/ABR bitrate callbacks, real errors, launch + metrics markers, the
-- resolution hint) and REDACT ids ON-DEVICE before anything leaves the machine (these logs are dense
-- with viewer and account PII we neither need nor want). Returns main, renderer, crashLog, name.
function tiktok_collect(ad)
  if not ad or ad == "" then return nil end
  local dir = ad .. "\\TikTok LIVE Studio\\logs\\"
  local names = list_newest(dir, "LS-*.log", 40)
  if not names or #names == 0 then return nil end
  local function newest_kind(kind)
    for _, nm in ipairs(names) do
      if nm:lower():find(kind, 1, true) then return nm end
    end
    return nil
  end
  local function keep_line(l)
    if l:find("OnStreamEncodeEvent", 1, true) or l:find("OnABRBitrateChangeEvent", 1, true)
       or l:find("recommendResolution", 1, true) or l:find("recommendFps", 1, true)
       or l:find("tt_electron_init_cost", 1, true) or l:find("metricsCollector", 1, true)
       or l:find("app_version", 1, true) then return true end
    if l:find("%[error%]") or l:find("%[ERROR%]") then
      if l:find("insecure web preferences", 1, true) or l:find("abtest", 1, true)
         or l:find("libra", 1, true) or l:find("getVar AB", 1, true) then return false end
      return true
    end
    return false
  end
  local function redact(t)
    t = t:gsub("streamId: '[^']*'", "streamId: '<id>'")
    t = t:gsub("(Users[\\/])[^\\/]+", "%1<user>")
    for _, k in ipairs({ "user_id", "device_id", "web_id", "room_id", "secUid", "displayId", "user_unique_id" }) do
      t = t:gsub('("' .. k .. '":%s*")[^"]*', "%1<id>")
    end
    return t
  end
  local function slice(name)
    if not name or name == "" then return nil end
    local raw = read_file(dir .. name)
    if not raw or raw == "" then return nil end
    local kept = {}
    for line in (raw .. "\n"):gmatch("(.-)\r?\n") do
      if keep_line(line) then kept[#kept + 1] = line end
    end
    if #kept == 0 then return nil end
    local out = redact(table.concat(kept, "\n"))
    if #out > 500000 then out = out:sub(-500000) end
    return out
  end
  local mainTxt = slice(newest_kind("main"))
  local rendTxt = slice(newest_kind("renderer"))
  local crashRaw = read_file(ad .. "\\TikTok LIVE Studio\\crash_log.json")
  if crashRaw then crashRaw = crashRaw:gsub("(Users[\\/])[^\\/]+", "%1<user>"); if #crashRaw > 100000 then crashRaw = crashRaw:sub(1, 100000) end end
  if not mainTxt and not rendTxt then return nil end
  return mainTxt, rendTxt, crashRaw, (newest_kind("main") or newest_kind("renderer"))
end

-- Which OBS crash-report folder exists on this machine. Newer OBS writes "crashLogs", older "crashes";
-- check both and return the one that actually holds a crash file (newest wins if both exist).
function crash_dir()
  local ad = os.getenv("APPDATA")
  if not ad then return nil end
  for _, d in ipairs({ ad .. "\\obs-studio\\crashLogs\\", ad .. "\\obs-studio\\crashes\\" }) do
    if #list_newest(d, "Crash *.txt", 1) > 0 then return d end
  end
  return nil
end

-- Windows/CRT DLLs a crash SURFACES in but almost never originates from, and OBS's own core binary.
-- The real culprit is the first crashed-stack module that is neither. (The server does the deep,
-- authoritative version; this light pass gives the free dock finding a real name.)
local CRASH_SYS = { ntdll=1, kernelbase=1, kernel32=1, user32=1, gdi32=1, gdi32full=1, win32u=1,
  combase=1, rpcrt4=1, ole32=1, oleaut32=1, sechost=1, shcore=1, shell32=1, shlwapi=1, advapi32=1,
  ucrtbase=1, msvcrt=1, vcruntime140=1, ws2_32=1, wininet=1, winmm=1, avrt=1, apphelp=1, wow64=1 }
local CRASH_CORE = { obs=1, obs64=1, libobs=1, ["w32-pthreads"]=1, obsglad=1 }
-- Ordered specific -> general; first Lua pattern that matches a crashed-stack module base names it.
local CRASH_MAP = {
  { "^win%-dshow", "a video capture device (a webcam or capture card)" },
  { "^ksproxy", "a capture device driver (Windows kernel streaming)" },
  { "^ks$", "a capture device driver (Windows kernel streaming)" },
  { "^mf", "a capture device driver (Media Foundation)" },
  { "^win%-capture", "a Game, Window, or Display Capture" },
  { "^graphics%-hook", "a Game, Window, or Display Capture" },
  { "^obs%-nvenc", "the NVIDIA NVENC encoder" },
  { "^nvenc", "the NVIDIA NVENC encoder" },
  { "^nvwgf2um", "the NVIDIA graphics driver" },
  { "^nvd3dum", "the NVIDIA graphics driver" },
  { "^nvoglv", "the NVIDIA graphics driver" },
  { "^nvcud", "the NVIDIA graphics driver" },
  { "^nvapi", "the NVIDIA graphics driver" },
  { "^amf", "the AMD graphics or encoder driver" },
  { "^atidxx", "the AMD graphics driver" },
  { "^aticfx", "the AMD graphics driver" },
  { "^amdxc", "the AMD graphics driver" },
  { "^igd", "the Intel graphics driver" },
  { "^igc", "the Intel graphics driver" },
  { "^intelcp", "the Intel graphics driver" },
  { "^libcef", "a Browser source (its Chromium engine)" },
  { "^obs%-browser", "a Browser source" },
  { "^chrome_elf", "a Browser source (its Chromium engine)" },
  { "^avcodec", "a media source or the FFmpeg engine" },
  { "^avformat", "a media source or the FFmpeg engine" },
  { "^obs%-ffmpeg", "a media source or the FFmpeg engine" },
  { "^libvlc", "a VLC video source" },
  { "^win%-wasapi", "an audio device (WASAPI)" },
  { "^obs%-vst", "a VST audio plugin filter" },
  { "^qt6", "the OBS user interface (Qt)" },
  { "^qt5", "the OBS user interface (Qt)" },
  { "^d3d11", "the Direct3D graphics layer" },
  { "^dxgi", "the Direct3D graphics layer" },
}

-- Light crash classifier: given the raw crash file, return when, faulting module, and a plain-language
-- component the crashed thread points at (or nil label if it is a bare OBS-core crash).
function crash_classify(raw)
  if not raw or raw == "" then return nil end
  local out = {}
  out.when = raw:match("Date/Time:%s*([%d%-]+,?%s*[%d:]+)")
  local fpath = raw:match("Fault address:%s*%S+%s*%(([^)]+)%)")
  if fpath then out.fault = fpath:match("([^\\/]+)$") end
  -- isolate the crashed thread's stack block
  local block = raw:match("Thread%s+%x+:[^\n]*%(Crashed%)(.-)\n%s*\n")
  if not block then block = raw:match("Thread%s+%x+:[^\n]*%(Crashed%)(.*)$") end
  if block then
    for modfull in block:gmatch("([%w%._%-]+%.%a+)!") do
      local base = modfull:gsub("%.%a+$", ""):lower()
      if not CRASH_SYS[base] then
        if CRASH_CORE[base] then
          out.core = true                          -- note core, keep looking for a real culprit below
        else
          out.module = modfull
          for _, row in ipairs(CRASH_MAP) do
            if base:match(row[1]) then out.component = row[2]; break end
          end
          if not out.component then out.component = 'the plugin "' .. modfull .. '"' end
          break
        end
      end
    end
  end
  if not out.component and out.core then out.component = "OBS itself (no single plugin or device on the stack)" end
  return out
end

-- "Crash 2026-08-14 11-38-00.txt" -> naive-ISO "2026-08-14T11:38:00" (same convention as the server's
-- crashDateFromName). Nil when the filename carries no dated stamp.
function crash_when_iso(nm)
  local y, mo, d, h, mi, s = tostring(nm or ""):match("(%d+)%-(%d+)%-(%d+)%s+(%d+)%-(%d+)%-(%d+)")
  if y then return y .. "-" .. mo .. "-" .. d .. "T" .. h .. ":" .. mi .. ":" .. s end
  return nil
end

-- What was OBS DOING when the crash named `cn` happened? Find the session log that OWNS the crash
-- time (the newest log that started at or before it) and read whether streaming/recording was active.
-- Returns live | live+recording | recording | idle | unknown. Extracted so EVERY recent crash gets
-- its own live/not-live flag, not just the newest: a crash while idle is the SAME fault that would
-- take a live stream down, so we surface it either way (the flag is a label, never a filter). Reads
-- audit_log_list (recent OBS log filenames), populated earlier in the audit.
function crash_state_for(cn)
  local cy, cmo, cd, ch, cmi, cs = tostring(cn or ""):match("Crash%s+(%d+)%-(%d+)%-(%d+)%s+(%d+)%-(%d+)%-(%d+)")
  if not cy then return "unknown" end
  local ckey = tonumber(cy .. cmo .. cd .. ch .. cmi .. cs)
  local logdir2 = (os.getenv("APPDATA") or "") .. "\\obs-studio\\logs\\"
  local owner, owner_key = nil, -1
  for _, ln in ipairs(audit_log_list or {}) do
    local ly, lmo, ld, lh, lmi, ls = ln:match("(%d+)%-(%d+)%-(%d+)%s+(%d+)%-(%d+)%-(%d+)")
    if ly then
      local lkey = tonumber(ly .. lmo .. ld .. lh .. lmi .. ls)
      if lkey and ckey and lkey <= ckey and lkey > owner_key then owner, owner_key = ln, lkey end
    end
  end
  if not owner then return "unknown" end
  local lraw = read_file(logdir2 .. owner)
  if not lraw then return "unknown" end
  local streaming, recording = false, false
  for line in lraw:gmatch("[^\r\n]+") do
    if line:match("====%s*Streaming%s+Start") then streaming = true
    elseif line:match("====%s*Streaming%s+Stop") then streaming = false
    elseif line:match("====%s*Recording%s+Start") then recording = true
    elseif line:match("====%s*Recording%s+Stop") then recording = false end
  end
  if streaming and recording then return "live+recording"
  elseif streaming then return "live"
  elseif recording then return "recording"
  else return "idle" end
end

-- obs-websocket keeps its persistent data in this file on disk, and the browser panel can
-- write to it (SetPersistentData). That's the one channel the panel has to hand a value
-- back to this script — we use it for "which session log do you want to read?". The panel
-- writes the slot, we read the file here. Returns the string value, or nil if not set.
function read_persist_str(slot)
  local ad = os.getenv("APPDATA")
  if not ad then return nil end
  local txt = read_file(ad .. "\\obs-studio\\plugin_config\\obs-websocket\\persistent_data.json")
  if not txt then return nil end
  return txt:match('"' .. slot .. '"%s*:%s*"(.-)"')
end

-- ── Membership tier signal (login-less dock) ───────────────
-- The dock has no sign-in by design. The signed-in Expert Read web page writes the
-- member's tier + product-improvement consent into obs-websocket's persistent data
-- (slots sx_tier / sx_improve_consent) over the SAME channel the session picker
-- uses; we read them back off disk here. Never signed in / unknown reads as free
-- with consent off — premium is never assumed. The slots survive OBS restarts, so
-- one signed-in visit to an Expert Read page primes the dock for good.
local sx_tier = "a"          -- 'a' free · 's'/'x' premium (from the hub, via the web page)
sx_tier_known = false        -- false until ANY signed-in page has written the slot: "a" then means "unknown, treat as free", and copy must not claim the member chose the free plan
local sx_consent = false     -- product-improvement consent (subscribers only)
local sx_consent_known = false -- true once ANY signed-in read page wrote the consent slot; a member's explicit choice always beats the dock's local default
local function sx_is_premium() return sx_tier == "s" or sx_tier == "x" end
local function sx_read_tier()
  local ad = os.getenv("APPDATA")
  if not ad then return end
  local txt = read_file(ad .. "\\obs-studio\\plugin_config\\obs-websocket\\persistent_data.json")
  if not txt then return end
  local t = txt:match('"sx_tier"%s*:%s*"(.-)"')
  if t == "a" or t == "s" or t == "x" then
    if t ~= sx_tier then print("StreamAuditX: membership signal updated (tier '" .. t .. "')") end
    sx_tier = t
    sx_tier_known = true
  end
  local sx_c = txt:match('"sx_improve_consent"%s*:%s*"(.-)"')
  sx_consent = (sx_c == "1")
  sx_consent_known = (sx_c ~= nil)
  -- Phase 8: the scoped health-upload key the page mirrors for premium members
  -- (empty when consent is off or the member signed out; short = garbage).
  local k = txt:match('"sx_health_key"%s*:%s*"(.-)"')
  sxdash.upkey = (k and #k >= 20) and k or nil
end

-- "2026-08-05 10-42-53.txt" -> "Aug 5, 10:42am" for the session picker labels.
local LOG_MONTHS = { "Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec" }
local function log_disp(name)
  local mo, d, h, mi = name:match("%d+%-(%d+)%-(%d+) (%d+)%-(%d+)%-%d+")
  if not mo then return (name:gsub("%.txt$", "")) end
  local hh = tonumber(h); local ap = hh >= 12 and "pm" or "am"
  local h12 = hh % 12; if h12 == 0 then h12 = 12 end
  return (LOG_MONTHS[tonumber(mo)] or mo) .. " " .. tonumber(d) .. ", " .. h12 .. ":" .. mi .. ap
end

function file_exists(path)
  if not path or path == "" then return true end
  local f = io.open(path, "rb")
  if f then f:close() return true end
  return false
end

function file_size(path)
  local f = io.open(path, "rb")
  if not f then return nil end
  local sz = f:seek("end")
  f:close()
  return sz
end

-- free space (GB) on a Windows drive like "D:" — best effort, no console
function drive_free_gb(drive)
  local gb
  safe(function()
    if winapi then
      local free = ffi.new("unsigned long long[1]")
      if ffi.C.GetDiskFreeSpaceExA(drive .. "\\", free, nil, nil) ~= 0 then
        gb = math.floor(tonumber(free[0]) / (1024 * 1024 * 1024))
      end
      return
    end
    local p = io.popen('cmd /c dir /-c "' .. drive .. '\\" 2>nul')
    if not p then return end
    local out = p:read("*a")
    p:close()
    local bytes = out and out:match("(%d+) bytes free")
    if bytes then gb = math.floor(tonumber(bytes) / (1024 * 1024 * 1024)) end
  end)
  return gb
end

function parse_ini(text)
  local ini = {}
  if not text then return ini end
  local section = ""
  for line in text:gmatch("[^\r\n]+") do
    local s = line:match("^%s*%[(.-)%]%s*$")
    if s then
      section = s
      ini[section] = ini[section] or {}
    else
      local k, v = line:match("^%s*([^=;#]-)%s*=%s*(.-)%s*$")
      if k and section ~= "" then ini[section][k:lower()] = v end
    end
  end
  return ini
end

-- ── Phase 5: minimal JSON reader + hotkey helpers ──────────
-- Pure-Lua so it never depends on obs_data key-enumeration (which the
-- Lua binding doesn't expose). Only used to read the scene-collection
-- file for hotkey bindings; every use is wrapped in pcall/safe.

function json_decode(s)
  local i = 1
  local function skip()
    while i <= #s do
      local c = s:sub(i, i)
      if c == " " or c == "\t" or c == "\n" or c == "\r" then i = i + 1 else break end
    end
  end
  local parse_val
  local function parse_str()
    i = i + 1
    local buf = {}
    while i <= #s do
      local c = s:sub(i, i)
      if c == '"' then i = i + 1 return table.concat(buf) end
      if c == "\\" then
        local n = s:sub(i + 1, i + 1)
        if n == "n" then buf[#buf + 1] = "\n"
        elseif n == "t" then buf[#buf + 1] = "\t"
        elseif n == "r" then buf[#buf + 1] = "\r"
        elseif n == "u" then
          local cp = tonumber(s:sub(i + 2, i + 5), 16) or 63
          i = i + 4
          if cp < 0x80 then buf[#buf + 1] = string.char(cp)
          elseif cp < 0x800 then buf[#buf + 1] = string.char(0xC0 + math.floor(cp / 0x40), 0x80 + (cp % 0x40))
          else buf[#buf + 1] = string.char(0xE0 + math.floor(cp / 0x1000), 0x80 + (math.floor(cp / 0x40) % 0x40), 0x80 + (cp % 0x40)) end
        else buf[#buf + 1] = n end
        i = i + 2
      else
        buf[#buf + 1] = c
        i = i + 1
      end
    end
    error("unterminated string")
  end
  local function parse_num()
    local j = i
    while i <= #s and s:sub(i, i):match("[%-%+%.%deE]") do i = i + 1 end
    return tonumber(s:sub(j, i - 1))
  end
  local function parse_obj()
    i = i + 1
    local t = {}
    skip()
    if s:sub(i, i) == "}" then i = i + 1 return t end
    while true do
      skip()
      local k = parse_str()
      skip()
      if s:sub(i, i) == ":" then i = i + 1 end
      local v = parse_val()
      t[k] = v
      skip()
      local c = s:sub(i, i)
      if c == "," then i = i + 1
      elseif c == "}" then i = i + 1 break
      else error("bad object") end
    end
    return t
  end
  local function parse_arr()
    i = i + 1
    local t = {}
    skip()
    if s:sub(i, i) == "]" then i = i + 1 return t end
    while true do
      t[#t + 1] = parse_val()
      skip()
      local c = s:sub(i, i)
      if c == "," then i = i + 1
      elseif c == "]" then i = i + 1 break
      else error("bad array") end
    end
    return t
  end
  parse_val = function()
    skip()
    local c = s:sub(i, i)
    if c == '"' then return parse_str()
    elseif c == "{" then return parse_obj()
    elseif c == "[" then return parse_arr()
    elseif c == "t" then i = i + 4 return true
    elseif c == "f" then i = i + 5 return false
    elseif c == "n" then i = i + 4 return nil
    else return parse_num() end
  end
  return parse_val()
end

-- mute/unmute share a key on purpose (toggle); same for push-to-talk/mute.
-- Collapse each pair so a legit toggle isn't reported as a conflict.
HK_FAMILY = {
  ["libobs.mute"] = "mute", ["libobs.unmute"] = "mute",
  ["libobs.push-to-mute"] = "ptt", ["libobs.push-to-talk"] = "ptt",
}
HK_LABEL = {
  mute = "Mute/unmute", ptt = "Push-to-talk",
  ["OBSBasic.SelectScene"] = "Switch to scene",
  ["OBSBasic.TransitionToScene"] = "Transition to scene",
}
function hk_combo_canon(b)
  local m = ""
  if b.control then m = m .. "C" end
  if b.alt then m = m .. "A" end
  if b.shift then m = m .. "S" end
  if b.command then m = m .. "M" end
  return m .. "|" .. tostring(b.key)
end
function hk_combo_label(b)
  local parts = {}
  if b.control then parts[#parts + 1] = "Ctrl" end
  if b.alt then parts[#parts + 1] = "Alt" end
  if b.shift then parts[#parts + 1] = "Shift" end
  if b.command then parts[#parts + 1] = "Cmd" end
  parts[#parts + 1] = (tostring(b.key):gsub("^OBS_KEY_", ""))
  return table.concat(parts, "+")
end

-- ── findings collector ─────────────────────────────────────
-- cat: audio | perf | recording | sources | cleanup | scenes | system

-- The scan state + helpers in this region are script-globals ON PURPOSE: OBS's LuaJIT
-- caps any function at 60 upvalues, and collect() blew past that cap when these were
-- file-locals. Each OBS script runs in its own Lua state, so these globals are private
-- to this file. Do not re-localize them.
findings = {}
-- `scene` (optional) = the scene to jump to for "Show Me Where"; nil = no jump button
-- fixa (optional): a one-click fix action = { input = "<source name>", set = { key = value, ... } }.
-- Applied over obs-websocket (same connection as Show Me Where) with backup + undo. Premium.
function add(sev, cat, title, why, fix, scene, fixa)
  table.insert(findings, { sev = sev, cat = cat, title = title, why = why, fix = fix, scene = scene, fixa = fixa })
end

-- TIDY-UP findings: pure housekeeping that does NOT affect how OBS runs (disabled
-- filters, unused/off-screen/shrunk/0-opacity sources, default names, empty scenes).
-- They live in their own "Tidy up" tab so they never dilute the real findings, and
-- (being tip severity) they never touch the health score. Same fields as add(); the
-- `tidy = true` flag routes them and sorts them to the bottom of any list.
function tidyadd(cat, title, why, fix, scene)
  table.insert(findings, { sev = "tip", cat = cat, title = title, why = why, fix = fix, scene = scene, tidy = true })
end

-- Findings from the SLOW checks (disk free space, log parse, hotkey-conflict scan).
-- Those shell out to Windows `cmd`, which flashes a console window — so we do NOT run
-- them on the every-5s auto-scan (that flash was stealing focus). They run once at load
-- and whenever the panel is opened, and the cached results ride along on every refresh.
-- `scope` (optional, Scoring v2): "measured" marks a finding as an EVENT from the last
-- streamed session (overload, frame loss, disk death, ...) so the panel scores it in
-- the Last-stream lane; absent = config lane. Tagged here at add time, mirroring the
-- server's rules.js scope tagging, so the panel JS never guesses from copy.
slow_findings = {}
function sadd(sev, cat, title, why, fix, scene, scope)
  table.insert(slow_findings, { sev = sev, cat = cat, title = title, why = why, fix = fix, scene = scene, scope = scope })
end

-- "Healthy" list — things you've got set up RIGHT. Shown in the Healthy tab and used
-- as the positive side of the score (score = how much is good vs. how much is wrong).
goods = {}
function good(cat, title)
  table.insert(goods, { cat = cat, title = title })
end

-- Resource-drag census (Scoring v2): always-loaded browser extras the shared scoring
-- module pools into ONE capped "Resource drag" line in the config lane (heavy polish
-- piles cost a little; a single tidy item never dings). Rebuilt by every scan's source
-- walk; baked into the panel as window.SX_RESOURCE. Items are {id, drag} only: the
-- module's why-line speaks in counts, so no source names ride along.
resource_items = {}
function sx_resource_json()
  local rows = {}
  for _, it in ipairs(resource_items or {}) do
    rows[#rows + 1] = '{"id":"' .. tostring(it.id) .. '","drag":' .. math.floor(it.drag or 1) .. '}'
  end
  return "[" .. table.concat(rows, ",") .. "]"
end

-- Filename of the OBS log the slow-path checks last read (nil until the first slow
-- scan). Surfaced in the UI so it's never a secret that the audit reads your logs,
-- and which one. Persists across fast ticks (they don't re-read the log).
audit_log_name = nil

-- Raw text of the session log we last read (slow path). Cached so the Expert Read snapshot
-- can ship the actual log to the hosted diagnosis, not just the findings derived from it.
expert_log_raw = nil

-- Companion apps auto-collected for the WHOLE-SETUP (holistic) Expert Read: when present, the
-- snapshot carries their logs too, so the one "Get an Expert Read" button reads the whole rig at
-- once (OBS + Streamer.bot + Lumia) and the server routes to the holistic read automatically.
-- Lumia lives in a fixed spot (zero setup); Streamer.bot is portable, so its folder comes from the
-- script setting, with a plain connect/moved status for the Full System Health sub-meter.
-- Companion-app state bundled in ONE table so the big collect() function captures a single
-- upvalue for the whole group, not one per field. Lua 5.1 caps a function at 60 upvalues, and
-- each new collector we add (Streamer.bot, Lumia, VoiceMeeter, and future ones) would otherwise
-- push collect() past that ceiling. Fields: <app>_name / <app>_raw = the log/config we shipped
-- to the holistic Expert Read; <app>_status = the dock meter state.
comp = {
  sb_name = nil, sb_raw = nil, sb_status = "connect", sb_status_path = nil,  -- connect | ok | moved
  lumia_name = nil, lumia_raw = nil, lumia_status = "none",                  -- ok | none
  miu_name = nil, miu_raw = nil, miu_status = "none",                        -- Mix It Up (%LOCALAPPDATA%\MixItUp); ok | none (zero setup)
  fb_name = nil, fb_raw = nil, fb_status = "none",                           -- Firebot (%APPDATA%\Firebot\v5\logs); ok | none (zero setup)
  vm_name = nil, vm_raw = nil, vm_status = "none",                          -- VoiceMeeter XML config; ok | none (zero setup)
  tt_name = nil, tt_main = nil, tt_renderer = nil, tt_crash = nil, tt_status = "none", -- TikTok LIVE Studio (%APPDATA%\TikTok LIVE Studio); ok | none (external-capture multistream)
  crash_name = nil, crash_raw = nil, crash_status = "none",                 -- newest OBS crash report; ok | none (zero setup)
  crash_recent = nil, crash_when = nil, crash_component = nil,              -- recent crash filenames (JSON), + classified newest
  crash_stream_state = "unknown",                                          -- what OBS was doing at crash: live|recording|idle|unknown
  crash_list = nil,                                                        -- FULL recent crash set: each {name,when,state,was_live,component}
}
streamer_log_path = ""                        -- folder the user pointed us at (script setting)
script_settings = nil                         -- live OBS settings handle (set in script_load); lets the scan persist an auto-found Streamer.bot path

-- The recent OBS logs (newest first, filenames) offered in the session picker, and the one
-- currently chosen for the slow-path checks. audit_pick nil = auto (newest / current session).
audit_log_list = {}
audit_log_meta = {}   -- per-log: { streamed = bool, dur = "3h 56m" or nil }
audit_pick = nil

-- Full-rig inventory (scene/source/filter/audio graph) the Expert Read ships so it can
-- reason about the actual setup, not just the log + finding summaries. Plus the scene-
-- membership tables the audit already computed. Forward-declared here; the builder is
-- defined after jesc() and cached on the slow pass so the 8s re-render stays cheap.
rig_containment, rig_scene_items, rig_inv_json = nil
snapshot_inventory = nil

-- Multi-session "auto-pull" payload: the newest REAL-stream logs' raw text, head+tail
-- capped and JSON-encoded, built ONCE on the slow pass and cached so the 8s re-render
-- never re-reads disk or re-encodes it. The dock button POSTs this to /api/read/aggregate
-- for a cross-session read of your last few streams. count = how many streams we bundled.
agg_logs_json = "[]"
agg_logs_count = 0
build_agg_payload = nil
sx_archive_logs = nil

-- Was a log a REAL stream, and roughly how long? OBS writes "==== Streaming Start"
-- only when you actually go live, so an idle troubleshooting session (or even the
-- "Starting Soon" media filename) can't be mistaken for one. Scanned in-process
-- (plain line match) instead of shelling out to findstr, so no console flash.
function log_stream_meta(fullpath)
  local meta = { streamed = false, dur = nil }
  local startt, stopt
  pcall(function()
    for line in io.lines(fullpath) do
      if line:find("==== Streaming St", 1, true) then   -- covers both Start and Stop markers
        local hh, mm, ss = line:match("^(%d%d):(%d%d):(%d%d)")
        local secs = hh and (tonumber(hh) * 3600 + tonumber(mm) * 60 + tonumber(ss)) or nil
        if line:find("Streaming Start", 1, true) then meta.streamed = true; startt = startt or secs end
        if line:find("Streaming Stop", 1, true) then stopt = secs end
      end
    end
  end)
  if meta.streamed and startt and stopt then
    local d = stopt - startt
    if d < 0 then d = d + 86400 end   -- session ran past midnight
    local h, m = math.floor(d / 3600), math.floor((d % 3600) / 60)
    if h > 0 then meta.dur = h .. "h" .. (m > 0 and (" " .. m .. "m") or "") else meta.dur = m .. "m" end
    meta.start_secs, meta.stop_secs, meta.dur_secs = startt, stopt, d   -- for companion time-alignment
  elseif meta.streamed and startt and not stopt then
    -- Started streaming but the log has no "Streaming Stop" marker: OBS crashed or was
    -- force-closed mid-broadcast. No clean duration, and this is itself a finding worth
    -- surfacing (a stream that ended unexpectedly is exactly what a deep read digs into).
    meta.ended_bad = true
    meta.start_secs = startt
  end
  return meta
end

-- Is a browser URL a live/stateful widget (alerts, chat, timer, goal) or a local
-- connection? Enabling shutdown/refresh on these RELOADS them on every scene switch,
-- which resets timers and goals, clears chat, and can drop alerts during the reconnect.
-- So for these we advise the OPPOSITE of a static overlay: keep them running.
local WIDGET_HOSTS = {
  { "streamelements", "StreamElements" }, { "streamlabs", "Streamlabs" }, { "twitchalerts", "Streamlabs" },
  { "nutty.gg", "Nutty" }, { "tikfinity", "TikFinity" }, { "tipeeestream", "TipeeeStream" },
  { "muxy.io", "Muxy" }, { "lumiastream", "Lumia Stream" }, { "own3d", "OWN3D" }, { "restream", "Restream" },
  { "streamer.bot", "a local app" }, { "127.0.0.1", "a local app" }, { "localhost", "a local app" },
  { "ko-fi", "Ko-fi" }, { "fourthwall", "Fourthwall" }, { "botrix", "Botrix" }, { "crowdcontrol", "Crowd Control" },
}
function widget_info(url)
  if not url or url == "" then return nil end
  local u = url:lower()
  local provider
  for _, w in ipairs(WIDGET_HOSTS) do if u:find(w[1], 1, true) then provider = w[2]; break end end
  if not provider then return nil end
  local kind = "widget"
  if u:find("alert") then kind = "alert box"
  elseif u:find("chat") then kind = "chat box"
  elseif u:find("goal") then kind = "goal bar"
  elseif u:find("timer") or u:find("countdown") or u:find("subathon") or u:find("marathon") then kind = "timer"
  elseif u:find("ticker") or u:find("event") or u:find("label") then kind = "event list" end
  return { provider = provider, kind = kind }
end

-- stable short id for a finding (so a dismissed item stays dismissed across refreshes)
local function short_hash(s)
  local h = 5381
  for i = 1, #s do h = (h * 33 + s:byte(i)) % 2147483648 end
  return "f" .. h
end

-- the top-level scene a source first appears in (strips " › Group" nesting + " (hidden)")
function first_scene_of(containment, name)
  local list = containment and containment[name]
  if not list or #list == 0 then return nil end
  local p = list[1]:gsub(" %(hidden%)$", "")
  return p:match("^(.-) › ") or p
end

MIC_KINDS = {
  wasapi_input_capture = true, coreaudio_input_capture = true, pulse_input_capture = true,
}
MEDIA_KINDS = { ffmpeg_source = true, vlc_source = true }

local DEFAULT_NAMES = {
  "Audio Input Capture", "Audio Output Capture", "Video Capture Device",
  "Display Capture", "Game Capture", "Window Capture", "Browser", "Image",
  "Media Source", "Text %(GDI%+%)", "Color Source", "Slideshow",
}
function is_default_name(name)
  for _, p in ipairs(DEFAULT_NAMES) do
    if name == p:gsub("%%", "") or name:match("^" .. p .. " %d+$") then return true end
  end
  return false
end

-- filters: return an ordered list of filter type-ids on a source
function filter_ids(src)
  local ids = {}
  safe(function()
    local list = obs.obs_source_enum_filters(src)
    if list ~= nil then
      for _, f in ipairs(list) do
        table.insert(ids, obs.obs_source_get_id(f))
      end
      obs.source_list_release(list)
    end
  end)
  return ids
end
function index_of(list, id)
  for i, v in ipairs(list) do if v == id then return i end end
  return nil
end

-- ── Phase 6: transform signature + small list helper ───────
-- A canonical string of a scene item's transform, so two copies of the same
-- source can be compared for "placed identically" across scenes.

local function round(n, mult)
  mult = mult or 1
  return math.floor((n or 0) * mult + 0.5) / mult
end

-- quote + join a list of names, showing at most `maxn` then "+N more"
function quote_join(list, maxn)
  local n = maxn or #list
  local shown = {}
  for k = 1, math.min(#list, n) do shown[k] = "“" .. tostring(list[k]) .. "”" end
  local s = table.concat(shown, ", ")
  if #list > n then s = s .. " +" .. (#list - n) .. " more" end
  return s
end

local function item_transform_sig(item)
  local parts = {}
  safe(function()
    local pos = obs.vec2()
    obs.obs_sceneitem_get_pos(item, pos)
    parts[#parts + 1] = round(pos.x) .. "," .. round(pos.y)
    local sc = obs.vec2()
    obs.obs_sceneitem_get_scale(item, sc)
    parts[#parts + 1] = round(sc.x, 1000) .. "x" .. round(sc.y, 1000)
    parts[#parts + 1] = "a" .. tostring(obs.obs_sceneitem_get_alignment(item))
    local bt = obs.obs_sceneitem_get_bounds_type(item)
    parts[#parts + 1] = "b" .. tostring(bt)
    if bt ~= obs.OBS_BOUNDS_NONE then
      local b = obs.vec2()
      obs.obs_sceneitem_get_bounds(item, b)
      parts[#parts + 1] = round(b.x) .. "," .. round(b.y) .. ",ba" .. tostring(obs.obs_sceneitem_get_bounds_alignment(item))
    end
    local rot = 0
    pcall(function() rot = obs.obs_sceneitem_get_rot(item) end)
    parts[#parts + 1] = "r" .. round(rot)
    local crop = obs.obs_sceneitem_crop()
    pcall(function() obs.obs_sceneitem_get_crop(item, crop) end)
    parts[#parts + 1] = "c" .. tostring(crop.left) .. "," .. tostring(crop.right) .. "," .. tostring(crop.top) .. "," .. tostring(crop.bottom)
  end)
  return table.concat(parts, "|")
end

-- ── scene walk (containment + stretch flags) ───────────────

stretched = {}
scene_item_count = {}
canvas_w, canvas_h = 1920, 1080
geo_seen = {}
scene_direct_items = {}
layout_records = {}   -- Phase 6: { {scene=, name=, sig=}, ... } for top-level items

function walk_scene(scene_source, path, containment, depth)
  if depth > 8 then return 0 end
  local scene = obs.obs_scene_from_source(scene_source)
  if scene == nil then scene = obs.obs_group_from_source(scene_source) end
  if scene == nil then return 0 end

  local items = obs.obs_scene_enum_items(scene)
  local count = 0
  if items ~= nil then
    for _, item in ipairs(items) do
      count = count + 1
      local src = obs.obs_sceneitem_get_source(item)
      if src ~= nil then
        local nm = obs.obs_source_get_name(src)
        local visible = obs.obs_sceneitem_visible(item)
        containment[nm] = containment[nm] or {}
        table.insert(containment[nm], path .. (visible and "" or " (hidden)"))
        if depth == 0 then
          scene_direct_items[path] = scene_direct_items[path] or {}
          table.insert(scene_direct_items[path], nm)
          -- Phase 6: record this top-level item's placement for cross-scene cluster detection
          layout_records[#layout_records + 1] = { scene = path, name = nm, sig = item_transform_sig(item) }
        end

        safe(function()
          local btype = obs.obs_sceneitem_get_bounds_type(item)
          if btype == obs.OBS_BOUNDS_NONE then
            local sc = obs.vec2()
            obs.obs_sceneitem_get_scale(item, sc)
            local x, y = sc.x, sc.y
            if x > 0 and y > 0 then
              -- only flag a clearly-distorted source (≥20% aspect skew); slight
              -- fit-to-frame stretching is intentional and shouldn't nag.
              local ratio = (x > y) and (x / y) or (y / x)
              if ratio > 1.2 then stretched[nm] = true end
            end
          end
        end)

        -- Phase 5: wasted render — top-level, visible items only (nested/grouped
        -- items live in their own coordinate space, so we skip those to stay safe).
        if depth == 0 and visible and not geo_seen[nm] then
          safe(function()
            local bw = obs.obs_source_get_width(src)
            local bh = obs.obs_source_get_height(src)
            if not bw or bw == 0 or not bh or bh == 0 then return end
            local btype = obs.obs_sceneitem_get_bounds_type(item)
            local w, h
            if btype ~= obs.OBS_BOUNDS_NONE then
              local b = obs.vec2()
              obs.obs_sceneitem_get_bounds(item, b)
              w, h = b.x, b.y
            else
              local sc = obs.vec2()
              obs.obs_sceneitem_get_scale(item, sc)
              w, h = bw * sc.x, bh * sc.y
            end
            if w < 1 or h < 1 then
              geo_seen[nm] = true
              tidyadd("cleanup",
                "“" .. nm .. "” is shrunk down to nothing",
                "Its size on the canvas is basically zero, so it's invisible on stream even though it's still loaded and rendering every frame.",
                "Select it in the scene and drag a corner to resize, or right-click → Transform → “Reset Transform”. If you don't need it, remove it.",
                path)
              return
            end
            -- alignment bits: LEFT=1 RIGHT=2 TOP=4 BOTTOM=8 (center = neither)
            local pos = obs.vec2()
            obs.obs_sceneitem_get_pos(item, pos)
            local align = obs.obs_sceneitem_get_alignment(item)
            local left, top
            if bit.band(align, 1) ~= 0 then left = pos.x
            elseif bit.band(align, 2) ~= 0 then left = pos.x - w
            else left = pos.x - w / 2 end
            if bit.band(align, 4) ~= 0 then top = pos.y
            elseif bit.band(align, 8) ~= 0 then top = pos.y - h
            else top = pos.y - h / 2 end
            if (left + w) <= 0 or left >= canvas_w or (top + h) <= 0 or top >= canvas_h then
              geo_seen[nm] = true
              tidyadd("cleanup",
                "“" .. nm .. "” is parked off-screen",
                "It's positioned completely outside your canvas, so viewers never see it. OBS still renders it every frame, wasting GPU.",
                "In the scene, drag “" .. nm .. "” back onto the canvas (or right-click → Transform → “Fit to screen”). If it's leftover, remove it.",
                path)
            end
          end)
        end

        local id = obs.obs_source_get_id(src)
        if id == "group" or obs.obs_source_get_type(src) == obs.OBS_SOURCE_TYPE_SCENE then
          walk_scene(src, path .. " › " .. nm, containment, depth + 1)
        end
      end
    end
    obs.sceneitem_list_release(items)
  end
  return count
end

-- ── main collect ───────────────────────────────────────────

local function collect(do_slow)
  findings = {}
  stretched = {}
  scene_item_count = {}
  geo_seen = {}
  scene_direct_items = {}
  layout_records = {}
  goods = {}
  resource_items = {}
  if do_slow then slow_findings = {} end   -- refresh the cached shell-based checks
  local containment = {}

  -- canvas (base) size for the off-screen check
  canvas_w, canvas_h = 1920, 1080
  safe(function()
    local ovi = obs.obs_video_info()
    if obs.obs_get_video_info(ovi) then
      canvas_w = ovi.base_width
      canvas_h = ovi.base_height
    end
  end)

  -- config (basic.ini) — read early so audio track logic can use it
  local cfg = { stream_track = 1, vod_enabled = false, vod_track = 0, adv = false, rec_tracks = 0 }
  safe(function()
    local profile_dir
    local ok = pcall(function() profile_dir = obs.obs_frontend_get_current_profile_path() end)
    if not ok or not profile_dir then
      local appdata = os.getenv("APPDATA")
      local prof = obs.obs_frontend_get_current_profile()
      if appdata and prof then profile_dir = appdata .. "\\obs-studio\\basic\\profiles\\" .. prof end
    end
    if not profile_dir then return end

    local ini = parse_ini(read_file(profile_dir .. "\\basic.ini"))
    local video   = ini["Video"] or {}
    local simple  = ini["SimpleOutput"] or {}
    local advout  = ini["AdvOut"] or {}
    local output  = ini["Output"] or {}

    cfg.adv = ((output["mode"] or ""):lower() == "advanced")
    cfg.stream_track = tonumber(advout["trackindex"]) or 1
    cfg.vod_enabled = ((advout["vodtrackenabled"] or ""):lower() == "true")
    cfg.vod_track = tonumber(advout["vodtrackindex"]) or 2
    -- which tracks get recorded: advanced = RecTracks bitmask; simple = the single stream track
    cfg.rec_tracks = cfg.adv and (tonumber(advout["rectracks"]) or 1) or bit.lshift(1, cfg.stream_track - 1)

    -- canvas vs output resolution
    local bcx, bcy = tonumber(video["basecx"]), tonumber(video["basecy"])
    local ocx, ocy = tonumber(video["outputcx"]), tonumber(video["outputcy"])
    if bcx and bcy and ocx and ocy and (bcx ~= ocx or bcy ~= ocy) then
      add("tip", "perf",
        "Canvas is " .. bcx .. "×" .. bcy .. " but you output " .. ocx .. "×" .. ocy,
        "OBS resizes every frame from canvas to output size. Downscaling (e.g. 1440p→1080p) is normal; just make sure the output is a clean resolution you actually want to send.",
        "Settings → Video → set “Output (Scaled) Resolution” to your target (usually 1920×1080).")

      -- downscale filter quality only matters when actually scaling
      local st = (video["scaletype"] or ""):lower()
      if st == "bilinear" then
        add("tip", "perf",
          "Your downscale filter is Bilinear (the blurry one)",
          "Bilinear softens the image when OBS scales your canvas down, so you're losing sharpness for no reason.",
          "Settings → Video → Downscale Filter → choose “Lanczos” (sharpest) or “Bicubic”.")
      elseif st ~= "" then
        good("perf", "Downscale filter is " .. (video["scaletype"] or "set") .. " (sharp)")
      end
    elseif bcx and bcy and ocx and ocy then
      good("perf", "Canvas and output resolution match (no rescaling)")
    end

    -- recording format
    local recfmt = (simple["recformat2"] or simple["recformat"] or advout["recformat2"] or advout["recformat"] or ""):lower()
    if recfmt == "mp4" then
      add("warning", "recording",
        "You're recording to plain MP4",
        "If OBS or your PC crashes mid-recording, a plain MP4 is unrecoverable: the whole file is corrupted and lost.",
        "Settings → Output → Recording → Recording Format → choose “Hybrid MP4” (or MKV, then File → Remux Recordings). Crash-safe, same MP4 result.")
    elseif recfmt ~= "" then
      good("recording", "Recording format is crash-safe (not plain MP4)")
    end

    -- CPU encoder tip
    local enc = (simple["streamencoder"] or advout["encoder"] or ""):lower()
    if enc:find("x264") then
      add("tip", "perf",
        "You're encoding with x264 (CPU)",
        "x264 uses your processor. If you have a modern NVIDIA (NVENC) or AMD GPU, hardware encoding frees the CPU for your game with basically no quality loss.",
        "Settings → Output → Streaming → Encoder → pick the hardware option (NVIDIA NVENC / AMD) if listed. If x264 is your only choice, ignore this.")
    elseif enc ~= "" then
      good("perf", "Encoding on your GPU (frees up the CPU)")
    end

    -- audio bitrate
    local ab = tonumber(simple["abitrate"] or advout["track" .. cfg.stream_track .. "bitrate"] or "")
    if ab and ab < 128 then
      add("tip", "audio",
        "Your audio bitrate is only " .. ab .. " kbps",
        "Below 128 kbps, music and voice start sounding thin or swishy. 160 kbps is the sweet spot for streaming.",
        "Settings → Output → Audio → set Audio Bitrate to 160.")
    elseif ab and ab >= 128 then
      good("audio", "Audio bitrate is solid (" .. ab .. " kbps)")
    end

    -- ── Phase 3: encoding / performance ──────────────────────
    local out_h = tonumber(video["outputcy"]) or 1080
    local fpsnum = tonumber((video["fpscommon"] or ""):match("^%d+")) or tonumber(video["fpsint"]) or 30

    -- color range / format (mode-independent, [Video])
    local crange = (video["colorrange"] or ""):lower()
    if crange == "full" then
      add("tip", "perf",
        "Your color range is set to “Full”",
        "Most streaming platforms and players expect “Partial/Limited” range. Full range makes your colors look washed-out or crushed for a lot of viewers.",
        "Settings → Advanced → Video → Color Range → “Partial” (unless you specifically need Full).")
    elseif crange ~= "" then
      good("perf", "Color range is Partial (correct for streaming)")
    end
    local cfmt = (video["colorformat"] or ""):upper()
    if cfmt:find("444") then
      add("tip", "perf",
        "Your color format is " .. cfmt .. " (not NV12)",
        "NV12 is the standard for streaming. I444/other formats use far more bandwidth and CPU and aren't supported by every player.",
        "Settings → Advanced → Video → Color Format → “NV12” for streaming.")
    elseif cfmt ~= "" then
      good("perf", "Color format is " .. cfmt .. " (streaming-standard)")
    end

    -- encoder settings (advanced = JSON via OBS's own loader; simple = ini)
    local venc = (simple["streamencoder"] or advout["encoder"] or ""):lower()
    local bitrate, rc, keyint, preset, psycho
    if cfg.adv then
      local ok2, d = pcall(function() return obs.obs_data_create_from_json_file(profile_dir .. "\\streamEncoder.json") end)
      if ok2 and d ~= nil then
        bitrate = obs.obs_data_get_int(d, "bitrate")
        rc = obs.obs_data_get_string(d, "rate_control")
        keyint = obs.obs_data_get_int(d, "keyint_sec")
        preset = obs.obs_data_get_string(d, "preset")
        if preset == "" then preset = obs.obs_data_get_string(d, "preset2") end
        psycho = obs.obs_data_get_bool(d, "psycho_aural_tuning")
        obs.obs_data_release(d)
      end
    else
      bitrate = tonumber(simple["vbitrate"])
      preset = simple["preset"]
    end

    -- rate control must be CBR for streaming (advanced only)
    if cfg.adv and rc and rc ~= "" and rc:upper() ~= "CBR" then
      add("warning", "perf",
        "Your streaming rate control is " .. rc .. ", not CBR",
        "Twitch and most platforms want CBR for a stable stream. VBR/CQP let your bitrate spike and dip, which shows up as dropped frames and buffering for viewers.",
        "Settings → Output → Streaming → Rate Control → “CBR”.")
    elseif cfg.adv and rc and rc:upper() == "CBR" then
      good("perf", "Rate control is CBR (stable for streaming)")
    end

    -- keyframe interval = 2 for Twitch (advanced only; simple mode auto-sets it)
    if cfg.adv and keyint and keyint ~= 0 and keyint ~= 2 then
      add("warning", "perf",
        "Your keyframe interval is " .. keyint .. "s (Twitch wants 2)",
        "Twitch requires a 2-second keyframe interval. Anything else can cause playback issues and transcoding problems for your viewers.",
        "Settings → Output → Streaming → Keyframe Interval → 2.")
    elseif cfg.adv and keyint == 2 then
      good("perf", "Keyframe interval is 2s (Twitch-ready)")
    end

    -- bitrate sanity vs resolution/fps
    if bitrate and bitrate > 0 then
      if bitrate > 6500 then
        add("tip", "perf",
          "Your video bitrate is " .. bitrate .. " kbps (above Twitch's ~6000 guideline)",
          "Twitch recommends staying around 6000. Higher can work for non-partners but risks buffering for viewers on slower connections and instability if your upload can't sustain it.",
          "Settings → Output → set Video Bitrate near 6000 for 1080p, ~4500 for 720p, unless you know your audience and upload handle more.")
      else
        local floor
        if out_h >= 1080 then floor = (fpsnum >= 50) and 4500 or 3500
        elseif out_h >= 720 then floor = (fpsnum >= 50) and 3500 or 2500
        else floor = 1500 end
        if bitrate < floor then
          add("tip", "perf",
            "Your video bitrate (" .. bitrate .. " kbps) is low for " .. out_h .. "p" .. (fpsnum >= 50 and "60" or ""),
            "At this bitrate, fast motion (games, camera movement) turns blocky and smeary: your resolution is writing a check the bitrate can't cash.",
            "Settings → Output → raise Video Bitrate toward " .. (out_h >= 1080 and "6000" or "4500") .. ", or drop to a lower resolution/FPS this bitrate can feed cleanly.")
        end
      end
    end

    -- x264 preset too fast
    if venc:find("x264") and preset and (preset:lower() == "ultrafast" or preset:lower() == "superfast") then
      add("tip", "perf",
        "Your x264 preset is “" .. preset .. "” (fast but low quality)",
        "The fastest presets skip most of x264's quality work, so your stream looks blockier than it needs to at the same bitrate.",
        "Settings → Output → Streaming → CPU Usage Preset → “veryfast” (the standard balance). Only go faster if your CPU is maxing out.")
    end

    -- NVENC quality left on the table
    if venc:find("nvenc") and cfg.adv and psycho == false then
      add("tip", "perf",
        "NVENC Psycho Visual Tuning is off",
        "This one NVENC setting noticeably sharpens your image at the same bitrate, basically free quality on modern NVIDIA cards.",
        "Settings → Output → Streaming → tick “Psycho Visual Tuning” and set Preset to P5/“Quality” or higher.")
    end

    -- ── Phase 4: recording / VOD safety ──────────────────────
    local recpath = simple["filepath"] or advout["recfilepath"] or advout["fffilepath"]
    if recpath and recpath:match("^%a:") then
      local drive = recpath:sub(1, 2)
      local osdrive = os.getenv("SystemDrive") or "C:"
      if drive:upper() == osdrive:upper() then
        add("tip", "recording",
          "You're recording onto your " .. drive .. " system drive",
          "Recording to the same drive Windows and your game run from makes them compete for the disk, which can cause “Encoding overloaded”, stutter, and dropped frames even with a strong CPU/GPU.",
          "Settings → Output → Recording → Recording Path → point it at a separate drive if you have one.")
      end
      if do_slow then   -- disk free-space shells out to cmd; slow path only (no console flash)
        local free = drive_free_gb(drive)
        if free ~= nil then
          if free < 10 then
            sadd("warning", "recording",
              "Your recording drive (" .. drive .. ") has only ~" .. free .. " GB free",
              "At streaming bitrates a recording eats 1-3 GB every 10 minutes. This drive could fill up mid-stream and your recording just stops.",
              "Free up space, or change the Recording Path (Settings → Output → Recording) to a drive with room.")
          elseif free < 25 then
            sadd("tip", "recording",
              "Your recording drive (" .. drive .. ") is down to ~" .. free .. " GB free",
              "A few hours of recording can use 20-40 GB, so you're close to the line for a long session.",
              "Clear some space before a long stream, or point the Recording Path at a roomier drive.")
          end
        end
      end
    end

    -- (Replay buffer is a personal preference — clipping the past is opt-in and
    -- costs resources when you don't want it, so we don't flag it as an issue.)
  end)

  -- walk every scene
  local scenes = obs.obs_frontend_get_scenes()
  if scenes ~= nil then
    for _, sc in ipairs(scenes) do
      local nm = obs.obs_source_get_name(sc)
      local c = walk_scene(sc, nm, containment, 0)
      if obs.obs_source_get_type(sc) == obs.OBS_SOURCE_TYPE_SCENE then
        scene_item_count[nm] = c or 0
      end
    end
    obs.source_list_release(scenes)
  end

  -- sources
  local device_groups = {}
  local has_media_source = false
  local has_desktop_audio_on = false
  -- trackers for "Healthy" aggregate passes
  local n_browsers, n_browsers_active, n_browsers_noshut = 0, 0, 0
  local n_browsers_widgets, n_browsers_widget_bad = 0, 0   -- connected widgets, and ones set to reload destructively
  local browser_list = {}   -- {name, scene, active} per browser source, for the overview drill-down
  -- Widgets the user parked as "rarely used" (kept for later; the reload setting is
  -- the right one for them, saving resources; the audit speaks up on days the widget
  -- is actually in use). Same persistent slot pattern as sx_audit_situational.
  local parked = {}
  do
    local pk = read_persist_str("sx_audit_parked")
    if pk and pk ~= "" then
      for pnm in (pk .. " ||| "):gmatch("(.-) %|%|%| ") do
        pnm = pnm:gsub("^%s+", ""):gsub("%s+$", "")
        if pnm ~= "" then parked[pnm] = true end
      end
    end
  end
  local n_parked_reload = 0
  local n_files, files_missing = 0, false
  local n_mics, mics_all_filtered = 0, true
  local monitor_echo = false
  local sources = obs.obs_enum_sources()
  if sources ~= nil then
    for _, src in ipairs(sources) do
      local name = obs.obs_source_get_name(src)
      local kind = obs.obs_source_get_id(src)
      local settings = obs.obs_source_get_settings(src)
      local is_input = (obs.obs_source_get_type(src) == obs.OBS_SOURCE_TYPE_INPUT)
      local jscene = first_scene_of(containment, name)   -- scene to jump to for "Show Me Where"

      -- browser sources
      if kind == "browser_source" then
        n_browsers = n_browsers + 1
        local b_active = obs.obs_source_active(src)
        if b_active then n_browsers_active = n_browsers_active + 1 end
        local url = obs.obs_data_get_string(settings, "url")
        local wi = widget_info(url)
        browser_list[#browser_list + 1] = { name = name, scene = jscene, active = b_active, widget = wi and true or false }
        local has_shutdown = obs.obs_data_get_bool(settings, "shutdown")
        local has_refresh = obs.obs_data_get_bool(settings, "restart_when_active")
        if wi then n_browsers_widgets = n_browsers_widgets + 1 end
        -- Resource-drag pool: shutdown OFF = this Chrome process stays loaded the whole
        -- time OBS is open, whether or not it is on screen. Each one is a small, honest
        -- drag; the scoring module only charges when they pile up.
        if not has_shutdown then
          table.insert(resource_items, { id = wi and "widget-always" or "browser-always", drag = 1 })
        end
        local pstate = wi and sxdash.park_classify(parked[name] == true, b_active and true or false,
          (has_shutdown or has_refresh) and true or false) or nil
        if wi and pstate then
          -- The user parked this widget as "rarely used": respect the choice. The two
          -- quiet states stay quiet; the two actionable ones get a TIP (never a score
          -- hit; the park was a decision, these are reminders in its service).
          if pstate == "parked-ok" then n_parked_reload = n_parked_reload + 1 end
          if pstate == "use-today" then
            add("tip", "sources",
              "You're using “" .. name .. "” today",
              "You parked this " .. wi.provider .. " " .. wi.kind .. " as rarely used, and it's on screen right now. Its reload setting is still on, so every scene switch today reloads it from scratch: timers and goals reset, chat clears, and it can miss alerts for a second or two.",
              "One click below makes it stay running for today's stream. When it's off screen again after your stream, you'll get a one click offer to flip it back to saving resources. Using it regularly again? Hit “I use this regularly” and it comes off the parked list.",
              jscene,
              { input = name, set = { shutdown = false, restart_when_active = false } })
            findings[#findings].park_restore_name = name
          elseif pstate == "save-resources" then
            add("tip", "sources",
              "Parked widget “" .. name .. "” is still always loaded",
              "You parked this " .. wi.provider .. " " .. wi.kind .. " as rarely used, but its settings keep it loaded the whole time OBS is open, spending memory on a widget you're not showing.",
              "One click below turns its reload setting back on, so it only loads when you actually show it. On a day you use it, the audit will offer to flip it for the stream. Using it regularly again? Hit “I use this regularly” and it comes off the parked list.",
              jscene,
              { input = name, set = { shutdown = true } })
            findings[#findings].park_restore_name = name
          end
        elseif wi and (has_shutdown or has_refresh) then
          -- A connected widget set to reload on scene change. This is the real, safe finding:
          -- reloading a timer/goal/alert/chat resets its state and can drop alerts.
          n_browsers_widget_bad = n_browsers_widget_bad + 1
          add("warning", "sources",
            "Your " .. wi.kind .. " “" .. name .. "” reloads on every scene change",
            "“" .. name .. "” is a " .. wi.provider .. " " .. wi.kind .. ", a connected widget that holds live state. It has " ..
              (has_shutdown and "“Shutdown source when not visible”" or "“Refresh browser when scene becomes active”") ..
              " turned on, which reloads it from scratch every time you switch to its scene. That resets timers and goals, clears chat history, and can make it MISS alerts during the second or two it takes to reconnect. The honest cost of the fix: the widget then stays quietly loaded while OBS is open, holding a little memory like a background browser tab. Worth it for a widget you actually use. If this one rarely goes on stream, click “I rarely use this” below instead: your score comes back, the reload setting stays as the resource saver, and the audit will speak up on days you actually use it.",
            "Right-click “" .. name .. "” → Properties → turn OFF “Shutdown source when not visible” and “Refresh browser when scene becomes active”. Connected widgets should stay running.",
            jscene,
            { input = name, set = { shutdown = false, restart_when_active = false } })
          findings[#findings].park_name = name
        elseif (not wi) and (not has_shutdown) then
          -- Static/unknown overlay with no shutdown: a possible resource save, surfaced in the
          -- overview (not per-source) since we can't be certain it isn't stateful.
          n_browsers_noshut = n_browsers_noshut + 1
        end
        if obs.obs_data_get_bool(settings, "fps_custom") and obs.obs_data_get_int(settings, "fps") >= 60 then
          add("tip", "perf",
            "Browser overlay “" .. name .. "” runs at " .. obs.obs_data_get_int(settings, "fps") .. " fps",
            "Most overlays (alerts, chat, labels) don't need 60 fps, it just burns GPU for no visible gain.",
            "Right-click “" .. name .. "” → Properties → set FPS to 30 unless it's genuinely animated video.",
            jscene)
          table.insert(resource_items, { id = "browser-60fps", drag = 1 })
        end
      end

      -- image sources
      if kind == "image_source" then
        local path = obs.obs_data_get_string(settings, "file")
        if path and path ~= "" then n_files = n_files + 1 end
        if path and path ~= "" and not file_exists(path) then
          files_missing = true
          add("critical", "sources",
            "Image “" .. name .. "” points to a file that's gone",
            "It'll show as nothing (or a broken box) on stream, usually because the file was moved or renamed.",
            "Right-click “" .. name .. "” → Properties → Browse to the file's new location.",
            jscene)
        else
          local sz = path and path ~= "" and file_size(path) or nil
          if sz and sz > 4 * 1024 * 1024 then
            add("tip", "sources",
              "Image “" .. name .. "” is " .. math.floor(sz / (1024 * 1024)) .. " MB",
              "Oversized images eat memory and slow scene loads for no quality gain at stream resolution.",
              "Downscale it to roughly its on-screen size, and tick Properties → “Unload image when not showing”.",
              jscene)
          end
        end
      end

      -- media sources
      if kind == "ffmpeg_source" then
        if obs.obs_data_get_bool(settings, "is_local_file") then
          local path = obs.obs_data_get_string(settings, "local_file")
          if path and path ~= "" then n_files = n_files + 1 end
          if path and path ~= "" and not file_exists(path) then
            files_missing = true
            add("critical", "sources",
              "Media source “" .. name .. "” can't find its file",
              "It'll play nothing (black/silence) on stream: the video or audio file was moved, renamed, or deleted.",
              "Right-click “" .. name .. "” → Properties → Browse to the file's new location.",
              jscene)
          end
        end
      end
      if MEDIA_KINDS[kind] then has_media_source = true end

      -- game capture locked to a specific window (fragile black-screen)
      if kind == "game_capture" then
        local mode = obs.obs_data_get_string(settings, "capture_mode")
        if mode == "window" then
          add("tip", "sources",
            "Game Capture “" .. name .. "” is locked to one specific window",
            "If that game closes or updates its window, the capture goes black. It's the #1 cause of “my game capture stopped working”.",
            "Right-click “" .. name .. "” → Properties → Mode → “Capture any fullscreen application” (or re-pick the window after the game is running).",
            jscene)
        end
      end

      -- duplicate device detection
      local dev = nil
      if kind == "wasapi_output_capture" or kind == "wasapi_input_capture" then
        dev = obs.obs_data_get_string(settings, "device_id")
      elseif kind == "dshow_input" then
        dev = obs.obs_data_get_string(settings, "video_device_id")
      end
      if dev and dev ~= "" and dev ~= "default" then
        local key = kind .. "|" .. dev
        device_groups[key] = device_groups[key] or { kind = kind, names = {} }
        table.insert(device_groups[key].names, name)
      end

      -- desktop audio device left on Default
      if kind == "wasapi_output_capture" then
        if obs.obs_data_get_string(settings, "device_id") == "default" then
          add("tip", "audio",
            "Desktop Audio “" .. name .. "” is set to “Default” device",
            "When Windows switches your default output (headset unplugged, a game grabs it), OBS can silently follow the wrong device and your stream loses sound.",
            "Right-click “" .. name .. "” → Properties → pick your actual speakers/headset by name instead of “Default”.")
        end
        if not obs.obs_source_muted(src) then has_desktop_audio_on = true end
      end

      -- audio-capable checks
      local is_audio = (bit.band(obs.obs_source_get_output_flags(src), obs.OBS_SOURCE_AUDIO) ~= 0)
      if is_audio then
        -- Monitor and Output echo
        if obs.obs_source_get_monitoring_type(src) == 2 then
          monitor_echo = true
          add("warning", "audio",
            "“" .. name .. "” is set to “Monitor and Output”",
            "This sends the audio to your headphones AND the stream at once, a very common cause of echo/doubling.",
            "Audio Mixer → gear on “" .. name .. "” → Advanced Audio Properties → set Audio Monitoring to “Monitor Off” unless you truly need to hear it.")
        end
        -- clipping (boosted above 0 dB)
        local vol = obs.obs_source_get_volume(src)
        if vol and vol > 1.4 then
          add("warning", "audio",
            "“" .. name .. "” volume is boosted above 0 dB",
            "Gain above 0 dB clips: the loud parts distort and there's no way to fix it after. Louder should come from a compressor/gain filter, not the fader.",
            "Audio Mixer → Advanced Audio Properties → bring “" .. name .. "” back to 0 dB (100%). Need more level? Add a Gain or Compressor filter instead.")
        end

        -- VOD / track intent (the big one)
        safe(function()
          local mix = obs.obs_source_get_audio_mixers(src)
          if not mix then return end
          local on_stream = (bit.band(mix, bit.lshift(1, cfg.stream_track - 1)) ~= 0)
          if cfg.vod_enabled and cfg.vod_track ~= cfg.stream_track then
            local on_vod = (bit.band(mix, bit.lshift(1, cfg.vod_track - 1)) ~= 0)
            if MIC_KINDS[kind] and on_stream and not on_vod then
              add("warning", "audio",
                "Your mic “" .. name .. "” is missing from your Twitch VOD",
                "It's on your live track but not your VOD track, so anyone who rewatches the VOD hears everything except your voice.",
                "Audio Mixer → Advanced Audio Properties → under Tracks for “" .. name .. "”, tick Track " .. cfg.vod_track .. " (your VOD track) as well as Track " .. cfg.stream_track .. ".")
            end
            if MEDIA_KINDS[kind] and on_vod then
              add("tip", "audio",
                "Music/media “" .. name .. "” is going into your Twitch VOD",
                "It's on your VOD track, so it stays in the recording. If it's copyrighted music, Twitch can mute or strike the VOD. If it's your own/licensed audio, this is fine.",
                "To keep it live-only: Audio Mixer → Advanced Audio Properties → under Tracks for “" .. name .. "”, untick Track " .. cfg.vod_track .. " (VOD) but leave Track " .. cfg.stream_track .. " (live) ticked.")
            end
          end
          -- silent-recording catch: mic not on any recorded track
          if MIC_KINDS[kind] and cfg.rec_tracks and cfg.rec_tracks > 0 and bit.band(mix, cfg.rec_tracks) == 0 then
            add("tip", "recording",
              "If you record locally, your mic “" .. name .. "” isn't on the recording track",
              "Your recorded file would capture everything except your voice, a nasty surprise if you save VODs or upload to YouTube.",
              "Audio Mixer → Advanced Audio Properties → under Tracks for “" .. name .. "”, tick your recording track(s).")
          end
        end)

        -- mic filter chain
        if MIC_KINDS[kind] then
          n_mics = n_mics + 1
          local ids = filter_ids(src)
          local i_supp = index_of(ids, "noise_suppress_filter")
          local i_gate = index_of(ids, "noise_gate_filter")
          local i_comp = index_of(ids, "compressor_filter")
          if not (i_supp and i_gate) then mics_all_filtered = false end
          if not i_supp then
            add("tip", "audio",
              "Mic “" .. name .. "” has no noise suppression",
              "Background hum, fans, and keyboard clatter go straight to your stream. One filter cleans it up dramatically.",
              "Right-click “" .. name .. "” → Filters → + → Noise Suppression → Method: “RNNoise” (best quality). Done.")
          end
          if not i_gate then
            add("tip", "audio",
              "Mic “" .. name .. "” has no noise gate",
              "A gate silences the mic when you're not talking, so viewers don't hear room noise, breathing, or a distant TV between sentences.",
              "Right-click “" .. name .. "” → Filters → + → Noise Gate. Good starting points: Close −32 dB, Open −26 dB, Attack 25 ms, Hold 200 ms, Release 150 ms, then nudge Close/Open to your room.")
          end
          if i_comp and ((i_supp and i_comp < i_supp) or (i_gate and i_comp < i_gate)) then
            add("tip", "audio",
              "Mic “" .. name .. "” filters are in the wrong order",
              "Your compressor runs before your noise suppression/gate, so it boosts the background noise first, then the gate can't tell noise from voice.",
              "Right-click “" .. name .. "” → Filters → drag them into this order: Noise Suppression → Noise Gate → EQ → Compressor → Limiter.")
          end
        end
      end

      -- orphaned source
      if not containment[name] and is_input and not is_audio then
        tidyadd("cleanup",
          "“" .. name .. "” isn't used in any scene",
          "It's still loaded in memory but nothing shows it, usually leftover from testing or a deleted scene.",
          "If you don't need it: add it to a scene, right-click → Remove; or leave it if it's on purpose.")
      end

      -- stretched
      if stretched[name] then
        add("tip", "sources",
          "“" .. name .. "” looks stretched",
          "Its width and height are scaled by different amounts, which softens/distorts the image on stream.",
          "Right-click it in the scene → Transform → “Reset Transform”, then resize by dragging a corner (holds the ratio).",
          jscene)
      end

      -- default name
      if is_default_name(name) then
        tidyadd("cleanup",
          "Source still named “" .. name .. "”",
          "Default names make it slow to find things and easy to grab the wrong source when you're live.",
          "Double-click the name in the Sources list and rename it to what it actually is (e.g. “Facecam”, “Alerts”).",
          jscene)
      end

      -- Phase 5: disabled (dead) filters + a Color Correction filter left at 0% opacity
      safe(function()
        local flist = obs.obs_source_enum_filters(src)
        if flist == nil then return end
        for _, fsrc in ipairs(flist) do
          local fname = obs.obs_source_get_name(fsrc)
          local fid = obs.obs_source_get_id(fsrc)
          if not obs.obs_source_enabled(fsrc) then
            tidyadd("cleanup",
              "Filter “" .. fname .. "” on “" .. name .. "” is turned off",
              "It's disabled, so it's doing nothing right now, usually a leftover from testing. Harmless, just clutter that makes the filter list confusing.",
              "Right-click “" .. name .. "” → Filters → either re-enable “" .. fname .. "” (click the eye) or remove it.",
              jscene)
          end
          if fid == "color_filter" or fid == "color_filter_v2" then
            local fset = obs.obs_source_get_settings(fsrc)
            if obs.obs_data_has_user_value(fset, "opacity") then
              -- read as both types; only flag a true zero (avoids a false hit on 0.5 vs 50)
              local opd = obs.obs_data_get_double(fset, "opacity")
              local opi = obs.obs_data_get_int(fset, "opacity")
              if opd == 0 and opi == 0 then
                tidyadd("cleanup",
                  "“" .. name .. "” is set to 0% opacity",
                  "A Color Correction filter has its opacity at 0, so the source is fully transparent: invisible on stream but still loaded and rendering.",
                  "Right-click “" .. name .. "” → Filters → select the Color Correction filter → raise Opacity above 0 (or remove the filter).",
                  jscene)
              end
            end
            obs.obs_data_release(fset)
          end
        end
        obs.source_list_release(flist)
      end)

      obs.obs_data_release(settings)
    end
    obs.source_list_release(sources)
  end

  -- Browser sources overview: the count and the on-screen split, with the shutdown
  -- tradeoff framed SAFELY (shutdown reloads a source, which is destructive for widgets).
  if n_browsers >= 6 then
    add("tip", "perf",
      "You have " .. n_browsers .. " browser sources (" .. n_browsers_active .. " on screen right now)",
      "Each browser source runs its own Chrome process, so a lot of them can push your CPU. You CAN save resources with “Shutdown source when not visible”, but ONLY on overlays that don't hold state or a live connection. Turning it on for a timer, goal, alert box, or chat widget is destructive: it reloads them on every scene switch, resetting the timer, clearing chat, and possibly missing alerts.",
      "Leave connected widgets (alerts, chat, timers, goals) running. Enabling Shutdown when not visible is only a safe win on plain static overlays that don't hold state. A widget you rarely use? Hit “I rarely use this” on its row below: the reload setting becomes the right one for it, and the audit handles the flips with you on days you use it.")
    -- Drill-down: the actual sources so the user doesn't have to hunt through all of them.
    -- On-screen first, then alphabetical; each row jumps to the scene it lives in.
    -- Connected-widget rows also carry park/unpark, so a dormant widget can be parked
    -- straight from this list, no warning card needed.
    table.sort(browser_list, function(a, b)
      if a.active ~= b.active then return a.active end
      return (a.name or "") < (b.name or "")
    end)
    local rows = {}
    for _, b in ipairs(browser_list) do
      local note = b.active and "on screen now" or nil
      local pk = nil
      if b.widget then
        if parked[b.name] then
          note = (note and (note .. " · ") or "") .. "parked · rarely used"
          pk = "unpark"
        else
          pk = "park"
        end
      end
      rows[#rows + 1] = { name = b.name, scene = b.scene, note = note, park = pk }
    end
    findings[#findings].items = { label = "browser source", rows = rows }
  end

  -- Healthy pass: connected widgets are set to stay running (not forced to reload).
  -- Parked widgets keep the reload setting BY CHOICE, so the claim stays honest.
  if n_browsers_widgets > 0 and n_browsers_widget_bad == 0 then
    good("sources", n_parked_reload > 0
      and "Your connected widgets stay running (parked ones save resources instead, your choice)"
      or "Your connected widgets stay running (safe from scene-change reloads)")
  end
  if n_files > 0 and not files_missing then
    good("sources", "All your source files are present")
  end
  if n_mics > 0 and mics_all_filtered then
    good("audio", "Your mic has noise suppression + a gate")
  end
  if not monitor_echo then
    good("audio", "No “Monitor and Output” echo on your sources")
  end

  -- teaching moment: they run media through OBS but VOD Track isn't set up
  if has_media_source and not cfg.vod_enabled then
    add("tip", "audio",
      "You play media through OBS but haven't set up a VOD audio track",
      "OBS's Twitch VOD Track lets music play live for your viewers but stay out of your VOD, so copyrighted music can't get the VOD muted or struck (Twitch shut down Soundtrack, so this is the current way).",
      "Settings → Output → set Output Mode to Advanced → Streaming → tick “Twitch VOD Track” → then in Advanced Audio Properties put music on Track 1 (live) only, not the VOD track.")
  end

  -- duplicate device findings
  local any_dup = false
  for _, g in pairs(device_groups) do
    if #g.names > 1 then
      any_dup = true
      if g.kind == "wasapi_output_capture" then
        add("critical", "audio",
          "Desktop audio is captured twice",
          "The same output device is in two sources (“" .. table.concat(g.names, "”, “") .. "”): viewers hear echo and everything twice as loud.",
          "Delete one: keep a single Desktop Audio source (Settings → Audio → Desktop Audio) and remove the duplicate.")
      elseif g.kind == "dshow_input" then
        add("warning", "sources",
          "The same camera is added " .. #g.names .. " times",
          "Each copy (“" .. table.concat(g.names, "”, “") .. "”) is a separate capture: extra CPU/USB load and sometimes a “device in use” error.",
          "Delete the copies and reuse one: in the other scene, Sources → + → “Add Existing” → pick the original camera.")
      end
    end
  end
  if not any_dup then good("audio", "No duplicated devices (no echo or double capture)") end

  -- empty scenes
  for scname, c in pairs(scene_item_count) do
    if c == 0 then
      tidyadd("cleanup",
        "Scene “" .. scname .. "” is empty",
        "An empty scene mid-rotation shows a black screen if you switch to it by accident.",
        "If it's a leftover, right-click it in the Scenes list → Remove; if it's a placeholder, add at least a background.",
        scname)
    end
  end

  -- Phase 5: near-duplicate scenes (same set of top-level sources)
  safe(function()
    local names = {}
    for k in pairs(scene_direct_items) do names[#names + 1] = k end
    table.sort(names)
    if #names < 2 or #names > 40 then return end
    local function setof(list)
      local s, n = {}, 0
      for _, v in ipairs(list) do if not s[v] then s[v] = true; n = n + 1 end end
      return s, n
    end
    for a = 1, #names do
      for b = a + 1, #names do
        local sa, na = setof(scene_direct_items[names[a]])
        local sb, nb = setof(scene_direct_items[names[b]])
        if na >= 3 and nb >= 3 then
          local inter = 0
          for k in pairs(sa) do if sb[k] then inter = inter + 1 end end
          local union = na + nb - inter
          local jac = union > 0 and (inter / union) or 0
          if jac >= 0.85 then
            local identical = (jac == 1)
            add("tip", "cleanup",
              "Scenes “" .. names[a] .. "” and “" .. names[b] .. "” are " ..
                (identical and "basically identical" or "nearly identical"),
              "They contain the same sources. If that's intentional (a variant of one layout), fine, but every edit to one has to be redone by hand on the other, which is where they quietly drift out of sync.",
              "If they should stay matched, put the shared sources in one scene and nest it in both (add that base scene as a source). Or delete the copy if it's a leftover.",
              names[a])
          end
        end
      end
    end
  end)

  -- Phase 6: Scene Architect — the SAME cluster of sources placed identically across
  -- many scenes (copy-paste layout) → teach nesting so edits happen once.
  safe(function()
    local scene_count = 0
    for _ in pairs(scene_direct_items) do scene_count = scene_count + 1 end
    if scene_count < 2 or scene_count > 60 then return end

    -- each (source name + exact transform) → the set of scenes it appears in
    local sig_scenes = {}
    for _, r in ipairs(layout_records) do
      local key = r.name .. "\1" .. r.sig
      local e = sig_scenes[key]
      if not e then e = { name = r.name, scenes = {} }; sig_scenes[key] = e end
      e.scenes[r.scene] = true
    end

    -- group signatures that travel together through the EXACT same set of scenes.
    -- A group with ≥2 items across ≥2 scenes is a copy-pasted base layout.
    local groups = {}
    for _, e in pairs(sig_scenes) do
      local list = {}
      for s in pairs(e.scenes) do list[#list + 1] = s end
      if #list >= 2 then
        table.sort(list)
        local gkey = table.concat(list, "\2")
        local g = groups[gkey]
        if not g then g = { scenes = list, items = {}, seen = {} } ; groups[gkey] = g end
        if not g.seen[e.name] then g.seen[e.name] = true; g.items[#g.items + 1] = e.name end
      end
    end

    for _, g in pairs(groups) do
      if #g.items >= 2 then
        table.sort(g.items)
        add("tip", "scenes",
          #g.items .. " sources sit identically across " .. #g.scenes .. " scenes",
          "These sources are in the same spot in every one of these scenes (" .. quote_join(g.scenes, 5) .. "): " ..
            quote_join(g.items, 4) .. ". Right now they're separate copies, so each time you move or restyle one you have to redo it by hand in all " ..
            #g.scenes .. " scenes, which is exactly where layouts quietly drift out of sync.",
          "Turn them into one reusable layout so edits happen once: 1) make a new scene called “Base Layout”. 2) Add these to it: " ..
            quote_join(g.items, #g.items) .. ". 3) In each of the " .. #g.scenes ..
            " scenes above, delete those copies and instead add “Base Layout” as a source (Sources → + → Scene → “Base Layout”), keeping it at the BOTTOM of the source list so your game/camera stay on top. Now every edit updates everywhere.",
          g.scenes[1])
      end
    end
  end)

  -- studio mode
  safe(function()
    if obs.obs_frontend_preview_program_mode_active() then
      add("tip", "perf",
        "Studio Mode is on",
        "Studio Mode renders your scenes twice (preview + program). If you use the preview/transition workflow, keep it, otherwise it's doubling render load for nothing.",
        "If you don't use it, click the “Studio Mode” button (bottom right) to turn it off.")
    else
      good("perf", "Studio Mode is off (no double rendering)")
    end
  end)

  -- current log parse (slow path only, cached)
  if do_slow then safe(function()
    local appdata = os.getenv("APPDATA")
    if not appdata then return end
    local logdir = appdata .. "\\obs-studio\\logs\\"
    -- list the recent sessions for the picker (newest first), cap 8
    audit_log_list = list_newest(logdir, "*.txt", 8)
    if #audit_log_list == 0 then return end
    -- classify each recent session: real stream + rough duration, for the picker + default
    audit_log_meta = {}
    for _, n in ipairs(audit_log_list) do audit_log_meta[n] = log_stream_meta(logdir .. n) end
    -- Choose which log to read. A manual pick wins. Otherwise default to the most recent
    -- REAL STREAM, not just the newest file, so an idle session opened after the stream
    -- can't hijack the read. Fall back to the newest file if nothing recent streamed.
    local pick = read_persist_str("sx_audit_pick_log")
    local chosen = nil
    if pick and pick ~= "" then
      for _, n in ipairs(audit_log_list) do if n == pick then chosen = n break end end
    end
    if not chosen then
      for _, n in ipairs(audit_log_list) do
        if audit_log_meta[n] and audit_log_meta[n].streamed then chosen = n break end
      end
    end
    if not chosen then chosen = audit_log_list[1] end
    audit_pick = chosen
    audit_log_name = chosen   -- remember which log we read, to show it in the UI
    -- refresh the dashboard's picked-session series to match what the audit reads
    if sxdash.pick_update then sxdash.pick_update(chosen, audit_log_meta[chosen]) end
    local log = read_file(logdir .. chosen)
    if not log then return end
    expert_log_raw = log      -- stash for the Expert Read snapshot (hosted diagnosis)

    if log:find("Encoding overloaded") then
      sadd("warning", "perf",
        "Your last session hit “Encoding overloaded”",
        "OBS couldn't encode frames fast enough and dropped some, viewers saw stutter. Slow disk writes during recording can also trigger this, not just the encoder.",
        "In order: run OBS as administrator (GPU priority); lower Output Resolution or FPS (Settings → Video); if you record to a slow/full drive, move recordings to a faster one.",
        nil, "measured")
    end
    if log:find("Failed to open NVENC codec") then
      sadd("warning", "perf",
        "NVENC failed to start last session",
        "OBS tried to use your NVIDIA hardware encoder and couldn't, almost always an out-of-date or mismatched GPU driver.",
        "Update your NVIDIA drivers (nvidia.com), reboot, then reselect NVENC in Settings → Output.",
        nil, "measured")
    end
    if log:find("No compatible window found") or log:find("capture window no longer exists") then
      sadd("tip", "sources",
        "A Game or Window Capture lost its target last session",
        "A capture could not find the window it was set to show. If that was a game or window you closed on purpose, this is expected and harmless. But if it was your MAIN gameplay capture, your viewers saw a black or frozen screen while it was lost, which is easy to miss while you are focused on playing.",
        "Open your main Game/Window Capture in Properties. If the game had closed, set Mode to “Capture any fullscreen application”, or re-pick the window while the game is running. If it keeps losing the hook, run OBS as administrator so it can capture games reliably.")
    end
    if log:find("Discarding audio which is too far behind") then
      sadd("warning", "audio",
        "Audio sample-rate mismatch detected last session",
        "One of your audio devices runs at a different sample rate (e.g. 44.1kHz vs 48kHz), causing crackle, drift, or desync over a long stream.",
        "Set everything to 48kHz: Windows Sound settings → each device → Advanced → 48000 Hz, and OBS Settings → Audio → Sample Rate → 48kHz.",
        nil, "measured")
    end

    -- ── Log forensics (unified with Log Check) ─────────────────────────────
    -- These read the same session log for things live inspection can't see.

    -- Recording died from low disk space (a past event, not a live free-space warning).
    if log:find("Recording stopped because of low disk space", 1, true) then
      sadd("critical", "recording",
        "Your recording ran out of disk space last session",
        "OBS stopped recording partway through because the target drive filled up. Anything you were counting on saving after that point was never written.",
        "Free space on your recording drive, or record to a bigger/faster drive (Settings, Output, Recording Path). If you run two recorders, make sure they are not both on the same drive.",
        nil, "measured")
    end

    -- Two (or more) recorders writing to the same physical drive at once.
    local rec_drives = {}
    local function add_rec(name, path)
      local d = path:match("^(%a:)"); if not d then return end
      d = d:upper(); rec_drives[d] = rec_drives[d] or {}
      for _, nm in ipairs(rec_drives[d]) do if nm == name then return end end
      table.insert(rec_drives[d], name)
    end
    for name, path in log:gmatch("muxer: '([^']+)'%] Writing file '([^']+)'") do add_rec(name, path) end
    for name, path in log:gmatch("output: '([^']+)'%] Writing[^']-'([^']+)'") do add_rec(name, path) end
    for d, list in pairs(rec_drives) do
      if #list >= 2 then
        sadd("critical", "recording",
          "Two recorders are writing to the same drive (" .. d .. ") at once",
          "More than one recording is saving to " .. d .. " at the same time (" .. table.concat(list, ", ") .. "). Their write speeds add together, so the drive fills about twice as fast as you expect and you can hit low disk space mid stream.",
          "Send one recorder to a different physical drive, or turn one off. A second camera ISO (a Source Record filter) should live on its own disk.")
      end
    end

    -- Real measured frame loss from the last session that ended (separate from config smell).
    local function worst_pct(pat)
      local m = 0; for p in log:gmatch(pat) do local n = tonumber(p) or 0; if n > m then m = n end end; return m
    end
    local rlag = worst_pct("rendering lag/stalls: %d+ %((%d+%.?%d*)%%%)")
    if rlag >= 1 then
      sadd("warning", "perf",
        "Rendering lag last session (" .. rlag .. "% of frames)",
        "OBS could not draw frames fast enough, usually a maxed-out GPU (too many active sources, or OBS on the wrong GPU). Viewers saw stutter. This is measured, not a guess.",
        "Reduce active browser/video sources, run OBS as administrator for GPU priority, or lower your canvas FPS.",
        nil, "measured")
    end
    local elag = worst_pct("skipped frames due to encoding lag: %d+/%d+ %((%d+%.?%d*)%%%)")
    if elag >= 1 then
      sadd("warning", "perf",
        "Encoding lag last session (" .. elag .. "% of frames)",
        "The encoder could not keep up and skipped frames. On NVENC this usually means the GPU is saturated by rendering or too many encodes at once.",
        "Lower output resolution or FPS, drop an extra recorder, or ease GPU load.",
        nil, "measured")
    end
    local nlag = worst_pct("insufficient bandwidth/connection stalls: %d+ %((%d+%.?%d*)%%%)")
    if nlag >= 0.5 then
      sadd("warning", "perf",
        "Dropped frames from your network last session (" .. nlag .. "%)",
        "Frames were dropped because they could not be uploaded in time. This is a connection problem, not your PC. It is what most people actually mean by dropped frames.",
        "Lower your stream bitrate, use a wired connection, try a closer ingest server, or check for other devices hogging your upload.",
        nil, "measured")
    end

    -- A browser overlay stuck in an error storm (accumulates live, real CPU cost).
    local btally = {}
    for src in log:gmatch("%[obs%-browser: '([^']+)'%] Error") do btally[src] = (btally[src] or 0) + 1 end
    local bmax, bname = 0, nil
    for src, c in pairs(btally) do if c > bmax then bmax, bname = c, src end end
    if bmax >= 1500 then
      sadd("warning", "perf",
        "A browser overlay is failing thousands of times (" .. bname .. ")",
        "The overlay \"" .. bname .. "\" errored " .. bmax .. " times this session and keeps retrying. A source failing nonstop burns CPU the whole stream and bloats your logs.",
        "Fix or replace that overlay, make sure whatever it connects to is actually running, or remove the source if you are not using it.",
        nil, "measured")
    end

    -- Plugin/module load failures at startup.
    local dockid = log:match("Dock id '([^']+)' already used")
    if dockid then
      sadd("warning", "system",
        "A plugin failed to load (two plugins want the same dock)",
        "Two plugins registered the same dock (" .. dockid .. "), so one failed to load. This is usually two multistream plugins clashing, like obs-multi-rtmp and aitum-multistream. You lose whichever one loses.",
        "Pick one and uninstall the other. Keep the one you use to go live to multiple platforms.")
    end
    local nohw, brands = {}, {}
    for m in log:gmatch("Failed to initialize module '([^']+)'") do
      if m:find("decklink") or m:find("blackmagic") then table.insert(nohw, m); brands["Blackmagic (DeckLink and the ATEM series)"] = true
      elseif m:find("aja") then table.insert(nohw, m); brands["AJA"] = true end
    end
    if #nohw > 0 then
      local blist = {}; for b in pairs(brands) do table.insert(blist, b) end
      sadd("tip", "system",
        "A capture-card plugin loaded but found no device",
        "These are the OBS plugins for " .. table.concat(blist, " and ") .. " capture hardware (" .. table.concat(nohw, ", ") .. "). They load at startup and find no matching device. Two very different things cause that. If you do not own one of these cards, OBS loads the plugin anyway and this is completely normal, ignore it. But if you DO use one to bring in a camera, a console, or a second PC, that input is not available right now, and nothing else on your stream points to it, so it is easy to miss.",
        "If you do not use Blackmagic or AJA gear, ignore this (or remove the plugin files to shave a little off startup). If you do use one and expected it live: reconnect the card and its cable, confirm its driver is installed and current, then restart OBS. If it still does not appear, reinstall the card driver.")
    end
    if log:match("LoadLibrary failed for '([^']+)'") then
      sadd("tip", "system",
        "A plugin file could not load",
        "OBS found a plugin but could not load it, usually a leftover from an old version or one built for a different OBS. If you do not use it, it is harmless clutter. If you DO rely on it, its features are silently unavailable this session.",
        "If you do not recognize it or no longer use it, delete the leftover file from your OBS plugins folder to clear the error. If you use it, reinstall the current version built for your OBS version, then restart OBS.")
    end

    -- Broken / erroring OBS SCRIPTS (Tools → Scripts). A Lua or Python script that errors
    -- on load, or won't compile, silently stops doing its job and nothing else in OBS tells
    -- you. Our own dead-reference bug ("Error opening file: (null)") would sail past every
    -- other check, so this is the exact 2 a.m. failure this tool exists to end. Match ONLY
    -- the script tags [Lua: NAME] / [Python: NAME]; NEVER the [obs-browser: '...'] Error CSP
    -- lines (those are browser SOURCES, not scripts). Last event wins, so a script you've
    -- already fixed and reloaded this session is not flagged.
    do
      local state = {}   -- name -> { pos = byte pos of last event, err = msg or false }
      local function mark(pos, name, err)
        if not name or name == "" then return end
        local st = state[name]
        if not st or pos >= st.pos then state[name] = { pos = pos, err = err } end
      end
      -- a successful (re)load clears any earlier error for that script
      for pos, name in log:gmatch("()%[obs%-scripting%]: Loaded %a+ script: ([^\r\n]+)") do
        mark(pos, name, false)
      end
      -- load / compile / runtime errors, tagged [Lua: NAME] or [Python: NAME]
      for pos, lang, name, msg in log:gmatch("()%[(%a+): ([^%]]+)%]%s*(Error [^\r\n]+)") do
        if lang == "Lua" or lang == "Python" then mark(pos, name, msg) end
      end
      local broken = {}
      for name, st in pairs(state) do
        if st.err then broken[#broken + 1] = { name = name, msg = st.err } end
      end
      table.sort(broken, function(a, b) return a.name < b.name end)
      for _, b in ipairs(broken) do
        local why, fix
        if b.msg:find("Error opening file", 1, true) then
          why = "The script “" .. b.name .. "” is listed in Tools → Scripts but errors on every launch with “Error opening file”. OBS is pointing at a file that has moved or is gone, so the script never runs (and it shows “No properties available”)."
          fix = "In OBS open Tools → Scripts, select “" .. b.name .. "”, click the trash icon to remove the dead entry, then click + and re-add the real file from its folder. If it still shows “No properties available”, the script file itself is broken, so reinstall it."
        else
          why = "The script “" .. b.name .. "” failed to load. It hit an error while OBS was reading it, so it never runs. Details: " .. b.msg
          fix = "In OBS open Tools → Scripts and select “" .. b.name .. "”. If you edited it, fix the error shown in the Script Log and reload it (the reload button). If you didn't write it, remove it with the trash icon and reinstall a fresh copy from where you got it."
        end
        sadd("warning", "system", "A script is erroring: “" .. b.name .. "”", why, fix)
      end
    end

    -- Not elevated, so OBS could not raise its GPU priority. The LIVE run-as-admin
    -- card (phase 8) owns this story now and uses this log signal as its escalation
    -- evidence; the log-only finding fires just when the live door can't answer.
    sxdash.admin_evidence = (log:find("GPU priority setup failed", 1, true)
      and log:find("Running as administrator: false", 1, true)) and true or false
    if sxdash.admin_evidence and sxcol.is_admin() == nil then
      sadd("warning", "system",
        "OBS is not running as administrator",
        "Because OBS is not elevated, it could not raise its GPU render priority. On a busy machine with several encoders, Windows can starve OBS of GPU time, which shows up as render lag.",
        "Right-click OBS and Run as administrator, or set it in the shortcut's Compatibility tab. This mainly matters if you push the GPU hard.")
    end

    -- Audio sources that started silent because their device was missing.
    local appset = {}
    for nm in log:gmatch("%- source: '([^']+)' %(wasapi_process_output_capture%)") do appset[nm] = true end
    local afails, aseen = {}, {}
    for nm in log:gmatch("failed to start %(source: ([^\n]+)%)") do
      nm = nm:gsub("%s+$", "")
      if nm ~= "" and not appset[nm] and not aseen[nm] then aseen[nm] = true; table.insert(afails, nm) end
    end
    -- Middle-strong: sources the user marked "situational" (gear they only use sometimes)
    -- are expected to be silent when idle, so drop them. Of the rest, a source that isn't
    -- in any scene can't be on your live stream, so it's a gentle tip, not a warning; a
    -- source that IS in a scene could go live dead, so that stays a warning.
    local situational = {}
    do
      local sit = read_persist_str("sx_audit_situational")
      if sit and sit ~= "" then
        for nm in (sit .. " ||| "):gmatch("(.-) %|%|%| ") do
          nm = nm:gsub("^%s+", ""):gsub("%s+$", "")
          if nm ~= "" then situational[nm] = true end
        end
      end
    end
    local active, in_scene = {}, false
    for _, nm in ipairs(afails) do
      if not situational[nm] then
        active[#active + 1] = nm
        if containment[nm] then in_scene = true end
      end
    end
    if #active > 0 then
      local n = #active
      sadd(in_scene and "warning" or "tip", "audio",
        n .. " audio source" .. (n > 1 and "s" or "") .. " started silent last session",
        "These could not open their device, so they made no sound: " .. table.concat(active, ", ") .. ". " ..
          (in_scene
            and "At least one is in a scene, so you could go live with a dead mic and not notice until chat says so. "
            or "None are in a scene as things stand right now, so this will not affect a live stream yet. But it means the device itself failed to open, not just that a source is unused, so if any of these is a mic or desktop audio you MEANT to use, it will be silent the moment you add it to a scene. That is worth checking, not just tidying. ") ..
          "If some of these are gear you only use sometimes (an event mic, a second cam), mark them situational below and this stops flagging them.",
        "Open each one (gear icon on the audio mixer, or right-click, Properties) and pick the device again. Reconnect anything unplugged before you go live, and remove ones tied to gear you no longer use.")
      slow_findings[#slow_findings].situational_names = table.concat(active, " ||| ")
    end
  end) end

  -- Phase 5: hotkey conflicts — one key combo bound to two+ different actions.
  -- Reads the current scene-collection JSON (slow path only, cached).
  if do_slow then safe(function()
    local appdata = os.getenv("APPDATA")
    if not appdata then return end
    local coll
    pcall(function() coll = obs.obs_frontend_get_current_scene_collection() end)
    local dir = appdata .. "\\obs-studio\\basic\\scenes\\"
    local files = list_newest(dir, "*.json")
    if #files == 0 then return end

    local data
    for _, fn in ipairs(files) do
      local txt = read_file(dir .. fn)
      if txt and #txt < 3000000 then
        local ok, d = pcall(json_decode, txt)
        if ok and type(d) == "table" and d.name and coll and d.name == coll then data = d break end
      end
    end
    if not data and files[1] then
      local txt = read_file(dir .. files[1])
      if txt and #txt < 3000000 then
        local ok, d = pcall(json_decode, txt)
        if ok then data = d end
      end
    end
    if not data or type(data.sources) ~= "table" then return end

    local combos = {}
    for _, srcobj in ipairs(data.sources) do
      local sname = srcobj.name or "?"
      local hk = srcobj.hotkeys
      if type(hk) == "table" then
        for action, binds in pairs(hk) do
          if type(binds) == "table" then
            local fam = HK_FAMILY[action] or action
            local disp = HK_LABEL[fam] or fam
            local dkey = sname .. "|" .. fam
            local desc = disp .. " “" .. sname .. "”"
            for _, bnd in ipairs(binds) do
              if type(bnd) == "table" and bnd.key and bnd.key ~= "" and bnd.key ~= "OBS_KEY_NONE" then
                local canon = hk_combo_canon(bnd)
                combos[canon] = combos[canon] or { label = hk_combo_label(bnd), uses = {}, seen = {} }
                if not combos[canon].seen[dkey] then
                  combos[canon].seen[dkey] = true
                  combos[canon].uses[#combos[canon].uses + 1] = desc
                end
              end
            end
          end
        end
      end
    end

    for _, v in pairs(combos) do
      if #v.uses >= 2 then
        local shown = {}
        for k = 1, math.min(#v.uses, 4) do shown[k] = v.uses[k] end
        local list = table.concat(shown, "; ")
        if #v.uses > 4 then list = list .. " (+" .. (#v.uses - 4) .. " more)" end
        sadd("warning", "cleanup",
          "The “" .. v.label .. "” hotkey triggers " .. #v.uses .. " actions at once",
          "One key is bound to more than one action, so pressing it fires all of them together, a classic cause of “my scene switched when I tried to mute”.",
          "Settings → Hotkeys → give one of these a different key. Bound to: " .. list .. ".")
      end
    end
  end) end

  -- Run-as-administrator (phase 8): live truth from the process itself, every scan
  -- (one cached shell32 call + one registry value read, no console, no flash).
  -- Joey's law 2026-09-15: elevated OBS is smart practice for EVERY streamer, so the
  -- card shows on any non elevated rig; last session's proven GPU priority failure
  -- (from the log scan) only escalates the severity from suggestion to warning.
  if SX_OS == "Windows" then safe(function()
    local ac = sxdash.admin_classify(sxcol.is_admin(), sxcol.admin_flag(), sxdash.admin_evidence == true)
    if ac then
      if ac.state == "ok" then good("system", ac.good)
      else add(ac.sev, ac.cat, ac.title, ac.why, ac.fix, nil, ac.fixa) end
    end
  end) end

  -- Verified Windows tuning (phase 7): instead of advising blindly like a web
  -- checklist, read the REAL state from the registry (ffi, no console, no guess)
  -- and only speak about what was read. Findings cache in slow_findings like the
  -- other slow checks; the passes ride the healthy list from the cache below.
  if do_slow and SX_OS == "Windows" then safe(function()
    sxdash.tuning = sxdash.tuning_classify(sxcol.win_tuning())
    for _, f in ipairs(sxdash.tuning.findings) do sadd(f.sev, f.cat, f.title, f.why, f.fix) end
  end) end

  -- Every installed GPU (name + VRAM). Ships in the Expert Read snapshot so the server
  -- can catch OBS rendering on the wrong card and reason about a two-GPU rig. Slow pass
  -- only (one registry sweep); the wrong-GPU / switching judgement is done server-side.
  if do_slow and SX_OS == "Windows" then safe(function()
    sxdash.gpus = sxcol.gpu_list()
  end) end

  -- Windows-wide crash + machine records (phases 2-3): WER's per-crash reports and the System
  -- event log's serious records (unexpected shutdowns, WHEA hardware errors, disk errors, GPU
  -- driver TDR resets, bluescreens). Heavier than a registry sweep (dir listing + file reads +
  -- wevtapi queries), so refreshed at most every 10 minutes; results cache on sxdash and ride
  -- the Expert Read snapshot as werReports / winEvents.
  if do_slow and SX_OS == "Windows" then safe(function()
    if not sxdash.wer_t or os.time() - sxdash.wer_t >= 600 then
      sxdash.wer_t = os.time()
      sxdash.wer = sxcol.wer_reports()
      sxdash.wevents = sxcol.win_events()
    end
  end) end

  -- merge in the cached shell-based findings (refreshed only on a slow pass)
  for _, f in ipairs(slow_findings) do table.insert(findings, f) end
  -- verified-tuning passes (slow-pass cache) join the healthy list every refresh
  if sxdash.tuning then
    for _, p in ipairs(sxdash.tuning.passes) do good(p.cat, p.title) end
  end

  -- Cache scene-membership + (on the slow pass) the full rig inventory the Expert Read
  -- ships. Kept off the fast tick so the 8s re-render stays cheap.
  rig_containment = containment
  rig_scene_items = scene_direct_items
  if do_slow then rig_inv_json = snapshot_inventory() end
  -- Companion apps (Streamer.bot + Lumia) for the WHOLE-SETUP Expert Read. Auto-read their recent
  -- logs the same way we read the OBS log, so the one button silently gathers the whole rig. Slow
  -- pass only (shells + disk reads); results cached for the fast tick. All in safe() so a missing
  -- app or odd path can never break the scan.
  if do_slow then safe(function()
    comp.sb_name, comp.sb_raw = nil, nil
    comp.lumia_name, comp.lumia_raw = nil, nil
    comp.vm_name, comp.vm_raw = nil, nil

    -- Lumia Stream: fixed location, zero setup. %APPDATA%\Lumia Stream\logs (electron-log files).
    comp.lumia_status = "none"
    local ad = os.getenv("APPDATA")
    if ad then
      local ln, lraw = newest_log_capped(ad .. "\\Lumia Stream\\logs\\", "*.log", 500000)
      if ln then comp.lumia_name, comp.lumia_raw = ln, lraw; comp.lumia_status = "ok" end
    end

    -- TikTok LIVE Studio: fixed location, zero setup. External multistream via the OBS Virtual Camera;
    -- pre-filtered + id-redacted on-device in tiktok_collect before it ever leaves the machine.
    comp.tt_status = "none"
    if ad then
      local tm, tr, tc, tn = tiktok_collect(ad)
      if tm or tr then
        comp.tt_main, comp.tt_renderer, comp.tt_crash, comp.tt_name = tm, tr, tc, tn
        comp.tt_status = "ok"
      end
    end

    -- Mix It Up: fixed location, zero setup. A .NET bot; its data lives in %LOCALAPPDATA%\MixItUp,
    -- with dated logs under Logs\. Read the newest so broken commands/timers/events surface in the read.
    comp.miu_status = "none"
    local lad = os.getenv("LOCALAPPDATA")
    if lad then
      local mn, mraw
      for _, d in ipairs({ lad .. "\\MixItUp\\Logs\\", lad .. "\\MixItUp\\" }) do
        mn, mraw = newest_log_capped(d, "*.txt", 500000)
        if not mn then mn, mraw = newest_log_capped(d, "*.log", 500000) end
        if mn then break end
      end
      if mn then comp.miu_name, comp.miu_raw = mn, mraw; comp.miu_status = "ok" end
    end

    -- Firebot: fixed location, zero setup. An Electron/winston bot; dated logs live in
    -- %APPDATA%\Firebot\v5\logs. Read the newest so broken commands/events/effects surface in the read.
    comp.fb_status = "none"
    if ad then
      local fn, fraw = newest_log_capped(ad .. "\\Firebot\\v5\\logs\\", "*.txt", 500000)
      if not fn then fn, fraw = newest_log_capped(ad .. "\\Firebot\\v5\\logs\\", "*.log", 500000) end
      if fn then comp.fb_name, comp.fb_raw = fn, fraw; comp.fb_status = "ok" end
    end

    -- VoiceMeeter: config, not a log, and zero setup. Its saved settings live in
    -- %USERPROFILE%\Documents\Voicemeeter\Voicemeeter<flavor>_LastSettings.xml; the newest one is
    -- the flavor the user actually runs. Config-sanity only (solo stuck, unrouted bus) via the
    -- server's VoiceMeeter collector, so a muted bus that silently drops audio surfaces in the read.
    comp.vm_status = "none"
    local up = os.getenv("USERPROFILE")
    if up then
      local vn, vraw = newest_log_capped(up .. "\\Documents\\Voicemeeter\\", "*_LastSettings.xml", 500000)
      if vn then comp.vm_name, comp.vm_raw = vn, vraw; comp.vm_status = "ok" end
    end

    -- OBS crash reports: zero setup. When OBS goes down hard it writes a crash file to
    -- %APPDATA%\obs-studio\crashes (or crashLogs on newer builds). We collect the newest so the read
    -- can NAME what crashed, and count recent ones so a recurring crash reads as a pattern. A crash is
    -- the single worst thing that can hit a live stream, so we surface it in the dock for free too.
    comp.crash_name, comp.crash_raw, comp.crash_status = nil, nil, "none"
    comp.crash_recent, comp.crash_when, comp.crash_component, comp.crash_stream_state = nil, nil, nil, "unknown"
    comp.crash_list = nil
    local cdir = crash_dir()
    if cdir then
      local cn, craw = newest_log_capped(cdir, "Crash *.txt", 500000)
      if cn and craw then
        comp.crash_name, comp.crash_raw, comp.crash_status = cn, craw, "ok"
        comp.crash_recent = list_newest(cdir, "Crash *.txt", 10)   -- filenames, newest first (for the count)
        local info = crash_classify(craw)
        if info then
          comp.crash_when = info.when
          comp.crash_component = info.component

          -- What was OBS actually DOING when it crashed? (Lets us say "you were not streaming when it
          -- crashed" instead of assuming a crash means a lost broadcast.) Computed for the newest here,
          -- and for EVERY recent crash below, so an idle crash is never dropped.
          local crash_state = crash_state_for(cn)
          comp.crash_stream_state = crash_state

          -- The FULL recent crash set, each with its timestamp + live/not-live flag + culprit, so the
          -- read can speak to ALL of them (not just the newest): a not-live crash still forecasts a
          -- live one, and if any recent crash WAS live that becomes the headline even when the newest
          -- was idle. This generalizes the multi-read's "newest + recent list" to the single read too.
          comp.crash_list = {}
          for _, nm in ipairs(comp.crash_recent or {}) do
            local st = (nm == cn) and crash_state or crash_state_for(nm)
            local entry = { name = nm, when = crash_when_iso(nm), state = st,
              was_live = (st == "live" or st == "live+recording") }
            -- Classify each crash's culprit on-device (the server only receives the newest raw, so it
            -- cannot classify the older ones itself). Head-capped: the header + crashed-thread stack
            -- live at the top of the file.
            local praw = (nm == cn) and craw or read_file(cdir .. nm)
            if praw and praw ~= "" then
              if #praw > 300000 then praw = praw:sub(1, 300000) end
              local pinfo = crash_classify(praw)
              if pinfo then entry.component = pinfo.component; entry.fault = pinfo.fault end
            end
            comp.crash_list[#comp.crash_list + 1] = entry
          end

          local n = #comp.crash_recent
          local compt = info.component or "a component we could not identify from the stack"
          local freq = (n >= 2) and (" OBS has written " .. n .. " crash reports recently, so this is a pattern, not a one-off.") or ""
          -- Say what OBS was doing, honestly. Only "live" claims you were on air (critical); an idle
          -- crash hurt no broadcast (warning), but is the same fault that would take a live stream down.
          local when_phrase = ({ live = "while you were live", ["live+recording"] = "while you were live and recording",
            recording = "while you were recording", idle = "while it was open (you were not streaming or recording)",
            unknown = info.when and ("on " .. info.when) or "recently" })[crash_state]
          local stakes = ({
            live = "A crash while you are live takes your whole broadcast down at once.",
            ["live+recording"] = "A crash while live takes your broadcast down and cuts your recording.",
            recording = "This did not hit a live stream, but footage after the last save was lost.",
            idle = "You were not live, so no broadcast was affected, but this is the same fault that would take a live stream down.",
            unknown = "We could not confirm whether you were streaming at that moment.",
          })[crash_state]
          local was_live = (crash_state == "live" or crash_state == "live+recording")
          -- cat "crash" so the snapshot builder can drop it (the server's crash finding is authoritative
          -- for the paid read); this copy is for the FREE dock display.
          sadd(was_live and "critical" or "warning", "crash",
            "OBS crashed " .. when_phrase .. " (" .. compt .. " was involved)",
            "OBS did not just close, it crashed and wrote its own crash report" ..
              (info.fault and (", faulting in " .. info.fault) or "") .. ". The crashed thread points at " .. compt ..
              (info.module and (" (" .. info.module .. ")") or "") .. ". " .. stakes .. freq,
            "Run an Expert Read for the exact, step-by-step fix. In short: if it names a webcam or capture card, update that device's driver and reseat its cable; if it names your graphics driver, update it clean and reboot; if it names a plugin or browser overlay, update or remove that one. If OBS keeps crashing, note what was on screen each time.")
        end
      end
    end

    -- Streamer.bot: portable (no fixed install path), so read the folder the user pointed us at in
    -- the script settings. Try the common layouts under it. Empty setting -> "connect" (ask once).
    -- If the saved folder goes stale (updates land in a fresh version-stamped folder, e.g.
    -- Streamer.bot-x64-0.2.9 next to 0.2.8), hunt for the install ourselves: first inside the
    -- picked folder, then in the folders beside it, newest first. A real install = has
    -- Streamer.bot.exe AND logs, so we cannot adopt a random folder. On a hit we re-point and
    -- persist, so the connection survives updates without the user doing anything. Only when
    -- the hunt also comes up empty do we show "moved" and ask.
    comp.sb_status, comp.sb_status_path = "connect", nil
    local function sb_probe(b)
      for _, d in ipairs({ b .. "\\data\\logs\\", b .. "\\logs\\", b .. "\\" }) do
        local pn, praw = newest_log_capped(d, "*.log", 500000)
        if pn then return pn, praw end
      end
    end
    local base = streamer_log_path
    if base and base ~= "" then
      base = base:gsub("[\\/]+$", "")   -- drop a trailing slash
      comp.sb_status_path = base
      local sn, sraw = sb_probe(base)
      if not sn then
        local spots = {}
        for _, d in ipairs(win_list_dirs(base .. "\\")) do
          spots[#spots + 1] = { path = base .. "\\" .. d.name, mtime = d.mtime }
        end
        local parent = base:match("^(.*)\\[^\\]+$")
        if parent and parent ~= "" then
          for _, d in ipairs(win_list_dirs(parent .. "\\")) do
            local p = parent .. "\\" .. d.name
            if p:lower() ~= base:lower() then spots[#spots + 1] = { path = p, mtime = d.mtime } end
          end
        end
        table.sort(spots, function(x, y) return x.mtime > y.mtime end)
        for _, s in ipairs(spots) do
          if file_exists(s.path .. "\\Streamer.bot.exe") then
            local fn, fraw = sb_probe(s.path)
            if fn then
              sn, sraw, base = fn, fraw, s.path
              streamer_log_path = base
              comp.sb_status_path = base
              if script_settings then obs.obs_data_set_string(script_settings, "sx_streamer_path", base) end
              print("StreamAuditX: Streamer.bot moved, auto-reconnected to " .. base)
              break
            end
          end
        end
      end
      if sn then
        comp.sb_name, comp.sb_raw = sn, sraw; comp.sb_status = "ok"
      else
        comp.sb_status = "moved"
      end
    end
  end) end

  -- Now that OBS + companion logs are loaded, fold new streamed sessions (each with its time-aligned
  -- companion slices) into the local archive, then bundle the recent ones for the cross-session read.
  if do_slow then safe(function()
    if sx_archive_logs then sx_archive_logs() end
    -- fold fresh sidecars into the dashboard's across-streams trend, and publish
    -- the updated pick/trend right away instead of waiting for the next sampler tick
    if sxdash.trend_scan then sxdash.trend_scan() end
    if sxdash.write then sxdash.write() end
    -- publish the hosted-dashboard hand-off (no-op unless premium + consent + key)
    if sxdash.upload_write then sxdash.upload_write() end
    -- Scoring v2: refresh the measured lane's crash descriptors AFTER the archive fold,
    -- so streams_ago/clean_since see the session that just ended. Cached on comp; fast
    -- scans re-bake the same JSON until the next deep scan.
    if sx_crash_ladder_json then comp.crash_ladder = sx_crash_ladder_json() end
  end) end
  if do_slow then agg_logs_json, agg_logs_count = build_agg_payload() end

  return findings
end

-- ── health score ───────────────────────────────────────────

local SEV_ORDER  = { critical = 1, warning = 2, tip = 3 }
local SEV_LABEL  = { critical = "Critical", warning = "Warning", tip = "Suggestion" }
local CAT_LABEL  = {
  audio = "Audio", perf = "Performance", recording = "Recording",
  sources = "Sources", cleanup = "Cleanup", scenes = "Scenes", system = "System",
}

-- Score = how much is set up right vs. how much is actually wrong. Passes (the Healthy
-- list) are the positive side; only real issues (critical/warning) pull it down — pure
-- suggestions never do. A working OBS with nothing broken lands high, never a flat 0.
-- (JS recomputes this live as you dismiss things; this is the no-JS fallback.)
local function compute_score(passed, crit, warn)
  -- Penalty model: start at 100 and subtract for real PROBLEMS only. Criticals hurt
  -- hard; warnings are gentle, so a rig with zero critical issues never lands in the
  -- red just for carrying a stack of non-urgent review items (the old ratio math made
  -- 18 warnings read as 21/100 — demoralizing when nothing was actually broken).
  -- Suggestions and Tidy-up items never count. Floors at 15 while OBS runs, so it's
  -- never a near-zero gut-punch. `passed` is no longer used — health is about what's
  -- wrong, not how many explicit good-checks we happened to define.
  local s = 100 - 20 * crit - 3 * warn
  if s < 15 then s = 15 elseif s > 100 then s = 100 end
  return s
end

local function band(score, crit)
  -- Critical-aware: with zero critical issues we NEVER show red or "fix before you go
  -- live" — you're safe to stream, the number just reflects polish. Red is reserved
  -- for things that can actually break the stream.
  if crit and crit > 0 then return "Fix the critical items before you go live", "#ff6b6b" end
  if score >= 85 then return "Dialed in", "#6fd394"
  elseif score >= 65 then return "Solid · a few quick wins", "#8fd36f"
  elseif score >= 40 then return "Safe to stream · cleanup recommended", "#ffc24d"
  else return "Safe to stream · lots to tidy up", "#ffc24d" end
end

-- ── Show Me Where (obs-websocket) ──────────────────────────
-- Read-only convenience: click a finding → OBS switches to that scene. The only
-- thing the user does is flip OBS's built-in WebSocket server on once; we read its
-- config to self-connect (password never leaves the machine). The panel talks to
-- ws://127.0.0.1 and only ever sends SetCurrentProgramScene — never changes anything.

local function read_smw_config()
  local st = { enabled = false, port = 4455, password = "", auth = true }
  safe(function()
    local appdata = os.getenv("APPDATA")
    if not appdata then return end
    local txt = read_file(appdata .. "\\obs-studio\\plugin_config\\obs-websocket\\config.json")
    if not txt then return end
    local ok, d = pcall(json_decode, txt)
    if not ok or type(d) ~= "table" then return end
    st.enabled  = (d.server_enabled == true)
    st.port     = tonumber(d.server_port) or 4455
    st.password = d.server_password or ""
    if d.auth_required ~= nil then st.auth = (d.auth_required == true)
    elseif d.authentication_required ~= nil then st.auth = (d.authentication_required == true) end
  end)
  return st
end

-- escape a Lua string for a JS double-quoted literal
local function js_str(s)
  s = tostring(s or "")
  return (s:gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\n", "\\n"):gsub("\r", "\\r"))
end

local SMW_CSS = [[
  .smw-rec{display:flex;gap:12px;align-items:flex-start;background:linear-gradient(135deg,rgba(255,122,26,.16),rgba(255,122,26,.04));border:1px solid rgba(255,122,26,.45);border-radius:12px;padding:13px 15px;margin-bottom:14px}
  .smw-star{font-size:22px;line-height:1.1}
  .smw-body{flex:1}
  .smw-h{font-weight:800;font-size:13.5px;color:#ffb374;margin-bottom:4px}
  .smw-p{color:#d7c3b0;font-size:12px;line-height:1.5}
  .smw-steps{margin-top:9px;padding-top:9px;border-top:1px solid rgba(255,122,26,.25);color:#e0d3c6;font-size:12px;line-height:1.9}
  .smw-steps b{color:#ffb374}
  .smw-set{align-self:center;background:#ff7a1a;color:#111;border:none;border-radius:20px;padding:8px 15px;font-size:12px;font-weight:800;cursor:pointer;font-family:inherit;white-space:nowrap;transition:transform .12s,background .12s}
  .smw-set:hover{background:#ff8f3d}
  .smw-set:active{transform:scale(.96)}
  .smw-on{display:flex;align-items:center;gap:8px;background:rgba(111,211,148,.1);border:1px solid rgba(111,211,148,.35);border-radius:10px;padding:9px 13px;margin-bottom:14px;color:#8fe0a8;font-size:12px;font-weight:600}
  .smw-jump{display:inline-block;margin-top:9px;background:rgba(255,122,26,.14);color:#ff9a4d;border:1px solid rgba(255,122,26,.4);border-radius:20px;padding:4px 12px;font-size:11px;font-weight:800;cursor:pointer;font-family:inherit;transition:transform .12s,background .12s}
  .smw-jump:hover{background:rgba(255,122,26,.24)}
  .smw-jump:active{transform:scale(.95)}
  .oa-fix{position:relative;display:inline-block;margin-top:9px;margin-right:8px;background:#6fd394;color:#0c1f14;border:none;border-radius:20px;padding:6px 14px;font-size:11px;font-weight:800;cursor:pointer;font-family:inherit;box-shadow:0 2px 0 rgba(11,31,20,.45),0 4px 10px rgba(0,0,0,.28);transition:transform .14s cubic-bezier(.34,1.56,.64,1),background .14s ease,box-shadow .14s ease,color .14s ease}
  .oa-fix:hover{background:#84e0a6;transform:translateY(-1px);box-shadow:0 3px 0 rgba(11,31,20,.5),0 7px 16px rgba(0,0,0,.32)}
  .oa-fix:active{transform:translateY(1px) scale(.96);box-shadow:0 1px 0 rgba(11,31,20,.45),0 2px 5px rgba(0,0,0,.3),inset 0 2px 5px rgba(0,0,0,.22);transition-duration:.06s}
  .oa-fix.working{background:#5bb37f;color:#0c1f14;cursor:default;transform:none;box-shadow:0 1px 0 rgba(11,31,20,.4),inset 0 1px 4px rgba(0,0,0,.2);opacity:.92;pointer-events:none}
  .oa-fix.done{background:#3fe08a;color:#062615;cursor:default;pointer-events:none;box-shadow:0 0 0 3px rgba(63,224,138,.28),0 4px 14px rgba(63,224,138,.4);animation:oaPop .42s cubic-bezier(.34,1.56,.64,1)}
  .oa-fix.failed{background:#e06f6f;color:#2b0c0c;box-shadow:0 2px 0 rgba(43,12,12,.4),0 4px 10px rgba(0,0,0,.3);animation:oaShake .4s ease}
  .oa-spin{display:inline-block;width:10px;height:10px;margin-right:6px;vertical-align:-1px;border:2px solid rgba(12,31,20,.35);border-top-color:#0c1f14;border-radius:50%;animation:oaSpin .6s linear infinite}
  @keyframes oaSpin{to{transform:rotate(360deg)}}
  @keyframes oaPop{0%{transform:scale(1)}45%{transform:scale(1.13)}100%{transform:scale(1)}}
  @keyframes oaShake{0%,100%{transform:translateX(0)}18%{transform:translateX(-4px)}36%{transform:translateX(4px)}54%{transform:translateX(-3px)}72%{transform:translateX(3px)}88%{transform:translateX(-1px)}}
  .smw-msg{position:fixed;left:50%;bottom:18px;transform:translate(-50%,10px) scale(.96);padding:11px 18px;border-radius:22px;font-size:12.5px;font-weight:700;opacity:0;box-shadow:0 8px 24px rgba(0,0,0,.4);transition:opacity .18s ease,transform .34s cubic-bezier(.34,1.56,.64,1);z-index:99;pointer-events:none}
  .smw-msg.show{opacity:1;transform:translate(-50%,0) scale(1)}
  .smw-msg.ok{background:#1c5b34;color:#c9f5d8;border:1px solid #2e8a50}
  .smw-msg.err{background:#5b1c1c;color:#f5c9c9;border:1px solid #8a2e2e}
  @media (prefers-reduced-motion:reduce){.oa-fix,.oa-fix:hover,.oa-fix:active{transform:none}.oa-fix.done,.oa-fix.failed{animation:none}.oa-spin{animation-duration:1.2s}.smw-msg{transition:opacity .2s ease}.smw-msg.show{transform:translate(-50%,0) scale(1)}}
  #sxRead:empty{display:none}
  .sxr-wrap{background:linear-gradient(135deg,#241b10,#171b23);border:1px solid rgba(255,122,26,.4);border-radius:16px;padding:14px 16px;margin:2px 2px 16px;box-shadow:0 8px 24px rgba(0,0,0,.3)}
  .sxr-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
  .sxr-badge{display:inline-flex;align-items:center;gap:5px;background:linear-gradient(135deg,#ff9a3d,#ff6a00);color:#1a0e00;font-size:9.5px;font-weight:900;letter-spacing:.09em;padding:3px 9px;border-radius:999px}
  .sxr-x{background:none;border:none;color:#9aa0ab;font-size:11px;font-weight:700;cursor:pointer;font-family:inherit}
  .sxr-x:hover{color:#e8e8ec}
  .sxr-sum{color:#d7c3b0;font-size:12.5px;line-height:1.5;margin-bottom:12px}
  /* Past Expert Reads: a collapsed history dropdown so a one-time deep read never buries the live audit */
  .sxr-hist{background:linear-gradient(135deg,#1a1510,#161a22);border:1px solid rgba(255,122,26,.26);border-radius:14px;margin:2px 2px 16px;overflow:hidden}
  .sxr-hist-head{display:flex;align-items:center;gap:10px;width:100%;background:none;border:none;padding:13px 15px;cursor:pointer;font-family:inherit;text-align:left;transition:background .15s}
  .sxr-hist-head:hover{background:rgba(255,122,26,.06)}
  .sxr-hist-count{margin-left:auto;font-size:10.5px;font-weight:800;color:#c9a986;background:rgba(255,122,26,.12);border:1px solid rgba(255,122,26,.28);border-radius:999px;padding:2px 9px}
  .sxr-hist-caret,.sxr-entry-caret{display:inline-block;color:#ff9a4d;font-size:11px;transition:transform .16s cubic-bezier(.2,.7,.2,1);flex-shrink:0}
  .sxr-hist-caret.open,.sxr-entry-head.open .sxr-entry-caret{transform:rotate(90deg)}
  .sxr-hist-body{padding:0 12px 8px}
  .sxr-entry{border-top:1px solid #232833}
  .sxr-entry:first-child{border-top:none}
  .sxr-entry-head{display:flex;align-items:center;gap:10px;width:100%;background:none;border:none;padding:11px 3px;cursor:pointer;font-family:inherit;text-align:left;transition:opacity .15s}
  .sxr-entry-head:hover{opacity:.82}
  .sxr-entry-date{font-size:12px;font-weight:800;color:#e8e8ec;min-width:50px}
  .sxr-entry-kind{font-size:11.5px;color:#c3b3a2}
  .sxr-entry-meta{margin-left:auto;font-size:10.5px;font-weight:700;color:#9aa0ab;white-space:nowrap}
  .sxr-entry-body{padding:2px 3px 12px}
  .sxr-entry-inner{padding-top:2px}
  .sxr-all{display:block;text-align:center;color:#c9a986;font-size:11px;font-weight:700;text-decoration:none;border-top:1px solid #232833;padding:10px 3px 8px;transition:color .15s}
  .sxr-all:hover{color:#ff9a4d}
  .sxr-continue{display:inline-block;background:linear-gradient(135deg,#ff9a3d,#ff6a00);color:#1a0e00;border:none;border-radius:999px;font-family:inherit;font-size:11.5px;font-weight:800;padding:6px 13px;margin:2px 0 4px;cursor:pointer;transition:transform .14s cubic-bezier(.2,.7,.2,1)}
  .sxr-continue:hover{transform:translateY(-1px)}
  .sxr-continue:active{transform:scale(.96)}
  .sxr-fine{display:block;color:#8b8f98;font-size:10.5px;margin-top:6px}
]]

local function smw_banner(smw)
  if smw.enabled then
    return "<div class='smw-on'>✓ Show Me Where is on. Click “Show me →” on any issue to jump straight to it.</div>"
  end
  return [[<div class="smw-rec">
  <div class="smw-star">⭐</div>
  <div class="smw-body">
    <div class="smw-h">Recommended: turn on “Show Me Where”</div>
    <div class="smw-p">See a problem below? Click it and OBS jumps straight to the exact scene that has it, no digging through your scenes to find it. One-time setup, about 15 seconds.</div>
    <div class="smw-steps" id="smwSteps" style="display:none">
      <div>1&nbsp; In OBS, open <b>Tools → WebSocket Server Settings</b></div>
      <div>2&nbsp; Tick <b>Enable WebSocket server</b>, then <b>OK</b></div>
      <div>3&nbsp; That's it, this panel connects on its own in a few seconds.</div>
    </div>
  </div>
  <button class="smw-set" onclick="var s=document.getElementById('smwSteps');s.style.display='block';this.style.display='none'">Set it up →</button>
</div>]]
end

-- SHA-256 + obs-websocket v5 auth + jump. The crypto here was verified byte-for-byte
-- against Node's crypto (empty/56/64/1000-byte vectors + the full auth algorithm).
local SMW_JS = [[
var SMW_K=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
function smwSha(str){var utf8=[],n,c;for(n=0;n<str.length;n++){c=str.charCodeAt(n);
if(c<128){utf8.push(c);}else if(c<2048){utf8.push(192|(c>>6),128|(c&63));}
else{utf8.push(224|(c>>12),128|((c>>6)&63),128|(c&63));}}
var H=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
var bl=utf8.length*8;utf8.push(0x80);while(utf8.length%64!==56)utf8.push(0);
utf8.push(0,0,0,0,(bl>>>24)&255,(bl>>>16)&255,(bl>>>8)&255,bl&255);
function r(x,n){return (x>>>n)|(x<<(32-n));}
var i,t,w,a,b,d,e,f,g,h,cc,S0,S1,ch,mj,t1,t2;
for(i=0;i<utf8.length;i+=64){w=[];for(t=0;t<16;t++){w[t]=(utf8[i+t*4]<<24)|(utf8[i+t*4+1]<<16)|(utf8[i+t*4+2]<<8)|(utf8[i+t*4+3]);}
for(t=16;t<64;t++){S0=r(w[t-15],7)^r(w[t-15],18)^(w[t-15]>>>3);S1=r(w[t-2],17)^r(w[t-2],19)^(w[t-2]>>>10);w[t]=(w[t-16]+S0+w[t-7]+S1)|0;}
a=H[0];b=H[1];cc=H[2];d=H[3];e=H[4];f=H[5];g=H[6];h=H[7];
for(t=0;t<64;t++){S1=r(e,6)^r(e,11)^r(e,25);ch=(e&f)^((~e)&g);t1=(h+S1+ch+SMW_K[t]+w[t])|0;
S0=r(a,2)^r(a,13)^r(a,22);mj=(a&b)^(a&cc)^(b&cc);t2=(S0+mj)|0;h=g;g=f;f=e;e=(d+t1)|0;d=cc;cc=b;b=a;a=(t1+t2)|0;}
H[0]=(H[0]+a)|0;H[1]=(H[1]+b)|0;H[2]=(H[2]+cc)|0;H[3]=(H[3]+d)|0;H[4]=(H[4]+e)|0;H[5]=(H[5]+f)|0;H[6]=(H[6]+g)|0;H[7]=(H[7]+h)|0;}
var out=[];for(i=0;i<8;i++){out.push((H[i]>>>24)&255,(H[i]>>>16)&255,(H[i]>>>8)&255,H[i]&255);}return out;}
function smwB64(by){var ch="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",o="",i,b0,b1,b2;
for(i=0;i<by.length;i+=3){b0=by[i];b1=by[i+1];b2=by[i+2];o+=ch[b0>>2];o+=ch[((b0&3)<<4)|((b1||0)>>4)];
o+=(i+1<by.length)?ch[((b1&15)<<2)|((b2||0)>>6)]:"=";o+=(i+2<by.length)?ch[b2&63]:"=";}return o;}
function smwHash(s){return smwB64(smwSha(s));}
function smwAuth(pw,salt,ch){return smwHash(smwHash(pw+salt)+ch);}
// One shared, persistent obs-websocket link for the whole panel. Every jump/fix/read/
// write rides this single socket instead of opening a fresh one per action, so OBS shows
// exactly one connect when the tool opens and one disconnect when it closes, never the
// per-poll connect/disconnect that spammed OBS's tray "client disconnected" alert every
// few seconds. Reconnects only on a real drop (1s, 2s, 5s backoff); closes cleanly on
// unload. Lives only on the top window, so the hidden refresh iframe never opens its own.
var sxObs=(function(){
var sock=null,ready=false,connecting=false,closing=false,backoff=0,reT=null,hideT=null,rid=0,pend={},q=[],TOP=(window.top===window.self);
function clearPend(){for(var k in pend){clearTimeout(pend[k].to);try{pend[k].reject(new Error("closed"));}catch(e){}}pend={};}
function fire(item){item.rq="r"+(++rid);pend[item.rq]=item;
item.to=setTimeout(function(){if(pend[item.rq]){delete pend[item.rq];try{item.reject(new Error("timeout"));}catch(e){}}},8000);
try{sock.send(JSON.stringify({op:6,d:{requestType:item.type,requestData:item.data||{},requestId:item.rq}}));}catch(e){}}
function schedule(){if(closing)return;clearTimeout(reT);backoff=backoff?Math.min(backoff*2,5000):1000;reT=setTimeout(open,backoff);}
function open(){
if(!TOP||!window.SMW||!SMW.enabled||ready||connecting||closing)return;
connecting=true;
try{sock=new WebSocket("ws://127.0.0.1:"+SMW.port,"obswebsocket.json");}catch(e){connecting=false;schedule();return;}
sock.onmessage=function(ev){var m;try{m=JSON.parse(ev.data);}catch(e){return;}
if(m.op===0){var d={op:1,d:{rpcVersion:m.d.rpcVersion||1,eventSubscriptions:0}};
if(m.d.authentication){var a=m.d.authentication;d.d.authentication=smwAuth(SMW.password,a.salt,a.challenge);}
try{sock.send(JSON.stringify(d));}catch(e){}}
else if(m.op===2){ready=true;connecting=false;backoff=0;var pq=q;q=[];for(var i=0;i<pq.length;i++)fire(pq[i]);}
else if(m.op===7){var it=pend[m.d.requestId];if(it){delete pend[m.d.requestId];clearTimeout(it.to);it.resolve(m.d);}}};
sock.onclose=function(){ready=false;connecting=false;sock=null;clearPend();if(!closing)schedule();};
sock.onerror=function(){try{sock.close();}catch(e){}};}
function request(type,data){return new Promise(function(resolve,reject){
if(!TOP){reject(new Error("framed"));return;}
if(!window.SMW||!SMW.enabled){reject(new Error("off"));return;}
var item={type:type,data:data,resolve:resolve,reject:reject};
if(ready&&sock){fire(item);}else{q.push(item);open();}});}
function shut(){closing=true;clearTimeout(reT);clearTimeout(hideT);try{if(sock)sock.close();}catch(e){}sock=null;ready=false;}
if(TOP){
window.addEventListener("pagehide",shut);
window.addEventListener("beforeunload",shut);
document.addEventListener("visibilitychange",function(){
if(document.visibilityState==="hidden"){clearTimeout(hideT);hideT=setTimeout(shut,30000);}
else{clearTimeout(hideT);closing=false;open();}});
open();}
return {request:request};})();
function smwMsg(t,ok){var m=document.getElementById('smwMsg');if(!m){m=document.createElement('div');m.id='smwMsg';document.body.appendChild(m);}
m.textContent=t;m.className='smw-msg '+(ok?'ok':'err');void m.offsetWidth;m.classList.add('show');clearTimeout(window._smwT);window._smwT=setTimeout(function(){m.classList.remove('show');},2600);}
function smwJump(scene){if(!window.SMW||!SMW.enabled)return;
sxObs.request("SetCurrentProgramScene",{sceneName:scene}).then(function(r){
if(r&&r.requestStatus&&r.requestStatus.result){smwMsg("Jumped to “"+scene+"”",true);}else{smwMsg("Couldn't switch to “"+scene+"”: is that scene still there?",false);}
}).catch(function(){smwMsg("Show Me Where couldn't reach OBS. Is the WebSocket server on?",false);});}
document.addEventListener('click',function(e){var b=e.target.closest?e.target.closest('.smw-jump'):null;if(!b)return;e.preventDefault();var s=b.getAttribute('data-scene');if(s)smwJump(s);});
// One-click fix: apply source settings over the same websocket, with backup + undo.
function oaBtn(btn,state){if(!btn)return;btn.classList.remove('working','done','failed');if(state==='working'){if(btn._oaText==null)btn._oaText=btn.innerHTML;btn.classList.add('working');btn.innerHTML='<span class="oa-spin"></span>Fixing…';}else if(state==='done'){btn.classList.add('done');btn.innerHTML='✓ Fixed';try{window._oaHold=Date.now()+2600;}catch(e){}}else if(state==='failed'){btn.classList.add('failed');btn.innerHTML='✗ Couldn’t fix';try{window._oaHold=Date.now()+2000;}catch(e){}}else{btn.innerHTML=(btn._oaText!=null?btn._oaText:btn.innerHTML);}}
function smwApply(input,setObj,isUndo,btn){if(!window.SMW||!SMW.enabled){smwMsg("Turn on Show Me Where first (Tools → WebSocket Server).",false);return;}
if(isUndo){sxObs.request("SetInputSettings",{inputName:input,inputSettings:setObj,overlay:true}).then(function(r){var ok=!!(r.requestStatus&&r.requestStatus.result);smwMsg(ok?"Reverted “"+input+"”.":"Couldn't revert “"+input+"”.",ok);}).catch(function(){smwMsg("Couldn't reach OBS. Is the WebSocket server on?",false);});return;}
oaBtn(btn,'working');
sxObs.request("GetInputSettings",{inputName:input}).then(function(r){var cur=(r.responseData&&r.responseData.inputSettings)||{};var old={};for(var k in setObj){old[k]=(cur[k]===undefined?false:cur[k]);}window._sxUndo={input:input,set:old};return sxObs.request("SetInputSettings",{inputName:input,inputSettings:setObj,overlay:true});}).then(function(r){if(r.requestStatus&&r.requestStatus.result){oaBtn(btn,'done');smwFixToast(input);}else{oaBtn(btn,'failed');setTimeout(function(){oaBtn(btn,'reset');},1600);smwMsg("Couldn't fix “"+input+"”: "+((r.requestStatus&&r.requestStatus.comment)||"is it still there?"),false);}}).catch(function(){oaBtn(btn,'failed');setTimeout(function(){oaBtn(btn,'reset');},1600);smwMsg("Couldn't reach OBS. Is the WebSocket server on?",false);});}
function smwFixToast(input){var m=document.getElementById('smwMsg');if(!m){m=document.createElement('div');m.id='smwMsg';document.body.appendChild(m);}m.className='smw-msg ok';m.innerHTML='';var t=document.createElement('span');t.textContent='✓ Fixed “'+input+'”. ';m.appendChild(t);var u=document.createElement('a');u.href='#';u.textContent='Undo';u.style.cssText='color:#c9f5d8;text-decoration:underline;font-weight:800;pointer-events:auto';u.onclick=function(e){e.preventDefault();if(window._sxUndo)smwApply(window._sxUndo.input,window._sxUndo.set,true);};m.appendChild(u);m.style.pointerEvents='auto';void m.offsetWidth;m.classList.add('show');clearTimeout(window._smwT);window._smwT=setTimeout(function(){m.classList.remove('show');m.style.pointerEvents='none';},6000);}
document.addEventListener('click',function(e){var b=e.target.closest?e.target.closest('.oa-fix'):null;if(!b)return;e.preventDefault();if(b.classList.contains('working')||b.classList.contains('done'))return;var input=b.getAttribute('data-input');var set;try{set=JSON.parse(b.getAttribute('data-set'));}catch(err){return;}if(input&&set)smwApply(input,set,false,b);});
// Run-as-admin one-click: hand the request to the Lua script over the same persistent-data
// channel as the session picker (a registry write can't ride obs-websocket). The card shows
// the honest outcome on the next refresh: it flips to "restart OBS to finish" only when the
// script verified the flag really landed.
document.addEventListener('click',function(e){var b=e.target.closest?e.target.closest('.oa-fixadmin'):null;if(!b)return;e.preventDefault();if(b.classList.contains('working'))return;if(!window.SMW||!SMW.enabled){smwMsg("Turn on Show Me Where first (Tools → WebSocket Server).",false);return;}smwSetData('sx_audit_fix_admin',String(Date.now()));oaBtn(b,'working');smwMsg("Setting it up… this card updates in a few seconds, then restart OBS to finish.",true);});
// Persistent data in OBS's OWN storage (survives restarts, any browser). Used to
// save the buried-ignore list and main-panel dismiss list so they stick for real.
function smwGetData(slot,cb){if(!window.SMW||!SMW.enabled){cb(null);return;}
sxObs.request("GetPersistentData",{realm:"OBS_WEBSOCKET_DATA_REALM_GLOBAL",slotName:slot}).then(function(r){var v=null;try{if(r&&r.responseData)v=r.responseData.slotValue;}catch(e){}cb(v);}).catch(function(){cb(null);});}
function smwSetData(slot,value){if(!window.SMW||!SMW.enabled)return;
sxObs.request("SetPersistentData",{realm:"OBS_WEBSOCKET_DATA_REALM_GLOBAL",slotName:slot,slotValue:value}).catch(function(){});}
// Session picker: hand the chosen log filename back to the Lua script via OBS's own storage.
// The script polls that value and re-reads the picked session's log on its next scan.
function sxPickLog(name){if(!window.SMW||!SMW.enabled){smwMsg("Turn on the WebSocket server (in Tools) to review other sessions.",false);return;}smwSetData('sx_audit_pick_log',name);smwMsg("Re-reading that session… this can take a few seconds.",true);}
// ControlX live tile feed: mirror a compact live-health snapshot + per-rig baseline
// into OBS persistent data (slot sx_health_live) so any other dock in this same OBS
// (ControlX) can poll it locally. No server in the loop; ~1KB; throttled to 10s.
// The baseline (median of the archived streams' stats) is what makes this different
// from OBS's own stats: the tile shows your numbers against YOUR usual.
window._sxHmLast=0;
window.sxHealthMirror=function(){
  try{
    if(!window.SMW||!SMW.enabled){return;}
    var D=window.SX_HEALTH;if(!D||!D.now){return;}
    var t=Date.now();if(t-window._sxHmLast<10000){return;}window._sxHmLast=t;
    var cur=D.cur||{};
    function last(a){if(!a||!a.length){return -1;}return a[a.length-1];}
    var rows=D.trend||[];
    function med(k){var v=[],i,x;for(i=0;i<rows.length&&i<10;i++){x=rows[i][k];if(typeof x==='number'&&x>=0){v.push(x);}}if(!v.length){return -1;}v.sort(function(a,b){return a-b;});return v[Math.floor(v.length/2)];}
    var base={cpu:med('cpu'),ram:med('ram'),gpu:med('gpu'),tmp:med('tmp'),n:rows.length};
    var why=[];
    if(D.now.sat_cpu){why.push('cpu maxed');}
    if(D.now.sat_gpu){why.push('gpu maxed');}
    if(D.now.sat_hot){why.push('running hot');}
    var lr=last(cur.lr),nd=last(cur.nd),es=last(cur.es);
    if(lr>0){why.push('render loss');}
    if(nd>0){why.push('network loss');}
    if(es>0){why.push('encoder loss');}
    var flag=why.length?'alert':'ok';
    if(flag==='ok'&&base.n>=3){
      if(base.gpu>=0&&D.now.gpu>=0&&D.now.gpu>base.gpu+15){flag='watch';why.push('gpu above your usual');}
      if(base.tmp>=0&&D.now.tmp>=0&&D.now.tmp>base.tmp+8){flag='watch';why.push('hotter than your usual');}
      if(base.cpu>=0&&D.now.cpu>=0&&D.now.cpu>base.cpu+20){flag='watch';why.push('cpu above your usual');}
    }
    var snap={v:1,t:t,live:!!D.now.live,flag:flag,why:why,
      now:{cpu:D.now.cpu,obs:D.now.obs,ram:D.now.ram,gpu:D.now.gpu,tmp:D.now.tmp,enc:D.now.encl},
      loss:{r:lr,n:nd,e:es},base:base,since:D.since||0,kept:D.kept||0,premium:!!D.premium};
    smwSetData('sx_health_live',JSON.stringify(snap));
    // ControlX rides the server: members (premium + consent, so SX_UPLOAD carries the
    // scoped key) also push the snapshot to /api/health/live, where the ControlX tile
    // polls it. Fire-and-forget; the server re-checks tier + consent on every push.
    var U=window.SX_UPLOAD;
    if(U&&U.key&&window.fetch){
      try{fetch(U.api+'/api/health/live',{method:'POST',headers:{'Content-Type':'application/json','X-SX-Health-Key':U.key},body:JSON.stringify(snap)}).catch(function(){});}catch(e2){}
    }
  }catch(e){}
};
]]

local function smw_script(smw)
  local cfg = "window.SMW={enabled:" .. (smw.enabled and "true" or "false") ..
    ",port:" .. tostring(smw.port) ..
    ",auth:" .. (smw.auth and "true" or "false") ..
    ",password:\"" .. js_str(smw.password) .. "\"};"
  return "<script>" .. cfg .. SMW_JS .. "</script>"
end

-- ── render ─────────────────────────────────────────────────

local VIEWS = {
  { m = "issues",  t = "Issues" },
  { m = "sug",     t = "Suggestions" },
  { m = "tidy",    t = "Tidy up" },
  { m = "healthy", t = "Healthy" },
  { m = "dismissed", t = "Dismissed" },
}
local CATS = {
  { m = "all", t = "All" }, { m = "audio", t = "Audio" }, { m = "perf", t = "Performance" },
  { m = "recording", t = "Recording" }, { m = "sources", t = "Sources" },
  { m = "scenes", t = "Scenes" }, { m = "cleanup", t = "Cleanup" }, { m = "system", t = "System" },
}
local SEV_HEAD = { critical = "Critical: fix before you go live", warning = "Warnings", tip = "Suggestions to make it better", tidy = "Tidy up: harmless housekeeping, doesn't affect OBS", good = "What's set up right" }

-- encode a fix's {key=value} settings map to JSON for a single-quoted HTML data attribute
local function fixa_set_json(set)
  local parts = {}
  for k, v in pairs(set) do
    local vs
    if type(v) == "boolean" then vs = v and "true" or "false"
    elseif type(v) == "number" then vs = tostring(v)
    else vs = "\"" .. tostring(v):gsub('\\', '\\\\'):gsub('"', '\\"') .. "\"" end
    parts[#parts + 1] = "\"" .. k .. "\":" .. vs
  end
  return "{" .. table.concat(parts, ",") .. "}"
end

-- ── Expert Read snapshot (the hosted, paid check-up) ──────────────────────────
-- JSON-escape a string value (quotes + control chars). Also neutralizes "</" so a
-- log line containing "</script>" can't break out of the injected <script> tag.
local function jesc(s)
  s = tostring(s or "")
  s = s:gsub("\\", "\\\\"):gsub('"', '\\"')
  s = s:gsub("\n", "\\n"):gsub("\r", "\\r"):gsub("\t", "\\t")
  s = s:gsub("[%z\1-\8\11\12\14-\31]", "")   -- strip remaining control chars
  s = s:gsub("</", "<\\/")                    -- keep a stray "</script>" from closing the tag
  return '"' .. s .. '"'
end

-- The scene/source/filter/audio graph the session log does NOT contain — this is what
-- makes the Expert Read as sharp as pasting the log to a human who can also see the rig.
-- Built on the slow pass and cached in rig_inv_json. Reuses the scene membership the
-- audit already walked (rig_containment / rig_scene_items). JSON by concatenation, and
-- browser URLs get their query string stripped so widget tokens never leave the machine.
function snapshot_inventory()
  local ok, out = pcall(function()
    local MON = { [0] = "off", [1] = "monitor-only", [2] = "monitor-and-output" }
    local scj = {}
    if rig_scene_items then
      for scene, items in pairs(rig_scene_items) do
        local its = {}
        for _, nm in ipairs(items) do its[#its + 1] = jesc(nm) end
        scj[#scj + 1] = '{"name":' .. jesc(scene) .. ',"items":[' .. table.concat(its, ",") .. "]}"
      end
    end
    local srj = {}
    local sources = obs.obs_enum_sources()
    if sources ~= nil then
      for _, src in ipairs(sources) do
        if obs.obs_source_get_type(src) == obs.OBS_SOURCE_TYPE_INPUT then
          local name = obs.obs_source_get_name(src)
          local kind = obs.obs_source_get_id(src)
          local st = obs.obs_source_get_settings(src)
          local fields = { '"name":' .. jesc(name), '"kind":' .. jesc(kind) }
          local is_audio = (bit.band(obs.obs_source_get_output_flags(src), obs.OBS_SOURCE_AUDIO) ~= 0)
          if is_audio then
            fields[#fields + 1] = '"audio":true'
            fields[#fields + 1] = '"muted":' .. (obs.obs_source_muted(src) and "true" or "false")
            local vol = obs.obs_source_get_volume(src) or 1
            local db = (vol > 0) and (20 * math.log(vol) / math.log(10)) or -100
            fields[#fields + 1] = '"volumeDb":' .. string.format("%.1f", db)
            fields[#fields + 1] = '"monitoring":' .. jesc(MON[obs.obs_source_get_monitoring_type(src)] or "off")
            local mix = obs.obs_source_get_audio_mixers(src) or 0
            local trk = {}
            for t = 1, 6 do if bit.band(mix, bit.lshift(1, t - 1)) ~= 0 then trk[#trk + 1] = tostring(t) end end
            fields[#fields + 1] = '"tracks":[' .. table.concat(trk, ",") .. "]"
          end
          if kind == "browser_source" then
            local u = (obs.obs_data_get_string(st, "url") or ""):gsub("%?.*$", "")
            fields[#fields + 1] = '"url":' .. jesc(u)
            fields[#fields + 1] = '"shutdownWhenHidden":' .. (obs.obs_data_get_bool(st, "shutdown") and "true" or "false")
            fields[#fields + 1] = '"refreshOnActive":' .. (obs.obs_data_get_bool(st, "restart_when_active") and "true" or "false")
          elseif kind == "image_source" then
            fields[#fields + 1] = '"file":' .. jesc(obs.obs_data_get_string(st, "file"))
          elseif kind == "ffmpeg_source" then
            fields[#fields + 1] = '"file":' .. jesc(obs.obs_data_get_string(st, "local_file"))
          elseif kind == "game_capture" then
            fields[#fields + 1] = '"mode":' .. jesc(obs.obs_data_get_string(st, "capture_mode"))
          elseif kind == "wasapi_input_capture" or kind == "wasapi_output_capture" then
            fields[#fields + 1] = '"device":' .. jesc(obs.obs_data_get_string(st, "device_id"))
          elseif kind == "dshow_input" then
            fields[#fields + 1] = '"device":' .. jesc(obs.obs_data_get_string(st, "video_device_id"))
          end
          obs.obs_data_release(st)
          local flj = {}
          local flist = obs.obs_source_enum_filters(src)
          if flist ~= nil then
            for _, fsrc in ipairs(flist) do
              flj[#flj + 1] = '{"name":' .. jesc(obs.obs_source_get_name(fsrc)) ..
                ',"id":' .. jesc(obs.obs_source_get_id(fsrc)) ..
                ',"on":' .. (obs.obs_source_enabled(fsrc) and "true" or "false") .. "}"
            end
            obs.source_list_release(flist)
          end
          fields[#fields + 1] = '"filters":[' .. table.concat(flj, ",") .. "]"
          local insc = {}
          if rig_containment and rig_containment[name] then
            for _, p in ipairs(rig_containment[name]) do insc[#insc + 1] = jesc(p) end
          end
          fields[#fields + 1] = '"inScenes":[' .. table.concat(insc, ",") .. "]"
          srj[#srj + 1] = "{" .. table.concat(fields, ",") .. "}"
        end
      end
      obs.source_list_release(sources)
    end
    return '{"scenes":[' .. table.concat(scj, ",") .. '],"sources":[' .. table.concat(srj, ",") .. "]}"
  end)
  if ok and out then return out end
  return "{}"
end

-- Keep a log under `maxn` chars WITHOUT losing the two parts a cross-session read needs:
-- the startup config dump (top of the log, where drift lives) and the end-of-session
-- frame totals (bottom, where the trends land). Over the cap we splice head+tail and drop
-- the least-useful repetitive middle, flagging that we did so. Returns text, capped-bool.
local function cap_headtail(s, maxn)
  if #s <= maxn then return s, false end
  local head = math.floor(maxn * 0.6)
  local tail = maxn - head
  return s:sub(1, head) .. "\n\n...[middle of this log trimmed to keep the dock light]...\n\n" ..
    s:sub(#s - tail + 1), true
end

-- Local session archive. Once the tool is installed we keep our OWN copy of each streamed
-- session's log under %APPDATA%\StreamAuditX\sessions, so cross-stream reads survive OBS
-- clearing its logs and can look further back than the handful OBS keeps on disk. Nothing
-- leaves the PC here: a copy is only uploaded when the streamer chooses to run a read.
local ARCHIVE_KEEP = 60   -- how many streamed sessions we retain locally (full log copies)

local function sx_archive_dir()
  local ad = os.getenv("APPDATA"); if not ad then return nil end
  return ad .. "\\StreamAuditX\\sessions\\"
end

-- Fire a cmd and drain it so the handle closes cleanly. FALLBACK ONLY (flashes a
-- console window) — the winapi doors below are the real path.
local function sx_run(cmd)
  local p = io.popen(cmd .. " 2>nul")
  if not p then return end
  p:read("*a"); p:close()
end

-- Silent (no-console) filesystem verbs, cmd fallback when ffi is unavailable.
local function sx_mkdir(path)
  if winapi then
    local okm = pcall(function()
      -- CreateDirectoryA makes ONE level at a time; walk the segments so a nested
      -- path gets fully created, same as cmd's mkdir did.
      local acc = path:match("^%a:\\") or ""
      for seg in path:sub(#acc + 1):gmatch("[^\\]+") do
        acc = acc .. seg .. "\\"
        ffi.C.CreateDirectoryA(acc, nil)
      end
    end)
    if okm then return end
  end
  sx_run('cmd /c if not exist "' .. path .. '" mkdir "' .. path .. '"')
end
local function sx_copy(src, dst)
  if winapi and pcall(function() ffi.C.CopyFileA(src, dst, 0) end) then return end
  sx_run('cmd /c copy /y "' .. src .. '" "' .. dst .. '" >nul')
end
local function sx_del(path)
  if winapi and pcall(function() ffi.C.DeleteFileA(path) end) then return end
  sx_run('cmd /c del /q "' .. path .. '" >nul')
end

-- .txt basenames in a folder, newest-first. OBS logs are timestamp-named
-- (YYYY-MM-DD HH-MM-SS.txt), so a reverse-lexical sort is chronological and does not depend
-- on copy time (which a plain "by date modified" listing would get wrong for archived copies).
local function sx_list_desc(dir)
  local out = {}
  local files = win_list(dir, "*.txt")
  if files then
    for _, f in ipairs(files) do out[#out + 1] = f.name end
  else
    local p = io.popen('cmd /c dir /b "' .. dir .. '*.txt" 2>nul')
    if p then for line in p:lines() do if line and line ~= "" then out[#out + 1] = line end end p:close() end
  end
  table.sort(out, function(a, b) return a > b end)
  return out
end

-- Fold any new streamed OBS sessions into the local archive, then prune to ARCHIVE_KEEP.
-- Wall-clock helpers to align companion-app logs to an OBS session's time window. OBS, Streamer.bot
-- and Lumia all stamp LOCAL machine time, so a plain linear day-second value is directly comparable
-- across apps and across midnight. (Epoch is arbitrary: 2000-01-01, only differences matter.)
local SX_MDAYS = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
local function sx_is_leap(y) return (y % 4 == 0 and (y % 100 ~= 0 or y % 400 == 0)) end
local function sx_days_since(y, mo, d)
  local days = 0
  for yy = 2000, y - 1 do days = days + (sx_is_leap(yy) and 366 or 365) end
  for mm = 1, mo - 1 do days = days + SX_MDAYS[mm] + ((mm == 2 and sx_is_leap(y)) and 1 or 0) end
  return days + (d - 1)
end
local function sx_epoch(y, mo, d, secs) return sx_days_since(y, mo, d) * 86400 + secs end

-- Keep only the companion-log lines inside [lo, hi] (linear seconds), plus untimestamped
-- continuation lines that follow a kept one. Companion logs stamp "[YYYY-MM-DD HH:MM:SS". Returns
-- "" when nothing matched, so the caller skips an empty association (e.g. a backfilled old session
-- the current rolling log no longer covers).
local function sx_slice_companion(raw, lo, hi)
  if type(raw) ~= "string" or raw == "" then return "" end
  local out, keep = {}, false
  for line in (raw .. "\n"):gmatch("(.-)\n") do
    local y, mo, d, hh, mm, ss = line:match("(%d%d%d%d)%-(%d%d)%-(%d%d)[ T](%d%d):(%d%d):(%d%d)")
    if y then
      local t = sx_epoch(tonumber(y), tonumber(mo), tonumber(d),
        tonumber(hh) * 3600 + tonumber(mm) * 60 + tonumber(ss))
      keep = (t >= lo and t <= hi)
      if keep then out[#out + 1] = line end
    elseif keep then
      out[#out + 1] = line
    end
  end
  return table.concat(out, "\n")
end

-- A name in the archive is a SESSION log unless it is one of our companion sidecars.
-- FIX (found building PART D): .health.txt was missing here, so archived health sidecars
-- counted as sessions: they ate retention slots and rode multi payloads as fake session
-- "logs". Joey's real archive carried 12 of them when this was caught.
local function sx_is_session(name)
  return not (name:match("%.sb%.txt$") or name:match("%.lumia%.txt$") or name:match("%.miu%.txt$") or name:match("%.fb%.txt$") or name:match("%.vm%.txt$") or name:match("%.health%.txt$") or name:match("%.tiktok%.txt$"))
end
local function sx_write(path, text)
  local f = io.open(path, "w"); if not f then return end
  f:write(text or ""); f:close()
end

-- Scoring v2: the dock's crash descriptors for the measured lane, as ONE JSON string
-- the panel bakes in as window.SX_CRASHES. Gathers the facts (recent crash reports +
-- archived streamed-session start times); ALL the ladder math lives in
-- sxdash.crash_recency (pure, harness-pinned). Refreshed by the slow scan after the
-- archive fold, so a stream that just ended counts as a session immediately.
sx_crash_ladder_json = function()
  local out = "[]"
  pcall(function()
    local list = comp and comp.crash_list
    if not list or #list == 0 then return end
    local times = {}
    local arc = sx_archive_dir()
    if arc then
      for _, nm in ipairs(sx_list_desc(arc)) do
        if sx_is_session(nm) then
          local y, mo, d, h, mi, s = nm:match("(%d+)%-(%d+)%-(%d+)%s+(%d+)%-(%d+)%-(%d+)")
          if y then times[#times + 1] = y .. "-" .. mo .. "-" .. d .. "T" .. h .. ":" .. mi .. ":" .. s end
        end
      end
    end
    out = sxdash.crash_recency_json(sxdash.crash_recency(list, times))
  end)
  return out
end

sx_archive_logs = function()
  local arc = sx_archive_dir(); if not arc then return end
  local appdata = os.getenv("APPDATA"); if not appdata then return end
  local logdir = appdata .. "\\obs-studio\\logs\\"
  sx_mkdir(arc)
  -- One-time VoiceMeeter dedup migration: older builds re-saved the identical ~300KB
  -- config per session (~18MB across a full archive). Fold those into ONE shared
  -- vm-latest.txt and drop the per-session copies that match it; copies that differ
  -- stay, they mark the sessions where the config actually changed.
  if sxdash.vm_path and not file_exists(sxdash.vm_path) then
    pcall(function()
      local newest_vm = nil
      for _, nm in ipairs(sx_list_desc(arc)) do
        if sx_is_session(nm) then
          local t = read_file(arc .. nm:gsub("%.txt$", "") .. ".vm.txt")
          if t and t ~= "" then newest_vm = t break end   -- list is newest first
        end
      end
      if newest_vm then
        sx_write(sxdash.vm_path, newest_vm)
        for _, nm in ipairs(sx_list_desc(arc)) do
          if sx_is_session(nm) then
            local p = arc .. nm:gsub("%.txt$", "") .. ".vm.txt"
            if read_file(p) == newest_vm then sx_del(p) end
          end
        end
      end
    end)
  end
  local have = {}
  for _, n in ipairs(sx_list_desc(arc)) do have[n] = true end
  -- Copy every streamed OBS log we do not already have; always refresh the newest streamed one so
  -- an in-progress session stays current. For each session we (re)write, slice each connected app's
  -- CURRENT rolling log to that session's time window and store it as a sidecar, so the cross-session
  -- read gets each app's activity per stream instead of one undated blob.
  -- Free-tier slot budget: never COPY a session the gate below would immediately prune
  -- (OBS keeps ~10 logs on disk, so without this a free member's older streamed logs
  -- would be re-copied and re-deleted on every single slow scan).
  local gman = (sxdash.man_read and sxdash.man_read()) or {}
  local gprem = sx_is_premium()
  local slots = gprem and ARCHIVE_KEEP or sxdash.FREE_KEEP
  local newest_streamed = nil
  for _, n in ipairs(audit_log_list) do   -- newest first
    local m = audit_log_meta[n]
    if m and m.streamed then
      if not newest_streamed then newest_streamed = n end
      local in_slot = false
      if gman[n] then in_slot = true
      elseif slots > 0 then slots = slots - 1; in_slot = true end
      if ((not have[n]) and in_slot) or (n == newest_streamed) then
        sx_copy(logdir .. n, arc .. n)
        local base = n:gsub("%.txt$", "")
        local y, mo, d = n:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)")
        if y and m.start_secs then
          local start_abs = sx_epoch(tonumber(y), tonumber(mo), tonumber(d), m.start_secs)
          local lo, hi = start_abs - 180, start_abs + (m.dur_secs or 0) + 180
          if comp.sb_raw and comp.sb_raw ~= "" then
            local s = sx_slice_companion(comp.sb_raw, lo, hi)
            if s ~= "" then sx_write(arc .. base .. ".sb.txt", s) end
          end
          if comp.lumia_raw and comp.lumia_raw ~= "" then
            local s = sx_slice_companion(comp.lumia_raw, lo, hi)
            if s ~= "" then sx_write(arc .. base .. ".lumia.txt", s) end
          end
          if comp.miu_raw and comp.miu_raw ~= "" then
            local s = sx_slice_companion(comp.miu_raw, lo, hi)
            if s ~= "" then sx_write(arc .. base .. ".miu.txt", s) end
          end
          if comp.fb_raw and comp.fb_raw ~= "" then
            local s = sx_slice_companion(comp.fb_raw, lo, hi)
            if s ~= "" then sx_write(arc .. base .. ".fb.txt", s) end
          end
          -- VoiceMeeter is CONFIG (no timeline) and rarely changes: one shared current
          -- copy (vm-latest.txt), plus a per-session snapshot ONLY when the config
          -- actually changed at this session. A session without its own snapshot
          -- inherits the nearest older one in build_agg_payload.
          if comp.vm_raw and comp.vm_raw ~= "" then
            local latest = sxdash.vm_path and read_file(sxdash.vm_path) or nil
            if latest ~= comp.vm_raw then
              if sxdash.vm_path then sx_write(sxdash.vm_path, comp.vm_raw) end
              sx_write(arc .. base .. ".vm.txt", comp.vm_raw)
            end
          end
          -- PC health: slice the local sampler series to this stream's window, so reads can
          -- line the PC's behavior up against this exact stream minute by minute.
          -- NOTE the sampler stamps UNIX time (os.time), not the 2000-linear clock the
          -- companion slices use — build this window in unix seconds or it never matches.
          local day0 = os.time({ year = tonumber(y), month = tonumber(mo), day = tonumber(d), hour = 0, min = 0, sec = 0 })
          if day0 then
            -- No Stop marker = crashed or still going: keep a wide window (up to 6h),
            -- the samples around an unclean end are the most valuable ones.
            local lo_u = day0 + m.start_secs - 180
            local hs = sx_slice_health(lo_u, lo_u + (m.dur_secs or 21600) + 360)
            if hs ~= "" then sx_write(arc .. base .. ".health.txt", hs) end
            -- TikTok LIVE Studio (multistream spec PART D): window the already-filtered,
            -- already-redacted daily main/renderer text to this stream, so the external
            -- leg exists PER STREAM in the archive. comp.tt_* is the newest day's text;
            -- an older backfilled session just matches nothing and gets no sidecar.
            if comp.tt_main or comp.tt_renderer then
              local tlo = os.date("%Y-%m-%dT%H:%M:%S", lo_u)
              local thi = os.date("%Y-%m-%dT%H:%M:%S", lo_u + (m.dur_secs or 21600) + 360)
              local tmain = sxdash.tiktok_window(comp.tt_main, tlo, thi)
              local trend = sxdash.tiktok_window(comp.tt_renderer, tlo, thi)
              if tmain ~= "" or trend ~= "" then
                local tj = {}
                if tmain ~= "" then tj[#tj + 1] = '"main":' .. jesc(tmain) end
                if trend ~= "" then tj[#tj + 1] = '"renderer":' .. jesc(trend) end
                sx_write(arc .. base .. ".tiktok.txt", "{" .. table.concat(tj, ",") .. "}")
              end
            end
          end
        end
      end
    end
  end
  -- ── Premium history gate ───────────────────────────────
  -- Subscribers keep the full ARCHIVE_KEEP history; free/unknown keeps a
  -- FREE_KEEP-stream teaser. Sessions ever kept under premium are remembered in a
  -- manifest (premium-sessions.txt, next to health.jsonl) and are NEVER wiped by a
  -- downgrade or a signed-out dock: an unknown tier must not destroy a paying
  -- member's history. The first pass with no manifest grandfathers everything
  -- already on disk, so installs that predate the gate keep what they have.
  local sess = {}
  for _, nm in ipairs(sx_list_desc(arc)) do if sx_is_session(nm) then sess[#sess + 1] = nm end end
  local man = sxdash.man_read and sxdash.man_read() or nil
  if man == nil then
    man = {}
    for _, nm in ipairs(sess) do man[nm] = true end
  end
  if sx_is_premium() then
    for _, nm in ipairs(sess) do man[nm] = true end
  end
  local kept_set, kept, free_kept = {}, 0, 0
  for i = 1, #sess do   -- newest first
    local nm = sess[i]
    local keep = false
    if kept < ARCHIVE_KEEP then
      if man[nm] or sx_is_premium() then keep = true
      elseif free_kept < sxdash.FREE_KEEP then keep = true; free_kept = free_kept + 1 end
    end
    if keep then kept = kept + 1; kept_set[nm] = true end
  end
  for _, nm in ipairs(sess) do
    if not kept_set[nm] then
      local base = nm:gsub("%.txt$", "")
      sx_del(arc .. nm)
      for _, sfx in ipairs({ ".sb.txt", ".lumia.txt", ".miu.txt", ".fb.txt", ".vm.txt", ".health.txt", ".tiktok.txt" }) do
        sx_del(arc .. base .. sfx)
      end
    end
  end
  -- The manifest mirrors what is actually on disk (drops pruned + hand-deleted).
  local man2 = {}
  for nm in pairs(man) do if kept_set[nm] then man2[nm] = true end end
  if sxdash.man_write then sxdash.man_write(man2) end
  sxdash.kept = kept
end

-- Build the JSON array of recent real-stream logs the cross-session read ships to
-- /api/read/aggregate. Reads from the local archive (falls back to OBS's live log folder when the
-- archive is empty on first run), newest first, OBS head+tail trimmed only if enormous, and folds
-- in each session's time-aligned companion sidecars (Streamer.bot/Lumia/VoiceMeeter). Kept to a
-- handful of sessions because the payload is inlined and re-parsed on the 8s refresh. Slow pass
-- only, cached. Concatenation, not a [[ ]] literal, so a "]]" inside a log is harmless string data.
build_agg_payload = function()
  local MAXN, PERLOG, COMPCAP = 8, 400000, 150000
  local arc = sx_archive_dir()
  local appdata = os.getenv("APPDATA")
  local names, srcdir, from_arc = {}, nil, false
  if arc then
    local a = {}
    for _, nm in ipairs(sx_list_desc(arc)) do if sx_is_session(nm) then a[#a + 1] = nm end end
    if #a > 0 then names, srcdir, from_arc = a, arc, true end
  end
  if not srcdir then
    if not appdata then return "[]", 0 end
    srcdir = appdata .. "\\obs-studio\\logs\\"
    for _, n in ipairs(audit_log_list) do
      local m = audit_log_meta[n]
      if m and m.streamed then names[#names + 1] = n end
    end
  end
  local function comp_field(key, path)
    local craw = read_file(path)
    if not craw or craw == "" then return "" end
    craw = cap_headtail(craw, COMPCAP)
    return ',"' .. key .. '":{"raw":' .. jesc(craw) .. '}'
  end
  local parts, cnt = {}, 0
  for idx, n in ipairs(names) do
    local raw = read_file(srcdir .. n)
    if raw and raw ~= "" then
      local capped
      raw, capped = cap_headtail(raw, PERLOG)
      -- Prefer the meta computed for OBS's live logs; for an archived session OBS has since
      -- deleted, that cache is empty, so read duration / clean-exit straight from the copy
      -- (a full copy of the original log) so history keeps its timing and end-state.
      local m = audit_log_meta[n] or log_stream_meta(srcdir .. n)
      local extra = ((m and m.dur) and (',"dur":' .. jesc(m.dur)) or "") ..
        ((m and m.ended_bad) and ',"unclean":true' or "") ..
        (capped and ',"capped":true' or "")
      local comps = ""
      if from_arc then
        local base = arc .. n:gsub("%.txt$", "")
        -- VoiceMeeter dedup: a per-session snapshot exists only where the config
        -- CHANGED. A session without one inherits the nearest older snapshot (the
        -- config it actually ran under), else the shared current copy.
        local vmp = base .. ".vm.txt"
        local vsz = file_size(vmp)
        if not (vsz and vsz > 0) then
          vmp = nil
          for j = idx + 1, #names do
            local p2 = arc .. names[j]:gsub("%.txt$", "") .. ".vm.txt"
            local s2 = file_size(p2)
            if s2 and s2 > 0 then vmp = p2 break end
          end
          vmp = vmp or sxdash.vm_path or ""
        end
        comps = comp_field("streamerbot", base .. ".sb.txt") ..
          comp_field("lumia", base .. ".lumia.txt") ..
          comp_field("mixitup", base .. ".miu.txt") ..
          comp_field("firebot", base .. ".fb.txt") ..
          comp_field("voicemeeter", vmp) ..
          comp_field("health", base .. ".health.txt")
        -- Per-stream TikTok LIVE Studio slice (PART D): the sidecar is already a JSON
        -- object ({"main":...,"renderer":...}, windowed + redacted at write time), so it
        -- embeds as-is. The server folds it into that session's read and wentLive row.
        local tk = read_file(base .. ".tiktok.txt")
        if tk and tk:sub(1, 1) == "{" then comps = comps .. ',"tiktok":' .. tk end
      end
      parts[#parts + 1] = '{"name":' .. jesc(n) .. ',"streamed":true' .. extra ..
        ',"raw":' .. jesc(raw) .. comps .. '}'
      cnt = cnt + 1
      if cnt >= MAXN then break end
    end
  end
  return "[" .. table.concat(parts, ",") .. "]", cnt
end

-- ── PC health dashboard data (dock, local only) ────────────
-- Everything the dock's health charts need, written as ONE small JS file
-- (streamauditx-health.js, next to the panel HTML) every sampler tick. The panel
-- re-loads that file in place, so the charts stay live DURING a stream even while
-- the audit scan is auto-paused. Nothing here ever leaves the PC.

-- One TikTok LIVE Studio daily log (already keep-line filtered + id-redacted by
-- tiktok_collect) -> just the lines inside [lo_iso, hi_iso]. TikTok stamps a local ISO
-- "[YYYY-MM-DDTHH:MM:SS..." on every line, so the bounds are the same format and a plain
-- string compare is the whole clock math. Untimestamped continuation lines follow the
-- line above them. Returns "" when nothing matched, so callers skip writing an empty
-- sidecar. Tail-capped at the companion budget. Pure (pinned in harness4).
function sxdash.tiktok_window(text, lo_iso, hi_iso)
  if type(text) ~= "string" or text == "" or type(lo_iso) ~= "string" or type(hi_iso) ~= "string" then return "" end
  local out, keep = {}, false
  for line in (text .. "\n"):gmatch("(.-)\n") do
    local ts = line:match("^%[(%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d:%d%d)")
    if ts then keep = (ts >= lo_iso and ts <= hi_iso) end
    if keep and line ~= "" then out[#out + 1] = line end
  end
  local s = table.concat(out, "\n")
  if #s > 150000 then s = s:sub(-150000) end
  return s
end

-- Scoring v2 crash recency ladder (pure, pinned in dev/harness4.lua). Turns the dock's
-- recent-crash entries (comp.crash_list shape: { when = naive-ISO or nil, was_live,
-- component, ... }) plus the archived streamed-session start times (naive-ISO strings)
-- into the shared scoring module's crash descriptors. Mirrors the server's
-- scorelanes.js crashDescriptors exactly:
--   - signature at dock level = the crash COMPONENT (unrelated crashes never pool)
--   - repeat_count = DISTINCT sessions-or-days with that signature (ten reports from
--     one bad night are one repeat, not ten): a crash belongs to the newest session
--     that started at or before it, else to its calendar day
--   - streams_ago / clean_since = streamed sessions started AFTER the newest crash
--   - was_live = true when ANY crash in the group was live
--   - provably_fixed stays false here: the dock cannot yet prove a traced cause gone,
--     and the module's "cannot confirm the fix yet, watching" copy covers it.
-- Entries without a parseable `when` are skipped (no clock here, so no guessing).
function sxdash.crash_recency(crash_list, session_times)
  local times = {}
  for _, t in ipairs(session_times or {}) do
    if type(t) == "string" and t ~= "" then times[#times + 1] = t end
  end
  table.sort(times)   -- ascending; naive-ISO strings compare chronologically
  local by, order = {}, {}
  for _, c in ipairs(crash_list or {}) do
    if type(c) == "table" and type(c.when) == "string" and c.when ~= "" then
      local sig = tostring(c.component or "unknown"):lower()
      if not by[sig] then by[sig] = {}; order[#order + 1] = sig end
      table.insert(by[sig], c)
    end
  end
  local out = {}
  for _, sig in ipairs(order) do
    local grp = by[sig]
    table.sort(grp, function(a, b) return a.when > b.when end)   -- newest first
    local newest = grp[1]
    local buckets, repeat_count = {}, 0
    local was_live = false
    for _, g in ipairs(grp) do
      local owner = nil
      for i = 1, #times do
        local nxt = times[i + 1]
        if g.when >= times[i] and (not nxt or g.when < nxt) then owner = times[i] break end
      end
      local key = owner and ("session:" .. owner) or ("day:" .. g.when:sub(1, 10))
      if not buckets[key] then buckets[key] = true; repeat_count = repeat_count + 1 end
      if g.was_live then was_live = true end
    end
    local after = 0
    for i = 1, #times do if times[i] > newest.when then after = after + 1 end end
    out[#out + 1] = {
      signature = sig,
      label = "OBS crashed (" .. tostring(newest.component or "unknown") .. ")",
      when = newest.when:sub(1, 10),
      streams_ago = after,
      clean_since = after,
      repeat_count = repeat_count,
      was_live = was_live,
      provably_fixed = false,
    }
  end
  return out
end

-- The ladder as the JSON array the panel bakes in as window.SX_CRASHES (pure, pinned:
-- output must parse as strict JSON). Own tiny escaper so this stays self-contained.
function sxdash.crash_recency_json(ladder)
  local function jstr(s)
    s = tostring(s or ""):gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("%c", " ")
    return '"' .. s .. '"'
  end
  local rows = {}
  for _, c in ipairs(ladder or {}) do
    rows[#rows + 1] = '{"signature":' .. jstr(c.signature) .. ',"label":' .. jstr(c.label) ..
      ',"when":' .. jstr(c.when) .. ',"streams_ago":' .. math.floor(c.streams_ago or 0) ..
      ',"clean_since":' .. math.floor(c.clean_since or 0) ..
      ',"repeat_count":' .. math.floor(c.repeat_count or 0) ..
      ',"was_live":' .. (c.was_live and "true" or "false") ..
      ',"provably_fixed":' .. (c.provably_fixed and "true" or "false") .. "}"
  end
  return "[" .. table.concat(rows, ",") .. "]"
end

-- One health.jsonl / .health.txt line -> sample table (nil on a garbage line).
function sxdash.parse(line)
  local t = tonumber(line:match('"t":(%d+)'))
  if not t then return nil end
  local function num(k) return tonumber(line:match('"' .. k .. '":(%-?[%d%.]+)')) end
  return { t = t, cpu_sys = num("cpu_sys"), cpu_obs = num("cpu_obs"), ram_pct = num("ram_pct"),
           lagged = num("lagged") or -1, total = num("total") or -1,
           net_drop = num("net_drop") or -1, net_total = num("net_total") or -1,
           enc_skip = num("enc_skip") or -1, enc_total = num("enc_total") or -1,
           gpu = num("gpu") or -1, gpu_temp = num("gpu_temp") or -1, gpu_enc = num("gpu_enc") or -1,
           cpu_temp = num("cpu_temp") or -1,
           live = (line:match('"live":true') ~= nil) }
end

function sxdash.parse_text(text)
  local out = {}
  if type(text) ~= "string" or text == "" then return out end
  for line in (text .. "\n"):gmatch("(.-)\n") do
    if line ~= "" then
      local s = sxdash.parse(line)
      if s then out[#out + 1] = s end
    end
  end
  return out
end

-- Pure: per-leg counters -> the compact JSON object a health row carries, keyed by
-- output name, value [total, dropped]. Names are sanitized (quotes, backslashes and
-- control chars stripped, capped at 80) rather than escaped so the row stays a plain
-- one-line JSON object, and keys are sorted so the same legs always produce the same
-- bytes (the off-OBS harness pins this). nil when nothing usable survives.
function sxdash.legs_json(legs)
  if type(legs) ~= "table" or #legs == 0 then return nil end
  local rows = {}
  for _, L in ipairs(legs) do
    local nm = tostring(L.name or ""):gsub('[%c"\\]', ""):sub(1, 80)
    if nm ~= "" then
      rows[#rows + 1] = { nm = nm, total = math.floor(tonumber(L.total) or -1), drop = math.floor(tonumber(L.drop) or -1) }
    end
  end
  if #rows == 0 then return nil end
  table.sort(rows, function(a, b) return a.nm < b.nm end)
  local parts = {}
  for _, r in ipairs(rows) do parts[#parts + 1] = string.format('"%s":[%d,%d]', r.nm, r.total, r.drop) end
  return "{" .. table.concat(parts, ",") .. "}"
end

-- ── phase 6 collector parsing (pure, unit-tested off-OBS in dev/harness3.lua) ──
-- Overall CPU % from two /proc/stat readings. prev = { busy=, total= } from the
-- previous sample (nil on the first). Returns cpu (nil until a valid delta exists)
-- plus the new prev; a garbage read or a counter reset degrades to absent.
function sxdash.cpu_from_stat(text, prev)
  local line = tostring(text or ""):match("^cpu%s+[^\n]*") or tostring(text or ""):match("\ncpu%s+[^\n]*")
  if not line then return nil, prev end
  local f = {}
  for num in line:gmatch("%d+") do
    f[#f + 1] = tonumber(num)
    if #f >= 8 then break end
  end
  if #f < 4 then return nil, prev end
  local idle = f[4] + (f[5] or 0)   -- idle + iowait
  local total = 0
  for i = 1, #f do total = total + f[i] end
  local busy = total - idle
  local cpu
  if prev and total > prev.total and busy >= prev.busy then
    cpu = math.floor(100 * (busy - prev.busy) / (total - prev.total) + 0.5)
    if cpu < 0 or cpu > 100 then cpu = nil end
  end
  return cpu, { busy = busy, total = total }
end

-- RAM pressure from /proc/meminfo (kB units): % in use and available GB.
function sxdash.mem_from_meminfo(text)
  local t = tostring(text or "")
  local tot = tonumber(t:match("MemTotal:%s*(%d+)"))
  local avail = tonumber(t:match("MemAvailable:%s*(%d+)"))
  if not (tot and avail and tot > 0) then return nil, nil end
  local pct = math.floor(100 * (tot - avail) / tot + 0.5)
  if pct < 0 then pct = 0 elseif pct > 100 then pct = 100 end
  return pct, math.floor(avail / 1048576 * 10 + 0.5) / 10
end

-- True when a hwmon sensor name belongs to the CPU package (not a GPU or drive).
function sxdash.is_cpu_hwmon(nm)
  nm = tostring(nm or ""):gsub("%s+$", "")
  return nm == "coretemp" or nm == "k10temp" or nm == "zenpower" or nm == "cpu_thermal"
end

-- Windows PDH "GPU Engine" instances -> overall GPU % + encoder %. Instance names
-- end in engtype_<Engine>; utilization sums per engine and the busiest non-codec
-- engine is "the GPU number" (Task Manager's reading). VideoEncode is the encoder.
function sxdash.gpu_from_pdh(items)
  local sums = {}
  for _, it in ipairs(items or {}) do
    local eng = tostring(it.name or ""):match("engtype_([%w_]+)")
    if eng then sums[eng] = (sums[eng] or 0) + (tonumber(it.val) or 0) end
  end
  local gpu, enc = -1, -1
  for eng, s in pairs(sums) do
    if eng == "VideoEncode" then enc = s
    elseif eng ~= "VideoDecode" and s > gpu then gpu = s end
  end
  local function cl(v)
    if v < 0 then return -1 end
    return math.max(0, math.min(100, math.floor(v + 0.5)))
  end
  return cl(gpu), cl(enc)
end

-- Windows PDH "GPU Adapter Memory" Dedicated Usage instances (bytes) -> used GB +
-- used % of the card. Multi-adapter rigs report per adapter; the busiest one is the
-- card OBS renders on. total_gb <= 0 = card size unknown, the % stays absent.
function sxdash.vram_from_pdh(items, total_gb)
  local best = -1
  for _, it in ipairs(items or {}) do
    local v = tonumber(it.val) or -1
    if v > best then best = v end
  end
  if best < 0 then return -1, -1 end
  local gb = math.floor(best / 1073741824 * 10 + 0.5) / 10
  local pct = -1
  if total_gb and total_gb > 0 then
    pct = math.floor(100 * (best / 1073741824) / total_gb + 0.5)
    if pct > 100 then pct = 100 end
  end
  return gb, pct
end

-- ── phase 7: verified Windows tuning (pure classify, harness-tested) ──
-- st = the raw registry state from sxcol.win_tuning() (nil field = unreadable).
-- Returns { findings = { {sev,cat,title,why,fix} }, passes = { {cat,title} },
-- snap_json = compact JSON for the Expert Read snapshot }. Verification law:
-- only speak about what was actually read; an unreadable value stays silent.
function sxdash.tuning_classify(st)
  local out = { findings = {}, passes = {}, snap_json = nil }
  if type(st) ~= "table" then return out end
  local snap = {}
  local function fnd(sev, title, why, fix)
    out.findings[#out.findings + 1] = { sev = sev, cat = "system", title = title, why = why, fix = fix }
  end
  local function pass(title)
    out.passes[#out.passes + 1] = { cat = "system", title = title }
  end
  -- Power plan: the classic scheme GUID, plus the Windows 11 "Best performance"
  -- overlay that can ride on top of Balanced.
  local sch = tostring(st.scheme or ""):lower()
  local best_overlay = tostring(st.overlay or ""):lower():find("ded574b5") ~= nil
  local plan
  if sch:find("8c5e7fda") or sch:find("e9a42b02") then plan = "high"
  elseif sch:find("381b4222") then plan = "balanced"
  elseif sch:find("a1841308") then plan = "saver"
  elseif sch ~= "" then plan = "custom" end
  if plan then
    snap[#snap + 1] = '"power":"' .. plan .. '"'
    snap[#snap + 1] = '"power_boost":' .. (best_overlay and "true" or "false")
    if plan == "high" or best_overlay then
      pass("Windows power mode is set for performance")
    elseif plan == "saver" then
      fnd("warning", "Windows is in Power saver mode",
        "Power saver slows your CPU down to save energy, the opposite of what a live encode needs. Stutters and dropped frames get much more likely, especially when a game is running too.",
        "Settings → System → Power & battery → set Power mode to Best performance. On a desktop: Control Panel → Power Options → High performance.")
    else
      fnd("tip", "Windows power mode is not set to Best performance",
        "We checked your power plan: it lets the CPU drop its speed between frames. Best performance keeps clocks steady while you are live, which means fewer encoder and render stutters. It costs a little idle power and nothing else.",
        "Settings → System → Power & battery → set Power mode to Best performance. On a desktop: Control Panel → Power Options → High performance.")
    end
  end
  -- Windows' own game recorder. Background recording ("Record what happened")
  -- is the part that quietly spends GPU while OBS is already recording.
  if st.dvr_hist == 1 then
    snap[#snap + 1] = '"dvr_background":true'
    fnd("warning", "Windows is recording your gameplay in the background",
      "We checked Windows itself: \"Record what happened\" is on, so Windows keeps its own rolling recording while you play. OBS already records for you, so this just spends GPU and disk on a copy you never use, and it can cost frames in game.",
      "Settings → Gaming → Captures → turn off \"Record what happened\".")
  elseif st.dvr_app == 0 or st.dvr_cfg == 0 then
    snap[#snap + 1] = '"game_dvr_off":true'
    pass("Windows' hidden game recorder is off")
  elseif st.dvr_hist == 0 then
    snap[#snap + 1] = '"dvr_background":false'
    pass("Windows' background gameplay recording is off")
  end
  -- Hardware-accelerated GPU scheduling: worth having on modern rigs, honest
  -- about the trade-off (rare stutter on older setups, needs a restart).
  if st.hags ~= nil then
    snap[#snap + 1] = '"hags":' .. ((st.hags == 2) and "true" or "false")
    if st.hags == 2 then
      pass("Hardware-accelerated GPU scheduling is on")
    else
      fnd("tip", "Hardware-accelerated GPU scheduling is off",
        "We checked Windows: HAGS is off. Turning it on lets the GPU manage its own work queue, which lowers scheduling latency and helps the newest encoders, especially AV1. On rare older setups it can cause stutter, and it is fully reversible.",
        "Settings → System → Display → Graphics → Change default graphics settings → turn on Hardware-accelerated GPU scheduling, then restart your PC.")
    end
  end
  -- Game Mode: lets Windows prioritise the game + capture and hold background noise.
  if st.game_mode ~= nil then
    snap[#snap + 1] = '"game_mode":' .. ((st.game_mode ~= 0) and "true" or "false")
    if st.game_mode ~= 0 then
      pass("Windows Game Mode is on")
    else
      fnd("tip", "Windows Game Mode is off",
        "We checked Windows: Game Mode is off. With it on, Windows gives your game the priority while you play and holds driver installs and some background work until you are done.",
        "Settings → Gaming → Game Mode → turn it on.")
    end
  end
  -- Delivery Optimization: only internet-wide sharing (mode 3) spends the same
  -- upload your stream depends on; local-network sharing is fine.
  if st.do_mode ~= nil then
    snap[#snap + 1] = '"delivery_mode":' .. st.do_mode
    if st.do_mode == 3 then
      fnd("warning", "Windows is sharing updates over your internet connection",
        "We checked Windows: Delivery Optimization is allowed to upload Windows updates to other people's PCs over the internet. That uses the same upload bandwidth your stream depends on, and a random upload burst mid-stream shows up as dropped frames.",
        "Settings → Windows Update → Advanced options → Delivery Optimization → turn off \"Allow downloads from other devices\", or limit it to devices on my local network.")
    else
      pass("Windows update sharing is staying off your internet upload")
    end
  end
  out.snap_json = "{" .. table.concat(snap, ",") .. "}"
  return out
end

-- ── phases 2-3: Windows crash/machine records (pure parse, harness-tested in dev/harness4.lua) ──
-- The ffi/file halves live in sxcol.wer_reports / sxcol.win_events; everything below is pure
-- string work so the off-OBS harness proves it. PII law: only exe/module BASENAMES, exception
-- codes and times ever come out of these parsers; full paths and usernames die here.

-- Minimal JSON string escaper for the snapshot packs built in this pure section (jesc lives
-- outside the harness-extracted region, so the pure JSON builders carry their own).
function sxdash.jstr(s)
  s = tostring(s or "")
  s = s:gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\r", "\\r"):gsub("\n", "\\n"):gsub("\t", "\\t")
  s = s:gsub("%c", "")
  return '"' .. s .. '"'
end

-- ASCII -> UTF-16LE bytes (each char followed by a zero byte, wide-NUL terminated). wevtapi has
-- no ANSI entry points; every string that crosses that ffi door goes through here first.
function sxdash.wstr(s)
  return (tostring(s or ""):gsub("(.)", "%1\0")) .. "\0\0\0"
end

-- Merge RUNASADMIN into an existing AppCompatFlags\Layers value (narrow text; the
-- data is always plain ASCII flags like "~ HIGHDPIAWARE"). Preserves flags already
-- there, guarantees the leading "~". nil = the flag is already set, nothing to write.
function sxdash.admin_layers_merge(cur)
  cur = tostring(cur or ""):gsub("^%s+", ""):gsub("%s+$", "")
  if cur:find("RUNASADMIN", 1, true) then return nil end
  if cur == "" then return "~ RUNASADMIN" end
  if cur:sub(1, 1) ~= "~" then cur = "~ " .. cur end
  return cur .. " RUNASADMIN"
end

-- A connected widget the user parked as "rarely used": what should this scan say?
-- nil = not parked, normal finding logic applies. 'use-today' = on screen right now
-- with the reload setting still on: recommend the stay-running settings for today.
-- 'in-use-ok' = on screen and already staying running: right for today, quiet.
-- 'parked-ok' = off screen with the reload setting on: the correct parked state,
-- quiet. 'save-resources' = off screen but configured to stay always loaded: offer
-- the one-click flip back to the resource saving setting. Pinned in harness3.
function sxdash.park_classify(is_parked, is_active, reload_configured)
  if not is_parked then return nil end
  if is_active then
    if reload_configured then return "use-today" end
    return "in-use-ok"
  end
  if reload_configured then return "parked-ok" end
  return "save-resources"
end

-- The run-as-administrator card, one variant per rig state (pure, harness-pinned so
-- the copy laws hold: honest caveats always present, no invented menus, the verify
-- path is the session log line, never the title bar - OBS shows nothing there).
-- is_admin: is THIS process elevated (true/false/nil=door closed)
-- flag_set: is the always-admin flag already written (true/false/nil=unreadable)
-- evidence: did the last session log prove GPU priority damage (boolean)
-- Returns nil when the door is closed (the log-only fallback finding owns it then),
-- {state="ok", good=...} when elevated, or a full finding table.
function sxdash.admin_classify(is_admin, flag_set, evidence)
  if is_admin == nil then return nil end
  if is_admin == true then
    return { state = "ok", good = "OBS is running as administrator" }
  end
  if flag_set == true then
    return { state = "restart", sev = "tip", cat = "system",
      title = "Restart OBS to finish switching it to administrator mode",
      why = "Windows is already set to launch OBS as administrator, but this session started without it (OBS opened before the setting existed, or the yes/no prompt was declined).",
      fix = "Close OBS completely and open it again. Windows will show its yes/no control prompt; choose Yes. That session's log will then say “Running as administrator: true”." }
  end
  local why
  if evidence then
    why = "Last session OBS could not raise its GPU priority because it was not elevated, and Windows can starve a non elevated OBS of GPU time under load. Your viewers feel that as render lag and stutter. Running OBS as administrator prevents it, and also lets Game Capture hook into games reliably. Two honest trade offs: Windows shows a yes/no prompt each time OBS starts, and dragging files from Explorer into OBS stops working (use the + button in Sources instead)."
  else
    why = "Streaming pros run OBS as administrator: Windows then lets OBS keep its GPU priority, so a heavy game can't starve it into render lag, and Game Capture hooks into games much more reliably. Two honest trade offs: Windows shows a yes/no prompt each time OBS starts, and dragging files from Explorer into OBS stops working (use the + button in Sources instead)."
  end
  return { state = "offer", sev = evidence and "warning" or "tip", cat = "system",
    title = "OBS is not running as administrator",
    why = why,
    fix = "Fix it for me tells Windows to always start OBS as administrator. It is the same thing as ticking “Run this program as an administrator” under Properties → Compatibility on obs64.exe, and unticking that box anytime undoes it. Then restart OBS. To confirm it worked: the next session's log will say “Running as administrator: true” (the OBS window itself shows no sign, so don't look for one).",
    fixa = { admin = true } }
end

-- Windows FILETIME (100ns ticks since 1601, the unit win_list mtimes and WER EventTime use)
-- -> unix epoch seconds. nil on garbage; tolerates the double-precision loss (microseconds).
function sxdash.filetime_epoch(ft)
  ft = tonumber(ft)
  if not ft or ft <= 0 then return nil end
  local e = math.floor(ft / 10000000 - 11644473600)
  if e < 0 or e > 4102444800 then return nil end   -- sanity: 1970..2100
  return e
end

-- A UTC ISO timestamp (the event log's SystemTime) -> the same instant as LOCAL "YYYY-MM-DDTHH:MM:SS",
-- so it lines up with every other clock in the snapshot (OBS log times are local). nil on garbage.
function sxdash.utc_to_local(iso)
  local y, mo, d, h, mi, s = tostring(iso or ""):match("^(%d+)%-(%d+)%-(%d+)T(%d+):(%d+):(%d+)")
  if not y then return nil end
  local okt, t = pcall(os.time, { year = tonumber(y), month = tonumber(mo), day = tonumber(d),
    hour = tonumber(h), min = tonumber(mi), sec = tonumber(s) })
  if not okt or not t then return nil end
  -- os.time read the UTC fields as if they were local; correct by the local-vs-UTC offset.
  local okd, off = pcall(function() return os.difftime(t, os.time(os.date("!*t", t))) end)
  if not okd or type(off) ~= "number" then return tostring(iso):sub(1, 19) end
  return os.date("%Y-%m-%dT%H:%M:%S", t + off)
end

-- One raw Report.wer (UTF-16LE or ASCII, INI-ish Key=Value) -> { event_type, app, app_version,
-- module, code, when_epoch } or nil. Sig indices are stable across Windows locales (the .Name
-- labels are localized, the numbers are not): Sig[0]=app, Sig[1]=version, Sig[3]=module. The
-- exception code slot differs by kind (APPCRASH Sig[6], BEX Sig[7]), so any Sig value shaped
-- like an NT status (c0/80/e0 + 6 hex) is accepted from a scan.
function sxdash.wer_parse(raw)
  if type(raw) ~= "string" or raw == "" then return nil end
  local t = raw:gsub("%z", ""):gsub("^\255\254", ""):gsub("^\239\187\191", "")
  local function val(k)
    local pat = k:gsub("(%W)", "%%%1")
    return t:match("\n" .. pat .. "=([^\r\n]*)") or t:match("^" .. pat .. "=([^\r\n]*)")
  end
  local et = val("EventType")
  if not et or et == "" then return nil end
  local function base(s)
    s = tostring(s or ""):gsub("%s+$", "")
    if s == "" then return nil end
    return s:match("[^\\/]+$")
  end
  local app = base(val("AppPath")) or base(val("Sig[0].Value"))
  if not app then return nil end
  local code
  for i = 0, 9 do
    local v = tostring(val("Sig[" .. i .. "].Value") or ""):lower()
    if v:match("^[c8e]0%x%x%x%x%x%x$") then code = v break end
  end
  local ver = val("Sig[1].Value")
  return {
    event_type = et,
    app = app,
    app_version = (ver and ver ~= "" and ver:match("^[%w%.%-_]+")) or nil,
    module = base(val("Sig[3].Value")),
    code = code,
    when_epoch = sxdash.filetime_epoch(val("EventTime")),
  }
end

-- Parsed reports -> the shipped list: 30-day window (now = epoch; future-dated garbage dropped),
-- repeats of the same app+module merged into one row with a count, newest first, capped at 12,
-- when_epoch formatted as local ISO.
function sxdash.wer_collapse(list, now)
  local by, order = {}, {}
  for _, r in ipairs(list or {}) do
    if type(r) == "table" and r.app and r.app ~= "" then
      local fresh = true
      if now and r.when_epoch then
        if now - r.when_epoch > 2592000 or r.when_epoch > now + 86400 then fresh = false end
      end
      if fresh then
        local key = (r.app .. "|" .. (r.module or "")):lower()
        local cur = by[key]
        if cur then
          cur.count = cur.count + 1
          if r.when_epoch and (not cur.when_epoch or r.when_epoch > cur.when_epoch) then cur.when_epoch = r.when_epoch end
        else
          cur = { event_type = r.event_type, app = r.app, app_version = r.app_version,
                  module = r.module, code = r.code, when_epoch = r.when_epoch, count = 1 }
          by[key] = cur
          order[#order + 1] = cur
        end
      end
    end
  end
  table.sort(order, function(a, b) return (a.when_epoch or 0) > (b.when_epoch or 0) end)
  while #order > 12 do table.remove(order) end
  for _, r in ipairs(order) do
    if r.when_epoch then r.when = os.date("%Y-%m-%dT%H:%M:%S", r.when_epoch) end
  end
  return order
end

function sxdash.wer_json(list)
  local rows = {}
  for _, r in ipairs(list or {}) do
    local p = { '"event_type":' .. sxdash.jstr(r.event_type or "APPCRASH"), '"app":' .. sxdash.jstr(r.app) }
    if r.app_version then p[#p + 1] = '"app_version":' .. sxdash.jstr(r.app_version) end
    if r.module then p[#p + 1] = '"module":' .. sxdash.jstr(r.module) end
    if r.code then p[#p + 1] = '"code":' .. sxdash.jstr(r.code) end
    if r.when then p[#p + 1] = '"when":' .. sxdash.jstr(r.when) end
    p[#p + 1] = '"count":' .. tostring(r.count or 1)
    rows[#rows + 1] = "{" .. table.concat(p, ",") .. "}"
  end
  return "[" .. table.concat(rows, ",") .. "]"
end

-- Which target does a (provider, event id) pair belong to? nil = not one of ours (skip it).
function sxdash.winevt_kind(provider, id)
  local p = tostring(provider or ""):lower()
  id = tonumber(id)
  if not id then return nil end
  if id == 41 and p:find("kernel%-power") then return "power_loss" end
  if id == 6008 and p == "eventlog" then return "dirty_shutdown" end
  if p:find("whea") and (id == 1 or id == 17 or id == 18 or id == 19) then return "whea" end
  if (p == "disk" and (id == 7 or id == 11 or id == 51 or id == 153)) or (p == "ntfs" and id == 55) then return "disk_error" end
  if id == 4101 and p == "display" then return "gpu_tdr" end
  if id == 1001 and (p:find("systemerrorreporting") or p == "bugcheck") then return "bluescreen" end
  return nil
end

-- One rendered event XML -> { kind, provider, id, when, detail } or nil. Lua patterns, no XML
-- lib: we only need the Provider name, EventID, SystemTime, and (for TDR/bluescreen) the first
-- EventData value (the driver name / the stop code).
function sxdash.winevt_parse(xml)
  local t = tostring(xml or "")
  local provider = t:match('<Provider Name="([^"]+)"') or t:match("<Provider Name='([^']+)'")
  local id = tonumber(t:match("<EventID[^>]*>(%d+)</EventID>"))
  if not provider or not id then return nil end
  local kind = sxdash.winevt_kind(provider, id)
  if not kind then return nil end
  local st = t:match('SystemTime="([^"]+)"') or t:match("SystemTime='([^']+)'")
  local row = { kind = kind, provider = provider, id = id, when = st and sxdash.utc_to_local(st) or nil }
  if kind == "gpu_tdr" then
    row.detail = t:match("<Data[^>]*>%s*([%w_%-%.]+)%s*</Data>")
  elseif kind == "bluescreen" then
    local d = t:match("<Data[^>]*>%s*(0x%x+[^<]*)")
    if d then row.detail = d:gsub("%s+$", ""):sub(1, 60) end
  elseif kind == "disk_error" then
    -- disk 51/153 name the failing device as \Device\HarddiskN\DRN - N is the physical-disk
    -- index (matches Windows' Disk N). Keep it so we can later resolve N to the real drive
    -- letter; without this the read has no way to know which drive and would be guessing.
    local dn = t:match("[Hh]arddisk(%d+)")
    if dn then row.disk = tonumber(dn) end
  end
  return row
end

-- Map each physical-disk index (the N in \Device\HarddiskN, as disk errors report it) to the
-- drive letter(s) living on it, so a disk_error can name the real drive instead of guessing.
-- Pure FFI, no subprocess/console flash: GetLogicalDrives lists mounted letters, then
-- IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS on \\.\X: hands back that volume's backing disk number(s).
-- The handle is opened with 0 access (query only) so it needs no admin and never locks the volume.
-- wevtapi/Win32 absent or locked down = {} (silence, never a wrong answer).
function sxdash.disk_letter_map()
  local map = {}
  if not (ffi_ok and ffi and SX_OS == "Windows" and winapi) then return map end
  pcall(function()
    local mask = tonumber(ffi.C.GetLogicalDrives()) or 0
    if mask == 0 then return end
    local INVALID = ffi.cast("void*", -1)
    local m = mask
    for i = 0, 25 do
      if m % 2 == 1 then
        local letter = string.char(65 + i) .. ":"
        pcall(function()
          local path = sxdash.wstr("\\\\.\\" .. letter)   -- keep alive: the cast below borrows it
          local pathw = ffi.cast("const unsigned short*", ffi.cast("const char*", path))
          local h = ffi.C.CreateFileW(pathw, 0, 3, nil, 3, 0, nil)   -- access 0, share RW, OPEN_EXISTING
          if h ~= nil and h ~= INVALID then
            local ext = ffi.new("SX_VOLUME_DISK_EXTENTS")
            local ret = ffi.new("unsigned long[1]")
            if ffi.C.DeviceIoControl(h, 0x560000, nil, 0, ext, ffi.sizeof("SX_VOLUME_DISK_EXTENTS"), ret, nil) ~= 0 then
              local n = tonumber(ext.NumberOfDiskExtents) or 0
              if n > 16 then n = 16 end
              for e = 0, n - 1 do
                local dn = tonumber(ext.Extents[e].DiskNumber)
                if dn then map[dn] = map[dn] and (map[dn] .. " " .. letter) or letter end
              end
            end
            ffi.C.CloseHandle(h)
          end
        end)
      end
      m = math.floor(m / 2)
    end
  end)
  return map
end

-- Attach real drive letters to disk_error rows. The letter map is built once, and ONLY when a
-- disk row actually carries a physical-disk index, so a clean rig pays nothing for this.
function sxdash.winevt_attach_drives(rows)
  local need = false
  for _, r in ipairs(rows or {}) do
    if type(r) == "table" and r.kind == "disk_error" and r.disk ~= nil then need = true; break end
  end
  if not need then return end
  local map = sxdash.disk_letter_map()
  for _, r in ipairs(rows) do
    if r.kind == "disk_error" and r.disk ~= nil and map[r.disk] then r.drive = map[r.disk] end
  end
end

-- Parsed rows -> the shipped list: newest first, at most 8 rows per kind, 40 total.
function sxdash.winevt_collapse(list)
  local rows = {}
  for _, r in ipairs(list or {}) do
    if type(r) == "table" and r.kind then rows[#rows + 1] = r end
  end
  table.sort(rows, function(a, b) return tostring(a.when or "") > tostring(b.when or "") end)
  local per, out = {}, {}
  for _, r in ipairs(rows) do
    per[r.kind] = (per[r.kind] or 0) + 1
    if per[r.kind] <= 8 and #out < 40 then out[#out + 1] = r end
  end
  return out
end

function sxdash.winevt_json(list)
  local rows = {}
  for _, r in ipairs(list or {}) do
    local p = { '"kind":' .. sxdash.jstr(r.kind), '"provider":' .. sxdash.jstr(r.provider), '"id":' .. tostring(tonumber(r.id) or 0) }
    if r.when then p[#p + 1] = '"when":' .. sxdash.jstr(r.when) end
    if r.detail then p[#p + 1] = '"detail":' .. sxdash.jstr(r.detail) end
    if r.drive then p[#p + 1] = '"drive":' .. sxdash.jstr(r.drive) end
    rows[#rows + 1] = "{" .. table.concat(p, ",") .. "}"
  end
  return "[" .. table.concat(rows, ",") .. "]"
end

-- Samples -> chart series: cumulative frame counters become per-interval loss %
-- (each loss type charts apart because each has a different fix), then everything
-- is thinned to <=150 points per series. Thinning keeps each bucket's MAX so a
-- one-sample spike never disappears into an average. -1 = not measured there.
function sxdash.pack(samples)
  local n = #samples
  if n == 0 then return nil end
  local function gauge(s, k)
    local v = s[k]
    if v == nil or v < 0 then return nil end
    return v
  end
  local function lossp(a, b, kl, kt)
    if not a then return -1 end
    local al, at, bl, bt = a[kl], a[kt], b[kl], b[kt]
    if not al or not bl or al < 0 or bl < 0 or at < 0 or bt < 0 then return -1 end
    local dl, dt = bl - al, bt - at
    if dt <= 0 or dl < 0 then return -1 end   -- counter reset (OBS/stream restart) or no frames
    local p = 100 * dl / dt
    if p > 100 then p = 100 end
    return math.floor(p * 100 + 0.5) / 100
  end
  local rows = {}
  for i = 1, n do
    local s, p = samples[i], (i > 1) and samples[i - 1] or nil
    rows[i] = { t = s.t, cpu = gauge(s, "cpu_sys"), obs = gauge(s, "cpu_obs"), ram = gauge(s, "ram_pct"),
                gpu = gauge(s, "gpu"), tmp = gauge(s, "gpu_temp"), enc = gauge(s, "gpu_enc"),
                lr = lossp(p, s, "lagged", "total"), nd = lossp(p, s, "net_drop", "net_total"),
                es = lossp(p, s, "enc_skip", "enc_total") }
  end
  local KEYS = { "cpu", "obs", "ram", "gpu", "tmp", "enc", "lr", "nd", "es" }
  local out = { t = {} }
  for _, k in ipairs(KEYS) do out[k] = {} end
  local per = math.ceil(n / 150)
  for b = 1, n, per do
    local hi = math.min(b + per - 1, n)
    out.t[#out.t + 1] = rows[hi].t
    for _, k in ipairs(KEYS) do
      local m
      for i = b, hi do
        local v = rows[i][k]
        if v ~= nil and v >= 0 and (m == nil or v > m) then m = v end
      end
      out[k][#out[k] + 1] = m or -1
    end
  end
  return out
end

-- Session-level stats for the across-streams trend rows.
function sxdash.stats(samples)
  local n = #samples
  if n == 0 then return nil end
  local cpu_sum, cpu_n, ram_sum, ram_n, pk_gpu, pk_tmp = 0, 0, 0, 0, -1, -1
  for i = 1, n do
    local s = samples[i]
    if s.cpu_sys and s.cpu_sys >= 0 then cpu_sum = cpu_sum + s.cpu_sys; cpu_n = cpu_n + 1 end
    if s.ram_pct and s.ram_pct >= 0 then ram_sum = ram_sum + s.ram_pct; ram_n = ram_n + 1 end
    if s.gpu and s.gpu > pk_gpu then pk_gpu = s.gpu end
    if s.gpu_temp and s.gpu_temp > pk_tmp then pk_tmp = s.gpu_temp end
  end
  -- Whole-session loss % per type: last valid counter pair minus first. If a counter
  -- reset mid-session, fall back to the sum of the positive deltas.
  local function span_loss(kl, kt)
    local fl, ft, ll, lt, pl, pt, sdl, sdt
    sdl, sdt = 0, 0
    for i = 1, n do
      local s = samples[i]
      local l, tt = s[kl], s[kt]
      if l and tt and l >= 0 and tt >= 0 then
        if not fl then fl, ft = l, tt end
        if pl and l >= pl and tt > pt then sdl = sdl + (l - pl); sdt = sdt + (tt - pt) end
        pl, pt, ll, lt = l, tt, l, tt
      end
    end
    if not fl then return -1 end
    local dl, dt = ll - fl, lt - ft
    if dl < 0 or dt <= 0 then dl, dt = sdl, sdt end
    if dt <= 0 then return -1 end
    local p = 100 * math.max(0, dl) / dt
    if p > 100 then p = 100 end
    return math.floor(p * 100 + 0.5) / 100
  end
  return { avg_cpu = (cpu_n > 0) and math.floor(cpu_sum / cpu_n + 0.5) or -1,
           avg_ram = (ram_n > 0) and math.floor(ram_sum / ram_n + 0.5) or -1,
           pk_gpu = pk_gpu, pk_tmp = pk_tmp,
           loss_r = span_loss("lagged", "total"),
           loss_n = span_loss("net_drop", "net_total"),
           loss_e = span_loss("enc_skip", "enc_total") }
end

-- Protected-session manifest for the premium history gate: one archived-session
-- filename per line. nil = no manifest yet (first run with the gate).
function sxdash.man_read()
  if not sxdash.man_path then return nil end
  local txt = read_file(sxdash.man_path)
  if txt == nil then return nil end
  local set = {}
  for line in (txt .. "\n"):gmatch("(.-)\n") do
    if line ~= "" then set[line] = true end
  end
  return set
end

function sxdash.man_write(set)
  if not sxdash.man_path then return end
  local names = {}
  for nm in pairs(set) do names[#names + 1] = nm end
  table.sort(names, function(a, b) return a > b end)
  sx_write(sxdash.man_path, table.concat(names, "\n"))
end

-- Seed the in-memory ring from the tail of health.jsonl once at load, so the dock
-- has charts right away instead of starting empty every OBS launch.
function sxdash.seed()
  if not health_path then return end
  pcall(function()
    local f = io.open(health_path, "r")
    if not f then return end
    local sz = f:seek("end") or 0
    local back = math.min(sz, 700000)   -- ~ the ring's worth of tail
    f:seek("set", sz - back)
    local txt = f:read("*a") or ""
    f:close()
    if back < sz then txt = txt:gsub("^[^\n]*\n", "") end   -- drop the partial first line
    sxdash.buf = sxdash.parse_text(txt)
  end)
end

-- Across-streams trend rows from the archived per-stream sidecars. Slow scan only,
-- cached per session: a written sidecar never changes except the newest one, which
-- the cache catches via its size.
function sxdash.trend_scan()
  pcall(function()
    local arc = sx_archive_dir(); if not arc then return end
    local rows = {}
    for _, nm in ipairs(sx_list_desc(arc)) do
      if #rows >= ARCHIVE_KEEP then break end
      if sx_is_session(nm) then
        local base = nm:gsub("%.txt$", "")
        local hp = arc .. base .. ".health.txt"
        local szf = file_size(hp)
        if szf and szf > 0 then
          local c = sxdash.tcache[base]
          if not (c and c.size == szf) then
            local smp = sxdash.parse_text(read_file(hp))
            -- stats feed the trend rows; the packed series doubles as the hosted
            -- dashboard's upload payload (phase 8), parsed once per sidecar
            c = { size = szf, stats = sxdash.stats(smp), series = sxdash.pack(smp) }
            sxdash.tcache[base] = c
          end
          if c.stats then rows[#rows + 1] = { label = log_disp(nm), stats = c.stats, base = base } end
        end
      end
    end
    sxdash.trend = rows   -- newest first
  end)
end

-- The picked streamed session's series (slow scan): the archive sidecar when it
-- exists, else a fresh slice of the rolling sampler file for that window.
function sxdash.pick_update(chosen, meta)
  sxdash.pick = nil
  pcall(function()
    if not (chosen and meta and meta.streamed and meta.start_secs) then return end
    local base = chosen:gsub("%.txt$", "")
    local text
    local arc = sx_archive_dir()
    if arc then text = read_file(arc .. base .. ".health.txt") end
    if not text or text == "" then
      local y, mo, d = chosen:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)")
      if not y then return end
      local day0 = os.time({ year = tonumber(y), month = tonumber(mo), day = tonumber(d), hour = 0, min = 0, sec = 0 })
      if not day0 then return end
      local lo_u = day0 + meta.start_secs - 180
      text = sx_slice_health(lo_u, lo_u + (meta.dur_secs or 21600) + 360)
    end
    local series = sxdash.pack(sxdash.parse_text(text))
    if series then sxdash.pick = { label = log_disp(chosen), dur = meta.dur, series = series } end
  end)
end

-- Phase 8: the hosted premium dashboard's upload hand-off. This script has no
-- HTTP client, so it writes streamauditx-upload.js next to the panel and the
-- PANEL posts each session to the server. The file exists with a payload ONLY
-- when the member is premium, the product-improvement consent toggle is on, and
-- the Expert Read page has mirrored the scoped upload key; anything else writes
-- an empty marker so a stale payload never lingers. The server re-checks tier +
-- consent on every upload, so this can never leak a free user's data.
function sxdash.upload_write()
  if not sxdash.js_path then return end
  local up_path = sxdash.js_path:gsub("streamauditx%-health%.js$", "streamauditx-upload.js")
  local key = sxdash.upkey
  if not (key and sx_is_premium() and sx_consent) then
    pcall(function()
      local f = io.open(up_path, "w")
      if f then f:write("window.SX_UPLOAD=null;") f:close() end
    end)
    return
  end
  pcall(function()
    local parts = {}
    for _, row in ipairs(sxdash.trend or {}) do
      local c = row.base and sxdash.tcache[row.base]
      if c and c.series then
        local sj = {}
        for _, k in ipairs({ "t", "cpu", "obs", "ram", "gpu", "tmp", "enc", "lr", "nd", "es" }) do
          sj[#sj + 1] = '"' .. k .. '":[' .. table.concat(c.series[k], ",") .. "]"
        end
        parts[#parts + 1] = '{"s":' .. jesc(row.base) .. ',"label":' .. jesc(row.label or "") ..
          ',"series":{' .. table.concat(sj, ",") .. "}}"
      end
    end
    local f = io.open(up_path, "w")
    if f then
      f:write('window.SX_UPLOAD={"key":' .. jesc(key) .. ',"api":"https://streamauditx.strmrx.com","sessions":[' ..
        table.concat(parts, ",") .. ']};if(window.sxUploadKick)try{window.sxUploadKick()}catch(e){}')
      f:close()
    end
  end)
end

-- Serialize everything the dashboard needs into streamauditx-health.js. Runs on the
-- sampler timer (8s): tiny file, local disk, and it is what keeps the charts moving
-- while the audit scan is paused mid-stream. Manual Pause stops the sampler, so it
-- stops this too — that promise stays absolute.
function sxdash.write()
  if not sxdash.js_path then return end
  pcall(function()
    local n = #sxdash.buf
    -- Chart window: the current live run when streaming (walk back over contiguous
    -- live samples, keep one earlier sample so the first loss delta exists), else
    -- the last 45 minutes.
    local cur, mode = {}, "recent"
    if n > 0 then
      if health_last and health_last.live then
        mode = "live"
        local i = n
        while i > 1 and sxdash.buf[i - 1].live do i = i - 1 end
        for j = math.max(1, i - 1), n do cur[#cur + 1] = sxdash.buf[j] end
      else
        local cutoff = os.time() - 45 * 60
        for j = 1, n do if sxdash.buf[j].t >= cutoff then cur[#cur + 1] = sxdash.buf[j] end end
      end
    end
    local function series_json(p)
      if not p then return "null" end
      local ks = { "t", "cpu", "obs", "ram", "gpu", "tmp", "enc", "lr", "nd", "es" }
      local o = {}
      for _, k in ipairs(ks) do o[#o + 1] = '"' .. k .. '":[' .. table.concat(p[k], ",") .. "]" end
      return "{" .. table.concat(o, ",") .. "}"
    end
    local h = health_last or {}
    local function nv(v) if v == nil then return -1 end return v end
    local parts = {}
    parts[#parts + 1] = '"now":{"t":' .. nv(h.t) .. ',"cpu":' .. nv(h.cpu_sys) .. ',"obs":' .. nv(h.cpu_obs) ..
      ',"ram":' .. nv(h.ram_pct) .. ',"ram_free":' .. nv(h.ram_free_gb) ..
      ',"gpu":' .. nv(h.gpu) .. ',"tmp":' .. nv(h.gpu_temp) .. ',"vram":' .. nv(h.vram_pct) ..
      ',"vram_gb":' .. nv(h.vram_gb) .. ',"encl":' .. nv(h.gpu_enc) .. ',"pow":' .. nv(h.gpu_pow) ..
      ',"ctmp":' .. nv(h.cpu_temp) ..
      ',"live":' .. (h.live and "true" or "false") ..
      ',"sat_cpu":' .. (sx_sat.cpu and "true" or "false") ..
      ',"sat_gpu":' .. (sx_sat.gpu and "true" or "false") ..
      ',"sat_hot":' .. (sx_sat.hot and "true" or "false") .. "}"
    parts[#parts + 1] = '"mode":"' .. mode .. '"'
    parts[#parts + 1] = '"cur":' .. series_json(sxdash.pack(cur))
    if mode == "live" and #cur > 0 then
      parts[#parts + 1] = '"since":' .. cur[1].t   -- stream start, for the live duration readout
    end
    if sxdash.pick then
      parts[#parts + 1] = '"pick":{"label":' .. jesc(sxdash.pick.label or "") ..
        (sxdash.pick.dur and (',"dur":' .. jesc(sxdash.pick.dur)) or "") ..
        ',"series":' .. series_json(sxdash.pick.series) .. "}"
    end
    -- Across-streams trend: newest-first rows. Show EVERY stream we actually kept on disk, whatever the
    -- tier: retention is already gated by archive_prune (free/unknown keeps FREE_KEEP going forward +
    -- anything grandfathered; premium keeps ARCHIVE_KEEP), so #rows is the true history and hiding some
    -- of it behind the tier just reads as lost data. The premium pitch lives in the trend note, not here.
    local rows = sxdash.trend or {}
    local prem = sx_is_premium()
    local lim = #rows
    local tr = {}
    for i = 1, lim do
      local s = rows[i].stats
      tr[#tr + 1] = '{"label":' .. jesc(rows[i].label) .. ',"cpu":' .. s.avg_cpu .. ',"ram":' .. s.avg_ram ..
        ',"gpu":' .. s.pk_gpu .. ',"tmp":' .. s.pk_tmp ..
        ',"lr":' .. s.loss_r .. ',"nd":' .. s.loss_n .. ',"es":' .. s.loss_e .. "}"
    end
    parts[#parts + 1] = '"trend":[' .. table.concat(tr, ",") .. "]"
    parts[#parts + 1] = '"trend_total":' .. #rows
    parts[#parts + 1] = '"premium":' .. (prem and "true" or "false")
    parts[#parts + 1] = '"tier":"' .. sx_tier .. '"'
    parts[#parts + 1] = '"tierKnown":' .. (sx_tier_known and "true" or "false")
    parts[#parts + 1] = '"kept":' .. (sxdash.kept or 0)
    parts[#parts + 1] = '"cap":' .. (prem and ARCHIVE_KEEP or sxdash.FREE_KEEP)
    parts[#parts + 1] = '"capPremium":' .. ARCHIVE_KEEP
    local f = io.open(sxdash.js_path, "w")
    if f then f:write("window.SX_HEALTH={" .. table.concat(parts, ",") .. "};if(window.sxHealthPaint)try{window.sxHealthPaint()}catch(e){}if(window.sxHealthMirror)try{window.sxHealthMirror()}catch(e){}") f:close() end
  end)
end

-- Build the whole-setup snapshot the hosted Expert Read diagnoses: the live rule
-- findings, this session's log, and a little system context. Returned as a JSON
-- string built by concatenation (NOT inside a [[ ]] literal), so a "]]" in the log
-- is harmless string data, never a source-level long-string terminator.
local function build_expert_snapshot()
  local obsver = ""
  safe(function() obsver = obs.obs_get_version_string() or "" end)
  local parts = {}
  parts[#parts + 1] = '"source":"obs-dock"'
  parts[#parts + 1] = '"system":{"obs":' .. jesc(obsver) ..
    ',"canvas":' .. jesc(tostring(canvas_w or "") .. "x" .. tostring(canvas_h or "")) .. '}'
  if expert_log_raw and expert_log_raw ~= "" then
    -- Send the whole session log so the hosted read sees everything (the read is the ceiling,
    -- not a keyword slice). Only a very large multi-hour log is capped, and we keep the TAIL
    -- where a session's ending problems land, flagging that we capped so nothing looks silently
    -- dropped. The server compresses only if it is still enormous, never by keyword.
    local lg, capped = expert_log_raw, false
    if #lg > 500000 then lg = lg:sub(-500000); capped = true end
    local sm = audit_log_meta[audit_log_name or ""]
    parts[#parts + 1] = '"log":{"name":' .. jesc(audit_log_name or "") ..
      ',"streamed":' .. ((sm and sm.streamed) and "true" or "false") ..
      ((sm and sm.dur) and (',"streamDuration":' .. jesc(sm.dur)) or "") ..
      ',"capped":' .. (capped and "true" or "false") .. ',"raw":' .. jesc(lg) .. '}'
  end
  -- Companion apps: when auto-collected, ship their logs so the server runs the holistic whole-setup
  -- read (2+ surfaces). Shape is { name, raw } to match buildHolisticDigest on the server.
  if comp.sb_raw and comp.sb_raw ~= "" then
    local lg, capped = comp.sb_raw, false
    if #lg > 500000 then lg = lg:sub(-500000); capped = true end
    parts[#parts + 1] = '"streamerbot":{"name":' .. jesc(comp.sb_name or "") ..
      ',"capped":' .. (capped and "true" or "false") .. ',"raw":' .. jesc(lg) .. '}'
  end
  if comp.lumia_raw and comp.lumia_raw ~= "" then
    local lg, capped = comp.lumia_raw, false
    if #lg > 500000 then lg = lg:sub(-500000); capped = true end
    parts[#parts + 1] = '"lumia":{"name":' .. jesc(comp.lumia_name or "") ..
      ',"capped":' .. (capped and "true" or "false") .. ',"raw":' .. jesc(lg) .. '}'
  end
  if comp.miu_raw and comp.miu_raw ~= "" then
    local lg, capped = comp.miu_raw, false
    if #lg > 500000 then lg = lg:sub(-500000); capped = true end
    parts[#parts + 1] = '"mixitup":{"name":' .. jesc(comp.miu_name or "") ..
      ',"capped":' .. (capped and "true" or "false") .. ',"raw":' .. jesc(lg) .. '}'
  end
  if comp.fb_raw and comp.fb_raw ~= "" then
    local lg, capped = comp.fb_raw, false
    if #lg > 500000 then lg = lg:sub(-500000); capped = true end
    parts[#parts + 1] = '"firebot":{"name":' .. jesc(comp.fb_name or "") ..
      ',"capped":' .. (capped and "true" or "false") .. ',"raw":' .. jesc(lg) .. '}'
  end
  if comp.vm_raw and comp.vm_raw ~= "" then
    local vg = comp.vm_raw
    if #vg > 500000 then vg = vg:sub(-500000) end
    parts[#parts + 1] = '"voicemeeter":{"name":' .. jesc(comp.vm_name or "") .. ',"raw":' .. jesc(vg) .. '}'
  end
  -- TikTok LIVE Studio: the external-capture multistream leg (OBS Virtual Camera -> TikTok LIVE Studio
  -- -> TikTok), which the OBS log cannot see. main/renderer are already filtered to the streaming lines
  -- and id-redacted on-device; shape { name, main, renderer, crashLog } matches parseTikTokLiveStudio.
  if (comp.tt_main and comp.tt_main ~= "") or (comp.tt_renderer and comp.tt_renderer ~= "") then
    local tp = { '"name":' .. jesc(comp.tt_name or "") }
    if comp.tt_main and comp.tt_main ~= "" then tp[#tp + 1] = '"main":' .. jesc(comp.tt_main) end
    if comp.tt_renderer and comp.tt_renderer ~= "" then tp[#tp + 1] = '"renderer":' .. jesc(comp.tt_renderer) end
    if comp.tt_crash and comp.tt_crash ~= "" then tp[#tp + 1] = '"crashLog":' .. jesc(comp.tt_crash) end
    parts[#parts + 1] = '"tiktok":{' .. table.concat(tp, ",") .. '}'
  end
  -- OBS crash report: ship the raw crash file so the server can classify what crashed, plus the recent
  -- crash filenames so it can speak to frequency. Rides WITH the OBS log (not a separate surface).
  if comp.crash_raw and comp.crash_raw ~= "" then
    local cg = comp.crash_raw
    if #cg > 500000 then cg = cg:sub(-500000) end
    -- recent = the FULL recent crash set, each with its timestamp + live/not-live flag + culprit, so
    -- the read leads with a live crash even if the newest (whose raw we ship) was idle. Falls back to
    -- bare filenames if the enriched list is somehow absent.
    local rec = {}
    if comp.crash_list and #comp.crash_list > 0 then
      for _, e in ipairs(comp.crash_list) do
        local one = '{"name":' .. jesc(e.name or "")
        if e.when then one = one .. ',"when":' .. jesc(e.when) end
        if e.state then one = one .. ',"state":' .. jesc(e.state) end
        one = one .. ',"wasLive":' .. (e.was_live and "true" or "false")
        if e.component then one = one .. ',"component":' .. jesc(e.component) end
        rec[#rec + 1] = one .. "}"
      end
    else
      for _, nm in ipairs(comp.crash_recent or {}) do rec[#rec + 1] = '{"name":' .. jesc(nm) .. '}' end
    end
    parts[#parts + 1] = '"crashes":{"name":' .. jesc(comp.crash_name or "") ..
      ',"raw":' .. jesc(cg) .. ',"streamState":' .. jesc(comp.crash_stream_state or "unknown") ..
      ',"recent":[' .. table.concat(rec, ",") .. ']}'
  end
  local fj = {}
  for _, f in ipairs(findings or {}) do
    -- Skip the free dock's crash finding: the server parses the raw crash file into its own
    -- authoritative crash finding, so including this one too would double-report the crash.
    if f.cat ~= "crash" then
      local one = '{"severity":' .. jesc(f.sev) .. ',"category":' .. jesc(f.cat) ..
        ',"title":' .. jesc(f.title) .. ',"why":' .. jesc(f.why) .. ',"fix":' .. jesc(f.fix)
      if f.scene and f.scene ~= "" then one = one .. ',"scene":' .. jesc(f.scene) end
      fj[#fj + 1] = one .. "}"
    end
  end
  if rig_inv_json and rig_inv_json ~= "" and rig_inv_json ~= "{}" then
    safe(function()
    -- This session's PC health series (last 6 hours, thinned): lets the read say what the
    -- PC was doing at the exact minute frames lagged.
    local hraw = sx_slice_health(os.time() - 21600, os.time() + 60)
    if hraw ~= "" then parts[#parts + 1] = '"health":{"raw":' .. jesc(hraw) .. '}' end
  end)
  -- Verified Windows tuning state (phase 7): read live from the registry at the
  -- last slow scan, so the read can VERIFY the optimization checklist instead of
  -- guessing from log-time state.
  if sxdash.tuning and sxdash.tuning.snap_json and sxdash.tuning.snap_json ~= "{}" then
    parts[#parts + 1] = '"windowsTuning":' .. sxdash.tuning.snap_json
  end
  parts[#parts + 1] = '"rig":' .. rig_inv_json
  end
  -- Installed GPUs (name + VRAM), read from the registry on the last slow scan. Ships even
  -- when the rig inventory is empty: a two-GPU rig / wrong-GPU render is worth catching on
  -- its own. The server compares this to the adapter OBS actually loaded (from the log).
  if sxdash.gpus and #sxdash.gpus > 0 then
    local gj = {}
    for _, g in ipairs(sxdash.gpus) do
      local o = '{"name":' .. jesc(g.name)
      if g.vram_gb then o = o .. ',"vram_gb":' .. tostring(g.vram_gb) end
      gj[#gj + 1] = o .. "}"
    end
    parts[#parts + 1] = '"gpus":[' .. table.concat(gj, ",") .. "]"
  end
  -- Windows-wide crash + machine records (phases 2-3), read on the slow scan: WER's machine-wide
  -- crash history (exe/module basenames only, never a path or username) and the System event
  -- log's serious records (unexpected shutdowns, WHEA, disk errors, GPU TDR resets, bluescreens).
  -- The JSON is built by the same pure sxdash builders the off-OBS harness tests.
  if sxdash.wer and #sxdash.wer > 0 then
    parts[#parts + 1] = '"werReports":' .. sxdash.wer_json(sxdash.wer)
  end
  if sxdash.wevents and #sxdash.wevents > 0 then
    parts[#parts + 1] = '"winEvents":' .. sxdash.winevt_json(sxdash.wevents)
  end
  parts[#parts + 1] = '"ruleCheck":{"findings":[' .. table.concat(fj, ",") .. "]}"
  return "{" .. table.concat(parts, ",") .. "}"
end

-- Scoring v2: the ONE scoring brain, embedded verbatim so the dock computes the same
-- lanes/bands/why-lines as the server reads and Log Check (SSOT law). This block is a
-- character-identical copy of tools/obs-audit/web/scoring.js: dev/run-harness.js fails
-- the build if the two differ by a single character. Never edit it here; edit the
-- canonical file, re-copy, and let the harness prove the copy.
-- SX-SCORING-EMBED-BEGIN
SX_SCORING_JS = [==[
// StreamAuditX shared scoring (Scoring v2) - THE one scoring brain (SSOT law).
// Consumed by: server reads (direct import), Log Check (script tag), and the dock panel
// (embedded verbatim, pinned character-identical by the dev harness). Pure: no clock,
// no I/O; callers gather the facts, this module does ALL lane math, banding, and the
// why-line breakdown. Contract + Joey's product calls: docs/scoring-v2-design.md.
// Severity default weights mirror schema/registry.json enums.severity.score_weight
// (pinned by server/read/scoring.test.mjs).
(function (root) {
  'use strict';

  var DEFAULT_WEIGHT = { critical: 20, warning: 3 };

  // Per-finding stakes overrides: the id wins over the severity default. Keep this table
  // small and deliberate; a missing id simply means "the severity default is right".
  var WEIGHTS = {
    'mic-muted-live-scene': 25,   // a dead mic in a live scene outranks a generic critical
    'widget-reload': 3,
    'unused-audio-track': 2
  };

  var BAND_ORDER = { failing: 0, attention: 1, fair: 2, good: 3, great: 4 };

  function weightFor(f) {
    if (f && typeof f.weight === 'number') return f.weight;
    if (f && Object.prototype.hasOwnProperty.call(WEIGHTS, f.id)) return WEIGHTS[f.id];
    return (f && DEFAULT_WEIGHT[f.severity]) || 0;
  }

  // Fix-forgiveness (the broken-streak law applied to the score): a finding absent from
  // the newest clean_since sessions fades to zero over 3 clean sessions, then flips to a
  // credit line. Live dock findings never carry clean_since (they are present right now).
  function fadeFactor(cleanSince) {
    if (!cleanSince || cleanSince <= 0) return 1;
    var left = (3 - cleanSince) / 3;
    return left > 0 ? left : 0;
  }

  function clampScore(s) { if (s < 15) s = 15; if (s > 100) s = 100; return Math.round(s); }

  function bandFor(score, hasCritical, gated) {
    if (gated) return 'failing';
    var b = score >= 85 ? 'great' : score >= 65 ? 'good' : score >= 40 ? 'fair' : 'attention';
    if (hasCritical && BAND_ORDER[b] > BAND_ORDER.fair) b = 'fair';
    return b;
  }

  function worseBand(a, b) {
    if (a == null) return b; if (b == null) return a;
    return BAND_ORDER[a] <= BAND_ORDER[b] ? a : b;
  }

  function scoreFindings(findings, why) {
    var total = 0, hasCritical = false;
    var list = findings || [];
    for (var i = 0; i < list.length; i++) {
      var f = list[i]; if (!f) continue;
      var w = weightFor(f);
      if (w <= 0) continue;
      var faded = Math.round(w * fadeFactor(f.clean_since) * 10) / 10;
      if (faded <= 0) {
        why.push({ id: f.id, kind: 'credit', points: 0,
          label: (f.label || f.id) + ' · gone from your newest sessions, no points taken' });
        continue;
      }
      if (f.severity === 'critical') hasCritical = true;
      var note = (f.clean_since > 0) ? ' · fading, ' + f.clean_since + ' clean since' : '';
      why.push({ id: f.id, kind: 'deduct', points: -faded, label: (f.label || f.id) + note });
      total += faded;
    }
    return { total: total, hasCritical: hasCritical };
  }

  // Pooled resource drag (config lane): resource-flavored polish only costs when it piles
  // up. The first 2 drag points are free (a single tidy item never dings), the pool caps
  // at 5, and it is always ONE why-line, never per-item deductions.
  function resourceDrag(items, why) {
    var list = items || [];
    if (!list.length) return 0;
    var pool = 0;
    for (var i = 0; i < list.length; i++) {
      var it = list[i]; if (!it) continue;
      pool += (typeof it.drag === 'number' ? it.drag : 1);
    }
    var cost = Math.min(5, Math.max(0, Math.round(pool - 2)));
    if (cost > 0) {
      why.push({ id: 'resource-drag', kind: 'deduct', points: -cost,
        label: 'Resource drag · ' + list.length + ' resource-hungry extras running together' });
    } else {
      why.push({ id: 'resource-drag', kind: 'ok', points: 0,
        label: String(list.length) + ' resource extra' + (list.length === 1 ? '' : 's') + ' · not enough together to cost points' });
    }
    return cost;
  }

  // Crash recency ladder. Tier from the descriptor, then was_live === false demotes ONE
  // tier (a dev-time crash is never headlined as if it hit viewers, and never silent
  // while the same code path runs live). Signature = faulting module + trigger; the
  // CALLER pools repeat_count by signature so unrelated crashes never pool.
  function crashTier(c) {
    var t;
    if (c.provably_fixed) t = 4;
    else if ((typeof c.streams_ago === 'number' && c.streams_ago <= 1) || (c.repeat_count || 0) >= 2) t = 1;
    else if (typeof c.streams_ago === 'number' && c.streams_ago <= 3 && !(c.clean_since > 0)) t = 2;
    else t = 3;
    if (c.was_live === false && t < 4) t += 1;
    return t;
  }

  function crashWhen(c) { return c.when ? ' (' + c.when + ')' : ''; }

  function applyCrashes(crashes, why) {
    var list = crashes || [];
    var total = 0, gate = null;
    for (var i = 0; i < list.length; i++) {
      var c = list[i]; if (!c) continue;
      var name = c.label || 'OBS crashed';
      var dev = (c.was_live === false) ? ' · happened while you were not live' : '';
      var t = crashTier(c);
      if (t === 1) {
        var repeat = (c.repeat_count || 0) >= 2;
        var reason = repeat
          ? 'OBS crashed ' + c.repeat_count + ' of your recent streams the same way. Fix this before anything else.'
          : 'OBS crashed your last stream. Fix this before anything else.';
        gate = { id: 'crash-gate', signature: c.signature || null, label: reason };
        why.push({ id: 'crash-' + (c.signature || i), kind: 'deduct', points: null,
          label: name + (repeat ? ' · ' + c.repeat_count + ' recent streams' : ' · in your last stream') + ' · this alone fails the lane' });
      } else if (t === 2) {
        var ago2 = (typeof c.streams_ago === 'number') ? c.streams_ago + ' streams ago' : 'recently';
        why.push({ id: 'crash-' + (c.signature || i), kind: 'deduct', points: -30,
          label: name + ' · ' + ago2 + crashWhen(c) + ' · keep an eye out' + dev });
        total += 30;
      } else if (t === 3) {
        var cost = Math.max(0, 24 - 6 * (c.clean_since || 0));
        var ago3 = (typeof c.streams_ago === 'number') ? c.streams_ago + ' streams ago' : 'a while back';
        var since = (c.clean_since || 0) + ' clean stream' + (c.clean_since === 1 ? '' : 's') + ' since';
        if (cost > 0) {
          why.push({ id: 'crash-' + (c.signature || i), kind: 'deduct', points: -cost,
            label: name + ' · ' + ago3 + crashWhen(c) + ' · ' + since + dev });
          total += cost;
        } else {
          why.push({ id: 'crash-' + (c.signature || i), kind: 'note', points: 0,
            label: name + ' · ' + ago3 + crashWhen(c) + ' · ' + since + ' · cannot confirm the fix yet, watching' + dev });
        }
      } else {
        why.push({ id: 'crash-' + (c.signature || i), kind: 'credit', points: 0,
          label: name + crashWhen(c) + ' · fixed since (' + (c.fix_evidence || 'traced cause gone from your newest evidence') + '), no points taken' + dev });
      }
    }
    return { total: total, gate: gate };
  }

  function scoreLanes(input) {
    var inp = input || {};
    var cfg = inp.config || {};
    var mea = inp.measured || {};

    var cfgWhy = [];
    var c = scoreFindings(cfg.findings, cfgWhy);
    var drag = resourceDrag(cfg.resource, cfgWhy);
    var cfgScore = clampScore(100 - c.total - drag);
    var config = { score: cfgScore, band: bandFor(cfgScore, c.hasCritical, false),
      hasCritical: c.hasCritical, why: cfgWhy };

    var measured;
    if (mea.checked === false) {
      measured = { score: null, band: null, gate: null, checked: false,
        why: [{ id: 'not-checked', kind: 'note', points: 0,
          label: 'No measured stream data yet · this lane fills in after your next stream' }] };
    } else {
      var meaWhy = [];
      var m = scoreFindings(mea.findings, meaWhy);
      var cr = applyCrashes(mea.crashes, meaWhy);
      var meaScore = clampScore(100 - m.total - cr.total);
      if (cr.gate) meaScore = Math.min(meaScore, 20);
      measured = { score: meaScore, band: bandFor(meaScore, m.hasCritical, !!cr.gate),
        gate: cr.gate, checked: true, why: meaWhy };
    }

    var overall = {
      score: measured.checked === false ? config.score : Math.min(config.score, measured.score),
      band: worseBand(config.band, measured.band)
    };
    return { config: config, measured: measured, overall: overall };
  }

  var api = {
    scoreLanes: scoreLanes,
    weightFor: weightFor,
    crashTier: crashTier,
    fadeFactor: fadeFactor,
    bandFor: bandFor,
    DEFAULT_WEIGHT: DEFAULT_WEIGHT,
    WEIGHTS: WEIGHTS
  };
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
  else root.SXScoring = api;
})(typeof self !== 'undefined' ? self : this);
]==]
-- SX-SCORING-EMBED-END

-- One Full System Health sub-meter for a companion app, reflecting its auto-collection status.
-- Framed as connecting to the APP, never "give us your logs". The connect/re-connect states are a
-- CLICKABLE pill: clicking opens a branded popup with the exact in-OBS steps (no site-jargon like
-- "Tools > Scripts", which reads like a menu on the website). `key` identifies the app to the popup.
-- Returns a self-contained <div> with no `]]` so it can be interpolated into the panel HTML string.
local function companion_meter(name, key, status)
  local cls, tag
  if status == "ok" then
    cls = "meter ok"
    tag = '<span class="m-tag m-ok">included in your Expert Read \226\156\147</span>'
  elseif status == "connect" then
    cls = "meter off"
    tag = '<button type="button" class="m-connect" onclick="sxConnectHelp(\'' .. key .. '\',\'connect\')">Connect</button>'
  elseif status == "moved" then
    cls = "meter warn"
    tag = '<button type="button" class="m-connect m-connect-warn" onclick="sxConnectHelp(\'' .. key .. '\',\'moved\')">Re-connect</button>'
  else -- none / not detected
    cls = "meter off"
    tag = '<span class="m-tag">not detected</span>'
  end
  return '<div class="' .. cls .. '"><span class="m-name">' .. name .. '</span>' .. tag .. '</div>'
end

local function render(list, smw)
  smw = smw or { enabled = false }
  table.sort(list, function(a, b)
    if SEV_ORDER[a.sev] ~= SEV_ORDER[b.sev] then return SEV_ORDER[a.sev] < SEV_ORDER[b.sev] end
    local at, bt = (a.tidy and 1 or 0), (b.tidy and 1 or 0)
    if at ~= bt then return at < bt end          -- real findings sort ahead of tidy-up within a tier
    local ai, bi = (a.impact or 0), (b.impact or 0)
    if ai ~= bi then return ai > bi end           -- higher impact first (things affecting OBS lead)
    return a.title < b.title
  end)

  local counts = { critical = 0, warning = 0, tip = 0 }
  for _, f in ipairs(list) do counts[f.sev] = (counts[f.sev] or 0) + 1 end
  local passed = #goods
  local score = compute_score(passed, counts.critical, counts.warning)
  local btext, bcolor = band(score, counts.critical)

  -- kind: issue (crit/warn) | sug (tip) | healthy (good)
  local cards = {}
  local last_head = nil
  local function head(sev)
    if last_head ~= sev then
      last_head = sev
      table.insert(cards, "<div class='sec sec-" .. sev .. "' data-sevhead='" .. sev .. "'>" .. (SEV_HEAD[sev] or "") .. "</div>")
    end
  end
  for _, f in ipairs(list) do
    local is_tidy = f.tidy and true or false
    local sevattr = is_tidy and "tidy" or f.sev
    head(sevattr)
    local kind = is_tidy and "tidy" or ((f.sev == "tip") and "sug" or "issue")
    -- normalize digits out of the title before hashing, so a finding whose title
    -- carries a changing count ("errored 5 times") keeps the SAME id across scans
    -- and a dismissal actually sticks instead of reappearing under a new id
    local fid = short_hash((f.title:gsub("%d+", "#")))
    local jump = ""
    if smw.enabled and f.scene and f.scene ~= "" then
      jump = "<button class='smw-jump' data-scene=\"" .. esc_attr(f.scene) .. "\">Show me →</button>"
    end
    local fixbtn = ""
    if smw.enabled and f.fixa then
      if f.fixa.admin then
        -- run-as-admin fix: no input/settings payload; the click hands a request to the
        -- Lua script over the persistent-data channel (see oa-fixadmin handler)
        fixbtn = "<button class='oa-fixadmin'>⚡ Fix it for me</button>"
      else
        fixbtn = "<button class='oa-fix' data-input=\"" .. esc_attr(f.fixa.input) ..
          "\" data-set='" .. fixa_set_json(f.fixa.set) .. "'>⚡ Fix it for me</button>"
      end
    end
    -- Optional drill-down: a "you have N of X" finding can carry the actual list so the
    -- user doesn't have to hunt. Each row names the item + the scene it's in, with a
    -- per-item "Show me →" jump (reuses the smw-jump handler → SetCurrentProgramScene).
    local drill = ""
    if f.items and f.items.rows and #f.items.rows > 0 then
      local rws = {}
      for _, it in ipairs(f.items.rows) do
        local sc = it.scene
        local scenehtml = (sc and sc ~= "")
          and ("<span class='dscene'>in “" .. esc(sc) .. "”</span>")
          or "<span class='dscene dscene-none'>not in any scene</span>"
        local notehtml = it.note and ("<span class='dnote'>" .. esc(it.note) .. "</span>") or ""
        local jmp = ""
        if smw.enabled and sc and sc ~= "" then
          jmp = "<button class='smw-jump' data-scene=\"" .. esc_attr(sc) .. "\">Show me →</button>"
        end
        local pbtn = ""
        if it.park == "park" then
          pbtn = "<button class='oa-park' data-name=\"" .. esc_attr(it.name) .. "\">◔ I rarely use this</button>"
        elseif it.park == "unpark" then
          pbtn = "<button class='oa-parkrestore' data-name=\"" .. esc_attr(it.name) .. "\">↩ I use this regularly</button>"
        end
        rws[#rws + 1] = "<div class='drow'><span class='dname'>" .. esc(it.name) .. "</span>" ..
          scenehtml .. notehtml .. jmp .. pbtn .. "</div>"
      end
      local labeltext = "the " .. #f.items.rows .. " " .. f.items.label .. (#f.items.rows == 1 and "" or "s")
      drill = "<div class='drill'>" ..
        "<button class='drill-toggle' data-drill='" .. fid .. "' data-label=\"" .. esc_attr(labeltext) .. "\">▸ Show " .. esc(labeltext) .. "</button>" ..
        "<div class='drill-list' data-drilllist='" .. fid .. "' style='display:none'>" .. table.concat(rws, "") .. "</div>" ..
      "</div>"
    end
    local sitbtn = ""
    if f.situational_names and f.situational_names ~= "" then
      sitbtn = "<button class='oa-situational' data-names=\"" .. esc_attr(f.situational_names) .. "\">◔ These are situational</button>"
    end
    if f.park_name and f.park_name ~= "" then
      sitbtn = sitbtn .. "<button class='oa-park' data-name=\"" .. esc_attr(f.park_name) .. "\">◔ I rarely use this</button>"
    end
    if f.park_restore_name and f.park_restore_name ~= "" then
      sitbtn = sitbtn .. "<button class='oa-parkrestore' data-name=\"" .. esc_attr(f.park_restore_name) .. "\">↩ I use this regularly</button>"
    end
    table.insert(cards, "<div class='card " .. sevattr .. "' data-kind='" .. kind .. "' data-sev='" .. sevattr ..
      "' data-cat='" .. f.cat .. "' data-scope='" .. (f.scope or "config") .. "' data-id='" .. fid .. "'>" ..
      "<div class='title'>" .. esc(f.title) .. " <span class='cattag'>" .. (CAT_LABEL[f.cat] or f.cat) .. "</span></div>" ..
      "<div class='why'><b>Why it matters:</b> " .. esc(f.why) .. "</div>" ..
      "<div class='fix'><b>Fix:</b> " .. esc(f.fix) .. "</div>" .. drill ..
      "<div class='cardfoot'>" .. fixbtn .. jump .. sitbtn ..
        "<button class='oa-dismiss' data-id='" .. fid .. "'>✕ Not an issue</button>" ..
        "<button class='oa-restore' data-id='" .. fid .. "'>↩ Bring back</button>" ..
      "</div></div>")
  end
  -- healthy cards
  local gsorted = {}
  for _, g in ipairs(goods) do gsorted[#gsorted + 1] = g end
  table.sort(gsorted, function(a, b) return a.title < b.title end)
  if #gsorted > 0 then
    table.insert(cards, "<div class='sec sec-good' data-sevhead='good'>" .. SEV_HEAD.good .. "</div>")
    for _, g in ipairs(gsorted) do
      table.insert(cards, "<div class='card good' data-kind='healthy' data-sev='good' data-cat='" .. g.cat .. "'>" ..
        "<div class='gtitle'>✓ " .. esc(g.title) .. " <span class='cattag'>" .. (CAT_LABEL[g.cat] or g.cat) .. "</span></div></div>")
    end
  end

  local viewhtml = {}
  for _, v in ipairs(VIEWS) do
    viewhtml[#viewhtml + 1] = "<button class='tab' data-view='" .. v.m .. "'>" .. v.t ..
      " <span class='badge' data-badge='" .. v.m .. "'></span></button>"
  end
  local cathtml = {}
  for _, c in ipairs(CATS) do
    cathtml[#cathtml + 1] = "<button class='chip' data-cat='" .. c.m .. "'>" .. c.t .. "</button>"
  end

  local body = table.concat(cards, "\n")

  -- Transparency: say plainly what the audit reads and which log, so it's never a
  -- surprise that it touches your files. "2026-08-04 10-30-39.txt" -> "2026-08-04 10:30:39".
  -- Session picker: the log line is a dropdown of recent sessions. Picking one hands the
  -- choice back to this script (through OBS's own storage) and it re-reads that session's
  -- log. Newest = the current session; earlier ones are where a past crash or overload
  -- actually lives. Switching needs the WebSocket server on (that's the JS -> Lua channel).
  local pickhtml = nil
  if #audit_log_list > 0 then
    local opts = {}
    for i, n in ipairs(audit_log_list) do
      local label = log_disp(n)
      local m = audit_log_meta[n]
      if m and m.streamed then
        label = label .. " · Streamed" .. (m.dur and (" " .. m.dur) or "")
      else
        label = label .. " · No stream"
      end
      if i == 1 then label = "Current session · " .. label end
      local sel = (n == audit_pick) and " selected" or ""
      opts[#opts + 1] = "<option value='" .. esc_attr(n) .. "'" .. sel .. ">" .. esc(label) .. "</option>"
    end
    local dis = smw.enabled and "" or " disabled title=\"Turn on the WebSocket server (in Tools) to review other sessions\""
    pickhtml = "<select class='logpick'" .. dis .. " onchange=\"sxPickLog(this.value)\">" .. table.concat(opts) .. "</select>"
  end
  -- Session line for the hero: WHICH stream this read is pulled from, with the picker to
  -- switch. Framed as the STREAM (a period of time), never "a log file" (Joey's rule: the
  -- data is that session, not a file). Sits under the score so the number has a clear source.
  local sm = audit_pick and audit_log_meta[audit_pick] or nil
  local is_current = (audit_pick and audit_log_list[1] and audit_pick == audit_log_list[1])
  local when_txt = audit_pick and esc(log_disp(audit_pick)) or "this session"
  local dur_txt = (sm and sm.streamed and sm.dur) and (" &middot; " .. esc(sm.dur)) or ""
  local what_txt
  if sm and sm.streamed then
    what_txt = "Reading your <b>" .. when_txt .. " stream</b>" .. dur_txt
  elseif is_current then
    what_txt = "Reading your <b>live setup</b> right now"
  else
    what_txt = "Reading your <b>" .. when_txt .. " session</b> (not a live stream)"
  end
  local session_line = "<div class='fsh-session'><span class='fsh-when'>" .. what_txt .. "</span>" ..
    (pickhtml and ("<span class='fsh-pick'>" .. pickhtml .. "</span>") or "") ..
    "<span class='live'><i></i> checking live</span></div>"
  -- Slim, honest reassurance (no picker here now, no "log" wording).
  local reads_html = "<div class='reads'><span class='reads-ic'>&#128274;</span><div><b>Everything stays on your PC.</b> This reads your live OBS scenes, sources and filters plus your recent session data, and keeps a private copy of each stream's log on your own machine so you can compare over time. Nothing is uploaded unless you choose to run a read.</div></div>"

  -- Expert Read CTA + snapshot. Static HTML/JS in [[ ]] (no "]]" inside); the snapshot
  -- JSON is concatenated at runtime so a "]]" in a log line can never terminate the string.
  local expert_block = [[<div class="xr-cta" id="xrCta">
  <div class="xr-glow"></div>
  <a class="xr-back" href="#audit">&larr; Back to your full audit</a>
  <div class="xr-cta-head"><span class="xr-badge">&#10022; PREMIUM</span><b>Get an Expert Read</b></div>
  <span class="xr-cta-sub">A deep expert checkup that finds the root cause and the exact fixes the quick scan can't catch.</span>
  <div class="xr-apps" id="xrApps"></div>
  <div class="xr-apps-note">Everything green gets folded into the <b>OBS + apps</b> and <b>Multi stream</b> reads automatically. The <b>OBS only</b> read sticks to OBS. Tap a gray app to see how to connect it.</div>
  <div class="xr-group">
    <div class="xr-ghead">Single stream<span class="xr-gsub">the session you just streamed</span></div>
    <div class="xr-opts">
      <div class="xr-opt">
        <div class="xr-opt-info">
          <div class="xr-opt-name">OBS only<span class="xr-opt-price">199 credits</span></div>
          <div class="xr-opt-desc">Your system, scenes, settings and this session's log, read together: what to fix and exactly how.</div>
        </div>
        <button class="xr-btn xr-btn-sm" id="xrBtnObs" onclick="sxExpertRead(this,'obs')">Read &rarr;</button>
      </div>
      <div class="xr-opt">
        <div class="xr-opt-info">
          <div class="xr-opt-name">OBS + your stream apps<span class="xr-opt-price">299 credits</span></div>
          <div class="xr-opt-desc">Adds every connected app above, so it catches the problems that only show up where two apps meet.</div>
          <div class="xr-opt-note" id="xrFullNote"></div>
        </div>
        <button class="xr-btn xr-btn-sm" id="xrBtnFull" onclick="sxExpertRead(this,'full')">Read &rarr;</button>
      </div>
    </div>
  </div>
  <div class="xr-group xr-group-multi">
    <div class="xr-ghead">Multi stream<span class="xr-gsub">your last several streams together</span><span class="xr-opt-flag">big picture</span></div>
    <div class="xr-opts">
      <div class="xr-opt xr-opt-hero">
        <div class="xr-opt-info">
          <div class="xr-opt-name">OBS + your apps<span class="xr-opt-price">499 credits</span></div>
          <div class="xr-opt-desc">Reads your latest streams across your connected apps and gives a deep report on your whole setup and system health.</div>
          <div class="xr-opt-note" id="xrMultiNote"></div>
        </div>
        <button class="xr-btn xr-btn-sm" id="xrBtnMulti" onclick="sxMultiRead(this)">Read &rarr;</button>
      </div>
    </div>
  </div>
  <span class="xr-err" id="xrErr">Couldn't reach StreamAuditX. Check your connection and try again.</span>
  <a class="xr-allreads" href="#" onclick="event.preventDefault();window.open('https://streamauditx.strmrx.com/read','_blank');">Already ran a read? See every read on your account &rarr;</a>
</div>
<div id="xrPre" class="sx-help" hidden onclick="if(event.target===this)sxPreClose()">
  <div class="sx-help-card" role="dialog" aria-modal="true" aria-labelledby="xrPreTitle">
    <button type="button" class="sx-help-x" onclick="sxPreClose()" aria-label="Close">&times;</button>
    <div class="sx-help-title" id="xrPreTitle">Multi stream read &middot; 499 credits</div>
    <div id="xrPreBody"></div>
    <div class="xr-pre-foot">
      <span class="xr-pre-fine">You confirm and pay in the browser. Charged only when the read runs.</span>
      <button type="button" class="xr-btn xr-btn-sm" id="xrPreGo" onclick="sxMultiGo(this)">Continue &rarr;</button>
    </div>
  </div>
</div>
<script>window.SX_EXPERT=]] .. build_expert_snapshot() .. [[;
window.SX_AGG=]] .. agg_logs_json .. [[;
window.SX_AGG_COUNT=]] .. agg_logs_count .. [[;
window.SX_CONTRIB=]] .. (sx_consent_known and (sx_consent and '{"known":true,"on":true}' or '{"known":true,"on":false}') or '{"known":false,"on":false}') .. [[;
// mode 'obs' = single-stream OBS-only read (199): strip EVERY companion surface so the server tags it single.
// mode 'full' = single-stream OBS + apps read (299): send every currently-connected app's surface.
window.SX_SURFS=['streamerbot','lumia','mixitup','firebot','voicemeeter'];
// Hand the read page a way to talk to THIS OBS (the tier signal channel). Rides as a
// #hash fragment, which the browser never sends to any server: the connect info stays
// on this machine, same as it already does inside this panel. Empty when the
// WebSocket server is off, and everything downstream degrades silently.
window.sxObsLink=function(){
  try{if(window.SMW&&SMW.enabled){return '#obs='+encodeURIComponent(JSON.stringify({p:SMW.port,s:SMW.password,a:!!SMW.auth}));}}catch(e){}
  return '';
};
window.sxExpertRead=function(btn,mode){
  if(!window.SX_EXPERT){return;}
  var payload=Object.assign({},window.SX_EXPERT);
  window.SX_SURFS.forEach(function(k){if(mode==='obs'||!window.sxAppOn(k)){delete payload[k];}});
  if(window.sxDismissedPayload){var dls=window.sxDismissedPayload();if(dls.length){payload.dismissed=dls;}}
  var o=btn.textContent;var e=document.getElementById('xrErr');if(e){e.style.display='none';}
  btn.disabled=true;btn.textContent='Preparing your read…';
  fetch('https://streamauditx.strmrx.com/api/read/upload',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})
    .then(function(r){return r.json();})
    .then(function(d){if(!d||!d.jobId){throw new Error('x');}
      window.open('https://streamauditx.strmrx.com/read/'+encodeURIComponent(d.jobId)+window.sxObsLink(),'_blank');
      window.sxReadHistoryPush(d.jobId,mode);
      btn.textContent='Opened in your browser ✓';
      setTimeout(function(){btn.disabled=false;btn.textContent=o;},6000);})
    .catch(function(){btn.disabled=false;btn.textContent=o;if(e){e.style.display='block';}});
};
// Multi stream read (499): the Read button opens a pre-flight panel first, which says
// exactly what this read will do (which streams, which apps have data for each stream,
// crashes, current setup) with app include/exclude right there. Nothing uploads until
// Continue, and payment still happens on the web page.
window.sxPreClose=function(){var m=document.getElementById('xrPre');if(m){m.hidden=true;}};
// Reveal the list of apps the user hasn't connected yet, right inside the pre-flight, so
// someone on this page who wants more coverage can find the connect steps without hunting.
window.sxPreConnect=function(k){window.sxPreClose();if(window.sxConnectHelp){window.sxConnectHelp(k,'connect');}};
window.SX_APP_LABEL={streamerbot:'Streamer.bot',lumia:'Lumia',mixitup:'Mix It Up',firebot:'Firebot',voicemeeter:'VoiceMeeter'};
// Turn a raw OBS log filename (2026-08-14 20-02-39.txt) into a friendly stream
// label ("August 14, 2026 Stream"); the raw name still rides along as a hover title.
window.sxStreamLabel=function(name){
  var s=String(name||'');var m=s.match(/(\d{4})-(\d{2})-(\d{2})/);
  if(!m){return s.replace(/\.txt$/i,'');}
  var mo=['January','February','March','April','May','June','July','August','September','October','November','December'][parseInt(m[2],10)-1]||m[2];
  return mo+' '+parseInt(m[3],10)+', '+m[1]+' Stream';
};
window.sxMultiRead=function(btn){
  if(!window.SX_AGG||window.SX_AGG.length<2){return;}
  var body=document.getElementById('xrPreBody');if(!body){return;}
  var rows=window.SX_AGG.map(function(l,i){
    var apps=['OBS'];
    window.SX_SURFS.forEach(function(k){if(l[k]){apps.push(window.SX_APP_LABEL[k]);}});
    if(l.tiktok){apps.push('TikTok LIVE Studio');}
    if(l.health){apps.push('PC health');}
    var label=window.sxStreamLabel(l.name)||('Stream '+(i+1));
    var tag=l.dur?'<span class="xr-pre-dur">'+window.sxrEsc(l.dur)+'</span>'
      :(l.unclean?'<span class="xr-pre-dur xr-pre-bad">Ended unexpectedly</span>':'');
    return '<div class="xr-pre-row">'+
      '<div class="xr-pre-top"><b>'+window.sxrEsc(label)+'</b>'+tag+'</div>'+
      '<div class="xr-pre-appline">'+window.sxrEsc(apps.join(' · '))+'</div>'+
    '</div>';
  }).join('');
  var checks='',more='';
  window.SX_APP_META.forEach(function(m){
    if((window.SX_APPS||{})[m.k]==='ok'){checks+='<label class="xr-pre-check"><input type="checkbox" checked data-k="'+m.k+'"> '+m.n+'</label>';}
    else{more+='<button type="button" class="xr-pre-morechip" onclick="sxPreConnect(&#39;'+m.k+'&#39;)">'+m.n+' <i>+</i></button>';}
  });
  // TikTok LIVE Studio is not one of the SX_SURFS companion apps (it has its own
  // external-capture door), so it gets its own include row: shown when any listed
  // stream carries a TikTok slice, or when TikTok is connected right now.
  var hasTT=window.SX_AGG.some(function(l){return !!l.tiktok;});
  if(hasTT||window.SX_TT==='ok'){
    checks+='<label class="xr-pre-check" title="'+(hasTT?'Per-stream TikTok LIVE Studio activity for the streams above.':'Connected. TikTok LIVE Studio activity is saved per stream from now on; streams from before this update have none saved yet.')+'"><input type="checkbox" checked data-k="tiktok"> TikTok LIVE Studio</label>';
  }
  var H=window.SX_HEALTH||{};
  var capP=H.capPremium||60;
  // Three states, not two: an unprimed dock reads as tier "a" but that means
  // "unknown", and the copy must never tell a member they chose the free plan.
  var hist=H.premium
    ?'<p class="xr-pre-hist">You are an '+(H.tier==='x'?'X':'S')+' member, so your PC keeps your last '+capP+' streams for reads like this ('+(H.kept||window.SX_AGG.length)+' saved so far). Each read digs deeper the more you stream.</p>'
    :(H.tierKnown
      ?'<p class="xr-pre-hist">You are on the free plan, so your PC keeps your last '+(H.cap||3)+' streams for reads like this. <a href="https://strmrx.com" target="_blank" rel="noopener">S and X members</a> keep their last '+capP+', so each read gets deeper and smarter the more they stream.</p>'
      :'<p class="xr-pre-hist">Your PC keeps your last '+(H.cap||3)+' streams for reads like this. <a href="https://strmrx.com" target="_blank" rel="noopener">S and X members</a> keep their last '+capP+'. Already a member? Running any read from this dock while signed in syncs your plan to this PC automatically, this one included.</p>');
  body.innerHTML=
    '<p class="xr-pre-lead">Reads your latest streams across your connected apps and gives a deep report on your whole setup and system health.</p>'+
    '<div class="xr-pre-rows">'+rows+'</div>'+
    hist+
    '<div class="xr-pre-sec">Include in this read</div>'+
    '<div class="xr-pre-pccard"><div class="xr-pre-pctop"><span class="xr-pre-pcic">🖥</span><b>Your PC · full health workup</b><span class="xr-pre-pcbadge">✓ Every read</span></div>'+
    '<div class="xr-pre-pcsub">GPU health, driver crashes, Windows stability, and performance samples from each stream above. When the problem is your PC and not OBS, the read catches that too.</div></div>'+
    (checks?'<div class="xr-pre-apps">'+checks+'</div>':'')+
    (more?'<div class="xr-pre-sec">Connect more apps</div><p class="xr-pre-morehint">Tap an app to see how to connect it. Once connected, it folds into your reads automatically.</p><div class="xr-pre-morelist">'+more+'</div>':'');
  var go=document.getElementById('xrPreGo');if(go){go.disabled=false;go.textContent='Continue →';}
  var m=document.getElementById('xrPre');m.hidden=false;
};
window.sxMultiGo=function(btn){
  var inc={};
  var cbs=document.querySelectorAll('#xrPre input[type=checkbox]');
  for(var i=0;i<cbs.length;i++){if(cbs[i].checked){inc[cbs[i].getAttribute('data-k')]=1;}}
  btn.disabled=true;btn.textContent='Preparing your read…';
  // Per-session objects, each with its time-aligned app slices (only the apps left ticked),
  // its PC health slice, plus crash reports + the current rig at the top level.
  var logs=window.SX_AGG.map(function(l){var o={name:l.name,raw:l.raw};window.SX_SURFS.forEach(function(k){if(l[k]&&inc[k]){o[k]=l[k];}});if(l.health){o.health=l.health;}if(l.tiktok&&inc.tiktok){o.tiktok=l.tiktok;}return o;});
  var mbody={logs:logs};
  if(window.sxDismissedPayload){var dlm=window.sxDismissedPayload();if(dlm.length){mbody.dismissed=dlm;}}
  if(window.SX_EXPERT){
    if(window.SX_EXPERT.crashes){mbody.crashes=window.SX_EXPERT.crashes;}
    if(window.SX_EXPERT.rig){mbody.rig=window.SX_EXPERT.rig;}
    if(window.SX_EXPERT.gpus){mbody.gpus=window.SX_EXPERT.gpus;}
    if(window.SX_EXPERT.windowsTuning){mbody.windowsTuning=window.SX_EXPERT.windowsTuning;}
    if(window.SX_EXPERT.werReports){mbody.werReports=window.SX_EXPERT.werReports;}
    if(window.SX_EXPERT.winEvents){mbody.winEvents=window.SX_EXPERT.winEvents;}
  }
  fetch('https://streamauditx.strmrx.com/api/read/upload-multi',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(mbody)})
    .then(function(r){return r.json();})
    .then(function(d){if(!d||!d.jobId){throw new Error('x');}
      window.open('https://streamauditx.strmrx.com/read/'+encodeURIComponent(d.jobId)+window.sxObsLink(),'_blank');
      window.sxReadHistoryPush(d.jobId,'multi');
      window.sxPreClose();
      var mb=document.getElementById('xrBtnMulti');
      if(mb){mb.textContent='Opened in your browser ✓';setTimeout(function(){mb.textContent='Read →';},6000);}
    })
    .catch(function(){btn.disabled=false;btn.textContent='Could not reach StreamAuditX, try again';});
};
// Help improve StreamAuditX (learning-loop Phase C). After a STREAMED session, an
// anonymized findings summary plus a REDACTED copy of that session's log go to the corpus
// that makes the free checks smarter. Quiet and honest, never covert: the footer names it
// and one tap turns it off. A signed-in member's Expert Read page choice (the consent
// slot) always beats the local default. Redaction runs here on-device (the same rules as
// the Log Check web redactor) AND again server-side, so a drifted client can't leak.
window.sxImproveOn=function(){
  var c=window.SX_CONTRIB||{};
  if(c.known){return !!c.on;}
  try{return localStorage.getItem('sxa_optin')!=='0';}catch(e){return false;}
};
window.sxImproveFlip=function(el){
  var c=window.SX_CONTRIB||{};
  if(!c.known){try{localStorage.setItem('sxa_optin',window.sxImproveOn()?'0':'1');}catch(e){}}
  if(el){el.textContent=window.sxImproveOn()?'on':'off';}
};
window.sxRedactLog=function(t){
  t=String(t||'');
  t=t.replace(/((?:[A-Za-z]:)?[\/\\]Users[\/\\])[^\/\\\r\n]+/g,'$1<user>');
  t=t.replace(/(\/home\/)[^\/\r\n]+/g,'$1<user>');
  t=t.replace(/(https?:\/\/[^\s'"()\x5D]*?\?)[^\s'"()\x5D]+/gi,'$1<redacted>');  /* \x5D = close bracket; two of those back to back would end the Lua literal */
  t=t.replace(/(streamelements\.com\/overlay\/[A-Za-z0-9]{6,24})\/[A-Za-z0-9_.\-]{12,}/gi,'$1/<redacted>');
  t=t.replace(/(stream[_-]?key\s*["':=]+\s*["']?)[^\s"',}\r\n]+/gi,'$1<redacted>');
  t=t.replace(/("key"\s*:\s*")[A-Za-z0-9_\-]{8,}(")/g,'$1<redacted>$2');
  return t;
};
window.sxContribute=function(){
  try{
    if(!window.sxImproveOn()){return;}
    var x=window.SX_EXPERT;var lg=x&&x.log;
    if(!lg||!lg.raw||lg.raw.length<50){return;}
    if(!/==== Streaming Start/.test(lg.raw)){return;}
    var nm=lg.name||'';
    if(!nm){return;}
    try{if(localStorage.getItem('sxa_dock_contrib')===nm){return;}}catch(e){return;}
    var fs=(x.ruleCheck&&x.ruleCheck.findings)||[];
    var counts={};
    fs.forEach(function(f){var s=String(f.severity||'').toLowerCase();counts[s]=(counts[s]||0)+1;});
    var meta={v:'dock1',hadSession:true,counts:counts,
      findings:fs.slice(0,60).map(function(f){return {id:String(f.title||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').slice(0,16),sev:String(f.severity||'').slice(0,10),cat:String(f.category||'').slice(0,16)};})};
    fetch('https://streamauditx.strmrx.com/api/contribute-dock',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({log:window.sxRedactLog(lg.raw),meta:meta})})
      .then(function(r){if(r.ok){try{localStorage.setItem('sxa_dock_contrib',nm);}catch(e){}}})
      .catch(function(){});
  }catch(e){}
};
setTimeout(function(){
  try{var pt=document.getElementById('sxPrivT');if(pt){pt.textContent=window.sxImproveOn()?'on':'off';}}catch(e){}
  window.sxContribute();
},8000);
// Connection pills + gates. The pills are pure status: green = connected (and folded
// into the paid app reads automatically), gray = not connected (tap for the connect
// screen). No toggles; nobody pays for an OBS + apps read while nothing is connected.
window.SX_APP_META=[{k:'streamerbot',n:'Streamer.bot'},{k:'lumia',n:'Lumia'},{k:'mixitup',n:'Mix It Up'},{k:'firebot',n:'Firebot'},{k:'voicemeeter',n:'VoiceMeeter'}];
window.sxAppOn=function(k){return (window.SX_APPS||{})[k]==='ok';};
window.sxRenderApps=function(){
  var box=document.getElementById('xrApps');if(!box){return;}
  var h='<span class="xr-app xr-on" title="OBS itself is read in every Expert Read."><i class="xr-dot on"></i>OBS<em>connected</em></span>';
  var conn=0,names=[];
  window.SX_APP_META.forEach(function(m){
    var st=(window.SX_APPS||{})[m.k];
    if(st==='ok'){
      conn++;names.push(m.n);
      h+='<span class="xr-app xr-on" title="Connected. Folded into the OBS + apps and Multi stream reads automatically."><i class="xr-dot on"></i>'+m.n+'<em>connected</em></span>';
    }else if(st==='moved'){
      h+='<button type="button" class="xr-app xr-off" onclick="sxConnectHelp(&#39;'+m.k+'&#39;,&#39;moved&#39;)" title="We lost track of this app. Tap to re-connect."><i class="xr-dot warn"></i>'+m.n+'<em>re-connect</em></button>';
    }else{
      h+='<button type="button" class="xr-app xr-off" onclick="sxConnectHelp(&#39;'+m.k+'&#39;,&#39;connect&#39;)" title="Tap to see how to connect '+m.n+'."><i class="xr-dot none"></i>'+m.n+'<em>not connected</em></button>';
    }
  });
  box.innerHTML=h;
  var fb=document.getElementById('xrBtnFull'), note=document.getElementById('xrFullNote');
  if(fb){
    if(conn<1){fb.disabled=true;fb.classList.add('xr-btn-off');
      if(note){note.textContent='Nothing besides OBS is connected right now, so this read would add nothing beyond the OBS only one. Tap a gray app above to see how to connect it.';}
    }else{fb.disabled=false;fb.classList.remove('xr-btn-off');
      if(note){note.textContent='Ready to read OBS with '+names.join(', ')+'.';}}
  }
  var mc=(window.SX_AGG_COUNT||0);
  var mb=document.getElementById('xrBtnMulti'), mn=document.getElementById('xrMultiNote');
  if(mb){
    if(mc<2){mb.disabled=true;mb.classList.add('xr-btn-off');
      if(mn){mn.textContent='Unlocks once you have streamed a couple of times, so there is more than one session to compare. Come back after your next stream.';}
    }else if(mn){mn.textContent='Ready to read across your last '+mc+' streams'+(names.length?(', plus '+names.join(', ')+'.'):'.');}
  }
};
window.sxRenderApps();
// Straight-to-Expert view: arriving from the script menu (#expert) shows ONLY this card;
// the back link (or any other hash) restores the full audit.
(function(){
  function ap(){document.body.classList.toggle('xr-solo',location.hash.indexOf('expert')>=0);}
  window.addEventListener('hashchange',ap);ap();
})();
// Expert Read results, piped back into the dock so fixes work HERE (the dock has the obs-websocket
// access the read web page doesn't). We keep a rolling history of recent reads, newest first,
// collapsed into a Past Expert Reads dropdown so a one-time deep read never buries the live audit.
// Injected cards reuse the .oa-fix / .smw-jump delegated handlers for the tactile fix + Show-me.
window.SXR_BASE='https://streamauditx.strmrx.com';
window.sxrEsc=function(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c];});};
window.sxrAttr=function(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/'/g,'&#39;');};
window.sxReadKindLabel=function(k){return k==='multi'?'Multi stream &middot; OBS only':(k==='full'||k==='holistic')?'Single stream &middot; OBS + apps':'Single stream &middot; OBS only';};
window.sxHistDate=function(ts){try{var d=new Date(ts);if(isNaN(d.getTime())){return '';}return d.toLocaleDateString(undefined,{month:'short',day:'numeric'});}catch(e){return '';}};
window.sxReadMetaLabel=function(result){
  var list=(result&&result.findings&&result.findings.length)?result.findings:[];
  if(!list.length){return 'all clear';}
  var crit=0,warn=0;
  list.forEach(function(f){var s=f&&f.severity;if(s==='critical'){crit++;}else if(s==='warning'||s==='broken'){warn++;}});
  var parts=[];
  if(crit){parts.push(crit+' critical');}
  if(warn){parts.push(warn+' to review');}
  if(!parts.length){parts.push(list.length+(list.length===1?' note':' notes'));}
  return parts.join(' &middot; ');
};
window.sxReadBodyHtml=function(result){
  var list=(result&&result.findings&&result.findings.length)?result.findings:[];
  var on=(window.SMW&&SMW.enabled);
  var cards=list.map(function(f){
    var t=f.target||{};var fa=f.fix_action||{};
    var jump='';
    if(on&&t.type&&t.type!=='external'&&t.scene){jump="<button class='smw-jump' data-scene=\""+window.sxrEsc(t.scene)+"\">Show me &rarr;</button>";}
    var fix='';
    if(on&&fa.input&&fa.settings_json){var okj=true;try{JSON.parse(fa.settings_json);}catch(_e){okj=false;}
      if(okj){fix="<button class='oa-fix' data-input=\""+window.sxrEsc(fa.input)+"\" data-set='"+window.sxrAttr(fa.settings_json)+"'>&#9889; Fix it for me</button>";}}
    var nm=(t.scene?t.scene+' · ':'')+(t.name||'');
    var tag=(nm.replace(/[\s·]/g,'')!=='')?(" <span class='cattag'>"+window.sxrEsc(nm)+"</span>"):'';
    // Cross-session badges for multi reads (how many of N streams, streak, silent).
    var mb='';
    if(f.streak&&f.streak>=2){mb+=" <span class='cattag'>"+f.streak+" in a row</span>";}
    else if(f.occurrences&&f.of){mb+=" <span class='cattag'>"+f.occurrences+" of "+f.of+" streams</span>";}
    var foot=(fix||jump)?("<div class='cardfoot'>"+fix+jump+"</div>"):'';
    return "<div class='card "+window.sxrEsc(f.severity||'tip')+"'>"+
      "<div class='title'>"+window.sxrEsc(f.title)+tag+mb+"</div>"+
      "<div class='why'><b>Why it matters:</b> "+window.sxrEsc(f.why)+"</div>"+
      "<div class='fix'><b>Fix:</b> "+window.sxrEsc(f.fix)+"</div>"+foot+"</div>";
  }).join('');
  var sum=(result&&result.summary)?("<div class='sxr-sum'>"+window.sxrEsc(result.summary)+"</div>"):'';
  return (sum+cards)||"<div class='sxr-sum'>No issues came back in this read.</div>";
};
window.sxReadFetch=function(jobId,cb){
  fetch(window.SXR_BASE+'/api/read/'+encodeURIComponent(jobId),{cache:'no-store'}).then(function(r){return r.json();}).then(function(j){cb(j);}).catch(function(){cb(null);});
};
window.sxToggleHist=function(){var b=document.getElementById('sxrHistBody');var c=document.getElementById('sxrHistCaret');if(!b){return;}var hid=b.style.display==='none';b.style.display=hid?'':'none';if(c){c.classList.toggle('open',hid);}};
window.sxToggleEntry=function(btn){var b=btn.parentNode.querySelector('.sxr-entry-body');if(!b){return;}var hid=b.style.display==='none';b.style.display=hid?'':'none';btn.classList.toggle('open',hid);};
window.sxRenderHistory=function(jobs){
  var host=document.getElementById('sxRead');if(!host){return;}
  jobs=(jobs||[]).filter(function(j){return j&&j.id;});
  if(!jobs.length){host.innerHTML='';return;}
  var rows=jobs.map(function(j){
    return "<div class='sxr-entry' data-jid='"+window.sxrEsc(j.id)+"'>"+
      "<button class='sxr-entry-head' onclick='sxToggleEntry(this)'>"+
        "<span class='sxr-entry-caret'>&#9656;</span>"+
        "<span class='sxr-entry-date'>"+window.sxrEsc(window.sxHistDate(j.ts))+"</span>"+
        "<span class='sxr-entry-kind'>"+window.sxReadKindLabel(j.kind)+"</span>"+
        "<span class='sxr-entry-meta' data-meta>&#8230;</span>"+
      "</button>"+
      "<div class='sxr-entry-body' style='display:none'><div data-body class='sxr-entry-inner'>Loading this read&#8230;</div></div>"+
    "</div>";
  }).join('');
  host.innerHTML="<div class='sxr-hist' id='sxrHist'>"+
    "<button class='sxr-hist-head' onclick='sxToggleHist()'>"+
      "<span class='sxr-hist-caret' id='sxrHistCaret'>&#9656;</span>"+
      "<span class='sxr-badge'>&#10022; PAST EXPERT READS</span>"+
      "<span class='sxr-hist-count'>"+jobs.length+"</span>"+
    "</button>"+
    "<div class='sxr-hist-body' id='sxrHistBody' style='display:none'>"+rows+
      "<a class='sxr-all' href='#' onclick=\"event.preventDefault();window.open(window.SXR_BASE+'/read','_blank');\">This dock keeps your last 6. See every read on your account &rarr;</a>"+
    "</div>"+
  "</div>";
  jobs.forEach(function(j){window.sxReadFill(j,0);});
};
window.sxReadFill=function(j,tries){
  window.sxReadFetch(j.id,function(res){
    var entry=document.querySelector(".sxr-entry[data-jid='"+j.id+"']");
    if(!entry){return;}
    var meta=entry.querySelector('[data-meta]');var body=entry.querySelector('[data-body]');
    if(res&&res.status==='done'&&res.result){
      if(meta){meta.innerHTML=window.sxReadMetaLabel(res.result);}
      if(body){body.innerHTML=window.sxReadBodyHtml(res.result);}
    }else if(res&&res.status==='failed'){
      if(meta){meta.textContent='did not finish';}
      if(body){body.innerHTML="<div class='sxr-sum'>That read did not finish, and you were not charged.</div>";}
    }else if(res&&res.status==='pending_payment'){
      // A checkout that was never finished is not a past read. Keep it for a day as
      // the way back to that checkout (clearly labeled), then clear it on its own.
      if(j.ts&&(Date.now()-j.ts)>86400000){window.sxReadHistoryDrop(j.id);return;}
      if(meta){meta.textContent='started, not finished';}
      if(body){body.innerHTML="<div class='sxr-sum'>You started this read but never ran it, so nothing was charged."+
        "<br><button type='button' class='sxr-continue' onclick=\"window.open(window.SXR_BASE+'/read/"+j.id+"'+(window.sxObsLink?sxObsLink():''),'_blank');\">Finish this read &rarr;</button>"+
        "<span class='sxr-fine'>If you skip it, this row clears itself after a day.</span></div>";}
      if((tries||0)<90){setTimeout(function(){window.sxReadFill(j,(tries||0)+1);},4000);}
    }else{
      if(meta){meta.textContent='still running';}
      if(body){body.innerHTML="<div class='sxr-sum'>This read is still running. It fills in here the moment it is done.</div>";}
      if((tries||0)<90){setTimeout(function(){window.sxReadFill(j,(tries||0)+1);},4000);}
    }
  });
};
// History plumbing. OBS's persistent-data slot is the source of truth and memory is
// only a cache: every save MERGES with what the slot already holds before writing.
// The old flow trusted memory and wrote it straight over the slot, so one load where
// the read-back failed (busy OBS, dropped socket) erased real history for good.
window._sxJobsDropped={};
window.sxJobsMerge=function(a,b){
  var seen={},out=[];
  (a||[]).concat(b||[]).forEach(function(j){
    if(!j||!j.id||window._sxJobsDropped[j.id]||seen[j.id]){return;}
    seen[j.id]=1;out.push({id:j.id,kind:j.kind||'obs',ts:j.ts||0});
  });
  out.sort(function(x,y){return (y.ts||0)-(x.ts||0);});
  return out.slice(0,6);
};
window.sxJobsPersist=function(){
  if(typeof smwGetData!=='function'){return;}
  smwGetData('sx_expert_jobs',function(v){
    var before=(window._sxJobs||[]).map(function(j){return j.id;}).join(',');
    var merged=window.sxJobsMerge(window._sxJobs||[],Array.isArray(v)?v:[]);
    window._sxJobs=merged;
    if(window.smwSetData){smwSetData('sx_expert_jobs',merged);}
    if(merged.map(function(j){return j.id;}).join(',')!==before){window.sxRenderHistory(merged);}
  });
};
window.sxReadHistoryPush=function(id,kind){
  if(!id){return;}
  var list=window.sxJobsMerge([{id:id,kind:kind||'obs',ts:Date.now()}],window._sxJobs||[]);
  window._sxJobs=list;
  window.sxRenderHistory(list);
  window.sxJobsPersist();
  var b=document.getElementById('sxrHistBody');if(b){b.style.display='';}
  var c=document.getElementById('sxrHistCaret');if(c){c.classList.add('open');}
  var eh=document.querySelector(".sxr-entry .sxr-entry-head");if(eh){window.sxToggleEntry(eh);}
};
window.sxReadHistoryDrop=function(id){
  if(!id){return;}
  window._sxJobsDropped[id]=1;
  window._sxJobs=(window._sxJobs||[]).filter(function(x){return x&&x.id!==id;});
  window.sxRenderHistory(window._sxJobs);
  window.sxJobsPersist();
};
window.sxReadRecover=function(tries){
  if(typeof smwGetData!=='function'){return;}
  smwGetData('sx_expert_jobs',function(v){
    var jobs=Array.isArray(v)?v:[];
    if(jobs.length){
      var before=(window._sxJobs||[]).map(function(j){return j.id;}).join(',');
      var merged=window.sxJobsMerge(window._sxJobs||[],jobs);
      window._sxJobs=merged;
      if(merged.map(function(j){return j.id;}).join(',')!==before){window.sxRenderHistory(merged);}
      return;
    }
    // An empty read can mean "OBS was not ready yet", not "no history". Keep asking
    // for a minute before believing it; a wrong "empty" here is exactly what used to
    // let the next save wipe real history.
    if((tries||0)<20){setTimeout(function(){window.sxReadRecover((tries||0)+1);},3000);return;}
    smwGetData('sx_expert_job',function(old){
      if(old&&typeof old==='string'&&old.length>3){
        var merged=window.sxJobsMerge(window._sxJobs||[],[{id:old,kind:'obs',ts:Date.now()}]);
        window._sxJobs=merged;window.sxRenderHistory(merged);
      }
    });
  });
};
if(window.addEventListener){window.addEventListener('load',function(){setTimeout(function(){if(window.sxReadRecover){window.sxReadRecover(0);}},1600);});}
</script>]]

  -- Auto-pull cross-session card. Lives OUTSIDE #oaLive (inserted next to #sxRead) so its
  -- heavier embedded log payload isn't re-swapped into the parent on the 8s live refresh, and
  -- so the rendered result survives that refresh the same way the Expert Read result does.
  -- Only offered once there are 2+ real streams to compare; below that it's a muted teaser.
  -- window.SX_AGG is CONCATENATED (not inside the [[ ]]) so a "]]" in a log is harmless.
  local agg_block
  if agg_logs_count >= 2 then
    agg_block = [[<div class="agg-cta" id="aggCta">
  <div class="agg-cta-head"><span class="agg-badge">FREE</span><span class="agg-ic">&#128218;</span><b>Compare your last ]] .. agg_logs_count .. [[ streams</b></div>
  <span class="agg-cta-sub">One log only shows one night. Read your recent sessions side by side to catch the problems that repeat, the settings that drifted, and the failures you never see live. StreamAuditX keeps a private copy of each stream on your PC, so this keeps working even after OBS clears its own logs.</span>
  <div class="agg-cta-foot"><button class="agg-run" id="aggBtn" data-label="Compare my ]] .. agg_logs_count .. [[ streams" onclick="sxAggRun(this)">Compare my ]] .. agg_logs_count .. [[ streams &rarr;</button></div>
</div>
<div id="sxAgg-out" class="agg-out"></div>
<script>
(function(){
  function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c];});}
  function shortDate(iso){try{var d=new Date(iso);if(isNaN(d.getTime()))return '';return d.toLocaleDateString(undefined,{month:'short',day:'numeric'});}catch(e){return '';}}
  function chip(f){var s='';
    if(f.streak&&f.streak>=2){s+="<span class='agg-chip streak'>"+f.streak+" in a row</span>";}
    else if(f.occurrences&&f.of){s+="<span class='agg-chip'>"+f.occurrences+" of "+f.of+"</span>";}
    return s;}
  function card(f){var sev=(f.severity==='critical'||f.severity==='warning')?f.severity:'';
    var why=f.why?("<div class='why'><b>Why:</b> "+esc(f.why)+"</div>"):'';
    var fix=f.fix?("<div class='fix'><b>Fix:</b> "+esc(f.fix)+"</div>"):'';
    return "<div class='agg-item "+sev+"'><div class='t'>"+esc(f.title)+chip(f)+"</div>"+why+fix+"</div>";}
  function driftName(k){var map={'video.bitrate_kbps':'Video bitrate','video.encoder':'Video encoder','video.fps':'Frame rate','video.base':'Canvas resolution','video.output':'Output resolution','audio.sample_rate':'Audio sample rate'};
    if(map[k])return map[k];
    var p=String(k).split('.');var last=p[p.length-1]||String(k);last=last.replace(/_/g,' ');return last.charAt(0).toUpperCase()+last.slice(1);}
  function trendName(k){var s=String(k);
    if(s.indexOf('network')>=0)return 'Dropped frames (your connection)';
    if(s.indexOf('render')>=0)return 'Rendering lag (your GPU)';
    if(s.indexOf('encod')>=0||s.indexOf('skip')>=0)return 'Encoding lag';
    var p=s.split('.');return (p[p.length-1]||s).replace(/_/g,' ');}
  window.sxAggRender=function(a){
    var host=document.getElementById('sxAgg-out');if(!host||!a)return;
    var sess=a.sessions||[];var n=sess.length;
    var d0=n?shortDate(sess[0].started_at):'';var d1=n?shortDate(sess[n-1].started_at):'';
    var range=(d0&&d1&&d0!==d1)?(d0+' to '+d1):(d1||d0);
    var html="<div class='agg-sum'>Your last "+n+" streams"+(range?(" <span class='agg-span'>"+esc(range)+"</span>"):'')+"</div>";
    var silent=a.silent_failures||[];
    var recurring=(a.recurring_findings||[]).filter(function(f){return !f.silent;});
    var drift=(a.config_drift||[]).filter(function(x){return x.important;});
    var trends=a.trends||{};var tkeys=[];
    for(var tk in trends){if(trends.hasOwnProperty(tk)&&trends[tk]&&trends[tk].samples>=2)tkeys.push(tk);}
    if(silent.length){html+="<div class='agg-sec'>Happening quietly across your streams</div>";
      for(var i=0;i<silent.length&&i<6;i++){html+=card(silent[i]);}}
    if(recurring.length){html+="<div class='agg-sec'>Keeps coming back</div>";
      for(var r=0;r<recurring.length&&r<6;r++){html+=card(recurring[r]);}}
    if(drift.length){html+="<div class='agg-sec'>What changed across your sessions</div>";
      for(var c=0;c<drift.length&&c<6;c++){var x=drift[c];
        html+="<div class='agg-drift'><span class='k'>"+esc(driftName(x.key))+"</span><span class='v'>"+esc(String(x.from))+" <b>&rarr; "+esc(String(x.to))+"</b></span></div>";}}
    if(tkeys.length){html+="<div class='agg-sec'>Trending</div>";
      for(var t=0;t<tkeys.length;t++){var key=tkeys[t];var tr=trends[key];
        var dir=tr.direction==='up'?"<span class='dir up'>&#9650; rising</span>":(tr.direction==='down'?"<span class='dir down'>&#9660; easing</span>":"<span class='dir'>steady</span>");
        html+="<div class='agg-trend'><span class='k'>"+esc(trendName(key))+"</span><span class='v'>avg "+esc(String(tr.avg))+" &middot; worst "+esc(String(tr.worst))+"</span>"+dir+"</div>";}}
    if(!silent.length&&!recurring.length&&!drift.length&&!tkeys.length){
      html+="<div class='agg-empty'>No repeating problems across your last "+n+" streams. Your setup is holding steady, nice work.</div>";}
    if(a.notes&&a.notes.length){html+="<div class='agg-note'>"+esc(a.notes.join(' '))+"</div>";}
    html+="<div class='agg-more'>Want an expert read across these that hands back the exact fixes, ranked, including the silent failures you never catch live? Run the <b>Multi stream</b> read up top.</div>";
    host.innerHTML="<div class='agg-wrap'>"+html+"</div>";
  };
  window.sxAggRun=function(btn){
    if(!window.SX_AGG||!window.SX_AGG.length)return;
    var host=document.getElementById('sxAgg-out');
    var o=btn.getAttribute('data-label')||'Compare my streams';
    btn.disabled=true;btn.textContent='Reading your last '+window.SX_AGG.length+' streams...';
    var logs=window.SX_AGG.map(function(l){return {name:l.name,raw:l.raw};});
    fetch('https://streamauditx.strmrx.com/api/read/aggregate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({logs:logs})})
      .then(function(res){return res.json();})
      .then(function(d){if(!d||!d.aggregate)throw new Error('x');
        window.sxAggRender(d.aggregate);
        btn.disabled=false;btn.textContent='↻ Re-check my streams';})
      .catch(function(){btn.disabled=false;btn.textContent=o;
        if(host)host.innerHTML="<div class='agg-err'>Couldn't reach StreamAuditX. Check your connection and try again.</div>";});
  };
})();
</script>]]
  else
    agg_block = [[<div class="agg-cta agg-muted" id="aggCta">
  <div class="agg-cta-head"><span class="agg-ic">&#128218;</span><b>Compare your streams over time</b></div>
  <span class="agg-cta-sub">Once you have streamed a couple of times, this reads your recent sessions together to surface the silent failures, repeat issues and setting drift a single log can't show. Come back after your next stream.</span>
</div>]]
  end

  -- Which companion apps are connected right now, exposed to the panel JS so the whole-setup
  -- read button can gate itself (needs 1+ companion) and name what it will read.
  local companion_ok = {}
  if comp.sb_status == "ok" then companion_ok[#companion_ok + 1] = "Streamer.bot" end
  if comp.lumia_status == "ok" then companion_ok[#companion_ok + 1] = "Lumia" end
  if comp.miu_status == "ok" then companion_ok[#companion_ok + 1] = "Mix It Up" end
  if comp.fb_status == "ok" then companion_ok[#companion_ok + 1] = "Firebot" end
  if comp.vm_status == "ok" then companion_ok[#companion_ok + 1] = "VoiceMeeter" end
  local companion_count = #companion_ok
  local companion_names_js = jesc(table.concat(companion_ok, ", "))

  return [[<!DOCTYPE html><html><head><meta charset="utf-8">
<title>StreamAuditX</title>
<style>
  *{box-sizing:border-box}
  body{background:#0d0f13;color:#e8e8ec;font-family:'Segoe UI',system-ui,sans-serif;margin:0;padding:18px;font-size:14px;line-height:1.5}
  h1{font-size:15px;margin:0 0 14px;letter-spacing:.5px;color:#c9ced8}
  h1 .hx{color:#ff7a1a}
  h1 b{color:#ff7a1a}
  /* Full System Health hero: the big number, its source stream, and per-system sub-meters */
  .fsh{background:linear-gradient(180deg,#161b23,#12161d);border:1px solid #242b37;border-radius:16px;padding:20px 20px 18px;margin-bottom:16px;cursor:pointer;transition:border-color .18s,background .18s}
  .fsh:hover{border-color:#3a4150}
  .fsh-top{display:flex;align-items:center;gap:18px}
  .num{font-size:52px;font-weight:800;line-height:.9;font-variant-numeric:tabular-nums;transition:color .3s}
  .fsh-main{flex:1;min-width:0}
  .fsh-title{font-size:18px;font-weight:800;letter-spacing:-.01em;color:#f2f4f8;margin-bottom:5px}
  .fsh-session{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:15px 0 0;padding:11px 13px;background:rgba(143,176,255,.06);border:1px solid rgba(143,176,255,.16);border-radius:11px;font-size:12.5px;color:#aab4c6}
  .fsh-when b{color:#dbe5fb;font-weight:700}
  .fsh-pick{display:inline-flex}
  .fsh-lanes{margin-top:13px;display:flex;flex-direction:column;gap:7px}
  .lanerow{display:flex;align-items:center;gap:10px;font-size:12px;min-width:0}
  .lane-name{flex:0 0 82px;font-weight:800;color:#cfd3db;white-space:nowrap;font-size:11.5px}
  .lane-bar{flex:1 1 90px;height:7px;background:#1c222c;border-radius:99px;overflow:hidden;min-width:56px}
  .lane-bar i{display:block;height:100%;border-radius:99px;background:#3a4150;transition:width .3s ease,background .3s ease}
  .lane-val{flex:0 0 28px;text-align:right;font-weight:800;font-variant-numeric:tabular-nums;color:#e8e8ec}
  .lane-band{flex:1 1 46%;min-width:0;font-size:11px;color:#8a919e;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .lane-band.gate{color:#ff6b6b;font-weight:700;white-space:normal}
  .fsh-meters{display:grid;grid-template-columns:repeat(auto-fit,minmax(122px,1fr));gap:8px;margin-top:13px}
  .meter{display:flex;align-items:center;gap:7px;background:#12161d;border:1px solid #222a35;border-radius:10px;padding:9px 11px;font-size:12px}
  .meter .m-name{font-weight:700;color:#cfd3db;white-space:nowrap}
  .meter.off,.meter.soon{opacity:.62}
  .meter.off .m-name,.meter.soon .m-name{color:#8a919e}
  .m-dot{width:7px;height:7px;border-radius:50%;background:#6fd394;flex-shrink:0;box-shadow:0 0 8px rgba(111,211,148,.6)}
  .m-bar{flex:1;height:5px;border-radius:20px;background:#232833;overflow:hidden;min-width:24px}
  .m-bar>i{display:block;height:100%;border-radius:20px;transition:width .4s cubic-bezier(.2,.7,.2,1),background .3s}
  .m-val{font-weight:800;font-variant-numeric:tabular-nums;color:#e8e8ec;margin-left:auto}
  .m-tag{margin-left:auto;font-size:10px;font-weight:700;letter-spacing:.3px;text-transform:uppercase;color:#6b7280}
  .meter.soon .m-tag{color:#c9a86a}
  .meter.ok{border-color:#25603f}
  .meter.ok .m-name{color:#cfd3db}
  .m-ok{color:#6fd394}
  .meter.warn{border-color:#7a5b1e}
  .meter.warn .m-tag{color:#e0b357;text-transform:none;letter-spacing:0}
  .meter.off .m-tag{text-transform:none;letter-spacing:0}
  .m-connect{margin-left:auto;font:inherit;font-size:11px;font-weight:800;letter-spacing:.2px;color:#12131a;background:#ff9a4d;border:0;border-radius:999px;padding:4px 11px;cursor:pointer;white-space:nowrap;transition:transform .12s cubic-bezier(.2,.7,.2,1),background .15s}
  .m-connect:hover{background:#ffb877}
  .m-connect:active{transform:scale(.96)}
  .m-connect.m-connect-warn{background:#e0b357}
  .m-connect.m-connect-warn:hover{background:#eac877}
  .sx-help{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(6,8,12,.62);backdrop-filter:blur(3px);padding:20px}
  .sx-help[hidden]{display:none}
  .sx-help-card{position:relative;max-width:440px;width:100%;background:#151a22;border:1px solid #2a3340;border-radius:16px;box-shadow:0 24px 60px rgba(0,0,0,.5);padding:22px 22px 20px;animation:sxHelpIn .22s cubic-bezier(.2,.7,.2,1)}
  @keyframes sxHelpIn{from{opacity:0;transform:translateY(10px) scale(.97)}to{opacity:1;transform:none}}
  .sx-help-x{position:absolute;top:12px;right:13px;width:28px;height:28px;border:0;border-radius:8px;background:#20262f;color:#aab2c0;font-size:19px;line-height:1;cursor:pointer;transition:background .15s}
  .sx-help-x:hover{background:#2a323d;color:#fff}
  .sx-help-title{font-size:16px;font-weight:800;color:#f2f4f8;padding-right:30px}
  .sx-help-sub{margin-top:7px;font-size:12.5px;line-height:1.5;color:#e0b357}
  .sx-help-lead{margin-top:9px;font-size:12.5px;color:#aab2c0}
  .sx-help-steps{margin:12px 0 0;padding:0;list-style:none;counter-reset:sxstep}
  .sx-help-steps li{position:relative;counter-increment:sxstep;padding:9px 0 9px 34px;font-size:13px;line-height:1.5;color:#d7dbe3;border-top:1px solid #212933}
  .sx-help-steps li:first-child{border-top:0}
  .sx-help-steps li::before{content:counter(sxstep);position:absolute;left:0;top:8px;width:22px;height:22px;border-radius:50%;background:#ff9a4d;color:#12131a;font-size:12px;font-weight:800;display:flex;align-items:center;justify-content:center}
  .sx-help-steps b{color:#fff;font-weight:800}
  .sx-help-done{margin-top:13px;padding:10px 12px;background:#132018;border:1px solid #25603f;border-radius:10px;font-size:12.5px;line-height:1.5;color:#8fe0ac}
  .sx-help-note{margin-top:11px;font-size:11.5px;color:#7d8697;line-height:1.5}
  .m-link{margin-left:auto;font-size:10px;font-weight:800;letter-spacing:.2px;color:#ff9a4d;text-decoration:none;white-space:nowrap;transition:color .12s}
  .m-link:hover{color:#ffb877;text-decoration:underline}
  .fsh-hint{margin-top:11px;font-size:11.5px;line-height:1.5;color:#8a919e}
  .fsh-hint a{color:#ff9a4d;text-decoration:none;font-weight:700}
  .fsh-hint a:hover{text-decoration:underline}
  .reaudit{align-self:center;background:#1a1f28;color:#cfd3db;border:1px solid #2b3240;border-radius:20px;padding:8px 15px;font-size:11.5px;font-weight:700;cursor:pointer;font-family:inherit;white-space:nowrap;transition:all .12s}
  .reaudit:hover{color:#fff;border-color:#ff7a1a;background:#221a12}
  .live{display:inline-flex;align-items:center;gap:4px;color:#6fd394;font-weight:600}
  .live i{width:6px;height:6px;border-radius:50%;background:#6fd394;display:inline-block;animation:pulse 1.8s ease-in-out infinite}
  @keyframes pulse{0%,100%{opacity:.35}50%{opacity:1}}
  @media (prefers-reduced-motion:reduce){.live i{animation:none}}
  .score-label{font-size:13.5px;font-weight:600;color:#c4c9d3;margin-bottom:8px}
  .bar{height:8px;border-radius:20px;background:#232833;overflow:hidden}
  .bar>i{display:block;height:100%;border-radius:20px;transition:width .4s cubic-bezier(.2,.7,.2,1),background .3s}
  .tallies{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px;font-size:11px}
  .tally{padding:3px 11px;border-radius:20px;font-weight:600;letter-spacing:.3px}
  .t-good{background:rgba(111,211,148,.14);color:#8fe0a8;border:1px solid rgba(111,211,148,.4)}
  .t-crit{background:rgba(217,68,68,.14);color:#ff8a8a;border:1px solid rgba(217,68,68,.4)}
  .t-warn{background:rgba(255,122,26,.14);color:#ff9a4d;border:1px solid rgba(255,122,26,.4)}
  .reads{display:flex;gap:9px;align-items:flex-start;font-size:12px;color:#8a909c;line-height:1.55;margin:2px 0 16px;padding:11px 13px;background:#12151b;border:1px solid #1e242e;border-radius:11px}
  .reads-ic{font-size:14px;line-height:1.3;flex-shrink:0}
  .reads b{color:#9aa0ab;font-weight:600}
  .reads .logname{color:#8fb0ff;font-variant-numeric:tabular-nums}
  .logpick{background-color:#141a24;color:#8fb0ff;border:1px solid #2b3547;border-radius:7px;padding:2px 22px 2px 7px;font-family:inherit;font-size:10.5px;font-weight:600;font-variant-numeric:tabular-nums;cursor:pointer;transition:border-color .15s,background-color .15s;appearance:none;-webkit-appearance:none;background-repeat:no-repeat;background-position:right 6px center;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'><path d='M1 3l4 4 4-4' stroke='%238fb0ff' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>")}
  .logpick:hover:not(:disabled){border-color:#3a4658;background-color:#171e29}
  .logpick:disabled{opacity:.5;cursor:default}
  .logpick option{background:#141a24;color:#cfe0ff}
  .reads-alt{margin:-4px 2px 12px;padding:8px 11px;border-radius:9px;background:rgba(143,176,255,.08);border:1px solid rgba(143,176,255,.22);color:#a9bbe0;font-size:10.5px;line-height:1.55}
  .reads-alt b{color:#c9d8ff;font-weight:700}
  /* Auto-pull cross-session card + rendered result */
  /* Free "compare your streams" feature: highlighted on its own, distinct from the paid orange sell */
  .agg-cta{position:relative;overflow:hidden;background:linear-gradient(180deg,#131b28,#111620);border:1px solid rgba(143,176,255,.30);border-radius:14px;padding:16px 18px;margin:0 0 14px;box-shadow:0 0 0 1px rgba(143,176,255,.05),0 10px 28px rgba(0,0,0,.3)}
  .agg-cta::before{content:"";position:absolute;top:-70%;left:-6%;width:280px;height:280px;background:radial-gradient(circle,rgba(120,160,255,.16),transparent 62%);pointer-events:none}
  .agg-cta.agg-muted{background:#141821;border-color:#222a35;box-shadow:none;opacity:.85}
  .agg-cta.agg-muted::before{display:none}
  .agg-badge{display:inline-flex;align-items:center;background:linear-gradient(135deg,#5ec98a,#3fa96a);color:#08130c;font-size:9.5px;font-weight:900;letter-spacing:.09em;padding:3px 9px;border-radius:999px;box-shadow:0 2px 8px rgba(63,169,106,.32)}
  .agg-more{margin-top:11px;padding-top:9px;border-top:1px solid #1c2129;font-size:11px;color:#8a93a3;line-height:1.55}
  .agg-more b{color:#ff9a4d;font-weight:700}
  .agg-cta-head{position:relative;display:flex;align-items:center;gap:8px;font-size:14px}
  .agg-cta-head b{color:#f2f4f8}
  .agg-ic{font-size:15px;line-height:1}
  .agg-cta-sub{display:block;color:#8a93a3;font-size:11px;line-height:1.55;margin:6px 0 0}
  .agg-cta-foot{margin-top:11px}
  .agg-run{background:#ff7a1a;color:#111;border:none;border-radius:20px;padding:8px 16px;font-size:12px;font-weight:800;cursor:pointer;font-family:inherit;transition:transform .12s cubic-bezier(.2,.9,.3,1.1),box-shadow .15s,background .15s;box-shadow:0 4px 14px -4px rgba(255,122,26,.5)}
  .agg-run:hover:not(:disabled){background:#ff8c38;box-shadow:0 6px 18px -4px rgba(255,122,26,.6)}
  .agg-run:active:not(:disabled){transform:scale(.96)}
  .agg-run:disabled{opacity:.6;cursor:default;box-shadow:none}
  .agg-out{margin:0 0 12px}
  .agg-wrap{background:#12151b;border:1px solid #232833;border-radius:12px;padding:13px 15px}
  .agg-sum{font-size:12px;color:#cfd3db;font-weight:600;margin-bottom:4px}
  .agg-sum .agg-span{color:#7f8593;font-weight:400}
  .agg-sec{font-size:10.5px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;color:#9aa0ab;margin:14px 0 8px}
  .agg-sec:first-child{margin-top:2px}
  .agg-item{background:#151920;border:1px solid #232833;border-left:3px solid #3a4150;border-radius:9px;padding:10px 12px;margin-bottom:8px}
  .agg-item.critical{border-left-color:#d94444}
  .agg-item.warning{border-left-color:#ff7a1a}
  .agg-item .t{font-size:12.5px;font-weight:700;color:#eef0f4;display:flex;flex-wrap:wrap;align-items:center;gap:7px}
  .agg-chip{display:inline-block;padding:1px 8px;border-radius:20px;font-size:9.5px;font-weight:800;letter-spacing:.3px;background:#232833;color:#aab2c0}
  .agg-chip.streak{background:rgba(255,122,26,.16);color:#ffab63}
  .agg-chip.silent{background:rgba(143,176,255,.16);color:#a9c2ff}
  .agg-item .why{font-size:11px;color:#9aa0ab;line-height:1.5;margin:6px 0 0}
  .agg-item .fix{font-size:11px;color:#c3c9d3;line-height:1.5;margin:5px 0 0}
  .agg-item .why b,.agg-item .fix b{color:#8a93a3;font-weight:700}
  .agg-drift,.agg-trend{display:flex;align-items:baseline;gap:8px;font-size:11.5px;padding:6px 0;border-top:1px solid #1c2129}
  .agg-drift:first-of-type,.agg-trend:first-of-type{border-top:none}
  .agg-drift .k,.agg-trend .k{color:#cfd3db;font-weight:600;flex:1}
  .agg-drift .v{color:#9aa0ab;font-variant-numeric:tabular-nums}
  .agg-drift .v b{color:#ffab63;font-weight:700}
  .agg-trend .v{font-variant-numeric:tabular-nums;color:#9aa0ab}
  .agg-trend .dir{font-weight:800}
  .agg-trend .dir.up{color:#ff8a8a}
  .agg-trend .dir.down{color:#6fd394}
  .agg-empty{font-size:11.5px;color:#8fe0a8;line-height:1.5}
  .agg-note{font-size:10px;color:#6b7280;line-height:1.5;margin-top:9px;padding-top:8px;border-top:1px solid #1c2129}
  .agg-err{font-size:11.5px;color:#ff9a9a;line-height:1.5;padding:4px 2px}
  .agg-premium{margin-top:11px;background:linear-gradient(180deg,rgba(255,122,26,.09),rgba(255,122,26,.03));border:1px solid rgba(255,122,26,.28);border-radius:10px;padding:11px 13px}
  .agg-premium .pt{font-size:11.5px;color:#e8e8ec;font-weight:700}
  .agg-premium .ps{font-size:10.5px;color:#9aa0ab;line-height:1.5;margin:4px 0 9px;display:block}
  .agg-premium .pb{background:transparent;color:#ffab63;border:1px solid rgba(255,122,26,.5);border-radius:20px;padding:6px 13px;font-size:11px;font-weight:800;cursor:pointer;font-family:inherit;transition:all .12s}
  .agg-premium .pb:hover{background:rgba(255,122,26,.14);color:#ffc38a}
  .tabs{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:10px}
  .tab{background:#151920;color:#9aa0ab;border:1px solid #232833;border-radius:20px;padding:7px 14px;font-size:12.5px;font-weight:700;cursor:pointer;font-family:inherit;transition:all .15s}
  .tab:hover{color:#e8e8ec;border-color:#3a4150}
  .tab.on{background:#ff7a1a;color:#111;border-color:#ff7a1a}
  .badge{display:inline-block;min-width:16px;padding:0 5px;border-radius:20px;background:rgba(255,255,255,.12);font-size:10px;font-weight:800;vertical-align:middle}
  .tab.on .badge{background:rgba(0,0,0,.25)}
  .chips{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:14px}
  .chip{background:transparent;color:#7f8593;border:1px solid #232833;border-radius:20px;padding:3px 10px;font-size:10.5px;font-weight:600;cursor:pointer;font-family:inherit;transition:all .15s}
  .chip:hover{color:#cfd3db;border-color:#3a4150}
  .chip.on{background:#232833;color:#e8e8ec;border-color:#3a4150}
  .sec{font-size:11px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;margin:20px 2px 10px}
  .sec-critical{color:#ff8a8a}
  .sec-warning{color:#ff9a4d}
  .sec-tip{color:#a3b3ff}
  .sec-tidy{color:#8b8f98}
  .sec-good{color:#8fe0a8}
  .card{background:#151920;border:1px solid #232833;border-left-width:3px;border-radius:10px;padding:13px 15px;margin-bottom:10px}
  .card.critical{border-left-color:#d94444}
  .card.warning{border-left-color:#ff7a1a}
  .card.tip{border-left-color:#7a8cff}
  .card.tidy{border-left-color:#5b6270}
  .card.broken{border-left-color:#a78bfa}
  .card.good{border-left-color:#4caf72;padding:9px 13px}
  .title{font-weight:600;font-size:14px;margin-bottom:7px;line-height:1.4}
  .gtitle{font-weight:600;font-size:12.5px;color:#cfe8d6;line-height:1.35}
  .cattag{display:inline-block;font-size:9px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:#7f8593;background:#1e2430;border:1px solid #2b3240;border-radius:20px;padding:1px 7px;vertical-align:middle;margin-left:4px}
  .why{color:#a9adb7;font-size:12.5px;line-height:1.55;margin-bottom:6px}
  .fix{color:#cfd3db;font-size:12.5px;line-height:1.55}
  .why b{color:#7f8593;font-weight:600}
  .fix b{color:#ff9a4d;font-weight:700}
  .drill{margin-top:8px}
  .drill-toggle{background:none;border:none;color:#8fb0ff;font-size:11.5px;font-weight:700;cursor:pointer;font-family:inherit;padding:2px 0}
  .drill-toggle:hover{color:#b9ccff;text-decoration:underline}
  .drill-list{margin-top:6px}
  .drow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:5px 0;border-top:1px solid #171b22;font-size:12px}
  .drow:first-child{border-top:none}
  .dname{font-weight:700;color:#e8e8ec}
  .dscene{color:#9aa0ab;font-size:11px}
  .dscene-none{color:#7f8593;font-style:italic}
  .dnote{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:#8fe0a8;background:rgba(111,211,148,.12);border:1px solid rgba(111,211,148,.35);border-radius:20px;padding:1px 7px}
  .drow .smw-jump{margin-left:auto}
  .cardfoot{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
  .oa-dismiss,.oa-restore,.oa-situational,.oa-park,.oa-parkrestore{background:#1a1f28;color:#8b8f98;border:1px solid #2b3240;border-radius:20px;padding:4px 11px;font-size:11px;font-weight:700;cursor:pointer;font-family:inherit;transition:all .12s}
  .oa-dismiss:hover{color:#e8e8ec;border-color:#3a4150}
  .oa-situational,.oa-park{color:#c9a86a;border-color:#4a3f27}
  .oa-situational:hover,.oa-park:hover{color:#ffd9a0;border-color:#6a5836;background:#221c12}
  .oa-parkrestore:hover{color:#e6ebf2;border-color:#4a5568}
  #sitrec{margin:6px 0 10px}
  .sitrec-head{font-size:12.5px;font-weight:800;color:#c9a86a;margin:2px 0 3px}
  .sitrec-sub{font-size:11.5px;line-height:1.5;color:#8f9bab;margin:0 0 8px}
  .sitrec-row{display:flex;align-items:center;justify-content:space-between;gap:10px;background:#151a22;border:1px solid #2b3240;border-radius:10px;padding:8px 12px;margin:0 0 6px;font-size:12.5px;color:#e6ebf2}
  .oa-sitrestore{background:#1a1f28;color:#8b8f98;border:1px solid #2b3240;border-radius:20px;padding:4px 11px;font-size:11px;font-weight:700;cursor:pointer;font-family:inherit;transition:all .12s;white-space:nowrap}
  .oa-sitrestore:hover{color:#e6ebf2;border-color:#4a5568}
  .oa-restore{display:none;color:#8fe0a8;border-color:rgba(111,211,148,.4)}
  .oa-restore:hover{background:rgba(111,211,148,.1)}
  .clean{background:#151920;border:1px solid rgba(80,200,120,.3);border-radius:12px;padding:22px;text-align:center;color:#6fd394;font-size:14px;font-weight:600}
  .clean.filtered{border-color:#232833;color:#7f8593}
  #filterhint{color:#7f8593;font-size:10.5px;margin:-8px 0 10px;padding-left:2px}
  .clean-sub{color:#8b8f98;font-size:11px;font-weight:400;margin-top:8px}
  .foot{color:#565b66;font-size:10px;margin-top:18px;text-align:center}
  .foot a{color:#ff7a1a;text-decoration:none}
  .xr-cta{position:relative;overflow:hidden;background:linear-gradient(135deg,#241b10 0%,#1a1206 55%,#171b23 100%);border:1px solid rgba(255,122,26,.45);border-radius:16px;padding:18px 20px;margin:2px 2px 16px;box-shadow:0 0 0 1px rgba(255,122,26,.08),0 10px 30px rgba(0,0,0,.35),0 0 42px rgba(255,122,26,.12)}
  .xr-glow{position:absolute;top:-60%;right:-8%;width:340px;height:340px;background:radial-gradient(circle,rgba(255,140,40,.30),transparent 62%);pointer-events:none;filter:blur(6px)}
  .xr-cta-head{position:relative;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
  .xr-badge{display:inline-flex;align-items:center;gap:5px;background:linear-gradient(135deg,#ff9a3d,#ff6a00);color:#1a0e00;font-size:9.5px;font-weight:900;letter-spacing:.09em;padding:3px 9px;border-radius:999px;box-shadow:0 2px 8px rgba(255,122,26,.4)}
  .xr-cta-head b{color:#ffd9b0;font-size:16px;font-weight:800;letter-spacing:-.01em}
  .xr-cta-sub{position:relative;display:block;color:#c3b3a2;font-size:12px;line-height:1.55;margin:8px 0 14px;max-width:640px}
  .xr-cta-foot{position:relative;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
  .xr-btn{position:relative;background:linear-gradient(135deg,#ff8f34,#ff6a00);color:#1a0d00;border:none;border-radius:999px;padding:11px 22px;font-size:13.5px;font-weight:900;cursor:pointer;font-family:inherit;white-space:nowrap;box-shadow:0 3px 0 rgba(140,58,0,.55),0 8px 20px rgba(255,106,0,.35);transition:transform .14s cubic-bezier(.34,1.56,.64,1),box-shadow .14s ease,filter .14s ease}
  .xr-btn:hover:not(:disabled){filter:brightness(1.06);transform:translateY(-2px);box-shadow:0 5px 0 rgba(140,58,0,.55),0 12px 26px rgba(255,106,0,.45)}
  .xr-btn:active:not(:disabled){transform:translateY(1px) scale(.97);box-shadow:0 1px 0 rgba(140,58,0,.55),0 3px 8px rgba(255,106,0,.4);transition-duration:.06s}
  .xr-btn:disabled{opacity:.75;cursor:default}
  .xr-price{color:#9a8b7a;font-size:11px;font-weight:700}
  .xr-opts{position:relative;display:flex;flex-direction:column;gap:10px;margin:2px 0}
  .xr-opt{display:flex;align-items:center;gap:14px;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.09);border-radius:12px;padding:12px 14px;transition:border-color .15s ease,background .15s ease}
  .xr-opt:hover{border-color:rgba(255,122,26,.35);background:rgba(255,122,26,.05)}
  .xr-opt-hero{border-color:rgba(255,122,26,.42);background:linear-gradient(135deg,rgba(255,122,26,.12),rgba(255,122,26,.02))}
  .xr-opt-info{flex:1;min-width:0}
  .xr-opt-name{display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:#ffe7cf;font-size:14px;font-weight:800}
  .xr-opt-price{color:#ff9a4d;font-size:12px;font-weight:800}
  .xr-opt-flag{background:linear-gradient(135deg,#ff9a3d,#ff6a00);color:#1a0e00;font-size:9px;font-weight:900;letter-spacing:.06em;text-transform:uppercase;padding:2px 7px;border-radius:999px}
  .xr-group{position:relative;margin-top:14px}
  .xr-group:first-of-type{margin-top:12px}
  .xr-ghead{display:flex;align-items:center;gap:9px;flex-wrap:wrap;font-size:11px;font-weight:900;letter-spacing:.08em;text-transform:uppercase;color:#ffd9b0;margin-bottom:9px}
  .xr-gsub{font-size:10.5px;font-weight:600;letter-spacing:0;text-transform:none;color:#9a8b7a}
  .xr-group-multi{margin-top:16px;padding:12px 13px 5px;border:1px solid rgba(255,122,26,.32);border-radius:13px;background:linear-gradient(135deg,rgba(255,122,26,.10),rgba(255,122,26,.015))}
  .xr-group-multi .xr-ghead{color:#ff9a4d;margin-bottom:10px}
  .xr-group-multi .xr-opt-flag{margin-left:auto}
  .xr-opt-desc{color:#c3b3a2;font-size:11.5px;line-height:1.5;margin-top:3px}
  .xr-opt-note{color:#9a8b7a;font-size:10.5px;line-height:1.45;margin-top:6px}
  .xr-btn-sm{padding:9px 16px;font-size:12.5px;flex-shrink:0}
  .xr-btn-off,.xr-btn-off:hover{opacity:.5;cursor:default;box-shadow:none;filter:grayscale(.35);transform:none}
  .xr-err{display:none;color:#ff9a4d;font-size:11px;font-weight:600;width:100%;margin-top:8px}
  .xr-allreads{display:block;text-align:center;color:#9a8b7a;font-size:11px;font-weight:700;text-decoration:none;margin-top:14px;transition:color .15s}
  .xr-allreads:hover{color:#ff9a4d}
  @keyframes xrAttn{0%{box-shadow:0 0 0 0 rgba(255,122,26,.55),0 10px 30px rgba(0,0,0,.35)}70%{box-shadow:0 0 0 15px rgba(255,122,26,0),0 10px 30px rgba(0,0,0,.35)}100%{box-shadow:0 0 0 0 rgba(255,122,26,0),0 10px 30px rgba(0,0,0,.35)}}
  .xr-cta.xr-attn{animation:xrAttn 1.1s ease-out 2}
  /* connected-apps picker inside the Expert Read card */
  .xr-apps{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 2px}
  .xr-app{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:600;color:#c9ced8;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.09);border-radius:999px;padding:4px 10px}
  .xr-app em{font-style:normal;font-weight:500;opacity:.55;font-size:10px}
  button.xr-app{cursor:pointer;font-family:inherit;transition:border-color .14s ease-out,opacity .14s ease-out,transform .14s ease-out}
  button.xr-app:hover{border-color:rgba(255,255,255,.28)}
  button.xr-app:active{transform:scale(.96)}
  .xr-on{background:rgba(64,192,110,.09);border-color:rgba(64,192,110,.38);color:#dff2e6}
  .xr-off{opacity:.55}
  .xr-off:hover{opacity:.95}
  .xr-apps-note{font-size:11px;line-height:1.5;color:#9a8b7a;margin:8px 0 2px}
  .xr-apps-note b{color:#c3b3a2}
  .xr-pre-lead{font-size:13px;line-height:1.55;color:#c9d2dd;margin:0 0 14px}
  .xr-pre-rows{display:flex;flex-direction:column;gap:8px;margin:0 0 15px}
  .xr-pre-row{background:rgba(255,255,255,.045);border:1px solid rgba(255,255,255,.09);border-radius:12px;padding:11px 13px;transition:border-color .15s,background .15s}
  .xr-pre-row:hover{background:rgba(255,255,255,.06);border-color:rgba(255,154,77,.35)}
  .xr-pre-top{display:flex;align-items:center;gap:10px}
  .xr-pre-row b{font-size:13.5px;font-weight:800;color:#f4efe9;letter-spacing:.1px}
  .xr-pre-dur{margin-left:auto;flex-shrink:0;font-size:11px;font-weight:800;color:#e0b357;background:rgba(224,179,87,.13);border:1px solid rgba(224,179,87,.22);border-radius:20px;padding:2px 10px;white-space:nowrap}
  .xr-pre-appline{margin-top:6px;font-size:11.5px;color:#8f9bab;line-height:1.4}
  .xr-pre-sec{margin:16px 0 9px;padding-top:14px;border-top:1px solid rgba(255,255,255,.07);font-size:10.5px;font-weight:800;letter-spacing:.7px;text-transform:uppercase;color:#8f9bab}
  .xr-pre-apps{display:flex;flex-wrap:wrap;align-items:center;gap:8px 10px;font-size:12px;color:#9aa6b3}
  .xr-pre-check{display:inline-flex;align-items:center;gap:6px;cursor:pointer;color:#e6ebf2;background:rgba(255,255,255,.045);border:1px solid rgba(255,255,255,.09);border-radius:20px;padding:4px 11px 4px 9px;font-size:12px;transition:border-color .15s,background .15s}
  .xr-pre-check:hover{background:rgba(255,255,255,.07);border-color:rgba(255,154,77,.35)}
  .xr-pre-dur.xr-pre-bad{color:#ff9a8a;background:rgba(255,90,70,.12);border-color:rgba(255,90,70,.28)}
  .xr-pre-morehint{margin:-3px 0 9px;font-size:11.5px;line-height:1.5;color:#8f9bab}
  .xr-pre-morelist{display:flex;flex-wrap:wrap;gap:8px}
  .xr-pre-morechip{display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,.03);border:1px dashed rgba(255,255,255,.18);border-radius:20px;padding:5px 12px;font-size:12px;color:#c9d2dd;cursor:pointer;transition:border-color .15s,color .15s,background .15s}
  .xr-pre-morechip:hover{border-color:rgba(255,154,77,.5);color:#ffb877;background:rgba(255,154,77,.06)}
  .xr-pre-morechip i{font-style:normal;font-weight:800;color:#7d8697;font-size:13px}
  .xr-pre-morechip:hover i{color:#ff9a4d}
  .xr-pre-foot{display:flex;align-items:center;gap:12px;margin-top:16px;padding-top:13px;border-top:1px solid rgba(255,255,255,.07)}
  .xr-pre-fine{font-size:11px;color:#9a8b7a;flex:1}
  .xr-pre-hist{margin:12px 0 0;padding:10px 13px;border-radius:10px;background:rgba(143,176,255,.07);border:1px solid rgba(143,176,255,.18);font-size:11.5px;line-height:1.55;color:#a9bbe0}
  .xr-pre-pccard{background:linear-gradient(135deg,rgba(255,154,77,.10),rgba(255,154,77,.03));border:1px solid rgba(255,154,77,.28);border-radius:12px;padding:11px 13px;margin:0 0 10px}
  .xr-pre-pctop{display:flex;align-items:center;gap:8px;font-size:13px;color:#ffe9d6}
  .xr-pre-pctop b{font-weight:800}
  .xr-pre-pcic{font-size:15px}
  .xr-pre-pcbadge{margin-left:auto;font-size:10px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;color:#ff9a4d;background:rgba(255,154,77,.12);border:1px solid rgba(255,154,77,.3);border-radius:20px;padding:3px 9px;white-space:nowrap}
  .xr-pre-pcsub{margin:6px 0 0;font-size:11.5px;line-height:1.55;color:#c9b39d}
  .xr-pre-hist a{color:#ff9a4d;font-weight:700;text-decoration:none}
  .xr-pre-hist a:hover{text-decoration:underline}
  .xr-dot{width:7px;height:7px;border-radius:50%;display:inline-block}
  .xr-dot.on{background:#40c06e;box-shadow:0 0 6px rgba(64,192,110,.6)}
  .xr-dot.warn{background:#ffb347;box-shadow:0 0 6px rgba(255,179,71,.6)}
  .xr-dot.none{background:#565b6b}
  /* Straight-to-Expert view: arriving from the script menu (#expert) shows ONLY the
     Expert Read setup; the full audit is one click back. */
  body.xr-solo #scoreCard,body.xr-solo #oaLive,body.xr-solo #healthDash{display:none}
  .xr-back{display:none;margin:0 0 10px;font-size:12px;font-weight:600;color:#9aa3b5;cursor:pointer;text-decoration:none;position:relative;z-index:1}
  .xr-back:hover{color:#e8e8ec}
  body.xr-solo .xr-back{display:inline-block}
  /* The dock styles above are tuned for a narrow panel; in a full browser window the
     card stretched edge to edge and read as mud. Solo view = centered readable column,
     hero-sized header, roomier rows. */
  body.xr-solo{max-width:860px;margin:0 auto;padding-top:34px}
  body.xr-solo h1{text-align:center;margin-bottom:20px;font-size:34px;font-weight:800;letter-spacing:.3px;color:#e8e8ec}
  body.xr-solo .xr-apps-note{text-align:center;font-size:12px;max-width:60ch;margin:10px auto 2px}
  body.xr-solo .xr-cta{padding:26px 30px 30px;border-radius:20px;animation:xrIn .42s cubic-bezier(.22,1.28,.36,1) both}
  @keyframes xrIn{from{opacity:0;transform:translateY(14px) scale(.985)}to{opacity:1;transform:none}}
  body.xr-solo .xr-back{position:absolute;top:18px;left:22px;margin:0}
  body.xr-solo .xr-cta-head{flex-direction:column;align-items:center;gap:10px;text-align:center;margin-top:14px}
  body.xr-solo .xr-cta-head b{font-size:24px;letter-spacing:.2px;color:#ffe9d2}
  body.xr-solo .xr-cta-sub{text-align:center;margin:10px auto 16px;font-size:13px;max-width:56ch}
  body.xr-solo .xr-apps{justify-content:center;margin:2px 0 8px}
  body.xr-solo .xr-app{font-size:12px;padding:6px 13px}
  body.xr-solo .xr-group{margin-top:22px}
  body.xr-solo .xr-ghead{font-size:11.5px}
  body.xr-solo .xr-opt{padding:16px 18px;gap:18px;border-radius:14px}
  body.xr-solo .xr-opt-name{font-size:16px}
  body.xr-solo .xr-opt-price{font-size:13px}
  body.xr-solo .xr-opt-desc{font-size:12.5px;margin-top:5px;max-width:62ch}
  body.xr-solo .xr-glow{right:-16%;top:-34%}
  @media (prefers-reduced-motion:reduce){.xr-btn,.xr-btn:hover,.xr-btn:active{transform:none}.xr-cta.xr-attn{animation:none}button.xr-app:active{transform:none}body.xr-solo .xr-cta{animation:none}}
  /* PC health dashboard: sparklines for this stream + across-streams trends */
  .hdash{background:linear-gradient(180deg,#161b23,#12161d);border:1px solid #242b37;border-radius:16px;padding:16px 16px 13px;margin-bottom:16px}
  .hd-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px}
  .hd-title{font-size:13.5px;font-weight:800;color:#f2f4f8}
  .hd-win{font-size:11.5px;color:#8fb0ff;font-weight:600;font-variant-numeric:tabular-nums;flex:1 1 auto;min-width:0}
  .hd-flag{margin-left:auto;font-size:10.5px;font-weight:800;color:#12131a;background:#e0b357;border-radius:999px;padding:3px 10px;white-space:nowrap}
  .hd-dash{font-size:11px;font-weight:800;color:#8fb0ff;text-decoration:none;white-space:nowrap;border:1px solid rgba(143,176,255,.28);border-radius:999px;padding:3px 11px;transition:background .15s,border-color .15s,color .15s}
  .hd-dash:hover{background:rgba(143,176,255,.12);border-color:rgba(143,176,255,.5);color:#b8ceff}
  .hd-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:8px}
  .hd-tile{background:#12161d;border:1px solid #222a35;border-radius:10px;padding:9px 11px 7px;min-width:0}
  .hd-k{display:flex;align-items:baseline;gap:6px}
  .hd-name{font-size:11px;font-weight:700;color:#8a919e;letter-spacing:.2px;white-space:nowrap}
  .hd-cur{margin-left:auto;font-size:13px;font-weight:800;font-variant-numeric:tabular-nums;color:#e8e8ec;white-space:nowrap}
  .hd-cur.ok{color:#6fd394}.hd-cur.warn{color:#e0b357}.hd-cur.bad{color:#ff6b6b}
  .hd-sub{font-size:10px;color:#6b7280;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .hd-svg{display:block;width:100%;height:36px;margin-top:5px}
  .hd-sec{margin-top:13px;border-top:1px solid #1e242e;padding-top:11px}
  .hd-sec-title{font-size:11px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;color:#9aa0ab;margin-bottom:7px}
  .hd-sec-title .hd-sub{text-transform:none;letter-spacing:0;font-weight:600;margin-left:6px;font-size:10.5px}
  .hd-trow{display:flex;align-items:center;gap:10px;padding:5px 0;border-top:1px solid #171c24;font-size:12px}
  .hd-trow:first-of-type{border-top:0}
  .hd-tname{color:#cfd3db;font-weight:700;flex:0 0 106px;white-space:nowrap;font-size:11.5px}
  .hd-tbars{flex:1;display:flex;align-items:flex-end;gap:2px;height:22px;min-width:40px}
  .hd-tbars i{flex:1;max-width:14px;border-radius:2px 2px 0 0;background:rgba(143,176,255,.38);min-height:2px}
  .hd-tbars i.last{background:#8fb0ff}
  .hd-tval{font-weight:800;font-variant-numeric:tabular-nums;color:#e8e8ec;white-space:nowrap;font-size:11.5px}
  .hd-dir{font-size:10.5px;font-weight:800;white-space:nowrap;flex:0 0 54px;text-align:right}
  .hd-dir.up{color:#ff6b6b}.hd-dir.down{color:#6fd394}.hd-dir.flat{color:#6b7280}
  .hd-empty{font-size:11.5px;color:#8a919e;padding:2px 0 4px}
  .hd-note{margin-top:8px;font-size:10.5px;color:#7d8697;line-height:1.5}
  #hdTip{position:fixed;z-index:9998;background:#1a212c;border:1px solid #2e3846;border-radius:8px;padding:5px 9px;font-size:11px;color:#dbe2ec;pointer-events:none;box-shadow:0 8px 24px rgba(0,0,0,.45);font-variant-numeric:tabular-nums;white-space:nowrap}
  #hdTip b{color:#fff}
]] .. SMW_CSS .. [[
</style></head><body>
<h1>StreamAudit<span class="hx">X</span> <b>●</b></h1>
<div class="fsh" id="scoreCard">
  <div class="fsh-top">
    <div class="num" id="scoreNum" style="color:]] .. bcolor .. [[">]] .. score .. [[</div>
    <div class="fsh-main">
      <div class="fsh-title">Full System Health</div>
      <div class="score-label"><span id="scoreLabel">]] .. score .. [[/100 &middot; ]] .. esc(btext) .. [[</span></div>
      <div class="bar"><i id="scoreBar" style="width:]] .. score .. [[%;background:]] .. bcolor .. [["></i></div>
    </div>
    <button class="reaudit" id="reauditBtn">&#8635; Re-audit</button>
  </div>
  <div class="fsh-lanes">
    <div class="lanerow"><span class="lane-name">OBS Health</span><span class="lane-bar"><i id="laneCfgBar" style="width:]] .. score .. [[%;background:]] .. bcolor .. [["></i></span><span class="lane-val" id="laneCfgVal" style="color:]] .. bcolor .. [[">]] .. score .. [[</span><span class="lane-band" id="laneCfgBand">]] .. esc(btext) .. [[</span></div>
    <div class="lanerow"><span class="lane-name">Last stream</span><span class="lane-bar"><i id="laneMeaBar" style="width:0%"></i></span><span class="lane-val" id="laneMeaVal">-</span><span class="lane-band" id="laneMeaBand">checking</span></div>
  </div>
]] .. session_line .. [[
  <div class="fsh-meters">
    <div class="meter live"><span class="m-dot"></span><span class="m-name">OBS</span><span class="m-bar"><i id="obsMeterBar" style="width:]] .. score .. [[%;background:]] .. bcolor .. [["></i></span><span class="m-val" id="obsMeterVal">]] .. score .. [[</span></div>
    ]] .. companion_meter("Streamer.bot", "streamerbot", comp.sb_status) .. [[
    ]] .. companion_meter("Lumia", "lumia", comp.lumia_status) .. [[
    ]] .. companion_meter("Mix It Up", "mixitup", comp.miu_status) .. [[
    ]] .. companion_meter("Firebot", "firebot", comp.fb_status) .. [[
    ]] .. companion_meter("VoiceMeeter", "voicemeeter", comp.vm_status) .. [[
    ]] .. companion_meter("TikTok LIVE Studio", "tiktok", comp.tt_status) .. [[
    ]] .. health_meter() .. [[
  </div>
  <div class="fsh-hint">OBS reads live here. Streamer.bot, Lumia, Mix It Up, Firebot, VoiceMeeter and TikTok LIVE Studio fold into one Expert Read, read automatically from your PC. If one shows a Connect button, click it and we will show you exactly where to point us.</div>
  <div class="tallies">
    <span class="tally t-good"><b id="tGood">]] .. passed .. [[</b> set up right</span>
    <span class="tally t-crit"><b id="tCrit">]] .. counts.critical .. [[</b> critical</span>
    <span class="tally t-warn"><b id="tWarn">]] .. counts.warning .. [[</b> to review</span>
  </div>
</div>
<div class="hdash" id="healthDash">
  <div class="hd-head">
    <div class="hd-title">PC health over time</div>
    <span class="hd-win" id="hdWin"></span>
    <span class="hd-flag" id="hdFlag" style="display:none"></span>
  </div>
  <div class="hd-empty" id="hdEmpty">Warming up. Your PC health charts appear after a minute of samples.</div>
  <div class="hd-grid" id="hdGrid"></div>
  <div class="hd-sec" id="hdTrendSec" style="display:none">
    <div class="hd-sec-title">Across your streams <span class="hd-sub" id="hdTrendSub"></span></div>
    <div id="hdTrend"></div>
    <div class="hd-note" id="hdTrendNote" style="display:none"></div>
  </div>
  <div id="hdTip" style="display:none"></div>
</div>
<div id="sxHelp" class="sx-help" hidden onclick="if(event.target===this)sxCloseHelp()">
  <div class="sx-help-card" role="dialog" aria-modal="true" aria-labelledby="sxHelpTitle">
    <button type="button" class="sx-help-x" onclick="sxCloseHelp()" aria-label="Close">&times;</button>
    <div class="sx-help-title" id="sxHelpTitle"></div>
    <div class="sx-help-sub" id="sxHelpSub" style="display:none"></div>
    <div class="sx-help-lead" id="sxHelpLead"></div>
    <ol class="sx-help-steps" id="sxHelpSteps"></ol>
    <div class="sx-help-done" id="sxHelpDone"></div>
    <div class="sx-help-note" id="sxHelpNote"></div>
  </div>
</div>
<script>
window.SX_SB_PATH=]] .. jesc(comp.sb_status_path or "") .. [[;
window.SX_SURF_APPS=]] .. companion_count .. [[;window.SX_SURF_NAMES=]] .. companion_names_js .. [[;
window.SX_APPS={streamerbot:"]] .. (comp.sb_status or "none") .. [[",lumia:"]] .. (comp.lumia_status or "none") .. [[",mixitup:"]] .. (comp.miu_status or "none") .. [[",firebot:"]] .. (comp.fb_status or "none") .. [[",voicemeeter:"]] .. (comp.vm_status or "none") .. [["};
window.SX_TT="]] .. (comp.tt_status or "none") .. [[";
window.SX_CRASHES=]] .. (comp.crash_ladder or "[]") .. [[;
window.SX_RESOURCE=]] .. sx_resource_json() .. [[;
window.SX_MEASURED_CHECKED=]] .. ((audit_log_meta and audit_pick and audit_log_meta[audit_pick] and audit_log_meta[audit_pick].streamed) and "true" or "false") .. [[;
(function(){
  if(window.top!==window.self){ return; }   // only the real top panel drives the popup
  var HELP={
    streamerbot:{
      app:'Streamer.bot', field:'Streamer.bot folder', exe:'Streamer.bot.exe',
      done:'That is it. Your Streamer.bot reads automatically with every Expert Read from now on. You never have to do this again unless you move the folder.'
    },
    lumia:{ app:'Lumia Stream', auto:true },
    mixitup:{ app:'Mix It Up', auto:true },
    firebot:{ app:'Firebot', auto:true },
    voicemeeter:{ app:'VoiceMeeter', auto:true }
  };
  window.sxConnectHelp=function(key,mode){
    var h=HELP[key]; if(!h) return;
    var T=document.getElementById('sxHelpTitle'), Sub=document.getElementById('sxHelpSub'),
        Lead=document.getElementById('sxHelpLead'), Steps=document.getElementById('sxHelpSteps'),
        Done=document.getElementById('sxHelpDone'), Note=document.getElementById('sxHelpNote');
    T.textContent=(mode==='moved'?'Re-connect ':'Connect ')+h.app;
    var steps;
    if(h.auto){
      // Auto-detected app: there is nothing to configure, so the "connect screen" is an
      // honest explanation of how detection happens and when the pill flips green.
      Sub.style.display='none';
      Lead.textContent='Good news: there is nothing to set up. StreamAuditX finds '+h.app+' on its own. If it shows not connected:';
      steps=[
        'Make sure <b>'+h.app+'</b> is installed and running on this PC.',
        'Use it like normal. It writes activity logs as it runs.',
        'Leave this panel open. The moment StreamAuditX spots those logs, the pill flips to <b>connected</b> by itself, usually within seconds.'
      ];
      Done.textContent='Once it shows connected, '+h.app+' is folded into the OBS + apps and Multi stream reads automatically.';
      Note.textContent='No accounts, no passwords, nothing leaves your PC. StreamAuditX only reads local log files.';
    } else {
      if(mode==='moved' && window.SX_SB_PATH){
        Sub.style.display='block';
        Sub.textContent='We can no longer find your '+h.app+' logs where you last pointed us ('+window.SX_SB_PATH+'). We also hunted inside and next to that folder automatically and came up empty, so point us at the new folder:';
        Lead.textContent='';
      } else {
        Sub.style.display='none';
        Lead.textContent='This is a one-time setup, inside OBS (not on a website). Follow these steps:';
      }
      steps=[
        'In <b>OBS</b>, click <b>Tools</b> in the menu bar along the very top of the OBS window.',
        'In the dropdown that opens, click <b>Scripts</b>.',
        'In the Scripts window, click <b>streamauditx.lua</b> in the list on the left.',
        'On the right side, find <b>'+h.field+'</b> and click its <b>Browse</b> button.',
        'Choose your '+h.app+' folder (the one that contains <b>'+h.exe+'</b>), then close the Scripts window.'
      ];
      Done.textContent=h.done;
      Note.textContent='Tip: you can reopen these steps any time by clicking Connect again.';
    }
    Steps.innerHTML=steps.map(function(s){return '<li>'+s+'</li>';}).join('');
    var m=document.getElementById('sxHelp'); m.hidden=false;
  };
  window.sxCloseHelp=function(){ var m=document.getElementById('sxHelp'); if(m) m.hidden=true; };
  document.addEventListener('keydown',function(e){ if(e.key==='Escape') window.sxCloseHelp(); });
})();
</script>
]] .. expert_block .. [[
<div id="oaLive">
]] .. smw_banner(smw) .. reads_html .. [[
<div class="tabs">]] .. table.concat(viewhtml, "") .. [[</div>
<div class="chips">]] .. table.concat(cathtml, "") .. [[</div>
<div id="filterhint" style="display:none"></div>
<div id="list">]] .. body .. [[</div>
<div class="clean" id="emptymsg" style="display:none"></div>
</div>
]] .. agg_block .. [[
<div id="sxRead"></div>
<div class="foot">by <a href="https://strmrx.com">StrmrX</a> &middot; free &amp; premium tools for streamers &middot; updates automatically while open &middot; <span title="After a stream, an anonymized findings summary and a redacted copy of that session's log help make the free checks smarter for everyone. Usernames, tokens and stream keys are stripped on this PC before anything is sent. Your choice on the Expert Read page always wins. Click to turn on or off.">Help improve: <a href="#" id="sxPrivT" onclick="sxImproveFlip(this);return false">on</a></span></div>
<script>
]] .. SX_SCORING_JS .. [[
</script>
<script>
(function(){
  // When this page is loaded inside our own hidden refresh iframe, do nothing but
  // exist as fresh server-rendered markup for the parent to read — no apply, no
  // websocket, no nested refresh loop.
  if(window.top!==window.self){ return; }
  // This panel is a file:// page that live-refreshes in place every 8s, and many browsers
  // refuse to persist localStorage for local files — so a dismissal saved only
  // there vanishes on the next reload and the finding pops back. Fix: mirror
  // state into the URL hash, which reload always preserves, with localStorage as
  // the cross-session store when the browser allows it.
  function hget(k){ try{ var m=new RegExp('[#&]'+k+'=([^&]*)').exec(location.hash); return m?decodeURIComponent(m[1]):null; }catch(e){ return null; } }
  function hset(k,val){ try{ var parts=location.hash.replace(/^#/,'').split('&').filter(Boolean), out=[], found=false;
    for(var i=0;i<parts.length;i++){ if(parts[i].split('=')[0]===k){ out.push(k+'='+encodeURIComponent(val)); found=true; } else out.push(parts[i]); }
    if(!found) out.push(k+'='+encodeURIComponent(val)); location.hash=out.join('&'); }catch(e){} }
  function lsGet(k){ try{ return localStorage.getItem(k); }catch(e){ return null; } }
  function lsSet(k,v){ try{ localStorage.setItem(k,v); }catch(e){} }
  function getSet(k,hk){ var s=new Set();
    try{ (JSON.parse(lsGet(k)||'[]')||[]).forEach(function(x){ s.add(x); }); }catch(e){}
    var h=hget(hk); if(h) h.split(',').forEach(function(x){ if(x) s.add(x); }); return s; }
  // s is a Set — use Array.from, NOT Array.prototype.slice.call (a Set isn't array-like,
  // so slice returns [] and every dismiss would silently save nothing).
  function saveSet(k,hk,s){ var ids=Array.from(s); lsSet(k,JSON.stringify(ids)); hset(hk,ids.join(','));
    if(window.smwSetData && k==='oa_dismissed') window.smwSetData('sx_audit_dismissed',ids); }
  var dismissed=getSet('oa_dismissed','d');
  // Gear marked "situational" (expected silent when idle). Stored as source NAMES in
  // three layers, same posture as dismissals: localStorage + the URL hash (file://
  // pages often refuse localStorage) + OBS's own storage, which is the SAME slot the
  // Lua scanner reads so the next deep scan stops flagging these sources at all.
  function sitLoad(){ var s=new Set();
    try{ (JSON.parse(lsGet('oa_situational')||'[]')||[]).forEach(function(x){ if(x) s.add(x); }); }catch(e){}
    var h=hget('sn'); if(h) h.split(' ||| ').forEach(function(x){ if(x) s.add(x); });
    return s; }
  var sitNames=sitLoad();
  function sitSave(){ var arr=Array.from(sitNames);
    lsSet('oa_situational',JSON.stringify(arr)); hset('sn',arr.join(' ||| '));
    if(window.smwSetData) smwSetData('sx_audit_situational', arr.join(' ||| ')); }
  // Widgets parked as "rarely used": same three-layer persistence, own slot. The Lua
  // scanner reads sx_audit_parked to swap the reload warning for the parked states.
  function parkLoad(){ var s=new Set();
    try{ (JSON.parse(lsGet('oa_parked')||'[]')||[]).forEach(function(x){ if(x) s.add(x); }); }catch(e){}
    var h=hget('pk'); if(h) h.split(' ||| ').forEach(function(x){ if(x) s.add(x); });
    return s; }
  var parkNames=parkLoad();
  function parkSave(){ var arr=Array.from(parkNames);
    lsSet('oa_parked',JSON.stringify(arr)); hset('pk',arr.join(' ||| '));
    if(window.smwSetData) smwSetData('sx_audit_parked', arr.join(' ||| ')); }
  // The streamer's "not an issue" choices, packaged for Expert Read payloads: ids plus
  // the human titles off the cards, so the read acknowledges instead of re-warning.
  // Cards for still-firing findings are always in the DOM (Dismissed view renders them),
  // so titles resolve; a long-gone finding ships id-only and matches nothing, harmlessly.
  // Situational gear rides along as one entry so a read never re-warns about sources
  // the streamer already said are only sometimes in use.
  window.sxDismissedPayload=function(){
    var out=[];
    dismissed.forEach(function(id){
      var t='';
      var el=document.querySelector('.card[data-id="'+id+'"] .title');
      if(el){ var n=el.childNodes[0]; t=((n&&n.nodeValue)||'').replace(/\s+/g,' ').trim(); }
      out.push({id:id,title:t});
    });
    if(sitNames.size){ out.push({id:'situational-gear',title:'Audio sources marked situational, silence expected when idle: '+Array.from(sitNames).join(', ')}); }
    if(parkNames.size){ out.push({id:'parked-widgets',title:'Widgets marked rarely used, parked on their resource saving reload setting: '+Array.from(parkNames).join(', ')}); }
    return out;
  };
  var view=hget('v')||lsGet('oa_view')||'issues';
  var cat=hget('c')||lsGet('oa_cat')||'all';
  var expanded=getSet('oa_expand','e');   // finding ids whose drill-down list is open

  // Show/hide each drill-down list from the `expanded` set and swap its toggle label.
  // Runs on load and after every reload, so an opened list survives the 8s refresh.
  function applyDrill(){
    var lists=document.querySelectorAll('.drill-list');
    for(var i=0;i<lists.length;i++){ var el=lists[i], id=el.getAttribute('data-drilllist'), open=expanded.has(id);
      el.style.display=open?'':'none';
      var btn=document.querySelector('.drill-toggle[data-drill="'+id+'"]');
      if(btn){ var lab=btn.getAttribute('data-label')||''; btn.textContent=(open?'▾ Hide ':'▸ Show ')+lab; }
    }
  }

  var EMPTY={issues:'✓ No problems found. Nothing here is going to hurt your stream.',
    sug:'No suggestions right now, nice.',
    tidy:'✓ Nothing to tidy up. No leftover clutter in your OBS.', healthy:'Nothing to show yet.',
    dismissed:'Nothing here yet. When you mark a finding “not an issue,” mark gear “situational,” or park a widget as rarely used, it lands here, ready to bring back any time.'};
  // For the filter-aware empty state: what one item in each tab is called.
  var NOUN1={issues:'issue',sug:'suggestion',tidy:'thing to tidy',healthy:'healthy check',dismissed:'dismissed item'};
  var NOUNS={issues:'issues',sug:'suggestions',tidy:'things to tidy',healthy:'healthy checks',dismissed:'dismissed items'};

  // Scoring v2: the shared module embedded above (character-identical to web/scoring.js)
  // owns ALL lane math, banding and why lines. This panel only gathers the facts and
  // owns the dock's WORDS for each band key (per-surface wording is policy; the key is
  // the fact). Critical-aware red stays a dock policy: zero criticals never shows red.
  var BAND_ORDER={failing:0,attention:1,fair:2,good:3,great:4};
  var CFG_WORDS={great:'Dialed in',good:'Solid · a few quick wins',fair:'Safe to stream · cleanup recommended',attention:'Safe to stream · lots to tidy up'};
  var MEA_WORDS={great:'Your recent streams ran clean',good:'Your recent streams ran mostly clean',fair:'Rough patches in your recent streams',attention:'Real trouble in your recent streams'};
  function bandColor(key,crit){ if(crit>0||key==='failing') return '#ff6b6b';
    return key==='great'?'#6fd394':key==='good'?'#8fd36f':'#ffc24d'; }
  function laneFill(px,score,col,txt,gateFlag){
    var b=document.getElementById(px+'Bar'),v=document.getElementById(px+'Val'),d=document.getElementById(px+'Band');
    if(b){ b.style.width=(score==null?0:score)+'%'; b.style.background=col; }
    if(v){ v.textContent=(score==null?'-':score); v.style.color=(score==null?'#8a919e':col); }
    if(d){ d.textContent=txt; d.className='lane-band'+(gateFlag?' gate':''); }
  }

  function apply(){
    var cards=document.querySelectorAll('.card[data-kind]'), vis=0;
    var passed=0, crit=0, warn=0, nIssue=0, nSug=0, nTidy=0, nHealthy=0, nDismissed=0;
    var cfgF=[], meaF=[];   // lane findings for the shared scoring module
    for(var i=0;i<cards.length;i++){
      var c=cards[i], kind=c.getAttribute('data-kind'), sev=c.getAttribute('data-sev'),
          ccat=c.getAttribute('data-cat'), id=c.getAttribute('data-id'), isD=id&&dismissed.has(id);
      // A card whose flagged sources were ALL marked situational is a standing choice,
      // not a problem: it must not count against the score or the pills, exactly like a
      // dismissal. This covers the window between the click and the next deep scan
      // (which rebuilds the findings without those sources); the Dismissed tab's
      // situational block is the visible record, so the card itself stays hidden.
      var sb=c.querySelector('.oa-situational'), isSit=false;
      if(sb&&sitNames.size){ var snl=(sb.getAttribute('data-names')||'').split(' ||| ').filter(Boolean);
        isSit=snl.length>0; for(var s2=0;s2<snl.length;s2++){ if(!sitNames.has(snl[s2])){ isSit=false; break; } } }
      // Same for a widget-reload card whose widget was just parked: score-neutral and
      // hidden until the next scan swaps it for the parked states. Only cards CARRYING
      // the park button in their FOOTER (the warning variant) hide; the parked tips
      // never do, and neither does the browser-source overview card, whose drill-down
      // rows carry their own per-widget park buttons.
      var pb=c.querySelector('.cardfoot .oa-park'), isPark=false;
      if(pb&&parkNames.size){ var pnm=pb.getAttribute('data-name'); isPark=!!(pnm&&parkNames.has(pnm)); }
      // running totals for score + badges (independent of the current view)
      if(kind==='healthy'){ passed++; nHealthy++; }
      else if(isD){ nDismissed++; }
      else if(isSit||isPark){ /* counted via the Dismissed-tab blocks, one entry per name */ }
      else if(kind==='issue'){ nIssue++; if(sev==='critical')crit++; else if(sev==='warning')warn++;
        // Lane findings for the module. Crash cards count in the pills but NEVER as
        // lane findings: the crash recency ladder (SX_CRASHES) owns crashes in the
        // measured lane, so a crash is never double-charged.
        if(ccat!=='crash'){
          var tEl=c.querySelector('.title');
          var lab=tEl?(tEl.childNodes[0]?tEl.childNodes[0].textContent:tEl.textContent):'';
          var fd={id:id||'',label:(lab||'').replace(/\s+/g,' ').trim()||'A finding',severity:sev};
          if(c.getAttribute('data-scope')==='measured') meaF.push(fd); else cfgF.push(fd);
        }
      }
      else if(kind==='sug'){ nSug++; }
      else if(kind==='tidy'){ nTidy++; }
      // visibility for the current view + category
      var catOk=(cat==='all'||ccat===cat), show;
      if(view==='dismissed'){ show = isD && catOk; }
      else if(view==='healthy'){ show = kind==='healthy' && catOk; }
      else { var wantKind=(view==='issues')?'issue':view; show = !isD && !isSit && !isPark && kind===wantKind && catOk; }  // 'issues' tab holds 'issue' cards
      c.style.display = show?'':'none';
      if(show){ vis++;
        var db=c.querySelector('.oa-dismiss'), rb=c.querySelector('.oa-restore');
        if(db) db.style.display=(view==='dismissed')?'none':'';
        if(rb) rb.style.display=(view==='dismissed')?'':'none';
      }
    }
    // section heads: show only if they have a visible card under them
    var heads=document.querySelectorAll('[data-sevhead]');
    for(var h=0;h<heads.length;h++){
      var s=heads[h].getAttribute('data-sevhead'), sc=document.querySelectorAll('.card[data-sev="'+s+'"]'), n=0;
      for(var k=0;k<sc.length;k++){ if(sc[k].style.display!=='none') n++; }
      heads[h].style.display=n?'':'none';
    }
    document.getElementById('tGood').textContent=passed;
    document.getElementById('tCrit').textContent=crit;
    document.getElementById('tWarn').textContent=warn;
    // Scoring v2: hand the walked facts to the shared module (two lanes, worst lane
    // wins). Live sustained-saturation flags from the health sampler join the measured
    // lane the moment they trip; the crash ladder rides in from SX_CRASHES. Guarded so
    // a scoring hiccup can never take the tabs/dismiss wiring down with it.
    try{
    var H=(window.SX_HEALTH&&window.SX_HEALTH.now)||{};
    if(H.sat_cpu)meaF.push({id:'sustained-cpu',label:'CPU maxed out for a sustained stretch right now (measured live)',severity:'warning'});
    if(H.sat_gpu)meaF.push({id:'sustained-gpu',label:'GPU maxed out for a sustained stretch right now (measured live)',severity:'warning'});
    if(H.sat_hot)meaF.push({id:'sustained-hot',label:'Thermal or power throttling right now (measured live)',severity:'warning'});
    var CR=window.SX_CRASHES||[];
    var checked=!!window.SX_MEASURED_CHECKED||meaF.length>0||CR.length>0||!!H.live;
    var lanes=window.SXScoring.scoreLanes({
      config:{findings:cfgF,resource:window.SX_RESOURCE||[]},
      measured:{checked:checked,findings:meaF,crashes:CR}
    });
    var gate=lanes.measured.gate;
    var cfgCrit=lanes.config.hasCritical?1:0;
    var cfgCol=bandColor(lanes.config.band,cfgCrit);
    // overall verdict = worst lane; the gate's own sentence beats every band word
    var oKey=lanes.overall.band;
    var oCol=(oKey==='failing'||cfgCrit>0)?'#ff6b6b':bandColor(oKey,0);
    var meaWorst=lanes.measured.checked!==false&&BAND_ORDER[lanes.measured.band]<=BAND_ORDER[lanes.config.band];
    var oText=gate?gate.label:(cfgCrit>0?'Fix the critical items before you go live':(meaWorst?MEA_WORDS[oKey]:CFG_WORDS[oKey]));
    var score=lanes.overall.score;
    var nEl=document.getElementById('scoreNum'); nEl.textContent=score; nEl.style.color=oCol;
    document.getElementById('scoreBar').style.width=score+'%';
    document.getElementById('scoreBar').style.background=oCol;
    document.getElementById('scoreLabel').textContent=score+'/100 · '+oText;
    // the OBS sub-meter among the companions tracks the config lane (OBS setup health)
    var omV=document.getElementById('obsMeterVal'); if(omV) omV.textContent=lanes.config.score;
    var omB=document.getElementById('obsMeterBar'); if(omB){ omB.style.width=lanes.config.score+'%'; omB.style.background=cfgCol; }
    laneFill('laneCfg',lanes.config.score,cfgCol,
      cfgCrit>0?'Fix the critical items before you go live':CFG_WORDS[lanes.config.band]);
    if(lanes.measured.checked===false){
      laneFill('laneMea',null,'#3a4150','Fills in after your next stream');
    } else {
      laneFill('laneMea',lanes.measured.score,bandColor(lanes.measured.band,0),
        gate?gate.label:MEA_WORDS[lanes.measured.band],!!gate);
    }
    }catch(eScore){}
    // badges — the Dismissed pill counts every choice: dismissed cards plus each
    // situational source and each parked widget (rows in the blocks, not cards)
    var bmap={issues:nIssue,sug:nSug,tidy:nTidy,healthy:nHealthy,dismissed:nDismissed+sitNames.size+parkNames.size};
    var bs=document.querySelectorAll('[data-badge]');
    for(var b=0;b<bs.length;b++){ bs[b].textContent=bmap[bs[b].getAttribute('data-badge')]||0; }
    // active states
    var tabs=document.querySelectorAll('.tab');
    for(var t=0;t<tabs.length;t++){ tabs[t].className='tab'+(tabs[t].getAttribute('data-view')===view?' on':''); }
    var chips=document.querySelectorAll('.chip');
    for(var q=0;q<chips.length;q++){ chips[q].className='chip'+(chips[q].getAttribute('data-cat')===cat?' on':''); }
    // The standing "marked situational" record: lives in the Dismissed tab even after
    // the deep scan stops flagging those sources, so the choice stays visible and
    // reversible forever. Rebuilt every apply (the 8s refresh swaps #oaLive markup).
    var em=document.getElementById('emptymsg'), fh=document.getElementById('filterhint');
    function escH(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
    var sr=document.getElementById('sitrec');
    if(!sr&&em&&em.parentNode){ sr=document.createElement('div'); sr.id='sitrec'; em.parentNode.insertBefore(sr,em); }
    var blockVis=(view==='dismissed'&&(sitNames.size>0||parkNames.size>0));
    if(sr){
      if(blockVis){
        var sh='';
        if(sitNames.size){
          sh+='<div class="sitrec-head">◔ Gear you marked situational</div>'+
            '<div class="sitrec-sub">Only in use sometimes, so silence is expected when idle. The audit skips these instead of flagging them, and Expert Reads know about the choice too.</div>';
          sitNames.forEach(function(nm){ sh+='<div class="sitrec-row"><span>'+escH(nm)+'</span><button class="oa-sitrestore" data-name="'+escH(nm)+'">↩ Bring back</button></div>'; });
        }
        if(parkNames.size){
          sh+='<div class="sitrec-head">◔ Widgets you parked as rarely used</div>'+
            '<div class="sitrec-sub">Kept for later on their resource saving reload setting. The audit stays quiet while they sit, and speaks up on days you actually use them.</div>';
          parkNames.forEach(function(nm){ sh+='<div class="sitrec-row"><span>'+escH(nm)+'</span><button class="oa-parkrestore" data-name="'+escH(nm)+'">↩ Bring back</button></div>'; });
        }
        sr.innerHTML=sh; sr.style.display='';
      } else { sr.style.display='none'; }
    }
    // empty state — filter-aware: when a category chip hides everything, the tab's
    // badge still shows its true total, so an absolute all-clear here would lie.
    // The situational block counts as content: never claim "nothing dismissed" above it.
    var vTotal=bmap[view]||0;
    var cbtn=document.querySelector('.chip[data-cat="'+cat+'"]'), catLabel=cbtn?cbtn.textContent:cat;
    if(vis===0&&!blockVis){
      if(cat!=='all'&&vTotal>0){
        var noun=(vTotal===1?NOUN1[view]:NOUNS[view])||'items';
        em.textContent='Nothing under '+catLabel+' here. '+vTotal+' '+noun+' under your other categories · tap All to see them.';
        em.className='clean filtered';
      } else { em.textContent=EMPTY[view]||'Nothing here.'; em.className='clean'; }
      em.style.display='';
    } else { em.style.display='none'; }
    // quiet truth line when a chip is hiding part of the tab ("Showing 1 of 15")
    if(fh){ if(cat!=='all'&&vis<vTotal){ fh.textContent='Showing '+vis+' of '+vTotal+' · '+catLabel+' only'; fh.style.display=''; } else { fh.style.display='none'; } }
    applyDrill();
  }

  document.addEventListener('click',function(e){
    var t=e.target;
    var ra=t.closest?t.closest('#reauditBtn'):null;
    if(ra){ ra.textContent='↻ Re-checking…'; oaRefresh(function(){ var rb=document.getElementById('reauditBtn'); if(rb) rb.textContent='↻ Re-audit'; }); return; }
    var dl=t.closest?t.closest('.drill-toggle'):null;
    if(dl){ var did=dl.getAttribute('data-drill');
      if(expanded.has(did)) expanded.delete(did); else expanded.add(did);
      saveSet('oa_expand','e',expanded); applyDrill(); return; }
    var tab=t.closest?t.closest('.tab'):null;
    if(tab){ view=tab.getAttribute('data-view'); lsSet('oa_view',view); hset('v',view); apply(); return; }
    var chip=t.closest?t.closest('.chip'):null;
    if(chip){ cat=chip.getAttribute('data-cat'); lsSet('oa_cat',cat); hset('c',cat); apply(); return; }
    var d=t.closest?t.closest('.oa-dismiss'):null;
    if(d){ var id=d.getAttribute('data-id'); if(id){ dismissed.add(id); saveSet('oa_dismissed','d',dismissed); apply(); } return; }
    var r=t.closest?t.closest('.oa-restore'):null;
    if(r){ var id2=r.getAttribute('data-id'); if(id2){ dismissed.delete(id2); saveSet('oa_dismissed','d',dismissed); apply(); } return; }
    var sit=t.closest?t.closest('.oa-situational'):null;
    if(sit){ (sit.getAttribute('data-names')||'').split(' ||| ').forEach(function(x){ if(x) sitNames.add(x); });
      sitSave(); apply();
      if(typeof smwMsg==='function') smwMsg("Marked as situational. They moved to your Dismissed tab and stop counting against your score.", true);
      return; }
    var sres=t.closest?t.closest('.oa-sitrestore'):null;
    if(sres){ var nm=sres.getAttribute('data-name');
      if(nm){ sitNames.delete(nm); sitSave(); apply();
        if(typeof smwMsg==='function') smwMsg("Brought back. The audit will flag this source again if it starts silent.", true); }
      return; }
    var pk=t.closest?t.closest('.oa-park'):null;
    if(pk){ var pn=pk.getAttribute('data-name');
      if(pn){ parkNames.add(pn); parkSave(); apply();
        if(typeof smwMsg==='function') smwMsg("Parked. It stops counting against your score, and the audit will speak up on days you actually use it.", true); }
      return; }
    var pres=t.closest?t.closest('.oa-parkrestore'):null;
    if(pres){ var pn2=pres.getAttribute('data-name');
      if(pn2){ parkNames.delete(pn2); parkSave(); apply();
        if(typeof smwMsg==='function') smwMsg("Off the parked list. The audit treats it as a widget you use again.", true); }
      return; }
  });

  apply();
  // Let the health dashboard rescore the measured lane the moment a sustained
  // saturation flag flips (its data file loads outside this closure).
  window.sxApply=apply;
  // Arrived from the script-page "Get an Expert Read" button: surface + pulse the CTA
  // so it can't be missed. The paid action still needs a deliberate click on the card.
  if(location.hash.indexOf('expert')>=0){ var _xc=document.getElementById('xrCta');
    if(_xc){ try{ _xc.scrollIntoView({behavior:'smooth',block:'center'}); }catch(e){} _xc.classList.add('xr-attn'); } }
  // On load, pull the dismiss list from OBS's own storage (bulletproof, any browser)
  // once the websocket helpers exist (defined in the script block appended after this).
  if(window.addEventListener) window.addEventListener('load',function(){
    if(window.smwGetData) window.smwGetData('sx_audit_dismissed',function(v){
      // Merge OBS's saved list IN (additive). Never replace-with-empty: a null or stale
      // read must not wipe what the hash/localStorage already restored this reload.
      if(!(v && v.constructor===Array) || !v.length) return;
      var ch=false; for(var i=0;i<v.length;i++){ if(!dismissed.has(v[i])){ dismissed.add(v[i]); ch=true; } }
      if(ch){ lsSet('oa_dismissed',JSON.stringify(Array.from(dismissed))); apply(); }
    });
    // Same for situational gear (stored as a ' ||| '-joined string, the format the
    // Lua scanner reads). Additive merge, never replace-with-empty.
    if(window.smwGetData) window.smwGetData('sx_audit_situational',function(v){
      if(typeof v!=='string'||!v) return;
      var ch2=false; v.split(' ||| ').forEach(function(x){ x=x.replace(/^\s+|\s+$/g,''); if(x&&!sitNames.has(x)){ sitNames.add(x); ch2=true; } });
      if(ch2){ lsSet('oa_situational',JSON.stringify(Array.from(sitNames))); apply(); }
    });
    // And parked widgets, same slot pattern.
    if(window.smwGetData) window.smwGetData('sx_audit_parked',function(v){
      if(typeof v!=='string'||!v) return;
      var ch3=false; v.split(' ||| ').forEach(function(x){ x=x.replace(/^\s+|\s+$/g,''); if(x&&!parkNames.has(x)){ parkNames.add(x); ch3=true; } });
      if(ch3){ lsSet('oa_parked',JSON.stringify(Array.from(parkNames))); apply(); }
    });
  });
  try{ var sy=parseInt(sessionStorage.getItem('oa_scroll')||'0',10); if(sy) window.scrollTo(0,sy); }catch(e){}
  window.addEventListener('scroll',function(){ try{ sessionStorage.setItem('oa_scroll',String(window.scrollY)); }catch(e){} });
  // Live in-place refresh (no hard reload). Pull the freshly-written file into a
  // hidden same-origin iframe, lift its regenerated #oaLive region out, swap it in,
  // and re-run apply(). All page state (view/cat/dismiss/scroll/toast/fix buttons)
  // survives because the document is never reloaded.
  var _oaTimer=null;
  function oaSchedule(){ clearTimeout(_oaTimer); _oaTimer=setTimeout(oaRefresh,8000); }
  function oaBusy(){ return !!(document.querySelector('.oa-fix.working')||(window._oaHold&&Date.now()<window._oaHold)); }
  function oaRefresh(cb){
    if(oaBusy()){ oaSchedule(); if(cb)cb(); return; }   // never yank a fix mid-flight
    var f=document.getElementById('oaFrame');
    if(!f){ f=document.createElement('iframe'); f.id='oaFrame'; f.setAttribute('aria-hidden','true');
      f.style.cssText='position:absolute;left:-9999px;top:0;width:0;height:0;border:0;opacity:0;pointer-events:none';
      document.body.appendChild(f); f._first=true; }
    f.onload=function(){
      try{
        var doc=f.contentDocument||(f.contentWindow&&f.contentWindow.document);
        var fresh=doc&&doc.getElementById('oaLive'), cur=document.getElementById('oaLive');
        if(fresh&&cur&&!oaBusy()){ var sy=window.scrollY; cur.innerHTML=fresh.innerHTML; apply(); window.scrollTo(0,sy); }
        // Lift the fresh app-connection status + read payloads too, so the Expert Read
        // picker (and what an actual purchase uploads) always reflects RIGHT NOW,
        // not the moment this tab first opened.
        var w=f.contentWindow;
        if(w){
          if(w.SX_APPS){ window.SX_APPS=w.SX_APPS; if(window.sxRenderApps){ window.sxRenderApps(); } }
          if(w.SX_TT){ window.SX_TT=w.SX_TT; }
          // Scoring v2 lane inputs regenerate with every scan: lift them so a crash
          // that just landed (or a widget just parked) rescored on the next apply.
          if(w.SX_CRASHES){ window.SX_CRASHES=w.SX_CRASHES; }
          if(w.SX_RESOURCE){ window.SX_RESOURCE=w.SX_RESOURCE; }
          if(typeof w.SX_MEASURED_CHECKED!=='undefined'){ window.SX_MEASURED_CHECKED=w.SX_MEASURED_CHECKED; }
          if(w.SX_EXPERT){ window.SX_EXPERT=w.SX_EXPERT; }
          if(w.SX_AGG){ window.SX_AGG=w.SX_AGG; window.SX_AGG_COUNT=w.SX_AGG_COUNT; }
          if(w.SX_CONTRIB){ window.SX_CONTRIB=w.SX_CONTRIB; }
          // A refreshed snapshot may carry a newly completed stream: offer it to the
          // corpus (sxContribute dedupes by session name and honors the consent posture).
          if(window.sxContribute){ window.sxContribute(); }
        }
        // The score card lives OUTSIDE #oaLive and used to stay frozen until a full
        // page load (stale score after a re-audit, frozen meters). Lift it too when
        // it actually changed, unless the session picker is in use, then re-assert
        // the live PC health meter onto the fresh markup.
        var fsc=doc&&doc.getElementById('scoreCard'), csc=document.getElementById('scoreCard');
        if(fsc&&csc&&fsc.innerHTML!==csc.innerHTML&&!(document.activeElement&&csc.contains(document.activeElement))){
          csc.innerHTML=fsc.innerHTML;
          if(window.sxHealthPaint){ try{ window.sxHealthPaint(); }catch(e3){} }
          apply();   // the fresh markup carries Lua-baked placeholders; rescore the lanes now
        }
      }catch(e){}
      if(cb)cb(); oaSchedule();
    };
    try{ if(f._first){ f._first=false; f.src=location.href.split('#')[0]; } else { f.contentWindow.location.reload(); } }
    catch(e){ f.src=location.href.split('#')[0]; }
  }
  oaSchedule();
})();
</script>
<script>
(function(){
  if(window.top!==window.self){ return; }   // the hidden refresh iframe never charts
  // ── PC health dashboard ─────────────────────────────────
  // Data arrives as streamauditx-health.js, written next to this file by the script
  // every 8 seconds. Re-loading a script tag works on file:// where fetch would not,
  // and the sampler keeps writing DURING a stream, so these charts stay live even
  // while the audit scan auto-pauses. The data file calls sxHealthPaint() itself.
  var seq=0;
  function hdLoad(){
    var s=document.createElement('script');
    s.src='streamauditx-health.js?v='+(++seq);
    s.onload=function(){ if(s.parentNode)s.parentNode.removeChild(s); };
    s.onerror=function(){ if(s.parentNode)s.parentNode.removeChild(s); };
    document.body.appendChild(s);
    setTimeout(hdLoad,8000);
  }
  // ── hosted dashboard uploader (premium + consent only) ──
  // streamauditx-upload.js is written by the script ONLY when the member is
  // premium with the improve toggle on and an upload key mirrored from the
  // Expert Read page. It calls sxUploadKick(). We ask the server what it already
  // has, then send just the missing sessions, one small POST each; the server
  // re-checks tier + consent per upload and upserts, so retries are harmless.
  var upBusy=false;
  window.sxUploadKick=function(){
    var U=window.SX_UPLOAD;
    if(!U||!U.key||!U.sessions||!U.sessions.length||upBusy)return;
    upBusy=true;
    fetch(U.api+'/api/health/have',{headers:{'X-SX-Health-Key':U.key}})
      .then(function(r){ return r.ok?r.json():{sessions:[]}; })
      .then(function(h){
        var have={},i,hs=(h&&h.sessions)||[];
        for(i=0;i<hs.length;i++){ have[hs[i] ]=1; }   /* the space matters: double close brackets would end the Lua literal */
        var todo=[];
        for(i=0;i<U.sessions.length;i++){ if(!have[U.sessions[i].s])todo.push(U.sessions[i]); }
        var at=0;
        function next(){
          if(at>=todo.length){ upBusy=false; return; }
          var s=todo[at++];
          fetch(U.api+'/api/health/upload',{method:'POST',headers:{'Content-Type':'application/json','X-SX-Health-Key':U.key},
            body:JSON.stringify({session:s.s,label:s.label,series:s.series})})
            .then(function(r){ if(r.status===403)at=todo.length; next(); })
            .catch(function(){ upBusy=false; });
        }
        next();
      }).catch(function(){ upBusy=false; });
  };
  function upLoad(){
    var s=document.createElement('script');
    s.src='streamauditx-upload.js?v='+Date.now();
    s.onload=function(){ if(s.parentNode)s.parentNode.removeChild(s); };
    s.onerror=function(){ if(s.parentNode)s.parentNode.removeChild(s); };
    document.body.appendChild(s);
  }
  function el(id){ return document.getElementById(id); }
  function fmtV(v,dec){ if(v==null||v<0)return '-'; var m=Math.pow(10,dec||0); return String(Math.round(v*m)/m); }
  function fmtDur(mins){ if(mins>=60){ var h=Math.floor(mins/60),m=mins%60; return h+'h'+(m?(' '+m+'m'):''); } return mins+'m'; }
  function fmtT(t){ try{ var d=new Date(t*1000),h=d.getHours(),m=d.getMinutes(),ap=h>=12?'pm':'am'; h=h%12; if(h===0)h=12; return h+':'+(m<10?'0':'')+m+ap; }catch(e){ return ''; } }
  function stCls(v,warn,bad){ if(v==null||v<0)return ''; if(v>=bad)return ' bad'; if(v>=warn)return ' warn'; return ''; }
  var TILES=[
    {k:'cpu',name:'CPU',unit:'%',lo:0,hi:100,warn:85,bad:95},
    {k:'gpu',name:'GPU',unit:'%',lo:0,hi:100,warn:90,bad:97},
    {k:'ram',name:'RAM',unit:'%',lo:0,hi:100,warn:85,bad:95},
    {k:'tmp',name:'GPU temp',unit:'°C',lo:25,hi:95,warn:80,bad:87},
    {k:'enc',name:'Encoder',unit:'%',lo:0,hi:100,warn:90,bad:98},
    {k:'lr',name:'Render lag',unit:'%',bars:true,warn:1,bad:3,hint:'GPU too busy'},
    {k:'nd',name:'Net drops',unit:'%',bars:true,warn:1,bad:3,hint:'your connection'},
    {k:'es',name:'Enc skips',unit:'%',bars:true,warn:1,bad:3,hint:'encoder overloaded'}
  ];
  function peakOf(a){ var p=-1,i; for(i=0;i<a.length;i++){ if(a[i]>p)p=a[i]; } return p; }
  function avgOf(a){ var s=0,n=0,i; for(i=0;i<a.length;i++){ if(a[i]>=0){ s+=a[i]; n++; } } return n?s/n:-1; }
  function spark(vals,lo,hi,warn,bars){
    var n=vals.length; if(!n)return '';
    var W=100,H=36,P=2;
    function Y(v){ v=Math.max(lo,Math.min(hi,v)); return H-P-((v-lo)/(hi-lo))*(H-2*P); }
    var o='<svg class="hd-svg" viewBox="0 0 '+W+' '+H+'" preserveAspectRatio="none" aria-hidden="true">';
    if(warn!=null&&warn>lo&&warn<hi){ var wy=Y(warn).toFixed(1);
      o+='<line x1="0" y1="'+wy+'" x2="'+W+'" y2="'+wy+'" stroke="rgba(224,179,87,.3)" stroke-width="1" stroke-dasharray="3 3" vector-effect="non-scaling-stroke"/>'; }
    if(bars){
      var bw=W/n;
      for(var i=0;i<n;i++){ var v=vals[i];
        if(v>0){ var yy=Y(v);
          o+='<rect x="'+(i*bw+bw*0.15).toFixed(2)+'" y="'+yy.toFixed(1)+'" width="'+(bw*0.7).toFixed(2)+'" height="'+(H-P-yy).toFixed(1)+'" rx="1" fill="#8fb0ff" opacity=".85"/>'; } }
      o+='<line x1="0" y1="'+(H-P)+'" x2="'+W+'" y2="'+(H-P)+'" stroke="#232833" stroke-width="1" vector-effect="non-scaling-stroke"/>';
    } else {
      var segs=[],cs=[],j;
      for(j=0;j<n;j++){ var v2=vals[j];
        if(v2>=0){ cs.push([(n===1?W/2:j/(n-1)*W),Y(v2)]); }
        else if(cs.length){ segs.push(cs); cs=[]; } }
      if(cs.length)segs.push(cs);
      for(var g=0;g<segs.length;g++){ var sg=segs[g],pts='',p0=sg[0],pl=sg[sg.length-1];
        for(var q=0;q<sg.length;q++){ pts+=(q?' ':'')+sg[q][0].toFixed(1)+','+sg[q][1].toFixed(1); }
        if(sg.length>1){
          o+='<polygon points="'+pts+' '+pl[0].toFixed(1)+','+(H-P)+' '+p0[0].toFixed(1)+','+(H-P)+'" fill="rgba(143,176,255,.10)"/>';
          o+='<polyline points="'+pts+'" fill="none" stroke="#8fb0ff" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>';
        } else {
          o+='<circle cx="'+p0[0].toFixed(1)+'" cy="'+p0[1].toFixed(1)+'" r="1.8" fill="#8fb0ff"/>';
        }
      }
    }
    return o+'</svg>';
  }
  function trendPaint(D){
    var sec=el('hdTrendSec'),sub=el('hdTrendSub'),host=el('hdTrend'),note=el('hdTrendNote');
    if(!sec||!host)return;
    var tr=(D&&D.trend)||[];
    sec.style.display='';
    if(!tr.length){
      if(sub)sub.textContent='';
      host.innerHTML='<div class="hd-empty">Every stream you run with StreamAuditX open lands here, charted stream over stream.</div>';
      if(note)note.style.display='none';
      return;
    }
    // The history-compounds line: free members see the 3-stream teaser + the member
    // pitch, members see how much history is working for them.
    if(note){
      var kn=D.kept||tr.length;
      var cP=D.capPremium||60;
      if(D.premium){
        note.innerHTML='Your membership keeps '+(kn===1?'your last stream':'your last '+kn+' streams')+' (up to '+cP+'). The more you stream with StreamAuditX running, the deeper every read gets.';
      }else if(!D.tierKnown){
        // Tier not yet confirmed on this machine. NEVER imply data loss here: existing streams are
        // grandfathered and kept regardless of tier, so a "keeping only your last 3" line would be a
        // false scare for a member whose slot simply is not primed yet. Affirm the data is safe and
        // ask them to confirm their plan once.
        note.innerHTML='Your streams are saved on your PC, and your full history is right here in the dock. Open an Expert Read while signed in and StreamAuditX picks up your plan automatically.';
      }else if(kn>3){
        note.innerHTML='You have '+kn+' streams saved. The free plan keeps your newest 3 going forward. <a href="https://strmrx.com" target="_blank" rel="noopener" style="color:#ff9a4d;font-weight:700;text-decoration:none">S and X members</a> keep their last '+cP+', so each read gets deeper the more they stream.';
      }else{
        note.innerHTML='You are on the free plan, keeping your last 3 streams. <a href="https://strmrx.com" target="_blank" rel="noopener" style="color:#ff9a4d;font-weight:700;text-decoration:none">S and X members</a> keep their last '+cP+', so each read gets deeper and smarter the more they stream.';
      }
      note.style.display='';
    }
    if(sub){ sub.textContent=(D.trend_total>tr.length)?('last '+tr.length+' of '+D.trend_total+' streams'):('your last '+tr.length+(tr.length===1?' stream':' streams')); }
    var rev=tr.slice().reverse();   // oldest to newest so time reads left to right
    function worst(r){ var w=-1; if(r.lr>w)w=r.lr; if(r.nd>w)w=r.nd; if(r.es>w)w=r.es; return w; }
    var ROWS=[
      {name:'Avg CPU',unit:'%',dec:0,pad:3,vals:rev.map(function(r){return r.cpu;})},
      {name:'Peak GPU temp',unit:'°C',dec:0,pad:3,vals:rev.map(function(r){return r.tmp;})},
      {name:'Worst frame loss',unit:'%',dec:2,pad:0.3,vals:rev.map(worst)}
    ];
    var htm='';
    for(var i=0;i<ROWS.length;i++){
      var rw=ROWS[i],have=false,mx=0,j;
      for(j=0;j<rw.vals.length;j++){ if(rw.vals[j]>=0){ have=true; if(rw.vals[j]>mx)mx=rw.vals[j]; } }
      if(!have)continue;
      var bars='';
      for(j=0;j<rw.vals.length;j++){ var v=rw.vals[j];
        var hpx=(v>=0&&mx>0)?Math.max(2,Math.round(v/mx*22)):2;
        bars+='<i class="'+(j===rw.vals.length-1?'last':'')+'" style="height:'+hpx+'px'+(v<0?';opacity:.25':'')+'" title="'+(rev[j].label||'')+(v>=0?(' · '+fmtV(v,rw.dec)+rw.unit):'')+'"></i>'; }
      var last=rw.vals[rw.vals.length-1],prior=[],p2;
      for(p2=0;p2<rw.vals.length-1;p2++){ if(rw.vals[p2]>=0)prior.push(rw.vals[p2]); }
      var dir='flat',word='steady';
      if(last>=0&&prior.length){
        var m2=0; for(p2=0;p2<prior.length;p2++)m2+=prior[p2]; m2/=prior.length;
        if(last>m2+rw.pad){ dir='up'; word='▲ rising'; }
        else if(last<m2-rw.pad){ dir='down'; word='▼ easing'; }
      }
      htm+='<div class="hd-trow"><span class="hd-tname">'+rw.name+'</span><span class="hd-tbars">'+bars+'</span><span class="hd-tval">'+(last>=0?(fmtV(last,rw.dec)+rw.unit):'-')+'</span><span class="hd-dir '+dir+'">'+word+'</span></div>';
    }
    host.innerHTML=htm;
  }
  window.sxHealthPaint=function(){
    var D=window.SX_HEALTH; if(!D)return;
    var nw=D.now||{};
    // Scoring v2: a sustained-saturation flag flipping (or going live) changes the
    // measured lane, so rescore right away instead of waiting for the 8s refresh.
    var satKey=(nw.sat_cpu?1:0)+(nw.sat_gpu?2:0)+(nw.sat_hot?4:0)+(nw.live?8:0);
    if(window._sxSatKey!==satKey){ window._sxSatKey=satKey; if(window.sxApply){ try{ window.sxApply(); }catch(eSat){} } }
    // the live meter inside the score card, kept fresh even while the audit is paused
    var bar=el('pcHealthBar'),val=el('pcHealthVal');
    if(bar&&val){
      var sc=100;
      if(nw.cpu>=0)sc-=Math.max(0,nw.cpu-70)*2;
      if(nw.ram>=0)sc-=Math.max(0,nw.ram-80)*2;
      if(nw.gpu>=0)sc-=Math.max(0,nw.gpu-85)*2;
      if(nw.tmp>=0)sc-=Math.max(0,nw.tmp-83)*3;
      if(nw.ctmp>=0)sc-=Math.max(0,nw.ctmp-90)*3;
      sc=Math.max(0,Math.round(sc));
      bar.style.width=sc+'%';
      bar.style.background=sc>=70?'#6fd394':(sc>=40?'#ffc24d':'#ff6b6b');
      var bits=[];
      if(nw.cpu>=0)bits.push('CPU '+nw.cpu+'%');
      if(nw.ram>=0)bits.push('RAM '+nw.ram+'%');
      if(nw.gpu>=0)bits.push('GPU '+nw.gpu+'%');
      if(nw.tmp>=0)bits.push(nw.tmp+'°C');
      if(nw.ctmp>=0)bits.push('CPU '+nw.ctmp+'°C');
      var mf=nw.sat_hot?'GPU running hot':(nw.sat_gpu?'GPU maxed out':(nw.sat_cpu?'CPU maxed out':null));
      val.innerHTML=bits.join(' · ')+(mf?(' · <b style="color:#ffc24d">'+mf+'</b>'):'');
    }
    var dash=el('healthDash'); if(!dash)return;
    // choose the view: a live stream wins, else the picked session, else recent
    var view=null;
    if(nw.live&&D.cur){ view={s:D.cur,m:'live'}; }
    else if(D.pick&&D.pick.series){ view={s:D.pick.series,m:'pick'}; }
    else if(D.cur){ view={s:D.cur,m:'recent'}; }
    var winEl=el('hdWin'),flagEl=el('hdFlag'),emptyEl=el('hdEmpty'),grid=el('hdGrid');
    var flag=nw.sat_hot?'GPU running hot':(nw.sat_gpu?'GPU maxed out':(nw.sat_cpu?'CPU maxed out':null));
    if(flagEl){ if(flag){ flagEl.textContent=flag; flagEl.style.display=''; } else flagEl.style.display='none'; }
    if(!view||!view.s||!view.s.t||view.s.t.length<2){
      if(emptyEl)emptyEl.style.display='';
      if(grid)grid.innerHTML='';
      if(winEl)winEl.textContent='';
      trendPaint(D);
      return;
    }
    if(emptyEl)emptyEl.style.display='none';
    if(winEl){
      if(view.m==='live'){
        var mins=(D.since&&nw.t)?Math.max(0,Math.round((nw.t-D.since)/60)):0;
        winEl.innerHTML='<span class="live"><i></i> live</span> · this stream'+(mins>0?(' · '+fmtDur(mins)):'');
      } else if(view.m==='pick'){
        winEl.textContent='Stream of '+(D.pick.label||'')+(D.pick.dur?(' · '+D.pick.dur):'');
      } else {
        winEl.textContent='Last 45 minutes';
      }
    }
    // tiles: skip the rebuild while the pointer is on the charts so a tooltip
    // never vanishes mid-hover; the next pass catches up
    if(grid&&!(grid.matches&&grid.matches(':hover'))){
      var nowMap={cpu:nw.cpu,gpu:nw.gpu,ram:nw.ram,tmp:nw.tmp,enc:nw.encl};
      var htm='';
      window._hdSeries=view.s;
      for(var i=0;i<TILES.length;i++){
        var tl=TILES[i],a=view.s[tl.k]||[];
        var pk=peakOf(a),nowV=nowMap[tl.k];
        if(pk<0&&(nowV==null||nowV<0))continue;   // nothing measured here: no tile
        var big,sub;
        if(tl.bars){
          big=fmtV(pk,2)+'%';
          sub='worst spike · '+tl.hint;
        } else if(view.m==='pick'){
          big=fmtV(pk,0)+tl.unit;
          var av=avgOf(a);
          sub='peak · avg '+(av>=0?(fmtV(av,0)+tl.unit):'-');
        } else {
          big=(nowV!=null&&nowV>=0)?(fmtV(nowV,0)+tl.unit):'-';
          sub='peak '+(pk>=0?(fmtV(pk,0)+tl.unit):'-');
          if(tl.k==='gpu'&&nw.vram>=0)sub='VRAM '+nw.vram+'% · '+sub;
          if(tl.k==='ram'&&nw.ram_free>=0)sub=nw.ram_free+' GB free · '+sub;
          if(tl.k==='cpu'&&nw.obs>=0)sub='OBS '+nw.obs+'% · '+sub;
        }
        var stV=tl.bars?pk:((view.m==='pick')?pk:nowV);
        var lo=tl.bars?0:tl.lo, hi=tl.bars?Math.max(1,pk):tl.hi;
        htm+='<div class="hd-tile" data-k="'+tl.k+'" data-u="'+tl.unit+'" data-dec="'+(tl.bars?2:0)+'">'+
          '<div class="hd-k"><span class="hd-name">'+tl.name+'</span><span class="hd-cur'+stCls(stV,tl.warn,tl.bad)+'">'+big+'</span></div>'+
          '<div class="hd-sub">'+sub+'</div>'+
          spark(a,lo,hi,tl.bars?null:tl.warn,tl.bars)+'</div>';
      }
      grid.innerHTML=htm;
    }
    trendPaint(D);
  };
  // shared hover readout across every sparkline
  document.addEventListener('mousemove',function(e){
    var tip=el('hdTip'); if(!tip)return;
    var t=e.target,tile=(t&&t.closest)?t.closest('.hd-tile'):null;
    if(!tile||!window._hdSeries){ tip.style.display='none'; return; }
    var svg=tile.querySelector('.hd-svg');
    if(!svg){ tip.style.display='none'; return; }
    var r=svg.getBoundingClientRect();
    if(e.clientY<r.top-6||e.clientY>r.bottom+6){ tip.style.display='none'; return; }
    var S=window._hdSeries,k=tile.getAttribute('data-k'),a=S[k],ts=S.t;
    if(!a||!ts||!ts.length){ tip.style.display='none'; return; }
    var f=(e.clientX-r.left)/Math.max(1,r.width);
    var idx=Math.max(0,Math.min(ts.length-1,Math.round(f*(ts.length-1))));
    var v=a[idx],dec=parseInt(tile.getAttribute('data-dec')||'0',10);
    tip.innerHTML='<b>'+(v>=0?(fmtV(v,dec)+tile.getAttribute('data-u')):'not measured')+'</b> · '+fmtT(ts[idx]);
    tip.style.display='block';
    var x=Math.min(window.innerWidth-tip.offsetWidth-8,Math.max(4,e.clientX+12));
    tip.style.left=x+'px'; tip.style.top=(e.clientY-30)+'px';
  });
  hdLoad();
  upLoad();
})();
</script>
]] .. smw_script(smw) .. [[
</body></html>]]
end

-- ═══════════════════════════════════════════════════════════
-- BURIED SOURCE CHECK — its own opt-in mini-run, NOT part of the main audit.
-- Rationale: the main audit's value is that everything it says is TRUE. Whether a
-- source is truly "see-through" is something OBS never tells a script, so this check
-- is a GUESS by design — we quarantine it here (own panel, honesty banner, NEVER a
-- fix) so it can't dent the main audit's trust. Two gates:
--   Gate 1 "Definitely buried" — fully behind a coverer we KNOW is opaque (Display
--     Capture, camera, full-alpha Color Source, or a JPG/BMP), at full opacity, with
--     no keying/mask filter, sitting above it in z-order.
--   Gate 2 "Worth a look" — mostly/fully covered by a front source we can't verify
--     opaque (browser, PNG, text, video…). Noisier on purpose.
-- ═══════════════════════════════════════════════════════════

-- filter type-ids that punch holes in an otherwise-solid source
local KEY_FILTERS = {
  chroma_key_filter = true, chroma_key_filter_v2 = true,
  color_key_filter = true, color_key_filter_v2 = true,
  luma_key_filter = true, luma_key_filter_v2 = true,
  mask_filter = true, mask_filter_v2 = true,
}

-- does this source carry a filter that could make an opaque type see-through?
local function has_transparency_filter(src)
  local bad = false
  safe(function()
    local list = obs.obs_source_enum_filters(src)
    if list == nil then return end
    for _, f in ipairs(list) do
      if obs.obs_source_enabled(f) then
        local fid = obs.obs_source_get_id(f)
        if KEY_FILTERS[fid] then bad = true end
        if fid == "color_filter" or fid == "color_filter_v2" then
          local fs = obs.obs_source_get_settings(f)
          if obs.obs_data_has_user_value(fs, "opacity") then
            local oi = obs.obs_data_get_int(fs, "opacity")
            local od = obs.obs_data_get_double(fs, "opacity")
            -- opacity is 0..100 in modern OBS; anything not clearly 100 → treat as maybe-transparent
            if not (oi == 100 or od == 100) then bad = true end
          end
          obs.obs_data_release(fs)
        end
      end
    end
    obs.source_list_release(list)
  end)
  return bad
end

-- returns "solid" only when we're CONFIDENT the source is fully opaque, else nil.
-- Conservative on purpose: rotation, any crop, or a hole-punching filter disqualifies.
local function coverer_solidity(item, src, settings, kind)
  local rot = 0
  pcall(function() rot = obs.obs_sceneitem_get_rot(item) end)
  if rot and math.abs(rot) > 0.01 then return nil end
  local crop = obs.obs_sceneitem_crop()
  pcall(function() obs.obs_sceneitem_get_crop(item, crop) end)
  if crop.left ~= 0 or crop.right ~= 0 or crop.top ~= 0 or crop.bottom ~= 0 then return nil end
  if has_transparency_filter(src) then return nil end

  if kind == "monitor_capture" then return "solid" end          -- Display Capture: screen has no alpha
  if kind == "dshow_input" then return "solid" end               -- camera: sensor frames have no alpha
  if kind == "color_source" or kind == "color_source_v2" or kind == "color_source_v3" then
    local color = obs.obs_data_get_int(settings, "color")
    local alpha = bit.band(bit.rshift(color, 24), 0xFF)
    local opok = true
    if obs.obs_data_has_user_value(settings, "opacity") then
      opok = (obs.obs_data_get_int(settings, "opacity") >= 100)
    end
    if alpha >= 255 and opok then return "solid" end
    return nil
  end
  if kind == "image_source" then
    local path = (obs.obs_data_get_string(settings, "file") or ""):lower()
    if path:match("%.jpe?g$") or path:match("%.bmp$") then return "solid" end  -- these formats can't hold alpha
    return nil
  end
  return nil
end

-- on-canvas box for a scene item (reuses the Phase-5 alignment math); nil if not visible/sized
local function compute_item_box(item, src)
  local bw = obs.obs_source_get_width(src)
  local bh = obs.obs_source_get_height(src)
  if not bw or bw == 0 or not bh or bh == 0 then return nil end
  local bt = obs.obs_sceneitem_get_bounds_type(item)
  local w, h
  if bt ~= obs.OBS_BOUNDS_NONE then
    local b = obs.vec2()
    obs.obs_sceneitem_get_bounds(item, b)
    w, h = b.x, b.y
  else
    local sc = obs.vec2()
    obs.obs_sceneitem_get_scale(item, sc)
    w, h = bw * sc.x, bh * sc.y
  end
  if w < 1 or h < 1 then return nil end
  local pos = obs.vec2()
  obs.obs_sceneitem_get_pos(item, pos)
  local align = obs.obs_sceneitem_get_alignment(item)
  local left, top
  if bit.band(align, 1) ~= 0 then left = pos.x
  elseif bit.band(align, 2) ~= 0 then left = pos.x - w
  else left = pos.x - w / 2 end
  if bit.band(align, 4) ~= 0 then top = pos.y
  elseif bit.band(align, 8) ~= 0 then top = pos.y - h
  else top = pos.y - h / 2 end
  return { left = left, top = top, w = w, h = h }
end

-- clamp a box to the visible canvas; nil if nothing on-canvas
local function clamp_box(bx)
  local l = math.max(0, bx.left)
  local t = math.max(0, bx.top)
  local r = math.min(canvas_w, bx.left + bx.w)
  local b = math.min(canvas_h, bx.top + bx.h)
  if r <= l or b <= t then return nil end
  return { left = l, top = t, right = r, bottom = b, w = r - l, h = b - t }
end

local function box_contains(a, b)   -- a fully contains b (with a half-pixel of slack)
  local e = 0.5
  return a.left <= b.left + e and a.top <= b.top + e and a.right >= b.right - e and a.bottom >= b.bottom - e
end
local function box_cover_frac(a, b)   -- fraction of b's area that a overlaps
  local ox = math.max(0, math.min(a.right, b.right) - math.max(a.left, b.left))
  local oy = math.max(0, math.min(a.bottom, b.bottom) - math.max(a.top, b.top))
  local area = b.w * b.h
  if area <= 0 then return 0 end
  return (ox * oy) / area
end

-- Does this scene actually render any pixels? A nested scene that holds only
-- audio (e.g. a "microphones" scene) renders nothing, so it can neither hide a
-- source nor be hidden — the buried check should ignore it. Recurses so a scene
-- of scenes still counts, with a depth guard so a self-referential loop is safe.
local function scene_has_visible_video(src, depth)
  if src == nil then return false end
  depth = depth or 0
  if depth > 4 then return true end   -- runaway guard: assume visible, never falsely hide
  local scene = obs.obs_scene_from_source(src)
  if scene == nil then return false end
  local items = obs.obs_scene_enum_items(scene)
  if items == nil then return false end
  local found = false
  for _, it in ipairs(items) do
    if not found then
      local s = obs.obs_sceneitem_get_source(it)
      if s ~= nil and obs.obs_sceneitem_visible(it)
         and bit.band(obs.obs_source_get_output_flags(s), obs.OBS_SOURCE_VIDEO) ~= 0 then
        if obs.obs_source_get_type(s) == obs.OBS_SOURCE_TYPE_SCENE then
          if scene_has_visible_video(s, depth + 1) then found = true end
        else
          found = true
        end
      end
    end
  end
  obs.sceneitem_list_release(items)
  return found
end

local function collect_buried()
  canvas_w, canvas_h = 1920, 1080
  safe(function()
    local ovi = obs.obs_video_info()
    if obs.obs_get_video_info(ovi) then canvas_w = ovi.base_width; canvas_h = ovi.base_height end
  end)

  local out = {}   -- { {scene=, g1={{name=,by=}}, g2={{name=,by=}}}, ... }
  local scene_list = obs.obs_frontend_get_scenes()
  if scene_list == nil then return out end

  for _, scsrc in ipairs(scene_list) do
    if obs.obs_source_get_type(scsrc) == obs.OBS_SOURCE_TYPE_SCENE then
      local sname = obs.obs_source_get_name(scsrc)
      safe(function()
        local scene = obs.obs_scene_from_source(scsrc)
        if scene == nil then return end
        local items = obs.obs_scene_enum_items(scene)
        if items == nil then return end

        -- build an array of visible VIDEO items with their on-canvas box + solidity
        local arr = {}
        for idx, it in ipairs(items) do
          local src = obs.obs_sceneitem_get_source(it)
          if src ~= nil and obs.obs_sceneitem_visible(it) then
            local flags = obs.obs_source_get_output_flags(src)
            local is_visual = bit.band(flags, obs.OBS_SOURCE_VIDEO) ~= 0
            -- a nested scene holding only audio (e.g. a "microphones" scene) renders
            -- nothing: it can't bury a source and can't be buried, so drop it here
            if is_visual and obs.obs_source_get_type(src) == obs.OBS_SOURCE_TYPE_SCENE then
              is_visual = scene_has_visible_video(src, 0)
            end
            if is_visual then
              local raw = compute_item_box(it, src)
              if raw then
                local box = clamp_box(raw)
                if box then
                  local st = obs.obs_source_get_settings(src)
                  local kind = obs.obs_source_get_id(src)
                  -- authoritative z-order (0 = bottom); fall back to enum index if unavailable
                  local ord = idx
                  pcall(function() ord = obs.obs_sceneitem_get_order_position(it) end)
                  arr[#arr + 1] = {
                    name = obs.obs_source_get_name(src),
                    box = box,
                    solid = coverer_solidity(it, src, st, kind),
                    ord = ord,
                  }
                  obs.obs_data_release(st)
                end
              end
            end
          end
        end
        obs.sceneitem_list_release(items)
        -- sort bottom→top so a[b] with b>a is always in front of a
        table.sort(arr, function(x, y) return x.ord < y.ord end)

        -- for each item, everything ABOVE it (higher index) can cover it
        local g1, g2 = {}, {}
        for a = 1, #arr do
          local under = arr[a]
          local buried_by, look_by = nil, nil
          for b = a + 1, #arr do
            local front = arr[b]
            if box_contains(front.box, under.box) then
              if front.solid == "solid" then buried_by = front.name; break
              else look_by = look_by or front.name end
            elseif not look_by and box_cover_frac(front.box, under.box) >= 0.92 then
              look_by = front.name
            end
          end
          if buried_by then g1[#g1 + 1] = { name = under.name, by = buried_by }
          elseif look_by then g2[#g2 + 1] = { name = under.name, by = look_by } end
        end

        if #g1 > 0 or #g2 > 0 then out[#out + 1] = { scene = sname, g1 = g1, g2 = g2 } end
      end)
    end
  end
  obs.source_list_release(scene_list)
  return out
end

local function buried_rows(scene, rows)
  local html = {}
  for _, it in ipairs(rows) do
    -- stable per-pair id (digits normalized) so an "ignore once" sticks across scans
    local bid = short_hash(((scene .. "|" .. it.name .. "|" .. it.by):gsub("%d+", "#")))
    html[#html + 1] = "<div class='brow' data-bid='" .. bid ..
      "' data-name=\"" .. esc_attr(it.name) .. "\" data-by=\"" .. esc_attr(it.by) .. "\">" ..
      "<span class='bsrc'>“" .. esc(it.name) .. "”</span>" ..
      "<span class='barr'>behind</span><span class='bcov'>“" .. esc(it.by) .. "”</span>" ..
      "<span class='bacts'>" ..
        "<button class='big-once' data-bid='" .. bid .. "'>Ignore once</button>" ..
        "<button class='big-name' data-name=\"" .. esc_attr(it.name) .. "\">Always ignore “" .. esc(it.name) .. "”</button>" ..
      "</span>" ..
      "</div>"
  end
  return table.concat(html, "")
end

local function render_buried(list, smw)
  smw = smw or { enabled = false }
  local function jbtn(scene)
    if smw.enabled and scene and scene ~= "" then
      return "<button class='smw-jump' data-scene=\"" .. esc_attr(scene) .. "\">Show me →</button>"
    end
    return ""
  end
  local total1, total2 = 0, 0
  for _, s in ipairs(list) do total1 = total1 + #s.g1; total2 = total2 + #s.g2 end

  local blocks = {}
  if total1 == 0 and total2 == 0 then
    blocks[#blocks + 1] = "<div class='clean'>✓ Nothing looks buried.<div class='clean-sub'>No source appears fully hidden behind another. Run the check again any time for a fresh look.</div></div>"
  else
    if total1 > 0 then
      blocks[#blocks + 1] = "<div class='sec sec-critical sechead' data-sec='crit'>Definitely buried &nbsp;·&nbsp; <span class='seccount'>" .. total1 .. "</span></div>"
      blocks[#blocks + 1] = "<div class='ghint sechint' data-sec='crit'>Fully hidden behind something we're confident is opaque: a screen/display capture, a camera, a solid color, or a JPG. These are almost certainly rendering for nothing.</div>"
      for _, s in ipairs(list) do
        if #s.g1 > 0 then
          blocks[#blocks + 1] = "<div class='card tip'><div class='title'>Scene: " .. esc(s.scene) .. "</div>" ..
            buried_rows(s.scene, s.g1) ..
            "<div class='fix'><b>What to do:</b> if that's on purpose, ignore it. If not, open <b>" .. esc(s.scene) ..
            "</b> and either drag the hidden source out from under the cover, or remove it so it stops using GPU. Nothing here is ever changed for you.</div>" ..
            jbtn(s.scene) .. "</div>"
        end
      end
    end
    if total2 > 0 then
      blocks[#blocks + 1] = "<div class='sec sec-warning sechead' data-sec='warn'>Worth a look &nbsp;·&nbsp; <span class='seccount'>" .. total2 .. "</span></div>"
      blocks[#blocks + 1] = "<div class='ghint sechint' data-sec='warn'>Mostly or fully covered by a source we can't be sure is opaque: a browser, PNG, text, or video. It might be hidden, or the cover might be see-through. Eyeball each one.</div>"
      for _, s in ipairs(list) do
        if #s.g2 > 0 then
          blocks[#blocks + 1] = "<div class='card warning'><div class='title'>Scene: " .. esc(s.scene) .. "</div>" ..
            buried_rows(s.scene, s.g2) ..
            "<div class='fix'><b>What to do:</b> switch to <b>" .. esc(s.scene) ..
            "</b> and look: if the covered source really is hidden, move it or remove it; if the cover is see-through, you're fine. It's your call; this only points.</div>" ..
            jbtn(s.scene) .. "</div>"
        end
      end
    end
  end

  return [[<!DOCTYPE html><html><head><meta charset="utf-8">
<title>Buried Source Check</title>
<style>
  *{box-sizing:border-box}
  body{background:#0d0f13;color:#e8e8ec;font-family:'Segoe UI',system-ui,sans-serif;margin:0;padding:14px;font-size:13px}
  h1{font-size:15px;margin:0 0 4px;letter-spacing:.5px}
  h1 b{color:#ff7a1a}
  .sub{color:#8b8f98;font-size:11px;margin:0 0 12px}
  .banner{background:rgba(255,122,26,.08);border:1px solid rgba(255,122,26,.35);border-radius:10px;padding:10px 13px;margin-bottom:14px;color:#e0c6a8;font-size:11.5px;line-height:1.5}
  .banner b{color:#ff9a4d}
  .sec{font-size:11px;font-weight:700;letter-spacing:.8px;text-transform:uppercase;margin:16px 2px 4px}
  .sec-critical{color:#ff8a8a}
  .sec-warning{color:#ff9a4d}
  .ghint{color:#8b8f98;font-size:11px;line-height:1.5;margin:0 2px 10px}
  .card{background:#151920;border:1px solid #232833;border-left-width:3px;border-radius:10px;padding:11px 13px;margin-bottom:9px}
  .card.tip{border-left-color:#7a8cff}
  .card.warning{border-left-color:#ff7a1a}
  .title{font-weight:600;font-size:12px;color:#9aa0ab;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px}
  .brow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:5px 0;border-top:1px solid #1c2129}
  .brow:first-of-type{border-top:none}
  .bsrc{font-weight:700;color:#e8e8ec}
  .barr{font-size:10px;color:#565b66;text-transform:uppercase;letter-spacing:.5px}
  .bcov{color:#cfd3db}
  .bacts{display:flex;gap:6px;margin-left:auto;flex-wrap:wrap}
  .big-once,.big-name{background:#1c2129;border:1px solid #2a3140;color:#9aa0ab;border-radius:6px;padding:3px 9px;font-size:10.5px;cursor:pointer;font-family:inherit;white-space:nowrap}
  .big-once:hover,.big-name:hover{color:#e8e8ec;border-color:#3a4150}
  .iglist{background:#12151b;border:1px solid #232833;border-radius:10px;padding:10px 12px;margin-bottom:14px}
  .igtitle{font-size:10.5px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:#8b8f98;margin-bottom:8px}
  .igchip{display:inline-flex;align-items:center;gap:6px;background:#1c2129;border:1px solid #2a3140;color:#cfd3db;border-radius:20px;padding:4px 6px 4px 11px;font-size:11px;margin:0 6px 6px 0}
  .igx{background:none;border:none;color:#ff9a4d;cursor:pointer;font-size:12px;padding:0 2px;line-height:1;font-family:inherit}
  .igx:hover{color:#ff6b6b}
  .allig{background:#151920;border:1px solid rgba(80,200,120,.3);border-radius:12px;padding:20px;text-align:center;color:#6fd394;font-size:14px}
  .fix{color:#cfd3db;font-size:12px;line-height:1.5;margin-top:9px;padding-top:8px;border-top:1px solid #1c2129}
  .fix b{color:#ff9a4d;font-weight:700}
  .clean{background:#151920;border:1px solid rgba(80,200,120,.3);border-radius:12px;padding:22px;text-align:center;color:#6fd394;font-size:15px;font-weight:600}
  .clean-sub{color:#8b8f98;font-size:11px;font-weight:400;margin-top:8px}
  .foot{color:#565b66;font-size:10px;margin-top:18px;text-align:center}
  .foot a{color:#ff7a1a;text-decoration:none}
]] .. SMW_CSS .. [[
</style></head><body>
<h1>BURIED SOURCE CHECK <b>●</b></h1>
<div class="sub">Sources that look hidden behind other sources, a separate, opt-in check.</div>
<div class="banner"><b>Read this first:</b> OBS never tells a script what's actually see-through, so this is a list to <b>eyeball, not gospel</b>. It only points things out, it never changes, moves, or removes anything.</div>
]] .. smw_banner(smw) .. [[
<div class="iglist" id="iglist" style="display:none"></div>
<div class="allig" id="allig" style="display:none">✓ Everything flagged here is ignored. See the Ignored list above to bring any back.</div>
]] .. table.concat(blocks, "\n") .. [[
<div class="foot">by <a href="https://strmrx.com">StrmrX</a> &middot; free &amp; premium tools for streamers &middot; a snapshot, click Check for Buried Sources again for a fresh look</div>
<script>
(function(){
  // NOTE: this panel does NOT auto-reload (it's an opt-in snapshot). That's what
  // makes ignores stick — with no reload, the state simply lives in the page and
  // can't get wiped. localStorage + the URL hash are best-effort cross-session
  // stores for browsers that allow them on file:// pages; in-memory is the truth.
  function lsGet(k){ try{ return localStorage.getItem(k); }catch(e){ return null; } }
  function lsSet(k,v){ try{ localStorage.setItem(k,v); }catch(e){} }
  function hget(k){ try{ var m=new RegExp('[#&]'+k+'=([^&]*)').exec(location.hash); return m?decodeURIComponent(m[1]):null; }catch(e){ return null; } }
  function hset(k,val){ try{ var parts=location.hash.replace(/^#/,'').split('&').filter(Boolean), out=[], f=false;
    for(var i=0;i<parts.length;i++){ if(parts[i].split('=')[0]===k){ out.push(k+'='+encodeURIComponent(val)); f=true; } else out.push(parts[i]); }
    if(!f) out.push(k+'='+encodeURIComponent(val)); location.hash=out.join('&'); }catch(e){} }
  function jparse(s,def){ try{ var v=JSON.parse(s); return v||def; }catch(e){ return def; } }
  function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }

  // names ignored EVERYWHERE (a source/scene you've marked fine); pairs ignored
  // ONCE (this exact "X behind Y"), stored with a readable label for the list.
  var names = jparse(lsGet('bs_ig_names'),null) || (hget('ign')?hget('ign').split(',').filter(Boolean):[]);
  var pairs = jparse(lsGet('bs_ig_pairs'),null) || [];
  function save(){
    lsSet('bs_ig_names', JSON.stringify(names)); lsSet('bs_ig_pairs', JSON.stringify(pairs));
    hset('ign', names.join(',')); hset('igp', pairs.map(function(p){ return p.b; }).join(','));
    if(window.smwSetData) window.smwSetData('sx_audit_buried_ignores',{names:names,pairs:pairs});
  }
  function renderList(){
    var box=document.getElementById('iglist'); if(!box) return;
    var total=names.length+pairs.length;
    if(!total){ box.style.display='none'; box.innerHTML=''; return; }
    box.style.display='';
    var h='<div class="igtitle">Ignored ('+total+')</div>';
    names.forEach(function(x){ h+='<span class="igchip">Always ignoring “'+esc(x)+'”<button class="igx" data-un="name" data-v="'+esc(x)+'" title="Un-ignore">✕</button></span>'; });
    pairs.forEach(function(p){ h+='<span class="igchip">'+esc(p.l)+'<button class="igx" data-un="pair" data-v="'+esc(p.b)+'" title="Un-ignore">✕</button></span>'; });
    box.innerHTML=h;
  }
  function apply(){
    var ns={}, i, nmv; for(i=0;i<names.length;i++){ nmv=names[i]; ns[nmv]=1; }
    var ps={}, pbv; for(i=0;i<pairs.length;i++){ pbv=pairs[i].b; ps[pbv]=1; }
    var rows=document.querySelectorAll('.brow[data-bid]'), shown=0;
    for(i=0;i<rows.length;i++){ var r=rows[i], bid=r.getAttribute('data-bid'), nm=r.getAttribute('data-name'), by=r.getAttribute('data-by');
      r.style.display = (ps[bid]||ns[nm]||ns[by]) ? 'none' : '';
    }
    var cards=document.querySelectorAll('.card');
    for(var c=0;c<cards.length;c++){ var rs=cards[c].querySelectorAll('.brow'), any=false;
      for(var k=0;k<rs.length;k++){ if(rs[k].style.display!=='none'){ any=true; break; } }
      if(rs.length>0){ cards[c].style.display=any?'':'none'; if(any) shown++; }
    }
    // Section headers ("Definitely buried · N", "Worth a look · N") are rendered
    // with pre-ignore totals; recompute them from what's still visible so the count
    // drops as you ignore things (and the whole section hides when it empties out).
    function secCount(sel){ var q=document.querySelectorAll(sel), n=0; for(var i=0;i<q.length;i++){ if(q[i].style.display!=='none') n++; } return n; }
    function setSec(sec,n){ var head=document.querySelector('.sechead[data-sec="'+sec+'"]'), hint=document.querySelector('.sechint[data-sec="'+sec+'"]');
      if(head){ var ce=head.querySelector('.seccount'); if(ce) ce.textContent=n; head.style.display=n?'':'none'; }
      if(hint) hint.style.display=n?'':'none'; }
    setSec('crit',secCount('.card.tip .brow')); setSec('warn',secCount('.card.warning .brow'));
    var allig=document.getElementById('allig'); if(allig) allig.style.display=((names.length+pairs.length)>0 && shown===0)?'':'none';
    renderList();
  }
  document.addEventListener('click',function(e){ var t=e.target;
    var o=t.closest?t.closest('.big-once'):null;
    if(o){ var bid=o.getAttribute('data-bid'), row=o.closest('.brow');
      var lbl='“'+(row?row.getAttribute('data-name'):'?')+'” behind “'+(row?row.getAttribute('data-by'):'?')+'”';
      var has=false; for(var i=0;i<pairs.length;i++){ if(pairs[i].b===bid){ has=true; break; } }
      if(!has) pairs.push({b:bid,l:lbl}); save(); apply(); return; }
    var nm=t.closest?t.closest('.big-name'):null;
    if(nm){ var v=nm.getAttribute('data-name'); if(names.indexOf(v)<0) names.push(v); save(); apply(); return; }
    var x=t.closest?t.closest('.igx'):null;
    if(x){ var kind=x.getAttribute('data-un'), val=x.getAttribute('data-v');
      if(kind==='name') names=names.filter(function(y){ return y!==val; });
      else pairs=pairs.filter(function(p){ return p.b!==val; });
      save(); apply(); return; }
  });
  apply();
  // Pull the saved ignore list from OBS's own storage once the websocket helpers
  // exist (they're defined in the script block appended just after this one). When
  // the server is on, OBS is the source of truth and this is bulletproof; when off,
  // the localStorage/in-memory copy above is what you get.
  if(window.addEventListener) window.addEventListener('load',function(){
    if(window.smwGetData) window.smwGetData('sx_audit_buried_ignores',function(v){
      if(v && typeof v==='object'){ if(v.names) names=v.names; if(v.pairs) pairs=v.pairs; save(); apply(); }
    });
  });
  try{ var sy=parseInt(sessionStorage.getItem('bs_scroll')||'0',10); if(sy) window.scrollTo(0,sy); }catch(e){}
  window.addEventListener('scroll',function(){ try{ sessionStorage.setItem('bs_scroll',String(window.scrollY)); }catch(e){} });
})();
</script>
]] .. smw_script(smw) .. [[
</body></html>]]
end

-- ── scan + plumbing ────────────────────────────────────────

local buried_active = false   -- only regenerate the buried panel once it's been opened
local last_html, last_buried   -- skip re-writing the file when nothing changed (idle disk savings)
local last_seen_pick = nil     -- detect when the panel picks a different session log to read
-- Run-as-admin one-click: the panel writes a fresh click stamp into this slot (same
-- persistent-data channel as the session picker). First tick PRIMES without acting, so
-- a stale stamp left from an earlier OBS run can never trigger a registry write by
-- itself; only a change observed while this script is alive is a real click.
local last_fix_admin = nil
local fix_admin_primed = false

-- do_slow = also run the cmd-based checks (disk free, log, hotkeys). Only true at
-- load and on the Open button — never on the auto-timer, so no console flash.
local function scan(do_slow)
  local ok, err = pcall(function()
    local html = render(collect(do_slow), read_smw_config())
    if html == last_html then return end   -- nothing changed since last tick — don't touch disk
    last_html = html
    local f = io.open(HTML_PATH, "w")
    if f ~= nil then f:write(html) f:close() end
  end)
  if not ok then print("StreamAuditX error: " .. tostring(err)) end
end

local function scan_buried()
  local ok, err = pcall(function()
    local html = render_buried(collect_buried(), read_smw_config())
    if html == last_buried then return end
    last_buried = html
    local f = io.open(HTML_PATH_BURIED, "w")
    if f ~= nil then f:write(html) f:close() end
  end)
  if not ok then print("StreamAuditX (buried) error: " .. tostring(err)) end
end

-- one timer drives both; the buried panel only refreshes after you've opened it.
-- The timer uses the fast path (no cmd) — that's what stops the every-5s console flash.
local function tick()
  -- if the panel picked a different session log, do one slow scan to actually read it;
  -- otherwise stay on the fast path (no cmd, no console flash). Reading the pick is a
  -- silent file read, so it's safe to check every tick.
  sx_read_tier()   -- same silent file read; picks up a fresh tier/consent write within a tick
  -- run-as-admin fix requests (see last_fix_admin above): act only on a live change
  local fixreq = read_persist_str("sx_audit_fix_admin")
  if fix_admin_primed and fixreq and fixreq ~= "" and fixreq ~= last_fix_admin then
    pcall(function() sxcol.admin_flag_write() end)
  end
  last_fix_admin = fixreq
  fix_admin_primed = true
  local pick = read_persist_str("sx_audit_pick_log")
  if pick ~= last_seen_pick then
    last_seen_pick = pick
    scan(true)
  else
    scan(false)
  end
  if buried_active then scan_buried() end
end

local function open_panel()
  scan(true)   -- opening = a good moment to refresh the slow disk/log/hotkey checks
  sx_open(HTML_PATH)
end

local function open_buried()
  buried_active = true
  scan_buried()
  sx_open(HTML_PATH_BURIED)
end

-- Expert Read straight from the script page. Refresh the snapshot data, then open the
-- panel in the browser jumped to the Expert Read card (#expert), where the actual paid
-- action still takes one deliberate click. Never auto-charges.
local function open_expert()
  scan(true)
  -- Windows can silently drop a #fragment when opening a file link, which lands the user
  -- on the regular audit view instead of the Expert screen. Open a tiny launcher page
  -- instead; it forwards to the panel with the hash guaranteed intact.
  local expert_path = script_path() .. "streamauditx-expert.html"
  local f = io.open(expert_path, "w")
  if f ~= nil then
    f:write("<!doctype html><meta charset=utf-8><script>location.replace('streamauditx.html#expert');</script>")
    f:close()
    sx_open(expert_path)
  else
    sx_open("file:///" .. HTML_PATH:gsub("\\", "/") .. "#expert")
  end
end

-- ── running toggle ─────────────────────────────────────────
-- Pause = remove the timer entirely, so the script makes ZERO calls until resumed.
-- The paused/running choice persists across OBS restarts (stored in the script's
-- settings), so once you pause it, it stays paused until you resume — no re-pausing.
local running = true
local timer_active = false
local function start_timer()
  if not timer_active then obs.timer_add(tick, SCAN_MS); timer_active = true end
end
local function stop_timer()
  if timer_active then obs.timer_remove(tick); timer_active = false end
end
-- The PC health sampler rides its own timer so auto-pause-on-stream never stops it
-- (sampling DURING the stream is the point). Manual Pause and unload stop it fully.
local health_timer_active = false
local function health_tick() health_sample() sxdash.write() end
local function start_health()
  if not health_timer_active then obs.timer_add(health_tick, HEALTH_MS); health_timer_active = true end
end
local function stop_health()
  if health_timer_active then obs.timer_remove(health_tick); health_timer_active = false end
end
local TOGGLE_LABEL = { [true] = "⏸  Pause audit (stops all checks)", [false] = "▶  Resume audit" }

-- ── auto-pause while live ──────────────────────────────────
-- Nothing about your setup can change once you're streaming, so re-scanning
-- mid-stream just spends CPU the encoder could be using. When the stream starts
-- we quietly stop the timer; when it ends we bring it back (unless you'd paused
-- it by hand). Turn this off if you'd rather it keep watching while live. This
-- never touches the stream itself.
local pause_on_stream = true
local paused_by_stream = false
local function on_frontend_event(event)
  if pause_on_stream then
    if event == obs.OBS_FRONTEND_EVENT_STREAMING_STARTED then
      if running and timer_active then stop_timer(); paused_by_stream = true end
    elseif event == obs.OBS_FRONTEND_EVENT_STREAMING_STOPPED then
      if paused_by_stream then paused_by_stream = false; if running then start_timer() end end
    end
  end
  if event == obs.OBS_FRONTEND_EVENT_STREAMING_STOPPED and running then
    -- Fold the stream that just ended into the local archive (log + sidecars) and
    -- the dashboard right now, instead of waiting for the next panel open or OBS
    -- restart. One slow scan, right when there is finally something new to read.
    pcall(scan, true)
  end
end

function script_description()
  local dock_url = "file:///" .. HTML_PATH:gsub("\\", "/")
  local smw = read_smw_config()
  local smw_para
  if smw.enabled then
    smw_para = [[<b>Show Me Where</b>: your WebSocket server is <b>on</b>. Every finding has a <b>Show me →</b> jump button, and your ignores &amp; dismissals save straight into OBS, so they persist across restarts. Nothing here ever changes a setting.]]
  else
    smw_para = [[<b>Show Me Where</b> (optional): in OBS, open <b>Tools → WebSocket Server Settings</b> and check <b>"Enable WebSocket server."</b> Once it's on, every finding gets a <b>Show me →</b> button that jumps OBS straight to the problem scene, and your ignores &amp; dismissals save into OBS so they stick. It only navigates, it never changes a setting.]]
  end
  return [[<b>StreamAuditX</b>: an OBS Health score plus every issue as
what's wrong, why you'd care, and the exact click-path to fix it. Filter by Quick / Deep / category.
Read-only, it never changes anything.<br/><br/>
<b>Easiest:</b> click <b>Open StreamAuditX</b> below (opens in your browser).<br/><br/>
<b>Pause / Resume</b>: the audit refreshes in the background while loaded. Hit <b>Pause audit</b>
any time to stop all checks (zero background work); <b>Resume</b> to start again. Your choice sticks
across OBS restarts, pause it once and it stays paused until you resume. (Open still works for a
one-off check while paused.)<br/><br/>
<b>Panel inside OBS:</b> View → Docks → Custom Browser Docks…<br/>
Name: <b>StreamAuditX</b> &nbsp;URL:<br/><span style="font-family:monospace">]] .. dock_url .. [[</span><br/><br/>
<b>Buried Source Check</b> (separate button): an opt-in look for sources hidden behind other
sources. It's a best-guess list to eyeball, kept apart from the main audit, and never changes anything.<br/><br/>
<b>PC health</b>: while OBS is open, StreamAuditX quietly notes a few numbers every few seconds
(whole-PC CPU, OBS's own CPU, memory pressure, GPU load and temperature, dropped frames), stored
only on your PC. The dock shows a live PC health meter plus a <b>PC health over time</b> card:
charts for the stream you're on (or your last one), and trends across your recent streams. Expert
Reads line these samples up against your stream minute by minute to explain drops and stutters.
It keeps recording while you're live (that's the point), and manual Pause stops it completely.
Nothing is uploaded unless you run a read.<br/><br/>
<b>Expert Read</b> (in the panel, not this menu): near the top of the StreamAuditX panel there's a
<b>Get an Expert Read</b> card. It sends your setup and logs for a deep expert diagnosis that finds
root causes the quick checks can't. Pick the read that fits: just OBS (199 credits), OBS plus your
stream apps like Streamer.bot and Lumia (299), or your last several streams read together for the
big picture (499). Free stuff stays free, these are the optional paid deep dives. The
<b>★ Get an Expert Read</b> button below jumps straight to that setup screen, which shows exactly
which of your apps are connected before you run anything.<br/><br/>
]] .. smw_para .. [[<br/><br/>
<b>by StrmrX</b>: more free &amp; premium streamer tools at <a href="https://strmrx.com">strmrx.com</a>]]
end

function script_properties()
  local props = obs.obs_properties_create()
  obs.obs_properties_add_button(props, "open_btn", "Open StreamAuditX", function()
    open_panel()   -- always does a fresh one-time scan, even while paused
    return false
  end)
  obs.obs_properties_add_button(props, "expert_btn", "★ Get an Expert Read  (deep checkup, from 199 credits)", function()
    open_expert()
    return false
  end)
  obs.obs_properties_add_button(props, "buried_btn", "Check for Buried Sources", function()
    open_buried()
    return false
  end)
  obs.obs_properties_add_button(props, "toggle_btn", TOGGLE_LABEL[running], function(props2, prop)
    running = not running
    if script_settings then obs.obs_data_set_bool(script_settings, "running", running) end   -- persist
    if running then scan(true); start_timer(); start_health() else stop_timer(); stop_health() end
    obs.obs_property_set_description(prop, TOGGLE_LABEL[running])
    return true   -- refresh the panel so the button label flips
  end)
  obs.obs_properties_add_bool(props, "pause_on_stream",
    "Auto-pause while live (frees CPU during your stream, resumes when you stop)")
  -- Streamer.bot is portable (no fixed install path), so point us at its folder once and the whole-
  -- setup Expert Read reads it automatically. Lumia is found on its own. A folder browse, not a log.
  obs.obs_properties_add_path(props, "sx_streamer_path",
    "Streamer.bot folder (so the Expert Read reads your whole setup)",
    obs.OBS_PATH_DIRECTORY, nil, nil)
  return props
end

function script_defaults(settings)
  obs.obs_data_set_default_bool(settings, "running", true)   -- default ON for a fresh install
  obs.obs_data_set_default_bool(settings, "pause_on_stream", true)   -- auto-pause the audit while live
  obs.obs_data_set_default_string(settings, "sx_streamer_path", "")  -- Streamer.bot folder (empty = ask once)
end

function script_update(settings)
  pause_on_stream = obs.obs_data_get_bool(settings, "pause_on_stream")
  streamer_log_path = obs.obs_data_get_string(settings, "sx_streamer_path") or ""
end

-- OBS loads custom browser docks at startup, BEFORE scripts run. If the dock
-- points at a generated HTML that doesn't exist yet (fresh install, cleaned
-- folder, or the script is paused so it never writes), OBS logs
-- "Error opening file: (null)". Lay down a placeholder so the dock always
-- finds a real file, regardless of running state.
local function ensure_html_stubs()
  local stub = "<!doctype html><meta charset=utf-8><body style=\"margin:0;font:14px/1.5 system-ui,Segoe UI,sans-serif;background:#12131a;color:#e7e7ef;display:flex;align-items:center;justify-content:center;height:100vh;text-align:center\"><div style=\"max-width:340px;padding:24px\"><div style=\"font-weight:700;margin-bottom:8px\">StreamAuditX</div><div style=\"opacity:.7\">Loading your audit. If this stays up, open Tools, Scripts and make sure StreamAuditX is added and not paused.</div><div style=\"margin-top:14px;opacity:.45;font-size:12px\">by StrmrX</div></div></body>"
  for _, path in ipairs({ HTML_PATH, HTML_PATH_BURIED }) do
    if not file_exists(path) then
      local f = io.open(path, "w")
      if f ~= nil then f:write(stub) f:close() end
    end
  end
end

function script_load(settings)
  script_settings = settings
  ensure_html_stubs()   -- guarantee the dock files exist before anything else
  running = obs.obs_data_get_bool(settings, "running")   -- restore the saved paused/running choice
  pause_on_stream = obs.obs_data_get_bool(settings, "pause_on_stream")
  streamer_log_path = obs.obs_data_get_string(settings, "sx_streamer_path") or ""   -- Streamer.bot folder
  obs.obs_frontend_add_event_callback(on_frontend_event)   -- watch stream start/stop to auto-pause
  -- PC health sampler home + rotation + OBS-process CPU handle
  local ad = os.getenv("APPDATA")
  if ad then
    sx_mkdir(ad .. "\\StreamAuditX\\")
    health_path = ad .. "\\StreamAuditX\\health.jsonl"
    health_rotate()
  end
  sxdash.js_path = script_path() .. "streamauditx-health.js"
  if ad then
    sxdash.man_path = ad .. "\\StreamAuditX\\premium-sessions.txt"
    sxdash.vm_path = ad .. "\\StreamAuditX\\vm-latest.txt"
  end
  sxdash.seed()   -- charts have history the moment the dock opens, not an empty line
  pcall(function() obs_cpu_info = obs.os_cpu_usage_info_start() end)
  nvml_init()           -- probe the NVIDIA door once; absent GPU just means absent fields
  if SX_OS == "Windows" and not nvml then sxcol.pdh_init() end   -- any-vendor fallback: OS GPU counters
  pcall(sx_read_tier)   -- know the tier before the first tick (slots persist across restarts)
  if running then
    scan(true)   -- one full pass at load (includes the slow checks); the timer stays fast
    start_timer()
    start_health()
    health_sample()   -- prime the CPU delta so the meter has a number by the second tick
    sxdash.write()    -- and give the dashboard its first data file immediately
  end            -- if paused, do nothing at all until you resume (or click Open for a one-off)
end

function script_save(settings)
  obs.obs_data_set_bool(settings, "running", running)
end

function script_unload()
  stop_timer()
  stop_health()
  nvml_close()
  sxcol.pdh_close()
  pcall(function() if obs_cpu_info then obs.os_cpu_usage_info_destroy(obs_cpu_info); obs_cpu_info = nil end end)
  obs.obs_frontend_remove_event_callback(on_frontend_event)
end
