The -arch x64 fix (previous commit) is confirmed live: build_errorlevel=0, BUILD_DONE, a real 958MB FEnigma-0.1.0.msi written to Z:\dist -- the first ever fully successful build this pipeline has produced. Copied to dist-windows/FEnigma-0.1.0.msi (gitignored). Not yet installed/launched on a real Windows machine to confirm the app actually runs -- packaging succeeding isn't the same claim as the app working once installed, per this repo's own README note. Logged as the next thing to check, along with light.exe's own ~15-18min runtime now that there's a clean build to measure it against. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
26 KiB
Bug Backlog (from user report, 2026-08-11)
Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input
-
Allies and enemies seem to share indices. First pass on this was wrong: I only checked that targets and allies are separate id namespaces (they are, always were) and stopped there. The actual bug was one level down:
Board.add_target/add_ally's auto-id assignment (used = {t.id for t in self.targets if t.type == type_}) was scoped per type, not per group — a Tank and an Infantry auto-added back to back both got id "A", each type getting its own independent A/B/C... sequence instead of sharing one across the whole group. Fixed: the id namespace split is targets-vs-allies ONLY, type never subdivides it further. New regression test (test_auto_id_is_shared_across_types_within_targets_and_within_allies). -
Regression FROM the fix above, caught via a real traceback: sharing one A/B/C... sequence across a whole group (instead of per-type) made it much easier to actually run out of the 26 letters --
next(c for c in string.ascii_uppercase if c not in used)raisesStopIterationthe instant all 26 are taken, silently killing whatever button click triggeredadd_target/add_ally(this is what "Accept as"/"Accept all" doing nothing turned out to be, see below). Fixed with_next_free_id(): rolls over to two-letter ids ("AA", "AB", ...) instead of raising, can't run out. New test (test_auto_id_survives_past_26_entities_in_one_group). -
Ally type 'ally' is called Enemy on map title.
icons._target_type_label(now publicicons.target_type_label) already special-cased this for the type picker, but the map's right-click popover heading, "Change type (...)" button, and toast all printedobj.type.valuedirectly instead, so an Ally with the ad-hoc TargetType.ENEMY still showed "Enemy" everywhere except the picker itself. Fixed inapp.py(_display_name, and the three spots using it). -
Reordering firing commands lags UI hard.
FiringPanel._reorder()was callingself.on_change()— app.py's full-app refresh (re-solve every target's clue graph, dedupe, redraw the map, THEN rebuild the panel) — on every single drag-drop, even though reordering touches no location/clue/coord state at all. Now calls a localself.refresh()instead. -
"Always show geo" doesn't reliably work / blast radius should stay shown too.
GridCanvas._draw_geo_overlays()'s candidate list wasreference_points + targetsonly — Allies have ashow_geo_descpin in the UI and can carry OCR'd clues too, but were never drawn. Added._draw_blast_radius()only ever looked atself.selected, ignoringshow_geo_descentirely, so pinning it and then selecting/ deselecting something else made it vanish; now iterates every selected-or-pinned target. -
Clearing the board doesn't clear allies.
Board.clear()cleared everything exceptself.allies. Fixed, plus the "clear board?" confirm-dialog's early-return guard (which skipped the whole action if only allies were on the board) now checks allies too. -
Allow right-click on Clear button: clear all enemies/units/flights, keep spotters/RPs/nest. New
Board.clear_units()+ a right-click popover on the header's Clear button wired to it. -
On map-reading error: save a screenshot locally to adapt the algo. New
debug_capture.py—save_map_read_failure()writes the PNG + the solver's rejection reason under$XDG_DATA_HOME/fenigma/debug_captures/failures/, wired intoapp.py's_start_map_import. -
When the user corrects the grid, store screenshot + ground truth too.
debug_capture.save_grid_correction(), wired into_accept_grid: fires only when the acceptedGridSolutionisn't the one auto-solve produced (the user actually dragged a handle in GridFixDialog), saves both solutions under.../debug_captures/corrections/. -
Many map screenshots seem to get read as text; if nothing relevant is found, also store the image to check whether it was actually a map.
debug_capture.save_maybe_map(), wired into_ocr_png: fires when a screenshot (not a plain-text paste) fell through to the OCR/text path and_merge_allfound nothing at all. Saved under.../debug_captures/maybe_map/. -
Unable to parse 3 given chat messages (Infantry "taking fire" fire- support requests). A different grammar from the existing Marine Garrison fire-support request: reversed shell word order ("Requesting X Shell" vs "X Shells requested"), a bare "before/by
-
Follow-up bug in the above: the bearing/distance-offset variant names TWO different places (the reporting unit's own position, and a separate fire point offset from it), but only produced one Target entity, sitting at the offset point but still labeled with the unit's own type/id (e.g. "Infantry#11" at a spot no infantry is actually at). Math itself was right; the single-entity shape wasn't. Now produces two entries: the original (Infantry#N etc.) keeps its own reported position with no shell/deadline, and a new synthetic
Strike#<TypeWord><id>entry (e.g.Strike#Infantry11) carries the shell/deadline at the computed offset coord. 2 more regression tests. -
When the user deletes/replaces the map screenshot, capture whatever units they confirmed as ground truth for it.
ScreenshotImport.baseline_targets/baseline_allies(a snapshot ofboard.targets/board.alliestaken when the grid is confirmed,Target/Allyare identity-hashable so these are plain sets of the live objects) letapp.pytell "added while this screenshot was up" apart from "was already on the board".Proposalalso now recordsconfirmed_type(what the user actually accepted it as, which can differ from the detector's own guess via "Accept as..."). All of it -- every proposal's accept/reject/undecided verdict, plus every target/ally added with no matching proposal at all (a manual add or an OCR-text merge run alongside the screenshot) -- is saved viadebug_capture.save_marker_ground_truth()under.../debug_captures/marker_ground_truth/. Wired into all three places a screenshot stops being "the active one": explicit drop, a new screenshot pasted straight over it, and window close.
Resolved via a real traceback (not guessed)
- "Accept as" / "Accept all" on proposed targets doing nothing.
A real traceback from the running app nailed it:
StopIterationfromBoard.add_ally's id auto-assignment once 26 allies existed already (see the id-namespace regression entry above) — every accept attempt after that silently died before the ally/target ever got added, popover already closed by the time it happened. Fixed there; not a separate bug. - "Accept as…" (the type-picker submenu on a proposal, and "Change
type" on an already-placed entity) opening to a visibly empty/
unchanged popover. This one left no traceback at all -- confirmed
live with temporary debug prints that the button's
clickedsignal fires, the icon grid builds successfully (all N types), andPopover.set_child()on the already-open outer popover reports the rightvisible=True/width/height afterward... but the compositor never actually repaints that reused surface, so nothing new ever appeared on screen. Fixed by not resizing the existing open popover at all: popping it down and opening a genuinely new one (fresh native surface) at the same anchor point instead. Same fix applied to both call sites (_open_proposal_menu'sshow_type,_open_entity_menu'sshow_type, the latter refactored to share the same_reopen_with()helper). - New: mark a Target as underground, at a hardening tier (1-3),
rendered as the game's own Armor-tier additive badge stacked on
the icon.
Target.underground_tier: int | None, a "Mark underground" entry in the entity-edit popover (tier picker reusing the same fresh-popover fix above), andGridCanvasdraws the badge above the marker's icon, overlapping down into it by_ADDITIVE_OVERLAP_PX-- both the diamond icon's top corner and the badge's bottom are tapered to a near-point, not a flat edge, so bbox-exact touching still read as a gap; a real pixel overlap is what actually looks contiguous (confirmed against the game's own stacked-badge screenshots). Badge is scaled/positioned off the art's real opaque content (PILgetbbox()), not its PNG canvas -- the additive files carry a lot of off-center transparent padding that made the badge look tiny and floating if sized off the raw canvas.
Needs more scope / your input before I keep going
-
[~] Enemy type detection needs to be more robust; read the entity id label so dedup is reliable; detect death from the log.
Started on the id-reading piece: `map_vision.read_marker_id` reads each marker's own small "#<N>" label (distinct from the big per-cell grid label `read_cell_label` reads) via the SAME template- correlation approach as `read_cell_label`, not OCR -- this text sits over the same aerial-photo backdrop that this module's own docstring says defeated every detection-based approach tried for grid labels, so pytesseract (already tried elsewhere in this repo, `ocr.py`, for a different image domain: flat scanned paper, not photo-textured) was skipped in favor of the approach already proven here. Wired end-to-end: `find_markers` -> `Proposal.detected_id` -> `debug_capture.save_marker_ground_truth`'s JSON. Reads against `ScreenshotImport.full_image` (sharper than the WORK_W image detection itself runs against) when available. Crop region and `MIN_MARKER_ID_SCORE` are a single-screenshot calibration (see `read_marker_id`'s own docstring) -- UNVALIDATED against a real ground-truth batch (none of the 6 existing captures have a confirmed id to check against, they all predate this). New unit tests (`tests/test_map_vision_marker_id.py`) only cover the synthetic-render round-trip, not real-screenshot accuracy. Measured type-detection reliability against the 6 existing `marker_ground_truth` captures (72 accepted proposals total, 2026-08-13): **0/72 (0%) had ANY confident `detected_unit` guess** -- `classify_marker` returned `None` on every single one, every side, every capture. Not "guesses wrong" -- never confident enough to answer at all. Spot-checked directly against one real marker crop (a hostile Infantry, confirmed by the user): best match was "Underground Fort" at score 0.376 (Infantry wasn't even in the top 8), against a `min_score=0.55` floor `classify_marker` requires -- not a close miss, a real correlation failure. The clean rendered icon templates `icon_bank()` matches against apparently don't correlate well with how markers actually look in a real screenshot (compression/blur/aerial-photo texture underneath), unlike text glyphs (`read_cell_label`'s measured 0.73-0.87 vs 0.40-0.56) where the same template-correlation idea works well. Added `unit_score`/ `unit_margin` to `Proposal`/ground-truth JSON (previously only pass/fail `unit` was logged) so every future capture shows exactly how far off a guess was, not just None -- there was no way to tell "barely missed the bar" from "wildly wrong" before this. Death-detection-from-log is still fully unstarted -- no log-parsing code exists in this repo at all yet, real scope work (find/access the game's log, agree a "<Type>#<id> Destroyed" grammar, wire it into a dedup key) rather than a quick pass. Follow-ups from user feedback after the above landed: - `_accept_proposal` now actually USES `detected_id` (it was only being logged before, never applied) -- an accepted proposal's entity id prefers the detected number over auto-assignment, falling back on a collision. 4 new regression tests (`tests/test_app_accept_proposal.py`). - Auto-assignment itself (`Board.add_target`/`add_ally` with no `id_`/no usable detection) changed from one shared letter sequence per group (targets, or allies) to its own sequence per TYPE within each group -- Tank#A/Infantry#A rather than Tank#A/Infantry#B. This directly reverses an earlier deliberate fix in this same file (see the "Allies and enemies seem to share indices" entry above, which moved FROM per-type TO shared-per-group) -- that fix is still correct for what it fixed (targets-vs-allies must stay separate namespaces), just not for per-type-vs-shared, which the user has now clarified the other way. Tests in `test_models.py` updated to match (renamed `test_auto_id_is_shared_across_types...` -> `test_auto_id_is_per_type...`, since it now asserts the opposite). First pass at this ALSO switched auto-assignment from letters to plain numbers (1/2/3...), reasoning that it should match what `detected_id` looks like when read successfully. Wrong -- caught by the user immediately: auto-assignment (no real id known, a manual add or an accept with no confident read) and a genuinely detected id need to stay visually distinct, or a made-up auto-assigned number could collide with, or be mistaken for, a real one. Reverted back to `_next_free_id` (letters, rolling over to "AA"/"AB"/... past 26 rather than raising `StopIteration`) as the auto-assignment fallback, scoped per type same as above; plain numbers are reserved for an id `_accept_proposal` is actually confident was read off the marker itself (`Proposal.detected_id`), passed straight through as `id_` and never touching auto-assignment at all. - `_accept_proposal` now actually USES `detected_id` (it was only being logged before, never applied) -- an accepted proposal's entity id prefers the detected number over auto-assignment, falling back on a collision (scoped per type, same bug fixed in two places: this collision pre-check, and the "Change ID" popover's own check, which still enforced the OLD shared-per- group rule after the auto-assignment change above and rejected valid renames across types). 4 new regression tests (`tests/test_app_accept_proposal.py`). - `detected_id` is now shown, not just logged: the proposal popover's heading (", id #8") and the pending-proposal's own on-map label (`? #8 G8 5:4`) both show it while there's still a screenshot up to check it against by eye.
Windows build (packaging/windows) -- real progress, not yet a clean pass
Booted the actual dockur/windows build VM and drove it live (VNC) to find out what's really failing, rather than guessing from the README's own "UNTESTED end to end" note. Three real, separate bugs found and fixed, each confirmed live against the real VM, not just read off a diff:
-
The build watcher was never actually installed at all, despite
install_progress.logclaiming every provisioning step succeeded.C:\OEM(dockur's/oemstaging dir) doesn't reliably persist past Windows Setup finishing -- exactly what the (already-uncommitted, now committed)install.bat/watch_build.batfix diagnosed, just never verified against a real run before now. A 2-day-oldBUILD_REQUESThad been sitting unclaimed the whole time. Manually re-applied the fix's logic live once (copied the corrected files toC:\FenigmaBuild, registered the Startup-folder entry) and confirmed on a full container restart that the watcher now auto-starts on login and picks up a pending request with zero manual intervention -- the actual fix, not just my live patch, is what's doing that. -
pip install pytesseractfails outright: MSYS2's mingw64 Python enforces PEP 668 ("externally-managed-environment"), whichinstall.batnever accounted for. Needs--break-system-packages. -
import fenigma.appfails withModuleNotFoundError: No module named 'cv2'even afterpacman -S mingw-w64-x86_64-opencvsucceeds -- that package is the C++ library only. The actual Python bindings are a SEPARATE package,mingw-w64-x86_64- python-opencv, thatinstall.bat's dependency list simply never included. (pip install opencv-python-headlessas a fallback doesn't work either and shouldn't be relied on: MSYS2's mingw64 Python uses a different ABI than PyPI's Windows wheels --cp314-mingw_x86_64_msvcrt_gnuvswin_amd64-- so pip can never use a prebuilt wheel there, only build from source, which then needs a full separate native toolchain -ninja/cmake/gcc- this VM doesn't have either.)All three are one-line fixes once known.
install.bat's pacman package list and pip install line need these applied for a from-scratch VM to provision correctly (currently they're only proven fixed live on this session's VM, not yet folded back into the committedinstall.bat-- do that before relying on a fresh./build_windows.shrun from scratch).With all three fixed,
import fenigma.appsucceeds and a real build attempt got all the way through source copy, sanity check, dist-tree assembly, and WiX harvest+compile (candle.exe) -- further than this pipeline has ever gotten. Two more issues surfaced right at the finish line:-
product.wxs'sVersionneeds strict WiXx.x.x.xnumeric form -- a0.1.0-testversion string (my own test invocation, notbuild_windows.sh's real default) failscandle.exewith CNDL0108/CNDL0010. Not a real bug, just don't pass a version with a suffix. -
light.exe(final MSI linking) did not finish within 15 minutes on a first retry (4 CPU / 8GB RAM VM) before the RAM-conscious auto-shutdown killed it -- turned out to be genuinely just slow (process was active, 343MB working set, not hung on a dialog), not a real bug: retried with a 40-minute budget and it finishedlight.exeitself in a few more minutes. -
...and then failed for a REAL reason right at the very end:
light.exe's ICE80 validation rejected essentially every harvested file -- "This 32BitComponent ... uses 64BitDirectory".product.wxs's ownINSTALLFOLDERis correctly underProgramFiles64Folder(a 64-bit mingw64 toolchain is what's actually being packaged), but nothing was making the components agree -- a real, on-disk mismatch, not a transient VM issue. First fix attempt (-platform x64onheat.exe's harvest) was WRONG -- re-verified live, identical ICE80 failures afterward (confirmed the correctedbuild.bathad actually reached the VM this time, ruling out a stale-copy repeat of the earlier watcher bug). WiX v3'sheat.exe -platformonly affects registry-key harvesting, it never stampsWin64="yes"on components. Real fix:-arch x64oncandle.exe(the COMPILE step, not the harvest step) -- sets the default Win64/Platform for every component compiled from either source file, hand-authored (product.wxs) or harvested (files.wxs) alike, the standard WiX v3 way to make a whole package consistently 64-bit. Kept the harmless-but-insufficient-platform x64onheat.exetoo.**CONFIRMED live**, third attempt: `build_errorlevel=0`, `BUILD_DONE`, and a real 958MB `FEnigma-0.1.0.msi` written to `Z:\dist` -- the first ever fully successful build this pipeline has produced. Copied to `dist-windows/FEnigma-0.1.0.msi` in the repo root (gitignored, same as `build_windows.sh` itself would do). NOT yet installed/launched on a real Windows machine to confirm the app actually runs (see the "Not tested against a real GTK4/libadwaita Windows install at all" line in this repo's own `packaging/windows/README.md` -- still true, packaging succeeding is not the same claim as the app working once installed). Follow-up, now that a clean build exists to measure against: `light.exe` alone took ~15-18 minutes even with ICE80 fixed -- revisit the ~1GB+ bulk-copied mingw64 dist tree (README's own "not lean" note) as a real perf issue, not just a packaging- correctness one.
-
OCR: new fire-support-request grammar gaps (from real user-pasted messages)
-
A "taking fire" report's reporting unit ("Infantry#11 taking fire!...") was being added as a hostile Target, not a friendly Ally -- see the id-scheme entry above for the "no Friendly/Hostile prefix word exists in this grammar" root cause and the fix (
_TAKING_FIRE_RE,force_ally, and splitting the shell/deadline into a synthetic StrikeRequest even for the no-offset "on our position" case, which previously kept them on the entity itself -- fine when it was wrongly a Target, silently lost once correctly an Ally, since Ally tuples carry no shell/deadline fields at all). 4 existing tests updated, all still passing plus the rest of the suite (51 total). -
A multi-shell sequential request ("Requesting TEAR Shell first, then HE Shell, at bearing...") only captures the FIRST shell (TEAR) into the structured
shellfield -- "then HE Shell" isn't parsed into anything. Less urgent than it first looked though: the full original message text (both shells, in order) is already preserved as-is and shown to the player via the coord dialog's description view (Location.desc_raw, set from the samerawevery merged target/ally carries) -- nothing is silently LOST, it's just not machine-parsed into a queryable second-shell field. Real scope question before building that: does the board/firing-panel data model even have a place to put a second shell for one strike request today, or does this need a new field/shape entirely -- worth confirming it's actually wanted (vs. "read the raw text yourself, it's right there") before spending the design effort. -
"Answer by 10:30:00" turned out to be one bug, not two. The phrasing itself was never the problem --
_TAKING_FIRE_TIME_REalready matches anybefore|by <time>, "Answer BY 10:30:00" included. The REAL bug: a same-message "Important: ..." follow-up line was misread as a brand new named entity header (the last-resort bare-<name>:fallback rule matched "Important:" itself with nothing excluding common prose lead-ins), creating a bogusTarget#Importantthat stole "Answer by 10:30:00" into ITS ownrequested_timeinstead of the real report's. Fixed with a blocklist (_BARE_NAME_HEADER_BLOCKLIST: important/note/warning/ attention/caution/alert/reminder/priority) on that fallback rule -- once the phantom split stopped happening, the deadline resolved onto the right entry with no separate fix needed. New regression test, confirmed against the user's real pasted message (with an assumedInfantry#N taking fire!header line prepended, since their paste seems to have been cropped before it). -
"<Type>#<id> Destroyed" kill-feed parsing already exists and already marks the matching Target dead (
parse_destroyed,_merge_targets's own destroyed-handling block in app.py) -- confirmed working end-to-end against a real 9-entry kill-feed paste, including multi-word types ("Enemy Mechanized Infantry#2 Destroyed" correctly resolved to INFANTRY_MECHANIZED). This was already-existing, working functionality, not something needing to be built. One real gap found in the same test: "Enemy Field Gun#1 Destroyed" silently dropped. Two bugs stacked, both fixed: - [x] "Field Gun" is just the game's own alt name for plain Artillery (confirmed by the user directly) -- not a missing unit type needing a new enum member/icon after all. Added to_TYPE_WORD_ALIASESnext to AmmoCache/CoastalBattery. - [x] Even with that alias, it still didn't resolve:_ALLY_PREFIX_REonly ever stripped a leading "Friendly"/"Hostile" word, never "Enemy" -- sosquash_multiword_ids's "EnemyFieldGun#1" token got alias-looked-up and fuzzy-matched as a WHOLE ("EnemyFieldGun" vs "Artillery", nowhere close), not just its "FieldGun" part. "Enemy Mechanized Infantry#2" only ever worked by fuzzy-match ACCIDENT (a long, distinctive type string still clears the ratio threshold with "Enemy" stuck to the front; a short, unrelated one like Artillery doesn't)._ALLY_PREFIX_REnow strips "Enemy" too, with a lookahead requiring something after it -- a BARE "Enemy#N" isTargetType.ENEMYitself (its own value IS "Enemy"), stripping unconditionally would've left an empty type_word and broken every ad-hoc "Enemy#N Destroyed" report instead. 3 new regression tests, all passing (54 total).