Merge dev and fix headless ROM builder dependency

This commit is contained in:
Martin
2026-08-06 16:22:34 +02:00
18 changed files with 1737 additions and 1122 deletions
+3
View File
@@ -383,6 +383,9 @@ jobs:
- name: install luajit
run: sudo apt-get update && sudo apt-get install -y luajit
- name: install Pillow
run: python3 -m pip install --upgrade pillow
- name: interpreter version
run: luajit -v
+26 -636
View File
@@ -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 `<PK>`-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 <path>` 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/<NAME>.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=<id or
number>` 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`.
+6 -5
View File
@@ -4,9 +4,10 @@
-- extracted cache lives, and the save-file suffix -- so the importer,
-- cache mount, SaveData, title screen and palette all agree.
--
-- Red keeps every un-suffixed path it always used (save.lua, the root cache),
-- so existing installs are untouched; Blue is namespaced under blue/ and
-- _blue, Yellow under yellow/ and _yellow, so all three can be imported and
-- Red keeps the un-suffixed save paths it always used (save.lua) so existing
-- saves are untouched, but its extracted cache lives under red/ like Blue and
-- Yellow (issue #899); a legacy root cache is moved into red/ once by
-- CacheFs.migrateLegacyRedCache. All three versions can be imported and
-- played side by side.
--
-- Zero requires, so it loads during love.conf and under plain Lua for tools
@@ -23,7 +24,7 @@ GameVersion.VERSIONS = {
launcherName = "Red", -- game-panel header in the launcher
sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
manifest = "tools/rom_manifest.json",
cachePrefix = "", -- Red owns the cache root (backwards compatible)
cachePrefix = "red/", -- red/data/generated, red/assets/generated (#899)
saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp
},
blue = {
@@ -40,7 +41,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
+154 -15
View File
@@ -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 <ip>", 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"
+128 -22
View File
@@ -33,11 +33,11 @@ local Platform = require("src.core.Platform")
local SEP = package.config:sub(1, 1)
-- Cache-relative paths are prefixed with this before every read/write, so a
-- Blue/Yellow import lands under its GameVersion.cachePrefix (blue/, yellow/)
-- while a Red import keeps the historical root. The launcher sets it per
-- import / per readiness check; it stays "" for Red. Runtime *reads*
-- (require / newImage) do NOT go through here -- CacheFs.mountVersion overlays
-- the active version's subtree onto the un-prefixed paths instead.
-- version's import lands under its GameVersion.cachePrefix (red/, blue/,
-- yellow/). The launcher sets it per import / per readiness check; it stays
-- "" outside those flows. Runtime *reads* (require / newImage) do NOT go
-- through here -- CacheFs.mountVersion overlays the active version's subtree
-- onto the un-prefixed paths instead.
CacheFs.prefix = ""
local function withPrefix(rel)
@@ -383,17 +383,120 @@ function CacheFs.removeTree(rel)
walk(rel)
end
-- One-time move of Red's pre-#899 cache (data/generated, assets/generated
-- and the rom-cache.complete marker at the cache root) into red/, the
-- layout Blue and Yellow always used. Idempotent: an existing red/ cache
-- wins and a missing root marker means nothing to do.
--
-- The two cache homes are handled separately: the save directory goes
-- through love.filesystem so every host (NX included) and the headless test
-- stub take the same path, and the portable game folder goes through
-- os.rename on real paths -- skipped for a source run, where the game
-- folder IS the checkout and its data/generated is Red's source data, not
-- a cache. Called from RomImporter.new (before the readiness loop) and
-- from mountVersion, so no boot path can probe red/ before the move ran.
function CacheFs.migrateLegacyRedCache()
if not (love and love.filesystem and love.filesystem.getInfo) then return end
local fs = love.filesystem
local function hasFile(p) return fs.getInfo(p, "file") ~= nil end
local function hasDir(p) return fs.getInfo(p, "directory") ~= nil end
local function moveFile(src, dst)
local data = fs.read(src)
if data then
local parent = dst:match("^(.*)/[^/]+$")
if parent and fs.createDirectory then fs.createDirectory(parent) end
fs.write(dst, data)
end
fs.remove(src)
end
local function moveTree(src, dst)
for _, child in ipairs(fs.getDirectoryItems(src) or {}) do
local sp, dp = src .. "/" .. child, dst .. "/" .. child
if hasDir(sp) then moveTree(sp, dp) else moveFile(sp, dp) end
end
-- remove only takes an empty directory; a non-empty one simply stays
fs.remove(src)
end
-- --- save directory
if hasDir("red/data/generated") or hasFile("red/rom-cache.complete") then
-- already on the new layout
elseif hasFile("rom-cache.complete") then
-- The marker must be a save-dir file before anything moves: a developer
-- checkout also resolves data/generated at the root, but from the physfs
-- SOURCE, and moving that tree would gut the repository.
local real = fs.getRealDirectory and fs.getRealDirectory("rom-cache.complete")
if not real or (fs.getSaveDirectory and real == fs.getSaveDirectory()) then
-- cheap path first: renames inside the same directory; the copy below
-- covers whatever rename could not take (or hosts where the save dir
-- is not a plain os path, like the headless stub)
local saveDir = fs.getSaveDirectory and fs.getSaveDirectory()
if saveDir and fs.createDirectory then
fs.createDirectory("red/data")
fs.createDirectory("red/assets")
os.rename(saveDir .. SEP .. "data" .. SEP .. "generated",
saveDir .. SEP .. "red" .. SEP .. "data" .. SEP .. "generated")
os.rename(saveDir .. SEP .. "assets" .. SEP .. "generated",
saveDir .. SEP .. "red" .. SEP .. "assets" .. SEP .. "generated")
os.rename(saveDir .. SEP .. "rom-cache.complete",
saveDir .. SEP .. "red" .. SEP .. "rom-cache.complete")
end
if hasDir("data/generated") then
moveTree("data/generated", "red/data/generated")
end
if hasDir("assets/generated") then
moveTree("assets/generated", "red/assets/generated")
end
if hasFile("rom-cache.complete") then
moveFile("rom-cache.complete", "red/rom-cache.complete")
end
-- drop the emptied roots; a non-empty one (e.g. mods/ beside them is
-- untouched -- only data and assets are cache subtrees) simply stays
fs.remove("data")
fs.remove("assets")
end
end
-- --- portable game folder (desktop only): rename on real paths
local root = CacheFs.root()
if root and not (fs.getSource and root == fs.getSource()) then
local function rootHas(rel)
local f = io.open(realPath(root, rel), "rb")
if f then f:close() return true end
return false
end
if rootHas("rom-cache.complete") and not rootHas("red/rom-cache.complete") then
local mkdir = resolveMkdir()
if mkdir then
mkdir(realPath(root, "red"))
mkdir(realPath(root, "red/data"))
mkdir(realPath(root, "red/assets"))
os.rename(realPath(root, "data/generated"),
realPath(root, "red/data/generated"))
os.rename(realPath(root, "assets/generated"),
realPath(root, "red/assets/generated"))
os.rename(realPath(root, "rom-cache.complete"),
realPath(root, "red/rom-cache.complete"))
end
end
end
end
-- Overlay the active version's extracted cache onto the un-prefixed read
-- paths, so require("data.generated.*") and love.graphics.newImage(
-- "assets/generated/*") resolve to that version's files.
--
-- Non-Red versions live under blue/ / yellow/ in the save directory. On
-- desktop fused+portable we PHYSFS_mount that folder by absolute path. On
-- NX (and any host without a working FFI mount) love.filesystem.mount of
-- the save-dir-relative name must succeed, or Play boots with Red's paths
-- and Data:load dies. Always also prepend-mount the version's
-- data/generated + assets/generated onto the un-prefixed paths so PhysFS
-- directory non-merge (archive data/ vs save generated) cannot hide them.
-- Each version lives under its cachePrefix folder in the save directory.
-- On desktop fused+portable we PHYSFS_mount that folder by absolute path.
-- On NX (and any host without a working FFI mount) love.filesystem.mount
-- of the save-dir-relative name must succeed, or Play boots with another
-- version's paths and Data:load dies. Always also prepend-mount the
-- version's data/generated + assets/generated onto the un-prefixed paths
-- so PhysFS directory non-merge (archive data/ vs save generated) cannot
-- hide them.
local function mountGeneratedTrees(prefix)
prefix = prefix or ""
if not (love and love.filesystem and love.filesystem.mount) then
@@ -416,10 +519,13 @@ local function mountGeneratedTrees(prefix)
end
function CacheFs.mountVersion(version)
-- A legacy root Red cache has to move into red/ before anything probes
-- red/ paths (idempotent and near-free once migrated, issue #899).
if version == "red" then CacheFs.migrateLegacyRedCache() end
local prefix = require("src.core.GameVersion").cachePrefix(version)
local sub = prefix:gsub("/+$", "")
-- Save-dir relative mount first (NX / no-FFI). Prepend so blue|yellow win.
-- Save-dir relative mount first (NX / no-FFI). Prepend so the version wins.
if sub ~= "" and love.filesystem.mount
and love.filesystem.getInfo(sub, "directory") then
love.filesystem.mount(sub, "", false)
@@ -436,21 +542,21 @@ function CacheFs.mountVersion(version)
end
end
-- Version-scoped generated trees → un-prefixed paths (Red prefix is "").
-- Version-scoped generated trees → un-prefixed paths.
mountGeneratedTrees(prefix)
return true
end
-- Undo mountVersion. A process normally mounts exactly one version and then
-- boots it, but the launcher can open the save editor on a Blue/Yellow save,
-- close it, and press Play on Red: with that version's subtree still
-- prepended, Red's require("data.generated.*") and its generated art would
-- silently resolve to the other game's files. Callers must also drop the
-- generated modules from package.loaded (src.core.Data:unloadGenerated) --
-- unmounting alone only fixes the read path, not what require already cached.
-- boots it, but the launcher can open the save editor on one game's save,
-- close it, and press Play on another: with the first version's subtree
-- still prepended, the other's require("data.generated.*") and generated
-- art would silently resolve to the first game's files. Callers must also
-- drop the generated modules from package.loaded
-- (src.core.Data:unloadGenerated) -- unmounting alone only fixes the read
-- path, not what require already cached.
--
-- Returns true when nothing was mounted or the unmount took. Red is a no-op
-- because its cache lives at the root and was never overlaid.
-- Returns true when nothing was mounted or the unmount took.
function CacheFs.unmountVersion(version)
local prefix = require("src.core.GameVersion").cachePrefix(version)
if prefix == "" then return true end
+25 -3
View File
@@ -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)
File diff suppressed because it is too large Load Diff
+54 -41
View File
@@ -32,7 +32,7 @@ end
-- carry Red's bank $1f header, wave-table, and CryData offsets.
local CACHE_FORMAT = "rom-cache-v9:"
-- The completion marker is written under each version's cache prefix
-- (rom-cache.complete for Red, blue/rom-cache.complete for Blue).
-- (red/rom-cache.complete, blue/rom-cache.complete, ...).
local MARKER_PATH = "rom-cache.complete"
-- The marker a finished import writes for a version: the generation tag plus
@@ -135,8 +135,8 @@ local PAL = {
-- CacheFs.exists checks the game folder directly for a portable install,
-- otherwise the save directory through love.filesystem. It honors
-- CacheFs.prefix, so we point it at the version's cache subtree (Red at the
-- root, Blue under blue/).
-- CacheFs.prefix, so we point it at the version's cache subtree (red/,
-- blue/, yellow/).
local function allRequiredFilesExist(version)
local CacheFs = require("src.import.CacheFs")
local saved = CacheFs.prefix
@@ -154,13 +154,19 @@ end
-- A developer checkout / Python build leaves generated data in the physfs
-- source: Red at the historical root, Blue/Yellow in their versioned trees.
-- Source data is produced from the current manifest, so it needs no runtime
-- import marker; still verify the whole version-specific required-file set.
-- Imported Red caches still live under red/. Check source paths directly so
-- that cache prefix cannot hide Red's source tree, and keep save-dir caches
-- from counting as current source data.
local function sourceTreeHasData(version)
if not allRequiredFilesExist(version) or not love.filesystem.getRealDirectory then
return false
if not love.filesystem.getRealDirectory then return false end
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
for _, path in ipairs(REQUIRED_FILES) do
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
end
local path = GameVersion.cachePrefix(version) .. REQUIRED_FILES[1]
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
end
local path = prefix .. REQUIRED_FILES[1]
local real = love.filesystem.getRealDirectory(path)
return real == love.filesystem.getSource()
end
@@ -216,8 +222,8 @@ local function purgeSaveDirCache()
f:close()
return true
end
-- Purge each version's stale save-directory copy (Red at the root, Blue
-- under blue/) so it cannot shadow the portable game-folder cache.
-- Purge each version's stale save-directory copy (under its red/ / blue/
-- / yellow/ prefix) so it cannot shadow the portable game-folder cache.
for _, version in ipairs(GameVersion.ORDER) do
local prefix = GameVersion.cachePrefix(version)
if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then
@@ -328,7 +334,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
@@ -1121,6 +1130,11 @@ function RomImporter.new(onComplete, opts)
_padInited = false,
}, RomImporter)
-- Pre-#899 installs keep Red's extracted cache at the save-dir root; move
-- it under red/ before the readiness loop looks for red/ paths, or every
-- such install would read as "never imported" and demand the ROM again.
CacheFs.migrateLegacyRedCache()
for _, version in ipairs(GameVersion.ORDER) do
local info = GameVersion.info(version)
local ready = RomImporter.isReady(version) and not self.forceImport
@@ -1909,6 +1923,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 +2470,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 +3365,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 +3448,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
+30 -5
View File
@@ -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
+21 -3
View File
@@ -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
+28 -14
View File
@@ -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
+84 -34
View File
@@ -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
+5 -2
View File
@@ -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
@@ -0,0 +1,69 @@
-- Issue #899: Red's extracted cache lives under red/ like blue/ and
-- yellow/, and a legacy root cache (pre-fix installs) is migrated on first
-- boot instead of reading as "never imported".
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local check = T.check
local eq = T.eq
local CacheFs = require("src.import.CacheFs")
local GameVersion = require("src.core.GameVersion")
eq(GameVersion.cachePrefix("red"), "red/",
"Red's cache is namespaced under red/")
-- legacy layout: the marker and both generated trees at the save-dir root
love.filesystem.write("rom-cache.complete", "rom-cache-v9:abc")
love.filesystem.write("data/generated/maps.lua", "return {}")
love.filesystem.write("data/generated/constants.lua", "return {}")
love.filesystem.write("assets/generated/fonts/font.png", "font-bytes")
CacheFs.migrateLegacyRedCache()
eq(love.filesystem.read("red/rom-cache.complete"), "rom-cache-v9:abc",
"the marker moved under red/")
eq(love.filesystem.read("red/data/generated/maps.lua"), "return {}",
"the data tree moved under red/")
eq(love.filesystem.read("red/assets/generated/fonts/font.png"), "font-bytes",
"the assets tree moved under red/")
check(love.filesystem.read("rom-cache.complete") == nil,
"the root marker is gone")
check(love.filesystem.read("data/generated/maps.lua") == nil,
"the root data tree is gone")
check(love.filesystem.read("assets/generated/fonts/font.png") == nil,
"the root assets tree is gone")
-- idempotent: a second run leaves the migrated tree alone
CacheFs.migrateLegacyRedCache()
eq(love.filesystem.read("red/rom-cache.complete"), "rom-cache-v9:abc",
"a second run keeps the migrated cache")
-- an existing red/ cache wins over a legacy root leftover: no clobber
love.filesystem.write("rom-cache.complete", "rom-cache-v9:STALE")
CacheFs.migrateLegacyRedCache()
eq(love.filesystem.read("red/rom-cache.complete"), "rom-cache-v9:abc",
"an existing red/ cache is not clobbered")
check(love.filesystem.read("rom-cache.complete") ~= nil,
"the unmigrated leftover stays (a stale-marker re-import handles it)")
love.filesystem.remove("rom-cache.complete")
-- mountVersion("red") overlays red/ at the un-prefixed paths, like blue/
love.filesystem._mounts = {}
check(CacheFs.mountVersion("red") == true, "mountVersion(red) returns true")
eq(love.filesystem.read("assets/generated/fonts/font.png"), "font-bytes",
"post-mount probe reads red assets at the un-prefixed path")
eq(love.filesystem.read("data/generated/constants.lua"), "return {}",
"post-mount probe reads red data at the un-prefixed path")
-- no legacy cache at all: migration is a no-op, not an error
love.filesystem.remove("red/rom-cache.complete")
love.filesystem.remove("red/data/generated/maps.lua")
love.filesystem.remove("red/data/generated/constants.lua")
love.filesystem.remove("red/assets/generated/fonts/font.png")
CacheFs.migrateLegacyRedCache()
check(love.filesystem.read("red/rom-cache.complete") == nil,
"nothing to migrate invents nothing")
T.finish()
+145
View File
@@ -0,0 +1,145 @@
-- The battle hit sounds must carry pokered's wFrequencyModifier onto the
-- noise channel (#826; #902 reports the same swap). PlayApplyingAttackSound
-- (engine/battle/animations.asm) picks SFX_DAMAGE / SFX_SUPER_EFFECTIVE /
-- SFX_NOT_VERY_EFFECTIVE off wDamageMultipliers and writes a frequency
-- modifier with it ($20 / $e0 / $50), and Audio2_ApplyFrequencyModifier adds
-- that to the polynomial-counter byte -- the low byte of NR43 -- with 8-bit
-- wrap (audio/engine_2.asm). The three programs are CHAN8-only, so that byte
-- IS their pitch. Dropped, the super effective hit reads as the duller of
-- the two: super effective's tail (shifts 3 then 6) sits below not very
-- effective's (5, 4, 2, 2), which is exactly the "swapped" sound #826 and
-- #902 describe. With the modifier on, super effective goes to shifts 1/4 --
-- a bright crack -- and not very effective to 10/9/7/7 -- a dull thud.
--
-- ROM-free: ChipAsm blobs stand in for the sfx headers, so nothing here
-- reads data/generated/. The noise-note streams below are transcribed from
-- audio/sfx/{damage,super_effective,not_very_effective}.asm, so the "sounds
-- swapped when bare" ordering is asserted against the real program shape.
-- luajit tests/engine/hit_sfx_noise_pitch_bug826.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
love = require("tests.love_stub")
local ChipAsm = require("src.audio.ChipAsm")
local ChipSynth = require("src.core.ChipSynth")
local data = { audio = {} }
-- noise_note len, volume, fade, parameter (the asm streams, verbatim)
local HITS = {
{
name = "Damage",
pitch = 0x20,
notes = {
{ len = 2, parameter = 0x44 },
{ len = 2, parameter = 0x14 },
{ len = 15, parameter = 0x32 },
},
want = { 0x64, 0x34, 0x52 },
},
{
name = "Super_Effective",
pitch = 0xe0,
notes = {
{ len = 4, parameter = 0x34 },
{ len = 15, parameter = 0x64 },
},
want = { 0x14, 0x44 },
},
{
name = "Not_Very_Effective",
pitch = 0x50,
notes = {
{ len = 4, parameter = 0x55 },
{ len = 2, parameter = 0x44 },
{ len = 8, parameter = 0x22 },
{ len = 15, parameter = 0x21 },
},
want = { 0xa5, 0x94, 0x72, 0x71 },
},
}
local function hitDef(notes)
local program = {}
for _, n in ipairs(notes) do
program[#program + 1] = {
noiseNote = { len = n.len, volume = 15, fade = 1, parameter = n.parameter },
}
end
return ChipAsm.sfx{ channels = { { hw = 4, program = program } } }
end
-- every noise note the program emits, as { parameter, duration }, walking
-- one event at a time by marking each consumed
local function noiseNotes(def, offset)
local engine = ChipSynth.newEngine(data, def, {
sfx = true, allowLoops = false, frequencyOffset = offset,
})
local channel = assert(engine.channels[1], "hit sfx uses exactly CHAN8")
local out = {}
while not engine:finished() do
channel:sample()
local event = channel.event
if not event then break end
if event.noiseParameter ~= nil then
out[#out + 1] = { parameter = event.noiseParameter, duration = event.duration }
end
event.sample = event.samples -- force the walk on to the next event
end
return out
end
-- NR43 shift-clock nibble, weighted by each note's on-air duration: the
-- number #826/#902 ears actually compare (higher = duller)
local function weightedShift(notes)
local total, sum = 0, 0
for _, n in ipairs(notes) do
total = total + n.duration
sum = sum + math.floor(n.parameter / 16) * n.duration
end
return total > 0 and sum / total or 0
end
local results = {}
for _, hit in ipairs(HITS) do
local def = hitDef(hit.notes)
local bare = noiseNotes(def, 0)
local pitched = noiseNotes(def, hit.pitch)
results[hit.name] = { bare = bare, pitched = pitched }
check(#bare == #hit.notes,
hit.name .. " program emits " .. #hit.notes .. " notes, not " .. #bare)
for i, n in ipairs(hit.notes) do
check(bare[i] and bare[i].parameter == n.parameter,
("%s note %d reads NR43 $%02x unmodified"):format(hit.name, i, n.parameter))
check(pitched[i] and pitched[i].parameter == hit.want[i],
("%s note %d reads NR43 $%02x once $%02x is applied"):format(
hit.name, i, hit.want[i], hit.pitch))
end
end
-- the ordering that IS the bug: unpitched, super effective ends duller than
-- not very effective, so they sound swapped; pitched, the bright crack lands
-- on super effective and the dull thud on not very effective
local superBare = weightedShift(results.Super_Effective.bare)
local nveBare = weightedShift(results.Not_Very_Effective.bare)
check(superBare > nveBare,
("bare, super effective (%.2f) reads duller than not very effective (%.2f)"):format(
superBare, nveBare))
local superPitched = weightedShift(results.Super_Effective.pitched)
local nvePitched = weightedShift(results.Not_Very_Effective.pitched)
check(superPitched < nvePitched,
("pitched, super effective (%.2f) is the brighter hit, not very effective (%.2f) the duller"):format(
superPitched, nvePitched))
-- the neutral hit shifts a shade duller than it used to be, per pokered
local damageBare = weightedShift(results.Damage.bare)
local damagePitched = weightedShift(results.Damage.pitched)
check(damagePitched > damageBare,
("the neutral hit dulls a little under $20 (%.2f -> %.2f)"):format(
damageBare, damagePitched))
T.finish("hit sfx noise pitch (#826/#902)")
+134
View File
@@ -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")
@@ -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.
+208
View File
@@ -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")