FeNigma/README.md
Dominik Roth 8bec273b3f Initial commit: IRON NEST Assist
GTK4/libadwaita desktop helper for IRON NEST: Heavy Turret Simulator.
Reads clipboard screenshots of the game's typewriter orders via Tesseract
OCR, parses absolute/relative entity positions, geometrically resolves
relative bearing/distance clues into map coordinates, and provides a
firing-commands sidebar with real ballistics (elevation/azimuth/powder
charge). Screen-reading only — no game files touched, no input injected.

- models.py: Board/Nest/Spotter/ReferencePoint/Target data model
- ocr.py: Tesseract preprocessing + typewriter-text parsing
- solver.py: bearing/distance geometric resolution (4 solvable shapes)
  plus position-based dedup for generic contacts later identified more
  specifically at the same resolved coord
- ballistics.py / shells.py: elevation/azimuth/charge math, shell types
- grid_widget.py: interactive map canvas (hidden entities and,
  optionally, dead targets are excluded from the map view entirely
  unless selected)
- firing_panel.py: drag-reorderable firing-command sidebar
- app.py: main window wiring it all together

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 20:01:50 +02:00

6.9 KiB
Raw Blame History

ironnest-assist

Screen-reading helper for IRON NEST: Heavy Turret Simulator. Tracks the Nest, Spotters, Reference Points, and Targets on a map matching the game's grid, settable by hand, by a free-text description, or by OCR-ing a clipboard screenshot of the typewriter order. A solver resolves relative descriptions ("Bearing 293 from Alpha") into absolute coordinates once their dependencies are known. Single-player QoL tool — no game files touched, no input injected.

Stack

  • GTK4 + libadwaita (via PyGObject) — native GNOME look on Linux. GTK4 itself also runs on Windows/macOS (Win32/Quartz backends), just without full Adwaita chrome there.
  • Pillow / numpy — image preprocessing for OCR.
  • pytesseract + tesseract — text OCR of the typewriter order.

Run

./run.sh

GTK apps with this app ID are single-instance — if a run gets killed uncleanly it can leave a zombie registered on D-Bus and silently no-op the next launch. If ./run.sh seems to do nothing, pgrep -af ironnest_assist and kill any stragglers first.

Coordinate system

Large grid: X in AT (20 cols), Y in 110 (10 rows, row 1 at the bottom). Sub-grid within a cell: x, y in 09. One large cell is 1km × 1km — that scale is what the solver's bearing/distance math runs on. Quick keyboard entry in the coord dialog: type e.g. C433 (letter + 3 digits) to fill and submit in one go — 0 for the Y digit means 10.

Location: coord and/or description

Every entity's location (models.py) independently holds:

  • coord — a resolved absolute position.
  • desc_raw + clues — a raw free-text description and the Clues parsed out of it (reference, bearing_deg, distance_km), each naming another entity it's relative to.
  • potential_coords — set instead of coord when the solver found the position genuinely ambiguous (see below); shown on the map, never chained into further resolution.

These aren't either/or — setting a coord never erases an existing description (and vice versa), since a coord can arrive via OCR after a description was already on file, or a description can be added as extra context for an already-placed entity.

Solver (solver.py)

Walks every RP/Target's clues, resolving whatever it can against currently-known positions, repeating until nothing new resolves (handles chains, e.g. AmmoCache#3AmmoCache#2Alpha/Spotters). Handles:

  1. One clue with both bearing and distance from a resolved reference — always unique.
  2. Two bearing-only clues from different references — ray/ray intersection, always unique (unless parallel).
  3. A bearing-only + a distance-only clue from different references — ray/circle intersection. A ray can cross a circle at 0, 1, or 2 points; when there are 2, that's genuinely ambiguous — both candidates go into potential_coords instead of picking one, and nothing depends on them further.

Two distance-only clues (circle/circle, also up to 2 solutions) isn't handled — even less to disambiguate with.

Runs automatically after every mutation (MainWindow._refresh() is the single choke point), so newly-unblocked descriptions resolve immediately.

Map overlays

Every entity row has an eye-icon (hide from map) and a star-icon (always show its bearing/distance overlay, vs. only on hover). Hovering a marker, or pinning it with the star, draws its clues: a yellow line for a bearing-only clue, a white circle (radius = distance) for a distance-only clue, a yellow arrow when a single clue has both. Ambiguous entities draw both potential_coords as hollow dashed markers. Targets also have an alive/destroyed toggle (checkmark icon) — dead ones show struck-through in their list and dimmed on the map.

OCR

Two sub-pipelines, planned: text (typewriter orders, implemented) and image (map icons/markers, not started). Text OCR: screenshot → grayscale → divide by a heavily-blurred copy of itself to flatten the game's light-falloff vignette → threshold → tesseract --psm 6 → parsing. See src/ironnest_assist/ocr.py.

Two text formats parsed, both fuzzy/typo-tolerant (difflib keyword matching, digit/letter OCR-mixup normalization O/0 I/l/1 S/5 B/8 etc., and every separator — #, :, block-terminating . — treated as just as corruptible as any other character, never matched literally):

  1. Absolute grid refsIRON NEST - C4 3:3, Spotter#1 - F7 7:1nest_coord, spotters.
  2. Field-intelligence blocks — named entity header (Target#5, AmmoCache#1:, Reference Point Alpha:) followed by one or more Bearing/Distance/combined clue lines, terminated by a blank line or lone .reference_points, targets, each as (raw text, parsed clues). The same clue grammar (ocr.parse_clues_from_text) also backs the manual "Description" tab in the coord-entry dialog.
  • Header clipboard button (or Ctrl+P): universal fetch — merges everything recognized into the board.
  • A category's "from screenshot" actions extract only that category; toasts an error if the requested item isn't found in the screenshot.
  • "Load all from screenshot" / a list item's screenshot action both use the same merge: same name/id → update in place, new → add.

Known limitation: OCR on a heavily skewed/rotated screenshot degrades badly — psm 6 assumes a roughly-upright text block, and deskewing isn't implemented. A front-on-ish shot works well. Considered switching OCR engines (vision-LLM structured extraction, PaddleOCR/EasyOCR) instead of continuing to special-case Tesseract's misreads one at a time — staying on Tesseract per your call for now.

Ammunition (shells.py)

Shell enum from High Command's field reference — description, blast radius in km (None where not yet measured), and whether it's a "standard" (unlocked-by-default) type. Feeds the firing-solution calculator later (e.g. AP is required for underground supply caches per the typewriter note).

Firing commands panel

Header button (top-right, sidebar-show-right-symbolic) slides in a right-hand sidebar via Adw.OverlaySplitView — it shares the window's width with the map (narrows it), rather than overlaying on top. One placeholder card per board target (firing_panel.py): shell + quantity, elevation/azimuth readout, confirm/cancel — all dummy values for now, since the actual aiming math isn't known yet (see open questions).

Status

Board data model, map view + overlays, exact/description coordinate input, save/load to JSON, text OCR for both known typewriter formats, the bearing/distance solver, and the firing-commands panel shell are all working. Still open: image OCR sub-pipeline, deskewing, circle/circle ambiguous case, and the actual firing-solution calculator (turret aiming math still unknown — the panel currently just shows placeholder cards).

Open questions

  • What inputs the firing-solution math actually needs, beyond target position and shell choice