Files
BT411/scratchpad/clickbank.py
T
arcattackandClaude Opus 5 ab91b5e7c1 clickbank: never target a degenerate window (the 0x0 Plasma window ate all 144 clicks)
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>
2026-07-26 14:51:21 -05:00

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())