--[[ FORRE.NU — Victory Royale
     Så här gör du: OBS → Verktyg → Skript → + → välj den här filen.
     Peka sedan ut din replay-mapp och tryck "Testa animationen nu".
]]

--[[ FORRE.NU — Victory Royale-detektor för OBS ------------------------
  EN fil. Verktyg → Skript → + → välj den här filen. Klart.
  Genererad av forre.nu/overlay. Innehåller allt: AES, replay-läsare,
  OBS-koppling. Inga andra filer, ingen installation, inget plugin.
  Händelsenamn: forreVictory · intervall: 2000 ms
----------------------------------------------------------------------]]

-- ── aes ──────────────────────────────────────────────────
local aes = (function()
-- AES-256-ECB avkryptering, ren Lua (LuaJIT 5.1 + bit-lib). Endast dekryptering:
-- vi behover exakt ETT block per match, sa hastighet ar irrelevant och noll
-- beroenden ar allt. Ingen FFI, ingen DLL, funkar aven om ffi ar avstangt.
local bit = require("bit")
local band, bxor, lshift, rshift = bit.band, bit.bxor, bit.lshift, bit.rshift

local SBOX, INV, MUL = {}, {}, {}
do
  -- S-box genereras ur GF(2^8) i stallet for att klistras in som 256 magiska tal
  local p, q = 1, 1
  local sb = {}
  repeat
    p = bxor(p, band(lshift(p,1),0xFF), (band(p,0x80)~=0) and 0x1B or 0)
    p = band(p,0xFF)
    q = bxor(q, band(lshift(q,1),0xFF)); q = band(q,0xFF)
    q = bxor(q, band(lshift(q,2),0xFF)); q = band(q,0xFF)
    q = bxor(q, band(lshift(q,4),0xFF)); q = band(q,0xFF)
    if band(q,0x80) ~= 0 then q = bxor(q,0x09) end
    local x = bxor(q, band(lshift(q,1),0xFF)+rshift(q,7))
    x = bxor(x, band(lshift(q,2),0xFF)+rshift(q,6))
    x = bxor(x, band(lshift(q,3),0xFF)+rshift(q,5))
    x = bxor(x, band(lshift(q,4),0xFF)+rshift(q,4))
    sb[p] = band(bxor(x,0x63),0xFF)
  until p == 1
  sb[0] = 0x63
  for i=0,255 do SBOX[i]=sb[i]; INV[sb[i]]=i end
  local function xt(a) local r=band(lshift(a,1),0xFF); if band(a,0x80)~=0 then r=bxor(r,0x1B) end; return r end
  for _,c in ipairs{9,11,13,14} do
    MUL[c]={}
    for a=0,255 do
      local r,x,n=0,a,c
      while n>0 do if band(n,1)==1 then r=bxor(r,x) end; x=xt(x); n=rshift(n,1) end
      MUL[c][a]=r
    end
  end
end

local function expand(key)   -- 32 byte -> 60 ord (15 rundnycklar)
  local w = {}
  for i=0,7 do
    w[i] = {key:byte(i*4+1), key:byte(i*4+2), key:byte(i*4+3), key:byte(i*4+4)}
  end
  local rcon = 1
  for i=8,59 do
    local t = {w[i-1][1], w[i-1][2], w[i-1][3], w[i-1][4]}
    if i % 8 == 0 then
      t = {SBOX[t[2]], SBOX[t[3]], SBOX[t[4]], SBOX[t[1]]}
      t[1] = bxor(t[1], rcon)
      rcon = band(lshift(rcon,1),0xFF); if rcon==0 then rcon=0x1B end
    elseif i % 8 == 4 then
      t = {SBOX[t[1]], SBOX[t[2]], SBOX[t[3]], SBOX[t[4]]}
    end
    w[i] = {bxor(w[i-8][1],t[1]), bxor(w[i-8][2],t[2]), bxor(w[i-8][3],t[3]), bxor(w[i-8][4],t[4])}
  end
  return w
end

local function addkey(s, w, r)
  for c=0,3 do for i=1,4 do s[c*4+i] = bxor(s[c*4+i], w[r*4+c][i]) end end
end

-- avkryptera ETT 16-byte block, returnera 16 byte
local function decrypt_block(w, blk)
  local s = {}
  for i=1,16 do s[i] = blk:byte(i) end
  addkey(s, w, 14)
  for round=13,0,-1 do
    -- InvShiftRows: rad r roteras hoger r steg (kolumnmajor state)
    local t = {}
    for c=0,3 do for r=0,3 do t[((c+r)%4)*4+r+1] = s[c*4+r+1] end end
    for i=1,16 do s[i] = INV[t[i]] end            -- InvSubBytes
    addkey(s, w, round)
    if round > 0 then                              -- InvMixColumns
      for c=0,3 do
        local a0,a1,a2,a3 = s[c*4+1],s[c*4+2],s[c*4+3],s[c*4+4]
        s[c*4+1] = bxor(MUL[14][a0], MUL[11][a1], MUL[13][a2], MUL[9][a3])
        s[c*4+2] = bxor(MUL[9][a0],  MUL[14][a1], MUL[11][a2], MUL[13][a3])
        s[c*4+3] = bxor(MUL[13][a0], MUL[9][a1],  MUL[14][a2], MUL[11][a3])
        s[c*4+4] = bxor(MUL[11][a0], MUL[13][a1], MUL[9][a2],  MUL[14][a3])
      end
    end
  end
  local out = {}
  for i=1,16 do out[i] = string.char(s[i]) end
  return table.concat(out)
end


return {
  expand = expand,
  ecb_decrypt = function(key, data)
    local w, parts = expand(key), {}
    for i=1,#data,16 do parts[#parts+1] = decrypt_block(w, data:sub(i,i+15)) end
    local out = table.concat(parts)
    local pad = out:byte(#out)                     -- PKCS7
    if pad and pad >= 1 and pad <= 16 then out = out:sub(1, #out-pad) end
    return out
  end,
}
end)()

-- ── replay ──────────────────────────────────────────────────
local replay = (function()
-- Laser .replay-containerns YTTRE lager. Ingen Oodle, inga netFieldExports,
-- ingen demo-frame-avkodning - bara info-blocket och chunk-tabellen.
-- Datachunkarna SEEKAS FORBI, aldrig las: det ar det som gor att en 12 MB-fil
-- kostar ~19 KB I/O. Kritiskt i OBS, dar timer-callbacken kor pa grafiktraden.
local bit = require("bit")
local M = {}

local function rd(f, n) local s = f:read(n); if not s or #s < n then return nil end; return s end
local function u32(f)
  local s = rd(f, 4); if not s then return nil end
  local a,b,c,d = s:byte(1,4)
  return a + b*256 + c*65536 + d*16777216
end
local function i32(f)
  local v = u32(f); if not v then return nil end
  if v >= 2147483648 then v = v - 4294967296 end
  return v
end
local function fstring(f)
  local n = i32(f); if not n then return nil end
  if n == 0 then return "" end
  if n < 0 then local s = rd(f, -n*2); return s and (s:gsub("%z","")) or nil end
  local s = rd(f, n); if not s then return nil end
  return (s:gsub("[%z ]+$",""))
end

local FILE_MAGIC = 0x1CA2E27F

--- Las info-blocket. Returnerar tabell eller nil, err.
function M.info(path)
  local f = io.open(path, "rb"); if not f then return nil, "kan inte öppna" end
  local ok, res = pcall(function()
    if u32(f) ~= FILE_MAGIC then error("fel magic") end
    local ver = u32(f)
    if ver >= 7 then
      local cnt = i32(f) or 0
      f:seek("cur", cnt * 20)                   -- 16 byte GUID + 4 byte int, hoppas
    end
    local t = { version = ver }
    t.lengthInMs = u32(f); u32(f); u32(f)       -- networkVersion, changelist
    t.friendlyName = fstring(f)
    t.isLive = (u32(f) == 1)
    if ver >= 3 then f:seek("cur", 8) end       -- timestamp i64
    if ver >= 2 then t.isCompressed = (u32(f) == 1) end
    if ver >= 6 then
      t.isEncrypted = (u32(f) == 1)
      local ks = u32(f) or 0
      t.key = ks > 0 and rd(f, ks) or nil
    end
    t.chunkStart = f:seek()
    return t
  end)
  f:close()
  if not ok then return nil, tostring(res) end
  return res
end

--- Ga igenom chunk-tabellen och plocka ut event-chunkar. Datachunkar seekas forbi.
-- @return { teamStats = {position=,totalPlayers=}, elims = n, bytesRead = n }
function M.scan(path, key)
  local f = io.open(path, "rb"); if not f then return nil, "kan inte öppna" end
  local info = M.info(path); if not info then f:close(); return nil, "ogiltig info" end
  key = key or info.key
  local size = f:seek("end")
  f:seek("set", info.chunkStart)
  local out = { elims = 0, bytesRead = info.chunkStart, chunks = 0 }
  while true do
    local ctype = u32(f); if not ctype then break end
    local csize = i32(f); if not csize or csize <= 0 then break end
    local off = f:seek()
    if off + csize > size then break end        -- trunkerad fil (live) - avbryt rent
    out.chunks = out.chunks + 1
    out.bytesRead = out.bytesRead + 8
    if ctype == 3 then                          -- Event
      local id, group, meta = fstring(f), fstring(f), fstring(f)
      u32(f); u32(f)                            -- startTime, endTime
      local payloadLen = i32(f) or 0
      out.bytesRead = out.bytesRead + (f:seek() - off)
      if group == "playerElim" then
        out.elims = out.elims + 1
      elseif meta == "AthenaMatchTeamStats" and key and payloadLen > 0 and payloadLen <= 64 then
        local enc = rd(f, payloadLen)
        out.bytesRead = out.bytesRead + payloadLen
        if enc then
          local pt = aes.ecb_decrypt(key, enc)
          if #pt >= 12 then
            local function g(o) local a,b,c,d = pt:byte(o,o+3); return a+b*256+c*65536+d*16777216 end
            out.teamStats = { unknown = g(1), position = g(5), totalPlayers = g(9) }
          end
        end
      end
    end
    f:seek("set", off + csize)                  -- YTTRE storleken ar facit. Alltid.
  end
  f:close()
  return out
end

--- Hela svaret pa "vann vi?".
function M.result(path)
  local info, err = M.info(path)
  if not info then return nil, err end
  if info.isLive then return { live = true } end
  local s = M.scan(path, info.key)
  if not s then return nil, "scan misslyckades" end
  return {
    live = false,
    won = s.teamStats and s.teamStats.position == 1 or false,
    position = s.teamStats and s.teamStats.position or nil,
    totalPlayers = s.teamStats and s.teamStats.totalPlayers or nil,
    elims = s.elims,
    lengthInMs = info.lengthInMs,
    bytesRead = s.bytesRead,
    chunks = s.chunks,
  }
end

return M
end)()


local obs = obslua
local R = replay

local S = {
  dir       = "",     -- Demos-mappen
  interval  = 2000,   -- ms mellan koll
  event     = "forreVictory",
  seen      = {},     -- filnamn -> true, redan avgjorda
  primed    = false,  -- första svepet räknas inte som vinst
  lastCount = 0,
}

local function log(...) print("[FORRE] " .. table.concat({...}, " ")) end

local function default_dir()
  local la = os.getenv("LOCALAPPDATA")
  if la and la ~= "" then return la .. "\\FortniteGame\\Saved\\Demos" end
  return ""
end

-- Sökvägsseparator ur Lua själv: "\\" på Windows, "/" annars.
local SEP = package.config:sub(1, 1)
local IS_WIN = (SEP == "\\")

--- Lista *.replay, nyast först, utan att öppna dem.
-- Fortnite finns bara på Windows, men separatorn hämtas ur Lua i stället för att
-- hårdkodas — annars går skriptet inte att testa någon annanstans, och en
-- otestbar detektor är en detektor man inte vet fungerar.
local function list_replays()
  if S.dir == "" then return {} end
  local cmd = IS_WIN
    and ('dir /b /o-d "' .. S.dir .. '\\*.replay" 2>nul')
    or  ('ls -t "' .. S.dir .. '"/*.replay 2>/dev/null')
  local out, p = {}, io.popen(cmd)
  if not p then return out end
  for line in p:lines() do
    line = line:gsub("^%s+", ""):gsub("%s+$", "")
    line = line:match("([^/\\]+)$") or line          -- ls ger full sökväg, dir bara namnet
    if line ~= "" then out[#out+1] = line end
  end
  p:close()
  return out
end

--- Skicka ett CustomEvent till ALLA browser-källor.
-- Motsvarar exakt det obs-websockets "emit_event" gör, men utan servern.
local function dispatch(name, json)
  local sources, n = obs.obs_enum_sources(), 0
  if sources then
    for _, src in ipairs(sources) do
      if obs.obs_source_get_id(src) == "browser_source" then
        local cd = obs.calldata_create()
        obs.calldata_set_string(cd, "eventName", name)
        obs.calldata_set_string(cd, "jsonString", json)
        obs.proc_handler_call(obs.obs_source_get_proc_handler(src), "javascript_event", cd)
        obs.calldata_destroy(cd)
        n = n + 1
      end
    end
    obs.source_list_release(sources)
  end
  return n
end

local function json_of(r, file)
  local function q(s) return '"' .. tostring(s):gsub('[\\"]', '\\%0') .. '"' end
  return string.format(
    '{"won":%s,"position":%s,"totalPlayers":%s,"elims":%d,"lengthMs":%d,"file":%s}',
    tostring(r.won), tostring(r.position or "null"), tostring(r.totalPlayers or "null"),
    r.elims or 0, r.lengthInMs or 0, q(file))
end

local function tick()
  local files = list_replays()
  if #files == 0 then return end

  -- Första svepet: markera allt som redan sett. Annars fyras en vinst av
  -- från förra veckans replay så fort skriptet laddas.
  if not S.primed then
    for _, f in ipairs(files) do S.seen[f] = true end
    S.primed = true
    log("primad med " .. #files .. " befintliga replays i " .. S.dir)
    return
  end

  -- Bara de nyaste fem behöver kollas; listan är sorterad nyast först.
  for i = 1, math.min(#files, 5) do
    local name = files[i]
    if not S.seen[name] then
      local r, err = R.result(S.dir .. SEP .. name)
      if not r then
        log("kunde inte läsa " .. name .. ": " .. tostring(err))
        S.seen[name] = true                    -- försök inte igen i all evighet
      elseif r.live then
        -- Matchen pågår. Rör den inte — den blir avgjord av sig själv.
      else
        S.seen[name] = true
        local n = dispatch(S.event, json_of(r, name))
        log(string.format("%s · placering %s/%s · %d elims · %d browser-källor",
            r.won and "VICTORY ROYALE" or "match slut (ingen vinst)",
            tostring(r.position), tostring(r.totalPlayers), r.elims or 0, n))
      end
    end
  end
end

-- ── OBS-gränssnitt ────────────────────────────────────────────────────────
function script_description()
  return [[<b>FORRE.NU — Victory Royale</b><br><br>
Bevakar Fortnites replay-mapp och skickar ett <code>CustomEvent</code> till alla
browser-källor när en match avgörs.<br><br>
I overlayn:<br>
<code>window.addEventListener('forreVictory', e =&gt; {<br>
&nbsp;&nbsp;if (e.detail.won) spelaAnimation(e.detail);<br>
});</code><br><br>
Ingen app, inget plugin, ingen obs-websocket. Filen läses aldrig i sin helhet —
bara containerns yttre lager, cirka 400 byte.]]
end

function script_properties()
  local p = obs.obs_properties_create()
  obs.obs_properties_add_path(p, "dir", "Replay-mapp", obs.OBS_PATH_DIRECTORY, "", default_dir())
  obs.obs_properties_add_int(p, "interval", "Kolla var (ms)", 500, 30000, 500)
  obs.obs_properties_add_text(p, "event", "Händelsenamn", obs.OBS_TEXT_DEFAULT)
  obs.obs_properties_add_button(p, "test", "Testa animationen nu", function()
    local n = dispatch(S.event,
      '{"won":true,"position":1,"totalPlayers":98,"elims":14,"lengthMs":1122000,"file":"test"}')
    log("testhändelse skickad till " .. n .. " browser-källor")
    return false
  end)
  return p
end

function script_defaults(s)
  obs.obs_data_set_default_string(s, "dir", default_dir())
  obs.obs_data_set_default_int(s, "interval", 2000)
  obs.obs_data_set_default_string(s, "event", "forreVictory")
end

function script_update(s)
  S.dir      = obs.obs_data_get_string(s, "dir")
  S.interval = obs.obs_data_get_int(s, "interval")
  S.event    = obs.obs_data_get_string(s, "event")
  S.primed, S.seen = false, {}
  obs.timer_remove(tick)
  if S.dir ~= "" then obs.timer_add(tick, S.interval) end
end

function script_unload() obs.timer_remove(tick) end
