Snapmove

@rebindv0.1.2

Last-key-wins movement resolution for WASD.

Files

main.luau

-- snapmove — SOCD "snap tap" for instantaneous directional changes in FPS games.
-- When both opposing keys are held (A+D or W+S), the most recently pressed
-- direction wins. The losing key is briefly overlapped so the game registers
-- the transition before it's released.

local GAMES = require("lib/games")

local cfg = UI.Schema({
  enabled = UI.Toggle(true, { label = "Enable Snapmove" }),
  toggle_key = UI.Keybind("F11", { label = "Toggle Key" }),

  -- Game Detection
  auto_detect = UI.Toggle(true, {
    label = "Auto-detect games",
    group = "Game Detection",
    tooltip = "Active in known FPS games (CS2, VALORANT, Apex, CoD, etc.)",
  }),
  targets = UI.Text("", {
    label = "Window/Process Targets",
    group = "Game Detection",
    placeholder = "comma-separated, e.g. cs2, valorant, r5apex",
    tooltip = "Optional extra targets. Leave empty if auto-detect covers your games. Matches against window title and process name (case-insensitive).",
  }),

  -- Behavior
  humanize = UI.Toggle(true, {
    label = "Humanize overlap",
    group = "Behavior",
    tooltip = "Randomize each overlap duration between the min and max for natural-feeling timing",
  }),
  overlap_min = UI.Slider(15, {
    min = 0,
    max = 55,
    suffix = "ms",
    group = "Behavior",
    label = "Overlap Min",
    showIf = "humanize",
    tooltip = "Minimum overlap when humanize is on",
  }),
  overlap_max = UI.Slider(25, {
    min = 0,
    max = 60,
    suffix = "ms",
    group = "Behavior",
    label = "Overlap Max",
    tooltip = "Maximum time both opposing keys stay held together before the loser releases",
  }),

  -- Diagnostics
  debug_global = UI.Toggle(false, {
    label = "Debug: run globally",
    group = "Diagnostics",
    tooltip = "Ignore game detection and run snapmove in every application",
  }),
  debug_log = UI.Toggle(false, {
    label = "Debug logging",
    group = "Diagnostics",
    tooltip = "Log every key state change to the Rebind console",
  }),
})

-- ── Targeting ────────────────────────────────────────────────────────────────────

local manualList = {}
local manualStr = ""

local function matchTarget()
  if cfg.debug_global then
    return true
  end

  -- auto-detect: check hardcoded game list
  if cfg.auto_detect then
    local win = System.Window()
    local title = win.title:lower()
    local proc = win.process:lower()
    for _, t in ipairs(GAMES) do
      if title:find(t, 1, true) or proc:find(t, 1, true) then
        return true
      end
    end
  end

  -- manual targets
  local raw = cfg.targets
  if raw == "" then
    -- no manual targets: if auto-detect is off, run globally;
    -- if auto-detect is on, only the game list matters (already checked above)
    return not cfg.auto_detect
  end

  if raw ~= manualStr then
    manualList = {}
    for part in raw:gmatch("[^,]+") do
      local t = part:match("^%s*(.-)%s*$")
      if #t > 0 then
        manualList[#manualList + 1] = t:lower()
      end
    end
    manualStr = raw
  end

  if #manualList == 0 then
    return not cfg.auto_detect
  end

  local win = System.Window()
  local title = win.title:lower()
  local proc = win.process:lower()
  for _, t in ipairs(manualList) do
    if title:find(t, 1, true) or proc:find(t, 1, true) then
      return true
    end
  end
  return false
end

local KEYS = { "W", "A", "S", "D" }
local OPPOSITE = { W = "S", S = "W", A = "D", D = "A" }
local AXIS_NAME =
  { A = "horizontal", D = "horizontal", W = "vertical", S = "vertical" }

-- ── State ─────────────────────────────────────────────────────────────────────────

local held = {} -- physically held (key → bool)
local virt = {} -- what the PC sees (key → bool)
local lastH = nil -- last pressed on horizontal axis ("A" or "D")
local lastV = nil -- last pressed on vertical axis ("W" or "S")
local timers = {} -- pending overlap-release timers (key → handle)

-- ── Debug logging ─────────────────────────────────────────────────────────────────

local function dbg(msg)
  if cfg.debug_log then
    Log.Info("[snapmove] " .. msg)
  end
end

-- ── Helpers ───────────────────────────────────────────────────────────────────────

local function setVirt(key, down)
  if virt[key] == down then
    return
  end
  if down then
    HID.Down(key)
    dbg("HID.Down  " .. key)
  else
    HID.Up(key)
    dbg("HID.Up    " .. key)
  end
  virt[key] = down
end

local function resolveAxis(a, b, lastPressed)
  -- Cancel any pending overlap timer for this axis
  for _, k in ipairs({ a, b }) do
    if timers[k] then
      timers[k]:Cancel()
      timers[k] = nil
    end
  end

  local aHeld = held[a]
  local bHeld = held[b]

  if aHeld and bHeld then
    -- Both held: last-pressed wins, loser releases after overlap
    local winner = lastPressed or a
    local loser = (winner == a) and b or a
    setVirt(winner, true)
    setVirt(loser, true)

    local overlapMs
    if cfg.humanize then
      overlapMs = Math.Random(cfg.overlap_min, cfg.overlap_max)
    else
      overlapMs = cfg.overlap_max
    end

    dbg(
      "SOCD "
        .. AXIS_NAME[winner]
        .. "  winner="
        .. winner
        .. " loser="
        .. loser
        .. " overlap="
        .. overlapMs
        .. "ms"
    )

    if overlapMs > 0 then
      timers[a] = Timer.After(overlapMs, function()
        if held[winner] and held[loser] then
          setVirt(loser, false)
          dbg("SOCD " .. AXIS_NAME[winner] .. " overlap end released " .. loser)
        end
      end)
    else
      setVirt(loser, false)
    end
  else
    setVirt(a, aHeld or false)
    setVirt(b, bHeld or false)
  end
end

local function resolveAll()
  resolveAxis("A", "D", lastH)
  resolveAxis("W", "S", lastV)
end

local function releaseAll()
  for _, t in pairs(timers) do
    if t then
      t:Cancel()
    end
  end
  timers = {}
  for _, key in ipairs(KEYS) do
    setVirt(key, false)
  end
  held = {}
  lastH, lastV = nil, nil
  dbg("all keys released")
end

-- ── Lifecycle ─────────────────────────────────────────────────────────────────────

function OnStop()
  releaseAll()
end

function OnBlur()
  releaseAll()
end

-- ── OnTick: catch releases the engine might miss ──────────────────────────────────
-- When OnDown swallows a WASD key, OnUp is not guaranteed. Poll instead.

function OnTick()
  if not cfg.enabled then
    return
  end
  if not matchTarget() then
    return
  end

  local dirty = false
  for _, key in ipairs(KEYS) do
    if held[key] and not Input.IsDown(key) then
      held[key] = nil
      dbg("OnTick caught release of " .. key)
      dirty = true
    end
  end
  if dirty then
    resolveAll()
  end
end

-- ── Input hooks ───────────────────────────────────────────────────────────────────

function OnDown(key)
  if key == cfg.toggle_key then
    cfg.enabled = not cfg.enabled
    if not cfg.enabled then
      releaseAll()
    end
    UI.Notify(cfg.enabled and "Snapmove ON" or "Snapmove OFF", "info")
    dbg("toggled  " .. (cfg.enabled and "ON" or "OFF"))
    return false
  end

  if not cfg.enabled then
    return true
  end
  if not matchTarget() then
    return true
  end
  if not OPPOSITE[key] then
    return true
  end

  held[key] = true

  if key == "A" or key == "D" then
    lastH = key
  else
    lastV = key
  end

  dbg("press    " .. key .. "  held=" .. #Input.GetActiveKeys())
  resolveAll()
  return false
end

function OnUp(key)
  if not cfg.enabled then
    return true
  end
  if not matchTarget() then
    return true
  end
  if not OPPOSITE[key] then
    return true
  end

  held[key] = nil
  dbg("release  " .. key)
  resolveAll()
  return false
end