From 984cefdc7bd1ec24cebca799805cf03d0c2e89c7 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 6 Aug 2026 09:57:31 -0400 Subject: [PATCH] launcher updates --- docs/new-features.md | 662 +------------- src/core/GameVersion.lua | 2 +- src/core/HostShell.lua | 169 +++- src/import/LauncherSettings.lua | 28 +- src/import/LauncherView.lua | 864 +++++++++++------- src/import/RomImporter.lua | 64 +- src/net/Fetch.lua | 35 +- src/net/fetch_worker.lua | 24 +- src/ui/kit/Kit.lua | 42 +- src/ui/kit/Theme.lua | 118 ++- src/update/check_worker.lua | 7 +- tests/engine/host_shell_fetch_errors.lua | 134 +++ .../launcher_one_column_reach_bug852.lua | 95 +- tests/engine/launcher_panel_reflow.lua | 208 +++++ 14 files changed, 1366 insertions(+), 1086 deletions(-) create mode 100644 tests/engine/host_shell_fetch_errors.lua create mode 100644 tests/engine/launcher_panel_reflow.lua diff --git a/docs/new-features.md b/docs/new-features.md index 3a19f226..93c6d23e 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -1,636 +1,26 @@ -# New features (deliberate additions beyond the original) - -Intentional enhancements this port adds on top of faithful Pokémon Red, Blue, -and Yellow behavior. They have no Game Boy equivalent and are kept by design. -Genuine divergences from the original (things still missing, wrong, or -approximated) live in docs/known-differences.md; faithfully-ported behavior is -in docs/behavior-porting-notes.md. - -## Survey zoom - -The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4` -zooms the overworld between 1 pixel per world pixel (full survey) and 2× -the window fit scale (close-up), in crisp integer steps. This has no Game -Boy equivalent: - -- Connected maps render their full bodies, and their NPCs appear as - visual-only "ghosts", they wander but have no sight lines, triggers, - dialogue, or collision until the map is actually entered. -- Menus, text boxes, and battles draw at normal scale on top of the - zoomed world. Zoom input is ignored while a script, menu, or battle is - active; the zoom offset is persisted as `save.options.zoom` (default - `0` = FIT) and survives New Game via `options.lua`. -- Hotkey `4` ticks through every integer zoom level (survey → FIT → - close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`. -- Beyond the border ring the void fill repeats indefinitely (see VOID - FILL below); interiors keep their own border block. Each visible map - area is colorized with its own SGB palette (the original recolored the - whole screen per map). -- Neighbor maps load two connection hops out so corner-adjacent maps - don't pop in and out, and ghost NPCs share instances with the real ones - so their wander positions persist across seamless connection crossings - (a warp or fresh map entry still respawns everything at its script - position, like the original's per-entry sprite init). - -## VOID FILL - -The Options **VOID FILL** row picks what paints the infinite beyond-edge -space on OVERWORLD-tileset maps during survey zoom: - -- **TREES** (default): solid tree wall block `$0F`. -- **WATER**: animated water tile `$14` (same hshift cycle as on-map water). -- **BLACK**: solid black. - -Other tilesets are unchanged (house/cave borders stay as authored). -Persisted as `save.options.voidFill`. - -## Tilt mode - -The `3` key (and the Options menu TILT row) cycles a visual-only perspective -tilt of the overworld through **OFF → 15° → 35° → 50° → OFF** for an HD-2D / -diorama look. Like survey zoom this is purely presentational and has no -Game Boy equivalent: - -- The entire map tilts as one rigid ground plane, paths, grass, water, - floors, and every background-tile structure (buildings, trees, fences, - signs; in Gen 1 these are baked into the tile layer, not sprites), so - rows above the player recede and rows below come toward the viewer. Only - things that actually *stand* on the ground draw as upright billboards, - unscaled and pixel-identical to flat mode: the player, NPCs, item balls, - and the standing FX attached to them (emote bubbles, the fishing rod, - the FLY bird). The Poké Center heal-machine overlay stays on the ground - plane with the machine tiles (it is OAM glued to a BG graphic, not a - standing sprite). An earlier revision tried - billboarding buildings/trees/signs too (cutting them out of the ground - per hand-curated per-tileset tables); that chased an endless tail of - special cases, dense tree canopy, fences fused into grass, building - facades with their own baked-in fake perspective, because Gen 1's art - was never drawn with a clean seam between ground and standing scenery. It - wasn't merged; tilting everything but the characters as one plane is the - simpler, shipped tradeoff (buildings recede/foreshorten with the ground - like a photo of a diorama, rather than standing fully upright next to - a full-height character). -- Cycling tweens the angle between levels over ~0.25s rather than snapping; - with tilt fully off the world pass drops back onto the flat blit path, so - flat rendering stays pixel-identical to tilt-off and off costs nothing. -- Tilt input is gated exactly like survey zoom, honored only while - free-roaming, ignored while a script, menu, or battle is active, and it - composes with survey zoom (the zoom scale feeds the projection). The tilt - level is persisted in `save.options.tilt` (default OFF). -- It applies everywhere the overworld draws, interiors and caves included. - Menus, text boxes, and battles render flat on top, unaffected, and the - infinite beyond-the-border-ring fill stays flat by design. -- Collision, movement, sight lines, triggers, encounters, and scripts are - untouched; nothing about the tilt reaches gameplay. - -## Colors mode - -The `2` key (and the Options menu COLORS row) cycles the display mode -through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED** -(on Blue the first slot labels **OG BLUE**; on Yellow, **OG YELLOW**). -The first three are the real colorizations; the rest are DMG-shade novelties: - -- **OG RED** / **OG BLUE**: the Game Boy Color boot-ROM look for that cart -- - one global BG palette + one OBJ palette, every map, no per-map variation - (Red/Blue ship no CGB code, so on a GBC the boot ROM colors them globally). - The player/NPCs keep the boot-ROM OBJ color over the terrain via the OBP - bake + post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`, or Blue's blue/pink - pair). -- **OG YELLOW** (Yellow playthrough, same `ogred` save id): Pokemon Yellow's - authentic GBC look from `CGBBasePalettes` (`data/palettes_yellow.lua`, - sourced from pret/pokeyellow). Per-map / per-species colors, not a single - boot-ROM ramp -- Yellow was CGB-enhanced. -- **SGB** (default): the per-map Super Game Boy region palettes - (`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on - real SGB. (This is the mode formerly mislabeled "GBC".) -- **ADVANCED**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus - per-species mon colors (`data/palettes_gbc.lua`). (Formerly labeled - "RED++"; it is the richest colorization rather than anything Red-specific.) -- **OG**: force the four DMG grays (colorization off). -- **OG INV**: inverted DMG grays. -- **SGB INV**: each SGB zone palette with shade order reversed. -- **CLASSIC**: original Game Boy pea-soup greens - (`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`). - -The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so -it covers overworld, menus, battles, and tilt upright billboards. OG RED's -global BG palette is supplied by `OverworldState:overworldBgColors` (per-map -override in the overworld pass). Persisted as `save.options.colors`; the -`gbc` / `gbc_inv` / `redpp` save ids are kept for back-compat under the new -labels. - -## GBC FX - -The `5` key (and the Options menu GBC FX row) cycles a "played on real -unlit-GBC hardware" post-process through **OFF → 1 → 2 → 3 → 4**. The -levels are a cumulative ladder: - -- **1**: reflective-screen backing transparency. -- **2**: + LCD pixel grid. -- **3**: + pixel drop shadows. -- **4**: + sunlight glare and rainbow shimmer with a drifting light. - -It runs as a final present pass after world + UI composite in -`Renderer:endFrame`, inspired by the Pixel Transparency RetroArch shader -([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)). -Default OFF; persisted as `save.options.gbcfx`. - -Mobile GPUs often compile the pass but present a black frame, so Android and -iOS hide the row entirely, pin the level to OFF, and rewrite a level already -persisted in `options.lua` (issue #136). `POKEPORT_GBCFX` overrides that -decision either way, same tri-state as `POKEPORT_TOUCH`: `=0` refuses the -effect, `=1` forces it available. The Anbernic handheld pack exports `0` from -its launcher because the device reports `"Linux"` while its GPU is in the -phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)). - -## Performance tier (low-end devices) - -The Options **PERFORMANCE** row scales the port's optional presentation -extras down for weaker hardware. The extras it governs are the three -heaviest things the port adds on top of the original -- the whole-screen 3D -**TILT** (transforms the entire map as a ground plane), the **GBC FX** -post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out -renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard -FPS ceiling. None of this touches game logic, which is fixed-step off `dt` -(`src/core/FixedStep.lua`), so every tier plays identically; they differ -only in how much eye-candy the renderer is allowed to do. - -| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling | -| ------------ | ---- | ------ | ----------- | ----------------- | -| **HIGH** | on | on | on | none | -| **BALANCED** | off | off | on | none | -| **LOW** | off | off | off | 60 | -| **AUTO** | picks a default from the device (below) ||| - -- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds - (e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core - desktops to **BALANCED**, and everything else -- a normal desktop, and - every existing `options.lua` that predates this option -- to **HIGH**, - so the common case is unchanged. See `src/core/Performance.detect`. -- AUTO only chooses the *default*; all four tiers are selectable, so a - wrong guess is one row away from being overridden. -- The clamps are applied **live** against your stored options and never - rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC - FX / ZOOM without forgetting them -- raising the tier restores exactly - what you had. (This is why the TILT / GBC FX / ZOOM rows still show your - saved choice on a clamped tier: it's your preference, waiting for a tier - that can afford it.) -- Persisted as `save.options.performance` (`auto` | `high` | `balanced` | - `low`); unit-tested in `tests/engine/performance_tiers.lua`. - -## Peer-to-peer link play (lua-enet) - -Trades and link battles connect two copies of the game directly over -lua-enet (ENet ships inside LÖVE, nothing to install, no server to run) -on a reliable-ordered channel, replacing the original standalone Python -room-code relay (`tools/relay_server.py`, deleted). HOST A GAME shows the -host's LAN address (UDP 7777; `POKEPORT_LINK_PORT` overrides); JOIN A -GAME enters it. Closing performs a graceful ENet disconnect so the final -confirm/bye always lands; a vanished peer exits with "The link was -broken." Internet play needs a forwarded UDP port or a VPN (deliberate -tradeoff vs. the relay). Headless tests drive the protocol over an -in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file -also exercises real UDP pairing. - -Red, Blue, and Yellow copies link with each other, as the real cable -does. The compatibility fingerprint hashes only data a link mode can -actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the -only R/B/Y link-surface difference) no longer read as different games -(issue #511). Moving the fingerprint is a link parity change: builds -from before this fix will refuse to pair with builds after it. - -## Fair play in link and online matches - -A link session is decided by the battle and nothing else, so for its -duration: - -- **Game speed is pinned to normal.** The GAME SPEED option and - `POKEPORT_SPEED` are ignored from the moment LINK PLAY opens until it - closes, and apply again after. Fast-forward otherwise runs one peer's - queue faster than the peer it is locked to and drains a tournament shot - clock faster than the opponent racing it. -- **Online play runs vanilla, except for your language.** Picking ONLINE - MATCH or TOURNAMENT with mods enabled offers to switch the gameplay ones - off and relaunch (mods merge at boot, so a restart is the only way). The - restart is confirmed, not silent. They stay listed as disabled, ready to - switch back on. A mod that declares itself a translation and provably - writes nothing but text stays on: the two games hash the same link - surface, so a Spanish install and an English one can battle and trade, - each reading the game in its own language and naming the other player's - party out of its own text. -- **Only a meaningful split ends a match.** The per-turn state signature - both peers exchange is split three ways: `actives` and `bench` carry - species, HP, status, stat stages, PP and the rest of the party, and a - divergence there ends the match as a draw. `volatile` carries per-turn - flags both sides recompute anyway - a divergence there is logged and - reported to mods, and play continues. - -The relay logs which component diverged on which turn, so a desync report -names something specific. - -## Custom boot text - -The boot sequence replaces the Nintendo / GAME FREAK identifiers with -"bois club" / "bryanthaboi", a deliberate branding customization. The -rest of the boot beats (copyright splash, "presents" shooting-star, the -Nidorino-vs-Gengar attract scene) mirror the original. - - -## Custom Options - -Options persist in a standalone `options.lua` (separate from the game -progress `save.lua`), so audio/display/battle preferences survive New Game -and aren't wiped when a save slot is cleared. Changing a row in the Options -menu or cycling hotkeys `2`/`3`/`4`/`5` writes immediately; an in-game save also -flushes the live options. Old saves that still embed an `options` table are -migrated once into `options.lua` on load. - -- Music / SFX volume -- PIKACHU VOL (0-7, Yellow only): trims Pikachu's PCM voice clips under the - SFX level, so the follower's constant chatter, the title-screen cry and - every in-battle "Pika!" can be pulled down (or muted at 0) without - quieting the rest of the sound effects. The row is hidden on Red/Blue, - which have no voice clips. -- Music Filter -- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules) -- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below -- COLORS (OG RED / SGB / ADVANCED / OG / OG INV / SGB INV / CLASSIC), also - hotkey `2` (OG RED = GBC boot-ROM look; ADVANCED uses pokered-gbc - SuperPalettes + per-species mon colors) -- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming -- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and - `-`/`=` step one level and save -- VOID FILL (TREES / WATER / BLACK) for OVERWORLD beyond-edge space -- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5` -- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60), - a hard render frame-rate cap (`save.options.fpsCap`). - -## Battle transition cascade + white battle letterbox - -Into-battle wipes still run the original eight styles inside the classic -160×144 letterbox. On wide/tall windows (survey zoom), matching black 8×8 -blocks cascade outward from that square into the surrounding world so the -void outside the OG wipe fills in lockstep. Once the battle state is up, -letterbox voids around the battle canvas fill **white** instead of black -so the whole window reads as one continuous battle screen. - -## Widescreen battle layout - -Options **BATTLE LAYOUT** picks the battle screen's composition: **OG** -(the default: the original 160×144 arrangement, unchanged) or **WIDE**, -which gives battles a 304×144 native-pixel surface and a Gen 3-style -arrangement on it: - -- the foe's status box upper left, the foe's picture upper right; -- the player's picture lower left, the player's status box lower right, - with a longer HP bar and the numeric HP under it; -- a full-width message window; -- a split "What will X do?" prompt / 2×2 command window; -- a 2×2 move menu, navigated with all four directions, with a PP and type - panel attached to its right. - -Only the composition changes. Pictures, palettes, HP-bar colors, font -pages, window borders, sounds, animations, timing and every battle rule -stay the engine's, so a COLORS mode or an asset mod still owns the look. -Each side's picture keeps its original pixels and placement math and is -composited into its own region of the wider battlefield -- nothing is -scaled or squeezed -- and animations, which are authored in the original -160-pixel space, shift as one rigid group onto whichever side they play -on. The whole screen is drawn at the window's integer fit scale for the -wider surface, so a 304-pixel screen is drawn a step smaller than a -160-pixel one in the same window. - -The wide surface is live only while the battle itself is the screen on -top: a party menu, the bag or a nickname prompt is a 160×144 screen and -brings the classic surface back with it. - -## On-screen touch controls (mobile) - -On Android/iOS the game draws a translucent d-pad (bottom-left), A/B -buttons (bottom-right, Game Boy diagonal), and +/- START/SELECT (bottom -center) over the frame, using Xelu's CC0 controller prompts -(`assets/touch/`). Real buttons, not gestures: press lands the frame the -finger does, sliding on the d-pad changes direction without lifting, and -multi-touch chords (e.g. hold a direction + tap B) work. The overlay only -appears while no controller is being used: the first gamepad button or -stick push hides it, the next screen touch brings it back, and unplugging -the last controller restores it immediately. Layout re-derives from the -window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces -the overlay on and lets the mouse act as a finger (`=0` forces it off). - -The launcher's **Touch Controls** button opens a drag editor: move each -button freely, resize the whole pad with **-/+** (60% to 160%), **Disable** -to hide the overlay permanently (for controllers / emulation handhelds -- -distinct from the temporary gamepad auto-hide), **Reset** for defaults, -**Done** to save into `options.lua` as normalized window fractions so a -different screen keeps the relative placement. - -Portrait and landscape are edited and saved separately (#633): the editor -follows whichever orientation is on screen, and **Reset** only clears that -one, so a layout that works held upright does not have to double as the -one used sideways. An `options.lua` from before this split keeps its single -layout in both orientations until one of them is edited. In-game, Options → -**TOUCH PAD** toggles the same on/off flag without leaving a play session. - -## Haptic feedback (mobile) - -Options → **VIBRATION** (also in the launcher's gear menu) buzzes the device -the instant an on-screen control takes a button (#806). A glass pad has no -edges under a thumb, so the pulse is what tells you the press landed: -sliding the d-pad from one direction to the next buzzes again, a second -finger landing on a button that is already held does not, and releasing -never does. - -Four levels: **OFF**, **LIGHT** (the default), **MEDIUM**, **HEAVY**. -"Intensity" is really a pulse length -- the platform call takes a duration -and nothing else -- so LIGHT is a 12 ms tick, MEDIUM 25 ms, HEAVY 45 ms. -Stepping the row fires one sample pulse at the level you land on, so the -three can be compared without leaving the menu. On iOS the system -vibration has one fixed length, so all three levels feel the same there and -the row is effectively on/off. The setting lives in `options.lua` and the -row only appears where the on-screen pad can (Android/iOS, or desktop with -`POKEPORT_TOUCH=1`, where it does nothing since desktop LOVE has no -vibrator). - -## Screen orientation lock (Android) - -Options → **ORIENTATION** (also in the launcher's gear menu) locks the -screen to **PORTRAIT**, **LANDSCAPE** (either landscape, following the -device), or **REVERSE LANDSCAPE**, or leaves it on **AUTO** (#592). AUTO -allows every orientation but defers to the system: with auto-rotate turned -off in Android's quick settings, the game stays put instead of following -the sensor (#716). Changes apply immediately -- the screen rotates as the -row is stepped -- and persist in `options.lua`. Android only: iOS follows -the app's fixed orientation list, and desktop windows rotate nothing. - -## Translation support - -Every string the player can read is now reachable from a mod, so a -translation is an ordinary content mod rather than a fork. - -Two things had to change. Text layout stopped counting bytes: the dialogue -box measures a line in glyphs (charmap sequences), so a 3-byte character -costs one column, a cut never lands inside a character, and a page with a -non-default `advance` re-measures instead of overflowing. That also fixed -25 vanilla English lines that were wrapping early because `é` in POKéMON -and POKéDEX costs two bytes ("I study POKéMON as" is 19 bytes and 18 -glyphs, and the box was breaking it). - -Second, the text the engine writes itself - battle messages, item results, -menu labels, the link-play screens - moved behind `src/core/Strings.lua` -and the new `strings` registry. Extracted script text was already -overridable through `text`; this covers the other half. Entries are keyed -by the English source, so a translation that has not reached a string yet -keeps rendering in English and a half-finished translation stays playable. - -Authors generate the whole thing: - -```sh -python3 tools/modkit.py translation francais --language "Francais" -``` - -That scaffolds a mod with every translatable string as an empty catalog, -plus a glyph-page and charmap stub, a naming-grid stub, and a -`francais-worksheet/` directory holding the English to translate from -(deliberately outside the mod: extracted text is ROM content and must not -be packed). `--refresh` re-harvests after an engine update, keeping -existing translations and parking orphaned keys rather than dropping them. - -A translation can also skip glyph pages entirely: scaffolding with -`--pixel-font` (or registering `mod.content.font:register("ttf", {})` in -an existing mod) renders text through a bundled TTF covering Latin with -diacritics, Cyrillic, kana and CJK, while box borders and ``-style -macro glyphs keep their tiles. The font is "Plain Pixel Font" by Douglas -Vautour (Burpy Fresh), licensed under CC-BY 4.0 (5x11 base characters, -11x11 double-width; see `assets/fonts/plainpixel/README.md`). Options on -the registry entry: `file` for a mod-shipped TTF, `size` (the font's -design em; Plain Pixel rasterizes cleanly only at multiples of 15), -`spacing` added to every advance, `yOffset` for vertical alignment -against the 8px cell grid, `bold`, which double-prints at a 1px -offset for fonts whose strokes read too light, and `tiles`, the -characters that keep their ROM tile instead of coming from the TTF. - -`tiles` matters for a CJK translation. Sizing the font so a kana fills -the 8px cell leaves Latin narrower than the tile font it replaces, which -pulls the numeric columns out of line: the party menu's `:L12` stops -sitting over `34/ 34`. Naming `"0123456789/:"` keeps those on the -vanilla tiles, so numbers render exactly as they do in English while -kana still come from the font. It takes a string of characters, or a -list when a multi-character charmap sequence is meant. - -See the wiki's Translations guide. - -## Save editor (bundled, reachable from the launcher) - -The save editor ships inside every build instead of being a developer-only -script, and the launcher's SAVE SLOT card grows an **Edit** label next to -Delete on every slot that actually holds a save. Edit suspends the -launcher, opens that slot's file in the editor, and **Close** hands the -process back to the launcher with the slot list re-read (a rename, a badge -or a dex change shows up on the row immediately). Unsaved edits arm a -confirm first, so leaving cannot lose work. `love . --editor` still opens -it standalone, where Close quits instead; `--save ` points it at any -file, and a save can be dragged onto the window. - -The editor now wears the launcher's visual language - the same navy radial -field, 16px translucent cards, tri-colour version rail and green/yellow/red -semantics - so the two windows read as one app. Six tabs: - -- **Party**: the roster with sprites, HP bars and level chips on the left, - and the mon inspector permanently docked on the right instead of floating - over the list. Species, level, DVs and moves all round-trip through the - Gen 1 formulas, so the inspector can never show illegal stats. -- **Boxes**: the 12 PC boxes as a 5x4 grid with a fill meter per box and a - party dock, so deposit and withdraw live in one place. Empty slots are - clickable and create a mon there. -- **Items**: money, a searchable item picker (replacing the arrows that - cycled one id at a time through ~250 items), the configurable bag (20 slots - by default), PC storage - with no slot cap, and the eight badges as toggle chips. The picker, the bag - and PC storage all scroll under the mouse wheel, so the whole catalog is - reachable one-handed without typing a query. -- **Events**: flags, defeated trainers, taken items and per-map object - toggles, with a real filter field and a two-column paged grid. -- **Map**: any map rendered with the game's own renderer, warps followable, - and the player / lastHeal / lastOutdoor spawn points settable by clicking - a cell. Setting lastOutdoor on a map the game would not accept as an - outdoor source is refused with the reason. -- **Dex**: seen / owned completion meters and a four-column grid; owning - implies seen and un-seeing clears owned, exactly as the game requires. - -Two rules run through all of it. Every mutation goes through one funnel -that sets the dirty flag and writes the status line together, so nothing -changes silently and no branch can quietly no-op - "Party is full", "Bag is -full", "click a cell first" all say so. And every destructive verb (Remove, -Release, Clear all, Wipe dex) arms on the first click and commits on the -second, relabelling itself to `Confirm?` in between. - -A validation pill in the tab rail mirrors what the running game would -quarantine on load; clicking it jumps to the tab holding the first problem. - -## Tiled map editing (mod authoring) - -`tools/tiled_export.py` turns the imported ROM cache into a Tiled workspace, -so maps can be edited in a real map editor and exported back out as a mod. -It has its own document: docs/tiled-map-editing.md. - -## Pokédex diploma (both versions) - -The Celadon Mansion 3F game designer shows the dex-completion diploma -once 150 species are owned. On Yellow, the graphic artist next to him -then offers to print it, saving the certificate as a PNG under `prints/` -in the save directory, and Bill's PC gains Yellow's PRINT BOX item which -exports the current box list the same way. - -## Pokédex printing (Yellow) - -Yellow's Game Boy Printer PRNT option in the Pokédex side menu is stood in -for by an image export: choosing PRNT renders the mon's entry page (sprite, -kind, number, height/weight, dex text) to a PNG at 4x scale under -`prints/` in the save directory, then reports the filename in a dialog. -No printer hardware or link cable emulation involved; the file is the -printout. - -## Find Mods (community mod indexes) - -A FIND MODS tab sits beside MODS in the launcher and browses a published -mod index: a metadata-only feed listing mods that live in their authors' -own repositories. No index ships with the launcher and none is ever added -automatically, so the tab opens on an "Add an index" prompt until you name -one; paste an index URL or its `owner/repo` and it is remembered in -`options.lua`. More than one index can be added, and the listings merge. - -A feed author can publish per-mod release stats by adding three optional -fields to an entry -- `downloads` (total across every release), and -`first_release` / `last_release` (ISO days) -- which the listing shows in -the same gold line the MODS tab uses. When a feed does not carry them, -the row fetches the mod's own GitHub releases instead -- the same cached -`ModUpdate` fetch the MODS tab uses, one entry per frame -- so the stats -appear for any mod with a `github` field regardless of feed maintenance. -The fields are additive: feeds that carry them stay readable by every -build that predates them, and feeds that do not render exactly as before. - -## Soft reset (all versions) - -Holding A, B, START and SELECT together restarts the game the way flicking -a Game Boy's power switch did, dropping straight back to the title screen. -It works from anywhere, including mid-battle, which the QUIT entry on the -start menu cannot do: the original combo is how stationary and gift -Pokemon get their stats rerolled without sitting through a full relaunch. -Unsaved progress is discarded, exactly as on hardware. - -As on the original, the four buttons have to stay held for 16 straight -polls (better than a quarter of a second) and any direction in the mix -cancels it, so it is hard to hit by accident -- including on the on-screen -touch controls, where it would take four fingers held on four separate -controls. - -## Controls rebinding (CONTROLS screen) - -OPTIONS -> CONTROLS lists every Game Boy button with its current keyboard -key and controller button side by side (Z/A). Press A on a row, then press -and release the key or pad button you want; the rebind commits on the -release. If that input already belongs to another row, the two rows swap, -so no button is ever stranded without an input and no input ever serves -two buttons. Holding a second key or pad button while the first is still -down backs out of the capture without touching a keyboard; Escape still -cancels too. SELECT clears one row back to its default, and START resets -every binding after a confirmation. - -Controllers a system has no mapping for (common on Linux handhelds and -off-brand pads) report bare button numbers rather than names. Those are -rebindable on the same screen and show up as JOY1, JOY2 and so on in the -controller column. Recognized controllers are read only through their -named buttons, so a rebind on those is never shadowed by the factory -layout underneath it. - -## Mod profiles (#593) - -The mod manager's PROFILES tab holds named setups. A profile remembers which -mods are on, every mod's own options, and which save slot each game version -plays, so swapping profiles swaps the whole playthrough and not just the mod -list. The setup that existed before profiles shipped becomes PROFILE 1 the -first time the manager opens. - -EXPORT.. writes the selected profile to `profiles/.g1rmodlist` in the -save directory; drop a `.g1rmodlist` someone shared into that folder and -IMPORT.. adds it. Imported profiles never overwrite an existing one (a name -clash gets a number). Mods the shared profile names but that are not installed -are reported when the profile is applied; installing them is still a manual -trip through the mods list or Find Mods. - -## Windows: no console windows on launcher actions - -Checking for updates, browsing a mod index, adding a mod repo, installing a -mod and picking a ROM all run a host tool (curl, PowerShell) in a child -process. On Windows those children used to each open their own console -window, so a session could end up buried under half a dozen of them. The -game now claims one console for itself at boot and hides it; the children -inherit that invisible console and nothing pops up. Nothing else changes: -file pickers are ordinary desktop dialogs and still appear normally, and a -run started from a terminal (`lovec.exe`, what `scripts\run.ps1` prefers) -keeps its terminal and its printed output. Set `POKEPORT_CONSOLE=1` to opt -out. - -## A faster, higher-contrast launcher - -The launcher and the save editor were rebuilt on one small immediate-mode UI -kit (`src/ui/kit/`), replacing the vendored FlexLove layout engine. The -visible result is that the launcher is quick: building and drawing a frame -went from about 9 ms to under 1 ms on the same machine and the same data, at -every window size, so the window keeps up with the pointer instead of -trailing it. Measure it yourself with `POKEPORT_LAUNCHER_PROF=200 love .`. - -**Nothing blocks the window any more.** Fetching a mod index, checking a mod -for updates, listing versions, downloading an install and pulling thumbnails -all run on background threads. Opening FIND MODS on a cold cache used to -freeze the launcher for as long as the server took -- often minutes, with no -indication anything was happening. Mod indexes are now also fetched at boot, -so the tab is usually already populated by the time you reach it. - -**Anything you wait on says so.** Every operation that takes time raises a -loading panel with a spinner or a progress bar that cannot be clicked around -or dismissed, so a half-finished install can never be interrupted by a stray -click. Work that only affects one row (a mod's update check) shows a small -spinner on that row instead and leaves the rest of the list usable. - -**Lists page instead of scrolling.** Mods, Find Mods, save slots, settings, -release notes and the version list all show a fixed number of rows with a -pager underneath, and the number of rows comes from the window height -- a -tall window shows more, a phone shows fewer. A long list costs exactly what a -short one does. The mouse wheel turns pages. - -**Updates live in the top right.** The in-app updater moved next to the -settings gear and pulses when an update is waiting, instead of sitting in a -banner at the bottom of the page that you had to scroll to notice. Checking -for updates from there shows a loader like everything else. - -**A quit button.** An X sits to the right of the settings gear and closes -the app cleanly, the same shutdown path as the window's close button. Mostly -for platforms where reaching the window chrome is awkward (Android, Steam -Deck, fullscreen desktops). - -**The look.** Black background, white outlines, no gradients or glows, and -buttons that are solid colour-coded keys: green commits, blue navigates, red -destroys, yellow wants attention. The three game tabs keep their red, blue -and gold cartridge colours. Everything is about a third larger than before. -The save editor follows the same theme, and adding an item there is now a -searchable pop-up like adding a Pokemon, rather than a cramped list wedged -into the tab. - -**Reset rebinds.** Input rebinds are additive, so there was no in-game way to -undo one. A RESET REBINDS row in Settings, and a matching button under Touch -Controls on each game tab, restore the stock keyboard, gamepad and touch -layout. Both ask twice. - -## Launch options: boot straight into a game - -`love . --game=red` skips the launcher and starts that game; `--slot=` picks the save slot to load, and `--launcher` forces the launcher -anyway. Spell these with an `=`: LÖVE reads the command line first and takes a -bare word as a path to a game, so `--game red` fails looking for a folder -called `red`. `POKEPORT_GAME` / `POKEPORT_SLOT` do the same for shortcuts that -can only pass environment variables. This is for one-click entries: a desktop -shortcut per game, a Steam entry, or a handheld frontend. Asking for a game -whose ROM has not been imported opens the launcher on that game's tab rather -than failing. +# New Features + +Features intentionally added beyond the original Pokémon Red, Blue, and Yellow games: + +* **Survey zoom** with connected-map rendering and configurable void fill +* **Perspective tilt mode** for an HD-2D-style overworld +* **Multiple color modes**, including original, SGB, advanced GBC, monochrome, and classic green +* **Optional GBC screen effects**, including pixel grids, shadows, glare, and transparency +* **Performance presets** and configurable FPS limits +* **Peer-to-peer link play** for trades and battles between Red, Blue, and Yellow +* **Persistent custom options** stored separately from game saves +* **Optional widescreen battle layout** +* **Mobile touch controls** with editable layouts, vibration, and orientation settings +* **Translation and custom font support** +* **Built-in save editor** for parties, boxes, items, events, maps, and Pokédex data +* **Tiled map editing tools** for mod authors +* **Pokédex diploma and printer image exports** +* **Community mod browser** +* **Soft reset button combination** +* **Keyboard and controller rebinding** +* **Mod profiles** with separate mod settings and save slots +* **Improved launcher and save editor UI**, including background downloads and update checks +* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends +* **Custom boot branding** + +Actual approximations, and missing original behavior are documented separately in `docs/known-differences.md`. diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 855b632f..8032aaa2 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -40,7 +40,7 @@ GameVersion.VERSIONS = { id = "yellow", label = "Yellow", displayName = "Pokemon Yellow", - launcherName = "Yellow (alpha)", + launcherName = "Yellow", sha1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1", manifest = "tools/rom_manifest_yellow.json", cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 448db31e..0c39e3c3 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -88,14 +88,60 @@ function HostShell.releasePointerGrab() end end +-- POPEN IS NOT THREAD SAFE, and this app calls it from four threads (the main +-- one, the update checker, and a pool of three fetch workers). +-- +-- On Darwin, popen() flushes every open stream first: _fwalk walks libc's +-- global FILE list and locks each entry as it goes. pclose() frees a FILE and +-- takes it off that list. Run the two concurrently and the walker can end up +-- waiting on the lock of a FILE another thread has already freed -- a wait +-- that nothing will ever satisfy. That is the launcher freezing on close +-- after a visit to the mod tabs: sampling a hung process shows a fetch worker +-- parked in popen -> _fwalk -> flockfile with NO curl running anywhere on the +-- machine, and the main thread blocked in Thread:wait() for that worker, which +-- is why LOVE never reaches the process exit. +-- +-- The fix is a process-wide mutex around the two list-mutating calls, and only +-- those: a LOVE Channel's performAtomic runs its callback holding the +-- channel's own mutex, which is the one lock primitive shared across love +-- threads. Reading a pipe stays outside it, so the fetch pool still runs its +-- transfers in parallel -- a spawn is microseconds, a transfer is seconds. +local POPEN_LOCK = "hostshell_popen_lock" + +local function popenLock() + if not (love and love.thread and love.thread.getChannel) then return nil end + local ok, ch = pcall(love.thread.getChannel, POPEN_LOCK) + return ok and ch or nil +end + +-- Run `fn` with the spawn lock held, or plain when there is no love.thread to +-- take one from (the headless test stub, a plain luajit run). +local function withPopenLock(fn) + local ch = popenLock() + if not ch then return fn() end + local okAtomic = pcall(function() ch:performAtomic(fn) end) + if not okAtomic then fn() end +end + -- Wraps io.popen with the AppImage env fix applied and lua errors swallowed function HostShell.popen(command, mode) HostShell.releasePointerGrab() - local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r") - if not ok or not pipe then return nil end + local pipe + withPopenLock(function() + local ok, p = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r") + pipe = (ok and p) or nil + end) return pipe end +-- Close a pipe HostShell.popen opened. Callers MUST use this rather than +-- pipe:close(): pclose is the other half of the race above, and a close that +-- skips the lock can free a FILE out from under another thread's spawn. +function HostShell.pclose(pipe) + if not pipe then return end + withPopenLock(function() pcall(function() pipe:close() end) end) +end + -- Restart the whole app. The obvious love.event.quit("restart") re-runs LÖVE's -- boot in-process, which calls love.filesystem.init a second time -- and inside -- an AppImage physfs is already initialized, so that second init throws @@ -157,6 +203,65 @@ end -- block the calling thread and deal in whole files, so callers keep exactly -- the contract they had with curl. +-- DIAGNOSING A FAILED FETCH. curl's own stderr ("curl: (56) The requested +-- URL returned error: 403") went straight to the terminal, naming neither the +-- URL nor which of the launcher's many fetches produced it, while the caller +-- got back a generic "empty response". Both curl branches below now merge +-- stderr into the pipe and ask curl for the HTTP status with --write-out, so +-- the message that reaches the UI and the log says which URL failed and how. +-- +-- The status rides a marker rather than a bare "%{http_code}": a GET streams +-- its body through the same pipe, so the code has to be findable at the end +-- of arbitrary text. Matched from the END, and only the last occurrence is +-- cut, so a body that happens to contain the marker keeps its content. +-- Two spellings on purpose. HTTP_MARK is what comes back down the pipe; the +-- FMT one is what goes to curl, where the newline MUST be the two characters +-- backslash-n (curl expands the escape itself). A literal newline inside the +-- argument would be quoted fine by a POSIX shell and be a syntax error in +-- cmd.exe, which has no multi-line quoted string. +local HTTP_MARK = "\n__gen1recomp_http__" +local HTTP_MARK_FMT = "\\n__gen1recomp_http__%{http_code}" + +-- Split a curl pipe's output into (body, status, noise). `status` is nil +-- when curl never got far enough to have one (DNS failure, no route, a +-- timeout), in which case `noise` carries curl's own complaint. +local function splitCurlOutput(out) + out = tostring(out or "") + local at = nil + local from = 1 + while true do + local s = out:find(HTTP_MARK, from, true) + if not s then break end + at, from = s, s + 1 + end + if not at then return out, nil, out end + local body = out:sub(1, at - 1) + local code = tonumber(out:sub(at + #HTTP_MARK):match("^(%d+)")) + -- curl writes http_code 0 when it never got a response at all (DNS, no + -- route, connect timeout). That is not a status, and reporting it as + -- "HTTP 0" buries the real reason, which is in curl's own message. + if code == 0 then code = nil end + return body, code, body +end + +-- The error string a caller (and the launcher's notice line) sees. It always +-- names the URL, because "403" on its own is unactionable when the launcher +-- has an index feed, a releases API and a page of thumbnails in flight. +local function fetchError(url, status, noise) + if status then + local extra = (noise or ""):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + if #extra > 160 then extra = extra:sub(1, 157) .. "..." end + if extra ~= "" then + return ("HTTP %d from %s (%s)"):format(status, url, extra) + end + return ("HTTP %d from %s"):format(status, url) + end + local why = (noise or ""):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + if why == "" then why = "no response" end + if #why > 160 then why = why:sub(1, 157) .. "..." end + return ("fetch failed for %s: %s"):format(url, why) +end + -- Shell quoting for one curl argument; cmd.exe has no single-quote form. function HostShell.quote(s) s = tostring(s) @@ -167,12 +272,21 @@ function HostShell.quote(s) return "'" .. s:gsub("'", "'\\''") .. "'" end +-- MEMOISED per Lua state (so once per thread). This used to spawn a whole +-- `curl --version` process on every single fetch -- twice for a GET through +-- the Android-bridge fallback -- which doubled the number of spawns the lock +-- above has to serialise, for an answer that cannot change while the app is +-- running. +local curlAvailable = nil + function HostShell.haveCurl() + if curlAvailable ~= nil then return curlAvailable end local pipe = HostShell.popen("curl --version") - if not pipe then return false end + if not pipe then curlAvailable = false return false end local readOk, out = pcall(function() return pipe:read("*a") end) - pcall(function() pipe:close() end) - return readOk and out ~= nil and out:find("curl", 1, true) ~= nil + HostShell.pclose(pipe) + curlAvailable = readOk and out ~= nil and out:find("curl", 1, true) ~= nil + return curlAvailable end -- An older mobile build reports nil here and falls back to the "no transport" @@ -217,11 +331,24 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime) if accept then cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " " end - cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " " .. HostShell.quote(url) + cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " " + .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " + .. HostShell.quote(url) .. " 2>&1" local pipe = HostShell.popen(cmd) if not pipe then return nil, "could not start download" end - pcall(function() pipe:read("*a") end) - pcall(function() pipe:close() end) + local readOk, out = pcall(function() return pipe:read("*a") end) + HostShell.pclose(pipe) + -- The file is still what the caller judges success by (-f writes nothing + -- on an HTTP error, and the callers all check the file anyway). The + -- status is here purely so the failure can NAME itself: "download failed" + -- with no URL and no code is the report this whole change exists to fix. + local body, status, noise = splitCurlOutput(readOk and out or "") + if status and (status < 200 or status >= 300) then + return nil, fetchError(url, status, body) + end + if not status and (noise or ""):match("%S") then + return nil, fetchError(url, nil, noise) + end return true end if not haveBridge() then @@ -229,7 +356,7 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime) end local ok, done = pcall(love.system.httpDownload, url, absPath, userAgent, accept) if ok and done then return true end - return nil, "download failed" + return nil, "download failed for " .. url end -- GET returning the body. curl streams it through a pipe; the Android bridge @@ -239,20 +366,32 @@ function HostShell.httpGet(url, userAgent, accept, maxTime) if type(url) ~= "string" or url == "" then return nil, "missing url" end userAgent = userAgent or "gen1recomp" if HostShell.haveCurl() then - local cmd = ("curl -fsSL --connect-timeout 10 --max-time %d ") + -- No -f here (the download branch keeps it). -f suppresses the error + -- BODY, and on the two services this talks to that body is the whole + -- diagnosis: GitHub's 403 says "API rate limit exceeded for ", which + -- tells a user to wait rather than to go hunting for a broken index. + local cmd = ("curl -sSL --connect-timeout 10 --max-time %d ") :format(tonumber(maxTime) or 40) .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " if accept then cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " " end - cmd = cmd .. HostShell.quote(url) + cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " + .. HostShell.quote(url) .. " 2>&1" local pipe = HostShell.popen(cmd) if not pipe then return nil, "could not run curl" end local readOk, out = pcall(function() return pipe:read("*a") end) - pcall(function() pipe:close() end) - if not readOk then return nil, "fetch failed: " .. tostring(out) end - if not out or out == "" then return nil, "empty response from " .. url end - return out + HostShell.pclose(pipe) + if not readOk then + return nil, fetchError(url, nil, tostring(out)) + end + local body, status, noise = splitCurlOutput(out) + if not status then return nil, fetchError(url, nil, noise) end + if status < 200 or status >= 300 then + return nil, fetchError(url, status, body) + end + if body == "" then return nil, "empty response from " .. url end + return body end if not haveBridge() then return nil, "no network transport on this platform" diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 45292922..aba010b6 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -62,7 +62,7 @@ local FILTERS = { "OFF", "1X", "2X", "3X" } -- The core rows. Helper modules are required lazily under pcall: they are -- pure label/cycle tables, but the launcher must never die because a render -- module grew a dependency on live game data. -local function coreRows(opts) +local function coreRows(opts, hooks) local rows = {} local function add(label, value, step) rows[#rows + 1] = { label = label, value = value, step = step } @@ -257,6 +257,26 @@ local function coreRows(opts) end end + -- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a + -- button on the game panel, once per game -- but the overlay layout is + -- global (options.touchControls.layouts), so three tabs offered three + -- buttons that edited the same thing while crowding the column that has to + -- hold Play. It belongs with the other control rows, behind the gear. + -- The host owns the editor screen, so the row only fires when a hook was + -- supplied (the standalone save editor opens this model with none). + if hooks and hooks.editTouchControls then + rows[#rows + 1] = { + label = Strings("TOUCH CONTROLS"), + actionLabel = Strings("Edit"), + action = function() + hooks.editTouchControls() + -- The editor replaces the whole screen: nothing left to persist here + -- beyond what the caller already saved on the way out. + return false + end, + } + end + -- RESET REBINDS, directly under the touch-pad row. Rebinds are additive -- (src/core/Input.lua:applyBindings layers options.bindings over the -- defaults rather than replacing them), so a player who has bound @@ -417,10 +437,12 @@ end -- sections of rows, and a save() that persists it. The caller keeps the -- model for as long as the panel is open; nothing else in the launcher -- writes options while a modal covers it, so the cached table stays true. -function LauncherSettings.open() +-- `hooks` carries the host actions a row cannot perform itself: +-- editTouchControls() -- hand the screen to the touch-overlay editor +function LauncherSettings.open(hooks) local opts = SaveData.loadOptions() local sections = { - { title = Strings("OPTIONS"), rows = coreRows(opts) }, + { title = Strings("OPTIONS"), rows = coreRows(opts, hooks) }, } for _, mod in ipairs(discoverModSchemas(opts)) do local rows = modRows(opts, mod) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 6b9e5c9b..c1732e6a 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -271,6 +271,45 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac end end +-- A square icon control: the header's gear and quit, and the game panel's +-- manage button. Inverts to a solid white fill when hot, the same signal +-- every other control here uses, and rounds to the shared control radius. +-- `image` draws a texture; `drawFn(x, y, size, hot)` draws a hand-rolled +-- glyph (the quit X, which ships no asset). +local function iconButton(imp, key, x, y, size, image, action, drawFn) + Kit._audit("control", x, y, size, size, key) + local focused = Kit.focusable(key, x, y, size, size) + local hot = focused or Kit.hover(x, y, size, size) + Theme.fillRounded(x, y, size, size, hot and PAL.ink or PAL.surface, 1) + Theme.strokeRounded(x, y, size, size, PAL.line, + hot and Theme.A.focus or Theme.A.hairline, 1) + if image then + local iw, ih = image:getDimensions() + local pad = math.floor(size * 0.24) + local s = math.min((size - 2 * pad) / iw, (size - 2 * pad) / ih) + if hot then love.graphics.setColor(0, 0, 0, 1) + else love.graphics.setColor(1, 1, 1, 0.85) end + love.graphics.draw(image, Theme.snap(x + (size - iw * s) / 2), + Theme.snap(y + (size - ih * s) / 2), 0, s, s) + love.graphics.setColor(1, 1, 1, 1) + elseif drawFn then + drawFn(x, y, size, hot) + end + if action and (Kit.press(x, y, size, size) or Kit._activateId == key) then + queueAction(imp, key, action) + end +end + +-- The cartridge colour for a game, matching its tab in the header. Play +-- wears it, so "which game is this button going to boot" is answered before +-- the label is read. Unknown versions fall back to the commit green. +local CART_COLOR = { + red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold, +} +local function cartColor(version) + return CART_COLOR[version] or PAL.green +end + local function modStatusColor(status) if status == "ok" then return Strings("Ready"), PAL.green end if status == "conflict" then return Strings("Conflict"), PAL.red end @@ -337,15 +376,22 @@ local function buildHeader(imp, m) local rowH = m.logoH + math.floor(12 * m.s) local gear = m.chip - -- The logo is centred in the FULL row, then the right cluster is drawn over - -- its own reserved space, so the wordmark never drifts as buttons appear. - if imp.logo then + -- The wordmark is centred in the row MINUS the right cluster, mirrored on + -- the left so it still reads as centred in the window. Centring it in the + -- FULL row (what this used to do) let a phone-width wordmark run straight + -- under the gear and the quit X -- "the settings is covering the logo". + -- Reserving the space on both sides costs a little width and cannot + -- overlap at any window size. + local clusterW = 2 * gear + math.floor(6 * m.s) + m.pad + local boxX = m.x + clusterW + local boxW = math.max(0, m.w - 2 * clusterW) + if imp.logo and boxW > 0 then local lw, lh = imp.logo:getDimensions() - local maxW = math.min(320 * m.s, m.w * 0.55) + local maxW = math.min(320 * m.s, boxW) local scale = math.min(maxW / lw, m.logoH / lh) local dw, dh = lw * scale, lh * scale love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(imp.logo, Theme.snap(m.x + (m.w - dw) / 2), + love.graphics.draw(imp.logo, Theme.snap(boxX + (boxW - dw) / 2), Theme.snap(y + (rowH - dh) / 2), 0, scale, scale) end @@ -359,47 +405,23 @@ local function buildHeader(imp, m) local quitX = rx - gear rx = quitX - math.floor(6 * m.s) - -- Settings gear. + -- Settings gear. It now also owns the CONTROL settings (touch overlay + -- editor, reset rebinds), which used to be buttons stacked in the game + -- panel -- see LauncherSettings.coreRows. imp._gearIcon = imp._gearIcon or love.graphics.newImage("assets/launcher/gear.png") rx = rx - gear - do - local x = rx - Kit._audit("control", x, by, gear, gear, "gear") - local focused = Kit.focusable("gear", x, by, gear, gear) - local hot = focused or Kit.hover(x, by, gear, gear) - Theme.fill(x, by, gear, gear, hot and PAL.ink or PAL.bg, 1) - Theme.stroke(x, by, gear, gear, PAL.line, - hot and Theme.A.focus or Theme.A.hairline, 1) - local iw, ih = imp._gearIcon:getDimensions() - local pad = math.floor(gear * 0.22) - local s = math.min((gear - 2 * pad) / iw, (gear - 2 * pad) / ih) - if hot then love.graphics.setColor(0, 0, 0, 1) - else love.graphics.setColor(1, 1, 1, 0.85) end - love.graphics.draw(imp._gearIcon, Theme.snap(x + (gear - iw * s) / 2), - Theme.snap(by + (gear - ih * s) / 2), 0, s, s) - love.graphics.setColor(1, 1, 1, 1) - if Kit.press(x, by, gear, gear) or Kit._activateId == "gear" then - queueAction(imp, "gear", function() imp:_openSettings() end) - end - end + iconButton(imp, "gear", rx, by, gear, imp._gearIcon, + function() imp:_openSettings() end) -- Quit, top-right corner. - do - local x = quitX - Kit._audit("control", x, by, gear, gear, "quit") - local focused = Kit.focusable("quit", x, by, gear, gear) - local hot = focused or Kit.hover(x, by, gear, gear) - Theme.fill(x, by, gear, gear, hot and PAL.ink or PAL.bg, 1) - Theme.stroke(x, by, gear, gear, PAL.line, - hot and Theme.A.focus or Theme.A.hairline, 1) - local pad = math.floor(gear * 0.32) - drawCross(x + pad, by + pad, gear - 2 * pad, - hot and { 0, 0, 0, 1 } or { 1, 1, 1, 0.85 }) - if Kit.press(x, by, gear, gear) or Kit._activateId == "quit" then - queueAction(imp, "quit", function() imp:_quitApp() end) - end - end + iconButton(imp, "quit", quitX, by, gear, nil, + function() imp:_quitApp() end, + function(x, y, size, hot) + local pad = math.floor(size * 0.32) + drawCross(x + pad, y + pad, size - 2 * pad, + hot and { 0, 0, 0, 1 } or { 1, 1, 1, 0.85 }) + end) -- The self-update control lives in the FOOTER next to the BCG mark (small, -- out of the wordmark's way -- it used to overlap the logo on a phone). It @@ -438,9 +460,9 @@ local function buildHeader(imp, m) local hot = focused or Kit.hover(tx, ty, w, tabH) local invert = active or hot local tint = t.color or PAL.ink - Theme.fill(tx, ty, w, tabH, invert and tint or PAL.bg, 1) + Theme.fillRounded(tx, ty, w, tabH, invert and tint or PAL.surface, 1) if not invert then - Theme.stroke(tx, ty, w, tabH, tint, + Theme.strokeRounded(tx, ty, w, tabH, tint, t.color and Theme.A.hover or Theme.A.hairline, 1) end -- Ink on a filled tab must contrast with THAT fill: black on the light @@ -503,162 +525,142 @@ end -- ------------------------------------------------------------ game panel --- One card of actions: Re-import ROM on top, the Import/Export save pair --- under it. The old ROM card's caption/filename/"Verified." furniture and --- the SAVE FILES caption are gone -- a ready game shows only the buttons. --- The ROM import STATE lines survive (progress, "Import failed", the no-ROM --- drop hint): while there is no ROM they are the whole story of this card. -local function buildActionsCard(imp, x, y, w, m, version, info, ready, locked, - maxH) +-- What this version's ROM situation is, as a plain table. The panel and the +-- per-game manage modal both read it, so the two can never disagree about +-- whether a ROM is present or what the import button should say. +-- state a headline, or nil when there is nothing to report (ready) +-- detail the paragraph under it +-- label the import button's caption +-- enabled whether that button may be pressed +-- progress 0-1 while an import for THIS version is running +local function romModel(imp, version, info, ready, locked) + local importLabel = imp.isNX and Strings("Scan again") or Strings("Import ROM") + if locked then + return { state = Strings("Not supported yet"), + detail = Strings("Support for this game is on the way."), + label = Strings("Import unavailable"), enabled = false } + end local dropHint = imp.isNX and Strings("Copy the .gb/.gbc via MTP into imports/.") or (imp.android and Strings("Copy the .gb/.gbc via USB.") or Strings("Or drop the .gb/.gbc file here.")) - local importLabel = imp.isNX and Strings("Scan again") or Strings("Import ROM") - local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress - if locked then - romState, romDetail = Strings("Not supported yet"), - Strings("Support for this game is on the way.") - romBtnLabel, romBtnEnabled = Strings("Import unavailable"), false - else - local importing = imp.importing == version - local erroring = imp.workState == "error" and imp.errorVersion == version - local notice = imp.notice and imp.notice.version == version and imp.notice - if importing and (imp.workState == "working" or imp.workState == "complete") then - romState = imp.status or Strings("Importing") - romDetail = imp.detail or "" - romProgress = imp.progress or 0 - elseif ready then - romBtnLabel, romBtnEnabled = Strings("Re-import ROM"), true - elseif erroring then - romState = Strings("Import failed") - romDetail = imp.detail or Strings("That ROM could not be imported.") - romBtnLabel, romBtnEnabled = importLabel, true - elseif notice then - romState = Strings("No ROM imported") - romDetail = ((notice.status or "") .. " " .. (notice.detail or "")) - :gsub("^%s+", ""):gsub("%s+$", "") - romBtnLabel, romBtnEnabled = importLabel, true - elseif imp.returning[version] then - romState = Strings("Update required") - romDetail = Strings("This build needs a few more things from your ") - .. info.label .. Strings(" ROM. Re-import to continue.") - romBtnLabel, romBtnEnabled = Strings("Re-import ROM"), true - else - romState = Strings("No ROM imported") - romDetail = Strings("The ROM is verified before any files are created. ") - .. dropHint - romBtnLabel, romBtnEnabled = importLabel, true - end + local importing = imp.importing == version + local erroring = imp.workState == "error" and imp.errorVersion == version + local notice = imp.notice and imp.notice.version == version and imp.notice + if importing and (imp.workState == "working" or imp.workState == "complete") then + return { state = imp.status or Strings("Importing"), + detail = imp.detail or "", progress = imp.progress or 0 } + elseif erroring then + -- An import that FAILED is reported even on a ready game (a re-import + -- that could not read the new file): the failure is the only reason the + -- library still holds the old cache, and it must not be silent (the + -- "Import failed with no explanation" report). + return { state = Strings("Import failed"), + detail = imp.detail or Strings("That ROM could not be imported."), + label = importLabel, enabled = true } + elseif ready then + return { label = Strings("Re-import ROM"), enabled = true } + elseif notice then + return { state = Strings("No ROM imported"), + detail = ((notice.status or "") .. " " .. (notice.detail or "")) + :gsub("^%s+", ""):gsub("%s+$", ""), + label = importLabel, enabled = true } + elseif imp.returning[version] then + return { state = Strings("Update required"), + detail = Strings("This build needs a few more things from your ") + .. info.label .. Strings(" ROM. Re-import to continue."), + label = Strings("Re-import ROM"), enabled = true } end + return { state = Strings("No ROM imported"), + detail = Strings("The ROM is verified before any files are created. ") + .. dropHint, + label = importLabel, enabled = true } +end - local sfImportEnabled, sfExportEnabled = false, false - if not locked then - imp:_ensureSlots(version) - sfImportEnabled = ready and true or false - local activeId = imp.activeSlot[version] - for _, sl in ipairs(imp.slots[version] or {}) do - if sl.id == activeId and sl.exists then sfExportEnabled = true break end - end +-- The import action behind whichever button carries it. +local function romAction(imp, version, mdl) + if not mdl.enabled then return nil end + return function() + if imp.ready[version] then imp:reimport(version) + else imp:choose(version) end end - local sfNotice = (not locked) and imp.saveNotice[version] or nil - local hintText, hintCol - if sfNotice then - hintText, hintCol = sfNotice.text, (sfNotice.ok and PAL.green or PAL.red) - elseif locked then - hintText, hintCol = Strings("Not available yet."), PAL.muted - else - hintText, hintCol = imp:_savesDefaultHint(version), PAL.muted - end - local savImportLabel = imp.isNX and Strings("Scan again") or Strings("Import save") +end +-- The ROM card: the state headline, its paragraph, and the Import button. +-- It exists ONLY while there is something to report -- a game with a verified +-- ROM shows Play, not a card of file management (that moved behind the manage +-- button next to Play, and the save file controls moved into the slot card). +-- Returns the height it consumed, 0 when it drew nothing. +local function buildRomCard(imp, x, y, w, m, version, mdl, maxH) + if not (mdl.state or mdl.progress) then return 0 end local pad = math.floor(14 * m.s) local iw = w - 2 * pad local lineH = Kit.textHeight("small") - local folderRow = sfNotice and sfNotice.dir - -- The buttons and pads are fixed furniture that always fits; the two text - -- runs are the elastic part. ROM detail lines come first (they only exist - -- while there is an import state to explain), the save hint takes whatever - -- lines remain. Without this the card overflowed its budget and got - -- clipped mid-button -- the failure a no-scroll layout must design out. - local fixedH = pad + m.btnH + math.floor(10 * m.s) + m.btnH - + math.floor(8 * m.s) + pad - + (folderRow and (math.floor(6 * m.s) + lineH) or 0) - local stateHeadH = romState - and (Kit.textHeight("button") + math.floor(4 * m.s) + math.floor(10 * m.s)) - or 0 - local detailLines = romState and 3 or 0 - local hintLines = 3 + local hasButton = mdl.progress == nil and mdl.label ~= nil + -- Pads and the button are fixed furniture that always fits; the detail + -- paragraph is the elastic part and gets trimmed to whatever lines the + -- budget leaves. Without that trim the card overflowed and got clipped + -- mid-button, which is the failure a no-scroll layout must design out. + local fixedH = pad + Kit.textHeight("button") + math.floor(4 * m.s) + + math.floor(10 * m.s) + + ((hasButton or mdl.progress) and (m.btnH + math.floor(2 * m.s)) or 0) + + pad + local detailLines = 3 if maxH then - local room = math.floor((maxH - fixedH - stateHeadH) / lineH) - detailLines = math.max(0, math.min(detailLines, room)) - hintLines = math.max(0, math.min(hintLines, room - detailLines)) + detailLines = math.max(0, + math.min(detailLines, math.floor((maxH - fixedH) / lineH))) end - local detailH = romState - and Kit.wrapHeight("small", romDetail, iw, detailLines) or 0 - local hintH = Kit.wrapHeight("small", hintText, iw, hintLines) - local h = fixedH + stateHeadH + detailH + hintH + local detailH = Kit.wrapHeight("small", mdl.detail or "", iw, detailLines) + local h = fixedH + detailH Kit.card(x, y, w, h) local cy = y + pad - if romState then - Kit.text("button", Kit.ellipsize("button", romState, iw), x + pad, cy, - PAL.heading) - cy = cy + Kit.textHeight("button") + math.floor(4 * m.s) - cy = cy + Kit.textWrapped("small", romDetail, x + pad, cy, iw, PAL.detail, - detailLines) - cy = cy + math.floor(10 * m.s) - end - if romProgress ~= nil then + Kit.text("button", Kit.ellipsize("button", mdl.state or "", iw), x + pad, cy, + PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(4 * m.s) + cy = cy + Kit.textWrapped("small", mdl.detail or "", x + pad, cy, iw, + PAL.detail, detailLines) + cy = cy + math.floor(10 * m.s) + if mdl.progress ~= nil then Kit.progress(x + pad, cy + (m.btnH - math.floor(10 * m.s)) / 2, iw, - math.floor(10 * m.s), romProgress) - else - btn(imp, x + pad, cy, iw, m.btnH, "rom-" .. version, romBtnLabel, { - kind = "accent", - enabled = romBtnEnabled, - action = romBtnEnabled and function() - if imp.ready[version] then imp:reimport(version) - else imp:choose(version) end - end or nil, + math.floor(10 * m.s), mdl.progress) + elseif hasButton then + btn(imp, x + pad, cy, iw, m.btnH, "rom-" .. version, mdl.label, { + kind = "accent", enabled = mdl.enabled, + action = romAction(imp, version, mdl), }) end - cy = cy + m.btnH + math.floor(10 * m.s) - local gap = math.floor(10 * m.s) - local halfW = math.floor((iw - gap) / 2) - btn(imp, x + pad, cy, halfW, m.btnH, "sav-import-" .. version, savImportLabel, { - kind = "accent", enabled = sfImportEnabled, - action = sfImportEnabled and function() imp:chooseSaveImport(version) end or nil, - }) - btn(imp, x + pad + halfW + gap, cy, halfW, m.btnH, "sav-export-" .. version, - Strings("Export save"), { - kind = "accent", enabled = sfExportEnabled, - action = sfExportEnabled and function() imp:exportSave(version) end or nil, - }) - cy = cy + m.btnH + math.floor(8 * m.s) - cy = cy + Kit.textWrapped("small", hintText, x + pad, cy, iw, hintCol, - hintLines) - if folderRow then - cy = cy + math.floor(6 * m.s) - local key = "sav-folder-" .. version - local label = Strings("Open folder") - local lw = Kit.textWidth("small", label) - local lh = Kit.textHeight("small") - Kit.focusable(key, x + pad, cy, lw, lh) - Kit.text("small", label, x + pad, cy, PAL.blue) - Theme.fill(x + pad, cy + lh - 1, lw, 1, PAL.blue, 0.6) - if Kit.press(x + pad, cy, lw, lh) or Kit._activateId == key then - local dir = sfNotice.dir - queueAction(imp, key, function() - love.system.openURL(imp:fileUrl(dir)) - end) - end - end return h end -- Save slots, PAGINATED. This was a fixed-height scroller with momentum; it -- is now a page of rows sized to whatever height the column has left, which -- is why 40 slots cost exactly what 4 do. -local function buildSlotCard(imp, x, y, w, availH, m, version) +-- Lay a row's action chips out right-aligned, wrapping onto further lines +-- when they cannot all fit across the row. A narrow window (the 150%-scaled +-- desktop and the portrait phone in the reports) could not fit four chips on +-- one line, and a fixed right-to-left cluster simply walked them off the left +-- edge and under the row's own text. Returns an array of lines, each an +-- array of chips, so the caller can size the row BEFORE drawing it. +local function chipLines(chips, inner, gap) + local lines, line, used = {}, {}, 0 + for _, c in ipairs(chips) do + if #line > 0 and used + gap + c.w > inner then + lines[#lines + 1] = line + line, used = {}, 0 + end + used = used + ((#line > 0) and gap or 0) + c.w + line[#line + 1] = c + end + if #line > 0 then lines[#lines + 1] = line end + return lines +end + +-- The width a chip needs for its caption, at the row-chip font. +local function chipWidth(label, m) + return Kit.textWidth("small", label) + math.floor(20 * m.s) +end + +local function buildSlotCard(imp, x, y, w, availH, m, version, ready) imp:_ensureSlots(version) local slots = imp.slots[version] or {} local active = imp.activeSlot[version] @@ -667,17 +669,71 @@ local function buildSlotCard(imp, x, y, w, availH, m, version) local iw = w - 2 * pad local gap = math.floor(8 * m.s) - -- A slot row: name + LOADED tag, meta line, action buttons. + -- A slot row: name + LOADED tag, meta line, then the action chips. The + -- chip set is measured against the WIDEST possible row (every chip present) + -- so every row on the page is the same height even though an empty slot + -- offers fewer -- pagination derives its row count from a uniform height. local chipH = math.max(Kit.tapMin(), math.floor(30 * m.s)) - local rowH = math.floor(8 * m.s) + Kit.textHeight("button") - + math.floor(4 * m.s) + Kit.textHeight("small") - + math.floor(8 * m.s) + chipH + math.floor(8 * m.s) + local rowInner = iw - math.floor(20 * m.s) + local maxChips = { + { w = chipWidth(Strings("Export"), m) }, + { w = chipWidth(Strings("Rename"), m) }, + { w = chipWidth(Strings("Edit"), m) }, + -- Delete's width is pinned to the WIDER of its two captions so arming to + -- "Sure?" never reflows the row under the pointer (#433). + { w = math.max(chipWidth(DELETE_LABEL(false), m), + chipWidth(DELETE_LABEL(true), m)) }, + } + local chipGap = math.floor(6 * m.s) + local maxChipsW = 0 + for i, c in ipairs(maxChips) do + maxChipsW = maxChipsW + c.w + ((i > 1) and chipGap or 0) + end + -- BESIDE the text when the row is wide enough to hold both and still leave + -- the name and meta lines a readable share, UNDER it when it is not. A + -- desktop row costs one text block instead of a text block plus a button + -- strip, which is what lets a two-column window show several slots per page + -- instead of one; a phone row keeps the taller shape rather than squeezing + -- four chips and a name into one line. + local textH = Kit.textHeight("button") + math.floor(4 * m.s) + + Kit.textHeight("small") + -- The threshold is what the TEXT needs, not a fraction of the row: a slot + -- name plus its badges/time/dex line wants about this much before it starts + -- ellipsizing anything a player came to read. + local textMinW = math.floor(150 * m.s) + local sideBySide = + (rowInner - maxChipsW - math.floor(12 * m.s)) >= textMinW + local chipRowCount = #chipLines(maxChips, rowInner, chipGap) + local chipBlockH = chipRowCount * chipH + + math.max(0, chipRowCount - 1) * chipGap + local rowH + if sideBySide then + rowH = math.floor(8 * m.s) + math.max(textH, chipH) + math.floor(8 * m.s) + else + rowH = math.floor(8 * m.s) + textH + math.floor(8 * m.s) + chipBlockH + + math.floor(8 * m.s) + end - local headH = Kit.textHeight("caption") + math.floor(8 * m.s) + -- The header carries "Import save": a .sav import CREATES a slot, so it + -- belongs to the slot list rather than to the ROM card it used to sit in. + local headH = math.max(Kit.textHeight("caption"), m.btnH) + math.floor(8 * m.s) local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local newBtnH = m.btnH + local sfNotice = imp.saveNotice[version] + local hintText, hintCol + if sfNotice then + hintText, hintCol = sfNotice.text, (sfNotice.ok and PAL.green or PAL.red) + else + hintText, hintCol = nil, PAL.muted + end + local hintH = hintText + and (Kit.wrapHeight("small", hintText, iw, 2) + math.floor(8 * m.s)) or 0 + local folderRow = sfNotice and sfNotice.dir + if folderRow then hintH = hintH + Kit.textHeight("small") + math.floor(4 * m.s) end + -- Rows get whatever is left after the card's fixed furniture. - local listH = availH - (pad * 2 + headH + pagerH + gap + newBtnH + gap) + local listH = availH + - (pad * 2 + headH + hintH + pagerH + gap + newBtnH + gap) local perPage = Kit.rowsThatFit(listH, rowH, gap, 1, 12) local pageKey = "slots-" .. version local first, last, cur, pages = Kit.pageBounds(page(imp, pageKey), n, perPage) @@ -686,14 +742,28 @@ local function buildSlotCard(imp, x, y, w, availH, m, version) local shown = math.max(0, last - first + 1) local usedListH = (n == 0) and math.floor(70 * m.s) or (shown * rowH + math.max(0, shown - 1) * gap) - local h = pad + headH + usedListH + gap + local h = pad + headH + usedListH + gap + hintH + (pages > 1 and (pagerH + gap) or 0) + newBtnH + pad Kit.card(x, y, w, h) local cy = y + pad - Kit.caption(x + pad, cy, Strings("SAVE SLOT")) - Kit.textRight("small", n == 1 and Strings("1 slot") or Strings("%d slots", n), - x + w - pad, cy, PAL.muted) + local capY = cy + math.floor((m.btnH - Kit.textHeight("caption")) / 2) + Kit.caption(x + pad, capY, Strings("SAVE SLOT")) + local savImportLabel = imp.isNX and Strings("Scan again") + or Strings("Import save") + local impW = chipWidth(savImportLabel, m) + math.floor(8 * m.s) + btn(imp, x + w - pad - impW, cy, impW, m.btnH, "sav-import-" .. version, + savImportLabel, { + kind = "accent", font = "small", enabled = ready and true or false, + action = ready and function() imp:chooseSaveImport(version) end or nil, + }) + local countW = (x + w - pad - impW - math.floor(8 * m.s)) + - (x + pad + Kit.captionWidth(Strings("SAVE SLOT")) + math.floor(8 * m.s)) + if countW > 0 then + Kit.textRight("small", + n == 1 and Strings("1 slot") or Strings("%d slots", n), + x + w - pad - impW - math.floor(8 * m.s), capY, PAL.muted) + end cy = cy + headH if n == 0 then @@ -715,17 +785,23 @@ local function buildSlotCard(imp, x, y, w, availH, m, version) local px = x + pad + math.floor(10 * m.s) local inner = iw - math.floor(20 * m.s) + -- Beside the chips, the text block only owns what they leave; under + -- them it owns the row. Either way the width is fixed before anything + -- prints, so the name ellipsizes into its own space rather than into + -- a button. + local textW = sideBySide + and (inner - maxChipsW - math.floor(12 * m.s)) or inner local ly = ry + math.floor(8 * m.s) + + (sideBySide and math.floor((math.max(textH, chipH) - textH) / 2) or 0) local name = slot.label or slot.name or Strings("NEW GAME") local tagW = 0 if selected then tagW = Kit.textWidth("micro", Strings("LOADED")) + math.floor(16 * m.s) - Kit.tag(x + pad + iw - math.floor(10 * m.s) - tagW, ly, - tagW, Kit.textHeight("button"), Strings("LOADED"), - selected and PAL.inverse or PAL.green) + Kit.tag(px + textW - tagW, ly, tagW, Kit.textHeight("button"), + Strings("LOADED"), PAL.inverse) tagW = tagW + math.floor(8 * m.s) end - Kit.text("button", Kit.ellipsize("button", name, inner - tagW), px, ly, ink) + Kit.text("button", Kit.ellipsize("button", name, textW - tagW), px, ly, ink) ly = ly + Kit.textHeight("button") + math.floor(4 * m.s) local metaTxt if slot.exists and slot.meta then @@ -734,45 +810,92 @@ local function buildSlotCard(imp, x, y, w, availH, m, version) else metaTxt = Strings("empty slot") end - Kit.text("small", Kit.ellipsize("small", metaTxt, inner), px, ly, + Kit.text("small", Kit.ellipsize("small", metaTxt, textW), px, ly, selected and PAL.inverse or PAL.muted) - ly = ly + Kit.textHeight("small") + math.floor(8 * m.s) + -- Where the chip block starts: centred on the row beside the text, or + -- on its own line under it. + ly = sideBySide and (ry + (rowH - chipBlockH) / 2) + or (ly + Kit.textHeight("small") + math.floor(8 * m.s)) - -- Action chips, right-aligned. A selected row is a white fill, so its - -- chips invert too or they would vanish. - local place = Layout.rightCluster(px, inner, math.floor(6 * m.s)) + -- Action chips, right-aligned and wrapped onto as many lines as the row + -- width needs. Export lives HERE rather than beside the ROM buttons: + -- an export is a property of a slot, so the control belongs on the slot + -- it exports (it selects the row first, since the exporter writes + -- whichever slot is active). local armed = deleteArmed(imp, "slot", slot.id, version) - -- Width pinned to the WIDER of the two captions so arming to "Sure?" - -- never reflows the row under the pointer (#433), and a translation - -- whose "delete" is shorter than its "sure?" is not clipped. - local delW = math.max(Kit.textWidth("small", DELETE_LABEL(false)), - Kit.textWidth("small", DELETE_LABEL(true))) + math.floor(20 * m.s) - btn(imp, place(delW), ly, delW, chipH, rowKey .. "-del", DELETE_LABEL(armed), { - kind = "danger", font = "small", keepArm = true, + local chips = {} + if slot.exists then + chips[#chips + 1] = { label = Strings("Export"), kind = "accent", + key = rowKey .. "-export", + action = function() + imp:_selectSlot(version, slot.id) + imp:exportSave(version) + end } + end + if not imp.android then + chips[#chips + 1] = { label = Strings("Rename"), kind = "accent", + key = rowKey .. "-rename", + action = function() imp:_beginRename(version, slot.id) end } + end + if imp.onEditSave and slot.exists then + chips[#chips + 1] = { label = Strings("Edit"), kind = "accent", + key = rowKey .. "-edit", + action = function() imp.onEditSave(version, slot.id) end } + end + chips[#chips + 1] = { label = DELETE_LABEL(armed), kind = "danger", + keepArm = true, key = rowKey .. "-del", + -- Pinned width, so arming to "Sure?" cannot reflow the cluster. + w = math.max(chipWidth(DELETE_LABEL(false), m), + chipWidth(DELETE_LABEL(true), m)), action = function() imp:pressDelete("slot", slot.id, version, function() imp:_deleteSlot(version, slot.id) end) - end, - }) - if imp.onEditSave and slot.exists then - local ew = Kit.textWidth("small", Strings("Edit")) + math.floor(20 * m.s) - btn(imp, place(ew), ly, ew, chipH, rowKey .. "-edit", Strings("Edit"), { - kind = "accent", font = "small", - action = function() imp.onEditSave(version, slot.id) end, - }) - end - if not imp.android then - local rw = Kit.textWidth("small", Strings("Rename")) + math.floor(20 * m.s) - btn(imp, place(rw), ly, rw, chipH, rowKey .. "-rename", Strings("Rename"), { - kind = "accent", font = "small", - action = function() imp:_beginRename(version, slot.id) end, - }) + end } + for _, c in ipairs(chips) do c.w = c.w or chipWidth(c.label, m) end + for li, line in ipairs(chipLines(chips, inner, chipGap)) do + local total = 0 + for i, c in ipairs(line) do + total = total + c.w + ((i > 1) and chipGap or 0) + end + local cx = px + inner - total + local cly = ly + (li - 1) * (chipH + chipGap) + for _, c in ipairs(line) do + btn(imp, cx, cly, c.w, chipH, c.key, c.label, { + kind = c.kind, font = "small", keepArm = c.keepArm, + action = c.action, + }) + cx = cx + c.w + chipGap + end end end cy = cy + usedListH + gap end + -- The save-file notice (import/export result) lands in this card now that + -- the buttons that produce it do. + if hintText then + cy = cy + Kit.textWrapped("small", hintText, x + pad, cy, iw, hintCol, 2) + if folderRow then + cy = cy + math.floor(4 * m.s) + local key = "sav-folder-" .. version + local label = Strings("Open folder") + local lw = Kit.textWidth("small", label) + local lh = Kit.textHeight("small") + Kit.focusable(key, x + pad, cy, lw, lh) + Kit.text("small", label, x + pad, cy, PAL.blue) + Theme.fill(x + pad, cy + lh - 1, lw, 1, PAL.blue, 0.6) + if Kit.press(x + pad, cy, lw, lh) or Kit._activateId == key then + local dir = sfNotice.dir + queueAction(imp, key, function() + love.system.openURL(imp:fileUrl(dir)) + end) + end + cy = cy + lh + end + cy = cy + math.floor(8 * m.s) + end + if pages > 1 then local newPage = Kit.pager(x + pad, cy, iw, cur, n, perPage, pageKey) setPage(imp, pageKey, newPage) @@ -817,84 +940,57 @@ local function buildGamePanel(imp, x, y, w, availH, m, version) lx, lw, rx2, rw = x, w, x, w end - -- LEFT COLUMN. The controls that must always be reachable -- Play, and the - -- control-reset pair -- are PINNED to the bottom of the column and laid out - -- upward; the informational cards fill downward from the top into whatever - -- is left. Without that pinning the column is a stack whose height depends - -- on how much text the actions card happens to carry, and at a - -- large UI scale on a short window the Play button is what falls off the - -- bottom -- the one thing that must never happen, and with no scrollbar to - -- rescue it. - local playH = math.max(m.btnH, math.floor(52 * m.s)) - local bottom = cy + remaining - local py = bottom - playH - btn(imp, lx, py, lw, playH, "play-" .. version, - ready and (Strings("Play ") .. gameName) - or (locked and Strings("Coming soon") or Strings("Import a ROM to play")), - { - kind = ready and "primary" or "ghost", font = "stat", - enabled = ready, - action = ready and function() imp:play(version) end or nil, - }) - - if imp.controlsNotice then - local nh = Kit.wrapHeight("small", imp.controlsNotice.text, lw, 2) - py = py - nh - math.floor(4 * m.s) - Kit.textWrapped("small", imp.controlsNotice.text, lx, py, lw, - imp.controlsNotice.ok and PAL.green or PAL.red, 2) - end - -- Reset rebinds, directly under the touch controls. Rebinds are additive - -- (Input:applyBindings layers them over the defaults), so there is no - -- in-game way to undo one -- this is the way back. Two-press confirm, - -- same as every other destructive control here. - do - py = py - math.floor(6 * m.s) - m.btnH - local armed = deleteArmed(imp, "rebinds", "all", nil) - btn(imp, lx, py, lw, m.btnH, "reset-rebinds", - armed and Strings("Sure? Reset all rebinds") or Strings("Reset rebinds"), { - kind = "danger", keepArm = true, - action = function() - imp:pressDelete("rebinds", "all", nil, function() - imp:_resetRebinds() - end) - end, - }) - end - if imp.onEditTouchControls then - py = py - math.floor(6 * m.s) - m.btnH - btn(imp, lx, py, lw, m.btnH, "touch-controls", Strings("Touch Controls"), { - kind = "accent", - action = function() imp.onEditTouchControls() end, - }) - end - - -- The actions card fills the space above the pinned block, clipped so a - -- long ROM error message can never paint over the controls below it; it - -- trims its own elastic text to fit. The clip is a backstop, not the - -- mechanism. - local cardsH = py - gap - cy - Kit.pushClip(lx, cy, lw, math.max(0, cardsH)) + -- LEFT COLUMN, laid out DOWNWARD from the top. It used to pin Play and a + -- Touch-Controls/Reset-rebinds pair to the BOTTOM and fill the cards + -- downward into whatever was left, which meant the column's height was + -- whatever its text happened to need -- and on any window shorter than + -- that pile the pinned block simply left the window (Play was measurably + -- off-screen at 1280x720 and on every phone shape). The controls pair has + -- moved behind the gear (they are global settings, not per-game), the ROM + -- and save file management moved into the manage modal and the slot card, + -- and what is left is short enough to lay out top-down and always fit. + local mdl = romModel(imp, version, info, ready, locked) local ly = cy - -- In one column the save-slot card shares this region, so the actions - -- card gets a bounded share of it rather than the whole thing. - local infoBudget = m.twoCol and cardsH or math.floor(cardsH * 0.42) - local actH = buildActionsCard(imp, lx, ly, lw, m, version, info, ready, - locked, infoBudget) - ly = ly + actH + gap - Kit.popClip() - -- Save slots. Two columns put them beside the info cards; ONE column - -- stacks them underneath, in the space between those cards and the pinned - -- controls at the bottom. Placing them after the pinned block (the - -- obvious reading of "stack it under the left column") drew them off the - -- bottom of the window and over the footer, with no scrollbar to reach - -- them -- in a no-scroll layout, anything below the fold is simply gone. + if ready then + -- Play IS the panel: it takes the space the ROM buttons used to hold, at + -- the top of the column where the eye lands, wearing this game's own + -- cartridge colour rather than a generic green. + -- Play grows into the room the column has: it is the one thing on this + -- screen the player came for, and the space freed by moving ROM and + -- control management out belongs to it rather than to a gap. Clamped at + -- both ends so a short window still gets a real button and a tall one + -- does not get a billboard. + local playH = math.floor(clamp(remaining * 0.30, 64 * m.s, 132 * m.s)) + local mgW = playH + local bgap = math.floor(8 * m.s) + btn(imp, lx, ly, lw - mgW - bgap, playH, "play-" .. version, + Strings("Play ") .. gameName, { + fill = cartColor(version), ink = PAL.inverse, font = "stat", + action = function() imp:play(version) end, + }) + imp._gearIcon = imp._gearIcon + or love.graphics.newImage("assets/launcher/gear.png") + iconButton(imp, "manage-" .. version, lx + lw - mgW, ly, playH, + imp._gearIcon, function() imp._gameManage = version end) + ly = ly + playH + gap + end + + -- The ROM card, which now only exists while there is something to report: + -- no ROM, a failed import, an import in flight, or an unsupported game. + local romH = buildRomCard(imp, lx, ly, lw, m, version, mdl, + m.twoCol and remaining or math.floor(remaining * 0.5)) + if romH > 0 then ly = ly + romH + gap end + + -- Save slots. Two columns put them beside the left stack; ONE column + -- stacks them underneath. Either way the card is clipped to the room it + -- actually has, and sizes its own list to that budget. if not locked then local slotY = m.twoCol and cy or ly - local slotAvail = m.twoCol and remaining or (py - gap - ly) + local slotAvail = m.twoCol and remaining or (cy + remaining - ly) if slotAvail > 80 * m.s then Kit.pushClip(rx2, slotY, rw, math.max(0, slotAvail)) - buildSlotCard(imp, rx2, slotY, rw, slotAvail, m, version) + buildSlotCard(imp, rx2, slotY, rw, slotAvail, m, version, ready) Kit.popClip() end end @@ -1457,7 +1553,12 @@ end -- z-ordered hit test, so this ordering IS the z-order. local function modalPanel(m, w, h) - Theme.fill(0, 0, m.W, m.H, PAL.bg, 0.82) + -- A near-opaque scrim, not a tint. At 0.82 the header and the wordmark + -- still read through the settings panel and the screen looked like two + -- layouts fighting rather than one panel on top ("the settings is covering + -- the logo"); at this weight the page behind is present but plainly out of + -- play, which is what a modal is supposed to say. + Theme.fill(0, 0, m.W, m.H, PAL.bg, 0.93) Kit.blockClicks = true local pw = math.floor(math.min(w, m.W - 2 * m.pad)) local ph = math.floor(math.min(h, m.H - 2 * m.pad)) @@ -1930,6 +2031,73 @@ local function buildFindEntryModal(imp, m) action = function() imp._findEntry = nil end }) end +-- Per-game file management, behind the manage button beside Play. A ready +-- game's panel is Play and its saves; everything episodic about the FILES -- +-- swapping the ROM out, finding them on disk -- lives here instead of taking +-- two permanent buttons out of a column that has to fit on a phone. +local function buildGameManageModal(imp, m) + local version = imp._gameManage + local info = GameVersion.info(version) + local ready = imp.ready[version] or false + local mdl = romModel(imp, version, info, ready, info == nil) + local gameName = info and (info.launcherName or info.displayName) + or tostring(version) + local saveDir = love.filesystem.getSaveDirectory + and love.filesystem.getSaveDirectory() or nil + -- The folder link is desktop-only: Android and NX have no browsable path to + -- open, and both already print their own transfer hint on the slot card. + local canOpenFolder = saveDir and not imp.android and not imp.isNX + + local pad = math.floor(18 * m.s) + local w = math.floor(460 * m.s) + local gap = math.floor(8 * m.s) + local bodyW = w - 2 * pad + local detailH = Kit.wrapHeight("small", + mdl.detail or Strings("The ROM for this game is imported and verified."), + bodyW, 3) + local pathH = saveDir + and (Kit.textHeight("micro") + math.floor(8 * m.s)) or 0 + local nBtns = 1 + (canOpenFolder and 1 or 0) + 1 + local h = pad + Kit.textHeight("button") + math.floor(8 * m.s) + detailH + + math.floor(12 * m.s) + pathH + nBtns * (m.btnH + gap) - gap + pad + local px, py, pw = modalPanel(m, w, h) + local cy = py + pad + + Kit.text("button", Kit.ellipsize("button", + Strings("Manage ") .. gameName, pw - 2 * pad), px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(8 * m.s) + cy = cy + Kit.textWrapped("small", + mdl.detail or Strings("The ROM for this game is imported and verified."), + px + pad, cy, pw - 2 * pad, mdl.state and PAL.detail or PAL.green, 3) + cy = cy + math.floor(12 * m.s) + if saveDir then + -- Truncated from the LEFT: the tail of a save path is the part that + -- identifies it. + Kit.text("micro", Kit.ellipsizeLeft("micro", saveDir, pw - 2 * pad), + px + pad, cy, PAL.faint) + cy = cy + Kit.textHeight("micro") + math.floor(8 * m.s) + end + + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "manage-rom", + mdl.label or Strings("Re-import ROM"), { + kind = "accent", font = "small", enabled = mdl.enabled ~= false, + action = (mdl.enabled ~= false) and function() + imp._gameManage = nil + local fn = romAction(imp, version, mdl) + if fn then fn() end + end or nil }) + cy = cy + m.btnH + gap + if canOpenFolder then + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "manage-folder", + Strings("Open folder"), { kind = "accent", font = "small", + action = function() love.system.openURL(imp:fileUrl(saveDir)) end }) + cy = cy + m.btnH + gap + end + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "manage-close", + Strings("Close"), { font = "small", + action = function() imp._gameManage = nil end }) +end + local function buildSettingsModal(imp, m) local model = imp._settings local pad = math.floor(18 * m.s) @@ -1944,10 +2112,12 @@ local function buildSettingsModal(imp, m) Strings("Close"), { font = "small", action = function() imp:_closeSettings() end }) cy = cy + math.max(Kit.textHeight("stat"), m.btnH) + math.floor(6 * m.s) - Kit.text("micro", Strings( + -- WRAPPED, not printed flat: on a portrait panel this line ran straight off + -- the right edge and the sentence ended mid-word at the card border. + cy = cy + Kit.textWrapped("micro", Strings( "Saved to your options file; the game applies these on its next start."), - px + pad, cy, PAL.muted) - cy = cy + Kit.textHeight("micro") + math.floor(10 * m.s) + px + pad, cy, pw - 2 * pad, PAL.muted, 2) + + math.floor(10 * m.s) -- Settings rows are PAGINATED, flattened across sections so a page is a -- uniform run of rows. Section titles ride along as their own entry. @@ -1962,8 +2132,39 @@ local function buildSettingsModal(imp, m) end imp._settingsFlat = flat end + -- The widest label in the whole model decides the row shape (below), so it + -- is measured once per model rather than per row per frame. Measuring the + -- WIDEST rather than each row keeps every row the same height, which is + -- what lets the list paginate off a uniform row. + if not flat.labelW or flat.labelFont ~= Kit.fonts.scale then + local widest = 0 + for _, item in ipairs(flat) do + if item.row then + widest = math.max(widest, Kit.textWidth("small", item.row.label)) + end + end + flat.labelW, flat.labelFont = widest, Kit.fonts.scale + end - local rowH = math.max(Kit.tapMin(), math.floor(36 * m.s)) + local stepW = math.floor(34 * m.s) + local valW = math.floor(140 * m.s) + local inner = pw - 2 * pad - math.floor(24 * m.s) + -- STACKED ROWS. Side by side, a row spends most of its width on the value + -- ladder and leaves the label whatever remains -- on a portrait phone that + -- was three characters and an ellipsis ("TEX...", "BAT...", "BAT..."), so + -- the panel listed a dozen settings none of which could be identified. + -- When the widest label does not fit beside its control, every row puts the + -- label on its own line ABOVE the control instead. All-or-nothing, because + -- a list that switches shape row by row is harder to scan than either form. + local stacked = flat.labelW + > (inner - 2 * stepW - valW - math.floor(24 * m.s)) + local rowH + if stacked then + rowH = Kit.textHeight("small") + math.floor(4 * m.s) + m.btnH + + math.floor(10 * m.s) + else + rowH = math.max(Kit.tapMin(), math.floor(36 * m.s)) + end local gap = math.floor(4 * m.s) local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local listH = (py + ph - pad) - cy - pagerH - math.floor(8 * m.s) @@ -1989,19 +2190,34 @@ local function buildSettingsModal(imp, m) else local row = item.row local key = "set-" .. i - Theme.stroke(px + pad, ry, pw - 2 * pad, rowH, PAL.line, + Theme.strokeRounded(px + pad, ry, pw - 2 * pad, rowH, PAL.line, Theme.A.hairline, 1) local ix = px + pad + math.floor(12 * m.s) - local inner = pw - 2 * pad - math.floor(24 * m.s) - local ly = ry + (rowH - Kit.textHeight("small")) / 2 + -- Where the label prints, and where the control band starts. Stacked: + -- label on its own full-width line, controls on the line below it. + -- Inline: both centred on one line, label left, controls right. + local labelY, ctlY, labelW + if stacked then + labelY = ry + math.floor(6 * m.s) + ctlY = labelY + Kit.textHeight("small") + math.floor(4 * m.s) + labelW = inner + else + labelY = ry + (rowH - Kit.textHeight("small")) / 2 + ctlY = ry + (rowH - m.btnH) / 2 + labelW = nil -- per-shape below: what the controls leave over + end + local rx = ix + inner + if row.editText then local ew = Kit.textWidth("small", Strings("Edit")) + math.floor(20 * m.s) local vw = math.floor(160 * m.s) Kit.text("small", Kit.ellipsize("small", row.label, - inner - ew - vw - math.floor(20 * m.s)), ix, ly, PAL.text) + labelW or (inner - ew - vw - math.floor(20 * m.s))), + ix, labelY, PAL.text) Kit.textRight("small", Kit.ellipsize("small", tostring(row.value()), vw), - ix + inner - ew - math.floor(10 * m.s), ly, PAL.detail) - btn(imp, ix + inner - ew, ry + (rowH - m.btnH) / 2, ew, m.btnH, + rx - ew - math.floor(10 * m.s), + ctlY + (m.btnH - Kit.textHeight("small")) / 2, PAL.detail) + btn(imp, rx - ew, ctlY, ew, m.btnH, key .. "-edit", Strings("Edit"), { kind = "accent", font = "small", action = function() imp._settingsText = { row = row, text = tostring(row.value() or ""), @@ -2009,30 +2225,34 @@ local function buildSettingsModal(imp, m) imp:_armTextInput() end }) elseif row.action then - -- A plain action row (Reset rebinds): the whole right side is one - -- button rather than a value ladder. + -- A plain action row (Reset rebinds, Touch controls): the whole right + -- side is one button rather than a value ladder. local aw = Kit.textWidth("small", row.actionLabel or Strings("Run")) + math.floor(24 * m.s) Kit.text("small", Kit.ellipsize("small", row.label, - inner - aw - math.floor(12 * m.s)), ix, ly, PAL.text) - btn(imp, ix + inner - aw, ry + (rowH - m.btnH) / 2, aw, m.btnH, + labelW or (inner - aw - math.floor(12 * m.s))), ix, labelY, PAL.text) + btn(imp, rx - aw, ctlY, aw, m.btnH, key .. "-act", row.actionLabel or Strings("Run"), { kind = row.danger and "danger" or "ghost", font = "small", action = function() if row.action() ~= false then model.save() end end }) else - local stepW = math.floor(34 * m.s) - local valW = math.floor(140 * m.s) Kit.text("small", Kit.ellipsize("small", row.label, - inner - 2 * stepW - valW - math.floor(24 * m.s)), ix, ly, PAL.text) - local rx = ix + inner - btn(imp, rx - stepW, ry + (rowH - m.btnH) / 2, stepW, m.btnH, + labelW or (inner - 2 * stepW - valW - math.floor(24 * m.s))), + ix, labelY, PAL.text) + -- Stacked rows give the value the whole span between the steppers, + -- which is where the extra width goes now that the label is not + -- competing for it. + local vw = stacked and (inner - 2 * stepW - math.floor(16 * m.s)) + or valW + btn(imp, rx - stepW, ctlY, stepW, m.btnH, key .. "-next", ">", { font = "small", action = function() if row.step and row.step(1) then model.save() end end }) - Kit.textCenter("small", Kit.ellipsize("small", tostring(row.value()), valW), - rx - stepW - valW, ly, valW, PAL.heading) - btn(imp, rx - stepW - valW - stepW, ry + (rowH - m.btnH) / 2, stepW, + Kit.textCenter("small", Kit.ellipsize("small", tostring(row.value()), vw), + rx - stepW - vw, ctlY + (m.btnH - Kit.textHeight("small")) / 2, vw, + PAL.heading) + btn(imp, rx - stepW - vw - stepW, ctlY, stepW, m.btnH, key .. "-prev", "<", { font = "small", action = function() if row.step and row.step(-1) then model.save() end end }) end @@ -2053,7 +2273,7 @@ local function modalUp(imp) or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes or imp._findDetails or imp._modVersions or imp._sortPopup or imp._filterPopup or imp._indexManage or imp._modActions - or imp._findEntry) ~= nil + or imp._findEntry or imp._gameManage) ~= nil end local function buildModals(imp, m) @@ -2130,6 +2350,7 @@ local function buildModals(imp, m) if imp._indexManage then buildIndexesModal(imp, m) return true end if imp._modActions then buildModActionsModal(imp, m) return true end if imp._findEntry then buildFindEntryModal(imp, m) return true end + if imp._gameManage then buildGameManageModal(imp, m) return true end return false end @@ -2200,15 +2421,16 @@ end -- pinned Play block used to walk up over the cards on a short window, which -- is unusable, and the footer simply lives below the fold until scrolled to. local function minPanelHeight(m) - -- One column stacks the actions card, the slot card and the pinned Play - -- block in a single pile, so it needs more room than the side-by-side - -- layout: 460 was tuned for two columns, and on a squat one-column window - -- (a 4:3 device, a phone held upright) it left the slot card clipped - -- inert against the pinned buttons -- Kit's clip bounds hit-testing, so - -- no slot could be picked at all (#852). 660 fits the actions card, one - -- slot row with its pager and New button, and the pinned block; whatever - -- the window cannot show, the page scroll above reaches. - return math.floor((m.twoCol and 460 or 660) * m.s) + -- One column stacks the ROM card and the slot card in a single pile, so it + -- needs more room than the side-by-side layout; two columns only have to + -- fit the taller of the two. Both numbers came DOWN sharply when the + -- pinned Touch-Controls / Reset-rebinds pair moved behind the gear and the + -- save-file buttons moved into the slot card: the pile they used to sit on + -- top of was what forced 460/660 (#852), and a threshold larger than the + -- content pushes the whole page below the fold on windows that could have + -- shown it outright (a 1280x720 desktop was scrolling for 93px of nothing). + -- Whatever a window still cannot show, the page scroll above reaches. + return math.floor((m.twoCol and 340 or 470) * m.s) end function LauncherView.draw(imp) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 0b4570e0..3544c7f4 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -328,7 +328,10 @@ local function commandOutput(command) local pipe = HostShell.popen(command) if not pipe then return nil end local result = pipe:read("*a") - pipe:close() + -- HostShell.pclose, never pipe:close(): closing a pipe outside the spawn + -- lock can free a FILE while a worker thread's popen is walking the stream + -- list, which deadlocks that thread for good (see HostShell). + HostShell.pclose(pipe) result = trim(result) return result ~= "" and result or nil end @@ -1909,6 +1912,12 @@ function RomImporter:update(dt) "Mods are not reviewed - trust the author." }, } end + -- POKEPORT_LAUNCHER_SETTINGS=1 opens the gear panel, the other layout + -- a capture cannot otherwise reach without a click. Pair it with + -- POKEPORT_LAUNCHER_SETTINGS_PAGE to land on a page past the first. + if os.getenv("POKEPORT_LAUNCHER_SETTINGS") == "1" then + self:_openSettings() + end local query = os.getenv("POKEPORT_LAUNCHER_QUERY") if query and query ~= "" then self.findQuery = query @@ -2450,8 +2459,19 @@ end -- ------- settings gear (options.lua + enabled mods' option schemas) function RomImporter:_openSettings() + -- The touch-overlay editor is a host screen, so the model gets it as a + -- hook rather than reaching for main.lua's handler itself. Closing the + -- settings panel FIRST persists the pending edits (_closeSettings saves) + -- and leaves no modal behind the editor to return to. + local hooks = {} + if self.onEditTouchControls then + hooks.editTouchControls = function() + self:_closeSettings() + self.onEditTouchControls() + end + end local ok, model = pcall(function() - return require("src.import.LauncherSettings").open() + return require("src.import.LauncherSettings").open(hooks) end) if ok and model then self._settings = model end end @@ -3334,34 +3354,11 @@ function RomImporter:_pumpFindFetch() self:_clearBusy() end --- Clear every input rebind and the dragged touch-overlay layout, restoring --- the stock keyboard/gamepad bindings. Rebinds are ADDITIVE --- (src/core/Input.lua:applyBindings layers options.bindings over the --- defaults instead of replacing them), so a player who has bound themselves --- into a corner has no in-game way out; this is it. The running game reads --- bindings on its next start, which is the same contract every other --- launcher setting has. -function RomImporter:_resetRebinds() - local ok = pcall(function() - local SaveData = require("src.core.SaveData") - local opts = SaveData.loadOptions() - opts.bindings = nil - if type(opts.touchControls) == "table" then - opts.touchControls.layouts = nil - end - SaveData.saveOptions(opts) - end) - -- Its own notice slot: this button lives on the game panel, and borrowing - -- the mods or save notice would print the result on a tab the user is not - -- looking at. - if ok then - self.controlsNotice = { ok = true, - text = Strings("Controls reset to defaults. Applies on the next start.") } - else - self.controlsNotice = { ok = false, - text = Strings("Could not reset controls.") } - end -end +-- Clearing rebinds used to live here, behind a button on the game panel. It +-- is now the RESET REBINDS row of the settings model +-- (src/import/LauncherSettings.lua), which edits the same options table the +-- rest of that panel does and saves through the same save() -- one control +-- for a setting that was never per-game in the first place. -- ------- busy state (drives the non-dismissable loader overlay) -- Anything that makes the user wait sets this; LauncherView renders it as a @@ -3440,7 +3437,12 @@ function RomImporter:_findThumb(entry) :format(tostring(entry.id):gsub("[^%w%-_]", "_"), ext) local Fetch = require("src.net.Fetch") self._findThumbFetch[entry.id] = { - job = Fetch.download(url, name, { userAgent = "gen1recomp-mod-index" }), + -- A short ceiling on purpose: a page of these is queued at once, and + -- each one's ceiling is part of the worst case for closing the window + -- (Fetch.shutdown). A thumbnail that has not arrived in 15s is not + -- worth holding the process open for -- the card shows its placeholder. + job = Fetch.download(url, name, + { userAgent = "gen1recomp-mod-index", maxSeconds = 15 }), } end return nil diff --git a/src/net/Fetch.lua b/src/net/Fetch.lua index d570146e..370ad1a4 100644 --- a/src/net/Fetch.lua +++ b/src/net/Fetch.lua @@ -29,6 +29,7 @@ local Fetch = {} local CMD = "fetch_cmd" local RESULT = "fetch_result" +local QUIT = "fetch_quit" -- Worker count. Three is enough to overlap the common burst (a mod index -- refresh plus a couple of per-mod release checks) without spawning a thread @@ -36,7 +37,7 @@ local RESULT = "fetch_result" local POOL = 3 local workers = {} -local cmdCh, resCh +local cmdCh, resCh, quitCh local ready -- nil = untried, true = running, false = unavailable local jobs = {} -- id -> { status, body, err, progress, path } local nextId = 0 @@ -50,6 +51,13 @@ local function ensureWorkers() end cmdCh = love.thread.getChannel(CMD) resCh = love.thread.getChannel(RESULT) + quitCh = love.thread.getChannel(QUIT) + -- Channels outlive a pool (they are global to the process, keyed by name), + -- so a pool started after a shutdown -- the save editor opens from a live + -- launcher and hands the screen back -- must clear the previous round's + -- flag and leftovers or its workers quit on their first job. + quitCh:clear() + cmdCh:clear() for i = 1, POOL do local ok, th = pcall(love.thread.newThread, "src/net/fetch_worker.lua") if ok and th and pcall(function() th:start() end) then @@ -114,12 +122,14 @@ local function submit(cmd) end -- GET a URL, returning the body as a string. --- opts: { userAgent, accept } +-- opts: { userAgent, accept, maxSeconds } +-- maxSeconds is the transfer ceiling, and it is also this job's worst-case +-- contribution to how long closing the window takes (see Fetch.shutdown). function Fetch.get(url, opts) opts = opts or {} return submit({ kind = "get", url = url, userAgent = opts.userAgent or "gen1recomp", - accept = opts.accept }) + accept = opts.accept, maxSeconds = opts.maxSeconds }) end -- Download a URL to `saveRel`, a path relative to the LOVE save directory. @@ -129,7 +139,7 @@ function Fetch.download(url, saveRel, opts) return submit({ kind = "download", url = url, dest = saveRel, size = opts.size, userAgent = opts.userAgent or "gen1recomp", - accept = opts.accept }) + accept = opts.accept, maxSeconds = opts.maxSeconds }) end -- Non-blocking status. Returns a table; never nil, even for an unknown id @@ -176,13 +186,28 @@ end -- End every worker. Their command loops sit in Channel:demand(), which never -- returns on its own, and LOVE waits for every live love.thread before the -- process exits (#339). +-- +-- ORDER MATTERS, and getting it wrong is what froze the launcher on close +-- after a visit to the mod tabs. A quit pushed as an ordinary command is +-- just another item in a FIFO the workers are already chewing through: a page +-- of thumbnail downloads sits in front of it, and th:wait() below blocks the +-- main thread until every one of them finishes. So: +-- 1. raise the quit FLAG, which workers check after every demand(), +-- 2. CLEAR the queue -- nobody will read those results, and dropping them +-- is what turns "wait for the backlog" into "wait for what is in flight", +-- 3. push one wake sentinel per worker, because a worker idling inside +-- demand() has nothing to check the flag on until something arrives. +-- What remains is at most one transfer per worker, bounded by the caller's +-- maxSeconds; there is no portable way to interrupt a running curl. function Fetch.shutdown() + if quitCh then quitCh:push(true) end if cmdCh then + cmdCh:clear() for _ = 1, #workers do cmdCh:push({ kind = "quit" }) end end for _, th in ipairs(workers) do pcall(function() th:wait() end) end workers = {} - cmdCh, resCh, ready = nil, nil, false + cmdCh, resCh, quitCh, ready = nil, nil, nil, false end return Fetch diff --git a/src/net/fetch_worker.lua b/src/net/fetch_worker.lua index ad028098..f86d78f1 100644 --- a/src/net/fetch_worker.lua +++ b/src/net/fetch_worker.lua @@ -27,16 +27,27 @@ local HostShell = loadModule("src/core/HostShell.lua") local cmdCh = love.thread.getChannel("fetch_cmd") local resCh = love.thread.getChannel("fetch_result") +-- Raised by Fetch.shutdown BEFORE the wake sentinels go out. A worker checks +-- it after every demand() and drops whatever it just pulled, so a quit does +-- not have to wait its turn behind a queue of jobs nobody will ever read the +-- results of. +local quitCh = love.thread.getChannel("fetch_quit") local saveDir = love.filesystem.getSaveDirectory() -- See the note in doGet: these bound how long a quit can block. A mod index -- or a release list is a small JSON document, and a mod zip is a few MB; the -- old 300s download ceiling was sized for the self-updater's whole payload, --- which does not come through this pool. +-- which does not come through this pool. Callers may pass a shorter one +-- (job.maxSeconds) -- a thumbnail has no business holding the process open +-- for as long as a mod install does. local GET_MAX_SECONDS = 20 local DOWNLOAD_MAX_SECONDS = 90 +local function quitting() + return quitCh:peek() ~= nil +end + local function post(t) resCh:push(t) end local function doGet(job) @@ -48,7 +59,7 @@ local function doGet(job) -- command, and LOVE waits for live threads before exiting (#339), so this -- ceiling is also the worst case for how long closing the window can take. local body, err = HostShell.httpGet(job.url, job.userAgent, job.accept, - GET_MAX_SECONDS) + tonumber(job.maxSeconds) or GET_MAX_SECONDS) if not body then post({ id = job.id, ok = false, err = err or "fetch failed" }) return @@ -74,7 +85,7 @@ local function doDownload(job) love.filesystem.remove(rel) local ok, err = HostShell.httpDownload(job.url, abs, job.userAgent, - job.accept, DOWNLOAD_MAX_SECONDS) + job.accept, tonumber(job.maxSeconds) or DOWNLOAD_MAX_SECONDS) if not ok then post({ id = job.id, ok = false, err = err or "download failed" }) return @@ -90,6 +101,13 @@ end while true do local job = cmdCh:demand() + -- The flag is checked before the job's KIND, so a worker woken by a + -- sentinel abandons whatever real job it happened to pull instead of + -- running it. Without this the quit commands queued behind a page of + -- thumbnail downloads and closing the window blocked for as long as those + -- transfers took -- the launcher froze on close after a visit to the mods + -- tabs, which is exactly what LOVE waiting on live threads looks like. + if quitting() then break end if type(job) == "table" then if job.kind == "quit" then break diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua index a127439e..8c423e2e 100644 --- a/src/ui/kit/Kit.lua +++ b/src/ui/kit/Kit.lua @@ -29,8 +29,10 @@ -- 4. Lists PAGINATE. Row count is bounded by the page size, so a 500-mod -- index costs exactly what a 10-mod one does. There is no virtualised -- scroller and no momentum integrator to run. --- 5. Draw flat. No stencil, no mesh, no blend-mode change, no rounded --- corners (see Theme.lua) -- every one is a pipeline flush. +-- 5. Draw flat. No stencil, no mesh, no canvas, no shader, no blend-mode +-- change -- every one is a pipeline flush. Rounded corners, the emboss +-- and a card's drop shadow are allowed because they only add VERTICES at +-- the same pipeline state (see Theme.lua's header for the full rule). -- -- ACCESSIBILITY / INPUT: every control is reachable four ways -- mouse, -- touch (>= 30px targets), keyboard (spatial focus ring, arrows + Enter), @@ -544,8 +546,9 @@ function Kit.row(x, y, w, h, selected, id) -- The focus ring is a second inset outline, so it reads on both a black -- row and a white selected one. if focused then - Theme.stroke(x + 2, y + 2, w - 4, h - 4, - selected and PAL.inverse or PAL.lineStrong, Theme.A.focus, 1) + Theme.strokeRounded(x + 2, y + 2, w - 4, h - 4, + selected and PAL.inverse or PAL.lineStrong, Theme.A.focus, 1, + Theme.radius()) end local clicked = Kit.press(x, y, w, h) or (id ~= nil and Kit._activateId == id) @@ -557,7 +560,7 @@ end -- hairline says the same thing for one rect.) function Kit.emptyBox(x, y, w, h, message) if not G then return end - Theme.stroke(x, y, w, h, PAL.line, 0.22, 1) + Theme.strokeRounded(x, y, w, h, PAL.line, 0.22, 1, Theme.radius()) Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale), x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted) end @@ -596,11 +599,16 @@ local KINDS = { } Kit.KINDS = KINDS --- opts: { kind, font, enabled, align, id, glow } +-- opts: { kind, font, enabled, align, id, glow, fill, ink } -- id -- opts into the focus ring (give every real control one) -- glow -- a pulsing outline for "something is waiting for you" (the -- update button). No blend-mode change: the alpha of the -- existing outline is animated instead. +-- fill/ink -- override the kind's colours. The ONE caller is the +-- launcher's Play button, which wears its cartridge colour +-- (red/blue/gold) rather than a semantic one: on that screen +-- "which game am I launching" outranks "what kind of verb is +-- this", and the colour is already the tab's identity. -- Returns true when activated, by click OR by the focus ring's Enter/A. function Kit.button(x, y, w, h, label, opts) opts = opts or {} @@ -611,6 +619,9 @@ function Kit.button(x, y, w, h, label, opts) local focused = enabled and opts.id and Kit.focusable(opts.id, x, y, w, h) or false local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"] + if enabled and opts.fill then + kind = { fill = opts.fill, ink = opts.ink or PAL.inverse } + end local hot = enabled and Kit.hover(x, y, w, h) if G then @@ -700,12 +711,13 @@ function Kit.checkbox(x, y, w, h, checked, label, id, labelColor) local box = 20 * Kit.scale local bx, by = x + 12 * Kit.scale, y + (h - box) / 2 if G then + local br = math.min(Theme.radius(), box / 3) if checked then - Theme.fill(bx, by, box, box, PAL.ink, 1) + Theme.fillRounded(bx, by, box, box, PAL.ink, 1, br) Kit.textCenter("small", "X", bx, by + (box - Kit.textHeight("small")) / 2, box, PAL.inverse) else - Theme.stroke(bx, by, box, box, PAL.line, Theme.A.hover, 1) + Theme.strokeRounded(bx, by, box, box, PAL.line, Theme.A.hover, 1, br) end local lx = bx + box + 12 * Kit.scale Kit.text("mono", Kit.ellipsize("mono", label, x + w - lx - 10 * Kit.scale), @@ -724,12 +736,14 @@ function Kit.toggle(x, y, w, h, on, id) -- Track, then a knob inset inside it, so the control reads as a switch -- rather than as a white square with a word next to it. The label sits -- in the empty half, which is the half that says what pressing does. - Theme.stroke(x, y, w, h, PAL.line, - (focused or Kit.hover(x, y, w, h)) and Theme.A.focus or Theme.A.hover, 1) + local r = math.min(Theme.radius(), h / 2) + Theme.fillRounded(x, y, w, h, PAL.rowBg, 1, r) + Theme.strokeRounded(x, y, w, h, PAL.line, + (focused or Kit.hover(x, y, w, h)) and Theme.A.focus or Theme.A.hover, 1, r) local inset = 3 local knob = w / 2 - inset - Theme.fill(on and (x + w / 2) or (x + inset), y + inset, knob, h - 2 * inset, - PAL.ink, 1) + Theme.fillRounded(on and (x + w / 2) or (x + inset), y + inset, knob, + h - 2 * inset, PAL.ink, 1, math.min(r, (h - 2 * inset) / 2)) Kit.textCenter("micro", on and "ON" or "OFF", on and x or (x + w / 2), y + (h - Kit.textHeight("micro")) / 2, w / 2, PAL.text) @@ -768,8 +782,8 @@ function Kit.textfield(id, x, y, w, h, value, placeholder) end end if G then - Theme.fill(x, y, w, h, PAL.bg, 1) - Theme.stroke(x, y, w, h, PAL.line, + Theme.fillRounded(x, y, w, h, PAL.bg, 1) + Theme.strokeRounded(x, y, w, h, PAL.line, (focused or focusRing) and Theme.A.focus or Theme.A.hairline, focused and 2 or 1) local pad = 10 * Kit.scale diff --git a/src/ui/kit/Theme.lua b/src/ui/kit/Theme.lua index 7e9c8f26..426f16da 100644 --- a/src/ui/kit/Theme.lua +++ b/src/ui/kit/Theme.lua @@ -1,7 +1,9 @@ --- High-contrast theme shared by the launcher (src/import/LauncherView.lua) --- and the save editor (tools/save-editor/). This replaces the old navy --- gradient look wholesale: black field, white hairline outlines, flat fills, --- no gradients and no glows anywhere. +-- High-contrast theme for the launcher (src/import/LauncherView.lua). The +-- save editor keeps its own tools/save-editor/Theme.lua, whose primitives +-- take a radius where these take a colour -- do not cross-wire them. This +-- replaces the old navy gradient look wholesale: a near-black field with the +-- faintest red cast, cards a few values above it, white hairline outlines, +-- flat fills, no gradients and no glows anywhere. -- -- That is not only a visual choice. Every effect this theme drops was a GPU -- pipeline flush in the old renderer: @@ -9,10 +11,13 @@ -- (G.stencil / setStencilTest / draw(mesh) = 3 state changes per card), -- * glows set blend mode "add", drew 7 stacked rects, then set it back. -- Flat fills with a 1px outline all share one pipeline state, so LOVE batches --- an entire panel into a couple of draw calls. Controls do carry a small --- corner radius and a two-rect emboss, which cost extra vertices but no state --- change -- that is the tier of expense this theme is willing to pay, and the --- tier above it (stencils, meshes, blend modes) is the one it will not. +-- an entire panel into a couple of draw calls. What this theme DOES pay for +-- is extra vertices at the same pipeline state: rounded corners, the +-- two-rect emboss on a control, and the three stacked rounded rects that make +-- a card's drop shadow (Theme.shadow). Vertices are the tier of expense this +-- theme is willing to pay; the tier above it -- stencils, meshes, blend-mode +-- changes, canvases, shaders -- is the one it will not, and a shadow drawn as +-- a blurred canvas would land squarely in it. -- -- Emphasis is carried by INVERSION, not by colour weight: a selected or -- focused control fills white and prints black. That keeps contrast at @@ -25,10 +30,16 @@ local Theme = {} local PAL = { - -- field + surfaces. Only three fills exist in the whole UI. - bg = { 0, 0, 0 }, -- the page, and every card interior - surface = { 0, 0, 0 }, -- cards/rows: same black, told apart by outline - raised = { 20, 20, 20 }, -- the one non-black fill: hover feedback + -- field + surfaces. The field carries a FAINT red cast (a few points of + -- red over an otherwise neutral near-black) and cards sit a few steps above + -- it in the same hue, so a card reads as a raised object rather than as an + -- outline drawn on the page. These are still flat fills -- the depth comes + -- from the value step plus Theme.shadow, not from a gradient. + field = { 16, 8, 10 }, -- the page BEHIND the cards + bg = { 0, 0, 0 }, -- true black: button rests, field interiors + surface = { 28, 21, 24 }, -- card interiors + rowBg = { 20, 14, 17 }, -- rows inside a card, one step below it + raised = { 44, 34, 38 }, -- hover feedback ink = { 255, 255, 255 }, -- the selected/focused fill -- outlines. Two weights only: a hairline for structure, solid for focus. line = { 255, 255, 255 }, -- hairline, drawn at alpha 0.35 @@ -55,7 +66,6 @@ local PAL = { } -- Semantic aliases kept so ported call sites read the same as before. PAL.cardBorder = PAL.line -PAL.rowBg = PAL.surface PAL.greenInk = PAL.inverse PAL.blueInk = PAL.blue PAL.redSoft = PAL.red @@ -106,11 +116,34 @@ function Theme.fill(x, y, w, h, c, a) G.rectangle("fill", snap(x), snap(y), snap(w), snap(h)) end --- Corner radius for controls. Small and fixed: enough to read as a physical --- key rather than a painted rectangle, small enough that the extra --- tessellation is noise next to the rest of the frame. +-- Corner radius for controls. Fixed rather than scaled: LOVE tessellates a +-- rounded rect by radius, so a scale-driven radius would change the vertex +-- count with the window size, and these are the two tiers the design needs. +-- Controls get the smaller one, containers the larger, so a button never +-- looks like a card and a card never looks like a button. function Theme.radius() - return 4 + return 8 +end + +function Theme.cardRadius() + return 14 +end + +-- DROP SHADOW. Three stacked rounded rects at low alpha, each one step wider +-- and one step lower than the last -- a cheap falloff that needs no blur, no +-- canvas and no blend-mode change, so it stays inside the pipeline budget the +-- rest of this file is written to. Drawn BEFORE the surface it belongs to, +-- and never for a control (only containers cast one, or the whole screen +-- reads as floating debris). +function Theme.shadow(x, y, w, h, r) + if not G or w <= 0 or h <= 0 then return end + r = r or Theme.cardRadius() + for i = 1, 3 do + local spread = i * 2 + col(PAL.bg, 0.13) + G.rectangle("fill", snap(x - spread), snap(y - spread + i * 3), + snap(w + 2 * spread), snap(h + 2 * spread), r + spread, r + spread) + end end function Theme.fillRounded(x, y, w, h, c, a, r) @@ -139,7 +172,10 @@ function Theme.emboss(x, y, w, h, strength) if not G or w <= 2 or h <= 2 then return end strength = strength or 1 local t = math.max(1, math.floor(h * 0.10)) - local r = Theme.radius() + -- The inset must clear the corner arc, but a narrow control (a stepper, a + -- row chip) is thinner than two radii -- clamp or the highlight rect goes + -- negative-width and vanishes. + local r = math.min(Theme.radius(), math.floor(w / 3)) -- highlight along the top col(PAL.ink, 0.28 * strength) G.rectangle("fill", snap(x) + r, snap(y) + 1, snap(w) - 2 * r, t) @@ -166,35 +202,46 @@ function Theme.stroke(x, y, w, h, c, a, lw) if probe("setLineWidth") then G.setLineWidth(1) end end --- The design's only container: black interior, white hairline. `emphasis` --- raises the outline to full white (used for the focused/active card). +-- The design's only container: a rounded surface a few values above the +-- field, its own drop shadow, and a white hairline. `emphasis` raises the +-- outline to full white (used for the focused/active card). function Theme.card(x, y, w, h, emphasis) - Theme.fill(x, y, w, h, PAL.bg, 1) - Theme.stroke(x, y, w, h, PAL.line, emphasis and Theme.A.focus or Theme.A.hairline, 1) + local r = Theme.cardRadius() + Theme.shadow(x, y, w, h, r) + Theme.fillRounded(x, y, w, h, PAL.surface, 1, r) + Theme.strokeRounded(x, y, w, h, PAL.line, + emphasis and Theme.A.focus or Theme.A.hairline, 1, r) end -- A list row. Three states, each one rect plus one outline: --- normal black fill, hairline --- hover near-black fill, brighter hairline +-- normal one value below the card it sits in, hairline +-- hover lifted fill, brighter hairline -- selected WHITE fill (callers print ink = PAL.inverse over it) function Theme.row(x, y, w, h, state) + local r = Theme.radius() if state == "selected" then - Theme.fill(x, y, w, h, PAL.ink, 1) + Theme.fillRounded(x, y, w, h, PAL.ink, 1, r) return PAL.inverse end - Theme.fill(x, y, w, h, state == "hover" and PAL.raised or PAL.surface, 1) - Theme.stroke(x, y, w, h, PAL.line, - state == "hover" and Theme.A.hover or Theme.A.hairline, 1) + Theme.fillRounded(x, y, w, h, + state == "hover" and PAL.raised or PAL.rowBg, 1, r) + Theme.strokeRounded(x, y, w, h, PAL.line, + state == "hover" and Theme.A.hover or Theme.A.hairline, 1, r) return PAL.text end -- A percentage meter (HP, box fill, dex completion, import progress). --- pct is 0-100. Outline + solid white fill, no rounding. +-- pct is 0-100. Outline + solid fill, rounded to the track's own half-height +-- so a thin bar reads as a capsule instead of a clipped rectangle. function Theme.meter(x, y, w, h, pct, c) if not G then return end - Theme.stroke(x, y, w, h, PAL.line, Theme.A.hairline, 1) + local r = math.min(Theme.radius(), h / 2) + Theme.strokeRounded(x, y, w, h, PAL.line, Theme.A.hairline, 1, r) local fill = (w - 2) * clamp((pct or 0) / 100, 0, 1) - if fill > 0 then Theme.fill(x + 1, y + 1, fill, h - 2, c or PAL.ink, 1) end + if fill > 0 then + Theme.fillRounded(x + 1, y + 1, fill, h - 2, c or PAL.ink, 1, + math.min(r, fill / 2)) + end end -- The 4px tri-colour rail across the top of both windows: the only brand @@ -325,11 +372,14 @@ function Theme.ellipsizeLeft(font, text, maxW) return ell end --- The background: a flat black clear. One call, no mesh, no fan, no --- allocation -- the old radial field built a 66-vertex mesh EVERY frame. +-- The background: one flat clear to the faintly red-cast field colour. One +-- call, no mesh, no fan, no allocation -- the old radial field built a +-- 66-vertex mesh EVERY frame. The tint is deliberately small (a handful of +-- points of red at near-black): enough that the cards read as sitting ON +-- something, not enough to compete with the tri-colour rail for brand duty. function Theme.field() if not G then return end - G.clear(0, 0, 0, 1) + G.clear(PAL.field[1] / 255, PAL.field[2] / 255, PAL.field[3] / 255, 1) end -- ------------------------------------------------------------------- fonts diff --git a/src/update/check_worker.lua b/src/update/check_worker.lua index 32388b82..7a0dd1ba 100644 --- a/src/update/check_worker.lua +++ b/src/update/check_worker.lua @@ -80,7 +80,10 @@ local function curlCapture(url) local pipe = HostShell.popen(cmd) if not pipe then return nil end local out = pipe:read("*a") - pipe:close() + -- HostShell.pclose, not pipe:close(): a close outside the spawn lock can + -- free a FILE while another thread's popen walks the stream list, which + -- deadlocks that thread permanently (see HostShell's popen notes). + HostShell.pclose(pipe) if not out or out == "" then return nil end return out end @@ -89,7 +92,7 @@ local function haveCurl() local pipe = HostShell.popen("curl --version") if not pipe then return false end local out = pipe:read("*a") - pipe:close() + HostShell.pclose(pipe) return out ~= nil and out:find("curl", 1, true) ~= nil end diff --git a/tests/engine/host_shell_fetch_errors.lua b/tests/engine/host_shell_fetch_errors.lua new file mode 100644 index 00000000..ad5383fe --- /dev/null +++ b/tests/engine/host_shell_fetch_errors.lua @@ -0,0 +1,134 @@ +-- HostShell's HTTP error reporting. No pokered cite: host transport is +-- port-only plumbing. +-- +-- A user hit the launcher's mod index against a rate-limited GitHub and all +-- they got was one line on the terminal: +-- +-- curl: (56) The requested URL returned error: 403 +-- +-- That is curl talking to its own stderr. It names no URL, so with an index +-- feed, a releases API and a page of thumbnails all in flight there was no way +-- to tell WHICH fetch failed, and the caller upstream got a generic "empty +-- response" that said even less. HostShell now merges curl's stderr into the +-- pipe and asks for the status with --write-out, so every failure names its +-- URL and its HTTP code, and a 403 body ("API rate limit exceeded") reaches +-- the launcher's notice line where a user can act on it. +-- +-- The seam is io.popen: these cases stub it to replay exactly what curl writes +-- for each outcome, which is the only way to pin the parsing without a network +-- and a cooperating server. +-- luajit tests/engine/host_shell_fetch_errors.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +love = love or require("tests.love_stub") + +local HostShell = require("src.core.HostShell") + +-- The marker HostShell asks curl to print before the status code. Spelled +-- here the way it arrives (a real newline), not the way it is passed to curl +-- (a backslash-n escape curl expands itself). +local MARK = "\n__gen1recomp_http__" + +-- Replay `output` as the next popen's whole stdout. `curl --version` is +-- answered separately so HostShell.haveCurl agrees a transport exists. +local realPopen = io.popen +local lastCommand +local function stubPopen(output) + io.popen = function(cmd, mode) + lastCommand = cmd + if cmd:find("--version", 1, true) then + return { read = function() return "curl 8.7.1 (test)" end, + close = function() return true end } + end + return { read = function() return output end, + close = function() return true end } + end +end +local function restorePopen() io.popen = realPopen end + +local URL = "https://api.github.com/repos/example/thing/releases" + +-- ------------------------------------------------------------------- 200 +stubPopen('{"tag_name":"v1.2.3"}' .. MARK .. "200") +local body, err = HostShell.httpGet(URL, "gen1recomp", nil, 10) +check(body == '{"tag_name":"v1.2.3"}', + "a 200 returns the body with the status marker stripped: " .. tostring(body)) +check(err == nil, "a 200 reports no error") +check(lastCommand:find("%-w ") ~= nil, + "the GET asks curl for the status code") +check(lastCommand:find("2>&1", 1, true) ~= nil, + "the GET captures curl's stderr instead of leaking it to the terminal") +check(lastCommand:find(" -f", 1, true) == nil, + "the GET does NOT pass -f: the error body is the diagnosis") + +-- ------------------------------------------------------------------- 403 +-- What GitHub actually sends when the launcher has burned its unauthenticated +-- hourly allowance, with curl's own stderr merged in ahead of it. +stubPopen('{"message":"API rate limit exceeded for 203.0.113.7."}' + .. MARK .. "403") +local body403, err403 = HostShell.httpGet(URL, "gen1recomp", nil, 10) +check(body403 == nil, "a 403 is a failure, not a body") +check(err403:find(URL, 1, true) ~= nil, + "a 403 names the URL that failed: " .. tostring(err403)) +check(err403:find("403", 1, true) ~= nil, "a 403 names the status code") +check(err403:find("rate limit", 1, true) ~= nil, + "a 403 carries the server's own explanation through to the caller") + +-- --------------------------------------------------- no response at all +-- DNS failure: curl writes its complaint and a http_code of 0. Zero is not a +-- status, and reporting "HTTP 0" would bury the only useful line there is. +stubPopen("curl: (6) Could not resolve host: nope.invalid" .. MARK .. "0") +local bodyDns, errDns = HostShell.httpGet("https://nope.invalid/x", "ua", nil, 10) +check(bodyDns == nil, "an unresolvable host is a failure") +check(errDns:find("HTTP 0", 1, true) == nil, + "a no-response failure is not reported as HTTP 0: " .. tostring(errDns)) +check(errDns:find("https://nope.invalid/x", 1, true) ~= nil, + "an unresolvable host still names the URL") +check(errDns:find("Could not resolve", 1, true) ~= nil, + "an unresolvable host reports curl's own reason") + +-- --------------------------------------------- a body containing the marker +-- The status is cut from the LAST marker only, so a payload that happens to +-- contain the token keeps every byte of its content. +local sneaky = "prefix" .. MARK .. "999" .. "suffix" +stubPopen(sneaky .. MARK .. "200") +local bodySneaky = HostShell.httpGet(URL, "gen1recomp", nil, 10) +check(bodySneaky == sneaky, + "only the trailing status marker is stripped: " .. tostring(bodySneaky)) + +-- ------------------------------------------------------------- downloads +-- The download branch keeps -f (no error body is written to the file), but it +-- must still name the URL and the code rather than "download failed". +stubPopen("curl: (56) The requested URL returned error: 403" .. MARK .. "403") +local ok, dlErr = HostShell.httpDownload(URL, "/tmp/gen1recomp-test.bin", + "gen1recomp", nil, 10) +check(ok == nil, "a 403 download fails") +check(dlErr:find(URL, 1, true) ~= nil, + "a failed download names the URL: " .. tostring(dlErr)) +check(dlErr:find("403", 1, true) ~= nil, "a failed download names the code") + +stubPopen(MARK .. "200") +local ok2, dlErr2 = HostShell.httpDownload(URL, "/tmp/gen1recomp-test.bin", + "gen1recomp", nil, 10) +check(ok2 == true, "a 200 download succeeds: " .. tostring(dlErr2)) + +restorePopen() + +-- ---------------------------------------------------------------- pclose +-- Every pipe HostShell hands out must be closed through pclose: a bare +-- pipe:close() from one thread can free a FILE while another thread's popen +-- is walking libc's stream list, and that thread never wakes up again (the +-- launcher freezing on close after a visit to the mod tabs). Nothing here can +-- exercise the race headlessly -- the test stub has no love.thread -- so this +-- pins the entry point's existence and its tolerance of junk. +check(type(HostShell.pclose) == "function", "HostShell exposes pclose") +local closed = false +HostShell.pclose({ close = function() closed = true return true end }) +check(closed, "pclose closes the pipe it is given") +local okNil = pcall(HostShell.pclose, nil) +check(okNil, "pclose on nil is a no-op rather than an error") + +T.finish("host shell fetch errors") diff --git a/tests/engine/launcher_one_column_reach_bug852.lua b/tests/engine/launcher_one_column_reach_bug852.lua index 00e85560..5b56d4e5 100644 --- a/tests/engine/launcher_one_column_reach_bug852.lua +++ b/tests/engine/launcher_one_column_reach_bug852.lua @@ -14,9 +14,18 @@ -- The seam is LauncherView.draw itself: it publishes the page-scroll extent -- on the importer (imp._pageScroll / imp._pageScrollMax, the values the -- touch-drag and wheel paths feed), so a headless draw shows whether the --- scroll engaged without reading any file-local constant. The 480x900 --- window below is the discriminator: its natural panel space satisfies the --- old flat threshold (inert, slot card clipped) but not the one-column one. +-- scroll engaged without reading any file-local constant. +-- +-- What is asserted here is REACHABILITY, not scrolling: "+ New save slot" is +-- the control that sits at the very bottom of the one-column pile, and the +-- bug was that it drew where no tap could land. Either it fits in the +-- window outright, or the page scrolls far enough to bring it in -- both are +-- correct, and which one a given window gets depends on how tall the panel's +-- content happens to be. Asserting "this window scrolls" instead pinned the +-- test to the size of the stack: when the pinned Touch-Controls / +-- Reset-rebinds pair moved behind the gear and the save-file buttons moved +-- into the slot card, 480x900 started fitting outright and a scroll +-- assertion failed on a window that had just got BETTER. -- -- #810 gets its unit-conversion pin in tests/engine/safe_area_units_test.lua; -- here the complementary end-to-end anchor: Layout.metrics must place the @@ -37,6 +46,7 @@ love.graphics.setLineJoin = love.graphics.setLineJoin or function() end love.graphics.newShader = love.graphics.newShader or function() return {} end local Layout = require("src.ui.kit.Layout") +local Kit = require("src.ui.kit.Kit") local RomImporter = require("src.import.RomImporter") local LauncherView = require("src.import.LauncherView") @@ -52,7 +62,43 @@ local function freshLauncher() return RomImporter.new(function() end, { launcher = true }) end --- ------------------------------------------------ #852: the scroll engages +-- Draw a frame with the layout audit on and report where "+ New save slot" +-- landed. Kit records the rect every clickable control occupies plus the +-- clip that bounds its hit test, so this sees exactly what a tap would. +local function newSlotRect(imp) + Kit.audit = {} + LauncherView.draw(imp) + local found + for _, r in ipairs(Kit.audit) do + if r.class == "control" and tostring(r.label):find("New save slot", 1, true) then + found = r + end + end + Kit.audit = nil + return found +end + +-- The control is reachable when its rect, intersected with whatever clip +-- bounds it, still has real area inside the window -- either straight away or +-- after the page is scrolled to the bottom. +local function reachable(imp, H) + local function visible() + local r = newSlotRect(imp) + if not r then return false end + local y1, y2 = r.y, r.y + r.h + if r.clip then + y1 = math.max(y1, r.clip.y) + y2 = math.min(y2, r.clip.y + r.clip.h) + end + y1, y2 = math.max(y1, 0), math.min(y2, H) + return (y2 - y1) > 1 + end + if visible() then return true end + imp._pageScroll = 1e6 -- clamps to the extent inside draw() + return visible() +end + +-- ------------------------------------------------ #852: the bottom is reachable -- 480x900 one column: enough room for the old flat 460*s threshold, not for -- the one-column stack. Before the fix draw() left _pageScrollMax at 0 here -- and the slot card sat clipped inert against the pinned buttons. @@ -61,30 +107,37 @@ local m = Layout.metrics(1200) eq(m.twoCol, false, "480-wide window lays out one column") local imp = freshLauncher() LauncherView.draw(imp) -check((imp._pageScrollMax or 0) > 0, - "one-column window short of the stack engages the page scroll") +check(reachable(imp, 900), + "one-column window can reach the bottom of the slot card") +imp = freshLauncher() +LauncherView.draw(imp) eq(imp._pageScroll, 0, "a fresh page starts at the top") --- The wheel moves the page (the same offset the touch drag feeds), and the --- offset clamps to the extent, so the whole stack down to "+ New save slot" --- and the footer is reachable rather than clipped away. -local extent = imp._pageScrollMax -imp._wheelY = -1 -LauncherView.draw(imp) -eq(imp._pageScroll, math.min(math.floor(48 * m.s), extent), - "one wheel notch scrolls the page down by its step") -imp._pageScroll = 1e6 -LauncherView.draw(imp) -eq(imp._pageScroll, imp._pageScrollMax, - "an offset past the end clamps to the extent, so the bottom is reachable") - --- The reporter's portrait phone (360x780 units) is shorter still and must --- also scroll; before the fix its slot list was unreachable. +-- The reporter's portrait phone (360x780 units) is shorter and narrower, so +-- it is the one that still engages the scroll; before the fix its slot list +-- was unreachable at any offset. window(360, 780) +local pm = Layout.metrics(1200) local phone = freshLauncher() LauncherView.draw(phone) check((phone._pageScrollMax or 0) > 0, "portrait-phone one-column window engages the page scroll") +eq(phone._pageScroll, 0, "a fresh page starts at the top") + +-- The wheel moves the page (the same offset the touch drag feeds), and the +-- offset clamps to the extent, so the whole stack down to "+ New save slot" +-- and the footer is reachable rather than clipped away. +local extent = phone._pageScrollMax +phone._wheelY = -1 +LauncherView.draw(phone) +eq(phone._pageScroll, math.min(math.floor(48 * pm.s), extent), + "one wheel notch scrolls the page down by its step") +phone._pageScroll = 1e6 +LauncherView.draw(phone) +eq(phone._pageScroll, phone._pageScrollMax, + "an offset past the end clamps to the extent, so the bottom is reachable") +check(reachable(phone, 780), + "the scrolled portrait phone reaches the bottom of the slot card") -- A one-column window tall enough for the whole stack stays inert: the -- column-aware minimum is a floor, not a permanent scroll. diff --git a/tests/engine/launcher_panel_reflow.lua b/tests/engine/launcher_panel_reflow.lua new file mode 100644 index 00000000..4e4db52a --- /dev/null +++ b/tests/engine/launcher_panel_reflow.lua @@ -0,0 +1,208 @@ +-- Launcher panel reflow. No pokered cite: the launcher is port-only chrome. +-- +-- Three reports from the same round of testing, all of them the same root +-- cause -- a panel laying out more content than its window could hold, with +-- no scrollbar to rescue what fell off: +-- +-- * "Import failed only appears in that single line" -- the ROM card's +-- detail paragraph is elastic and got trimmed to zero lines whenever the +-- card's height budget was tight, so a failed import printed a headline +-- with no reason under it. The reporter's German ROM was rejected for a +-- specific, printable reason and the launcher swallowed it. +-- * "The settings labels are barely visible at all" -- settings rows put +-- the label and the value ladder side by side, and on a portrait phone +-- the ladder took so much of the width that every label ellipsized to +-- three characters ("TEX...", "BAT...", "BAT..."). +-- * "these buttons don't appear correctly" / Play walking off the bottom -- +-- the game panel pinned Play and a Touch-Controls/Reset-rebinds pair to +-- the bottom of a column whose height was whatever its cards needed, so +-- on a short window the pinned block left the window entirely. +-- +-- The audit sweep at the end is the general form of the third: Kit records +-- every control that could take a click (plus the clip that bounds its hit +-- test) while Kit.audit is set, so a window-size sweep can assert that no two +-- controls overlap and that nothing escapes a window which is not scrolling. +-- A window that IS scrolling legitimately draws below the fold -- reachability +-- there is pinned by tests/engine/launcher_one_column_reach_bug852.lua. +-- luajit tests/engine/launcher_panel_reflow.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end + +local Kit = require("src.ui.kit.Kit") +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +local function window(w, h) + love.graphics.getDimensions = function() return w, h end + love.graphics.getPixelDimensions = function() return w, h end +end + +local function freshLauncher() + return RomImporter.new(function() end, { launcher = true }) +end + +-- Every string the frame printed. The kit falls back to love.graphics.print +-- under the stub (no newText), so recording that call captures the text the +-- panel actually put on screen -- ellipsis and all, which is the point. +local realPrint = love.graphics.print +local function drawAndCapture(imp) + local seen = {} + love.graphics.print = function(str, ...) + seen[#seen + 1] = tostring(str) + return realPrint(str, ...) + end + local ok, err = pcall(LauncherView.draw, imp) + love.graphics.print = realPrint + check(ok, "the frame draws: " .. tostring(err)) + return table.concat(seen, "\n") +end + +-- ------------------------------------- a failed import explains itself +-- setError stores the reason on imp.detail; the ROM card must print it, not +-- just the "Import failed" headline above it. Checked on the reporter's +-- phone shape, since a narrow window is exactly where the old budget +-- arithmetic trimmed the paragraph away. +local REASON = "This is a German ROM; only the English releases are supported." +window(360, 780) +local failed = freshLauncher() +failed:setError(REASON, "red") +failed.tab = "red" +local text = drawAndCapture(failed) +check(text:find("Import failed", 1, true) ~= nil, + "a failed import prints its headline") +check(text:find(REASON, 1, true) ~= nil, + "a failed import prints the REASON it failed, not just the headline") + +-- The same on a desktop window, so the detail is not an artefact of one shape. +window(1280, 720) +local failedWide = freshLauncher() +failedWide:setError(REASON, "red") +failedWide.tab = "red" +check(drawAndCapture(failedWide):find(REASON, 1, true) ~= nil, + "the failure reason survives on a desktop window too") + +-- ------------------------------- settings labels stay readable in portrait +-- Every core row's label must print in FULL. Side by side they could not, +-- so a narrow panel stacks the label on its own line above its control; the +-- assertion is on the text, not on the layout mode, because "the label is +-- readable" is the property that broke. +local function settingsText(w, h) + window(w, h) + local imp = freshLauncher() + imp:_openSettings() + check(imp._settings ~= nil, "the gear opens the settings model") + drawAndCapture(imp) -- first frame paginates + return drawAndCapture(imp) +end + +local LONG_LABELS = { "TEXT SPEED", "BATTLE ANIMATION", "BATTLE STYLE" } +local portrait = settingsText(360, 780) +for _, label in ipairs(LONG_LABELS) do + check(portrait:find(label, 1, true) ~= nil, + ("portrait settings print %q in full"):format(label)) +end +check(portrait:find("BAT...", 1, true) == nil, + "no settings label is clipped to an ellipsis on a portrait phone") + +-- A desktop window has room for the side-by-side shape and must not regress. +local desktop = settingsText(1280, 720) +for _, label in ipairs(LONG_LABELS) do + check(desktop:find(label, 1, true) ~= nil, + ("desktop settings print %q in full"):format(label)) +end + +-- ----------------------------------------------- the layout audit sweep +local function clipped(r) + local x1, y1, x2, y2 = r.x, r.y, r.x + r.w, r.y + r.h + if r.clip then + x1 = math.max(x1, r.clip.x); y1 = math.max(y1, r.clip.y) + x2 = math.min(x2, r.clip.x + r.clip.w); y2 = math.min(y2, r.clip.y + r.clip.h) + end + if x2 - x1 <= 1 or y2 - y1 <= 1 then return nil end + return x1, y1, x2, y2 +end + +local function overlap(a, b) + local ax1, ay1, ax2, ay2 = clipped(a) + if not ax1 then return false end + local bx1, by1, bx2, by2 = clipped(b) + if not bx1 then return false end + return math.min(ax2, bx2) - math.max(ax1, bx1) > 1 + and math.min(ay2, by2) - math.max(ay1, by1) > 1 +end + +-- `scrolling` windows are allowed to draw below the fold: that is the page +-- scroll doing its job, and the reach test covers it. +local function auditFrame(label, W, H, scrolling) + local controls = {} + for _, r in ipairs(Kit.audit or {}) do + if r.class == "control" then controls[#controls + 1] = r end + end + check(#controls > 0, label .. ": the frame dispatched controls at all") + local collisions, escapes = 0, 0 + for i = 1, #controls do + local a = controls[i] + local x1, y1, x2, y2 = clipped(a) + if x1 and not scrolling + and (x1 < -0.5 or y1 < -0.5 or x2 > W + 0.5 or y2 > H + 0.5) then + escapes = escapes + 1 + print((" escape: %s (%.0f,%.0f %.0fx%.0f)") + :format(a.label, a.x, a.y, a.w, a.h)) + end + for j = i + 1, #controls do + if overlap(a, controls[j]) then + collisions = collisions + 1 + print((" overlap: '%s' vs '%s' at (%.0f,%.0f) / (%.0f,%.0f)") + :format(a.label, controls[j].label, a.x, a.y, + controls[j].x, controls[j].y)) + end + end + end + check(collisions == 0, label .. ": no two controls overlap") + check(escapes == 0, label .. ": every control stays inside the window") +end + +-- The shapes the reports came from, plus the desktop ones they have to keep +-- serving: portrait phones, a 150%-scaled Linux handheld, 4:3, and widescreen. +local SIZES = { + { 360, 780 }, { 412, 915 }, { 480, 900 }, { 720, 1280 }, + { 1280, 720 }, { 1024, 768 }, { 900, 700 }, { 1920, 1080 }, +} + +for _, size in ipairs(SIZES) do + local W, H = size[1], size[2] + window(W, H) + for _, tab in ipairs({ "red", "yellow", "mods", "find" }) do + local imp = freshLauncher() + imp.tab = tab + LauncherView.draw(imp) -- warm frame: pagination settles + Kit.audit = {} + local ok, err = pcall(LauncherView.draw, imp) + Kit.audit = ok and Kit.audit or nil + check(ok, ("%dx%d %s draws: %s"):format(W, H, tab, tostring(err))) + if ok then + auditFrame(("%dx%d %s"):format(W, H, tab), W, H, + (imp._pageScrollMax or 0) > 0) + end + Kit.audit = nil + end + -- The settings panel is its own layout and its own reflow. + local imp = freshLauncher() + imp:_openSettings() + LauncherView.draw(imp) + Kit.audit = {} + local ok, err = pcall(LauncherView.draw, imp) + Kit.audit = ok and Kit.audit or nil + check(ok, ("%dx%d settings draws: %s"):format(W, H, tostring(err))) + if ok then auditFrame(("%dx%d settings"):format(W, H), W, H, false) end + Kit.audit = nil +end + +T.finish("launcher panel reflow")