Pokered Save Editor 2
Pokemon Red & Blue save file editor - Qt 6 C++/QML
Loading...
Searching...
No Matches
2026-06-15 — Session Log

Exchange: fix the money<->coin rate (from pokered) + a clear +Money/+Coins converter

Twilight: the exchange rates were wrong (derived from game data), and wanted a clearer converter — money left, coins right, a button by each that adds to that side at the shown rate, +/- deltas above, Checkout.

  • Rate fix (save-critical). The money exchange was inverted: it computed coins = rate * money (giving ~20 coins per ₽1) instead of money = rate * coins (₽20 per coin). The rate itself was correct — gameCorner.json "price": 20 = ₽20/coin, which itemdbentry also uses (it divides Mart prices by 20 to get coin prices), confirming a coin is worth ₽20. So the bug was purely the C++ math. ItemMarketEntryMoney now uses onCart = number of coins; added moneyDelta() (-buyRate·n buying / +sellRate·n selling) and coinsDelta() (±n); rewrote onCartLeft (bounded by coin/money caps + affordability/ownership), canCheckout, and checkout. Model exchangeMoney/CoinsAfter now sum those deltas. JSON untouched (per Twilight's new rule — ask before editing data JSON; the data was right anyway).
  • Converter redesign. MONEY (left) ⇄ COINS (right), each a big live balance with a ±Δ above and a button under it: "+ Coins" (buys 1 coin, near coins) / "+ Money" (sells 1 coin, near money), each with the per-coin rate beneath (₽20 / coin, ₽10 / coin). Material accent Buttons with autoRepeat (hold to bulk) that disable at the caps / when unaffordable. They drive one net axis via new model API exchangeAdjust(±1) + exchangeNet() (+Coins → +1, +Money → -1, so they cancel), plus exchangeBuyRate/exchangeSellRate props for the labels. Replaced the spend→get lanes (and their StepPill use; StepPill still backs the shop rows).
  • Tests: exchangeMode_swapsMoneyForCoins rewritten to the ₽20/coin math (1 coin → money −20, coins +1, and the same after checkout); new exchangeAdjust_netAxis (buy/sell/cancel + clamps). Full ctest green (66/66). VERSION 0.8.4 → 0.8.5 (PATCH).

Market: Exchange becomes a money<->coins converter; pret/pokered cloned in-repo

Twilight: make Exchange "something different and better" (it felt out of place as a shop list). Built the converter idea we'd discussed.

  • Header moved to a single full-width accent bar holding both segmented strips (was inside the left pane). The body below now switches on isExchangeMode: the two-pane shop (Buy/Sell) or the converter (Exchange). The left list / right receipt only build in shop mode (model: bound to null in the other mode so hidden delegates aren't created).
  • Converter card (Exchange): a centered white card with both balances — MONEY ⇄ COINS shown large with the live resulting balance and a (±Δ) under each (from exchangeMoney/CoinsAfter/ …Start) — and the two swap lanes rendered from the exchange money rows: "₽ → ⭘" and "⭘ → ₽", each a StepPill (spend amount) + a live "= <get>". Checkout (footer) commits as before.
  • StepPill inline component extracted (the -/qty/+ pill with the borderless DefTextEdit, the live guard, and a Connections re-seat on external value change). Now shared by the shop rows AND the converter lanes — removed the duplicated stepper.
  • New model role MoneyDirRoledataMoneyDir (money row's forceDir: 1 = Money=>Coins, 0 = Coins=>Money; -1 otherwise) so each lane labels its own direction/currency cleanly (no name parsing).
  • The old right-pane dual-currency receipt (exchange variant) was removed — the converter replaces it.

pret/pokered reference clone. Per Twilight, cloned github.com/pret/pokered (the documented Gen 1 disassembly — the oracle for save-format/behavior) into assets/references/pokered (the canonical in-repo reference home from decisions/architecture.md; gitignored contents). It's inside the Cowork mount, so it's now scannable with the Read/Grep/Glob tools directly. ~3.7k files incl. main.asm.

Build/test. C++ (one role) + the QML restructure. Full ctest green (66/66), including the empty new-file GUI path that caught the earlier use-after-free. App relaunched. VERSION 0.8.3 → 0.8.4 (PATCH).

Market: header title, wider cart, and a SemVer reset (favor PATCH; 0.10.1 → 0.8.3)

  • Header title → "Market". The Home tile was renamed earlier, but the screen's header bar title still read "Pokemart" — it comes from the router registration (router.cpp screens.insert("pokemart", new Screen(false, "Pokemart", …))), not the tile. Changed that title to "Market". The venue segmented tab stays "Pokemart" (the route key + Home page stay pokemart).
  • Cart a bit wider: receiptPane Layout.preferredWidth paneRow.width * 0.33* 0.37.
  • SemVer reset (Twilight, project leader). I'd been bumping MINOR for every feature, which ran the number to 0.10.x — too high; should read "around 0.8". New standing policy: PATCH is the default for almost everything (the PATCH number isn't capped at 9 — let it climb to 2-3 digits); MINOR only for a real milestone. Reset VERSION 0.10.1-alpha → 0.8.3-alpha (continues from the released v0.8.2). Updated the guidance in VERSION + reference/versioning.md; saved a memory. The published v0.9.0 / v0.10.0 / v0.10.1 GitHub pre-releases are stray higher numbers (same work) — offered to delete them to tidy the list (needs Twilight's OK; destructive). Full ctest 66/66.

Market: Home-tile rename, Exchange into the venue strip, segmented-button corner fix

Three small UI tweaks from Twilight on the shop screen:

  • Home tile → "Market" (HomeIconsModel.qml name). Only the Home label; the venue tab stays "Pokemart" (Twilight: "rename it only on the home screen not the tab"). Route key page: "pokemart" and the router registration are unchanged.
  • Exchange moved off the action strip onto the venue strip. Header is now action [Buy | Sell] (stripEnabled: !isExchangeMode, so it greys when Exchange is picked) + venue [Pokemart | Game Corner | Exchange] (index 2 → isExchangeMode = true; 0/1 → isMoneyCurrency). Reads more naturally — Exchange is a place/mode alongside the two shops, not a buy/sell action.
  • Selected segment corners weren't rounded. The SegStrip container has radius + clip, but clip is rectangular so the selected fill's outer corners stayed square at the strip ends. Fixed with per-corner radius on the fill (topLeftRadius/bottomLeftRadius on the first segment, topRightRadius/bottomRightRadius on the last; Qt 6.7+ feature) matching seg.radius.

QML-only. Kit + tst_qml_screens rebuilt; smoke green (pokemart loads clean). App relaunched. VERSION 0.10.0 → 0.10.1 (PATCH).

Pokemart: unified Buy+Sell cart (per currency) + cross-app use-after-free fix

Twilight wanted one cart to hold buys AND sells (and exchange) "in one swoop". Clarified: the cart is single-currency, locked per venue (you can't mix Pokemart money and Game Corner coins); Exchange stays its own thing (money<->coins is inherently two-currency, can't net into one total — excluded from the buy/sell cart). So this unifies Buy + Sell within a currency; the receipt sums them (+ sells, - buys) to one net total.

Model (the most complex one in the app).

  • ItemMarketEntryStoreItem / …PlayerItem: dropped the *isBuyMode mode-gating (compat Either/Either) so buys and sells coexist; direction is intrinsic. Added a plain cartSignVal member (sell +1, buy -1) — deliberately not a virtual (see the use-after-free note below). StoreItem::_itemWorth no longer checks the buy flag.
  • ItemMarketEntry::totalWorth() is now the signed net (Σ cartSignVal·cartWorth); moneyLeftover() = (active currency balance) + signed net — one formula for a mixed cart.
  • ItemMarketModel::buildList() builds BOTH sell rows (buildPlayerItemList) and buy rows (buildMartItemList) into one list each build; tags the ranges ViewSell/ViewBuy. isBuyMode no longer triggers reUpdateAll (that would clear the cart) — it's a view filter now. New roles ViewTagRole/CartSignRole.
  • New ItemMarketViewModel (mvc/itemmarketviewmodel.*, brg.marketViewModel): a QSortFilterProxy that slices the unified list to the active view (viewTag == isBuyMode?ViewBuy:ViewSell; whole list in Exchange) and invalidateFilter()s on isBuyModeChanged — so toggling Buy/Sell re-slices WITHOUT a rebuild and the cart persists. Wired in bridge + CMake. Left ListView binds to it.

QML. Left list → brg.marketViewModel. Receipt line sign is per-row (dataCartSign > 0 ? "+ ":"- "); the Total is the signed net. Footer is a single Checkout (AppFooterBtn1). canStep simplified to dataCanSell (store + money rows already report sellable). The cart field no longer writes the model during its initial Component.onCompleted fill (a live flag) to avoid re-entrancy through the proxy.

The crash + fix (real debugging, not a guess). The GUI suite went red: tst_gui_navigation (navigatesEveryScreen_newFile, the empty new-file path) SEGFAULT + tst_gui_fidelity fail-fast, while populatedSave passed and the QML smoke test passed. lldb gave the tell: atomic_load<int> on 0xfeeefeeefeee… — a use-after-free reached from a QML binding. Root cause: the entry aggregates (totalWorth/canAnyCheckout/totalStackCount) iterate the global static registries instancesCombined/instances, which accumulate entries across EVERY model and EVERY per-test GuiApp. Building store rows in every mode now (so the default Sell view's canAnyCheckout/footer binding sweeps them) + the per-test app teardown = an aggregate reading a freed entry. Fix: sweep the current model's own live itemListCache via a new ItemMarketEntry::activeList (set at the top of ItemMarketModel::buildList), never the cross-app statics. (Making cartSign a member not a virtual was part of the same hardening — a virtual call on a freed entry is the worst case.) After the fix: nav 5/5, fidelity green, full ctest 66/66.

Test. tst_market_model unifiedCart_buyAndSellNetTogether: cart a sell + a buy together → totalCartWorth == sellWorth - buyWorth, moneyLeftover == start + net, sign roles ±1.

VERSION 0.9.0 → 0.10.0 (MINOR). App relaunched.

Open / next: the exchange still feels out of place (Twilight's words) — ideas noted for a future pass (a dedicated money<->coins transfer view/popup). Stock-cap (OnCartLeft) half-done path still pending.

Pokemart: segmented mode header (Buy/Sell/Exchange · Pokemart/Game Corner) + Exchange as its own mode

Twilight wanted the Buy/Sell + Currency footer buttons gone, replaced by something nicer in the pane header, and — because some buy/sell combos showed a confusing "Money Exchange" section at the top — all the money↔coins exchanges pulled into one dedicated "Exchange" list, leaving the buy/sell lists pure. Chosen header arrangement: two segmented strips side by side. Since the data model only had Buy/Sell × Pokemart/Game Corner (no real "Exchange"), this needed model work to make Exchange a first-class mode.

Header / footer (QML).

  • New inline component SegStrip in Pokemart.qml: a connected, single-select segmented control styled for the accent bar (selected segment = light fill + accent text; 1px light outline; dividers between; clip + rounded container). Takes an options array, a bound currentIndex, stripEnabled, and emits picked(index).
  • Header hosts two strips, centered: action [Buy|Sell|Exchange] and venue [Pokemart|Game Corner]. Action maps to isExchangeMode / isBuyMode; venue to isMoneyCurrency and is disabled (greyed) while Exchange is selected. The static mode title is gone.
  • Footer is now a single Checkout button (AppFooterBtn1); the Buy/Sell + Currency footer buttons are removed.

Exchange as its own mode (C++).

  • ItemMarketEntryMoney: added forceDir (DirToCoins/DirToMoney/DirGlobal) + buying(), so the two swap rows have fixed directions rather than reading the global buy flag. Constructor compat → Either/Either (these rows are only ever in the Exchange list now, so they always pass the filter). Added an exchange-aware canCheckout() gating on the currency actually spent (money>=onCart when buying coins, coins>=onCart when selling) — the base moneyLeftover() is single-currency and excludes money rows, so it can't gate a swap. checkout() unchanged in effect (now via buying()).
  • ItemMarketModel: added isExchangeMode (rebuilds + isAnyChanged on change) and buildExchangeList() (a "Coin Exchange" header + a DirToCoins and a DirToMoney money row). Removed the if(!isMoneyCurrency){ "Money Exchange" + money } blocks from buildPlayerItemList/buildMartItemList. Added dual-currency totals exchangeMoney/CoinsStart/ After computed by mirroring the money rows' checkout deltas exactly (so preview == commit). The GC "In-House Exclusives" prize block stays.

Exchange receipt (QML). The right pane swaps to a dual-currency view in Exchange mode: Money and Coins each as start → after (Δ), after-value reddened on under/overflow, plus an exchange-specific warning (exchangeWarningText()); the item receipt (money-on-hand/itemized/total/balance) shows in buy/sell. curSym/moneyStr etc. unchanged for buy/sell; the exchange rows use literal ₽/⭘.

Build/test. C++ + QML. Full rebuild clean; full ctest green (66/66), incl. new tst_market_model exchangeMode_swapsMoneyForCoins (3 rows; Money=>Coins lowers money by spent amount, raises coins; checkout applies). The test helper now resets isExchangeMode defensively (shared fixture). App relaunched. VERSION 0.8.2 → 0.9.0 (MINOR).

Open / next: exchange-row line currency in the itemized receipt would be direction-dependent, so the exchange receipt deliberately uses the dual-currency summary instead of itemized money lines — revisit if itemized exchange lines are wanted. Long names / big numbers at the narrower receipt still worth a glance. Stock-cap (OnCartLeft) half-done path still pending.

Pokemart: cart pane → deterministic 1/3 width

Twilight wanted the cart wider (~33%, it was reading ~20%). The earlier Layout.horizontalStretchFactor 5:3 split wasn't producing the expected 37.5% — swapped it for an explicit width: receiptPane Layout.preferredWidth: Math.round(paneRow.width * 0.33) with fillWidth: false, and listPane keeps fillWidth (takes the remaining ~67%). Gave the RowLayout an id (paneRow) to bind against. QML-only; tst_qml_screens green. VERSION 0.8.1 → 0.8.2 (PATCH). (Next up, discussed separately: moving the Buy/Sell + Currency mode controls off the footer into a segmented control in the pane header.)

Pokemart shopping list → a proper list (hover/zebra rows, stepper-pill action) + narrower receipt

Twilight felt the left list was still "rows of labels, textboxes, and buttons" — clunky and rigid — and asked to refactor it into a proper item list with actions and row-effect colours, narrow the receipt ~25%, and drop the redundant per-row total (the receipt totals better).

  • Panes re-proportioned 5:3. listPane/receiptPane use Layout.horizontalStretchFactor: 5 / 3 (was 50/50 fillWidth), so the receipt is ~25% narrower and the list gets the room.
  • Left list rebuilt as a real list. The centered cart-stepper delegate (name/price fanned around a screen-centered stepper) is gone. Each item row is now a left-aligned RowLayout spanning the pane (14px left, 16px scrollbar lane): name (fillWidth, elide) · owned xN (sell only) · unit price (min-width 54, right-aligned) · the stepper action. Backgrounds: a whole-row HoverHandler drives an accent-tinted hover highlight (accentColor at 0.12 alpha), with zebra (index % 2rgba(0,0,0,0.025)) otherwise; faint rgba(0,0,0,0.06) separators.
  • Stepper "pill" as the row action. -/qty/+ sit in a rounded Rectangle (radius 6, faint bg+border) that turns white on row hover; the DefTextEdit qty is made borderless (background: Item {}) so it reads as one control. Same cart logic as before (the neg/pos onClicked and validator are unchanged). Unsellable rows (sell mode, !dataCanSell) show a muted italic "Can't sell" instead.
  • "msg" rows = section headers. Restyled to a left-aligned uppercase, letter-spaced, bold label on a faint tint bar (rgba(0,0,0,0.045)) with a bottom divider — reads as a real list section, not centered bold text.
  • Dropped the per-row running cart total from the list (the receipt itemizes + totals it). Receipt pane otherwise unchanged (still brg.marketCartModel).
  • New sizing knobs on the page root: rowH 46, headH 32, qtyW 34.

QML-only change. Kit + tst_qml_screens rebuilt; smoke test green (17/17), pokemart loads clean. App relaunched for review. VERSION 0.8.0 → 0.8.1 (PATCH).

Still open: the half-done stock-cap (OnCartLeft) path remains for a later pass; watch the narrower receipt's wrapping on very long item names / big numbers.

Pokemart → two panes: shopping list + store receipt (new cart filter model)

Continuing the Pokemart effort, Twilight asked to make it a two-pane screen: the shopping list on the left and a cart on the right that reads like a store receipt — itemized buy/sell entries with a total at the end, rather than the old summed-up floating box. Confirmed up front: keep the +/- steppers on the left (the receipt is read-only), and show a full receipt (money before → items → total → balance, with warnings by the total).

  • New ItemMarketCartModel (projects/app/src/mvc/itemmarketcartmodel.{h,cpp}). A QSortFilterProxyModel over ItemMarketModel whose filterAcceptsRow keeps only rows with cartCount > 0 and drops "msg" section headers. It inherits the source's role names, so the receipt delegate reads the same dataName/dataCartCount/dataItemWorth/dataCartWorth/dataWhichType. dynamicSortFilter on → it re-filters on the source's per-row + model-wide dataChanged (emitted on every cart edit), so receipt lines appear/leave as counts cross zero. The aggregate totals (totalCartWorth/moneyLeftover/anyNotEnoughSpace/canAnyCheckout) stay on ItemMarketModel; the receipt binds those directly. Wired in bridge.{h,cpp} (brg.marketCartModel, constructed right after marketModel) + added to app/CMakeLists.txt.
  • Pokemart.qmlRowLayout of two panes (spacing 0, a 1px dividerColor divider between).
    • Left pane = the existing shopping list verbatim (accent mode-title header + the ListView with the centered stepper delegate). Just lives in the left half now.
    • Right pane = the receipt: an accent "Cart" header (with a live xN count), then a ColumnLayout — "Money on hand" row, divider, the itemized ListView over brg.marketCartModel (each line: name + signed line total on top, xQty @ unit beneath; 16px scrollbar lane; faint row separators; "Your cart is empty." placeholder), a divider, the bold Total, the Balance after, and a wrapped warning line (errorColor) shown only when something's wrong.
    • Removed the floating summary box and the old bottom status bar — both folded into the receipt. The currency helpers stay on the page root so both panes share them (kept it one file rather than fighting QML's context rules for shared JS).
  • Reused the errorColor token from the previous change for every red here (line totals, total, balance, warning) — no literals.

Build/test: added new C++ + CMake + a new .qml-only screen rewrite. Reconfigured + full rebuild clean. Full local ctest green (66/66), including the new tst_market_model cartModel_filtersToCartedRows (carting a row makes it the receipt's only line; clearing removes it; no msg rows leak). App relaunched for review. VERSION 0.7.6 → 0.8.0 (MINOR — new feature).

Possible follow-ups: at 50% width the left list's name/price can get tight for long item names — may want to revisit the left delegate's spacing for the narrower pane. And the still-half-done stock-cap (OnCartLeft) path remains for a later pass.

Modernize the Pokemart screen — clean rebuild + fix the stuck Checkout button

Twilight kicked off what will be a longer effort on the Pokemart/Game-Corner shop screen — one of the most buggy, only-half-done screens, long put off because of how painful it was. The modernization done across the other screens has already cut out a chunk of its old problems, so this is the first pass. Agreed scope this round: keep the look and behavior identical, modernize the internals, and fix known functional bugs (no visual redesign — that's a design decision for later).

What changed (all in projects/app/ui/app/):

  • screens/non-modal/Pokemart.qml — full internal rebuild, same look.
    • The item-row delegate was a ColumnLayout holding a Rectangle { width: 1; height: 50 } whose children all anchored off each other (name → eachPrice → inventoryAmount → cartAmount) with the stepper centerIn the width-1 point. Rebuilt as a plain Item row: the -/amount/+ stepper is anchors.centerIn: parent (still screen-centered), the name / unit-price / owned-count are right-aligned up against its left with the same font.pixelSize-based margins, and the running cart cost (or the "Item cannot be sold" note) sits to its right. Identical layout, no width:1 hack, no fragile sibling chain.
    • Dead/cruft removed: the commented-out getTo(), the unused curName(), the scattered //topPadding: 15, and every cond ? A : A no-op ternary (the === "phony" checks — dataCartWorth/ moneyLeftover/totalCartWorth are ints, so they never equalled the string "phony"; both branches were identical anyway). Behavior-preserving (the dead branch was never taken).
    • Consolidated duplicated UI: the four whichMode-toggled header Texts → one bound to headerText(); the four manually visibility-juggled footer Texts → one bound to statusText() with the original priority (money-left < 0 > money-overflow > no-space > cart count).
    • Error color → a proper errorColor token. First cut used brg.settings.primaryColor, but Twilight pointed out primaryColor is pink (#d81b60), not red. Briefly reverted to a literal "red", then — on Twilight's call to "do it proper" — added an errorColor to Settings next to the primary palette (Q_PROPERTY + signal + QColor errorColor = QColor("red"), exposed as brg.settings.errorColor). It's a fixed, theme-independent red: setColorScheme() leaves it untouched (not derived from the accent), so the upcoming theming pass won't turn "error" pink. Pokemart's four red spots now use the token. Look unchanged. Rule going forward: use brg.settings.errorColor for plain red on screens, never primaryColor (pink) or a literal. Other screens still on literal "red" can migrate over time.
    • moneyStr tidy: dropped a duplicated useSigning !== true guard; logic unchanged.
    • Breathing room: the per-delegate isLast empty-Text (bottomPadding 25) became a clean ListView { footer: Item { height: 25 } }, mirroring the Bag/Pokémon lists.
  • fragments/header/FooterButton.qml — fixed the stuck-hover Checkout button at the root. The old Pokemart code carried an apologetic comment: the Checkout button, when it becomes disabled while the cursor is over it, stayed visually highlighted and stuck. Cause: a Material Button keeps hovered == true when it's disabled under the pointer (Qt stops delivering hover-leave to a disabled item), so the hover background never clears. Fix: hoverEnabled: enabled on the shared FooterButton — disabling forces hovered back to false instantly, clearing the highlight; re-enabling restores normal hovering. Generic and correct for every footer (only the Checkout button actually toggles disabled today). The apologetic comment is gone from Pokemart.qml.

Build/test: kit dir + build/ rebuilt clean (AUTORCC re-embedded the qrc). Green: tst_qml_screens (17/17 — pokemart loads clean through the real engine, the class the C++ suite can't catch), tst_market_model (8/8), tst_bridge (9/9). App launched for in-app review. VERSION 0.7.3 → 0.7.4 (PATCH).

Next on this screen (future passes): the screen is still only ~half-done — e.g. the OnCartLeft/stock-cap path (the old getTo() was a stub toward it), and a possible visual restyle toward the modern screens if Twilight wants it. Logged here so the next session can pick it up.

Stop auto-generating screenshot GIFs (still PNGs only; GIFs added manually)

Twilight asked to stop all the auto-generated GIF stuff in the screenshot pipeline and add GIFs manually, one at a time — keeping the regular still images. So the automated GIF machinery built earlier today (see the two sections below) was removed, while the manual-assembly tooling was kept:

  • screenshooter.cpp — deleted the three frame-sequence routines (captureFramesTileset, captureFramesTyping, captureFramesTabs) and their calls; updated the header doc. It now captures flat screens/*.png + editor/*.png stills only (no frames/ folders). Verified: a fresh run produced 13 screens/ + 7 editor/ PNGs, zero frames/, zero .gif.
  • scripts/make_gifs.pykept (Twilight will use it to assemble GIFs by hand); it's just no longer invoked by the scripts/CI.
  • capture_screenshots.{ps1,sh} — removed the GIF-assembly step (and the .ps1's -SkipGifs switch + Python resolver). Build + run the tool, nothing more.
  • CIpages.yml + release.yml no longer pip install Pillow or auto-assemble GIFs; Pages stopped copying tmp/screenshots/*.gif.
  • CreditsPillow kept in credits.json + credits.md (still used for manual GIFs); its note was reworded to say so.
  • Notesscreenshots.md, deployment.md, CLAUDE.md updated to reflect "automated = still PNGs only; GIFs are manual".

VERSION bumped 0.7.2-alpha → 0.7.3-alpha (PATCH).

UI polish: rival box height, View All tooltip, default window size

Three small UI/UX fixes requested by Twilight, all verified green (full ctest 66/66) and committed.

  • Rival grey box height. screens/non-modal/Rival.qml — the rival card was 360×300 vs the Trainer Card's 500×250. Per Twilight ("match the height only, rival only, don't touch width or the trainer screen"), changed the rival card height 300 → 250; width stays 360.
  • View All count tooltip placement. screens/non-modal/Pokemon.qml — the per-count hover ToolTip in the storage View All drawer relied on the default popup position (top-left of the cell), so it floated away from the number. Anchored it under the hovered count: x: (parent.width - width) / 2, y: parent.height + 4.
  • Default window size 1130×740. Was 640×480 first-run / 1024×768 in tests. Changed the first-run fallback in MainWindow::loadState() (QSize(1130, 740)) and the mainwindow.ui initial geometry, plus both test sizes (tests/helpers/guiapp.h, tests/qml/tst_qml_screens.cpp) — the latter also drives the headless screenshooter. Note: the on-screen default only applies on a fresh profile; an existing install restores its saved WindowState size from QSettings.

VERSION bumped 0.7.1-alpha → 0.7.2-alpha (PATCH). tst_qml_screens green (17/17), full ctest green (66/66, incl. all tst_gui_*).

Headless UI screenshot + animation-capture pipeline

Built an automated, repo-friendly system to capture UI screenshots and animated GIFs of the app — no manual screen-grabbing, no GPU, no real window, and it never touches save data.

  • projects/tests/tools/screenshooter.cpp — a C++ tool (not a CTest test) on the shared GUI harness tests/helpers/guiapp.h. Boots the real UI headless (offscreen), loads BaseSAV.sav, and grabs PNGs via QQuickWindow::grabWindow(): every top-level screen + the About/File Tools/New File modals; the Pokémon and Bag View All drawers slid open; the three Pokémon-editor tabs (General/DV-EV/Moves); both text-editor modes (quick popup + full keyboard) and the keyboard tileset "tileviewer"; hover states (font tile preview tooltip, Pokédex tile); and frame sequences for GIFs (the in-game name animation, live typing, and an editor tab-cycle). 39 PNGs + 3 GIFs, all meaningful on the first full run. CMake target screenshooter modeled on pse_add_gui_test / gen_synthetic_fixtures.
  • scripts/make_gifs.py — Pillow assembler: turns each frames/<name>/ folder into a GIF. CI-friendly; graceful (clear message, no crash) if Pillow is absent — the PNGs are unaffected.
  • scripts/capture_screenshots.ps1 / .sh — build the target, run it offscreen (crash-fast, detached), then assemble the GIFs. Output → tmp/screenshots/ (already ignored by tmp/.gitignore).
  • Wired into CLAUDE.md Default Workflow step 4 to run by default after a fast-forward of main (alongside the Doxygen rebuild), so the screenshots track main.

Two offscreen gotchas found + fixed (now in reference/screenshots.md and reference/qt-patterns.md):

  1. Tofu text. The offscreen platform's FreeType font DB finds no fonts unless pointed at one, so all UI text rendered as boxes (the in-game bitmap name preview still rendered via the font image provider, which masked it at first). Fix: the tool sets QT_QPA_FONTDIR to the OS font dir when empty (WINDIR%\Fonts on Windows).
  2. Blank grabs. Forcing QT_QUICK_BACKEND=software makes offscreen grabWindow() produce real pixels with no GPU (so it also works in CI/Docker); trade-off is MultiEffect shadows/desaturation are skipped — acceptable for documentation shots.

Byte-fidelity preserved: the tool only ever reads the save in memory to render — no saveFile(), no save byte written. New reference doc: reference/screenshots.md. Pillow added to credits.json / credits.md (Tools Used). This is a developer-tooling/test addition (no app-code change), so VERSION is unchanged per the versioning policy.

Follow-up — ran + tested capture_screenshots.ps1 end-to-end (and hardened Python detection). Verified the full driver on the dev kit: build → run (39 PNGs, 0 failed) → 3 GIFs assembled with Pillow (Python 3.14 / Pillow 12). Fixed a real gotcha first: Get-Command python returns the Microsoft Store execution-alias stub (…\WindowsApps\python.exe, which just prints "Python was not found") because it sits ahead of the real interpreter on PATH — so GIF assembly would have silently been skipped on this machine. capture_screenshots.ps1 now has a Resolve-Python that skips any WindowsApps path and falls back to the registered PythonCore installs (HKLM/HKCU), picking the first that actually runs --version. (.sh is unaffected — Linux python3 is the real one.)

Follow-up 2 — fixed the GIFs (they weren't actually animating) + re-captured at 1130×740. Twilight reported the GIFs didn't move (one "kind of did but really fast"). Root causes, all fixed:

  • Window size. The old shots were 1024×768; the harness is now 1130×740, so a re-run captures at the correct artistic size (no tool change needed — guiapp.h is the single source).
  • name_anim was static. The in-game name preview cycles curFrame 0–7, but plain letters don't animate — only tileset tiles (water/flower) do, so all 8 frames were byte-identical. Replaced it with tileset_anim: set brg.settings.previewTileset = "Overworld" + previewOutdoor, open the full keyboard's tileset view, stop the TilesetDisplay Timer and drive curFrame 0–7 manually so each grab is a distinct, deterministic frame (7/8 unique — the flower's 4-subframe cycle repeats one).
  • typing was static. Synthesized key events never reached the modal popup's field. Now it drives the value directly — sets the field text and the NameDisplay.str per step ("P"…"PIKACHU"), so the live GB-font preview + byte counter build up letter-by-letter (7/7 unique frames). Closes with suppressNextCommit so no in-memory player-name/OT mutation.
  • Playback too fast. make_gifs.py durations retuned: tileset_anim 300 ms, typing 420 ms, tab_cycle 850 ms (was 260).
  • The tool now wipes the output dir at the start of each run so a renamed/removed sequence leaves no stale PNG/GIF behind. Re-verified end-to-end: 41 PNGs + 3 animating GIFs at 1130×740.

Follow-up 3 — fixed missing content (the real "wrong window size"), HiDPI resolution, typing field, Bag View All. Twilight reported screenshots looked the wrong window size, Credits showed only its header/background, Home was missing its bottom-left tiles, Bag's View All didn't open, and "Pikachu" typed into the preview while the textbox below (which should control it) stayed unchanged. Root causes + fixes:

  • The real culprit was the software backend, not the size. The app renders QML via a GPU QQuickWidget; offscreen + QT_QUICK_BACKEND=software silently drops every MultiEffect/layered item, so the Credits cards and Home's greyed disabled tiles (Maps/Options/Hall of Fame/Event Pokemon) vanished and screens looked washed-out/sparse — which read as "wrong size." Now the tool renders through a real GPU window shown frameless + Qt::Tool + off-screen (setPosition(-4000,-4000)) so it draws exactly like the app without flashing on the desktop; the .ps1 no longer sets QT_QPA_PLATFORM. Credits + all 10 Home tiles now render. (Offscreen+software stays the CI fallback, effects caveat noted.)
  • HiDPI. On the 150%-scaled dev display grabWindow() returned 1695×1110; grab() now downsamples every image to the logical window size → a stable 1130×740 (smooth downscale = crisp). QT_SCALE_FACTOR does not override screen DPR, so we downscale rather than rely on it.
  • Typing. The textbox now drives it: the popup's name field is found by its placeholderText "Enter a name" (the previous code set the tileset combo's field), and setting that field's text pushes up to str so field + preview + byte counter update together. (Replaced the earlier set-str-directly approach Twilight flagged.)
  • Bag View All. Opened by clicking the real footer "View All" button (found by text) instead of poking a shown property — the property lookup matched the wrong item on the Bag screen. Works on both Bag and Pokémon now. Pokédex tile-hover threshold relaxed so it captures too.

Follow-up 4 — render via a real GPU window (effects), fix popup centering, hover, Pokédex, CI. The offscreen+software path was dropping all MultiEffect/layered content (Credits cards, Home's greyed disabled tiles → washed-out screens), which is what made everything look the "wrong size." Worked through it:

  • Effects need a real GPU window. Confirmed by test that even offscreen + the default RHI doesn't render the effects on Windows (no swapchain) — only a real GPU window does. The tool now runs on the native platform and shows the QQuickView frameless/Qt::Tool, off-screen on Windows/macOS (-4000,-4000, no desktop flash) / at (0,0) under xvfb. grabWindow() renders regardless of position. Credits + all 10 Home tiles now render.
  • Quick-edit popup was offset bottom-right — diagnosed (logged geometry) to a plain QQuickView leaving QQuickOverlay at size 0×0 at the window centre (an ApplicationWindow/QQuickWidget would size it). fixOverlay() sets the overlay to fill the window so anchors.centerIn: Overlay.overlay re-centers. Popup + typing GIF are centered now (this also fixed the "glitched" typing GIF).
  • HiDPI: QT_SCREEN_SCALE_FACTORS did NOT override the 150% screen (DPR stayed 1.5), so the tool just downsamples each grab to the logical 1130×740 (smooth, crisp).
  • Keyboard hover now hovers a real character pill (skipping the blank "Space") so the TilePreview tooltip + DetailView show the "A" glyph — the hover effect that was missing.
  • Pokédex hover screenshot removed (Twilight: it doesn't need one).
  • CI: capture_screenshots.sh now runs under xvfb-run (real X window + llvmpipe → effects, still headless); offscreen+software is only the last-resort fallback (effects missing). So: CI-compatible via xvfb on Linux; on Windows it's a real hidden window.

Re-verified: 40 PNGs + 3 GIFs at 1130×740, popup centered, hover glyph shown, typing driven by the textbox.

Follow-up 5 — the "too big" was the WINDOW SIZE: capture at the user's real ~751×480. The screenshots looked "too big" through every iteration because they were 1130×740 — but the app is designed for a small rectangle and Twilight actually runs it at 751×480 (found in the registry: HKCU\Software\Twilight\Pokered Save Editor\WindowState\size = @Size(751 480); 1130×740 was only the fresh-profile default baked into guiapp.h). The tool now reads that saved size via QSettings("Twilight","Pokered Save Editor")WindowState/size and resizes to it (override: PSE_SHOT_SIZE="WxH"; fallback 1130×740). It resizes before start() — doing it after the initial layout left Home's greyed Maps/Options tiles (MultiEffect-layered) unrendered; resizing first lays out once at the final size and all 10 tiles render. Result: shots now match the app's actual window. Then, on Twilight's request, simplified to a fixed 750×480 (the round number; dropped the live QSettings read) — still overridable via PSE_SHOT_SIZE.

Deployment: GitHub Actions release pipeline (release.yml)

Added .github/workflows/release.yml — builds release artifacts and publishes a GitHub Release. CI (tests.yml) was test-only; this is the deploy half.

  • Trigger + gate: push to main (FF-only from green dev, so always all-green), gated on the tag v<VERSION> not yet existing — i.e. VERSION was bumped since the last release. No bump → tag exists → no-op. A -alpha/-beta/-rc label marks the Release prerelease. workflow_dispatch has a dry_run input (default true) that builds + uploads artifacts WITHOUT publishing, for shakeout.
  • Artifacts: Windows portable .zip + Inno Setup installer .exe; Linux .AppImage + portable .tar.gz; Doxygen HTML docs .zip; UI screenshots/GIFs .zip (the screenshooter under xvfb). All attached via softprops/action-gh-release (which also creates the tag).
  • Toolchain mirrors tests.yml (Qt 6.8.3 install-qt-action; Win win64_llvm_mingw + tools_llvm_mingw1706 + MinGW Makefiles; Linux linux_gcc_64 + Ninja) but Release, app target, and packaging. Supporting files: packaging/windows/installer.iss, packaging/linux/PokeredSaveEditor.desktop.
  • New doc reference/deployment.md; Inno Setup + linuxdeploy added to credits. CI/deploy infra, so VERSION is unchanged.
  • Not yet exercised on GitHub (no gh on the dev box to dry-run; same first-run-shakeout caveat as tests.yml — windeployqt runtime DLLs, linuxdeploy-qt QML discovery, Inno path are the watch points). Committed to dev; merging to main will cut the first release (v0.7.2-alpha), so that FF is left for Twilight to trigger when ready (watching the Actions tab).

Follow-up — installed gh, shipped main, first-run shakeout, GitHub in default management. Installed the GitHub CLI (gh, winget; Twilight authed via SSH/browser). Discovered the tests CI has in fact been green on every recent run (the "not yet exercised" note was stale). FF'd main → the first release run fired (v0.7.2-alpha). Results: version / docs / Linux AppImage+tar.gz jobs passed first try; the rest was Windows + one script path. Took four iterations (each watched live via gh run view --log-failed), all now fixed and the pipeline is green — v0.7.2-alpha published with all six artifacts:

  • windeployqt --compiler-runtime errors on llvm-mingw ("implemented for Desktop MSVC/g++ only"). Dropped the flag; copy libc++ / libunwind / libwinpthread from the compiler bin by hand.
  • windeployqt deployed nothing ("Unable to find the platform plugin") because a stray windeployqt on PATH (a different Qt) was being used → invoke it by full path ($QT_ROOT_DIR/bin/windeployqt.exe), plus a manual Qt-DLL/plugin/qml fallback and a Qt6Core.dll + platforms/qwindows.dll assertion.
  • Inno Setup ISCC.exe path: $Env:ProgramFiles(x86) mis-parses in PowerShell → use ${Env:ProgramFiles(x86)} (Inno is pre-installed on windows-latest).
  • Linux screenshots couldn't find the binary: screenshooter lands at build/tests/ on Linux (build root on Windows). capture_screenshots.sh now searches for it; renders fine under xvfb + llvmpipe.

Also wired GitHub into default management (CLAUDE.md): (1) "green" before FF main now includes the remote tests workflow, not just local ctest; (2) after FF, the release run is watched; (3) the issue tracker + PRs are checked as part of prepping main for shipment — event-based, no scheduled ping (Twilight's preference) — surfacing anything open with a "now or later?" and offering a separate chat; (4) never auto-act on issues/PRs. VERSION unchanged (CI/deploy infra).

README screenshots → GitHub Pages; release-notes + release-policy standards

Twilight wanted the loose UI images embeddable in the README and kept current by CI, without ballooning the repo (no LFS) and without a separate release. Iterated on the hosting choice:

  • First tried a rolling images-only release — Twilight then set the rule "never release anything that isn't a software release / never an images-only release", so that release was deleted.
  • An orphan branch would put image blobs in the repo object store (force-push limits it, GC is lazy) → repo-size creep, which is exactly what Twilight wanted to avoid. Imgur purges images / deprecated API / needs a secret. So → GitHub Pages (Twilight's pick): images on the Pages CDN, not git, not a release; free, first-party, CI-current.
  • Enabled Pages (Actions source) via gh api … -f build_type=workflow; rewrote screenshots.yml to build a small site (screenshots/<images> + an index.html gallery) and deploy via configure-pages/upload-pages-artifact/deploy-pages. URLs: https://junebug12851.github.io/pokered-save-editor-2/screenshots/<name>.

Also: release titles/descriptions are now well-structured by defaultrelease.yml composes a downloads table + prerelease/unsigned note (auto "What's Changed" appended); the existing v0.7.2-alpha release notes were upgraded to match. And Pillow is pip-installed for the active python3 in both CI workflows (the setup-python hostedtoolcache python lacked the apt python3-pil), so the GIFs are included. Standing rules recorded in CLAUDE.md + reference/deployment.md. VERSION unchanged.

Pillow timing gotcha (resolved): installing Pillow in an earlier step targeted the system python 3.10; install-qt-action then swapped python3 to its hostedtoolcache python, so make_gifs.py still found no Pillow. Fix: install Pillow in the same step that runs the capture. After that the GIFs were generated and tab_cycle.gif served 200 on Pages.

Then (Twilight's request): Pages serves the DOCS too. Replaced screenshots.yml with pages.yml that, on every main push, builds the Doxygen docs and captures the screenshots and deploys ONE Pages site. Initially I added live Pages images to the README — Twilight then asked to revert the README (they'll add screenshots themselves), keeping its original Imgur images, so the README edits were undone. And per a follow-up, the Doxygen home is now the Pages root (not /docs/): the docs HTML is copied to the site root, and Screenshots + GitHub are custom Doxygen top-nav tabs added without touching the README (which is the USE_MDFILE_AS_MAINPAGE) — by generating DoxygenLayout.xml (doxygen -l), injecting two <tab type="user"> entries, and building with that layout. Images stay at /screenshots/<name> (+ a gallery linking back to the docs root). Docs reference screenshots by the absolute Pages URL (resolves on Pages, GitHub, and the README). The versioned release still ships its own docs/screenshots zips as archival snapshots.

Doxygen nav: new notes weren't being nested (floated to the root)

Twilight noticed today's session log sitting at the Doxygen/Pages root instead of under Sessions. Cause: the _nav.dox hard rule — every Markdown page Doxygen ingests needs an explicit \subpage, or it floats flat to the top of "Related Pages". Three pages created this session were missing entries and all floated: sessions/2026-06/2026-06-15, reference/screenshots, and reference/deployment. Added all three under their hubs (the session newest-first under "June 2026"; the two reference docs in the Reference list). Verified with a local doxygen run — no \subpage … not found warnings, so the IDs resolve and the pages nest. To stop it recurring, CLAUDE.md → "Maintaining the Notes" now has an explicit row: **any new Markdown note in the Doxyfile INPUT must get a \subpage in _nav.dox in the same commit** (new month/folder → add its hub), and the session-log row calls it out too. (Notes-only — no changelog entry, per policy.)