NX Play for Blue failed because mountVersion relied on absolute
PHYSFS_mount first. Prefer love.filesystem.mount of blue|yellow, overlay
generated trees by version prefix, and align CacheFs.prefix in bootGame.
Co-authored-by: Cursor <cursoragent@cursor.com>
PhysFS does not merge archive data/ with save-dir data/generated, so
fused NX Play crashed after import. Prepend-mount generated trees,
fall back to CacheFs.read in Data:load, keep multiline lua-error logs,
and skip Boot.run when network is unvalidated. T24 stays open.
Co-authored-by: Cursor <cursoragent@cursor.com>
SDL labels south as a and east as b; on Switch remap so physical A
confirms and physical B cancels in launcher and NamingScreen.
Co-authored-by: Cursor <cursoragent@cursor.com>
love-nx emits gamepad+raw on one press; NamingScreen saw a+b and always
erased. Skip raw when isGamepad(); align NX Y→a/X→b; prefer A if both.
Co-authored-by: Cursor <cursoragent@cursor.com>
Both are code paths that never run in a green test today but crash or
misbehave the moment a mod or a link failure exercises them.
1. Music.lua: applyVolume built its `music.volume` hook context from the
private `state` table, but was defined *above* `local state = {...}`, so
those reads bound to the nil global `state`. Any mod registering the
music.volume hook crashed with "attempt to index a nil value (global
'state')" the first time a volume was applied. Forward-declare `state`
above applyVolume. Regression test drives a file-backed song through the
hook and asserts the context resolves.
2. Tournament.lua: `local battle, why = isHost and newHost() or newGuest()`
had two defects. The and/or idiom truncates a call to its first result,
so `why` (the specific failure reason) was always dropped and every link
failure showed the generic "Link battle can't start" instead of e.g.
"same mods on both games". Worse, when a host's newHost() returned nil,
the `or` fell through and wrongly called newGuest() as the host. Split
into an explicit if/else so the reason is preserved and each role calls
its own constructor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
Introduce an OPTIONS -> PERFORMANCE setting that scales the port's
optional presentation extras down for weaker hardware, so older/lower-end
devices can run the game smoothly.
The tier governs the three heaviest non-faithful extras -- the 3D TILT,
the GBC FX post-process shader, and survey ZOOM (which renders connected
neighbor maps) -- plus a hard FPS ceiling. It never touches game logic,
which is fixed-step off dt, so every tier plays identically.
- src/core/Performance.lua: tiers (auto/high/balanced/low), a conservative
device auto-detect (ARM handhelds -> low, phones -> balanced, normal
desktops -> high), per-tier caps, and the option-row cycle. Zero
requires, like GameVersion.
- Game:applyOptions clamps the *live* presentation state against the tier
without rewriting stored options, so a lower tier hides the player's
TILT/GBC FX/ZOOM/FPS choices and a higher tier restores them exactly.
- Zoom.offsetRange floors the range at FIT when survey is disallowed, so
the option row, hotkey, and mouse wheel all stop at close-up on LOW.
- New save.options.performance default "auto"; OPTIONS row heads the
display group and re-applies live.
- Tests: tests/engine/performance_tiers.lua (ROM-free); mod_ui_tests row
golden updated for the spliced row.
AUTO resolves to HIGH on a normal desktop and on every options.lua that
predates the option, so the common case is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
Follow-up to #452, from dogfooding the merged build on a real iPhone.
A mod fused into game.love sits in the read-only app bundle: the mod
manager's Delete can't remove it and it reappears every launch, and an
imported newer version can never fully replace it. iOS now packs no
mods, matching every other platform - mods install as .zips at runtime
with a real install/upgrade/delete lifecycle.
With nothing fusing it, mods/pokewalker moves out of this tree to its
canonical home, https://github.com/mresnick67/Gen1ReComp-Pokewalker
(already at 0.2.0 there with move learning and a paged credit dialog;
in-tree 0.1.0 was stale). docs/ios-install.md points at it. The
event:save.loaded coverage-debt entry returns to gate_meta_coverage:
its coverage came from the mod's test suite and leaves with it.
Local reproduction of the ci workflow on this branch: all tiers, mod
lint, fingerprint + meta-coverage gates green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
T4 auto-runs shipped-mod suites ROM-free: the test now uses
T.fixtures.fresh() (FIXMON_A) with the level assertion derived from the
growth curve, and the now-covered event:save.loaded DEBT entry is
removed as gate_meta_coverage instructs. All four CI jobs reproduced
green on a ROM-free checkout.
On a window too short for the stacked single-column layout -- a phone, or a
narrow desktop window -- the ROM / SAVE FILES / Play / SAVE SLOT stack ran
past the bottom of the window while the footer stayed pinned there and drew
over it. Nothing clipped the panel and nothing scrolled it, so the overflow
was unreachable.
Everything under the tab bar (panel, updater banner, footer) is now one
scrolling column, used only when it is taller than the room below the tab
bar. The strip, logo and tab bar stay pinned, so navigation is always on
screen, and the footer is laid out downward from footerTop right after the
content instead of upward from the window bottom.
- RomImporter.pageScrollFor is the whole decision, pure and covered by
tests/engine/launcher_page_scroll.lua. A window that grows back drags the
offset down with it, so the page never stays parked past its own end.
- The panels return their natural height as they draw, so the measurement is
the previous frame's: the same one-frame settle the slot and mod lists
already rely on.
- One scroll axis at a time. While the page scrolls, the panels draw paged:
the slot and mod lists take their natural height, keep no inner scroll
region and report a max of 0, so wheel, right stick and drag all move the
page. Two-column layouts do not overflow, paged stays false, and every one
of these behaves exactly as before.
- inside() and _ptIn() reject a rect that scrolled out of the viewport, so a
control that slid under the tab bar cannot be clicked through it. Tab chips
are pinned and exempt. pageScroll resets on a tab change.
Android had no scroll gesture at all: the launcher is handed no move events
(main.lua forwards neither touchmoved nor mousemoved while it is up) and its
mouse emulation was never trusted, which is what "no reliable pointer
polling" referred to. That was survivable while every scroll region was an
inner list, and useless once the page itself scrolls, since a phone is
exactly where it overflows. love.touch is pollable, so _pointerHold reads the
first active touch there and hands _updateSlotDrag the same (held, y) pair
the mouse gives on desktop. Slot rows and mod toggles consequently arm on
press and commit on release on Android too, matching desktop, so a swipe that
starts on a card scrolls instead of selecting it. All of it is gated on
touchPollable: without love.touch every Android path is exactly what it was.
conf.lua also grows minwidth/minheight (480x360) for the desktop window,
under which the cards stop being readable. Mobile is fullscreen and ignores
it.
Verified on the Android emulator (1080x2400) against a build of the parent
commit: before, the footer painted over the SAVE SLOT card with no way to
reach it; after, the page pans by touch and the footer is reachable and
intact.
love.filesystem looks for "mods/" in two places: the save directory, and
-- portable installs only -- the game folder, which CacheFs mounts. So a
player who unzips a mod next to the executable of an ordinary install,
which is where very nearly every other game would want it, gets no error
and no mod. The panel just comes up empty, with nothing on screen to
suggest the files are sitting in the wrong folder twenty centimetres away.
That is a hard failure to self-diagnose, and it is worse behind a
launcher: the install lives somewhere the player never opens, so "the
game's mods folder" is a guess to begin with.
The mods panel now looks in those folders before its first listing and
copies what it finds into the tree the game really reads, reporting what
it took in the notice line. It happens on open rather than behind a
button because the failure being fixed is one where nothing suggests
there is anything to press.
Looking is scoped: CacheFs.withMounted puts the folder on the read path
at its own mount point, runs the scan, and takes it straight back off.
Nothing a stray folder contains can shadow a game file or change what the
running game resolves, which is what makes it safe to point at a folder
whose contents nobody has validated. Adoption skips ids the game can
already see, so it is idempotent and never nags twice, and it leaves the
loose folder alone -- deleting files outside the save directory on the
player's behalf is not this code's call to make.
Which strays are worth taking is pure (LauncherMods.pickStrays), matching
how deriveList and locateRoot are already split out, so the engine tier
covers the rules without needing love. SaveData.gameFolders is the old
detectPortable candidate list lifted out unchanged -- portable mode is
just the case where one of those folders holds the marker.
Claude-Session: https://claude.ai/code/session_01JvEthuoNBPfxpvHUD9Pd4N
The reporter labels runs by abusing the in-game player name; give slots
a real label instead. SaveData.renameSlot persists a trimmed label in
the options registry (options.saveSlots[version].names) -- never in the
save file, so renaming needs no save rewrite and an empty slot can be
labeled too -- listSlots rows carry it as `label`, and deleteSlot
drops it with the slot.
In the launcher, right-clicking a slot row opens an inline rename modal
(Enter commits, Esc cancels, empty clears; 24 whole-codepoint cap via
local UTF-8 helpers, since plain luajit has no utf8 library). The row
title shows the label over the player name; badges/time/caught stay on
the meta line. Desktop-only: touch has no secondary button. main.lua
now forwards love.textinput to the importer while it is up.
Backend covered by a new renameSlot block in tests/engine/save_slots.lua
(78/78); docs/launcher.md's registry section documents the label.
The Windows pickers shell out to PowerShell, which writes the chosen
path in the console's OEM codepage (CP437 on en-US): a file named
"Pokémon ...zip" came back as Pok\x82mon. The import then failed and
the error notice carrying those bytes hard-crashed the mods panel's
UTF-8-validating text draw.
- All three Windows picker scripts (ROM, mod, save) now force
[Console]::OutputEncoding to UTF-8, so returned paths and any notice
built from them are valid.
- The mod picker also copies the pick to a plain-ASCII temp name and
returns that, so a non-ASCII filename actually imports instead of
failing the io.open (Windows io.open needs ANSI bytes).
Suite discovery, the extension-point catalog scan, mod test-dir pickup,
and the meta-coverage corpus all shelled out to ls/find/test -d, which do
not exist in cmd.exe. Every listing came back empty on Windows, so tiers
ran 0 suites and still reported ALL TESTS PASSED.
Add portable probes to tests/fs_io.lua (same Unix commands on
Linux/macOS; dir /b and a shell-free rename-self existence check on
Windows) and rewire the four call sites to them. 15/15 engine suites and
2/2 modkit suites now genuinely run and pass on Windows.
Fixes#266
Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>