_DESTROYED_RE requires a digit-shaped id ('SupplyCache#2 Destroyed'),
but an Enemy installation's id (after squash_enemy_names()) is
letters ('Enemy#SignalStation'), so 'Enemy Signal Station Destroyed'
silently matched nothing and never marked the target dead. Same
digit-vs-letter split every other Enemy-aware regex in this file
already needed, just missed here. Added _ENEMY_DESTROYED_RE alongside
it, verified through the real _merge_targets() flow for both an
already-known target and one whose destruction is the first mention
of it at all.
Scrollbar: the ScrolledWindow left horizontal policy on AUTOMATIC (the
default), so a vertical scrollbar's own width could shrink the
content area enough to trigger a horizontal scrollbar too, perturbing
card heights and tripping vertical scrolling that wasn't actually
needed. Pinned horizontal off outright, this is a fixed-width sidebar,
never wanted anyway.
Reorder: the card sort key forced every Strike above every non-Strike
on *every* refresh, not just at creation, silently undoing any manual
drag-reorder that moved a strike below other targets. The 'new
strikes go first' behavior only needs to happen once, at creation
(already handled by _add_strike_at's reorder_target(target, 0)); the
sort key was redundant with that and actively fighting the user
afterward. Removed it.
Session produced a working prototype (docs/map_vision_wip.py, not
wired into the app yet) for the image-based map-screenshot marker
pipeline: label OCR, pitch estimation from label spacing, a matched-
filter grid-intersection detector, whole-grid crossing prediction, and
RANSAC homography fitting, each validated against real screenshots
with diagnostic images along the way.
Found a real remaining bug before pausing: crossings are currently
all predicted from one single reference label using one global pitch,
so predictions drift with distance from that reference under genuine
perspective distortion (confirmed: ~2-4 degree measured tilt, not
noise). Documented the fix (BFS grid-growing: expand one cell at a
time from every confirmed point, using local rather than global
spacing) as the next step, not yet implemented.
New shapes:
- 'Listening Post#1 at K6 7:8 ...': a named anchor given inline rather
than as its own block, parse_named_at_coord() picks up any
'<Name>#<id> at <Coord>' anywhere in the text, RP-shaped like
everything else that resolves to a name+coord.
- 'Distance 6.28km South-East from X': a listening post gives distance
readings with an approximate 8-point compass direction instead of a
precise degree bearing, different word order too (the direction sits
between the distance and 'from', no separate Bearing keyword). New
TargetType.COASTAL_BATTERY for what these turned out to report on.
- 'Coastal Battery#2' / 'Listening Post#1': multi-word type names with
a real digit id already attached (unlike the Enemy case, nothing to
invent), squash_multiword_ids() collapses the embedded space so
_NAMED_HEADER_RE and every from-<ref> clue pattern see the single
token they expect.
Also: squash_bold_spans(), prompted by the observation that a rich-text
paste already tells us exactly where a multi-word name starts and ends
via its own <b>...</b> wrapping, no need to guess from capitalization
the way squash_enemy_names()/squash_multiword_ids() do. Runs before
strip_html() while the tags are still there, using each span's own
content as an authoritative boundary. Those two whole-document regexes
stay as the fallback for plain OCR text, which never has markup to
lean on, but squash_multiword_ids() first shipped with a real bug this
caught: 'Distance 6.28km South-East from Listening Post#1' matched
'East from ListeningPost' as if it were one multi-word name, backward
through the lowercase connector word 'from'. Fixed by requiring
Title Case on every word in that whole-document fallback path (the
scoped bold-span path never had this problem, it can't reach past a
span's own boundary). Verified end to end against the exact example
text, including through the real _merge_targets()/_merge_reference_points()
app flow, plus the full existing regression sweep (Enemy names, train
intel, standard blocks, calibration target).
Previously _draw_geo_overlays() only ever looked at
placed_entities()/ambiguous_entities(), so a target that failed to
resolve entirely (no coord, no potential_coords, e.g. two clues that
don't quite geometrically agree) never got its bearing/distance lines
drawn no matter what, there was no way to see why on the map itself.
Selection is now also a trigger alongside hover/show_geo_desc
(matching how firing arrows and blast radius already key off
selection), and the candidate list comes from board.reference_points
+ board.targets directly rather than the resolved-only views, so an
unresolved selection still shows its clue geometry. Lets you eyeball
whether a bad reading is plain wrong or just off by a bit, bearing
readings apparently carry some real-world error margin.
Selecting text in-game copies it to the clipboard, screenshots were
never the only way in and are now explicitly the fallback for when a
selection isn't practical. Also mention scout flight planning and the
clipboard auto-watch toggle, both added since the last README pass.
'Enemy Signal Station:' and its ilk are named in plain English rather
than the usual Type#N shape, and get referenced the same way
elsewhere ('Bearing 034 from Enemy Signal Station'), breaking two
assumptions everywhere else in this module: headers/references are a
single whitespace-free token, and an id is digit-shaped.
squash_enemy_names() collapses 'Enemy' + up to 4 Title Case words that
follow it into one token in our own id shape ('Enemy#SignalStation')
before anything else parses the text, so every existing from-<ref>
clue pattern and the named-header matcher keep working unmodified.
Wired into both parse_text() (OCR/clipboard) and
parse_clues_from_text() (manual description tab).
Two follow-on fixes this surfaced: _clean_reference() previously
assumed a named reference's id is always digit-shaped and would
truncate 'Enemy#SignalStation' down to 'Enemy#Sig' via
_fix_id_digits's letter-to-digit mapping; and the letter-id header
check needed to run *before* _NAMED_HEADER_RE, whose digit class
overlaps plain letters (S/B/Z/G/O/I/L) and would otherwise
partial-match and mangle the id first.
New TargetType.ENEMY carries these. Verified end to end (including
the solver resolving the cross-references between them) through both
parse_text() and the real _merge_targets() app flow.
A different intel shape entirely: a station's absolute grid ref, the
rail's bearing from it, and waypoints given only as a distance along
that same bearing. ocr.parse_train_intel() turns the station and each
waypoint into RP-shaped entries (bearing+distance-from-station is
solve_location()'s simplest case), merged through the exact same
_merge_reference_points() path as any other RP, no new UI or entity
type needed.
The T=HH:MM:SS timestamp on every line is deliberately never parsed,
there's no game clock to compare it against.
Verified end to end against the real screenshot: MainStation resolves
from its own grid ref, and all three waypoints resolve correctly along
the bearing at their reported distances.
Previously two distance-only clues whose circles didn't actually
intersect (real typewriter data isn't perfectly consistent, a
misplaced spotter or an off-by-a-bit distance reading is enough) just
gave up entirely, even when they were nearly touching.
solver.closest_compromise_point() picks the midpoint between each
circle's point facing the other, the standard notion of the closest
approach between two circles. It stays well-behaved even when the
centers are nearly coincident, unlike projecting along the center
line the way a real intersection's formula does, which diverges as
the centers get close while the radii stay far apart, exactly the
near-coincident case this is for.
The result is flagged rather than treated as a clean fix: Location
gained a field, set whenever solve_location() had to use this
fallback, cleared by any subsequent coord.setter call (manual or
solver), shown in the entity's status label and toasted after a
manual clue edit. Persisted through save/load.
Circle-circle intersection (and the other solvable shapes) already
worked correctly, verified with a direct test: two distance-only
clues resolve to potential_coords whenever the two circles actually
cross. What was missing was feedback when they don't, e.g. two
distances that put one circle entirely inside the other given the
references' real positions looks identical, from the dialog's
perspective, to a reference that's just not placed yet.
solver.explain_unresolved() distinguishes 'waiting on <ref> for a
known position' from 'these two readings are geometrically
inconsistent', wired into _apply_and_refresh's toast after a manual
Description-tab edit.
A ScoutFlight anchors to the center of whatever large grid square the
cursor is in, with its bearing read off where in that square the
cursor actually sits, decoupling a clean anchor point from fine
direction control. The plotted rectangle extends 0.92km back and
13.04km forward along that bearing, 1.21km to each side
(solver.scout_flight_corners), previewed live while placing (see
GridCanvas.start_scout_flight_placement) and drawn as a filled
rectangle once committed.
New 'Scout Flights' header dropdown lists them with replot/hide/remove
per entry, plus a header button to plan a new one. Wired into
Board.clear() and save/load (SAVE_FORMAT_VERSION bumped to 3, old
saves load fine via the new key's default).
Board.clear() drops the Nest position, spotters, reference points, and
targets; wired to a header button behind a confirm dialog since it's
not reversible.
The clipboard-watch toggle connects to Gdk.Clipboard's 'changed'
signal and re-runs the merge-all OCR flow automatically whenever the
clipboard gains new image content, skipping non-image changes (e.g.
text copied elsewhere) so it doesn't spam toasts. Off by default.
Disconnected on window destroy.
'Target is at- Q4 4:2' (no #id, unlike the usual block shape) is how
the game's very first order gives the target. Store it as a fixed
(UNKNOWN, "1") entry since there's only ever one.
Same style fix applied to the README earlier, extended everywhere:
replaced " -- " with commas/colons/periods (picking whichever reads
right per occurrence, splitting into two sentences where the clauses
were independent), fixed a few user-facing strings along the way
(entity list rows, placement/strike toasts, ambiguous-candidate tag,
shell picker button label). Left three intentional non-prose uses
alone: the "unassigned" dash glyph in firing_panel.py (and its
docstring diagram), and ocr.py's dash-variant regex character class,
which needs to literally match em/en-dashes in OCR'd text.
Also caught and fixed a stale models.py docstring claiming "no solver
yet" (solver.py has existed for a while) while touching that
paragraph anyway, and a formatting artifact in coord_dialog.py's
docstring left by the sed pass (misaligned comma from a since-removed
alignment gap).
Verified: py_compile across all files, the 9-screenshot OCR
regression sweep, and a GTK smoke test exercising the edited
toast/placement code paths.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- src/ironnest_assist/ -> src/fenigma/
- run.sh invokes -m fenigma.app
- APP_ID: eu.dominik-roth.IronNestAssist -> eu.dominik-roth.FeNigma
- window title, IronNestApp class -> FeNigmaApp
- __init__.py docstring, README pgrep hint updated
IRON NEST (the game's own name, e.g. NEST_KEYWORD in ocr.py) is left
untouched, only our own project/app naming changed. Verified: clean
import under the new module path, install.sh still builds the venv
correctly, and the OCR regression sweep (9 known screenshots) still
passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
install.sh creates a --system-site-packages venv (so it can still see
the system-installed PyGObject bindings, which pip can't build
reliably) and pip-installs the rest of requirements.txt into it.
Checks for PyGObject (GTK4 + libadwaita) and tesseract up front and
prints per-distro package hints if either is missing, rather than
failing deep into pip install. run.sh now prefers .venv/bin/python3
when it exists, falling back to system python3 otherwise. README gets
an Install section describing this.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Shorter pitch focused on what the app actually automates for the
player: screenshot orders -> geo puzzle + trajectory solved -> ready
fire commands; screenshot the field log -> kills/new contacts picked
up automatically; strike planning with blast-radius preview. Mentions
the map's geometric derivation overlays and ambiguous-intersection
handling. Drops the old deep technical dump in favor of pointing at
code comments for internals. Adds icon.png (used in the header) and
showcase.png (embedded screenshot); icon_alt.png kept as an unused
alternate for now.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>