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:
@@ -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.
|
||||
@@ -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).
|
||||
Executable
+191
@@ -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()
|
||||
Executable
+15
@@ -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}"
|
||||
Executable
+299
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
})();
|
||||
Reference in New Issue
Block a user