commit e65f9db3ae2830bec915f185b95ab16bbf180117 Author: templis Date: Tue Aug 4 21:39:19 2026 +0200 feat: Linux bulk importer for the Allflame Voyage Solver Mirrors the solver's Windows-only voyage-import.ahk on KDE Wayland: - poe-clip-bridge.py: clipboard watcher that keeps only PoE item text, serves it on loopback and can trigger a sweep from the browser - voyage-sweep.py: hovers every cell of the in-game chart grid, Ctrl+C's it and hands over the whole batch in one go - userscript: feeds the batch into the solver page without focusing it Includes notes on the four Wayland pointer pitfalls this ran into. Co-Authored-By: Claude Opus 5 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b7dc0c9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Thomas Kopp + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ca8f59 --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# voyage-bulk-autoimporter + +Bulk-import your Path of Exile **Charts** into the +[Allflame Voyage Solver](https://one-more-map.github.io/allflame-voyage-solver/) +— on Linux / Wayland. + +The solver ships `voyage-import.ahk`, an AutoHotkey bulk importer that only runs +on Windows. This is the Linux counterpart: it sweeps the in-game chart grid, +copies every chart with `Ctrl+C`, and hands the whole batch to the solver page +in the browser — no manual pasting, no per-item clicking. + +Tested on KDE Plasma 6 / Wayland with PoE running through Proton. + +## What you get + +``` + PoE chart grid clipboard bridge browser + ┌──────────────┐ Ctrl+C ┌────────────────┐ HTTP ┌──────────────┐ + │ voyage-sweep │ ───────────► │ poe-clip-bridge│ ─────────► │ userscript │ + │ hovers each │ per cell │ 127.0.0.1:8477│ polling │ pastes into │ + │ cell │ │ (item filter) │ │ the solver │ + └──────────────┘ └────────────────┘ └──────────────┘ +``` + +Three independent pieces — each is useful on its own: + +| Piece | What it does | +|---|---| +| `bin/poe-clip-bridge.py` | Watches the clipboard, keeps **only** PoE item text, serves it on loopback. Also starts a sweep on request, so a browser bookmark can trigger it. | +| `userscript/allflame-autopaste.user.js` | Polls the bridge and feeds copied items into the solver — works even while the browser is unfocused on another monitor. | +| `bin/voyage-sweep.py` | Sweeps the whole chart grid and produces one batch. | + +Copy a single chart in game and it appears in the solver by itself. Or trigger a +sweep and get all of them at once. + +## Requirements + +- KDE Plasma 6 on Wayland (uses KWin's scripting API for one pointer read) +- `wl-clipboard`, `ydotool` (+ `ydotoold`), `xdotool`, `kdotool`, `qdbus6`, Python 3 +- Firefox with [Violentmonkey](https://violentmonkey.github.io/) (or Tampermonkey) +- PoE in **Windowed** or **Windowed Fullscreen** mode + +On Arch: + +```bash +sudo pacman -S wl-clipboard ydotool xdotool python +paru -S kdotool # AUR +``` + +`ydotool` injects input through `/dev/uinput`. Add yourself to the `input` group +(or whatever group owns `/dev/uinput` on your distro) so it works without root: + +```bash +sudo usermod -aG input "$USER" # log out and back in +systemctl --user enable --now ydotool.service +``` + +## Install + +```bash +git clone https://git.opennerds.org/templis/voyage-bulk-autoimporter.git +cd voyage-bulk-autoimporter +./install.sh +``` + +`install.sh` copies the two scripts to `~/.local/bin`, installs the systemd user +unit and starts the bridge. Then install the userscript: open +`userscript/allflame-autopaste.user.js` in Firefox — Violentmonkey offers to +install it. If Firefox will not open `file://` URLs, paste the file contents +into a new Violentmonkey script instead. + +## Calibrate the grid + +The sweep needs to know where the chart grid is. Coordinates are stored +**relative to the PoE window**, so moving the window does not invalidate them. + +1. Open the Voyage panel in game (the chart grid must be visible). +2. Run: + + ```bash + voyage-sweep.py calibrate --cols 6 --rows 10 + ``` + +3. Put the mouse on the **centre of the top-left cell** — a 5 second countdown + captures it. Then the **centre of the bottom-right cell**, even if it is empty. + +Adjust `--cols/--rows` to your panel. The result lands in +`~/.config/voyage-sweep.json`: + +```json +{ + "cols": 6, + "rows": 10, + "tl": [1759, 427], + "br": [2093, 1026], + "hover_delay": 0.14, + "clip_timeout": 0.6, + "poe_window": "Path of Exile" +} +``` + +If cells get missed, raise `hover_delay` (tooltip needs longer to appear) or +`clip_timeout`. + +## Use it + +Terminal: + +```bash +voyage-sweep.py run # whole grid +voyage-sweep.py run --rows 2 # only the first 2 rows, for testing +``` + +Or from the browser — get your personal trigger URL: + +```bash +voyage-bookmarklet.sh +``` + +It prints a ready-made bookmarklet. Add it via the bookmark manager +(`Ctrl+Shift+O` → new bookmark, paste into the URL field). Clicking it starts a +sweep without leaving the solver page. + +**Do not** open the raw trigger URL and then bookmark the open tab — visiting it +already starts a sweep. + +Then: solver page open, Voyage panel open in game, click the bookmarklet. After +a 4 second countdown PoE is raised, the grid is swept (~90 s for 60 cells) and +the batch is imported automatically. + +Hands off mouse and keyboard while it runs, and leave the Voyage panel open — +closing it mid-sweep produces garbage. + +## Security + +A clipboard watcher sees everything you copy, including passwords from your +password manager. This one is built so that never leaves the process: + +- Clipboard content that does not match `^(Item Class|Rarity):` or + `=== VOYAGE BORDER` is **discarded immediately** — not stored, not served. +- Item text is kept in memory only. Nothing is written to disk. +- The HTTP server binds to `127.0.0.1` exclusively. +- Endpoints that *act* (`/sweep`, `/pause`, `/resume`) require a random token + from `~/.config/poe-clip-bridge/token` (mode `0600`, generated on first start). + Without it any web page could fire `` + and take over your mouse and keyboard. +- If you sync bookmarks: the bookmarklet contains that token. Either keep it out + of sync or use the terminal command. + +`ydotoold` can inject input into any window while it runs. That is inherent to +this kind of tool — start it on demand instead of enabling it permanently if you +would rather not have it around all the time. + +## Troubleshooting + +**"skipped N uncharted (run them first to reveal their modifier)"** — not a bug. +The solver rejects charts whose Voyage modifier is still hidden. Chart them with +Valerie aboard the Sovereign first. + +**"Every cell copied the SAME chart"** — calibration is off or the Voyage panel +was closed during the sweep. Re-calibrate and try again. + +**"ydotoold is not running"** — `systemctl --user start ydotool.service`. + +**Pointer lands next to the cells** — the pointer control loop learns how much +your display multiplies injected motion (2.0× on some setups). Give it room by +raising `hover_delay`, and make sure the PoE window is not scaled differently +from when you calibrated. + +**Nothing appears in the browser** — check the bridge: + +```bash +systemctl --user status poe-clip-bridge.service +curl -s http://127.0.0.1:8477/clip +``` + +`docs/wayland-pointer-notes.md` documents the pointer pitfalls behind these +workarounds — useful for anyone automating input on Wayland. + +## Credits + +- [Allflame Voyage Solver](https://one-more-map.github.io/allflame-voyage-solver/) + by one-more-map, including the original Windows `voyage-import.ahk` this + mirrors. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/bin/poe-clip-bridge.py b/bin/poe-clip-bridge.py new file mode 100755 index 0000000..fd5c715 --- /dev/null +++ b/bin/poe-clip-bridge.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Clipboard bridge for the Allflame Voyage Solver. + +Watches the Wayland clipboard, keeps ONLY Path of Exile item text in memory +and serves it on http://127.0.0.1:8477/clip for a Violentmonkey userscript. +Also triggers the grid sweep, so a Firefox bookmark can start it. + +Security: anything that does not look like PoE item text (passwords from +KeePassXC, tokens, ...) is discarded immediately and never stored or served. +Nothing is written to disk. The socket is bound to loopback only. +Endpoints that act (sweep/pause/resume) require the token from +~/.config/poe-clip-bridge/token - otherwise any web page could start a sweep +that takes over mouse and keyboard. +""" + +import json +import os +import pathlib +import re +import secrets +import subprocess +import threading +import urllib.parse +from collections import deque +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +HOST, PORT = "127.0.0.1", 8477 +ORIGIN = "https://one-more-map.github.io" +TOKEN_FILE = pathlib.Path.home() / ".config" / "poe-clip-bridge" / "token" +SWEEP = str(pathlib.Path.home() / ".local" / "bin" / "voyage-sweep.py") + +# Only clipboard content matching this ever leaves the watcher. +ITEM_RE = re.compile( + r"^[ \t]*(?:Item Class|Rarity)\s*[::]|===\s*VOYAGE BORDER", + re.IGNORECASE | re.MULTILINE, +) + +state = {"seq": 0, "text": ""} +paused = False +sweep_proc = None +sweep_log = deque(maxlen=40) +lock = threading.Lock() + + +def load_token() -> str: + if TOKEN_FILE.exists(): + return TOKEN_FILE.read_text().strip() + TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True) + token = secrets.token_urlsafe(24) + TOKEN_FILE.write_text(token + "\n") + TOKEN_FILE.chmod(0o600) + return token + + +TOKEN = load_token() + + +def publish(text: str) -> None: + """Store item text; called by the watcher and once again on resume.""" + if not ITEM_RE.search(text): + return + with lock: + if text != state["text"]: + state["seq"] += 1 + state["text"] = text + print(f"[bridge] item #{state['seq']} ({len(text)} chars)", flush=True) + + +def watch() -> None: + """Stream clipboard changes as NUL-delimited records from wl-paste.""" + proc = subprocess.Popen( + ["wl-paste", "--watch", "sh", "-c", "cat; printf '\\0'"], + stdout=subprocess.PIPE, + ) + buf = b"" + while True: + chunk = proc.stdout.read1(4096) # read1: do not block for a full buffer + if not chunk: + break + buf += chunk + while b"\0" in buf: + raw, buf = buf.split(b"\0", 1) + if paused: # during a sweep every single copy would import separately + continue + publish(raw.decode("utf-8", "replace").strip()) + + +def read_clipboard() -> str: + r = subprocess.run(["wl-paste", "--no-newline", "--type", "text/plain"], + capture_output=True, text=True) + return r.stdout.strip() if r.returncode == 0 else "" + + +def run_sweep() -> None: + """Run the grid sweep, capturing its output for the status page.""" + global sweep_proc, paused + paused = True + sweep_log.clear() + sweep_log.append("sweep started") + try: + sweep_proc = subprocess.Popen( + ["python3", "-u", SWEEP, "run", "--delay", "4"], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + for line in sweep_proc.stdout: + for part in line.replace("\r", "\n").splitlines(): + if part.strip(): + sweep_log.append(part.strip()) + sweep_proc.wait() + sweep_log.append(f"sweep finished (exit {sweep_proc.returncode})") + except Exception as exc: # noqa: BLE001 - surfaced on the status page + sweep_log.append(f"sweep failed: {exc}") + finally: + sweep_proc = None + paused = False + publish(read_clipboard()) # the batch the sweep just put there + + +STATUS_PAGE = """ +Voyage sweep + +

Voyage sweep

...
+""" + + +class Handler(BaseHTTPRequestHandler): + def _send(self, body, ctype="application/json", code=200): + data = body.encode() if isinstance(body, str) else body + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", ORIGIN) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(data) + + def _authed(self, query): + return secrets.compare_digest(query.get("token", [""])[0], TOKEN) + + def do_GET(self): + parsed = urllib.parse.urlparse(self.path) + query = urllib.parse.parse_qs(parsed.query) + path = parsed.path + + if path == "/clip": + with lock: + self._send(json.dumps(state)) + return + + if path in ("/sweep", "/log", "/pause", "/resume"): + if not self._authed(query): + self._send('{"error":"bad token"}', code=403) + return + + global paused + if path == "/sweep": + if sweep_proc is None: + threading.Thread(target=run_sweep, daemon=True).start() + self._send(STATUS_PAGE, ctype="text/html; charset=utf-8") + elif path == "/log": + self._send(json.dumps({"running": sweep_proc is not None, + "lines": list(sweep_log)})) + elif path == "/pause": + paused = True + self._send('{"paused":true}') + elif path == "/resume": + paused = False + publish(read_clipboard()) + self._send('{"paused":false}') + else: + self.send_error(404) + + def log_message(self, *args): + pass + + +if __name__ == "__main__": + threading.Thread(target=watch, daemon=True).start() + print(f"[bridge] listening on http://{HOST}:{PORT}/clip", flush=True) + print(f"[bridge] sweep bookmark: http://{HOST}:{PORT}/sweep?token={TOKEN}", + flush=True) + ThreadingHTTPServer((HOST, PORT), Handler).serve_forever() diff --git a/bin/voyage-bookmarklet.sh b/bin/voyage-bookmarklet.sh new file mode 100755 index 0000000..981d5bb --- /dev/null +++ b/bin/voyage-bookmarklet.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Print the personal bookmarklet that triggers a grid sweep from the browser. +set -euo pipefail +token_file="${XDG_CONFIG_HOME:-$HOME/.config}/poe-clip-bridge/token" +if [[ ! -f $token_file ]]; then + echo "No token yet - start the bridge once: systemctl --user start poe-clip-bridge.service" >&2 + exit 1 +fi +token=$(cat "$token_file") +echo "Add this as a bookmark (Ctrl+Shift+O -> new bookmark, paste into URL):" +echo +echo "javascript:fetch('http://127.0.0.1:8477/sweep?token=${token}');void 0" +echo +echo "Status page (opens a tab instead of staying on the solver page):" +echo "http://127.0.0.1:8477/sweep?token=${token}" diff --git a/bin/voyage-sweep.py b/bin/voyage-sweep.py new file mode 100755 index 0000000..0825c05 --- /dev/null +++ b/bin/voyage-sweep.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Bulk-import PoE Charts into the Allflame Voyage Solver (Linux/KDE Wayland). + +Linux counterpart of the site's voyage-import.ahk: hovers every cell of the +in-game chart grid, Ctrl+C's it, collects the item texts and puts the whole +batch into the clipboard, where poe-clip-bridge + the Violentmonkey userscript +pick it up and paste it into the solver. + + voyage-sweep.py calibrate # capture the grid corners + voyage-sweep.py run # do the sweep +""" + +import argparse +import json +import os +import pathlib +import re +import subprocess +import sys +import time +import urllib.parse +import urllib.request + +CONFIG = pathlib.Path.home() / ".config" / "voyage-sweep.json" +DEFAULTS = { + "cols": 6, + "rows": 10, + "tl": None, # [x, y] centre of the top-left cell + "br": None, # [x, y] centre of the bottom-right cell + "hover_delay": 0.14, # let the tooltip build before Ctrl+C + "clip_timeout": 0.6, # how long to wait for clipboard content + "poe_window": "Path of Exile", +} +CHART_RE = re.compile(r"^[ \t]*Item Class\s*[::]", re.IGNORECASE | re.MULTILINE) +MAX_STEP = 150 # larger relative jumps get distorted by pointer acceleration +ENV = {**os.environ, "YDOTOOL_SOCKET": f"/run/user/{os.getuid()}/.ydotool_socket"} + + +def run(cmd, **kw): + return subprocess.run(cmd, env=ENV, capture_output=True, text=True, **kw) + + +def load_config(): + cfg = dict(DEFAULTS) + if CONFIG.exists(): + cfg.update(json.loads(CONFIG.read_text())) + return cfg + + +def save_config(cfg): + CONFIG.write_text(json.dumps(cfg, indent=2) + "\n") + + +# ---------- pointer ---------- + +def cursor(): + """Pointer position. Reliable while the pointer is over an XWayland window.""" + out = run(["xdotool", "getmouselocation", "--shell"]).stdout + pos = dict(l.split("=", 1) for l in out.strip().splitlines() if "=" in l) + return int(pos["X"]), int(pos["Y"]) + + +def cursor_kwin(): + """Pointer position straight from KWin. Slow (~0.3s) but always correct, + also while the pointer sits on a Wayland-native window where X sees nothing.""" + js = pathlib.Path(f"/tmp/voyage-probe-{os.getpid()}.js") + js.write_text('print("VOYAGEPROBE " + workspace.cursorPos.x + ","' + ' + workspace.cursorPos.y);\n') + name = f"voyageprobe{os.getpid()}" + since = time.strftime("%Y-%m-%d %H:%M:%S") + sid = run(["qdbus6", "org.kde.KWin", "/Scripting", + "org.kde.kwin.Scripting.loadScript", str(js), name]).stdout.strip() + run(["qdbus6", "org.kde.KWin", f"/Scripting/Script{sid}", + "org.kde.kwin.Script.run"]) + time.sleep(0.25) + log = run(["journalctl", "_COMM=kwin_wayland", "--since", since, + "--no-pager", "-o", "cat"]).stdout + run(["qdbus6", "org.kde.KWin", "/Scripting", + "org.kde.kwin.Scripting.unloadScript", name]) + js.unlink(missing_ok=True) + hits = re.findall(r"VOYAGEPROBE (\d+),(\d+)", log) + if not hits: + raise RuntimeError("KWin cursor probe returned nothing") + return int(hits[-1][0]), int(hits[-1][1]) + + +# Injected motion is not applied 1:1 - this display multiplies it (measured 2.0). +# The factor is learned from the actual movement instead of being hardcoded. +_gain = 1.0 + + +def move_to(x, y, tries=40, coarse=False, tol=1): + """Closed loop: step, re-read, correct, and learn the motion gain.""" + global _gain + read = cursor_kwin if coarse else cursor + for _ in range(tries): + cx, cy = read() + dx, dy = x - cx, y - cy + if abs(dx) <= tol and abs(dy) <= tol: + return True + sx = max(-MAX_STEP, min(MAX_STEP, round(dx / _gain))) + sy = max(-MAX_STEP, min(MAX_STEP, round(dy / _gain))) + run(["ydotool", "mousemove", "-x", str(sx), "-y", str(sy)]) + time.sleep(0.03) + nx, ny = read() + for sent, moved in ((sx, nx - cx), (sy, ny - cy)): + if abs(sent) >= 20 and abs(moved) >= 1: + _gain = max(0.2, min(8.0, 0.7 * _gain + 0.3 * (moved / sent))) + break + return False + + +def ctrl_c(): + run(["ydotool", "key", "29:1", "46:1", "46:0", "29:0"]) # LEFTCTRL + C + + +# ---------- clipboard ---------- + +def clip_arm(token): + """Put a unique marker in the clipboard. + + 'wl-copy --clear' is asynchronous: a poll right after it still reads the + previous owner's data, which made every cell report the first chart again. + Waiting for the marker to disappear is race-free. + """ + subprocess.run(["wl-copy"], input=token, text=True, env=ENV) + + +def clip_read(): + r = run(["wl-paste", "--no-newline", "--type", "text/plain"]) + return r.stdout if r.returncode == 0 else "" + + +def clip_wait(timeout, token): + """Wait until the game replaced our marker with the copied item text.""" + deadline = time.time() + timeout + while time.time() < deadline: + text = clip_read().strip() + if text and text != token: + return text + time.sleep(0.03) + return "" + + +# ---------- grid ---------- + +def poe_geometry(cfg): + """Window origin+size of the PoE window (an XWayland client, so X knows it).""" + wid = run(["xdotool", "search", "--name", cfg["poe_window"]]).stdout.split() + if not wid: + sys.exit(f"No window named '{cfg['poe_window']}' found - is PoE running?") + out = run(["xdotool", "getwindowgeometry", "--shell", wid[-1]]).stdout + g = dict(l.split("=", 1) for l in out.strip().splitlines() if "=" in l) + return int(g["X"]), int(g["Y"]), int(g["WIDTH"]), int(g["HEIGHT"]) + + +def cell_pos(cfg, row, col, origin): + """Grid coordinates are stored relative to the window, so moving the + PoE window does not invalidate the calibration.""" + tlx, tly = cfg["tl"] + brx, bry = cfg["br"] + dx = (brx - tlx) / (cfg["cols"] - 1) if cfg["cols"] > 1 else 0 + dy = (bry - tly) / (cfg["rows"] - 1) if cfg["rows"] > 1 else 0 + return round(origin[0] + tlx + col * dx), round(origin[1] + tly + row * dy) + + +def bridge(action): + """Pause/resume the clipboard bridge over its local HTTP API. + + Not 'systemctl stop': when the sweep is started by the bridge itself + (Firefox bookmark), stopping the unit would kill this process too. + """ + token_file = pathlib.Path.home() / ".config" / "poe-clip-bridge" / "token" + if not token_file.exists(): + return + token = urllib.parse.quote(token_file.read_text().strip()) + try: + urllib.request.urlopen( + f"http://127.0.0.1:8477/{action}?token={token}", timeout=2).read() + except OSError: + pass # bridge not running - sweep still works, just no auto-import + + +def countdown(msg, seconds=5): + print(msg) + for i in range(seconds, 0, -1): + print(f" {i} ...", end="\r", flush=True) + time.sleep(1) + print(" " * 20, end="\r") + + +# ---------- commands ---------- + +def cmd_calibrate(cfg, args): + cfg["cols"], cfg["rows"] = args.cols, args.rows + ox, oy = poe_geometry(cfg)[:2] + print(f"Grid: {cfg['cols']} columns x {cfg['rows']} rows. Window at {ox},{oy}.") + countdown("\nPut the mouse on the CENTRE of the TOP-LEFT cell.") + cx, cy = cursor() + cfg["tl"] = [cx - ox, cy - oy] + print(f" top-left = {cfg['tl']} (window-relative)") + countdown("\nNow the CENTRE of the BOTTOM-RIGHT cell (even if empty).") + cx, cy = cursor() + cfg["br"] = [cx - ox, cy - oy] + print(f" bottom-right = {cfg['br']} (window-relative)") + if cfg["tl"] == cfg["br"]: + sys.exit("Both corners are identical - nothing saved.") + save_config(cfg) + print(f"\nSaved to {CONFIG}. Cell spacing: " + f"{(cfg['br'][0] - cfg['tl'][0]) / max(1, cfg['cols'] - 1):.1f} x " + f"{(cfg['br'][1] - cfg['tl'][1]) / max(1, cfg['rows'] - 1):.1f} px") + + +def preflight(): + sock = pathlib.Path(ENV["YDOTOOL_SOCKET"]) + if not sock.exists(): + sys.exit("ydotoold is not running (no %s).\nStart it with: " + "systemctl --user start ydotool.service" % sock) + + +def cmd_run(cfg, args): + if not cfg["tl"] or not cfg["br"]: + sys.exit("Not calibrated yet - run: voyage-sweep.py calibrate") + preflight() + rows = args.rows or cfg["rows"] + + ox, oy, ww, wh = poe_geometry(cfg) + subprocess.run(["kdotool", "search", "--name", cfg["poe_window"], + "windowactivate"], capture_output=True) + countdown("\nSweeping - keep hands off mouse and keyboard.", args.delay) + + # The fast X11 pointer read only works while the pointer is over the game, + # so get it in there first using KWin's own (slower) cursor position. + if not move_to(ox + ww // 2, oy + wh // 2, coarse=True, tol=25): + sys.exit("Could not move the pointer into the PoE window.") + + bridge("pause") # otherwise every single copy gets imported separately + charts, skipped, first, identical = [], 0, None, True + try: + for row in range(rows): + for col in range(cfg["cols"]): + x, y = cell_pos(cfg, row, col, (ox, oy)) + token = f"voyage-sweep-{row}-{col}-{time.monotonic_ns()}" + clip_arm(token) + if not move_to(x, y): + print(f" cell {row + 1}/{col + 1}: pointer did not reach {x},{y}") + skipped += 1 + continue + time.sleep(cfg["hover_delay"]) + ctrl_c() + text = clip_wait(cfg["clip_timeout"], token) + if not text or not CHART_RE.search(text): + skipped += 1 + continue + charts.append(text) + if first is None: + first = text + elif text != first: + identical = False + print(f" charts {len(charts)} skipped {skipped}", end="\r", flush=True) + except KeyboardInterrupt: + print("\nAborted.") + finally: + bridge("resume") + + print(" " * 40, end="\r") + if len(charts) >= 5 and identical: + sys.exit("\nEvery cell copied the SAME chart - calibration is off " + "(or the PoE window moved). Re-run: voyage-sweep.py calibrate") + if not charts: + sys.exit(f"\nNothing copied ({skipped} empty/non-chart cells).") + + blob = "\n".join(charts) + subprocess.run(["wl-copy"], input=blob, text=True) + print(f"\n{len(charts)} charts copied, {skipped} cells empty/non-chart.") + print("Batch is in the clipboard - the userscript imports it into the solver.") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + + cal = sub.add_parser("calibrate", help="capture the grid corners") + cal.add_argument("--cols", type=int, default=DEFAULTS["cols"]) + cal.add_argument("--rows", type=int, default=DEFAULTS["rows"]) + + sweep = sub.add_parser("run", help="sweep the grid and import") + sweep.add_argument("--delay", type=int, default=5, + help="seconds before the sweep starts (default 5)") + sweep.add_argument("--rows", type=int, help="sweep only the first N rows") + + args = ap.parse_args() + cfg = load_config() + {"calibrate": cmd_calibrate, "run": cmd_run}[args.cmd](cfg, args) + + +if __name__ == "__main__": + main() diff --git a/docs/wayland-pointer-notes.md b/docs/wayland-pointer-notes.md new file mode 100644 index 0000000..027c13a --- /dev/null +++ b/docs/wayland-pointer-notes.md @@ -0,0 +1,107 @@ +# Notes on driving the pointer under KDE Wayland + +Everything here was measured on KDE Plasma 6 / `kwin_wayland`, multi-monitor, +game running as an XWayland client through Proton. If you are automating input +on Wayland, these four are what will bite you. + +## 1. `ydotool mousemove --absolute` is useless on multi-monitor + +Absolute positioning gets clamped into whatever output the pointer currently +sits on, so the mapping is neither linear nor monotonic: + +| requested | landed on | +|---|---| +| `0,0` | `302,1081` | +| `1000,500` | `1855,747` | +| `5000,2500` | `1418,1411` | +| `6000,3000` | `301,1080` | + +`xdotool mousemove` (XWarpPointer) does not work either — KWin ignores warp +requests from clients that do not own the pointer focus. + +**Use relative motion.** + +## 2. Injected relative motion is not applied 1:1 + +On one of the test monitors every injected delta came out multiplied by exactly +2.00 — not acceleration, a constant factor: + +``` +sent=-25,-96 moved=-50,-192 ratio=2.00,2.00 +sent=+24,+99 moved=+48,+198 ratio=2.00,2.00 +``` + +A naive "move by the remaining error" loop therefore oscillates forever around +the target, roughly ±100 px, and never converges. + +**Fix:** learn the factor at runtime instead of hardcoding it. Send +`error / gain`, measure what actually happened, update `gain` with an +exponential moving average. Converges in a handful of iterations regardless of +scale factor or acceleration profile — see `move_to()` in `bin/voyage-sweep.py`. + +Large single deltas additionally get distorted by pointer acceleration, so cap +each step (150 px works well). + +## 3. `xdotool getmouselocation` freezes outside XWayland surfaces + +X only learns the pointer position while the pointer is over an XWayland +surface. Anywhere else it keeps returning a stale value — for hours, with no +error: + +``` +KWin says: 5102,2532 +xdotool says: 4300,2662 # stale, and stays that way +``` + +That silently breaks any control loop that starts outside the game window. + +**Fix:** two-phase. Coarse approach with KWin's own cursor position, which is +always correct, then fine control with `xdotool` once the pointer is inside the +(XWayland) game window, where it is live and fast. + +Reading KWin's cursor position without a compositor plugin: + +```bash +echo 'print("PROBE " + workspace.cursorPos.x + "," + workspace.cursorPos.y);' > /tmp/probe.js +id=$(qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.loadScript /tmp/probe.js probe) +qdbus6 org.kde.KWin /Scripting/Script$id org.kde.kwin.Script.run +journalctl _COMM=kwin_wayland -n 5 -o cat | grep PROBE +qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.unloadScript probe +``` + +Roughly 300 ms per reading — fine for a coarse approach, too slow for a loop. + +## 4. `wl-copy --clear` is asynchronous + +Clearing the clipboard and then polling for "something appeared" is a race: the +poll still reads the *previous* owner's data. In a sweep this looks like every +cell copying the same item — 48 identical charts, all of them the first one. + +**Fix:** write a unique sentinel token into the clipboard, then wait for it to be +replaced: + +```python +token = f"sweep-{row}-{col}-{time.monotonic_ns()}" +subprocess.run(["wl-copy"], input=token, text=True) +send_ctrl_c() +# ... poll until clipboard != token +``` + +## Bonus: do not home the pointer into a screen corner + +The obvious bootstrap for "get to a known position" — send a huge negative delta +and let it clamp in the corner — triggers KDE's hot corners. Ours opened the +application overview mid-sweep. Approach a known target directly instead. + +## Bonus: measure the grid from a screenshot, do not eyeball it + +Hand-calibrating cell corners is imprecise. Take a window screenshot, run a +brightness projection per axis to get the cell pitch, then draw the computed +centres back onto the screenshot and look at it: + +```bash +spectacle -b -n -a -o window.png +``` + +That is how the 66.8 × 66.6 px pitch in this repo was derived — and how a wrong +row count (which silently changes the computed pitch) was caught. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..eba2932 --- /dev/null +++ b/install.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Install the bridge, the sweep script and the systemd user unit. +set -euo pipefail +cd "$(dirname "$0")" + +bindir="$HOME/.local/bin" +unitdir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" +mkdir -p "$bindir" "$unitdir" + +install -m 755 bin/poe-clip-bridge.py bin/voyage-sweep.py bin/voyage-bookmarklet.sh "$bindir/" +install -m 644 systemd/poe-clip-bridge.service "$unitdir/" + +systemctl --user daemon-reload +systemctl --user enable --now poe-clip-bridge.service + +echo +echo "Installed to $bindir." +systemctl --user is-active poe-clip-bridge.service >/dev/null \ + && echo "Bridge is running on http://127.0.0.1:8477/clip" + +cat <<'NEXT' + +Next steps: + 1. systemctl --user enable --now ydotool.service + 2. Install userscript/allflame-autopaste.user.js in Violentmonkey + 3. Open the Voyage panel in game, then: voyage-sweep.py calibrate --cols 6 --rows 10 + 4. voyage-sweep.py run (or: voyage-bookmarklet.sh for the browser trigger) +NEXT diff --git a/systemd/poe-clip-bridge.service b/systemd/poe-clip-bridge.service new file mode 100644 index 0000000..8d0848a --- /dev/null +++ b/systemd/poe-clip-bridge.service @@ -0,0 +1,12 @@ +[Unit] +Description=PoE clipboard bridge for the Allflame Voyage Solver +PartOf=graphical-session.target +After=graphical-session.target + +[Service] +ExecStart=%h/.local/bin/poe-clip-bridge.py +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=graphical-session.target diff --git a/userscript/allflame-autopaste.user.js b/userscript/allflame-autopaste.user.js new file mode 100644 index 0000000..f68ec31 --- /dev/null +++ b/userscript/allflame-autopaste.user.js @@ -0,0 +1,67 @@ +// ==UserScript== +// @name Allflame Voyage Solver - clipboard auto-paste +// @namespace templis +// @version 1.0 +// @description Polls the local clipboard bridge and feeds copied PoE items into the solver without focusing the browser. +// @match https://one-more-map.github.io/allflame-voyage-solver/* +// @grant GM_xmlhttpRequest +// @connect 127.0.0.1 +// @run-at document-idle +// ==/UserScript== + +(function () { + "use strict"; + + const ENDPOINT = "http://127.0.0.1:8477/clip"; + const INTERVAL = 400; + let lastSeq = -1; + + const toast = document.createElement("div"); + toast.style.cssText = + "position:fixed;bottom:12px;right:12px;z-index:99999;padding:6px 10px;" + + "border-radius:6px;font:12px/1.4 monospace;background:#222;color:#8f8;" + + "opacity:0;transition:opacity .2s;pointer-events:none"; + document.body.appendChild(toast); + + function flash(msg, color) { + toast.textContent = msg; + toast.style.color = color; + toast.style.opacity = "1"; + setTimeout(() => (toast.style.opacity = "0"), 1500); + } + + function feed(text) { + const dt = new DataTransfer(); + dt.setData("text/plain", text); + document.dispatchEvent( + new ClipboardEvent("paste", { + clipboardData: dt, + bubbles: true, + cancelable: true, + }) + ); + flash("item pasted", "#8f8"); + } + + function poll() { + GM_xmlhttpRequest({ + method: "GET", + url: ENDPOINT, + onload: (r) => { + try { + const data = JSON.parse(r.responseText); + if (data.seq !== lastSeq) { + lastSeq = data.seq; + if (data.text) feed(data.text); // also feeds the last item on page load + } + } catch (e) { + /* ignore malformed response */ + } + }, + onerror: () => flash("bridge offline", "#f88"), + }); + } + + setInterval(poll, INTERVAL); + poll(); +})();