-- swim-pause-menu.online.lua -- -- PRODUCTION online script. Replaces AC's ESC pause UI on the swim server -- with a custom Obsidian-Glass card, a built-in server browser, and the -- global cutup leaderboard. Drop this file into the server's online-script -- slot. -- -- Runs alongside swim-cutup.online.lua. The two share live settings via -- the `ac.connect` block below — that script owns disk persistence (this -- one only reads/writes the shared struct). -- -- For local iteration with a debug toggle for the unregistered-prompt -- branch, use swim-pause-menu.app.lua instead. -- Auto-refresh any cached fetch (servers, leaderboard) older than this when -- the menu opens. Manual refresh button always bypasses the cache. local CACHE_TTL_SEC = 120 -- The menu is triggered by ESC at any time (driving, paused, replay etc.) -- and the game keeps running underneath. local menuOpen = false local sim = ac.getSim() local steam = require('shared/utils/steam') -- Player identity (looked up once at script load — Steam ID won't change) local localSteamId do local ok, id = pcall(function() return steam.user.getSteamID() end) localSteamId = ok and id or nil end -------------- -- Settings — two-layer scheme so the cutup script (a separate online script) -- can read live values without us having to merge the codebases: -- -- 1. Persisted (ac.storage) — disk, debounced auto-save, per-script -- 2. Settings (ac.connect) — shared memory, visible to every Lua script -- in the session, zero-cost reads -- -- The pause menu owns the lifecycle: load from disk, push into shared, and -- mirror writes to both. The cutup script just declares the same ac.connect -- layout and reads Settings.hudEnabled etc. directly. -- -- IMPORTANT: the layout below must be byte-for-byte identical in both -- scripts. Add `ac.StructItem.key(...)` to scope the channel safely. local Persisted = ac.storage{ hudEnabled = true, hudPosition = 1, -- 1=TL, 2=TR, 3=BL, 4=BR compactMode = false, } local Settings = ac.connect({ ac.StructItem.key('swim.settings.v1'), hudEnabled = ac.StructItem.boolean(), hudPosition = ac.StructItem.int32(), compactMode = ac.StructItem.boolean(), }, true) -- keepLive=true so the struct outlives any single script unload -- Hydrate the shared struct from disk on startup. If the cutup script also -- runs ac.connect with the same key, it sees these values immediately. Settings.hudEnabled = Persisted.hudEnabled Settings.hudPosition = Persisted.hudPosition Settings.compactMode = Persisted.compactMode -- Mirror writes: shared struct (live consumers) AND ac.storage (disk). local function setPref(key, value) Settings[key] = value Persisted[key] = value end local POSITION_NAMES = { 'TL', 'TR', 'BL', 'BR' } -------------- -- THEME (Obsidian Glass — palette mirrors swim>) local Theme = { glass = rgbm(0.047, 0.047, 0.059, 0.78), -- card base glassSheen = rgbm(1, 1, 1, 0.06), -- top sheen (high) glassSheenLo = rgbm(1, 1, 1, 0.00), -- top sheen (low) glassEdge = rgbm(1, 1, 1, 0.10), -- subtle card border glassSurface = rgbm(0.094, 0.098, 0.118, 0.78), -- inner button bg glassSurfaceHi = rgbm(0.149, 0.153, 0.180, 0.92), -- inner button bg on hover textHero = rgbm(1, 1, 1, 1), textPrimary = rgbm(0.941, 0.941, 0.949, 1), textSecondary = rgbm(0.659, 0.659, 0.690, 1), textMuted = rgbm(0.376, 0.376, 0.408, 1), textGhost = rgbm(0.518, 0.518, 0.553, 1), accent = rgbm(0.647, 0.769, 0.831, 1), accentBright = rgbm(0.761, 0.855, 0.902, 1), accentGlow = rgbm(0.647, 0.769, 0.831, 0.22), accentSubtle = rgbm(0.647, 0.769, 0.831, 0.10), success = rgbm(0.639, 0.902, 0.208, 1), error = rgbm(0.973, 0.443, 0.443, 1), backdrop = rgbm(0.012, 0.012, 0.024, 0.42), -- behind the cards } local UI = { cardW = 420, cardH = 550, userCardW = 420, userCardH = 170, serverCardW = 460, serverCardH = 736, -- pause(550) + 16 + user(170) — match col 1 height lbCardW = 460, lbCardH = 736, -- matches server column cardGap = 16, pad = 22, radius = 18, innerRadius = 11, btnH = 44, btnGap = 8, pulsePhase = 0, } -------------- -- Smoothing helper (frame-rate independent) local function smooth(current, target, speed, dt) local k = 1 - math.exp(-speed * dt) return current + (target - current) * k end local function lerpColor(a, b, t) return rgbm( a.r + (b.r - a.r) * t, a.g + (b.g - a.g) * t, a.b + (b.b - a.b) * t, a.mult + (b.mult - a.mult) * t ) end -------------- -- Drawing primitives -- Rounded rect with optional border. Bug fix: ui.drawRect signature is -- (p1, p2, color, rounding, roundingFlags, thickness) — earlier code was -- passing thickness into the roundingFlags slot, leaving 3 corners square, -- which created the visible "thin straight line" at the card edges. local function drawPanel(x, y, w, h, radius, fillColor, borderColor, borderWidth) local p1, p2 = vec2(x, y), vec2(x + w, y + h) if fillColor then ui.drawRectFilled(p1, p2, fillColor, radius) end if borderColor then ui.drawRect(p1, p2, borderColor, radius, ui.CornerFlags.All, borderWidth or 1) end end -- Vertical gradient with rounded corners. Uses beginGradientShade so the -- gradient respects the card's rounded corners (drawRectFilledMultiColor -- has no rounding param and would leak past curved edges). local function drawVerticalGradient(x, y, w, h, top, bottom, rounding, cornerFlags) ui.beginGradientShade() ui.drawRectFilled(vec2(x, y), vec2(x + w, y + h), rgbm.colors.white, rounding or 0, cornerFlags or ui.CornerFlags.All) ui.endGradientShade(vec2(x, y), vec2(x, y + h), top, bottom, true) end -- Centered glow strip (the accent at the top of the card) local function drawGlowStrip(x, y, width, color) local cx = x + width / 2 local sw = width * 0.6 ui.drawRectFilled(vec2(cx - sw / 2, y), vec2(cx + sw / 2, y + 2), color) end -- ── Card chrome ──────────────────────────────────────────────────────────── -- Halo + base panel + top accent strip. Every card uses this set of layers -- to define its frame. local function drawCardShell(cardW, cardH, accent) drawPanel(-2, -2, cardW + 4, cardH + 4, UI.radius + 2, nil, Theme.accentGlow, 1) drawPanel(0, 0, cardW, cardH, UI.radius, Theme.glass, Theme.glassEdge, 1) drawGlowStrip(0, 0, cardW, accent) end -- Top sheen gradient sized to a header band, rounded on the top corners -- to follow the card silhouette. local function drawCardSheen(cardW, headerBandH) drawVerticalGradient(0, 0, cardW, headerBandH, Theme.glassSheen, Theme.glassSheenLo, UI.radius, ui.CornerFlags.Top) end -- Mirror of the top accent strip, drawn on the bottom edge at half alpha. local function drawCardFooterStrip(cardW, cardH, accent) drawGlowStrip(0, cardH - 2, cardW, rgbm(accent.r, accent.g, accent.b, 0.5)) end -- ── Icon buttons in card corners ─────────────────────────────────────────── -- Square 28x28 hit area, vertically centered against a brand line of the -- given height. Used for the close (×) and refresh (↻) glyphs. local CORNER_BTN_SIZE = 28 local function cornerButton(id, cardW, brandY, brandH, onClick) local sz = CORNER_BTN_SIZE local cx = cardW - sz - 12 local cyBtn = brandY + (brandH - sz) / 2 ui.setCursor(vec2(cx, cyBtn)) if ui.invisibleButton(id, vec2(sz, sz)) then onClick() end local hov = ui.itemHovered() return cx, cyBtn, sz, hov end -- Refresh (Reset) icon in the top-right of a card. local function drawRefreshButton(cardW, brandY, brandH, id, onClick) local cx, cyBtn, sz, hov = cornerButton(id, cardW, brandY, brandH, onClick) local col = hov and Theme.textHero or Theme.textSecondary local iconSz = 18 ui.setCursor(vec2(cx + (sz - iconSz) / 2, cyBtn + (sz - iconSz) / 2)) ui.icon(ui.Icons.Reset, iconSz, col) end -- Close (×) glyph drawn with two strokes. local function drawCloseButton(cardW, brandY, brandH, id, onClick) local cx, cyBtn, sz, hov = cornerButton(id, cardW, brandY, brandH, onClick) local col = hov and Theme.textHero or Theme.textSecondary local p = 8 ui.drawLine(vec2(cx + p, cyBtn + p), vec2(cx + sz - p, cyBtn + sz - p), col, 2) ui.drawLine(vec2(cx + sz - p, cyBtn + p), vec2(cx + p, cyBtn + sz - p), col, 2) end -- 8-dot pulsing ring used as the "loading" indicator inside list cards. local function drawSpinner(x, y, size, color, t) local cx, cy = x + size / 2, y + size / 2 local r = size / 2 - 2 for i = 0, 7 do local a = (i / 8) * math.pi * 2 local fade = 0.2 + 0.8 * ((math.sin(t * 4 - i * 0.6) + 1) / 2) ui.drawCircleFilled( vec2(cx + math.cos(a) * r, cy + math.sin(a) * r), 2, rgbm(color.r, color.g, color.b, color.mult * fade), 8) end end -- ── List status fallback ─────────────────────────────────────────────────── -- Renders the loading / error / empty branch for any data-driven list and -- returns true if it handled the frame (so the caller can skip its rows). local function drawListStatus(state, errMsg, emptyMsg, isEmpty, innerW, innerH, accent, spinPhase, loadingLabel) if state == 'loading' or state == 'idle' then ui.pushFont(ui.Font.Small) local lw = ui.measureText(loadingLabel).x ui.popFont() local cx0 = (innerW - (16 + 8 + lw)) / 2 local cy0 = innerH / 2 - 12 drawSpinner(cx0, cy0, 16, accent, spinPhase) ui.pushFont(ui.Font.Small) ui.setCursor(vec2(cx0 + 24, cy0 + 1)) ui.textColored(loadingLabel, Theme.textSecondary) ui.popFont() return true end if state == 'error' then ui.pushFont(ui.Font.Small) ui.setCursor(vec2(8, 12)) ui.textColored('Failed to load', Theme.error) ui.setCursor(vec2(8, 30)) ui.textColored(errMsg or '', Theme.textMuted) ui.popFont() return true end if isEmpty then ui.pushFont(ui.Font.Small) ui.setCursor(vec2(8, 12)) ui.textColored(emptyMsg, Theme.textMuted) ui.popFont() return true end return false end -- Shared scaffolding for any scrollable list inside a card: bg panel, -- themed scrollbar (slim, accent-on-drag), child window with clipping. -- The caller's contentFn receives (innerW, innerH, scrollbarW) and renders -- rows at positions relative to the child window. local SCROLLBAR_W = 8 local function drawScrollableList(id, x, y, w, h, accent, contentFn) drawPanel(x, y, w, h, UI.innerRadius, Theme.glassSurface, Theme.glassEdge, 1) ui.setCursor(vec2(x + 6, y + 6)) local innerW = w - 12 local innerH = h - 12 ui.pushStyleVar(ui.StyleVar.ScrollbarSize, SCROLLBAR_W) ui.pushStyleVar(ui.StyleVar.ScrollbarRounding, 4) ui.pushStyleColor(ui.StyleColor.ScrollbarBg, rgbm(0, 0, 0, 0)) ui.pushStyleColor(ui.StyleColor.ScrollbarGrab, rgbm(1, 1, 1, 0.10)) ui.pushStyleColor(ui.StyleColor.ScrollbarGrabHovered, rgbm(1, 1, 1, 0.20)) ui.pushStyleColor(ui.StyleColor.ScrollbarGrabActive, rgbm(accent.r, accent.g, accent.b, 0.65)) if ui.beginChild(id, vec2(innerW, innerH), false, ui.WindowFlags.NoBackground) then contentFn(innerW, innerH, SCROLLBAR_W) end ui.endChild() ui.popStyleColor(4) ui.popStyleVar(2) end -------------- -- Per-button hover animation state local hover = setmetatable({}, { __index = function() return 0 end }) -- Compact toggle/selector pill. Active = accent-tinted bg + accent text; -- inactive = glass surface + primary text. Returns true on click. local function togglePill(id, label, x, y, w, h, active, accent) local bg = active and Theme.accentSubtle or Theme.glassSurface local edge = active and Theme.accent or Theme.glassEdge drawPanel(x, y, w, h, UI.innerRadius, bg, edge, 1) ui.setCursor(vec2(x, y)) local clicked = ui.invisibleButton(id, vec2(w, h)) local hov = ui.itemHovered() local col = active and accent or (hov and Theme.textHero or Theme.textPrimary) ui.pushFont(ui.Font.Small) local lw = ui.measureText(label).x ui.setCursor(vec2(x + (w - lw) / 2, y + (h - 14) / 2)) ui.textColored(label, col) ui.popFont() return clicked end -- Animated row button with optional icon, primary accent bar, and a hint -- on the right. Returns true on click. local function rowButton(opts) local id, label, icon = opts.id, opts.label, opts.icon local x, y, w, h = opts.x, opts.y, opts.w, opts.h local primary, dt, hint = opts.primary, opts.dt or 0, opts.hint ui.setCursor(vec2(x, y)) local clicked = ui.invisibleButton(id, vec2(w, h)) hover[id] = smooth(hover[id] or 0, ui.itemHovered() and 1 or 0, 14, dt) local hv = hover[id] local bg = lerpColor(Theme.glassSurface, Theme.glassSurfaceHi, hv) local edge = lerpColor(Theme.glassEdge, primary and Theme.accent or Theme.accentSubtle, hv) drawPanel(x, y, w, h, UI.innerRadius, bg, edge, 1) if primary then ui.drawRectFilled(vec2(x, y + 8), vec2(x + 3, y + h - 8), Theme.accent) end local textX = x + 16 if icon then local iconCol = primary and lerpColor(Theme.accent, Theme.accentBright, hv) or lerpColor(Theme.textSecondary, Theme.textHero, hv) ui.setCursor(vec2(x + 14, y + (h - 18) / 2)) ui.icon(icon, 18, iconCol) textX = x + 14 + 18 + 12 end ui.pushFont(ui.Font.Main) local labelCol = lerpColor(Theme.textPrimary, Theme.textHero, hv) local lh = ui.measureText(label).y ui.setCursor(vec2(textX, y + (h - lh) / 2)) ui.textColored(label, labelCol) ui.popFont() if hint then ui.pushFont(ui.Font.Small) local hw = ui.measureText(hint).x ui.setCursor(vec2(x + w - hw - 14, y + (h - 14) / 2)) ui.textColored(hint, Theme.textGhost) ui.popFont() end return clicked end -------------- -- Server browser state + networking local SWIM_API = 'https://api.swimserver.com/servers/assetto' local Browser = { state = 'idle', -- 'idle' | 'loading' | 'ready' | 'error' servers = {}, error = nil, lastFetch = 0, spinPhase = 0, -- for the loading spinner animation } -- Pulls the max-player count out of swim's `player_format` like "{players}/12" local function parseMaxFromFormat(fmt) if type(fmt) ~= 'string' then return 0 end local m = fmt:match('/(%d+)') return tonumber(m) or 0 end local function truncate(s, n) if type(s) ~= 'string' then return '' end if #s > n then return s:sub(1, n - 1) .. '…' end return s end -- Schema (swim API): -- { running, total, servers: [ -- { id, name, display_name, status, group, description, -- connect_url, players: "0", player_format: "{players}/12" }, ... ] } local function parseServers(raw) local data = JSON.parse(raw) if type(data) ~= 'table' then return nil, 'malformed JSON' end local list = data.servers or data.data or data if type(list) ~= 'table' then return nil, 'no server list in response' end local out = {} for _, s in ipairs(list) do if type(s) == 'table' and (s.status == nil or s.status == 'running') then out[#out + 1] = { id = tostring(s.id or s.name or ''), name = tostring(s.display_name or s.name or s.serverName or 'Unnamed'), group = tostring(s.group or ''), description = tostring(s.description or ''), connectUrl = tostring(s.connect_url or s.connectUrl or ''), players = tonumber(s.players) or 0, maxPlayers = parseMaxFromFormat(s.player_format), passworded = s.password == true or s.passworded == true, } end end table.sort(out, function(a, b) if a.players ~= b.players then return a.players > b.players end return a.name:lower() < b.name:lower() end) return out end local function fetchServers() if Browser.state == 'loading' then return end Browser.state = 'loading' Browser.error = nil ac.log('swim> fetching server list...') web.get(SWIM_API, function(err, response) if err then Browser.state = 'error' Browser.error = tostring(err) ac.log('swim> server fetch failed: ' .. Browser.error) return end if response.status and response.status >= 400 then Browser.state = 'error' Browser.error = 'HTTP ' .. tostring(response.status) return end local body = response.body or '' local servers, perr = parseServers(body) if not servers then Browser.state = 'error' Browser.error = perr or 'parse error' return end Browser.servers = servers Browser.state = 'ready' Browser.lastFetch = tonumber(sim.systemTime) or 0 ac.log(string.format('swim> %d servers loaded', #servers)) end) end -------------- -- Join flow with confirm modal -- Confirm = nil OR { server = s } -- modal visible -- Joining = nil OR { server, state, error } -- in-flight join local Confirm = nil local Joining = nil -- Hand the join off to Content Manager without flashing a browser window. -- swim's connect_url is an HTTP URL that 30x-redirects to an `acstuff://` -- link; CM is registered as the OS handler for that scheme. We: -- 1. HEAD the swim URL ourselves -- 2. Pull `acstuff://...` out of the Location header (CSP's web stack can't -- auto-follow a non-HTTP redirect, so the 302 surfaces back to us) -- 3. Pass the acstuff URL straight to os.openURL, which dispatches to CM -- via the protocol handler — no browser involved -- CM then does the full Steamworks flow (auth ticket, race.ini, AC relaunch -- with proper Steam context). local function dispatchAndShutdown(url) ac.log('swim> os.openURL: ' .. url) os.openURL(url) setTimeout(function() ac.shutdownAssettoCorsa() end, 0.5) end -- Tiny HTML entity decoder. The `acmanager://` link in CM's redirect page -- has its query separator encoded as `&` (HTML), but Windows' shell -- protocol dispatcher wants a plain `&` so CM gets the parameters intact. local function htmlDecode(s) return (s:gsub('&', '&'):gsub('<', '<'):gsub('>', '>') :gsub('"', '"'):gsub(''', "'")) end -- Pulls a CM protocol link out of an HTML body. swim's redirect page renders: -- Join -- We match the URL literal up to the closing quote / whitespace / angle. local function extractCmLink(text) if not text then return nil end -- Allow either acmanager:// (current scheme) or acstuff:// (legacy alias) local m = text:match('ac[mst][a-z]+://[^"\'<>%s]+') return m and htmlDecode(m) or nil end local function performConnect(s) ac.log('swim> resolving CM link from ' .. s.connectUrl) web.get(s.connectUrl, function(err, response) local link -- 1) Standard HTTP redirect — if CSP didn't auto-follow. if response and response.headers then local loc = response.headers['Location'] or response.headers['location'] if loc then link = extractCmLink(loc) or (loc:match('^ac') and loc or nil) end end -- 2) HTML body containing the acmanager:// link (CM's redirect page -- is a 200 with this anchor — the most common path for swim). if not link and response and response.body then link = extractCmLink(response.body) end -- 3) Defensive: error message sometimes contains the URL if not link and err then link = extractCmLink(tostring(err)) end if link then dispatchAndShutdown(link) else local status = response and response.status or 'no-response' local preview = (response and response.body or ''):sub(1, 400) ac.log(string.format('swim> no CM link found (status=%s)', tostring(status))) ac.log('swim> body preview: ' .. preview) ac.log('swim> falling back to opening the http URL (browser will flash)') dispatchAndShutdown(s.connectUrl) end end) end local function resolveAndConnect(s) if not s or s.connectUrl == '' then Joining = { server = s, state = 'error', error = 'no connect_url' } return end Joining = { server = s, state = 'connecting' } performConnect(s) end -- Called by the JOIN button on a row — opens the confirm modal. local function requestJoin(s) if Joining and (Joining.state == 'resolving' or Joining.state == 'connecting') then return end Confirm = { server = s } end local function confirmJoin() if not Confirm then return end local s = Confirm.server Confirm = nil resolveAndConnect(s) end local function cancelJoin() Confirm = nil end -- Kick off an initial fetch so the list is warm by the first ESC press. fetchServers() -------------- -- Leaderboard state + networking -- Schema (api.swimserver.com/identity/cutup/leaderboard) — array of: -- { steamid, player_name, username, discord_id, -- track, track_name, car, car_name, score } -- Already sorted by score descending in the response. -- registered_only=true makes the server return only entries where the player -- has linked their Steam to a swimserver.com account. Saves client-side work -- and shrinks the payload. local LEADERBOARD_API = 'https://api.swimserver.com/identity/cutup/leaderboard?registered_only=true' local Leaderboard = { state = 'idle', -- 'idle' | 'loading' | 'ready' | 'error' entries = {}, error = nil, lastFetch = 0, } -- Filter selection used by both the leaderboard card and the user-score -- card. Empty string = no filter (i.e., "All"). local mapFilterId = '' local carFilterId = '' -- Catalog of cars + maps from swim's API. Schema is { id = display_name, ... } -- so we expose both a `byId` lookup and a `list` (sorted by display name) for -- the dropdowns. local Maps = { state = 'idle', byId = {}, list = {}, error = nil } local Cars = { state = 'idle', byId = {}, list = {}, error = nil } local function fetchCatalog(target, url, label) if target.state == 'loading' then return end target.state = 'loading' target.error = nil web.get(url, function(err, response) if err then target.state = 'error'; target.error = tostring(err); return end if response.status and response.status >= 400 then target.state = 'error'; target.error = 'HTTP ' .. tostring(response.status); return end local data = JSON.parse(response.body or '') if type(data) ~= 'table' then target.state = 'error'; target.error = 'malformed JSON'; return end local byId, list = {}, {} for id, name in pairs(data) do if type(id) == 'string' and type(name) == 'string' then byId[id] = name list[#list + 1] = { id = id, name = name } end end table.sort(list, function(a, b) return a.name:lower() < b.name:lower() end) target.byId = byId target.list = list target.state = 'ready' ac.log(string.format('swim> %s catalog loaded (%d entries)', label, #list)) end) end -- Format a number with thousands separators: 1234567 -> "1,234,567" local function fmtScore(n) local s = tostring(math.floor(tonumber(n) or 0)) while true do local s2, c = s:gsub('^(-?%d+)(%d%d%d)', '%1,%2') s = s2 if c == 0 then break end end return s end -- Steam IDs are 17-digit integers that exceed Lua's double precision -- (53 bits). JSON.parse silently rounds them, so we strip non-digits and -- compare as digit-strings. fetchLeaderboard also pre-quotes the steamid -- field in the JSON body so the parser keeps the exact value as a string. local function steamIdEqual(a, b) if a == nil or b == nil then return false end local sa = tostring(a):gsub('%D+', '') local sb = tostring(b):gsub('%D+', '') return sa ~= '' and sa == sb end local function applyLbFilter(entries) if mapFilterId == '' and carFilterId == '' then return entries end local out = {} for _, e in ipairs(entries) do local mapOk = mapFilterId == '' or e.track == mapFilterId local carOk = carFilterId == '' or e.car == carFilterId if mapOk and carOk then out[#out + 1] = e end end return out end -- Return rank (1-based) and entry for the local player in the given list, or nil. local function findUserEntry(entries) if not localSteamId then return nil, nil end for i, e in ipairs(entries) do if steamIdEqual(e.steamid, localSteamId) then return i, e end end return nil, nil end local function fetchLeaderboard() if Leaderboard.state == 'loading' then return end Leaderboard.state = 'loading' Leaderboard.error = nil ac.log('swim> fetching leaderboard...') web.get(LEADERBOARD_API, function(err, response) if err then Leaderboard.state = 'error' Leaderboard.error = tostring(err) ac.log('swim> leaderboard fetch failed: ' .. Leaderboard.error) return end if response.status and response.status >= 400 then Leaderboard.state = 'error' Leaderboard.error = 'HTTP ' .. tostring(response.status) return end -- Pre-quote 17-digit IDs in the body so JSON.parse keeps them as strings. -- Without this they parse as doubles and the last 4-5 digits get rounded, -- which breaks the local-player lookup. local body = (response.body or '') :gsub('"steamid"%s*:%s*(%-?%d+)', '"steamid":"%1"') :gsub('"discord_id"%s*:%s*(%-?%d+)', '"discord_id":"%1"') local data = JSON.parse(body) if type(data) ~= 'table' then Leaderboard.state = 'error' Leaderboard.error = 'malformed JSON' return end table.sort(data, function(a, b) return (tonumber(a.score) or 0) > (tonumber(b.score) or 0) end) Leaderboard.entries = data Leaderboard.state = 'ready' Leaderboard.lastFetch = tonumber(sim.systemTime) or 0 ac.log(string.format('swim> %d leaderboard entries loaded', #data)) end) end fetchLeaderboard() fetchCatalog(Maps, 'https://api.swimserver.com/assetto/maps', 'maps') fetchCatalog(Cars, 'https://api.swimserver.com/assetto/cars', 'cars') -------------- -- Pause menu local function drawCustomPauseMenu(dt, cardX, cardY) -- Pulse the accent (shared via UI.pulsePhase, so animation stays in sync -- with the server browser card) local pulse = 0.92 + math.sin(UI.pulsePhase) * 0.08 local accent = rgbm(Theme.accent.r * pulse, Theme.accent.g * pulse, Theme.accent.b * pulse, 1) local cardW, cardH = UI.cardW, UI.cardH ui.beginTransparentWindow('swimPauseMenu', vec2(cardX, cardY), vec2(cardW, cardH), true, true) local pad = UI.pad drawCardShell(cardW, cardH, accent) ui.pushFont(ui.Font.Huge) local brandSize = ui.measureText('swim>') ui.popFont() local brandY = 16 local headerBandH = brandY + brandSize.y + 14 drawCardSheen(cardW, headerBandH) ui.pushFont(ui.Font.Huge) ui.setCursor(vec2(pad, brandY)) ui.textColored('swim', accent) ui.sameLine(0, 0) ui.textColored('>', Theme.textMuted) ui.popFont() drawCloseButton(cardW, brandY, brandSize.y, '##swimPauseClose', function() menuOpen = false end) -- Divider just below the brand local cy = headerBandH ui.drawRectFilled(vec2(pad, cy), vec2(cardW - pad, cy + 1), Theme.glassEdge) cy = cy + 18 -- ── SESSION ───────────────────────── ui.pushFont(ui.Font.Small) ui.setCursor(vec2(pad, cy)) ui.textColored('SESSION', Theme.textGhost) ui.popFont() cy = cy + 18 local btnW = cardW - pad * 2 if rowButton{ id = '##resume', label = 'Resume', icon = ui.Icons.Resume, x = pad, y = cy, w = btnW, h = UI.btnH, primary = true, dt = dt, hint = 'ESC', } then menuOpen = false end cy = cy + UI.btnH + UI.btnGap if rowButton{ id = '##teleport', label = 'Teleport to pits', icon = ui.Icons.PitStopAlt, x = pad, y = cy, w = btnW, h = UI.btnH, dt = dt, } then ac.tryToTeleportToPits() menuOpen = false end cy = cy + UI.btnH + UI.btnGap if rowButton{ id = '##revert', label = 'Revert to pits', icon = ui.Icons.Pitlane, x = pad, y = cy, w = btnW, h = UI.btnH, dt = dt, } then -- Park in the pit box, then surface AC's race menu (Drive / Setup tabs). -- The menu only opens when the car is in pits, so defer slightly to let -- the teleport land first. ac.tryToTeleportToPits() menuOpen = false setTimeout(function() ac.tryToOpenRaceMenu() end, 0.1) end cy = cy + UI.btnH + UI.btnGap if rowButton{ id = '##exit', label = 'Exit game', icon = ui.Icons.Exit, x = pad, y = cy, w = btnW, h = UI.btnH, dt = dt, } then ac.shutdownAssettoCorsa() end cy = cy + UI.btnH + UI.btnGap + 8 -- ── SETTINGS ───────────────────────── ui.pushFont(ui.Font.Small) ui.setCursor(vec2(pad, cy)) ui.textColored('SETTINGS', Theme.textGhost) ui.popFont() cy = cy + 18 -- HUD + Compact toggles, side-by-side local toggleW = (btnW - 8) / 2 local toggleH = 32 if togglePill('##setHud', Settings.hudEnabled and 'HUD: ON' or 'HUD: off', pad, cy, toggleW, toggleH, Settings.hudEnabled, accent) then setPref('hudEnabled', not Settings.hudEnabled) end if togglePill('##setCompact', Settings.compactMode and 'Compact: ON' or 'Compact: off', pad + toggleW + 8, cy, toggleW, toggleH, Settings.compactMode, accent) then setPref('compactMode', not Settings.compactMode) end cy = cy + toggleH + 10 -- HUD position grid, 4 pills in a row local pillGap = 6 local pillW = (btnW - pillGap * 3) / 4 local pillH = 30 for i, name in ipairs(POSITION_NAMES) do local px = pad + (i - 1) * (pillW + pillGap) if togglePill('##pos' .. i, name, px, cy, pillW, pillH, Settings.hudPosition == i, accent) then setPref('hudPosition', i) end end cy = cy + pillH -- ── Footer ───────────────────────── drawCardFooterStrip(cardW, cardH, accent) ui.pushFont(ui.Font.Small) ui.setCursor(vec2(pad, cardH - 26)) ui.textColored('swim>', Theme.textMuted) local stateText = 'online' local sw = ui.measureText(stateText).x ui.setCursor(vec2(cardW - pad - sw - 16, cardH - 26)) ui.textColored('●', Theme.success) ui.sameLine(0, 4) ui.textColored(stateText, Theme.textMuted) ui.popFont() ui.endTransparentWindow() end -------------- -- Server browser card -- Draws a single server row inside the scroll container. -- Returns true when the Join button is clicked. local function drawServerRow(s, x, y, w, h, dt, accent) -- Use the swim id (or a fallback hash) as the imgui id rather than ip/port, -- because the swim API doesn't expose ip/port directly. local id = '##srv_' .. (s.id ~= '' and s.id or s.name) -- Row background. The left ~80% of the row acts as a hover-only zone -- (we deliberately do NOT join on a stray row click — Join button only). ui.setCursor(vec2(x, y)) ui.invisibleButton(id, vec2(w - 80, h)) local hov = ui.itemHovered() hover[id] = smooth(hover[id] or 0, hov and 1 or 0, 14, dt) local hv = hover[id] local bg = lerpColor(Theme.glassSurface, Theme.glassSurfaceHi, hv) local edge = lerpColor(Theme.glassEdge, Theme.accentSubtle, hv) drawPanel(x, y, w, h, UI.innerRadius, bg, edge, 1) -- Accent left-bar grows on hover if hv > 0.01 then local barCol = rgbm(Theme.accent.r, Theme.accent.g, Theme.accent.b, hv) ui.drawRectFilled(vec2(x, y + 8), vec2(x + 3, y + h - 8), barCol) end -- Name ui.pushFont(ui.Font.Main) ui.setCursor(vec2(x + 14, y + 6)) ui.textColored(s.name, hv > 0.01 and Theme.textHero or Theme.textPrimary) ui.popFont() -- Subtitle: group · description (truncated to fit row width) ui.pushFont(ui.Font.Small) local parts = {} if s.group ~= '' then parts[#parts + 1] = s.group end if s.description ~= '' then parts[#parts + 1] = s.description end if s.passworded then parts[#parts + 1] = 'password' end local subtitle = #parts > 0 and table.concat(parts, ' · ') or 'no info' -- Cap subtitle to fit beside the player-count area subtitle = truncate(subtitle, 48) ui.setCursor(vec2(x + 14, y + h - 18)) ui.textColored(subtitle, Theme.textMuted) ui.popFont() -- Player count (right side, above the join button) ui.pushFont(ui.Font.Small) local pc = string.format('%d/%d', s.players, s.maxPlayers) local pcW = ui.measureText(pc).x local rightX = x + w - 14 - 72 -- left of join button local pcCol = s.players > 0 and Theme.accent or Theme.textSecondary ui.setCursor(vec2(rightX - pcW - 8, y + 8)) ui.textColored('●', pcCol) ui.sameLine(0, 4) ui.textColored(pc, pcCol) ui.popFont() -- Join button (right-aligned, smaller than full row buttons) local btnW, btnH = 64, 28 local btnX = x + w - btnW - 10 local btnY = y + (h - btnH) / 2 local btnId = id .. '_join' ui.setCursor(vec2(btnX, btnY)) local joinClicked = ui.invisibleButton(btnId, vec2(btnW, btnH)) local jhov = ui.itemHovered() hover[btnId] = smooth(hover[btnId] or 0, jhov and 1 or 0, 16, dt) local jhv = hover[btnId] local joinBg = lerpColor(Theme.accentSubtle, Theme.accent, jhv) local joinEdge = lerpColor(Theme.accent, Theme.accentBright, jhv) drawPanel(btnX, btnY, btnW, btnH, 8, joinBg, joinEdge, 1) ui.pushFont(ui.Font.Small) local joinLabel = 'JOIN' local jw = ui.measureText(joinLabel).x local jcol = jhv > 0.5 and Theme.textHero or Theme.accent ui.setCursor(vec2(btnX + (btnW - jw) / 2, btnY + (btnH - 14) / 2)) ui.textColored(joinLabel, jcol) ui.popFont() return joinClicked end local function drawServerBrowser(dt, cardX, cardY) Browser.spinPhase = Browser.spinPhase + dt local pulse = 0.92 + math.sin(UI.pulsePhase) * 0.08 local accent = rgbm(Theme.accent.r * pulse, Theme.accent.g * pulse, Theme.accent.b * pulse, 1) local cardW, cardH = UI.serverCardW, UI.serverCardH ui.beginTransparentWindow('swimServerBrowser', vec2(cardX, cardY), vec2(cardW, cardH), true, true) local pad = UI.pad drawCardShell(cardW, cardH, accent) ui.pushFont(ui.Font.Huge) local brandSize = ui.measureText('Servers') ui.popFont() local brandY = 16 local headerBandH = brandY + brandSize.y + 14 drawCardSheen(cardW, headerBandH) ui.pushFont(ui.Font.Huge) ui.setCursor(vec2(pad, brandY)) ui.textColored('Servers', Theme.textHero) ui.popFont() drawRefreshButton(cardW, brandY, brandSize.y, '##swimSrvRefresh', fetchServers) -- Divider local cy = headerBandH ui.drawRectFilled(vec2(pad, cy), vec2(cardW - pad, cy + 1), Theme.glassEdge) cy = cy + 16 -- Section label ui.pushFont(ui.Font.Small) ui.setCursor(vec2(pad, cy)) ui.textColored(Browser.state == 'ready' and string.format('SERVERS · %d', #Browser.servers) or 'SERVERS', Theme.textGhost) ui.popFont() cy = cy + 18 -- Scrollable list (shared scaffolding + shared status fallback) local listX, listY = pad, cy local listW, listH = cardW - pad * 2, cardH - cy - 40 drawScrollableList('##swimSrvList', listX, listY, listW, listH, accent, function(innerW, innerH, scrollW) local handled = drawListStatus( Browser.state, Browser.error, 'No active servers right now.', #Browser.servers == 0, innerW, innerH, accent, Browser.spinPhase, 'fetching servers') if handled then return end local rowH, rowGap = 56, 6 local rowW = innerW - scrollW - 6 local rowY = 0 for _, s in ipairs(Browser.servers) do if drawServerRow(s, 0, rowY, rowW, rowH, dt, accent) then requestJoin(s) end rowY = rowY + rowH + rowGap end ui.setCursor(vec2(0, rowY)) ui.invisibleButton('##swimSrvBottom', vec2(1, 1)) end) drawCardFooterStrip(cardW, cardH, accent) ui.pushFont(ui.Font.Small) ui.setCursor(vec2(pad, cardH - 26)) local footL if Browser.state == 'ready' then footL = string.format('swim> · %d servers', #Browser.servers) elseif Browser.state == 'loading' then footL = 'swim> · loading...' else footL = 'swim> · ' .. (Browser.error and 'error' or 'idle') end ui.textColored(footL, Theme.textMuted) -- Right side: relative time since last fetch. tonumber() forces the -- int64 cdata that sim.systemTime returns down to a plain Lua number so -- string.format('%d', ...) doesn't choke (which would abort the callback -- and leave other Lua app UI bleeding through). if Browser.lastFetch > 0 then local age = math.max(0, tonumber(sim.systemTime - Browser.lastFetch) or 0) local ageStr if age < 60 then ageStr = string.format('%ds ago', age) elseif age < 3600 then ageStr = string.format('%dm ago', math.floor(age / 60)) else ageStr = string.format('%dh ago', math.floor(age / 3600)) end local aw = ui.measureText(ageStr).x ui.setCursor(vec2(cardW - pad - aw, cardH - 26)) ui.textColored(ageStr, Theme.textMuted) end ui.popFont() ui.endTransparentWindow() end -------------- -------------- -- User score card (compact summary of the player's best score under filter) local function drawUserScoreCard(dt, cardX, cardY) local pulse = 0.92 + math.sin(UI.pulsePhase) * 0.08 local accent = rgbm(Theme.accent.r * pulse, Theme.accent.g * pulse, Theme.accent.b * pulse, 1) local cardW, cardH = UI.userCardW, UI.userCardH ui.beginTransparentWindow('swimUserScore', vec2(cardX, cardY), vec2(cardW, cardH), true, true) local pad = UI.pad drawCardShell(cardW, cardH, accent) ui.pushFont(ui.Font.Main) local titleSize = ui.measureText('Your score') ui.popFont() local headerBandH = 12 + titleSize.y + 10 drawCardSheen(cardW, headerBandH) ui.pushFont(ui.Font.Main) ui.setCursor(vec2(pad, 12)) ui.textColored('Your score', Theme.textPrimary) ui.popFont() local cy = headerBandH + 12 local filtered = applyLbFilter(Leaderboard.entries) local rank, entry = findUserEntry(filtered) -- The leaderboard is fetched with ?registered_only=true, so the local -- player only appears in the data if they've linked a swimserver.com -- account. Use any-filter presence as the "registered" signal so we can -- prompt unregistered players to create an account. local registered = findUserEntry(Leaderboard.entries) ~= nil -- Filter chip on the right (only meaningful for registered players). if registered then local label if mapFilterId == '' and carFilterId == '' then label = 'overall' elseif mapFilterId ~= '' and carFilterId == '' then label = truncate(Maps.byId[mapFilterId] or mapFilterId, 14) elseif carFilterId ~= '' and mapFilterId == '' then label = truncate(Cars.byId[carFilterId] or carFilterId, 14) else label = 'combo' end ui.pushFont(ui.Font.Small) local lw = ui.measureText(label).x ui.setCursor(vec2(cardW - pad - lw, 16)) ui.textColored(label, Theme.textGhost) ui.popFont() end if Leaderboard.state == 'loading' or Leaderboard.state == 'idle' then ui.pushFont(ui.Font.Small) ui.setCursor(vec2(pad, cy)) ui.textColored('loading...', Theme.textMuted) ui.popFont() elseif Leaderboard.state == 'error' then ui.pushFont(ui.Font.Small) ui.setCursor(vec2(pad, cy)) ui.textColored('failed to load: ' .. (Leaderboard.error or ''), Theme.error) ui.popFont() elseif not registered then -- Unregistered (or registered but never scored — indistinguishable when -- the API filters server-side). Prompt the user to register. ui.pushFont(ui.Font.Main) ui.setCursor(vec2(pad, cy)) ui.textColored('Not on the leaderboard', accent) ui.popFont() ui.pushFont(ui.Font.Small) ui.setCursor(vec2(pad, cy + 26)) ui.textColored('Register at swimserver.com', Theme.textPrimary) ui.setCursor(vec2(pad, cy + 42)) ui.textColored('and link your Steam to see your ranking.', Theme.textMuted) ui.popFont() elseif not entry then -- Registered, has scores elsewhere, but nothing under the current filter. ui.pushFont(ui.Font.Main) ui.setCursor(vec2(pad, cy)) ui.textColored('No score for this filter', Theme.textMuted) ui.popFont() ui.pushFont(ui.Font.Small) ui.setCursor(vec2(pad, cy + 26)) ui.textColored('Try a different map or car to see your score.', Theme.textGhost) ui.popFont() else -- Big score (left). Measure Huge font height so the subtitle sits -- below it instead of overlapping (was using a hardcoded 44px offset -- which was shorter than the actual glyph height). local scoreStr = fmtScore(entry.score) ui.pushFont(ui.Font.Huge) local scoreSize = ui.measureText(scoreStr) ui.setCursor(vec2(pad, cy)) ui.textColored(scoreStr, Theme.textHero) ui.popFont() -- 'pts' label, baseline-aligned with the score ui.pushFont(ui.Font.Main) ui.setCursor(vec2(pad + scoreSize.x + 6, cy + scoreSize.y - 22)) ui.textColored('pts', Theme.textMuted) ui.popFont() -- Rank (right side, accent-tinted) local rankStr = '#' .. tostring(rank) ui.pushFont(ui.Font.Huge) local rw = ui.measureText(rankStr).x ui.setCursor(vec2(cardW - pad - rw, cy)) ui.textColored(rankStr, accent) ui.popFont() -- Subtitle: car · track — sits BELOW the measured score height ui.pushFont(ui.Font.Small) ui.setCursor(vec2(pad, cy + scoreSize.y + 8)) local subtitle = truncate( (entry.car_name ~= '' and entry.car_name or entry.car) .. ' · ' .. (entry.track_name ~= '' and entry.track_name or entry.track), 54) ui.textColored(subtitle, Theme.textSecondary) ui.popFont() end ui.endTransparentWindow() end -------------- -- Leaderboard card -- Render a single leaderboard row (rank, name, car, score). -- Defensive: each field is type-coerced so a single bad entry can't error -- and abort the enclosing callback (which would leave other Lua app UI -- bleeding through, since `return true` would never be reached). local function drawLbRow(e, rank, x, y, w, h, accent) if type(e) ~= 'table' then return end local isYou = steamIdEqual(e.steamid, localSteamId) if isYou then local bg = rgbm(Theme.accent.r, Theme.accent.g, Theme.accent.b, 0.10) drawPanel(x, y, w, h, 6, bg, nil) end ui.pushFont(ui.Font.Main) local rankStr = '#' .. tostring(rank) local rankCol = isYou and accent or (rank <= 3 and Theme.textHero or Theme.textMuted) ui.setCursor(vec2(x + 8, y + (h - 18) / 2)) ui.textColored(rankStr, rankCol) ui.popFont() -- Prefer username (registered_only=true guarantees this), fall back to -- player_name then a literal placeholder. Type-checked because JSON null -- can surface as a non-string sentinel in some parsers. local name = e.username if type(name) ~= 'string' or name == '' then name = e.player_name end if type(name) ~= 'string' or name == '' then name = 'Unknown' end name = truncate(name, 16) ui.pushFont(ui.Font.Main) ui.setCursor(vec2(x + 50, y + (h - 18) / 2)) ui.textColored(name, isYou and Theme.textHero or Theme.textPrimary) ui.popFont() local scoreStr = fmtScore(e.score) ui.pushFont(ui.Font.Main) local sw = ui.measureText(scoreStr).x ui.setCursor(vec2(x + w - sw - 10, y + (h - 18) / 2)) ui.textColored(scoreStr, isYou and accent or Theme.textPrimary) ui.popFont() end local function drawLeaderboardCard(dt, cardX, cardY) local pulse = 0.92 + math.sin(UI.pulsePhase) * 0.08 local accent = rgbm(Theme.accent.r * pulse, Theme.accent.g * pulse, Theme.accent.b * pulse, 1) local cardW, cardH = UI.lbCardW, UI.lbCardH ui.beginTransparentWindow('swimLeaderboard', vec2(cardX, cardY), vec2(cardW, cardH), true, true) local pad = UI.pad drawCardShell(cardW, cardH, accent) ui.pushFont(ui.Font.Huge) local brandSize = ui.measureText('Leaderboard') ui.popFont() local brandY = 16 local headerBandH = brandY + brandSize.y + 14 drawCardSheen(cardW, headerBandH) ui.pushFont(ui.Font.Huge) ui.setCursor(vec2(pad, brandY)) ui.textColored('Leaderboard', Theme.textHero) ui.popFont() drawRefreshButton(cardW, brandY, brandSize.y, '##swimLbRefresh', fetchLeaderboard) -- Divider local cy = headerBandH ui.drawRectFilled(vec2(pad, cy), vec2(cardW - pad, cy + 1), Theme.glassEdge) cy = cy + 14 -- Two filter dropdowns (map, car), populated from /assetto/maps + /cars local dropGap = 8 local dropW = (cardW - pad * 2 - dropGap) / 2 local dropH = 30 ui.setCursor(vec2(pad, cy)) ui.setNextItemWidth(dropW) ui.combo('##lbMapFilter', mapFilterId == '' and 'All maps' or (Maps.byId[mapFilterId] or mapFilterId), ui.ComboFlags.None, function() if ui.selectable('All maps', mapFilterId == '') then mapFilterId = '' end for _, m in ipairs(Maps.list) do if ui.selectable(m.name, mapFilterId == m.id) then mapFilterId = m.id end end end) ui.setCursor(vec2(pad + dropW + dropGap, cy)) ui.setNextItemWidth(dropW) ui.combo('##lbCarFilter', carFilterId == '' and 'All cars' or (Cars.byId[carFilterId] or carFilterId), ui.ComboFlags.None, function() if ui.selectable('All cars', carFilterId == '') then carFilterId = '' end for _, c in ipairs(Cars.list) do if ui.selectable(c.name, carFilterId == c.id) then carFilterId = c.id end end end) cy = cy + dropH + 12 -- Section label + scrollable list local filtered = applyLbFilter(Leaderboard.entries) ui.pushFont(ui.Font.Small) ui.setCursor(vec2(pad, cy)) ui.textColored(string.format('TOP 10 · %d total', #filtered), Theme.textGhost) ui.popFont() cy = cy + 18 local listX, listY = pad, cy local listW, listH = cardW - pad * 2, cardH - cy - 56 -- 56px footer reserve drawScrollableList('##swimLbList', listX, listY, listW, listH, accent, function(innerW, innerH, scrollW) local handled = drawListStatus( Leaderboard.state, Leaderboard.error, 'No entries for this filter', #filtered == 0, innerW, innerH, accent, Browser.spinPhase, 'loading leaderboard') if handled then return end local rowH, rowGap = 32, 4 local rowW = innerW - scrollW - 6 local rowY = 0 for i = 1, 10 do local e = filtered[i] if e ~= nil then pcall(drawLbRow, e, i, 0, rowY, rowW, rowH, accent) end rowY = rowY + rowH + rowGap end ui.setCursor(vec2(0, rowY)) ui.invisibleButton('##swimLbBottom', vec2(1, 1)) end) -- swimserver.com onboarding note (two lines, muted) below the list ui.pushFont(ui.Font.Small) ui.setCursor(vec2(pad, cardH - 42)) ui.textColored('Create an account at swimserver.com', Theme.textSecondary) ui.setCursor(vec2(pad, cardH - 26)) ui.textColored('and link your Steam to appear on the board.', Theme.textMuted) ui.popFont() ui.endTransparentWindow() end -- Confirm-join modal (drawn on top of both cards when Confirm is set) local function drawConfirmModal(dt) if not Confirm then return end local s = Confirm.server local uiState = ac.getUI() local sw, sh = uiState.windowSize.x, uiState.windowSize.y -- Extra dim layer beneath the modal so the cards visually recede ui.drawRectFilled(vec2(0, 0), vec2(sw, sh), rgbm(0, 0, 0, 0.45)) local cardW, cardH = 460, 260 local cardX = math.floor((sw - cardW) / 2) local cardY = math.floor((sh - cardH) / 2) ui.beginTransparentWindow('swimConfirmJoin', vec2(cardX, cardY), vec2(cardW, cardH), true, true) -- Force the modal above the pause/server cards. Without this the new -- window enters at the back of the imgui z-stack on its first frame. ui.bringWindowToFront() local pad = UI.pad local pulse = 0.92 + math.sin(UI.pulsePhase) * 0.08 local accent = rgbm(Theme.accent.r * pulse, Theme.accent.g * pulse, Theme.accent.b * pulse, 1) drawCardShell(cardW, cardH, accent) ui.pushFont(ui.Font.Main) local titleSize = ui.measureText('Confirm join') ui.popFont() local headerBandH = 16 + titleSize.y + 14 drawCardSheen(cardW, headerBandH) ui.pushFont(ui.Font.Main) ui.setCursor(vec2(pad, 16)) ui.textColored('Confirm join', Theme.textPrimary) ui.popFont() -- Divider local cy = headerBandH ui.drawRectFilled(vec2(pad, cy), vec2(cardW - pad, cy + 1), Theme.glassEdge) cy = cy + 18 -- Server name (Huge) ui.pushFont(ui.Font.Huge) ui.setCursor(vec2(pad, cy)) ui.textColored(truncate(s.name, 28), accent) local nameH = ui.measureText('A').y ui.popFont() cy = cy + nameH + 4 -- Meta row: group · players ui.pushFont(ui.Font.Small) ui.setCursor(vec2(pad, cy)) local meta = string.format('%s · %d/%d', s.group ~= '' and s.group or 'unknown', s.players, s.maxPlayers) ui.textColored(meta, Theme.textSecondary) ui.popFont() cy = cy + 18 -- Description (truncated) if s.description ~= '' then ui.pushFont(ui.Font.Small) ui.setCursor(vec2(pad, cy)) ui.textColored(truncate(s.description, 64), Theme.textMuted) ui.popFont() cy = cy + 18 end -- Warning that AC will restart ui.pushFont(ui.Font.Small) ui.setCursor(vec2(pad, cy + 6)) ui.textColored('Assetto Corsa will restart to connect.', Theme.textGhost) ui.popFont() -- Buttons (bottom row) local btnH = 40 local gap = 10 local btnW = (cardW - pad * 2 - gap) / 2 local btnY = cardH - btnH - pad -- Cancel do ui.setCursor(vec2(pad, btnY)) local clicked = ui.invisibleButton('##swimConfirmCancel', vec2(btnW, btnH)) local hov = ui.itemHovered() hover['##swimConfirmCancel'] = smooth(hover['##swimConfirmCancel'] or 0, hov and 1 or 0, 14, dt) local hv = hover['##swimConfirmCancel'] local bg = lerpColor(Theme.glassSurface, Theme.glassSurfaceHi, hv) drawPanel(pad, btnY, btnW, btnH, UI.innerRadius, bg, Theme.glassEdge, 1) ui.pushFont(ui.Font.Main) local lw = ui.measureText('Cancel').x ui.setCursor(vec2(pad + (btnW - lw) / 2, btnY + (btnH - 18) / 2)) ui.textColored('Cancel', hv > 0.5 and Theme.textHero or Theme.textPrimary) ui.popFont() if clicked then cancelJoin() end end -- Join (primary, accent-filled) do local jx = pad + btnW + gap ui.setCursor(vec2(jx, btnY)) local clicked = ui.invisibleButton('##swimConfirmOk', vec2(btnW, btnH)) local hov = ui.itemHovered() hover['##swimConfirmOk'] = smooth(hover['##swimConfirmOk'] or 0, hov and 1 or 0, 16, dt) local hv = hover['##swimConfirmOk'] local bg = lerpColor(Theme.accentSubtle, Theme.accent, hv) local edge = lerpColor(Theme.accent, Theme.accentBright, hv) drawPanel(jx, btnY, btnW, btnH, UI.innerRadius, bg, edge, 1) ui.pushFont(ui.Font.Main) local lw = ui.measureText('Join').x ui.setCursor(vec2(jx + (btnW - lw) / 2, btnY + (btnH - 18) / 2)) ui.textColored('Join', hv > 0.5 and Theme.textHero or accent) ui.popFont() if clicked then confirmJoin() end end ui.endTransparentWindow() end -- Connecting overlay shown while we resolve connect_url & restart local function drawConnectingOverlay(dt) if not Joining then return end if Joining.state ~= 'resolving' and Joining.state ~= 'connecting' and Joining.state ~= 'error' then return end local uiState = ac.getUI() local sw, sh = uiState.windowSize.x, uiState.windowSize.y ui.drawRectFilled(vec2(0, 0), vec2(sw, sh), rgbm(0, 0, 0, 0.55)) local cardW, cardH = 360, 140 local cardX = math.floor((sw - cardW) / 2) local cardY = math.floor((sh - cardH) / 2) ui.beginTransparentWindow('swimConnecting', vec2(cardX, cardY), vec2(cardW, cardH), true, true) ui.bringWindowToFront() local pulse = 0.92 + math.sin(UI.pulsePhase) * 0.08 local accent = rgbm(Theme.accent.r * pulse, Theme.accent.g * pulse, Theme.accent.b * pulse, 1) drawCardShell(cardW, cardH, accent) Browser.spinPhase = Browser.spinPhase + dt drawSpinner(28, 28, 18, accent, Browser.spinPhase) ui.pushFont(ui.Font.Main) ui.setCursor(vec2(60, 24)) if Joining.state == 'connecting' then ui.textColored('Launching Content Manager...', Theme.textPrimary) else ui.textColored('Connection failed', Theme.error) end ui.popFont() ui.pushFont(ui.Font.Small) ui.setCursor(vec2(60, 52)) ui.textColored(truncate(Joining.server.name, 32), Theme.textSecondary) ui.popFont() if Joining.state == 'error' then ui.pushFont(ui.Font.Small) ui.setCursor(vec2(28, 80)) ui.textColored(Joining.error or '', Theme.textMuted) ui.popFont() -- Dismiss button local btnW, btnH = 80, 28 local btnX = cardW - btnW - 16 local btnY = cardH - btnH - 12 ui.setCursor(vec2(btnX, btnY)) if ui.invisibleButton('##swimJoinDismiss', vec2(btnW, btnH)) then Joining = nil end local hov = ui.itemHovered() drawPanel(btnX, btnY, btnW, btnH, 8, hov and Theme.glassSurfaceHi or Theme.glassSurface, Theme.glassEdge, 1) ui.pushFont(ui.Font.Small) local lw = ui.measureText('Dismiss').x ui.setCursor(vec2(btnX + (btnW - lw) / 2, btnY + (btnH - 14) / 2)) ui.textColored('Dismiss', hov and Theme.textHero or Theme.textPrimary) ui.popFont() end ui.endTransparentWindow() end -------------- -- Hook: replace AC's pause UI local lastFrameTime = ui.time() ui.onExclusiveHUD(function(mode) ac.blockEscapeButton() if ui.keyboardButtonPressed(ui.KeyIndex.Escape, false) then menuOpen = not menuOpen end if not menuOpen then return end local now = ui.time() local dt = math.max(0, math.min(0.1, now - lastFrameTime)) lastFrameTime = now -- Advance the shared accent pulse phase once per frame so both cards stay in sync UI.pulsePhase = (UI.pulsePhase + dt * 1.5) % (math.pi * 2) -- Lazy-fetch (>2 min stale) for both endpoints. Plain Lua numbers for -- the age comparison — see note on the age display in drawServerBrowser. local nowSec = tonumber(sim.systemTime) or 0 local function stale(target) return target.state == 'idle' or (target.state == 'ready' and target.lastFetch > 0 and nowSec - target.lastFetch > CACHE_TTL_SEC) end if stale(Browser) then fetchServers() end if stale(Leaderboard) then fetchLeaderboard() end -- Compute group layout: three columns, top-aligned, centered. -- Col 1: pause card (top), user-score card (below) -- Col 2: server browser -- Col 3: leaderboard local uiState = ac.getUI() local sw, sh = uiState.windowSize.x, uiState.windowSize.y ui.drawRectFilled(vec2(0, 0), vec2(sw, sh), Theme.backdrop) local col1H = UI.cardH + UI.cardGap + UI.userCardH local groupW = UI.cardW + UI.cardGap + UI.serverCardW + UI.cardGap + UI.lbCardW local groupH = math.max(col1H, UI.serverCardH, UI.lbCardH) local groupX = math.floor((sw - groupW) / 2) local groupY = math.floor((sh - groupH) / 2) -- Column 1: pause card on top, user-score card below local col1X = groupX drawCustomPauseMenu(dt, col1X, groupY) drawUserScoreCard(dt, col1X, groupY + UI.cardH + UI.cardGap) -- Column 2: server browser (full column height) local col2X = groupX + UI.cardW + UI.cardGap drawServerBrowser(dt, col2X, groupY) -- Column 3: leaderboard (full column height) local col3X = col2X + UI.serverCardW + UI.cardGap drawLeaderboardCard(dt, col3X, groupY) -- Modals on top drawConfirmModal(dt) drawConnectingOverlay(dt) return true end) ac.log('swim> Pause Menu loaded')