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 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 21:39:19 +02:00
co-authored by Claude Opus 5
commit e65f9db3ae
9 changed files with 928 additions and 0 deletions
+21
View File
@@ -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.
+188
View File
@@ -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 `<img src="http://127.0.0.1:8477/sweep">`
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).
+191
View File
@@ -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 = """<!doctype html><meta charset=utf-8>
<title>Voyage sweep</title>
<style>body{font:14px/1.5 monospace;background:#151515;color:#ddd;margin:2rem}
h1{font-size:1rem;color:#8f8}pre{white-space:pre-wrap}</style>
<h1>Voyage sweep</h1><pre id=log>...</pre>
<script>
const t = new URLSearchParams(location.search).get('token');
setInterval(async () => {
const r = await fetch('/log?token=' + encodeURIComponent(t));
const d = await r.json();
document.getElementById('log').textContent =
d.lines.join('\\n') + (d.running ? '\\n\\n(laeuft...)' : '\\n\\n(fertig)');
}, 1000);
</script>"""
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()
+15
View File
@@ -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}"
+299
View File
@@ -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()
+107
View File
@@ -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.
Executable
+28
View File
@@ -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
+12
View File
@@ -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
+67
View File
@@ -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();
})();