The picker matched any visible window whose title contains the substring and took windows[0] -- which for a live game is 'BattleTech - Plasma', a 0x0-client window. Every posted click landed in it: 144 clicks, zero dispatches, and what looked exactly like the click/render alignment regression being checked for. Windows with a client smaller than 50x50 are skipped now; verified by re-running the full 72-button pass with the broad 'BattleTech' title straight through to 72/72 dispatches. (Found during the post-merge alignment regression check: boot geometry, a mid-session resize to an odd 1120x640 letterbox, and a minimize/restore cycle -- 72/72 dispatched in every round, 288 clicks total, zero Gitea #56 tripwire lines. The merge did NOT regress click-vs-render alignment.) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
170 lines
6.1 KiB
Python
170 lines
6.1 KiB
Python
"""Click every RIO button the layout claims, and check each one dispatched.
|
|
|
|
Reads a BT_RIOBANK_LOG=1 dump for the rects, posts a real WM_LBUTTONDOWN/UP at
|
|
each button's centre, then compares the addresses the game LOGGED against the
|
|
addresses the layout placed. This is the end-to-end check the geometry dump
|
|
alone cannot give: it proves the hit test, the coordinate mapping and the RIO
|
|
dispatch agree with the rects.
|
|
|
|
python clickbank.py <log> <window-title-substring> [--mode surround|exploded]
|
|
|
|
surround: one window, buttons are in backbuffer canvas space (== client space
|
|
for a windowed run), so the centres go straight to the main window.
|
|
exploded: one window per bank, each with its own client space; the bank tag
|
|
names the window.
|
|
"""
|
|
import ctypes
|
|
import re
|
|
import sys
|
|
import time
|
|
from ctypes import wintypes
|
|
|
|
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
|
|
|
WM_LBUTTONDOWN = 0x0201
|
|
WM_LBUTTONUP = 0x0202
|
|
MK_LBUTTON = 0x0001
|
|
|
|
BANK_RE = re.compile(r"\[riobank\] (.+?) bounds=")
|
|
BTN_RE = re.compile(r"\[riobank\] addr=0x([0-9a-f]+) rect=\((-?\d+),(-?\d+),(-?\d+),(-?\d+)\)")
|
|
|
|
|
|
def parse(path):
|
|
banks, current, done = {}, None, set()
|
|
with open(path, "r", errors="replace") as handle:
|
|
for line in handle:
|
|
match = BANK_RE.search(line)
|
|
if match:
|
|
current = match.group(1)
|
|
if current in done:
|
|
current = None
|
|
else:
|
|
done.add(current)
|
|
banks[current] = []
|
|
continue
|
|
match = BTN_RE.search(line)
|
|
if match and current is not None:
|
|
addr = int(match.group(1), 16)
|
|
x, y, w, h = (int(match.group(i)) for i in range(2, 6))
|
|
banks[current].append((addr, x + w // 2, y + h // 2))
|
|
return banks
|
|
|
|
|
|
def find_windows(substring):
|
|
found = []
|
|
|
|
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
|
def callback(hwnd, _):
|
|
length = user32.GetWindowTextLengthW(hwnd)
|
|
if length:
|
|
buffer = ctypes.create_unicode_buffer(length + 1)
|
|
user32.GetWindowTextW(hwnd, buffer, length + 1)
|
|
if substring.lower() in buffer.value.lower() and user32.IsWindowVisible(hwnd):
|
|
# Skip zero/degenerate clients: the game also owns a
|
|
# "BattleTech - Plasma" window with a 0x0 client, and taking
|
|
# windows[0] blindly posted every click into it -- 144 clicks,
|
|
# zero dispatches, and a scary-looking false regression
|
|
# (2026-07-26). A window you cannot click in is not a target.
|
|
r = RECT()
|
|
user32.GetClientRect(hwnd, ctypes.byref(r))
|
|
if r.right > 50 and r.bottom > 50:
|
|
found.append((hwnd, buffer.value))
|
|
return True
|
|
|
|
user32.EnumWindows(callback, 0)
|
|
return found
|
|
|
|
|
|
class RECT(ctypes.Structure):
|
|
_fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long),
|
|
("right", ctypes.c_long), ("bottom", ctypes.c_long)]
|
|
|
|
|
|
def canvas_size(path):
|
|
"""The cockpit canvas from the boot line, e.g. '[cockpit] view 900x500 canvas 1452x1059'."""
|
|
with open(path, "r", errors="replace") as handle:
|
|
for line in handle:
|
|
match = re.search(r"\[cockpit\] view \d+x\d+ canvas (\d+)x(\d+)", line)
|
|
if match:
|
|
return int(match.group(1)), int(match.group(2))
|
|
return None
|
|
|
|
|
|
def fit_rect(client_w, client_h, canvas_w, canvas_h):
|
|
"""Same uniform-scale centred fit the game computes (BTCockpitFitRect)."""
|
|
w = client_w
|
|
h = client_w * canvas_h // canvas_w
|
|
if h > client_h:
|
|
h = client_h
|
|
w = client_h * canvas_w // canvas_h
|
|
return ((client_w - w) // 2, (client_h - h) // 2, max(w, 1), max(h, 1))
|
|
|
|
|
|
def click(hwnd, x, y):
|
|
lparam = (y << 16) | (x & 0xFFFF)
|
|
user32.PostMessageW(hwnd, WM_LBUTTONDOWN, MK_LBUTTON, lparam)
|
|
time.sleep(0.02)
|
|
user32.PostMessageW(hwnd, WM_LBUTTONUP, 0, lparam)
|
|
time.sleep(0.02)
|
|
|
|
|
|
def main():
|
|
log = sys.argv[1]
|
|
title = sys.argv[2]
|
|
mode = "surround"
|
|
if "--mode" in sys.argv:
|
|
mode = sys.argv[sys.argv.index("--mode") + 1]
|
|
|
|
banks = parse(log)
|
|
if not banks:
|
|
print("no [riobank] lines in", log)
|
|
return 1
|
|
|
|
# exploded: each bank is its OWN window with its own caption, so match
|
|
# across every visible window rather than filtering by one title
|
|
windows = find_windows("" if mode == "exploded" else title)
|
|
if not windows:
|
|
print("no window matching", title)
|
|
return 1
|
|
|
|
# surround: the dump is in CANVAS space; if the window has been resized the
|
|
# canvas is letterboxed inside the client, so map through the same fit the
|
|
# game uses or every click lands off its button
|
|
transform = None
|
|
if mode == "surround":
|
|
canvas = canvas_size(log)
|
|
client = RECT()
|
|
user32.GetClientRect(windows[0][0], ctypes.byref(client))
|
|
if canvas and client.right > 0 and client.bottom > 0:
|
|
ox, oy, fw, fh = fit_rect(client.right, client.bottom, canvas[0], canvas[1])
|
|
if (fw, fh) != canvas:
|
|
transform = (ox, oy, fw, fh, canvas[0], canvas[1])
|
|
print("client %dx%d, canvas %dx%d -> fit %dx%d at (%d,%d)"
|
|
% (client.right, client.bottom, canvas[0], canvas[1], fw, fh, ox, oy))
|
|
|
|
posted = set()
|
|
for tag, buttons in banks.items():
|
|
if mode == "surround":
|
|
target = windows[0][0]
|
|
else:
|
|
# the bank tag is the window caption
|
|
match = [h for h, caption in windows if caption.strip() == tag.strip()]
|
|
if not match:
|
|
print(" no window for bank %r -- skipped" % tag)
|
|
continue
|
|
target = match[0]
|
|
for addr, cx, cy in buttons:
|
|
if transform is not None:
|
|
ox, oy, fw, fh, cw, ch = transform
|
|
cx = ox + cx * fw // cw
|
|
cy = oy + cy * fh // ch
|
|
click(target, cx, cy)
|
|
posted.add(addr)
|
|
print("posted clicks for %d addresses" % len(posted))
|
|
print(" ".join("0x%02x" % a for a in sorted(posted)))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|