Crosshair Studio

@itzchill / crosshair-studiov1.5.9Public

Overlay crosshair for games and apps that have none.

Files

main.luau43.1 KB

--[[
  rebind: min_sdk=3.14.0
  rebind: permissions=screen
  rebind: key_block=false
  rebind: tick_rate=60
  rebind: memory_limit_mb=32
]]

-- Crosshair Studio is a vector overlay: no game memory, no pixel reading,
-- and no injection. The bloom is an input-driven approximation, not real
-- weapon spread. Monitor geometry is read only to place the surface.

local VERSION = "1.5.9"
local reticle_png = require("reticle_png")
local STATE_FILE = "crosshair_state.json"
local SURFACE_SIZE = 600
local CENTER = SURFACE_SIZE / 2
local DEFAULT_COLOR = "#a5e63c"
local DEFAULT_OUTLINE = "#000000"

local PROFILE_KEYS = {
  "shape",
  "shape_size",
  "png_preset",
  "color",
  "outline_color",
  "outline",
  "glow",
  "gap",
  "length",
  "thickness",
  "dot",
  "dot_shape",
  "opacity",
  "offset_x",
  "offset_y",
  "center_on",
  "game_offset",
  "preset_colour",
  "preset_size",
  "activation",
  "ads",
  "bloom",
  "move_bloom",
  "fire_bloom",
  "smooth",
}

-- Reticle offsets that put the crosshair on the world centre in games whose
-- camera sits below the middle of the screen. Most games need nothing; the
-- ones listed are starting points you can nudge per profile.
local GAME_OFFSETS = {
  { label = "none (manual)", x = 0, y = 0 },
  { label = "Rust", x = 0, y = 20 },
  { label = "CS2", x = 0, y = 0 },
  { label = "Valorant", x = 0, y = 0 },
  { label = "Apex Legends", x = 0, y = 0 },
  { label = "Overwatch 2", x = 0, y = 0 },
  { label = "Fortnite", x = 0, y = 0 },
  { label = "PUBG", x = 0, y = 0 },
  { label = "Rainbow Six Siege", x = 0, y = 0 },
  { label = "Escape from Tarkov", x = 0, y = 0 },
  { label = "Hunt: Showdown", x = 0, y = 0 },
  { label = "Deadlock", x = 0, y = 0 },
  { label = "Team Fortress 2", x = 0, y = 0 },
  { label = "Destiny 2", x = 0, y = 0 },
  { label = "The Finals", x = 0, y = 0 },
}

local GAME_OFFSET_OF = {}
local GAME_LABELS = {}
for _, entry in ipairs(GAME_OFFSETS) do
  GAME_OFFSET_OF[entry.label] = entry
  GAME_LABELS[#GAME_LABELS + 1] = entry.label
end

local DEFAULTS = {
  shape = "plus",
  shape_size = 160,
  png_preset = "none",
  color = DEFAULT_COLOR,
  outline_color = DEFAULT_OUTLINE,
  outline = 1,
  glow = 0,
  gap = 8,
  length = 14,
  thickness = 2,
  dot = 2,
  dot_shape = "circle",
  opacity = 0.95,
  offset_x = 0,
  offset_y = 0,
  center_on = "auto",
  game_offset = "none (manual)",
  preset_colour = "match colour",
  preset_size = 64,
  activation = "toggle",
  ads = "show",
  bloom = true,
  move_bloom = 7,
  fire_bloom = 5,
  smooth = 14,
}

local PRESETS = {
  plus = { shape = "plus", gap = 8, length = 14, thickness = 2, dot = 2, dot_shape = "circle" },
  dot = { shape = "dot", gap = 0, length = 0, thickness = 2, dot = 5, dot_shape = "circle" },
  t = { shape = "t", gap = 8, length = 12, thickness = 2, dot = 2, dot_shape = "circle" },
  circle = { shape = "circle", gap = 8, length = 10, thickness = 2, dot = 0, dot_shape = "circle" },
  x = { shape = "x", gap = 6, length = 16, thickness = 2, dot = 0, dot_shape = "circle" },
}

local PRESET_ORDER = { "plus", "dot", "t", "circle", "x" }
local PNG_PRESETS = { "plus-thin", "gap-cross", "dot-fine", "circle-dot", "t-reticle", "brackets", "chevron", "sniper", "glow-ring" }
local PNG_CHOICES = { "none", "plus-thin", "gap-cross", "dot-fine", "circle-dot", "t-reticle", "brackets", "chevron", "sniper", "glow-ring" }

-- Kenney crosshair pack (CC0, kenney.nl). An overlay image cannot be tinted at
-- draw time, so each shape ships in two tones instead of one recolourable
-- asset. Drawn at its native 128 px so it stays sharp.
local KENNEY = {
  { label = "Dot 1", file = "01-dot" },
  { label = "Dot 2", file = "02-dot" },
  { label = "Dot 3", file = "03-dot" },
  { label = "Ring 1", file = "04-ring" },
  { label = "Ring 2", file = "05-ring" },
  { label = "Ring 3", file = "06-ring" },
  { label = "Dot 4", file = "07-dot" },
  { label = "Core 1", file = "08-core" },
  { label = "Core 2", file = "09-core" },
  { label = "Core 3", file = "10-core" },
  { label = "Bar wide", file = "11-wide" },
  { label = "Bar short", file = "12-wide" },
  { label = "Bar mid", file = "13-wide" },
  { label = "Bar tall", file = "14-tall" },
  { label = "Bar long", file = "15-tall" },
}

local KENNEY_OF = {}
for _, entry in ipairs(KENNEY) do
  KENNEY_OF[entry.label] = entry
  PNG_CHOICES[#PNG_CHOICES + 1] = entry.label
  PNG_PRESETS[#PNG_PRESETS + 1] = entry.label
end
-- Preset art is a fixed image, so colour cannot be applied at draw time. Each
-- Kenney shape is pre-rendered in these eight tones instead, and the colour
-- picker selects the nearest one.
local PALETTE = {
  { name = "white", hex = "#ffffff" },
  { name = "black", hex = "#000000" },
  { name = "red", hex = "#ff3b30" },
  { name = "green", hex = "#34c759" },
  { name = "cyan", hex = "#32d0d0" },
  { name = "yellow", hex = "#ffd60a" },
  { name = "magenta", hex = "#ff2d95" },
  { name = "orange", hex = "#ff9500" },
}

local function palette_name(choice, colour)
  if choice and choice ~= "match colour" then
    return choice
  end
  local hex = tostring(colour or "#ffffff"):gsub("#", "")
  local r = tonumber(hex:sub(1, 2), 16) or 255
  local g = tonumber(hex:sub(3, 4), 16) or 255
  local b = tonumber(hex:sub(5, 6), 16) or 255
  local best, best_distance = "white", math.huge
  for _, entry in ipairs(PALETTE) do
    local pr = tonumber(entry.hex:sub(2, 3), 16)
    local pg = tonumber(entry.hex:sub(4, 5), 16)
    local pb = tonumber(entry.hex:sub(6, 7), 16)
    local distance = (r - pr) ^ 2 + (g - pg) ^ 2 + (b - pb) ^ 2
    if distance < best_distance then
      best_distance = distance
      best = entry.name
    end
  end
  return best
end

-- A removed preset can linger in a saved config; fall back instead of leaving
-- a blank entry in the dropdown.
local function normalize_preset(value)
  local name = tostring(value or "none")
  for _, allowed in ipairs(PNG_CHOICES) do
    if name == allowed then
      return name
    end
  end
  if name:sub(1, 7) == "custom/" then
    return name
  end
  return "none"
end

local function normalize_shape(value)
  local name = tostring(value or "plus")
  for _, allowed in ipairs(PRESET_ORDER) do
    if name == allowed then
      return name
    end
  end
  return "plus"
end

local cfg = rb.ui.schema({
  -- Reticle: what the crosshair looks like
  shape = rb.ui.select("plus", PRESET_ORDER, { label = "Shape", tab = "Reticle" }),
  shape_size = rb.ui.slider(160, { min = 40, max = 320, step = 8, suffix = "px", label = "Shape size", tab = "Reticle" }),
  dot_shape = rb.ui.select("circle", { "circle", "square" }, { label = "Dot shape", tab = "Reticle" }),
  dot = rb.ui.slider(2, { min = 0, max = 12, step = 1, label = "Dot size", tab = "Reticle" }),
  thickness = rb.ui.slider(2, { min = 1, max = 10, step = 1, label = "Thickness", tab = "Reticle" }),
  length = rb.ui.slider(14, { min = 0, max = 40, step = 1, label = "Arm length", tab = "Reticle" }),
  gap = rb.ui.slider(8, { min = 0, max = 50, step = 1, label = "Center gap", tab = "Reticle" }),
  opacity = rb.ui.slider(0.95, { min = 0, max = 1, step = 0.05, label = "Opacity (0 = transparent)", tab = "Reticle" }),
  color = rb.ui.color(DEFAULT_COLOR, { label = "Crosshair color", tab = "Reticle" }),
  outline_color = rb.ui.color(DEFAULT_OUTLINE, { label = "Outline color", tab = "Reticle" }),
  outline = rb.ui.slider(1, { min = 0, max = 6, step = 1, label = "Outline width", tab = "Reticle" }),
  glow = rb.ui.slider(0, { min = 0, max = 20, step = 1, label = "Glow", tab = "Reticle" }),

  -- Presets: fixed art instead of the generated shape
  png_preset = rb.ui.select("none", PNG_CHOICES, { label = "PNG preset (fixed colours)", tab = "Presets" }),
  preset_colour = rb.ui.select("match colour", { "match colour", "white", "black", "red", "green", "cyan", "yellow", "magenta", "orange" }, { label = "Preset colour", tab = "Presets" }),
  preset_size = rb.ui.slider(64, { min = 16, max = 200, step = 4, suffix = "px", label = "Preset size", tab = "Presets" }),

  -- Position: where the reticle sits
  center_on = rb.ui.select("auto", { "auto", "display", "window" }, { label = "Centre on", tab = "Position" }),
  offset_x = rb.ui.slider(0, { min = -2000, max = 2000, step = 1, label = "Offset X", tab = "Position" }),
  offset_y = rb.ui.slider(0, { min = -2000, max = 2000, step = 1, label = "Offset Y", tab = "Position" }),
  game_offset = rb.ui.select("none (manual)", GAME_LABELS, { label = "Game offset preset", tab = "Position" }),

  -- Behaviour: when it shows and how it reacts
  activation = rb.ui.select("toggle", { "toggle", "hold" }, { label = "Activation", tab = "Behaviour" }),
  start_visible = rb.ui.toggle(true, { label = "Show on start", tab = "Behaviour" }),
  ads = rb.ui.select("show", { "show", "compact", "hide" }, { label = "While ADS (right mouse)", tab = "Behaviour" }),
  bloom = rb.ui.toggle(true, { label = "Input bloom (cosmetic)", tab = "Behaviour" }),
  move_bloom = rb.ui.slider(7, { min = 0, max = 40, step = 1, label = "Move bloom", tab = "Behaviour" }),
  fire_bloom = rb.ui.slider(5, { min = 0, max = 40, step = 1, label = "Fire bloom", tab = "Behaviour" }),
  smooth = rb.ui.slider(14, { min = 1, max = 30, step = 1, label = "Bloom speed", tab = "Behaviour" }),

  -- Keys
  toggle_key = rb.ui.keybind("F12", { label = "Toggle / editor key", tab = "Keys" }),
  placement_key = rb.ui.keybind("F11", { label = "Place crosshair (hold)", tab = "Keys" }),
})

local state = {
  surface = nil,
  menu = nil,
  visible = false,
  saved_visible = nil,
  gap = 0,
  keys = {},
  profiles = {},
  last_profile = 0,
  last_process = "",
  placing = false,
  placement_saved = nil,
  dirty = true,
  signature = nil,
  png_data = nil,
  png_sig = nil,
  preset_name = nil,
  preset_data = nil,
  last_render_gap = -1,
  geo_key = nil,
  geo_at = nil,
  last_game_preset = nil,
  saved_game_preset = nil,
  game_profiles = {},
  tab = 1,
  last_ads = false,
  warned_invisible = false,
  logged_surface = false,
}

local function n(value, fallback)
  local number = tonumber(value)
  if number == nil then
    return fallback
  end
  return number
end

local function clamp(value, low, high)
  return math.max(low, math.min(high, value))
end

-- A display's full bounds, taskbar included. rb.screen.work_area excludes the
-- taskbar, which put the reticle half a taskbar above the true centre.
local function display_at(x, y)
  local ok, displays = pcall(rb.screen.list)
  if not ok or type(displays) ~= "table" then
    return nil
  end
  local first = nil
  for _, d in ipairs(displays) do
    if tonumber(d.x) and tonumber(d.y) and tonumber(d.width) and tonumber(d.height) then
      first = first or d
      if x >= d.x and x < d.x + d.width and y >= d.y and y < d.y + d.height then
        return d
      end
    end
  end
  return first
end

local function foreground_window()
  local ok, info = pcall(rb.system.window)
  if ok and type(info) == "table"
    and tonumber(info.x) and tonumber(info.y)
    and tonumber(info.width) and tonumber(info.height) then
    return info
  end
  return nil
end

-- What the reticle is centred on. Any display size, any monitor, and a
-- windowed or lower-resolution game gets its own centre rather than the
-- desktop's.
local function target_rect()
  local window = foreground_window()
  local point_x, point_y = 0, 0
  if window then
    point_x = window.x + window.width / 2
    point_y = window.y + window.height / 2
  end

  local display = display_at(point_x, point_y)
  if not display then
    local width, height = rb.system.screen()
    display = { x = 0, y = 0, width = width or 1, height = height or 1 }
  end

  local mode = tostring(cfg.center_on or "auto")
  local use_window = false
  if window and mode ~= "display" then
    if mode == "window" then
      use_window = true
    else
      -- a big window that is not the whole display is a game running at its
      -- own resolution; a small window is just whatever app has focus
      use_window = window.width >= display.width * 0.6
        and window.height >= display.height * 0.6
        and (window.width < display.width * 0.98 or window.height < display.height * 0.98)
    end
  end

  if use_window then
    return { x = window.x, y = window.y, width = window.width, height = window.height }
  end
  return { x = display.x, y = display.y, width = display.width, height = display.height }
end

local function geometry_key()
  local rect = target_rect()
  return string.format("%d,%d %dx%d", rect.x, rect.y, rect.width, rect.height)
end

local function foreground_process()
  local ok, info = pcall(rb.system.window)
  if ok and type(info) == "table" then
    return tostring(info.process or "")
  end
  return ""
end

local function norm_key(key)
  local text = tostring(key or "")
  if #text == 1 then
    return text:upper()
  end
  return text
end

-- One place that turns whatever the widget hands over into "#rrggbb".
-- A colour that arrives with an alpha channel, without a hash, or as a
-- non-string is normalised here instead of reaching the renderer raw.
local function color_hex(value, fallback)
  local ok, text = pcall(tostring, value)
  if not ok or type(text) ~= "string" then
    return fallback or "#ffffff"
  end
  text = text:gsub("#", ""):gsub("[^%x]", "")
  if #text == 8 then
    text = text:sub(1, 6)
  end
  if #text ~= 6 then
    return fallback or "#ffffff"
  end
  return "#" .. text:lower()
end

local function with_alpha(hex, alpha)
  local text = tostring(hex or "#ffffff"):gsub("#", "")
  local source_alpha = 255
  if #text == 8 then
    source_alpha = tonumber(text:sub(7, 8), 16) or 255
    text = text:sub(1, 6)
  end
  if #text ~= 6 or text:find("%X") then
    text = "ffffff"
    source_alpha = 255
  end
  local value = math.floor(clamp(alpha or 1, 0, 1) * source_alpha)
  return "#" .. text .. string.format("%02X", value)
end

local function add_rect(ops, x, y, w, h, fill, radius)
  w = math.floor(w)
  h = math.floor(h)
  if w < 1 or h < 1 then
    return
  end
  ops[#ops + 1] = {
    kind = "rect",
    x = math.floor(x),
    y = math.floor(y),
    w = w,
    h = h,
    fill = fill,
    radius = radius or 0,
  }
end

local function add_ring(ops, cx, cy, radius, thickness, color)
  radius = math.max(1, radius)
  ops[#ops + 1] = {
    kind = "rect",
    x = cx - radius,
    y = cy - radius,
    w = radius * 2,
    h = radius * 2,
    fill = "#00000000",
    border = color,
    border_width = math.max(1, thickness),
    radius = radius,
  }
end

local function add_arm(ops, cx, cy, direction, gap, length, thickness, color, expand)
  local extra = expand or 0
  local size = thickness + extra * 2
  local arm = length + extra * 2
  if direction == "up" then
    add_rect(ops, cx - size / 2, cy - gap - arm, size, arm, color, 1)
  elseif direction == "down" then
    add_rect(ops, cx - size / 2, cy + gap, size, arm, color, 1)
  elseif direction == "left" then
    add_rect(ops, cx - gap - arm, cy - size / 2, arm, size, color, 1)
  elseif direction == "right" then
    add_rect(ops, cx + gap, cy - size / 2, arm, size, color, 1)
  end
end

local function add_dot(ops, cx, cy, size, shape, color, expand)
  local extra = expand or 0
  local dot_size = size + extra * 2
  if dot_size <= 0 then
    return
  end
  local radius = 0
  if shape == "circle" then
    radius = math.floor(dot_size / 2)
  end
  add_rect(ops, cx - dot_size / 2, cy - dot_size / 2, dot_size, dot_size, color, radius)
end

local function add_shape(ops, shape, cx, cy, gap, length, thickness, dot, dot_shape, color, expand)
  local extra = expand or 0
  if shape == "plus" then
    add_arm(ops, cx, cy, "up", gap, length, thickness, color, extra)
    add_arm(ops, cx, cy, "down", gap, length, thickness, color, extra)
    add_arm(ops, cx, cy, "left", gap, length, thickness, color, extra)
    add_arm(ops, cx, cy, "right", gap, length, thickness, color, extra)
  elseif shape == "t" then
    add_arm(ops, cx, cy, "up", gap, length, thickness, color, extra)
    add_arm(ops, cx, cy, "left", gap, length, thickness, color, extra)
    add_arm(ops, cx, cy, "right", gap, length, thickness, color, extra)
  elseif shape == "circle" then
    -- A square with radius = half its width is a true circle. Drawing it as a
    -- transparent-filled, bordered rounded rect gives a clean vector ring.
    local radius = math.max(1, gap + length + extra)
    add_ring(ops, cx, cy, radius, thickness + extra * 2, color)
  elseif shape == "x" then
    -- One-pixel diagonal stepping keeps the arms joined; square joins avoid
    -- the beaded look produced by rounded, spaced blocks.
    local size = thickness + extra * 2
    local step = math.max(1, math.ceil((length + 1) / 20))
    local block = size + step - 1
    local amount = gap
    while amount <= gap + length do
      add_rect(ops, cx + amount - block / 2, cy + amount - block / 2, block, block, color, 0)
      add_rect(ops, cx - amount - block / 2, cy + amount - block / 2, block, block, color, 0)
      add_rect(ops, cx + amount - block / 2, cy - amount - block / 2, block, block, color, 0)
      add_rect(ops, cx - amount - block / 2, cy - amount - block / 2, block, block, color, 0)
      amount = amount + step
    end
  end

  if shape == "dot" then
    add_dot(ops, cx, cy, math.max(1, dot), dot_shape, color, extra)
  elseif dot > 0 then
    add_dot(ops, cx, cy, dot, dot_shape, color, extra)
  end
end

local function settings_signature()
  local parts = {}
  for _, key in ipairs(PROFILE_KEYS) do
    parts[#parts + 1] = tostring(cfg[key])
  end
  return table.concat(parts, "|")
end

local function remove_surface()
  if state.surface then
    pcall(function()
      state.surface:remove()
    end)
    state.surface = nil
  end
end

local function render_crosshair()
  if not state.visible then
    remove_surface()
    return
  end

  local ads = state.keys.Mouse2 == true
  if tostring(cfg.ads) == "hide" and ads then
    remove_surface()
    return
  end

  local shape = normalize_shape(cfg.shape)
  local color = color_hex(cfg.color, DEFAULT_COLOR)
  local outline_color = color_hex(cfg.outline_color, DEFAULT_OUTLINE)
  local outline = math.max(0, n(cfg.outline, 1))
  local glow = math.max(0, n(cfg.glow, 0))
  local thickness = math.max(1, n(cfg.thickness, 2))
  local dot = math.max(0, n(cfg.dot, 2))
  local dot_shape = tostring(cfg.dot_shape or "circle")
  local gap = math.max(0, state.gap)
  local png_gap = math.max(0, n(cfg.gap, 8))
  local length = math.max(0, n(cfg.length, 14))

  local compact_scale = 1
  if tostring(cfg.ads) == "compact" and ads then
    gap = gap * 0.7
    png_gap = png_gap * 0.7
    length = length * 0.8
    compact_scale = 0.7
  end

  local cx = CENTER
  local cy = CENTER
  local area = target_rect()
  local left = math.floor(area.x + area.width / 2 - CENTER + n(cfg.offset_x, 0))
  local top = math.floor(area.y + area.height / 2 - CENTER + n(cfg.offset_y, 0))
  -- Only treat ADS as a drawing change when it actually alters the drawing.
  -- Including it unconditionally rebuilt the surface on every right click,
  -- which showed up as the reticle fading in "show" mode.
  local sig = table.concat({ shape, color, outline_color, outline, glow, png_gap, length, thickness, dot, dot_shape, tostring(cfg.png_preset), palette_name(cfg.preset_colour, color), tostring(cfg.preset_size), tostring(cfg.shape_size), (tostring(cfg.ads) == "compact") and tostring(ads) or "-" }, "|")
  local preset = tostring(cfg.png_preset or "none")

  -- A preset PNG carries its own pixels: the image op has no tint, so the
  -- colour settings cannot reach it. Say so instead of ignoring the change.
  local recoloured = state.last_color ~= nil
    and (color ~= state.last_color or outline_color ~= state.last_outline)
  if recoloured then
    state.last_color = color
    state.last_outline = outline_color
    rb.log.info("Crosshair Studio colors: " .. color .. " / " .. outline_color)
    if preset ~= "none" and not KENNEY_OF[preset] then
      rb.ui.notify("Classic presets keep their own colours - Kenney shapes follow the colour picker", "info")
    end
  end
  local preset_data = nil
  -- Shapes and fixed art size independently: shape_size drives the generated
  -- reticle, preset_size drives PNG presets.
  local preset_size = clamp(n(cfg.shape_size, 160), 40, 320)
  if preset ~= "none" then
    preset_size = clamp(n(cfg.preset_size, 64), 16, 200)
  end
  if preset ~= "none" then
    local key = preset
    local kenney = KENNEY_OF[preset]
    if kenney then
      key = "kenney/" .. palette_name(cfg.preset_colour, color) .. "/" .. kenney.file
    end
    if state.preset_name ~= key then
      state.preset_name = key
      local ok, data = pcall(rb.file.read_bytes, "assets/presets/" .. key .. ".png")
      state.preset_data = ok and data or nil
    end
    preset_data = state.preset_data
  end
  if not preset_data and state.png_sig ~= sig then
    local ok, data = pcall(reticle_png, shape, color, outline_color, outline, glow, png_gap, length, thickness, dot, dot_shape, 1)
    state.png_data = ok and data or nil
    state.png_sig = sig
  end
  local ops = {}
  local bloom_scale = 1
  if cfg.bloom ~= false and gap > png_gap + 0.2 then
    bloom_scale = 1 + (gap - png_gap) / math.max(24, png_gap + length + dot)
  end
  if preset_data or state.png_data then
    -- The generated shape PNG already contains the compact shrink in its
    -- geometry; scaling the image as well shrank it twice.
    local scale = bloom_scale
    if preset_data then
      scale = scale * compact_scale
    end
    local iw = preset_size * scale
    ops[#ops + 1] = { kind = "image", x = cx - iw / 2, y = cy - iw / 2, w = iw, h = iw, png = preset_data or state.png_data }
  else
    if glow > 0 then
      add_shape(ops, shape, cx, cy, gap, length, thickness, dot, dot_shape, with_alpha(color, 0.16), outline + glow)
    end
    if outline > 0 then
      add_shape(ops, shape, cx, cy, gap, length, thickness, dot, dot_shape, outline_color, outline)
    end
    add_shape(ops, shape, cx, cy, gap, length, thickness, dot, dot_shape, color, 0)
  end

  if #ops == 0 then
    remove_surface()
    return
  end

  local opacity = clamp(n(cfg.opacity, 0.95), 0, 1)

  -- The reticle is a PNG whose pixels change with the colour while its
  -- geometry stays identical. Updating that in place left the old image on
  -- screen, so any change to the drawing signature rebuilds the surface.
  if state.surface and state.surface_sig ~= sig then
    remove_surface()
  end

  if state.surface then
    local updated = pcall(function()
      state.surface:set({
        width = SURFACE_SIZE,
        height = SURFACE_SIZE,
        x = left,
        y = top,
        ops = ops,
        opacity = opacity,
      })
    end)
    if not updated then
      remove_surface()
    end
  end

  if not state.surface then
    local created = pcall(function()
      -- No background op is added: the surface stays transparent and only
      -- the reticle geometry is painted.
      state.surface = rb.overlay.surface({
        x = left,
        y = top,
        width = SURFACE_SIZE,
        height = SURFACE_SIZE,
        opacity = opacity,
        ops = ops,
      })
    end)
    if not created then
      state.surface = nil
      rb.log.warn("Crosshair Studio: surface creation failed")
      return
    end
    if not state.logged_surface then
      state.logged_surface = true
      rb.log.info("Crosshair Studio surface ready (" .. #ops .. " ops)")
    end
  end

  state.surface_sig = sig
  -- compare against the un-compacted gap; storing the compacted one made the
  -- tick think the gap kept moving and re-render every frame while ADS
  state.last_render_gap = math.max(0, state.gap)
  state.dirty = false
end

local function save_state()
  local payload = {
    version = 1,
    visible = state.visible,
    last_profile = state.last_profile,
    game_preset = state.last_game_preset,
    game_profiles = state.game_profiles,
    profiles = state.profiles,
  }
  local ok, err = pcall(rb.file.write_json, STATE_FILE, payload)
  if not ok then
    rb.log.warn("Crosshair Studio: state write failed: " .. tostring(err))
  end
end

local function load_state()
  if not rb.file.exists(STATE_FILE) then
    return
  end
  local ok, data = pcall(rb.file.read_json, STATE_FILE)
  if not ok or type(data) ~= "table" then
    return
  end
  if type(data.profiles) == "table" then
    state.profiles = data.profiles
  end
  if type(data.game_profiles) == "table" then
    state.game_profiles = data.game_profiles
  end
  state.last_profile = tonumber(data.last_profile) or 0
  if type(data.game_preset) == "string" then
    state.saved_game_preset = data.game_preset
  end
  if type(data.visible) == "boolean" then
    state.saved_visible = data.visible
  end
end

local function set_visible(value)
  state.visible = value and true or false
  rb.log.info("Crosshair Studio: " .. (state.visible and "visible" or "hidden"))
  save_state()
  if state.visible then
    render_crosshair()
  else
    remove_surface()
  end
end

local function toggle_visible()
  set_visible(not state.visible)
end

local function follow_mouse_for_placement()
  local mx, my = rb.system.mouse()
  local area = target_rect()
  local center_x = area.x + area.width / 2
  local center_y = area.y + area.height / 2
  cfg.offset_x = clamp(math.floor((mx or center_x) - center_x), -2000, 2000)
  cfg.offset_y = clamp(math.floor((my or center_y) - center_y), -2000, 2000)
  state.dirty = true
  state.signature = nil
end

local function start_placement()
  if state.placing then
    return
  end
  if state.menu then
    pcall(function()
      state.menu:close()
    end)
    state.menu = nil
  end
  state.placing = true
  state.placement_saved = {
    offset_x = n(cfg.offset_x, 0),
    offset_y = n(cfg.offset_y, 0),
    visible = state.visible,
  }
  if not state.visible then
    set_visible(true)
  end
  rb.overlay.toast("Move the mouse. Release " .. tostring(cfg.placement_key) .. " to save. Esc cancels.", {
    title = "Place crosshair",
    duration = 5000,
  })
end

local function finish_placement(save)
  if not state.placing then
    return
  end
  state.placing = false
  local saved = state.placement_saved
  state.placement_saved = nil
  if not save and saved then
    cfg.offset_x = saved.offset_x
    cfg.offset_y = saved.offset_y
  elseif save and state.last_profile > 0 then
    local profile = state.profiles[state.last_profile]
    if type(profile) == "table" then
      profile.offset_x = cfg.offset_x
      profile.offset_y = cfg.offset_y
    end
  end
  state.dirty = true
  state.signature = nil
  save_state()
  if not save and saved and not saved.visible then
    set_visible(false)
  else
    render_crosshair()
  end
  rb.overlay.toast(save and "Crosshair position saved" or "Placement cancelled", {
    title = "Crosshair Studio",
    duration = 2200,
  })
end

local function apply_settings(settings)
  for _, key in ipairs(PROFILE_KEYS) do
    if settings[key] ~= nil then
      cfg[key] = settings[key]
    end
  end
  cfg.shape = normalize_shape(cfg.shape)
  state.last_shape = tostring(cfg.shape)
  -- the cached preset image belongs to the settings that were live before
  -- this switch, so drop it rather than risk drawing the old reticle
  state.preset_name = nil
  state.preset_data = nil
  state.png_sig = nil
  state.last_game_preset = tostring(cfg.game_offset or "none (manual)")
  state.dirty = true
  state.signature = nil
  render_crosshair()
end

local function snapshot()
  local data = {}
  for _, key in ipairs(PROFILE_KEYS) do
    data[key] = cfg[key]
  end
  return data
end

local function profile_process(slot)
  local profile = state.profiles[slot]
  if type(profile) == "table" then
    return tostring(profile.process or "")
  end
  return ""
end

local function profile_hint(slot)
  local profile = state.profiles[slot]
  if type(profile) ~= "table" then
    return "empty"
  end
  local hint = string.format("%s  %s  gap:%s  len:%s",
    normalize_shape(profile.shape),
    tostring(profile.color or DEFAULT_COLOR),
    tostring(profile.gap or "?"),
    tostring(profile.length or "?"))
  local process = profile_process(slot)
  if process ~= "" then
    hint = hint .. "  |  " .. process
  end
  return hint
end

local function save_profile(slot)
  local data = snapshot()
  data.visible = state.visible
  local existing = state.profiles[slot]
  if type(existing) == "table" then
    data.process = tostring(existing.process or "")
  end
  state.profiles[slot] = data
  state.last_profile = slot
  save_state()
  rb.overlay.toast("Saved to profile " .. slot, {
    title = "Crosshair Studio",
    duration = 2500,
  })
end

local function load_profile(slot)
  local profile = state.profiles[slot]
  if type(profile) ~= "table" then
    rb.overlay.toast("Profile " .. slot .. " is empty", {
      title = "Crosshair Studio",
      duration = 2500,
    })
    return
  end
  apply_settings(profile)
  state.last_profile = slot
  save_state()
  -- carry the profile's own on/off state, so switching restores the look as
  -- it was saved rather than whatever the previous profile left
  if type(profile.visible) == "boolean" then
    set_visible(profile.visible)
  end
  rb.overlay.toast("Loaded profile " .. slot, {
    title = "Crosshair Studio",
    duration = 2500,
  })
end

local function cycle_profile()
  local available = {}
  for slot = 1, 4 do
    if type(state.profiles[slot]) == "table" then
      available[#available + 1] = slot
    end
  end
  if #available == 0 then
    rb.overlay.toast("Save a profile first", {
      title = "Crosshair Studio",
      duration = 3000,
    })
    return
  end
  local next_slot = available[1]
  for _, slot in ipairs(available) do
    if slot > state.last_profile then
      next_slot = slot
      break
    end
  end
  load_profile(next_slot)
end

local function bind_profile_game(slot)
  local process = foreground_process()
  if process == "" then
    rb.overlay.toast("No foreground process found", {
      title = "Crosshair Studio",
      duration = 3000,
    })
    return
  end
  local profile = state.profiles[slot]
  if type(profile) ~= "table" then
    profile = snapshot()
  end
  profile.process = process
  state.profiles[slot] = profile
  load_profile(slot)
  rb.overlay.toast("Profile " .. slot .. " bound to " .. process, {
    title = "Crosshair Studio",
    duration = 3200,
  })
end

local function clear_profile_game(slot)
  local profile = state.profiles[slot]
  if type(profile) == "table" then
    profile.process = ""
    save_state()
    rb.overlay.toast("Profile " .. slot .. " game link cleared", {
      title = "Crosshair Studio",
      duration = 2600,
    })
  end
end

local function profile_for_process(process)
  local target = tostring(process or ""):lower()
  if target == "" then
    return nil
  end
  for slot = 1, 4 do
    local linked = profile_process(slot):lower()
    if linked ~= "" and linked == target then
      return slot
    end
  end
  return nil
end

local function reset_settings()
  for _, key in ipairs(PROFILE_KEYS) do
    if DEFAULTS[key] ~= nil then
      cfg[key] = DEFAULTS[key]
    end
  end
  state.last_game_preset = tostring(cfg.game_offset or "none (manual)")
  state.last_shape = tostring(cfg.shape)
  state.dirty = true
  state.signature = nil
  render_crosshair()
  rb.overlay.toast("Crosshair reset", { title = "Crosshair Studio", duration = 2500 })
end

local function show_help()
  rb.overlay.toast("F12 toggle  |  F11 hold to place with mouse\nCtrl+F12 editor  |  Alt+F12 profile\nAll size, color, ADS and bloom settings are in Rebind Options.", {
    title = "Crosshair Studio",
    duration = 7000,
  })
end

local function open_studio()
  if state.menu then
    pcall(function()
      state.menu:close()
    end)
    state.menu = nil
    return
  end

  if not state.visible and tostring(cfg.activation) ~= "hold" then
    set_visible(true)
  end

  local function show_menu(title, subtitle, rows)
    local items = {}
    local actions = {}
    for _, row in ipairs(rows) do
      items[#items + 1] = { label = row[1], hint = row[2] }
      actions[#actions + 1] = row[3]
    end
    local handle, why = rb.overlay.menu(items, {
      title = title,
      subtitle = subtitle,
      width = 560,
      theme = "tokyo-night",
      on_select = function(index)
        local action = actions[index]
        if type(action) == "function" then
          action()
        end
      end,
      on_close = function()
        if handle and state.menu == handle then
          state.menu = nil
        end
      end,
    })
    if handle then
      state.menu = handle
    else
      rb.log.warn("Crosshair Studio menu: " .. tostring(why))
    end
    return handle
  end

  local function use_preset(name)
    cfg.png_preset = name
    state.preset_name = nil
    state.preset_data = nil
    state.signature = nil
    state.dirty = true
    render_crosshair()
    rb.overlay.toast("Preset: " .. name, { title = "Crosshair Studio", duration = 1600 })
  end

  local function use_shape(name)
    cfg.png_preset = "none"
    apply_settings(PRESETS[name] or PRESETS.plus)
    rb.overlay.toast("Shape: " .. name, { title = "Crosshair Studio", duration = 1600 })
  end

  local classic = {}
  for _, name in ipairs(PNG_PRESETS) do
    if not KENNEY_OF[name] then
      classic[#classic + 1] = name
    end
  end
  local modern = {}
  for _, entry in ipairs(KENNEY) do
    modern[#modern + 1] = entry.label
  end

  local TABS = { "Shapes", "Classic", "New", "Custom", "Tools" }

  -- Custom reticles: any PNG dropped into assets/presets/custom shows up here.
  local function custom_names()
    local names = {}
    local ok, list = pcall(rb.file.list, "assets/presets/custom")
    if ok and type(list) == "table" then
      for _, entry in ipairs(list) do
        local name = tostring(entry)
        if name:lower():sub(-4) == ".png" then
          names[#names + 1] = name:sub(1, -5)
        end
      end
    end
    table.sort(names)
    return names
  end

  local show_tab

  local function profile_rows(rows)
    for slot = 1, 4 do
      rows[#rows + 1] = { "Save " .. slot, profile_hint(slot), function() save_profile(slot) end }
    end
    for slot = 1, 4 do
      rows[#rows + 1] = { "Load " .. slot, profile_hint(slot), function() load_profile(slot) end }
    end
    for slot = 1, 4 do
      local process = profile_process(slot)
      rows[#rows + 1] = { "Bind " .. slot, process ~= "" and process or "not bound", function() bind_profile_game(slot) end }
    end
    for slot = 1, 4 do
      local process = profile_process(slot)
      if process ~= "" then
        rows[#rows + 1] = { "Unbind " .. slot, process, function() clear_profile_game(slot) end }
      end
    end
  end

  show_tab = function(active)
    active = math.max(1, math.min(#TABS, tonumber(active) or 1))
    state.tab = active
    local rows = {}
    for index, name in ipairs(TABS) do
      rows[#rows + 1] = {
        (index == active and "[ " or "  ") .. name .. (index == active and " ]" or ""),
        index == active and "current" or ("tab " .. index),
        function() show_tab(index) end,
      }
    end

    if active == 1 then
      for _, name in ipairs(PRESET_ORDER) do
        rows[#rows + 1] = { name, "takes your colour", function() use_shape(name) end }
      end
    elseif active == 2 then
      rows[#rows + 1] = { "None", "no preset", function() use_preset("none") end }
      for _, name in ipairs(classic) do
        rows[#rows + 1] = { name, "fixed art", function() use_preset(name) end }
      end
    elseif active == 3 then
      rows[#rows + 1] = { "None", "no preset", function() use_preset("none") end }
      for _, name in ipairs(modern) do
        rows[#rows + 1] = { name, "fixed art", function() use_preset(name) end }
      end
    elseif active == 4 then
      local names = custom_names()
      if #names == 0 then
        rows[#rows + 1] = { "No custom PNGs yet", "assets/presets/custom", nil }
      else
        for _, name in ipairs(names) do
          rows[#rows + 1] = { name, "custom", function() use_preset("custom/" .. name) end }
        end
      end
    else
      rows[#rows + 1] = { "Toggle crosshair", state.visible and "on" or "off", function() toggle_visible() end }
      rows[#rows + 1] = { "Place with mouse", "hold " .. tostring(cfg.placement_key), function() start_placement() end }
      rows[#rows + 1] = { "Cycle profile", "Alt+F12", function() cycle_profile() end }
      profile_rows(rows)
      rows[#rows + 1] = { "Reset settings", "back to defaults", function() reset_settings() end }
      rows[#rows + 1] = { "Help / keys", "Ctrl+F12", function() show_help() end }
    end

    show_menu("Crosshair Studio", "1-" .. #TABS .. " switch tab  |  arrows pick  |  Enter apply", rows)
  end

  show_tab(state.tab or 1)
end

function OnStart()
  load_state()
  cfg.png_preset = normalize_preset(cfg.png_preset)
  state.last_game_preset = state.saved_game_preset or tostring(cfg.game_offset or "none (manual)")
  cfg.shape = normalize_shape(cfg.shape)
  state.keys = {}
  state.last_process = ""
  state.placing = false
  state.placement_saved = nil
  state.gap = math.max(0, n(cfg.gap, 8))
  if cfg.start_visible ~= false then
    state.visible = true
  elseif state.saved_visible ~= nil then
    state.visible = state.saved_visible
  else
    state.visible = false
  end
  if tostring(cfg.activation) == "hold" then
    state.visible = false
  end
  state.signature = nil
  state.geo_key = nil
  state.geo_at = nil
  state.last_shape = tostring(cfg.shape)
  state.dirty = true
  save_state()
  render_crosshair()

  if not rb.overlay.visible() then
    state.warned_invisible = true
    rb.overlay.toast("Exclusive fullscreen hides overlays. Use borderless.", {
      title = "Crosshair Studio",
      duration = 5000,
    })
  else
    rb.overlay.toast("F12 toggle  |  Ctrl+F12 editor", {
      title = "Crosshair Studio " .. VERSION,
      duration = 4000,
    })
  end
end

function OnStop()
  remove_surface()
  if state.menu then
    pcall(function()
      state.menu:close()
    end)
    state.menu = nil
  end
  save_state()
end

function OnTick()
  -- monitors, resolutions and window sizes change while the game runs
  local now = rb.system.time()
  if not state.geo_at or now - state.geo_at >= 500 then
    state.geo_at = now
    local key = geometry_key()
    if key ~= state.geo_key then
      state.geo_key = key
      state.dirty = true
    end
  end

  -- Game preset is a full per-game memory: switching away stores everything
  -- you changed, switching back restores it.
  local game = tostring(cfg.game_offset or "none (manual)")
  if game ~= state.last_game_preset then
    local previous = state.last_game_preset
    if previous and previous ~= "none (manual)" then
      state.game_profiles[previous] = snapshot()
    end
    state.last_game_preset = game
    if game ~= "none (manual)" then
      local stored = state.game_profiles[game]
      if type(stored) == "table" then
        apply_settings(stored)
        cfg.game_offset = game
        rb.overlay.toast("Game profile: " .. game, { title = "Crosshair Studio", duration = 1600 })
      else
        local entry = GAME_OFFSET_OF[game]
        if entry then
          cfg.offset_x = entry.x
          cfg.offset_y = entry.y
        end
      end
      state.last_game_preset = game
      state.dirty = true
      state.signature = nil
    end
    save_state()
  end

  if state.placing then
    follow_mouse_for_placement()
  elseif not state.menu then
    local process = foreground_process()
    if process ~= state.last_process then
      state.last_process = process
      local slot = profile_for_process(process)
      if slot and slot ~= state.last_profile then
        load_profile(slot)
      end
    end
  end

  -- Shape and preset are two ways to draw the same reticle, so picking one
  -- has to switch the other off; otherwise the preset silently wins.
  if state.last_shape ~= nil and tostring(cfg.shape) ~= state.last_shape then
    state.last_shape = tostring(cfg.shape)
    if tostring(cfg.png_preset or "none") ~= "none" then
      cfg.png_preset = "none"
      state.preset_name = nil
      state.preset_data = nil
      state.signature = nil
      state.dirty = true
      render_crosshair()
      rb.ui.notify("Shape chosen - preset switched off", "info")
    end
  end

  local signature = settings_signature()
  if signature ~= state.signature then
    local first_tick = state.signature == nil
    state.signature = signature
    state.dirty = true
    local active_game = tostring(cfg.game_offset or "none (manual)")
    if active_game ~= "none (manual)" then
      state.game_profiles[active_game] = snapshot()
    end
    -- Editing an option is a preview action. Bring the reticle back if the
    -- user had toggled it off, but never override Hold mode or the initial
    -- Show-on-start choice.
    if not first_tick
      and not state.visible
      and tostring(cfg.activation) ~= "hold" then
      set_visible(true)
    end
  end

  local keys = state.keys
  local moving = keys.W or keys.A or keys.S or keys.D or keys.Space
  local firing = keys.Mouse1 == true
  local ads = keys.Mouse2 == true

  if ads ~= state.last_ads then
    state.last_ads = ads
    if tostring(cfg.ads) ~= "show" then
      state.dirty = true
    end
  end

  local target = math.max(0, n(cfg.gap, 8))
  if cfg.bloom ~= false then
    if moving then
      target = target + math.max(0, n(cfg.move_bloom, 7))
    end
    if firing then
      target = target + math.max(0, n(cfg.fire_bloom, 5))
    end
  end
  local speed = clamp(n(cfg.smooth, 14) / 60, 0.02, 1)
  state.gap = state.gap + (target - state.gap) * speed
  state.gap = clamp(state.gap, 0, 80)

  if state.visible then
    local gap_delta = math.abs(state.gap - state.last_render_gap)
    if state.dirty or gap_delta > 0.08 then
      render_crosshair()
    end
  else
    remove_surface()
  end
end

-- Observer hooks, not OnDown/OnUp: the crosshair tracks keys without ever
-- intercepting or re-emitting game input.
function OnInputDown(key)
  local normalized = norm_key(key)
  state.keys[normalized] = true

  if normalized == norm_key(cfg.placement_key) then
    start_placement()
    return
  end

  if state.placing and normalized == "Escape" then
    finish_placement(false)
    return
  end

  if normalized == norm_key(cfg.toggle_key) then
    local modifiers = rb.input.modifiers() or {}
    if modifiers.ctrl then
      open_studio()
      return
    end
    if modifiers.alt then
      cycle_profile()
      return
    end
    if tostring(cfg.activation) == "hold" then
      set_visible(true)
    else
      toggle_visible()
    end
  end
end

function OnInputUp(key)
  local normalized = norm_key(key)
  state.keys[normalized] = false

  if normalized == norm_key(cfg.placement_key) and state.placing then
    finish_placement(true)
    return
  end

  if normalized == norm_key(cfg.toggle_key)
    and tostring(cfg.activation) == "hold" then
    local modifiers = rb.input.modifiers() or {}
    if not modifiers.ctrl and not modifiers.alt then
      set_visible(false)
    end
  end
end

136 files, 165.2 KB. 130 have no text preview.