# Design ## Purpose This project watches a running instance of the game **Eternal Lands** (an X11 window) and continuously analyzes screenshots of it. In its current form it detects three kinds of state and records them to a JSONL log: 1. **Harvesting** — whether the player is currently harvesting (mining, cutting, etc.), detected from the color of a single pixel near the bottom of the screen. 2. **HUD values** — the numeric readouts next to the five status bars at the bottom of the screen (mana, food, health, inventory capacity, action points), extracted with OCR. 3. **Crafting windows** — the player can drag the Manufacturing and Inventory windows anywhere on screen, so they are located relative to their own title bars (not fixed coordinates). The colored status line inside the Manufacturing window is OCR'd and mapped to a category (`working`, `success`, `failed`, `failed_lost`, `hungry`, `overloaded`, `stopped`, `none`). The near-term goal is observation only: the log is produced so a future **observer** component can read it and act — e.g., automatically resume harvesting when the player stops, or react when health/food get low. Keeping the log format stable and machine-parseable is therefore a first-class requirement. ## Capabilities - **Window capture** (`capture.py`) — finds a window by title (substring, case-insensitive) on X11 and grabs its pixels even when unfocused or occluded, via `XGetImage` with a root-window fallback. - **Harvesting detection** (`analyze.is_harvesting`) — samples the pixel at `(1710, 1068)`. Light tan (~`#f2c285`, luma ≈ 201) means harvesting; dark brown (~`#664c33`, luma ≈ 81) means idle. Decision is `luma >= 140`. - **Bar value extraction** (`analyze.read_bar_values`) — crops the small number that precedes each bar, binarizes it, upscales 3x, and runs tesseract with a digits-only whitelist (`--psm 7`). - **Window verification** (`analyze.verify_window`) — checks a `150x42` crop around a candidate point for the title substring `anufact` (Manufacturing) or `nven` (Inventory). This is the per-frame cache check; it passes on all 53 reference frames. - **Window recovery** (`analyze.find_windows`) — full-frame OCR scan (`--psm 11`, run at two scales) that collects candidate title words and verifies each. Used only when a cached position stops verifying, i.e. after the window moves. - **Craft message OCR** (`analyze.read_manufacturing_message`) — crops a window-relative region, isolates green or red text pixels, tight-bboxes them, and OCRs the isolated color channel. - **Message classification** (`analyze.classify_message`) — maps an OCR'd message plus its color to a stable category string. - **Window cache** (`main.WindowTracker`) — keeps last-known window positions, re-verifies them each frame, and triggers a full-frame rescan only when a cache miss happens. - **JSONL logging** (`main.py`) — appends one JSON object per frame. - **Replay mode** (`main.py --replay DIR`) — processes existing PNGs instead of a live window, so analysis and OCR can be verified offline. - **Test suite** (`tests/`) — folder-driven pytest tests; dropping a new screenshot into a folder automatically covers it. ## Architecture ``` capture.py X11 window capture -> PIL Image (RGBA) analyze.py Pure functions: summarize, is_harvesting, read_bar_values, verify_window, find_windows, read_manufacturing_message, classify_message, load_image, difference. No side effects. main.py CLI entry point. Live capture loop or replay mode; owns the WindowTracker cache, builds the JSON record, appends to log. tests/ Folder-driven pytest tests + test images. ``` - `capture.py` is the only module that talks to X11. - `analyze.py` is pure image analysis — every function takes a PIL image (or path) and returns data. This makes it directly testable. - `main.py` wires them together and owns I/O (capturing, logging, printing). ## HUD calibration All screen coordinates are hardcoded for a **1920x1080 captured window** and live at the top of `analyze.py`. They were measured from real screenshots and must move only if the game window resolution/layout changes. | Constant | Value | Meaning | |---|---|---| | `HARVEST_PIXEL` | `(1710, 1068)` | pixel that flips color while harvesting | | `HARVEST_LUMA_THRESHOLD` | `140` | above = harvesting, below = idle | | `NUMBER_REGIONS["mana"]` | `(0, 1033, 62, 1048)` | crop before the blue bar | | `NUMBER_REGIONS["food"]` | `(195, 1033, 226, 1048)` | crop before the yellow bar | | `NUMBER_REGIONS["health"]` | `(355, 1033, 389, 1048)` | crop before the red bar | | `NUMBER_REGIONS["inventory"]` | `(520, 1033, 552, 1048)` | crop before the dark bar | | `NUMBER_REGIONS["action_points"]` | `(610, 1033, 714, 1048)` | crop before the purple bar | Regions are `(x0, y0, x1, y1)`. `y0` starts at 1033 because row 1032 is the panel's top border, which would confuse OCR. Observed values on the reference screenshots: mana `32`, food `39`, health `40`, action points `140`; inventory capacity varies (`110` → `108` → `106`) as items are collected. ## Window calibration The Manufacturing and Inventory windows are semi-transparent panels the player can drag anywhere, so their positions are **relative to the window title bar**, never fixed screen coordinates. All offsets below are measured from real captures and live at the top of `analyze.py`. | Constant | Value | Meaning | |---|---|---| | `TITLE_VERIFY_CROP` | `(14, 12, 150, 42)` | `(left, top, width, height)` crop around a candidate title point | | `TITLE_VERIFY_THRESHOLD` | `115` | binarization threshold for the title crop | | `TITLE_SUBSTRING` | `anufact` / `nven` | OCR substring that proves the title bar | | `MESSAGE_SEARCH` | `(175, 105, 600, 70)` | craft message search region relative to the title | | `MESSAGE_COLOR_GREEN` | `(170, 120, 30)` | green-text pixel filter `(min g, min g-r, min g-b)` | | `MESSAGE_COLOR_RED` | `(170, 100, 60)` | red-text pixel filter `(min r, min r-g, min r-b)` | | `FIND_OCR_SCALES` | `(1.0, 0.75)` | full-frame scan scales; some frames only recover at one | - The craft message sits at `(title_x - 161, title_y + 121)` — measured at 5 different Manufacturing window positions, all the same offset. `MESSAGE_SEARCH` is a wider region around it that absorbs title-detection jitter. - The title bar is ~11px tall. The verify crop is deliberately generous (`±14/±12` px) because the verify point is the title-bar center, which OCR occasionally reports a few pixels from the glyphs. - Full-frame recovery is approximate: OCR word boxes land within ~15px of the true title. Positions are re-anchored by `verify_window`, so exactness only matters for the message offset, and `MESSAGE_SEARCH` absorbs the jitter. - Reference window positions across the capture sessions: Manufacturing at `(488, 480)`, `(376, 262)`, `(1330, 194)`, `(1005, 565)`, `(281, 676)`, and `(1104, 327)`/`(1104, 337)`; Inventory always `(1629, 686)`. ## Log format One JSON object per line, appended to `analysis.jsonl` (configurable via `--log`): ```json {"time": "20:31:02", "frame": 4, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 39, "health": 40, "inventory": 106, "action_points": 140}, "manufacturing_window": [1104, 327], "manufacturing_message": {"text": "you successfully created 1 potion of minor healing", "color": "green", "category": "success"}, "inventory_window": [1629, 686]} ``` - `frame` is an integer in live mode, the file name in replay mode. - `bars` values are integers, or `null` when OCR could not read them (e.g. tesseract not installed). Consumers must tolerate `null`. - `manufacturing_window` / `inventory_window` are `[x, y]` title-bar coordinates of the (currently verified) window, or `null` when unverified. - `manufacturing_message` is `{"text", "color", "category"}` or `null`. `text`/`color` are `null` when no message is showing (category `none`); `manufacturing_message` itself is `null` when the Manufacturing window position is unverified. Colors: `green` (success/working), `red` (failed/failed_lost/hungry/overloaded/stopped). - Message categories: `working`, `success`, `stopped`, `failed`, `failed_lost`, `hungry`, `overloaded`, `none`, `unknown`. - This shape is the contract for the future observer — it should not change without updating `tests/` and the observer accordingly. ## Testing `tests/images/` holds the screenshots used as fixtures, grouped by expected behavior: ``` tests/images/harvesting/ is_harvesting must be True tests/images/not_harvesting/ is_harvesting must be False tests/images/corrupt/ must be skipped gracefully (None) tests/images/manufacturing/ craft message at the pinned title position must /*.png classify to the folder's category tests/images/inventory/ verify_window must pass at the pinned position ``` Tests are **folder-driven**: new edge cases are added by dropping a screenshot into the appropriate folder; no test code changes needed unless a new expected *value* must be pinned. `tests/test_bars.py` pins per-frame expectations (e.g. inventory = 110/108/106 depending on frame). `tests/test_windows.py` pins the Manufacturing title position per fixture frame (`MFG_POSITION`), the Inventory position, and a representative subset of `find_windows` recovery cases. ## Dependencies and setup System: - `tesseract-ocr` (OCR engine; the Python binding is `pytesseract`) - X11 (for live capture) Python (installed in the project virtualenv `.venv/`, see `requirements.txt`): - `python-xlib`, `pillow`, `pytesseract`, `pytest` ```bash python3 -m venv .venv .venv/bin/pip install -r requirements.txt .venv/bin/python -m pytest tests/ .venv/bin/python main.py # live capture .venv/bin/python main.py --replay tests/images/harvesting ``` ## Known limitations - Coordinates are resolution-specific (1920x1080). Resizing the game window requires recalibration of `HARVEST_PIXEL` / `NUMBER_REGIONS`. - OCR depends on the system `tesseract` binary; if missing, bar values log as `null` (harvesting detection is unaffected). Window/message OCR degrades the same way (`verify_window` returns False, messages read as `null`). - The panel appears to be semi-transparent; some scene pixels show through. So far this has not affected detection, but a very different background could. - Full-frame window recovery (`find_windows`) is not 100% reliable — it found the Manufacturing window in all 53 reference frames but missed the Inventory window in one harvesting frame. `WindowTracker` compensates by retrying on every frame while a window stays stale, so a missed recovery only delays the update by a frame or two. The cheap per-frame `verify_window` cache check is the workhorse (53/53) and recovery only runs after a window moves. - The steady-state loop adds roughly 0.5s/frame for the two window verifies plus message OCR; a full-frame recovery scan costs ~5-9s but only happens after a window moves.