mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
Merge dev and fix headless ROM builder dependency
This commit is contained in:
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+543
-321
File diff suppressed because it is too large
Load Diff
+54
-41
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)")
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user