""" Interactive coordinate calibration for TFC forge macro. Run from a terminal: python setup.py Flow: 1. Without -f/--full: requires an existing coords file (default coords.json). Only tl, br, width, and height are updated for the anvil and recipe sections; all other keys are preserved. If no coords file exists yet, run with -f/--full first. 2. With -f/--full: full calibration from scratch (empty maps). 3. Anvil GUI: click top-left, then bottom-right of the bounding box (defines tl, br, width, height). 4. With -f/--full: click only the first main inventory slot (i1); i2–i27 and h1–h9 are filled from grid steps (see SLOT_STEP_X / INV_ROW_STEP_Y below). 5. Recipe GUI: same top-left / bottom-right for the recipe panel. 6. With -f/--full: click the first recipe slot (r1); r2–r18 are filled from grid steps; scroll buttons rlb/rrb are still clicked manually. 7. Writes coords.json. With -f/--full, every named offset is recorded. Press Esc to abort at any time. """ from __future__ import annotations import argparse import copy import json import sys import threading from pathlib import Path from queue import Queue from typing import Any from pynput import keyboard, mouse DEFAULT_OUTPUT = Path(__file__).resolve().parent / "coords.json" # Normalized grid steps (fraction of GUI width / height). One row = 9 slots left-to-right; # inventory is 4 rows (i1–i27 then hotbar h1–h9); recipe panel is 2 rows (r1–r18). SLOT_STEP_X = 1.0 / 9.5 INV_ROW_STEP_Y = 1.0 / 11.0 RECIPE_ROW_STEP_Y = 1.0 / 8.35 def _empty_anvil_map() -> dict[str, Any]: m: dict[str, Any] = { "tl": (0, 0), "br": (0, 0), "width": 0, "height": 0, "plans": (0.0, 0.0), "weld": (0.0, 0.0), "input1": (0.0, 0.0), "input2": (0.0, 0.0), "L": (0.0, 0.0), "M": (0.0, 0.0), "D": (0.0, 0.0), "P": (0.0, 0.0), "G": (0.0, 0.0), "Y": (0.0, 0.0), "O": (0.0, 0.0), "R": (0.0, 0.0), } for i in range(1, 28): m[f"i{i}"] = (0.0, 0.0) for i in range(1, 10): m[f"h{i}"] = (0.0, 0.0) return m def _empty_recipe_map() -> dict[str, Any]: m: dict[str, Any] = { "tl": (0, 0), "br": (0, 0), "width": 0, "height": 0, "rlb": (0.0, 0.0), "rrb": (0.0, 0.0), } for i in range(1, 19): m[f"r{i}"] = (0.0, 0.0) return m # Normalized offsets relative to anvil GUI box (after tl/br/width/height are set). ANVIL_UI_OFFSET_KEYS = [ "weld", "input1", "input2", "L", "M", "D", "P", "G", "Y", "O", "R", ] RECIPE_SCROLL_KEYS = ["rrb","rlb"] def fill_anvil_inventory_from_i1(coord_map: dict[str, Any]) -> None: """Fill i1–i27 and h1–h9 from i1's normalized offset and SLOT_STEP_X / INV_ROW_STEP_Y.""" ox0, oy0 = coord_map["i1"] for k in range(1, 28): idx = k - 1 row, col = idx // 9, idx % 9 coord_map[f"i{k}"] = (ox0 + col * SLOT_STEP_X, oy0 + row * INV_ROW_STEP_Y) for k in range(1, 10): col = k - 1 coord_map[f"h{k}"] = (ox0 + col * SLOT_STEP_X, oy0 + 3 * INV_ROW_STEP_Y) def fill_recipe_slots_from_r1(coord_map: dict[str, Any]) -> None: """Fill r1–r18 from r1's normalized offset and SLOT_STEP_X / RECIPE_ROW_STEP_Y.""" ox0, oy0 = coord_map["r1"] for k in range(1, 19): idx = k - 1 row, col = idx // 9, idx % 9 coord_map[f"r{k}"] = (ox0 + col * SLOT_STEP_X, oy0 + row * RECIPE_ROW_STEP_Y) _ANVIL_UI_LABELS: dict[str, str] = { "weld": "weld button", "input1": "first anvil input slot", "input2": "second anvil input slot", "L": "Light Hit", "M": "Medium Hit", "D": "Heavy Hit", "P": "Draw", "G": "Punch", "Y": "Bend", "O": "Upset", "R": "Shrink", } def _recipe_offset_label(key: str) -> str: if key == "rlb": return "recipe panel left scroll / page button" if key == "rrb": return "recipe panel right scroll / page button" return f"recipe slot {key}" def apply_top_left_bottom_right( coord_map: dict[str, Any], tl_corner: tuple[int, int], br_corner: tuple[int, int] ) -> None: """Set tl, br, width, height from top-left and bottom-right corners.""" if br_corner[0] <= tl_corner[0] or br_corner[1] <= tl_corner[1]: raise ValueError( f"Invalid box: top-left {tl_corner}, bottom-right {br_corner}. " "Bottom-right should be farther right (larger x) and lower down (larger y) than top-left." ) coord_map["tl"] = (tl_corner[0], tl_corner[1]) coord_map["br"] = (br_corner[0], br_corner[1]) coord_map["width"] = int(br_corner[0] - tl_corner[0]) coord_map["height"] = int(br_corner[1] - tl_corner[1]) def set_offset(coord_map: dict[str, Any], coord_name: str, x: int, y: int) -> None: """Store normalized offset relative to tl and box size (matches main.get_coord).""" if coord_name in ("tl", "br"): coord_map[coord_name] = (x, y) return w = coord_map["width"] h = coord_map["height"] if w <= 0 or h <= 0: raise ValueError("width and height must be set and positive before recording offsets") tl = coord_map["tl"] ox = (x - tl[0]) / w oy = (y - tl[1]) / h coord_map[coord_name] = (ox, oy) def _serialize_value(v: Any) -> Any: if isinstance(v, tuple): return [_serialize_value(x) for x in v] if isinstance(v, float): return round(v, 8) return v def coord_map_to_json_dict(coord_map: dict[str, Any]) -> dict[str, Any]: return {k: _serialize_value(v) for k, v in coord_map.items()} def load_existing_coord_maps(path: Path) -> tuple[dict[str, Any], dict[str, Any]]: """Load anvil and recipe maps from JSON for rectangle-only updates. Deep-copied for mutation.""" try: raw = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: raise ValueError(f"Could not read coords file {path}: {e}") from e if not isinstance(raw, dict) or "anvil" not in raw or "recipe" not in raw: raise ValueError(f"{path} must be a JSON object with 'anvil' and 'recipe' keys") anvil = raw["anvil"] recipe = raw["recipe"] if not isinstance(anvil, dict) or not isinstance(recipe, dict): raise ValueError("'anvil' and 'recipe' must be JSON objects") return copy.deepcopy(anvil), copy.deepcopy(recipe) class _ClickSession: def __init__(self) -> None: self._q: Queue[tuple[int, int] | None] = Queue() self._abort = threading.Event() def on_click(self, x: float, y: float, button: mouse.Button, pressed: bool) -> None: if not pressed or button != mouse.Button.left or self._abort.is_set(): return self._q.put((int(round(x)), int(round(y)))) def on_release(self, key: keyboard.Key | keyboard.KeyCode) -> bool | None: if key == keyboard.Key.esc: self._abort.set() self._q.put(None) return False return None def wait_click(self, prompt: str) -> tuple[int, int]: print(prompt, flush=True) item = self._q.get() if item is None: raise SystemExit("Aborted (Esc).") return item def run_setup(output_path: Path, full: bool) -> None: if full: anvil = _empty_anvil_map() recipe = _empty_recipe_map() else: if not output_path.is_file(): raise SystemExit( f"No coords file at {output_path}. Run with -f/--full once to record all offsets, " "then use this mode to refresh only GUI rectangles after resolution or UI scale changes.\n" f"Example: python {Path(__file__).name} -f" ) anvil, recipe = load_existing_coord_maps(output_path) session = _ClickSession() mouse_listener = mouse.Listener(on_click=session.on_click) mouse_listener.start() try: with keyboard.Listener(on_release=session.on_release) as kbd_listener: intro_anvil = ( "=== Anvil GUI ===\n" "Open the anvil / forge screen. You will define the full GUI outer rectangle" + (" then every clickable offset.\n" if full else ".\n") ) print(intro_anvil, flush=True) tl_pt = session.wait_click("1/2 Click the TOP-LEFT corner of the anvil GUI (outer box).") br_pt = session.wait_click("2/2 Click the BOTTOM-RIGHT corner of the same GUI box.") apply_top_left_bottom_right(anvil, tl_pt, br_pt) print( f" Box: tl={anvil['tl']} br={anvil['br']} size={anvil['width']}x{anvil['height']}\n", flush=True, ) if full: ui_total = len(ANVIL_UI_OFFSET_KEYS) for n, key in enumerate(ANVIL_UI_OFFSET_KEYS, start=1): label = _ANVIL_UI_LABELS.get(key, key) xy = session.wait_click( f"Anvil UI {n}/{ui_total} ({key}): click the center of the {label}." ) set_offset(anvil, key, xy[0], xy[1]) xy_i1 = session.wait_click( "Inventory: click the center of slot i1 (top-left of the 9×3 main grid). " "i2–i27 and h1–h9 are filled using horizontal step 1/9.5 and vertical 1/11 per row (4 rows total including hotbar)." ) set_offset(anvil, "i1", xy_i1[0], xy_i1[1]) fill_anvil_inventory_from_i1(anvil) session.wait_click("Pickup your ingot to move to the second input slot.") session.wait_click("Move the ingot to the second input slot.") plansclick = session.wait_click("Click the PLAN button of the anvil GUI box.") set_offset(anvil, "plans", plansclick[0], plansclick[1]) print( "\n=== Recipe GUI ===\n" "Open the recipe selector panel (or the screen where it appears). Same corner convention.\n", flush=True, ) if not full: session.wait_click("Click the OPEN button of the recipe GUI box.") rtl = session.wait_click("1/2 Click the TOP-LEFT corner of the recipe GUI box.") rbr = session.wait_click("2/2 Click the BOTTOM-RIGHT corner of the recipe GUI box.") apply_top_left_bottom_right(recipe, rtl, rbr) print( f" Box: tl={recipe['tl']} br={recipe['br']} size={recipe['width']}x{recipe['height']}\n", flush=True, ) if full: for n, key in enumerate(RECIPE_SCROLL_KEYS, start=1): xy = session.wait_click( f"Recipe scroll {n}/{len(RECIPE_SCROLL_KEYS)} ({key}): " f"click the center of the {_recipe_offset_label(key)}." ) set_offset(recipe, key, xy[0], xy[1]) xy_r1 = session.wait_click( "Recipe: click the center of slot r1 (top-left of the recipe grid). " "r2–r18 use horizontal step 1/9.5 and vertical 1/8.35 per row." ) set_offset(recipe, "r1", xy_r1[0], xy_r1[1]) fill_recipe_slots_from_r1(recipe) payload = { "anvil": coord_map_to_json_dict(anvil), "recipe": coord_map_to_json_dict(recipe), } output_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") print(f"Wrote {output_path}", flush=True) finally: mouse_listener.stop() def main() -> None: parser = argparse.ArgumentParser( description="Interactive coordinate calibration for TFC forge macro.", ) parser.add_argument( "-f", "--full", action="store_true", help=( "Record every offset from scratch: anvil UI (plans, weld, inputs, forge steps), i1 plus derived " "inventory/hotbar grid, and recipe UI (r1 plus derived r2–r18, then rlb/rrb). " "Required the first time (no coords file). Without -f, an existing coords file is updated: only " "anvil and recipe tl, br, width, and height." ), ) parser.add_argument( "-o", "--output", type=Path, default=DEFAULT_OUTPUT, help=f"Path to output coords.json (default: {DEFAULT_OUTPUT}).", ) args = parser.parse_args() try: run_setup(args.output, args.full) except SystemExit as e: print(str(e), flush=True) raise except ValueError as e: print(f"Error: {e}", flush=True) sys.exit(1) # Backwards-compatible names for any imports anvil_coord_map = _empty_anvil_map() recipe_coord_map = _empty_recipe_map() set_coord = set_offset if __name__ == "__main__": main()