GCD Goblin for WoW Forever

@paysch / gcd-goblinv1.0.1Public

World of Warcraft turbo keypress for Rebind: hold a key to auto repeat it, record a rotation and replay it with one key, plus anti-AFK.

Files

Older release. Installs get 1.0.1.

main.luau13.6 KB

-- GCD Goblin: turbo keypress, rotations and anti-AFK for World of Warcraft
-- Turbo: a held key taps once at once, then repeats until released.
-- Anti-AFK: presses an unbound key when idle, cancelled by any player activity.
-- Rotation: record ability presses from the menu, then hold a key to loop them.
-- Menu: WoW-themed overlay menu (F10) showing every feature and its hotkey.

local overlay = require("lib.overlay")
local rotation = require("lib.rotation")

local cfg = rb.ui.schema({
  enabled = rb.ui.toggle(true, { label = "Turbo keypress on", group = "Turbo keypress" }),
  keys = rb.ui.list(
    { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "Minus", "Equal",
      "R", "F", "G", "T", "Z", "X", "C", "V", "Mouse5" },
    {
      label = "Keys",
      placeholder = "key, or key:ms for its own gap",
      tooltip = "A key name, or key:ms to give that key its own gap between presses, such as 3:250",
      group = "Turbo keypress",
    }
  ),
  start_delay = rb.ui.slider(90, { min = 0, max = 500, suffix = "ms", label = "Hold before repeat", group = "Turbo keypress" }),
  hold = rb.ui.slider(40, { min = 10, max = 150, suffix = "ms", label = "Press length", group = "Turbo keypress" }),
  gap = rb.ui.slider(40, { min = 10, max = 400, suffix = "ms", label = "Between presses", group = "Turbo keypress" }),
  toggle_key = rb.ui.keybind("F8", { label = "Toggle turbo keypress", group = "Turbo keypress" }),

  afk_enabled = rb.ui.toggle(false, { label = "Anti-AFK on", group = "Anti-AFK" }),
  afk_hotkey = rb.ui.keybind("F9", { label = "Toggle anti-AFK", group = "Anti-AFK" }),
  afk_interval_s = rb.ui.slider(180, { min = 30, max = 600, suffix = "s", label = "Nudge interval", group = "Anti-AFK" }),
  afk_key = rb.ui.keybind("End", {
    label = "Nudge key",
    tooltip = "Pressed when idle. Use a key with no action in WoW's key bindings.",
    group = "Anti-AFK",
  }),
  afk_overlay = rb.ui.toggle(true, { label = "Show indicator", group = "Anti-AFK" }),

  rotation_play = rb.ui.keybind("F12", {
    label = "Play rotation",
    tooltip = "Plays the saved rotation once; pressing it again restarts it",
    group = "Rotation",
  }),
  stop_keys = rb.ui.list({ "W", "A", "S", "D", "Q", "E", "Space" }, {
    label = "Stop rotation keys",
    placeholder = "key name",
    tooltip = "Pressing any of these stops a rotation that is playing",
    group = "Rotation",
  }),

  chat_detect = rb.ui.toggle(true, { label = "Chat detection", group = "Chat safety" }),
  chat_overlay = rb.ui.toggle(false, { label = "Show chat state overlay", group = "Chat safety" }),

  menu_key = rb.ui.keybind("F10", { label = "Open menu", group = "Menu" }),
})

-- A keybind setting can carry "@<device id>" once restricted to one device;
-- pressing a key, or asking whether it is down, needs the bare key name.
local function bare(keybind)
  return keybind:match("^[^@]+")
end

-- The keys as the overlay shows them. A changed keybind reloads the script,
-- so they are read once.
local keys = {
  menu = bare(cfg.menu_key),
  turbo = bare(cfg.toggle_key),
  afk = bare(cfg.afk_hotkey),
  afk_key = bare(cfg.afk_key),
  play = bare(cfg.rotation_play),
}

-- Chat state comes from the PixelState addon's strip in the game's top-left
-- corner: square 0 is the magenta sync marker, square 6 is white while a text
-- box has keyboard focus and black otherwise. Guessing it from Enter, slash
-- and clicks got stuck on "typing" mid-fight, since every ability press kept
-- the guess alive. Full-on and full-off channels read true under any gamma,
-- brightness or contrast setting, unlike the mid-range values of squares 1-5.
-- A tolerance of 0.5 lets each channel stray up to half its range, so only a
-- clearly white or clearly black square counts.
local function square_shows(w, index, color, tolerance)
  local x = w.x + index * 4 + 1
  return rb.screen.search_color({ x, w.y + 1, x + 1, w.y + 2 }, color, tolerance) ~= nil
end

-- The full check: true or false, or nil when the strip cannot be read (addon
-- off or older than square 6, corner covered). Up to three reads of about
-- 2.5 ms each, so only the 250 ms timer runs it.
local function read_strip()
  local w = rb.system.window()
  if not w or w.width == 0 then
    return nil
  end
  if not square_shows(w, 0, "ff00ff", 0.1) then
    return nil
  end
  if square_shows(w, 6, "ffffff", 0.5) then
    return true
  end
  if square_shows(w, 6, "000000", 0.5) then
    return false
  end
  return nil
end

-- Whether the timer's last full check found a readable strip.
local strip_ok = false

-- The key-press path: one read, trusting the timer's last check. An
-- unreadable strip counts as not typing, so turbo keeps working; the chat
-- overlay says so.
local function typing()
  if not strip_ok then
    return false
  end
  local w = rb.system.window()
  return w ~= nil and square_shows(w, 6, "ffffff", 0.5)
end

local function turbo_active()
  return cfg.enabled and not (cfg.chat_detect and typing())
end

-- Turbo keys from Options: "3" uses the "Between presses" gap, "3:250" its
-- own 250 ms gap. They are also the keys a rotation records.
local turbo_keys = {}
-- key -> { gap }, the gap nil for the "Between presses" one
local ability_keys = {}
for _, entry in cfg.keys do
  local key, gap = entry:match("^([^:]+):(%d+)$")
  key = key or entry
  table.insert(turbo_keys, { key = key, gap = tonumber(gap) })
  ability_keys[key] = { gap = tonumber(gap) }
end

-- The taps the game gets for an ability key held held_ms, as turbo sends
-- them: one at once, then after "Hold before repeat" one every press length
-- plus gap while the key stays down. With turbo off, the hold reaches the
-- game as it is.
local function turbo_taps(key, held_ms)
  if not cfg.enabled then
    return { { at = 0, length = held_ms } }
  end
  local taps = { { at = 0, length = 0 } }
  local at = cfg.start_delay
  while at < held_ms do
    table.insert(taps, { at = at, length = cfg.hold })
    at += cfg.hold + (ability_keys[key].gap or cfg.gap)
  end
  return taps
end

-- Any of these stops a rotation that is playing.
local stop_keys = {}
for _, key in cfg.stop_keys do
  stop_keys[key] = true
end

-- Anti-AFK presses its key only after a full interval with no key, button,
-- mouse move or scroll, then waits another interval. The game's idle timer
-- counts key presses, not mouse movement.
local last_activity_at = 0

local function mark_activity()
  last_activity_at = rb.system.time()
end

-- the pass playing now: { cancelled, task }
local playing = nil

rb.on("input_down", function(key)
  mark_activity()
  if ability_keys[key] and rotation.saw() then
    overlay.recording(rotation.seen())
  end
  if playing and stop_keys[key] then
    playing.cancelled = true
  end
end)
rb.on("input_move", mark_activity)
rb.on("input_scroll", function()
  mark_activity()
  if rotation.saw() then
    overlay.recording(rotation.seen())
  end
end)

-- Turbo. One repeating loop per key. A loop only ever ends between taps, by
-- its own condition: stopping a coroutine parked in rb.sleep(hold) would skip
-- its rb.hid.up and leave the key down, since Rebind only force-releases what
-- a script holds when the script itself stops. Turning turbo off therefore
-- only flips cfg.enabled; every loop exits after its current tap.
local tasks = {}

local function turbo(key, gap)
  rb.bind(key, function()
    rb.hid.combo(key) -- the first tap is never late
    local running = tasks[key]
    if running and running:is_running() then
      return
    end
    local task
    task = rb.run(function()
      rb.sleep(cfg.start_delay)
      while turbo_active() and rb.input.is_down(key) do
        rb.hid.down(key)
        rb.sleep(cfg.hold)
        rb.hid.up(key)
        rb.sleep(gap or cfg.gap)
      end
      if tasks[key] == task then
        tasks[key] = nil
      end
    end)
    tasks[key] = task
  end, { when = turbo_active })
end

for _, turbo_key in turbo_keys do
  turbo(turbo_key.key, turbo_key.gap)
end

-- A toggle hotkey flashes the new state with the key that flips it back.
rb.bind(cfg.toggle_key, function()
  cfg.enabled = not cfg.enabled
  overlay.notice(if cfg.enabled then "chip_turbo_on" else "chip_turbo_off",
    { { key = keys.turbo }, if cfg.enabled then "d_turns_off" else "d_turns_on" })
end)

rb.bind(cfg.afk_hotkey, function()
  cfg.afk_enabled = not cfg.afk_enabled
  overlay.notice(if cfg.afk_enabled then "chip_afk_on" else "chip_afk_off",
    { { key = keys.afk }, if cfg.afk_enabled then "d_turns_off" else "d_turns_on" })
end)

-- Rotation. Recording starts from the menu and shows a panel above the action
-- bars with a live count; Enter saves it, kept to the turbo keys, and Escape
-- cancels. The play key plays the saved rotation once; pressing it again
-- restarts it, and a stop key or opening chat ends it. It never plays while
-- recording.
local function seconds(ms)
  return string.format("%.1f s", ms / 1000)
end

local function start_recording()
  rotation.start()
  overlay.recording(0)
end

local function finish_recording(save)
  overlay.recording(nil)
  if not save then
    rotation.cancel()
    overlay.notice("chip_cancelled")
    return
  end
  local presses = rotation.stop(ability_keys, turbo_taps)
  if presses == 0 then
    overlay.notice("chip_nothing")
    return
  end
  overlay.notice("chip_saved", {
    { text = tostring(presses), color = "#f0c060", size = 13 }, "d_presses",
    { text = seconds(rotation.length()), color = "#f0c060", size = 13 }, "d_dot",
    { key = keys.play }, "d_plays_it",
  }, 4000)
end

rb.bind(cfg.rotation_play, function()
  if rotation.recording() then
    return
  end
  if not rotation.ready() then
    overlay.notice("chip_empty")
    return
  end
  -- A press while playing restarts: the running pass stops and releases its
  -- keys before the new one starts, so the two never overlap.
  local previous = playing
  if previous then
    previous.cancelled = true
  end
  local pass = { cancelled = false }
  playing = pass
  overlay.playing(true)
  pass.task = rb.run(function()
    while previous and previous.task:is_running() do
      rb.sleep(5)
    end
    rotation.play(function()
      return not pass.cancelled and not (cfg.chat_detect and typing())
    end)
    if playing == pass then
      playing = nil
      overlay.playing(false)
    end
  end)
end)

-- Copies the bundled addon into the focused game's own AddOns folder, found
-- from its process, so the right WoW install gets it. rb.system.exec runs
-- through cmd.exe /C, which mangles quoted arguments, and the game's path has
-- spaces, so the copy lives in addon/install.ps1, started from its own folder
-- with nothing but a pid on the command line. script_dir() answers with the
-- \\?\ long-path prefix, which turns off path normalization, so the folder is
-- joined with a backslash: a forward slash there makes the path invalid.
local function install_addon()
  local handle = rb.window.get_foreground()
  if not handle then
    return
  end
  local pid = rb.window.get_pid(handle)
  rb.run(function()
    local result = rb.system.exec(
      "%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
        .. " -NoProfile -ExecutionPolicy Bypass -File install.ps1 " .. pid,
      { cwd = rb.file.script_dir() .. "\\addon", timeout = 15000 }
    )
    if result.exit ~= 0 then
      rb.log.error("addon install: " .. result.stderr)
      overlay.toast(false, result.stderr:match("[^\r\n]+") or "exit " .. result.exit)
      return
    end
    rb.log.info("addon installed to " .. result.stdout)
    overlay.toast(true)
  end)
end

overlay.init(cfg, keys, {
  record = start_recording,
  install = install_addon,
  length = function()
    return if rotation.ready() then seconds(rotation.length()) else nil
  end,
})

rb.bind(cfg.menu_key, function()
  if not rotation.recording() then
    overlay.toggle_menu()
  end
end)

-- The menu's keys are taken only while it is open, and Enter and Escape also
-- while recording; otherwise they reach the game as usual (Enter still opens
-- chat, Left and Right still turn).
for _, key in { "Up", "Down", "Left", "Right" } do
  rb.bind(key, function()
    overlay.menu_key(key)
  end, { when = overlay.menu_open })
end

for _, key in { "Enter", "Escape" } do
  rb.bind(key, function()
    if rotation.recording() then
      finish_recording(key == "Enter")
    else
      overlay.menu_key(key)
    end
  end, {
    when = function()
      return overlay.menu_open() or rotation.recording()
    end,
  })
end

-- Leaving the game pauses the loops, possibly mid-tap; release every turbo
-- key so nothing stays pressed in the window you switch to. The overlay
-- belongs over the game only, so it goes too (the menu closes, as its keys
-- only reach this script while the game is focused), and comes back over the
-- game window when the game is in front again.
rb.on("blur", function()
  for _, turbo_key in turbo_keys do
    rb.hid.up(turbo_key.key)
  end
  overlay.hide()
end)

-- The first time the game is focused, also say how to open the menu.
local welcomed = false

rb.on("focus", function()
  overlay.show()
  if not welcomed then
    welcomed = true
    overlay.notice("chip_welcome", { { key = keys.menu }, "d_opens_menu" }, 4000)
  end
end)

rb.timer.every(250, function()
  if cfg.afk_enabled then
    local now = rb.system.time()
    if (now - last_activity_at) >= cfg.afk_interval_s * 1000 then
      rb.hid.combo(bare(cfg.afk_key))
      last_activity_at = now
    end
  end

  -- read even with chat detection off: the menu shows whether the addon is in
  local chat = read_strip()
  strip_ok = chat ~= nil
  overlay.sync(chat)
end)

61 files, 595.0 KB. 53 have no text preview.