2 Commits

Author SHA1 Message Date
Nayan Sawyer
6c1bfbf0c4 remove tfc forge macro 2026-04-04 16:48:49 -04:00
Nayan Sawyer
0e455a2e40 add gitignore 2026-04-04 16:48:34 -04:00
13 changed files with 607 additions and 1269 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
__pycache__/ __pycache__/
.venv/

View File

@@ -1 +0,0 @@
3.12

397
README.md
View File

@@ -1,392 +1,15 @@
# TFC Forge Macro # public-python
This repository contains all of my public python program releases
`tfc-forge-macro` is a small desktop automation tool for `TerraFirmaCraft` forging. March 22 2026
It records the screen coordinates of the anvil and recipe UIs, then replays mouse clicks
against named slots and buttons so you can run repeatable forging sequences.
## Table of Contents terrafirmagreg_bloomery.py Release v1.0.0
A calculator for optimizing iron dust to charcoal ratios to minimize lost iron in the bloomery
- [What It Does](#what-it-does) Sep 19 2019
- [Requirements](#requirements)
- [Quick Start](#quick-start)
- [Installation (details)](#installation-details)
- [Coordinate Setup](#coordinate-setup)
- [Running the Macro](#running-the-macro)
- [Running the macro through Macro Deck](#running-the-macro-through-macro-deck)
- [Shift-Click Support](#shift-click-support)
- [Using a Sequence File](#using-a-sequence-file)
- [Command Reference](#command-reference)
- [How `coords.json` Works](#how-coordsjson-works)
- [Safety and Usage Notes](#safety-and-usage-notes)
- [Troubleshooting](#troubleshooting)
- [Recommended Workflow](#recommended-workflow)
- [TODO](#todo)
## What It Does mental_math.py Release v1.0.0
Mental math training program utilizing the command line interface
The macro works by: conversions.py Beta v0.1.0
A command line temperature conversion calculator
1. Recording the outer rectangle of the anvil and recipe boxes.
2. Storing button and slot positions as normalized offsets inside those rectangles.
3. Moving the mouse to named targets such as `L`, `M`, `D`, `R`, `i1`, or `r1`.
4. Left-clicking each target in order, with configurable timing between clicks.
Because the coordinates are normalized, the same `coords.json` can usually survive small
position changes as long as the UI layout stays consistent. If your resolution or UI scale
changes, you can refresh the saved rectangles without fully recalibrating everything.
## Requirements
- Python `3.12+`
- `pynput`
- A desktop session where Python is allowed to control the mouse and keyboard
- The TerraFirmaCraft anvil UI open and visible, and an ingot with more than one page worth of recipes (like wrought iron) when you run the setup.py script
- The TerraFirmaCraft anvil UI open and visible when you run the macro
- [uv](https://docs.astral.sh/uv/getting-started/installation/) installed if you want to use it to install the dependencies and run the scripts
## Quick Start
1. [Install uv](https://docs.astral.sh/uv/getting-started/installation/) (a rust based python package manager)
2. Clone this repository
3. Install the dependencies using uv
```powershell
uv sync
```
2. Open TerraFirmaCraft and open the anvil UI with an ingot with more than one page worth of recipes (like wrought iron) in your inventory (not in the top left inventory slot).
3. Run a full calibration once:
```powershell
python setup.py -f
```
4. Test the macro:
```powershell
python main.py -s 2000
```
5. Once you confirm the clicks land correctly, run longer sequences directly or from a file. (The `-s 2000` is a 2 second delay before the macro starts, you can remove it if you want the macro to start immediately).
## Installation (details)
### Option 1: Use `uv`
From the `tfc-forge-macro` folder:
```powershell
uv sync
```
Then run commands with:
```powershell
uv run python setup.py --help
uv run python main.py --help
```
### Option 2: Use a normal virtual environment
```powershell
python -m venv .venv
.\.venv\Scripts\activate
pip install pynput
```
Then run:
```powershell
python setup.py --help
python main.py --help
```
## Coordinate Setup
`setup.py` is interactive. It waits for you to click points on the screen and writes the
results to `coords.json`.
Press `Esc` at any time during setup to abort.
### Full Setup
Run this the first time, or any time the stored offsets are wrong:
```powershell
python setup.py -f
```
Full setup records:
- Anvil outer box: `tl`, `br`, `width`, `height`. (`tl` stands for top left corner, `br` stands for bottom right corner)
- Anvil buttons and controls:
`plans`, `weld`, `input1`, `input2`, `L`, `M`, `D`, `P`, `G`, `Y`, `O`, `R`. (The letters correspond to the color of the anvil actions in the anvil GUI in the game)
- Inventory and hotbar slots:
`i1` through `i27`, and `h1` through `h9` (i2 through h9 are derived from i1)
- Recipe outer box: `tl`, `br`, `width`, `height`
- Recipe controls:
`rlb`, `rrb`, and `r1` through `r18` (r2 through r18 are derived from r1)
### What Full Setup Asks You To Click
For the anvil UI:
1. Top-left corner of the anvil GUI
2. Bottom-right corner of the anvil GUI
3. The center of each named anvil control
4. The center of `i1` (top-left slot of the 9x3 inventory grid)
5. Two transition clicks while moving the ingot between input slots
6. The `plans` button after the prompt sequence reaches it
For the recipe UI:
1. Top-left corner of the recipe GUI
2. Bottom-right corner of the recipe GUI
3. Left and right recipe scroll/page buttons: `rlb`, `rrb`
4. The center of `r1`
### Refresh Only the GUI Rectangles
If you already have a good `coords.json`, and only the UI position/scale changed, run setup
without `-f`:
```powershell
python setup.py
```
In this mode:
- the existing `coords.json` must already exist
- only the anvil and recipe rectangles are updated
- existing slot/button offsets are preserved
This is useful after changing resolution, window placement, or UI scale while keeping the
same relative layout, and is a much faster way to recalibrate than a full setup. You can still perform a full setup any time you want, which will overwrite the existing coordinates file when you're done.
## Running the macro directly
The main runner replays a list of slot names:
```powershell
python main.py SLOT SLOT SLOT
```
Example:
```powershell
python main.py -s 1500 i1 i2 i3
```
This waits for the start delay, then clicks the first three inventory slots in order.
### Default Test Sequence
If you run `main.py` with no slot arguments and no `--file`, it uses this built-in test:
```text
i1 i2 i3 i4 i5 i6 i7 i8 i9 i1
```
That is useful for validating that your inventory row calibration is correct.
### Speeding up the macro
The default delays for click actions are about as fast as they can be while still being reliable. The best way to speed up the macro is to decrease the delay between actions, which you can do by passing the `-d` flag to `main.py` with a smaller number. `-d 5` is reliable, and quite fast, with only 5 milliseconds between clicks, not counting the delay built into the clicks themselves to keep everything reliable.
## Running the macro through Macro Deck
The whole reason for making this macro program in the first place was so I could run it through Macro Deck, a macro recording and playback program for Windows that allows you to trigger actions through your phone.
### Setup Instructions
Assuming you already followed the [Quick Start](#quick-start) and [Coordinate Setup](#coordinate-setup) instructions and have a coords.json file. The run.vbs file is a wrapper that runs the macro through uv, so you MUST have uv installed and the dependencies synced.
1. Install the Macro Deck server and a client from https://macro-deck.app/
2. Open the server and client and connect them
3. In the server interface (on your computer), install the windows utils plugin
4. Create a new macro
- Add a new action to the macro
- Select "Start Application" from windows utils
- Set the to the run.vbs file in your tfc-forge-macro folder.
- Set the arguments something like `-d 5 --file scripts/bloom-wi_ingot-9.txt`. Any arguments just get passed through to main.py
- Save your changes
5. Open the anvil UI in the game
6. Trigger the macro from a client (like your phone)
## Shift-Click Support
Prefix any slot token with `_` to hold `Shift` while clicking it.
Example:
```powershell
python main.py _i1 _i2 _i3
```
`_i2` means "shift-click `i2`".
This is the only special token syntax recognized by `main.py`.
## Using a Sequence File
Instead of passing slots on the command line, you can put them in a text file and use `--file`.
Example file:
```text
_i1
R R O Y L L L
_i2
R R Y Y Y L L L
```
Run it with:
```powershell
python main.py --file scripts\example.txt
```
Token parsing rules:
- commas, spaces, tabs, and newlines all work as separators
- empty entries are ignored
- if `--file` is provided, slot arguments passed on the command line are ignored
- every token must be a valid slot name, or `_slotname` for shift-click
## Command Reference
### `main.py`
```powershell
python main.py [options] [SLOT ...]
```
Options:
- `-s`, `--start-delay MS`
Wait before starting the sequence. Default: `700` ms.
- `-d`, `--delay MS`
Wait between clicks. Default: `200` ms.
- `--move-settle-ms MS`
Pause after moving the cursor before mouse-down. Default: `20` ms.
- `--click-hold-ms MS`
How long to hold the mouse button down. Default: `30` ms.
- `--post-click-ms MS`
Pause after mouse-up. Default: `30` ms.
- `-c`, `--coords-file PATH`
Use a custom coordinates file instead of `coords.json`.
- `-f`, `--file PATH`
Read tokens from a text file instead of positional arguments.
Examples:
```powershell
python main.py -s 1200 -d 150 L M D
python main.py -c alt-coords.json R R O Y
python main.py --file scripts\forge-sequence.txt
python main.py --move-settle-ms 40 --click-hold-ms 50 --post-click-ms 40 L L L
```
### `setup.py`
```powershell
python setup.py [options]
```
Options:
- `-f`, `--full`
Perform a full calibration from scratch.
- `-o`, `--output PATH`
Write the generated coordinates file to a custom path.
Examples:
```powershell
python setup.py -f
python setup.py
python setup.py -f -o custom-coords.json
```
## How `coords.json` Works
The saved file has two top-level sections:
- `anvil`
- `recipe`
Each section stores:
- `tl`: absolute top-left pixel
- `br`: absolute bottom-right pixel
- `width`
- `height`
- normalized offsets for the named clickable targets inside that box
`main.py` converts those normalized offsets back into screen pixels at runtime.
That means:
- changing the monitor resolution or GUI scale may require rerunning `setup.py`
- if only the GUI rectangle moved but the internal layout stayed the same, rectangle-only refresh mode may be enough
## Safety and Usage Notes
- This tool moves your real mouse cursor and sends real clicks.
- Do not touch the mouse while a sequence is running.
- Keep the game window visible and in the expected state before the start delay expires.
- Start with short sequences until you trust your calibration.
- There is no dedicated emergency stop hotkey in `main.py`; use cautious timings and small tests first.
- `setup.py` does support `Esc` to abort calibration.
## Troubleshooting
### Clicks land slightly off target
- Rerun `python setup.py`
- If the layout changed completely, rerun `python setup.py -f`
- Increase `--move-settle-ms` a little
- Increase `--click-hold-ms` if the game misses short clicks
### The final click is sometimes missed
Increase:
- `--post-click-ms`
- or `--click-hold-ms`
### The wrong screen area is being clicked after changing resolution or UI scale
Refresh calibration:
```powershell
python setup.py
```
If that does not fix it:
```powershell
python setup.py -f
```
### `coords.json` is missing
Create it first:
```powershell
python setup.py -f
```
### A slot name causes an error
Make sure the token is one of the names listed in this README. `main.py` only understands:
- defined anvil controls
- inventory/hotbar slots
- recipe slots and recipe page buttons
- optional `_` prefix for shift-click
## TODO
- [ ] - Double check the anvil action colors without the resource pack and possibly change the letters or update the README to reflect the base mod
- [ ] - Add support for actions other than clicking (like pressing keys)

56
conversions.py Normal file
View File

@@ -0,0 +1,56 @@
"""
conversions.py
A command line temperature conversion calculator
by Nayan Sawyer
started Feb 2019
version 0.1.0 Sep 19 2019
This was a small personal project to make chem class more interesting.
I never got around to updating or documenting it, but it's pretty straight forward.
"""
def runTemp():
inType = input("Input unit(F,C,K): ")
while inType == "":
inType = input("Input unit(F,C,K): ")
outType = input("Output unit(F,C,K): ")
while outType == "":
outType = input("Output unit(F,C,K): ")
inTemp = float(input("Temperature: "))
if inType == "F" or inType == "f":
if outType == "K" or outType == "k":
print("F: " + str(inTemp) + ", K: " + str(((5/9) * (inTemp-32)) + 273.15))
elif outType == "C" or outType == "c":
print("F: " + str(inTemp) + ", C: " + str((5/9) * (inTemp-32)))
else:
print("invalid output unit")
elif inType == "C"or inType == "c":
if outType == "F" or outType == "f":
print("C: " + str(inTemp) + ", F: " + str(((9/5) * inTemp) + 32))
elif outType == "K" or outType == "k":
print("C: " + str(inTemp) + ", K: " + str(inTemp + 273.15))
else:
print("invalid output unit")
elif inType == "K" or inType == "k":
if outType == "F" or outType == "f":
print("K: " + str(inTemp) + ", F: " + str(((9/5) * (inTemp - 273.15)) + 32))
elif outType == "C" or outType == "c":
print("K: " + str(inTemp) +", C: " + str(inTemp - 273.15))
else:
print("invalid output unit")
else:
print("invalid input unit")
'''
PROGRAM START
'''
while True:
runTemp()
print("")

View File

@@ -1,82 +0,0 @@
{
"anvil": {
"tl": [1020, 402],
"br": [1542, 1018],
"width": 522,
"height": 616,
"plans": [0.16283525, 0.2288961],
"weld": [0.82950192, 0.2288961],
"input1": [0.11877395, 0.36688312],
"input2": [0.22988506, 0.37175325],
"L": [0.34482759, 0.28246753],
"M": [0.45210728, 0.27597403],
"D": [0.35057471, 0.35876623],
"P": [0.45977011, 0.35714286],
"G": [0.56321839, 0.26785714],
"Y": [0.65900383, 0.26948052],
"O": [0.53448276, 0.37175325],
"R": [0.651341, 0.35876623],
"i1": [0.09003831, 0.64285714],
"i2": [0.19530147, 0.64285714],
"i3": [0.30056463, 0.64285714],
"i4": [0.40582779, 0.64285714],
"i5": [0.51109095, 0.64285714],
"i6": [0.6163541, 0.64285714],
"i7": [0.72161726, 0.64285714],
"i8": [0.82688042, 0.64285714],
"i9": [0.93214358, 0.64285714],
"i10": [0.09003831, 0.73376623],
"i11": [0.19530147, 0.73376623],
"i12": [0.30056463, 0.73376623],
"i13": [0.40582779, 0.73376623],
"i14": [0.51109095, 0.73376623],
"i15": [0.6163541, 0.73376623],
"i16": [0.72161726, 0.73376623],
"i17": [0.82688042, 0.73376623],
"i18": [0.93214358, 0.73376623],
"i19": [0.09003831, 0.82467532],
"i20": [0.19530147, 0.82467532],
"i21": [0.30056463, 0.82467532],
"i22": [0.40582779, 0.82467532],
"i23": [0.51109095, 0.82467532],
"i24": [0.6163541, 0.82467532],
"i25": [0.72161726, 0.82467532],
"i26": [0.82688042, 0.82467532],
"i27": [0.93214358, 0.82467532],
"h1": [0.09003831, 0.91558442],
"h2": [0.19530147, 0.91558442],
"h3": [0.30056463, 0.91558442],
"h4": [0.40582779, 0.91558442],
"h5": [0.51109095, 0.91558442],
"h6": [0.6163541, 0.91558442],
"h7": [0.72161726, 0.91558442],
"h8": [0.82688042, 0.91558442],
"h9": [0.93214358, 0.91558442]
},
"recipe": {
"tl": [1018, 459],
"br": [1543, 954],
"width": 525,
"height": 495,
"rlb": [0.06857143, 0.37575758],
"rrb": [0.94285714, 0.37979798],
"r1": [0.09714286, 0.15151515],
"r2": [0.20240602, 0.15151515],
"r3": [0.30766917, 0.15151515],
"r4": [0.41293233, 0.15151515],
"r5": [0.51819549, 0.15151515],
"r6": [0.62345865, 0.15151515],
"r7": [0.7287218, 0.15151515],
"r8": [0.83398496, 0.15151515],
"r9": [0.93924812, 0.15151515],
"r10": [0.09714286, 0.27127563],
"r11": [0.20240602, 0.27127563],
"r12": [0.30766917, 0.27127563],
"r13": [0.41293233, 0.27127563],
"r14": [0.51819549, 0.27127563],
"r15": [0.62345865, 0.27127563],
"r16": [0.7287218, 0.27127563],
"r17": [0.83398496, 0.27127563],
"r18": [0.93924812, 0.27127563]
}
}

225
main.py
View File

@@ -1,225 +0,0 @@
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from pathlib import Path
from typing import Any
from pynput.keyboard import Controller as KeyboardController, Key
from pynput.mouse import Button, Controller as MouseController
DEFAULT_COORDS_PATH = Path(__file__).resolve().parent / "coords.json"
# Timing tuned for games that poll input each frame: move must settle before click,
# and a short pause after mouse-up helps the last click flush before the process exits.
MOVE_SETTLE_SEC = 0.02
CLICK_HOLD_SEC = 0.03
POST_CLICK_SEC = 0.03
DEFAULT_TEST_SLOTS = ["i1", "i2", "i3", "i4", "i5", "i6", "i7", "i8", "i9", "i1"]
def get_coord(coord_map: dict[str, Any], coord_name: str) -> tuple[float, float]:
"""Pixel position for a named slot. tl/br are absolute pixels; others use normalized offsets."""
if coord_name in ("tl", "br"):
v = coord_map[coord_name]
return (float(v[0]), float(v[1]))
offsets = coord_map[coord_name]
tl = coord_map["tl"]
width = coord_map["width"]
height = coord_map["height"]
x = tl[0] + (offsets[0] * width)
y = tl[1] + (offsets[1] * height)
return (x, y)
def load_coords(path: Path) -> dict[str, Any]:
data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
if "anvil" not in data or "recipe" not in data:
raise ValueError("coords.json must contain 'anvil' and 'recipe' keys")
return data
def resolve_coord_map(payload: dict[str, Any], name: str) -> dict[str, Any]:
"""Return the anvil or recipe sub-map that contains this slot name."""
if name in ("width", "height"):
raise ValueError(f"{name!r} is metadata, not a clickable slot.")
for section in ("anvil", "recipe"):
m = payload[section]
if name not in m:
continue
val = m[name]
if isinstance(val, (int, float)):
raise ValueError(f"{name!r} in {section!r} is not a coordinate pair.")
return m
raise KeyError(f"Unknown slot {name!r} (not in anvil or recipe).")
def parse_slots_file(content: str) -> list[str]:
"""Split slot names on commas, spaces, tabs, and newlines; empty tokens are dropped."""
return [t for t in re.split(r"[\s,]+", content.strip()) if t]
def parse_slot_token(token: str) -> tuple[str, bool]:
"""Underscore prefix means hold Shift for that click (_i2 -> i2 with shift)."""
if token.startswith("_"):
return token[1:], True
return token, False
def run_sequence(
payload: dict[str, Any],
slots: list[str],
delay_ms: float,
move_settle_sec: float = MOVE_SETTLE_SEC,
click_hold_sec: float = CLICK_HOLD_SEC,
post_click_sec: float = POST_CLICK_SEC,
) -> None:
mouse = MouseController()
keyboard = KeyboardController()
for i, token in enumerate(slots):
name, use_shift = parse_slot_token(token)
if not name:
raise ValueError(f"Empty slot name in token {token!r}")
coord_map = resolve_coord_map(payload, name)
x, y = get_coord(coord_map, name)
xi, yi = int(round(x)), int(round(y))
mouse.position = (xi, yi)
time.sleep(move_settle_sec)
if use_shift:
keyboard.press(Key.shift)
time.sleep(move_settle_sec)
try:
mouse.press(Button.left)
time.sleep(click_hold_sec)
mouse.release(Button.left)
finally:
if use_shift:
keyboard.release(Key.shift)
time.sleep(post_click_sec)
if i + 1 < len(slots) and delay_ms > 0:
time.sleep(delay_ms / 1000.0)
def main() -> None:
parser = argparse.ArgumentParser(
description="Move the mouse to slots from coords.json and left-click.",
)
parser.add_argument(
"-s",
"--start-delay",
type=int,
default=700,
metavar="MS",
help="Milliseconds to wait before starting the sequence (default: 1000).",
)
parser.add_argument(
"-d",
"--delay",
type=int,
default=200,
metavar="MS",
help="Milliseconds to wait after each click before the next (default: 200).",
)
parser.add_argument(
"--move-settle-ms",
type=float,
default=MOVE_SETTLE_SEC * 1000,
metavar="MS",
help=(
"Milliseconds to wait after moving the cursor before mouse down "
f"(default: {MOVE_SETTLE_SEC * 1000:.0f}). Helps clicks register on the new position."
),
)
parser.add_argument(
"--post-click-ms",
type=float,
default=POST_CLICK_SEC * 1000,
metavar="MS",
help=(
"Milliseconds to wait after each mouse up (including the last) "
f"(default: {POST_CLICK_SEC * 1000:.0f}). Helps the final click flush before exit."
),
)
parser.add_argument(
"--click-hold-ms",
type=float,
default=CLICK_HOLD_SEC * 1000,
metavar="MS",
help=f"Milliseconds to hold the mouse button down (default: {CLICK_HOLD_SEC * 1000:.0f}).",
)
parser.add_argument(
"-c",
"--coords-file",
type=Path,
default=None,
help=f"Path to coords.json (default: {DEFAULT_COORDS_PATH}).",
)
parser.add_argument(
"-f",
"--file",
type=Path,
default=None,
metavar="PATH",
help=(
"Read slot names from this file (commas, spaces, and newlines separate tokens). "
"When set, positional SLOT arguments are ignored."
),
)
parser.add_argument(
"slots",
nargs="*",
metavar="SLOT",
help=(
"Slot names from coords.json, e.g. M D P G i9 h6. Use _NAME for shift-click. "
"Ignored when --file is set."
),
)
args = parser.parse_args()
path = args.coords_file.expanduser().resolve() if args.coords_file else DEFAULT_COORDS_PATH
if not path.is_file():
print(f"Missing coords file: {path}", file=sys.stderr)
sys.exit(1)
if args.file is not None:
slots_path = args.file.expanduser().resolve()
if not slots_path.is_file():
print(f"Missing slots file: {slots_path}", file=sys.stderr)
sys.exit(1)
slots = parse_slots_file(slots_path.read_text(encoding="utf-8"))
if not slots:
print("Slots file is empty or contains no slot names.", file=sys.stderr)
sys.exit(1)
else:
slots = args.slots if args.slots else DEFAULT_TEST_SLOTS
time.sleep(args.start_delay / 1000)
try:
payload = load_coords(path)
run_sequence(
payload,
slots,
args.delay,
move_settle_sec=args.move_settle_ms / 1000.0,
click_hold_sec=args.click_hold_ms / 1000.0,
post_click_sec=args.post_click_ms / 1000.0,
)
except (json.JSONDecodeError, ValueError, KeyError) as e:
print(e, file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

526
mental_math.py Normal file
View File

@@ -0,0 +1,526 @@
"""
mental_math.py
Mental math training program utilizing the command line interface
by Nayan Sawyer
started Sep 17 2019
version 1.0.0 Sep 19 2019
IMPORTANT INFORMATION
This program uses two sections of GS_timing.py v0.2.1 by Gabriel Staples to calculate user
response times. This is not my code and all credit goes to Gabriel Staples. Keep in mind that
these selections do not represent the full quality of his work as only the sections of code used
in mental_math.py are retained in the selection below the main program. Please do not use or reference
his work as seen in my program. If you wish to use or reference a much better time implementation than
standard python time, please use the original file and article by Gabriel Staples. The full GS_timing.py
file can be found at https://github.com/ElectricRCAircraftGuy/eRCaGuy_PyTime and the original article
can be found at https://www.electricrcaircraftguy.com/ go to table of contents, it is listed under
PYTHON. Needless to say, a huge thank you Gabriel Staples! The timing code would be much less accurate
without him!
MODES
All modes utilize integer values only. This includes generated numbers and compatible inputs.
For this reason and a few others division is not included. This program's sole purpose is intended
to be practicing and training of high speed thinking and memory.
Typing "stop" during normal operation of any mode is the correct way to stop the program. It will
output final average user response time and jump to the end of the program. Unless "stop" is used
the program will continue to produce problems indefinitely.
Type Simply for practicing typing numbers on the numpad (or any keyboard/configuration)
Add This mode is for practicing mental addition. Two numbers are presented between
input Minimum(boundMin) and Maximum(boundMax), and the correct answer is the sum of
these two numbers.
Subtract This mode is for practicing mental subtraction. Virtually the same as mode[Add] except for
practicing subtraction, includes additional optional argument to restrict possible answers to
positive only.
Multiply This mode is for practicing multiplication tables. Arguments include number of multiplication
tables, actual integer value[s] of table base[s], and maximum multiplier. The last being the highest
number you can be asked to multiply the table base[s] by.
TERMS / ACRONYMS
URT User response time
AURT Average user response time
VARIABLES
global
flag An input variable used to determine which mode to run
function*
boundMin/boundMax Boundary values for generating random numbers
n Number. Holds random number, for mode[Type] only
a Math variable a. Used for display and calculation
b Math variable b. Used for display and calculation
t Total. This is the user input answer
numBases Number of multiplication bases. User defined, defaults to 1, for mode[Multiplication] only
bases Multiplication base(table) list. User defined, for mode[Multiplication] only
fails ***DEPRECATED*** Used for accuracy calculation in alpha version, potential future feature
time
cTime Current time in millis. Used for calculating URT
fTime Formatted float time. This is the time difference between question and user response
count Counts loop iterations for calculating average user response time
total Holds the sum of all response times, used for calculating AURT^
*many variable names are used in all functions, variables unique to only one function are specified( mode[...] only )
REFERENCE
*1* fTime = round(((millis() - cTime) / 1000), 3)
set fTime to the value of
...(millis() - cTime)...
the time difference in milliseconds between start(cTime) and current(millis()) time
... / 1000)...
converted to seconds
...round( ... , 3)...
rounded to 3 decimal places
*2* str(" " + str(c) + " Problems in " + str(int(int(t) / 60)) + ":" + str(int(t) % 60) +
"\n Final average response time: " + str(average) + " seconds per problem")
Final string should appear as: (example)
3 Problems in 0:4
Final average response time: 1.61 seconds per problem
...str(int(int(t) / 60))...
to get minutes string remove* any fractions of a second ...int(t)...
divide by 60 secs/minute, and remove* any remaining seconds ...int(... / 60)...
...str(int(t) % 60)...
to get string of remaining seconds, remove* any fractions of a second and modulo by 60
*typecasting a float as an int truncates all information beyond the decimal point
ERROR CODES
1 Error printing. Most likely due to bug in time calculation code. Most likely deprecated
2 Error with user input in mode[Subtract]. Debugging only, most likely deprecated
3 Invalid user input for bases. Common error thrown for any non-integer input during multiplication
setup. Has no effect on program function and can be ignored. Primarily for debugging
4 This indicates an error while rounding the AURT to 3 decimal places. Typically result of all
time variables being zero
"""
import random
# For borrowed time code. See important info
import ctypes, os
# Generate time string. For compressing code. f = fTime, c = count, t = total
def resTime(f, c, t, stop = 0):
"""
"\n " + str(int(((c - int(stop - 1)) / c) * 100)) + "% Input accuracy" ***DEPRECATED***
"""
try:
if stop >= 1:
# Generate float value of average time to nearest hundredth of a second
average = round(t / c, 2)
# Generate final string. See ref 2
return str(" " + str(c) + " Problems in " + str(int(int(t) / 60)) + ":" + secString(t) +
"\n Final average response time: " + str(average) + " seconds per problem")
else:
average = round(t / c, 2)
return str("Time passed: " + str(f) + " Average response time = " + str(average))
except:
return str("Stopped before first submission. Error code 4")
def secString(time):
if (int(time) % 60) < 10:
return "0" + str(int(time) % 60)
else:
return str(int(time) % 60)
def getMin():
while True:
try:
val = int(input("Minimum: "))
break
except:
print("Invalid input")
return val
def getMax(mIn):
while True:
try:
val = int(input("Maximum: "))
while val < mIn:
print("Maximum cannot be less than minimum")
val = int(input("Maximum: "))
break
except:
print("Invalid input")
return val
def isnumber(string):
try:
if string.isnumeric():
return True
elif string[1].isnumeric():
return True
else:
return False
except:
if string[1].isnumeric():
return True
else:
return False
def modeType():
'''MODE[Type]'''
# Initialize mode variables
boundMin = getMin()
boundMax = getMax(boundMin)
count = 0
total = 0
fTime = 0
fails = 0
while True:
# Generate random numbers
n = random.randint(int(boundMin), int(boundMax))
# Get current time in millis
cTime = millis()
while True:
# Try. Prints error instead of crashing if printing error occurs. Most likely deprecated
try:
# Get user input. For checking math
t = input("Number (" + str(n) + "): ")
# If input is "stop" end the program
if t.lower() == "stop":
return resTime(fTime, count, total, fails + 1)
# Check if input is numeric
elif isnumber(t):
# Check that input is same as number, if so break (while True)
if int(t) == int(n):
break
else:
print("Incorrect!")
fails += 1
else:
print("Incorrect!")
fails += 1
except:
print("Invalid input. Error code 1")
fails += 1
# Calculate formatted float time (time difference). ref 1
fTime = round(((millis() - cTime) / 1000), 3)
# Update total response times. Count number of times loop has run. For calculating AURT
total += fTime
count += 1
# Print answer "Correct" and response time. The function will only get here given a correct answer, otherwise
print("Correct! " + resTime(fTime, count, total)) # it will loop forever (while True) or stop
'''MODE[Add]'''
def modeAdd():
# Initialize mode variables
boundMin = getMin()
boundMax = getMax(boundMin)
count = 0
total = 0
fTime = 0
fails = 0
while True:
# Generate random numbers
a = random.randint(int(boundMin), int(boundMax))
b = random.randint(int(boundMin), int(boundMax))
# Get current time in millis
cTime = millis()
# Keep asking for same number if incorrect
while True:
# Try so you don't crash you mother fucking piece of shit!
try:
# Get user input for checking math
t = input("Sum (" + str(a) + " + " + str(b) + "): ")
# Check for program stop
if t == "stop":
return resTime(fTime, count, total, fails + 1)
elif isnumber(t):
# Check that input is same as sum, if so break (while True)
if int(t) == int(a) + int(b):
break
else:
print("Incorrect!")
fails += 1
else:
print("Incorrect!")
fails += 1
except:
print("Invalid input. Error code 1")
fails += 1
# Calculate formatted float time (time difference). ref 1
fTime = round(((millis() - cTime) / 1000), 3)
total += fTime
count += 1
# Print answer "Correct" and response time. The function will only get here given a correct answer, otherwise
print("Correct! " + resTime(fTime, count, total)) # it will loop forever (while True) or stop
'''MODE[Subtraction]'''
def modeSubtract():
# Initialize mode variables
boundMin = getMin()
boundMax = getMax(boundMin)
count = 0
total = 0
fTime = 0
fails = 0
# Additional argument. Given all problems are subtraction does user want to allow negative answers?
negativeA = input("Allow possibility of negative answers? (y/n): ")
# Check that input is valid
while negativeA.lower() != "y" and negativeA.lower() != "n":
negativeA = input("Allow possibility of negative answers? (y/n): ")
# If negative answers are undesired make sure the value being subtracted FROM is no lower than 1
if negativeA.lower() == "n":
if boundMin <= 1:
boundMin = 1
while True:
# Generate random numbers
if negativeA.lower() == "y":
a = random.randint(int(boundMin), int(boundMax))
b = random.randint(int(boundMin), int(boundMax))
elif negativeA.lower() == "n":
# When no negatives make sure the subtracted value is lower than the initial value
a = random.randint(int(boundMin), int(boundMax))
b = random.randint(int(boundMin), int(a))
else: # for debugging
print("Negative answer input invalid, or bug. Returned error code: 2")
# Get current time in millis
cTime = millis()
while True:
# Try. Prints error instead of crashing if printing error occurs. Most likely deprecated
try:
# Get user input. For checking math
t = input("Sum (" + str(a) + " - " + str(b) + "): ")
# End if stop
if t == "stop":
return resTime(fTime, count, total, fails + 1)
elif isnumber(t):
# Check that input is same as sum, if so break while True
if int(t) == int(a) - int(b):
break
else:
print("Incorrect!")
fails += 1
else:
print("Incorrect!")
fails += 1
except:
print("Invalid input. Error code 1")
fails += 1
# Calculate formatted float time (time difference). ref 1
fTime = round(((millis() - cTime) / 1000), 3)
# Update total response times. Count number of times loop has run. For calculating AURT
total += fTime
count += 1
# Print answer "Correct" and response time. The function will only get here given a correct answer, otherwise
print("Correct! " + resTime(fTime, count, total)) # it will loop forever (while True) or stop
'''MODE[Multiply]'''
def modeMultiply():
# Initialize mode variables
numBases = 0
while numBases <= 0:
try:
# Get number of bases(times tables) desired
numBases = int(input("Number of tables "
"(ex: Doing the 6 and 8 tables together would require 2. default 1):"))
break
except:
# If invalid input (non-int) default to one base
numBases = 1
# Initialize list of bases
bases = []
# Fill list with [numBases] of actual base values via user input
for i in range(numBases):
while True:
try:
bases.append(int(input("Base[" + str(i + 1) + "]: ")))
break
except:
print("Invalid input. Must be integer. Returned error code: 3")
boundMax = 0
while True:
try:
# Get number of bases desired
boundMax = int(input("Maximum multiplier: "))
break
except:
print("Invalid input")
count = 0
total = 0
fTime = 0
fails = 0
# Main function
while True:
# Generate Random numbers
a = random.choice(bases)
b = random.randint(0, int(boundMax))
# Get current time in millis
cTime = millis()
while True:
# Try. Prints error instead of crashing if printing error occurs. Most likely deprecated
try:
# Get user input. For checking math
t = input("Product (" + str(a) + " x " + str(b) + "): ")
# End if stop
if t == "stop":
return resTime(fTime, count, total, fails + 1)
elif isnumber(t):
# Check that input is same as sum, if so break while True
if int(t) == int(a) * int(b):
break
else:
print("Incorrect!")
fails += 1
else:
print("Incorrect!")
fails += 1
except:
print("Invalid input. Error code 1")
fails += 1
# Calculate formatted float time (time difference). ref 1
fTime = round(((millis() - cTime) / 1000), 3)
# Update total response times. Count number of times loop has run. For calculating AURT
total += fTime
count += 1
# Print answer "Correct" and response time. The function will only get here given a correct answer, otherwise
print("Correct! " + resTime(fTime, count, total)) # it will loop forever (while True) or stop
"""
THE FOLLOWING CODE IS TWO TIME CALCULATION FUNCTIONS BORROWED FROM Gabriel Staples
PLEASE SEE IMPORTANT INFO IN DOCSTRING
"""
"""
START OF BORROWED CODE
"""
#-------------------------------------------------------------------
#MODULE FUNCTIONS:
#-------------------------------------------------------------------
#OS-specific low-level timing functions:
if (os.name=='nt'): #for Windows:
def millis():
"return a timestamp in milliseconds (ms)"
tics = ctypes.c_int64() #use *signed* 64-bit variables; see the "QuadPart" variable here: https://msdn.microsoft.com/en-us/library/windows/desktop/aa383713(v=vs.85).aspx
freq = ctypes.c_int64()
#get ticks on the internal ~2MHz QPC clock
ctypes.windll.Kernel32.QueryPerformanceCounter(ctypes.byref(tics))
#get the actual freq. of the internal ~2MHz QPC clock
ctypes.windll.Kernel32.QueryPerformanceFrequency(ctypes.byref(freq))
t_ms = tics.value*1e3/freq.value
return t_ms
elif (os.name=='posix'): #for Linux:
#Constants:
CLOCK_MONOTONIC_RAW = 4 # see <linux/time.h> here: https://github.com/torvalds/linux/blob/master/include/uapi/linux/time.h
#prepare ctype timespec structure of {long, long}
#-NB: use c_long (generally signed 32-bit) variables within the timespec C struct, per the definition here: https://github.com/torvalds/linux/blob/master/include/uapi/linux/time.h
class timespec(ctypes.Structure):
_fields_ = \
[
('tv_sec', ctypes.c_long),
('tv_nsec', ctypes.c_long)
]
#Configure Python access to the clock_gettime C library, via ctypes:
#Documentation:
#-ctypes.CDLL: https://docs.python.org/3.2/library/ctypes.html
#-librt.so.1 with clock_gettime: https://docs.oracle.com/cd/E36784_01/html/E36873/librt-3lib.html #-
#-Linux clock_gettime(): http://linux.die.net/man/3/clock_gettime
librt = ctypes.CDLL('librt.so.1', use_errno=True)
clock_gettime = librt.clock_gettime
#specify input arguments and types to the C clock_gettime() function
# (int clock_ID, timespec* t)
clock_gettime.argtypes = [ctypes.c_int, ctypes.POINTER(timespec)]
def monotonic_time():
"return a timestamp in seconds (sec)"
t = timespec()
#(Note that clock_gettime() returns 0 for success, or -1 for failure, in
# which case errno is set appropriately)
#-see here: http://linux.die.net/man/3/clock_gettime
if clock_gettime(CLOCK_MONOTONIC_RAW , ctypes.pointer(t)) != 0:
#if clock_gettime() returns an error
errno_ = ctypes.get_errno()
raise OSError(errno_, os.strerror(errno_))
return t.tv_sec + t.tv_nsec*1e-9 #sec
def millis():
"return a timestamp in milliseconds (ms)"
return monotonic_time()*1e3 #ms
"""
END OF BORROWED CODE
"""
#
#
#
'''
PROGRAM START
'''
#
#
#
# User input to determine mode
flag = input('Select mode: Type[1], Add[2], Subtract[3] or Multiply[4], or "help" for help ')
if flag == "1":
print(modeType())
elif flag == "2":
print(modeAdd())
elif flag == "3":
print(modeSubtract())
elif flag == "4":
print(modeMultiply())
elif flag.lower() == "help":
print(__doc__)
else:
print("Invalid mode input")

View File

@@ -1,9 +0,0 @@
[project]
name = "tfc-forge-macro"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"pynput>=1.7.6",
]

19
run.vbs
View File

@@ -1,19 +0,0 @@
Set sh = CreateObject("WScript.Shell")
Set fso = CreateObject("Scripting.FileSystemObject")
sh.CurrentDirectory = fso.GetParentFolderName(WScript.ScriptFullName)
Function QuoteForCmd(s)
QuoteForCmd = """" & Replace(s, """", """""") & """"
End Function
Dim extra, i
extra = ""
For i = 0 To WScript.Arguments.Count - 1
If Len(extra) > 0 Then extra = extra & " "
extra = extra & QuoteForCmd(WScript.Arguments(i))
Next
Dim cmdLine
cmdLine = "cmd /c uv run main.py"
If Len(extra) > 0 Then cmdLine = cmdLine & " " & extra
sh.Run cmdLine, 0, False

View File

@@ -1,45 +0,0 @@
_i1
R R O Y L L L
R R Y Y Y L L L
-input2
+q
_i2
R R O Y L L L
R R Y Y Y L L L
-input2
+q
_i3
R R O Y L L L
R R Y Y Y L L L
-input2
+q
_i4
R R O Y L L L
R R Y Y Y L L L
-input2
+q
_i5
R R O Y L L L
R R Y Y Y L L L
-input2
+q
_i6
R R O Y L L L
R R Y Y Y L L L
-input2
+q
_i7
R R O Y L L L
R R Y Y Y L L L
-input2
+q
_i8
R R O Y L L L
R R Y Y Y L L L
-input2
+q
_i9
R R O Y L L L
R R Y Y Y L L L
-input2
+q

359
setup.py
View File

@@ -1,359 +0,0 @@
"""
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); i2i27 and h1h9 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); r2r18 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 (i1i27 then hotbar h1h9); recipe panel is 2 rows (r1r18).
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 i1i27 and h1h9 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 r1r18 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). "
"i2i27 and h1h9 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). "
"r2r18 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 r2r18, 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()

View File

@@ -0,0 +1,12 @@
num_ore = 25
mb_per_ore = 129
mb_per_ingot = 144
mb_per_coal = 144
mb = num_ore * mb_per_ore
num_ingots = int(mb / mb_per_ingot)
num_coal = int(mb / mb_per_coal)
lost_mb = mb % mb_per_ingot
print(f"With {num_ore} ore and {num_coal} coal, you will get {num_ingots} ingots and lose {lost_mb} MB of iron. Total items are {num_ore + num_coal}.")

140
uv.lock generated
View File

@@ -1,140 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "evdev"
version = "1.9.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a5/f5/397b61091120a9ca5001041dd7bf76c385b3bfd67a0e5bcb74b852bd22a4/evdev-1.9.3.tar.gz", hash = "sha256:2c140e01ac8437758fa23fe5c871397412461f42d421aa20241dc8fe8cfccbc9", size = 32723, upload-time = "2026-02-05T21:54:24.987Z" }
[[package]]
name = "pynput"
version = "1.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "evdev", marker = "'linux' in sys_platform" },
{ name = "pyobjc-framework-applicationservices", marker = "sys_platform == 'darwin'" },
{ name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" },
{ name = "python-xlib", marker = "'linux' in sys_platform" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/c3/dccf44c68225046df5324db0cc7d563a560635355b3e5f1d249468268a6f/pynput-1.8.1.tar.gz", hash = "sha256:70d7c8373ee98911004a7c938742242840a5628c004573d84ba849d4601df81e", size = 82289, upload-time = "2025-03-17T17:12:01.481Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/4f/ac3fa906ae8a375a536b12794128c5efacade9eaa917a35dfd27ce0c7400/pynput-1.8.1-py2.py3-none-any.whl", hash = "sha256:42dfcf27404459ca16ca889c8fb8ffe42a9fe54f722fd1a3e130728e59e768d2", size = 91693, upload-time = "2025-03-17T17:12:00.094Z" },
]
[[package]]
name = "pyobjc-core"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" },
{ url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" },
{ url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" },
{ url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" },
{ url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" },
]
[[package]]
name = "pyobjc-framework-applicationservices"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
{ name = "pyobjc-framework-coretext" },
{ name = "pyobjc-framework-quartz" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/6a/d4e613c8e926a5744fc47a9e9fea08384a510dc4f27d844f7ad7a2d793bd/pyobjc_framework_applicationservices-12.1.tar.gz", hash = "sha256:c06abb74f119bc27aeb41bf1aef8102c0ae1288aec1ac8665ea186a067a8945b", size = 103247, upload-time = "2025-11-14T10:08:52.18Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/a7/55fa88def5c02732c4b747606ff1cbce6e1f890734bbd00f5596b21eaa02/pyobjc_framework_applicationservices-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c8f6e2fb3b3e9214ab4864ef04eee18f592b46a986c86ea0113448b310520532", size = 32835, upload-time = "2025-11-14T09:36:11.855Z" },
{ url = "https://files.pythonhosted.org/packages/fc/21/79e42ee836f1010f5fe9e97d2817a006736bd287c15a3674c399190a2e77/pyobjc_framework_applicationservices-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bd1f4dbb38234a24ae6819f5e22485cf7dd3dd4074ff3bf9a9fdb4c01a3b4a38", size = 32859, upload-time = "2025-11-14T09:36:15.208Z" },
{ url = "https://files.pythonhosted.org/packages/66/3a/0f1d4dcf2345e875e5ea9761d5a70969e241d24089133d21f008dde596f5/pyobjc_framework_applicationservices-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8a5d2845249b6a85ba9e320a9848468c3f8cd6f59605a9a43f406a7810eaa830", size = 33115, upload-time = "2025-11-14T09:36:18.384Z" },
{ url = "https://files.pythonhosted.org/packages/40/44/3196b40fec68b4413c92875311f17ccf4c3ff7d2e53676f8fc18ad29bd18/pyobjc_framework_applicationservices-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f43c9a24ad97a9121276d4d571aa04a924282c80d7291cfb3b29839c3e2013a8", size = 32997, upload-time = "2025-11-14T09:36:21.58Z" },
{ url = "https://files.pythonhosted.org/packages/fd/bb/dab21d2210d3ef7dd0616df7e8ea89b5d8d62444133a25f76e649a947168/pyobjc_framework_applicationservices-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1f72e20009a4ebfd5ed5b23dc11c1528ad6b55cc63ee71952ddb2a5e5f1cb7da", size = 33238, upload-time = "2025-11-14T09:36:24.751Z" },
]
[[package]]
name = "pyobjc-framework-cocoa"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" },
{ url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" },
{ url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" },
{ url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" },
{ url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" },
]
[[package]]
name = "pyobjc-framework-coretext"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
{ name = "pyobjc-framework-quartz" },
]
sdist = { url = "https://files.pythonhosted.org/packages/29/da/682c9c92a39f713bd3c56e7375fa8f1b10ad558ecb075258ab6f1cdd4a6d/pyobjc_framework_coretext-12.1.tar.gz", hash = "sha256:e0adb717738fae395dc645c9e8a10bb5f6a4277e73cba8fa2a57f3b518e71da5", size = 90124, upload-time = "2025-11-14T10:14:38.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/0f/ddf45bf0e3ba4fbdc7772de4728fd97ffc34a0b5a15e1ab1115b202fe4ae/pyobjc_framework_coretext-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d246fa654bdbf43bae3969887d58f0b336c29b795ad55a54eb76397d0e62b93c", size = 30108, upload-time = "2025-11-14T09:47:04.228Z" },
{ url = "https://files.pythonhosted.org/packages/20/a2/a3974e3e807c68e23a9d7db66fc38ac54f7ecd2b7a9237042006699a76e1/pyobjc_framework_coretext-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7cbb2c28580e6704ce10b9a991ccd9563a22b3a75f67c36cf612544bd8b21b5f", size = 30110, upload-time = "2025-11-14T09:47:07.518Z" },
{ url = "https://files.pythonhosted.org/packages/0f/5d/85e059349e9cfbd57269a1f11f56747b3ff5799a3bcbd95485f363c623d8/pyobjc_framework_coretext-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:14100d1e39efb30f57869671fb6fce8d668f80c82e25e7930fb364866e5c0dab", size = 30697, upload-time = "2025-11-14T09:47:10.932Z" },
{ url = "https://files.pythonhosted.org/packages/ef/c3/adf9d306e9ead108167ab7a974ab7d171dbacf31c72fad63e12585f58023/pyobjc_framework_coretext-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:782a1a9617ea267c05226e9cd81a8dec529969a607fe1e037541ee1feb9524e9", size = 30095, upload-time = "2025-11-14T09:47:13.893Z" },
{ url = "https://files.pythonhosted.org/packages/bd/ca/6321295f47a47b0fca7de7e751ddc0ddc360413f4e506335fe9b0f0fb085/pyobjc_framework_coretext-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7afe379c5a870fa3e66e6f65231c3c1732d9ccd2cd2a4904b2cd5178c9e3c562", size = 30702, upload-time = "2025-11-14T09:47:17.292Z" },
]
[[package]]
name = "pyobjc-framework-quartz"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/9b/780f057e5962f690f23fdff1083a4cfda5a96d5b4d3bb49505cac4f624f2/pyobjc_framework_quartz-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7730cdce46c7e985535b5a42c31381af4aa6556e5642dc55b5e6597595e57a16", size = 218798, upload-time = "2025-11-14T10:00:01.236Z" },
{ url = "https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:629b7971b1b43a11617f1460cd218bd308dfea247cd4ee3842eb40ca6f588860", size = 219206, upload-time = "2025-11-14T10:00:15.623Z" },
{ url = "https://files.pythonhosted.org/packages/67/43/b1f0ad3b842ab150a7e6b7d97f6257eab6af241b4c7d14cb8e7fde9214b8/pyobjc_framework_quartz-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:53b84e880c358ba1ddcd7e8d5ea0407d760eca58b96f0d344829162cda5f37b3", size = 224317, upload-time = "2025-11-14T10:00:30.703Z" },
{ url = "https://files.pythonhosted.org/packages/4a/00/96249c5c7e5aaca5f688ca18b8d8ad05cd7886ebd639b3c71a6a4cadbe75/pyobjc_framework_quartz-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:42d306b07f05ae7d155984503e0fb1b701fecd31dcc5c79fe8ab9790ff7e0de0", size = 219558, upload-time = "2025-11-14T10:00:45.476Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a6/708a55f3ff7a18c403b30a29a11dccfed0410485a7548c60a4b6d4cc0676/pyobjc_framework_quartz-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0cc08fddb339b2760df60dea1057453557588908e42bdc62184b6396ce2d6e9a", size = 224580, upload-time = "2025-11-14T10:01:00.091Z" },
]
[[package]]
name = "python-xlib"
version = "0.33"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "tfc-forge-macro"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "pynput" },
]
[package.metadata]
requires-dist = [{ name = "pynput", specifier = ">=1.7.6" }]