Compare commits

...

183 Commits

Author SHA1 Message Date
bryanthaboi f74e21782b Merge pull request #798 from bryanthaboi/dev
perhaps a massive PR
2026-08-04 10:35:29 -04:00
bryanthaboi 8fbe819493 bingus dingus 2026-08-04 10:32:51 -04:00
Shane McGovern 626080d228 Show mod downloads and release dates in the launcher MODS and Find Mods panels (#793)
* Resolve Find Mods stats from each mod's GitHub repo when the feed lacks them

A FIND MODS row now shows download/date stats even when its feed publishes
none: the row fetches the mod's own GitHub releases through the same
cached ModUpdate.fetchReleases the MODS tab uses (six-hour options cache,
so an installed mod's repo is instant). Feed-published stats still win
when present; otherwise one repo is fetched per frame -- the thumbnail
budget pattern -- so opening the tab never stalls for the whole listing.
ModUpdate.statsForReleases is the shared resolver.

* Fix crash opening the Find Mods tab: rename the stats cache field

The resolver stored results in self._findStats, which collides with the
method of the same name: self._findStats resolves through the metatable to
the function, so the or {} guard never fired and indexing it crashed the
launcher the moment the panel built. State now lives in _findStatsCache.

* Fix Find Mods crash: require ModUpdate in the find panel

buildFindPanel called ModUpdate.statsLine without a local require --
only buildModsPanel had one -- so opening the tab indexed a nil global.

* Retry Find Mods stats after failed repo fetches

A failed repo fetch (hourly GitHub API rate limit, transient network error)
was memoized as resolved, so a rate-limited first visit left those rows
empty for the whole session. Failures now schedule a 60s retry; a 404 is
still permanent so a renamed or vanished repo is fetched once.

* Add the MODS tab sort options to the Find Mods tab
2026-08-04 10:16:44 -04:00
bryanthaboi f56e82de81 translate the launcher: load mod string catalogs before it draws (#794)
the launcher runs before Game:load, so #767's Strings hooks had nothing
filling the catalog and a restart could not help - the ordering is the same
every launch. reads lang/strings.lua from enabled mods only, data not the
entry chunk, sandboxed.

plus a plain pixel fallback on the ui faces, or the kana draw as tofu.
2026-08-04 10:15:10 -04:00
bryanthaboi 7d3950658a launcher: route the last raw literals through strings (#791)
finishes what #767 started (thanks @hernan0078). most of that pr landed
already when the launcher panels moved to LauncherView; these six sites were
what was left, and they sit next to siblings that already call Strings.

also pins the delete chip to the wider of Delete/Sure? instead of to Delete.
english happens to have the longer word first, a translation need not.
2026-08-04 09:50:51 -04:00
bryanthaboi 3c1246ee06 Merge pull request #787 from luisgonzaleznf/fix/love12-scissor-bias
Fix LÖVE 12 anchored UI scissor seam
2026-08-04 09:45:17 -04:00
bryanthaboi d69965e3b1 Merge pull request #790 from bryanthaboi/ttf-box-color
fix ttf text erased after a box, and type names outside battle
2026-08-04 09:44:46 -04:00
bryanthaboi 30edac03a6 fix ttf text erased after a box, and type names outside battle
drawBox left the color white, which tiles ignore but ttf text does not, so
every label after a box drew white on white.

typechart only loaded on entering a battle, so the summary screen got raw
type ids back instead of display names.
2026-08-04 09:43:01 -04:00
bryanthaboi dd4aface93 test fixes 2026-08-04 09:41:44 -04:00
bryanthaboi b1350fdecd Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-04 09:25:30 -04:00
bryanthaboi f2d9b74490 CLOSES #644, CLOSES #703, CLOSES #726, CLOSES #737, CLOSES #750, CLOSES #752, CLOSES #764, CLOSES #765, CLOSES #768, CLOSES #773, CLOSES #774, CLOSES #775, CLOSES #777, CLOSES #780, CLOSES #782 2026-08-04 09:25:28 -04:00
luisgonzaleznf 84d09d0894 Fix LÖVE 12 scissor seam 2026-08-04 15:20:06 +02:00
bryanthaboi e61f231316 Merge pull request #789 from bryanthaboi/ttf-tiles
add ttf tiles option
2026-08-04 09:14:49 -04:00
bryanthaboi 33998aab5d add ttf tiles option
lets a translation keep chosen characters on the rom tiles instead of the ttf.
needed for japanese: sizing the font for kana makes latin narrower, which
knocks the party menu numbers out of line.
2026-08-04 09:11:05 -04:00
bryanthaboi 9ce7379946 Merge pull request #786 from ShaneMcGovernIE/find-mods-release-stats
Show feed-published release stats on Find Mods rows
2026-08-04 08:00:46 -04:00
Shane McGovern b692474e24 Show feed-published release stats on Find Mods rows
A mod index can now publish three optional per-entry fields -- downloads
(total across every release), first_release and last_release (ISO days) --
which the FIND MODS listing shows in the same gold line the MODS tab uses.

The fields are additive by design: feeds that carry them stay readable by
every build that predates them (schema_version stays 1), and feeds that do
not render exactly as before. ModUpdate.statsLine builds the shared line;
the MODS tab reuses it. Parser, formatting, and parse coverage are tested.
2026-08-04 12:37:28 +01:00
bryanthaboi 6cfc85ca83 icons update 2026-08-04 06:52:06 -04:00
github-actions 7b060c19f4 chore(ios): update app-repo.json [skip ci] 2026-08-04 06:50:19 -04:00
bryanthaboi 604b9338f9 Merge pull request #783 from bryanthaboi/dev
fixing some bugs and adding another tool to the translation toolbelt
2026-08-04 06:45:30 -04:00
bryanthaboi 3a6557ffe2 CLOSES #779 , CLOSES #743 + new font 2026-08-04 06:41:34 -04:00
bryanthaboi fe139ca7f4 CLOSES #691 maybe 2026-08-04 05:47:58 -04:00
bryanthaboi 5c617d3e32 Merge pull request #776 from vegerot/max/yellow-docs 2026-08-03 23:20:24 -04:00
Max 👨🏽‍💻 Coplan 8d11c04d43 fix(docs): add support for Blue and Yellow 2026-08-03 17:09:20 -07:00
github-actions 92f73d8bed chore(ios): update app-repo.json [skip ci] 2026-08-03 17:28:07 -04:00
bryanthaboi 8da51aa7ba Merge pull request #759 from jherediagu/fix/rom-text-statuses-items-evolve
Extend ROM-text messages to statuses, items and the learn/evolve flows
2026-08-03 17:23:34 -04:00
bryanthaboi 78f87111de Merge pull request #762 from ShaneMcGovernIE/launcher-mod-downloads
Show mod downloads and release dates in the launcher MODS panel
2026-08-03 17:23:26 -04:00
bryanthaboi e3fcdd0776 Merge pull request #761 from bryanthaboi/dev
bugs and switch
2026-08-03 17:22:17 -04:00
Shane McGovern 6ea59f1a79 Show mod downloads and release dates in the launcher MODS panel
The MODS tab now shows each installed mod's total GitHub downloads
(summed asset download_count across all releases), its first and latest
release dates, and a Sort row (Name / Popularity / Release date /
Last updated) persisted in options.modSort.

The launcher already fetched per-repo release lists for update checks, so
the data rides the same cached fetch: parseRelease keeps download_count
and published_at, writeCache persists them, and a cache entry written
before the fields existed is treated as stale and refetched once instead
of hiding the line behind an old cache.
2026-08-03 22:17:56 +01:00
bryanthaboi 51fb4db82e Merge origin/main into dev; resolve Game.lua gamepad speed-hotkey conflict 2026-08-03 17:17:26 -04:00
bryanthaboi 7f78de8718 Merge pull request #574 from andrewqsantos/feat/switch-nx
Nintendo Switch / love-nx support (#531)
2026-08-03 17:08:54 -04:00
bryanthaboi 76aab74bf1 potentially CLOSES #758 2026-08-03 17:08:42 -04:00
Juan Heredia a2bf08c6ff Extend ROM-text messages to statuses, items and the learn/evolve flows 2026-08-03 23:04:29 +02:00
bryanthaboi b831076839 CLOSES #592 2026-08-03 16:38:07 -04:00
bryanthaboi 45575a1fd8 Merge pull request #757 from Bortlesboat/fix/launcher-save-slot-overlap
Fix launcher save-slot panel overlap
2026-08-03 16:21:47 -04:00
bryanthaboi 56d92246cb commit o 2026-08-03 16:19:39 -04:00
Andrew Barnes 4a00f2335c Fix launcher save-slot panel overlap 2026-08-03 16:18:35 -04:00
github-actions e9cb830d71 chore(ios): update app-repo.json [skip ci] 2026-08-03 16:18:21 -04:00
bryanthaboi c2666d2b43 Merge pull request #756 from ShaneMcGovernIE/fix/boulder-wall-push
Stop boulders being pushed through walls (#754)
2026-08-03 16:14:42 -04:00
bryanthaboi 9ceec3070d Merge pull request #742 from ShaneMcGovernIE/speed-triggers
Cycle game speed from the analog triggers too (L2/R2)
2026-08-03 16:13:27 -04:00
bryanthaboi f89977a8e7 Merge pull request #747 from johnjohto/fix-fly-animation-702
Play the full Fly departure and landing animation (#702)
2026-08-03 16:11:40 -04:00
bryanthaboi 8d512b95cb Merge pull request #755 from castdrian/ios
fix(ios): support mod index networking and text input dismissal
2026-08-03 16:11:27 -04:00
Andrew Barnes 0914387c16 Fix launcher save-slot panel overlap 2026-08-03 15:53:49 -04:00
Andrew Quenehen 105672c132 fix(switch): restore pad cursor in Touch Controls and launcher overlays
Opening Touch Controls dropped the launcher virtual cursor and swallowed
gamepad input while touch still worked. Share PadCursor with the save
editor, forward pad events, and centralize overlay handoff/resume so both
hosts park and re-arm the pointer cleanly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:46:48 -03:00
Andrew Quenehen 0dd6ecc219 fix(switch): widen Pikachu PCM to 16-bit stereo without Source channel probe
Source:getChannelCount could skip the #626 widen on love-nx, and keeping
8-bit depth into a stereo buffer still sounded wrong on audren. Decode the
file via newSoundData, always emit 16-bit stereo like ChipSynth, and write
fresh pika-cry WAVs as stereo at extract time so re-imports skip the hop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:35:18 -03:00
Andrew Quenehen 9ef5c8dead test(switch): cover widenMono newSoundData hop and run NX suites in T0
The Yellow boot suite only recorded newSource, so a missing sound.newSoundData
wrap (the silent hole that motivated the full-surface overlay) could still go
green. Force mono on the Source stub, record newSoundData, and assert the cry
re-read lands on yellow/. Also pin the three NX suites in scripts/test.sh T0
and fix the seed_chunk global leak in the fallback suite.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:20:45 -03:00
Andrew Quenehen 12ff4dba2e fix(switch): wrap the whole read-side love API surface in the NX overlay
The overlay only wrapped the five loaders the boot path needed, leaving a
silent-failure hole: any future state (or current code like Sound.lua's
widenMono, which re-reads the pika-cry WAV via love.sound.newSoundData
with the caller's bare path) could load a generated asset through an
unwrapped API and silently degrade on hardware.

NxAssetOverlay now wraps every read-side love function that accepts a
filesystem path (filesystem.read/load/lines/newFileData/getInfo,
graphics.newImage/newFont, image.newImageData, audio.newSource,
sound.newSoundData, font.newFontData), so new states and mods fall inside
the Blue/Yellow fallback with zero per-call-site work.  Write-side
functions stay stock, proven by identity assertions in the fallback
suite.  The static guard's forbidden-literal list covers the same APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:14:33 -03:00
Shane McGovern ff9992dff8 Stop boulders being pushed through walls (#754)
checkBoulderPush had an isWarpTileCell escape hatch that let a boulder
be pushed onto any door/warp tile, walkable or not. In pokered,
CheckForCollisionWhenPushingBoulder walks the same wTilesetCollisionPtr
list as player movement (CheckTilePassable) -- there is no hole/warp
exception, so a boulder can never land on a cell the player cannot walk
onto.

The known push targets (CAVERN  holes, Victory Road switches) are
walkable tiles in their tileset's coll list already, so removing the
escape hatch only stops pushing boulders into walls.

Fixes #754
2026-08-03 20:09:29 +01:00
Andrew Quenehen 98c08e4b22 test(switch): close the silent-regression gaps the Yellow NX bug exposed
Three layers, all running without a ROM:

- nx_yellow_boot_test.lua: drives the real Yellow and Blue boot states
  (TitleState, YellowIntro + IntroMovie pre-roll for Yellow, IntroMovie
  direct for Blue, Sound.playPikaCry) against a broken-mount filesystem
  where generated art exists only under yellow|blue/, and asserts no bare
  assets/generated path ever reaches the raw love loaders.  This is the
  runtime complement to the static literal guard: data-driven manifest
  paths and formatted paths (cry_%02d.wav) are exactly what a source scan
  cannot see.  love_stub gains Image:setFilter/getFilter so IntroMovie
  constructs headless.

- CI path gate: switch-changes now also triggers on the NX runtime
  (NxAssetOverlay, Platform, GameVersion, CacheFs) and the NX engine
  suites, so src-side NX regressions rebuild the fused NRO instead of
  slipping through with green headless-only checks.

- switch-selftest runs the three NX engine suites headlessly on the
  fork-safe ubuntu runner, giving PR feedback before the self-hosted Mac
  build.  The content gate and switch-build.md docs were updated in sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:07:41 -03:00
Andrew Quenehen 0bd3b26acc Merge branch 'dev' into feat/switch-nx
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:47:28 -03:00
Adrian Castro 59b55a8f37 fix(ios): support mod index networking and text input dismissal 2026-08-03 20:43:27 +02:00
Andrew Quenehen 67e6b1fb04 fix(switch): centralize the NX asset fallback in a boot-time loader overlay
The scattered per-call-site prefix rewrites were a parallel track that any
future newImage("assets/generated/...") would silently bypass.  Replace
them with NxAssetOverlay: installed once from love.load on NX only, it
wraps newImage / newImageData / newSource / filesystem.read / getInfo so a
missing assets/generated path falls back to the active version's
blue|yellow copy.  Call sites return to plain love loader calls, and
Assets.resolve goes back to being the platform-free mod-override point.

Two deliberate exceptions remain: the chip-audio worker (separate Lua
state) keeps receiving the prefix explicitly via audio.programPrefix, and
data/generated module loads keep using CacheFs.readActive.

A new guard test (tests/engine/nx_generated_guard_test.lua) fails CI on
any direct love loader call with a literal assets/generated path, so the
class of bug cannot regress by accident.  scripts/test.sh --quick is
green across all tiers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:42:55 -03:00
Andrew Quenehen 17243a7d8b fix(switch): route remaining generated-art loads through Assets.resolve
Five more places called love.graphics.newImage directly on
assets/generated paths, bypassing the NX prefix rewrite:

- TradeAnim: cable/ball/bubble art
- TownMap: Kanto background, cursor, nest icon
- SurfingMinigame: surf bg/ob sheets
- BattleState: party ball row, substitute doll

All now resolve through Assets.resolve, which maps to the versioned
blue/ or yellow/ save-dir prefix on NX only. Desktop and Android keep
the mountVersion overlay behavior unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:31:58 -03:00
Andrew Quenehen 16b6b98ede fix(switch): pass NX cache prefix to the chip-audio worker
Yellow music was still silent because the background worker thread loads
ChipSynth.lua in a fresh Lua state with no GameVersion/Platform context.
The main thread's prefix never reached it.

ChipAudio.slimAudio now resolves the versioned cache prefix on the main
thread and includes it in the audio payload as `programPrefix`.
ChipSynth.loadBanks prefers `audio.programPrefix` when present, falling
back to its own NX detection for the sync path. Blue and Yellow are
handled the same way.

Tests cover the worker prefix hand-off and Blue's programs.bin path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:26:37 -03:00
Andrew Quenehen e4ce1063a1 fix(switch): extend NX-only asset prefix to audio and title/intro art
The previous NX gate only rewrote image paths that go through Assets.resolve.
Pokemon Yellow still had no sound and a blank title screen because:

- ChipSynth reads programs.bin directly via love.filesystem.read, bypassing
  Assets. On NX the unprefixed path is missing when the mount overlay fails,
  so the engine never built and every song/SFX was silent.
- Sound.playPikaCry loads pika_cries WAVs with love.audio.newSource, also
  bypassing Assets.resolve.
- TitleState, YellowIntro, and IntroMovie call love.graphics.newImage
  directly on unprefixed assets/generated paths, so the Pikachu title and
  intro atlases failed to load.

Fix: apply the same NX-only prefix rewrite in those four places.
Desktop/Android keep the existing mountVersion overlay behavior.

Also add ChipSynth._loadBanksForTest and tests covering the new paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:19:15 -03:00
Andrew Quenehen f099593136 fix(switch): write nx-asset-probe.log on every NX Play
Capture resolve paths and newImage open results for Yellow/Blue art
triage without enabling switch-debug.txt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:01:58 -03:00
Andrew Quenehen f944507520 fix(switch): gate Blue/Yellow asset path rewrite to NX only
Desktop and Android keep mountVersion as the overlay; only love-nx
resolves assets/generated to yellow|blue/ save-dir paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:59:44 -03:00
Andrew Quenehen 6824949247 fix(switch): resolve Blue/Yellow art to prefixed save-dir paths
NX fused mount often cannot expose assets/generated; open the real
yellow|blue/assets/generated file with newImage instead of FileData.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:56:19 -03:00
Andrew Quenehen 4f54255518 fix(switch): prefer Yellow/Blue asset bytes when mount overlay lies
Probe generated canaries after mountVersion and always readActive for
prefixed caches so sprites are not blanked by empty PhysFS stubs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:50:28 -03:00
Andrew Quenehen cfe482e1fc fix(switch): stop pad cursor flicker from SDL mouse drift
Disable mouse-yield on NX where stick/touch moves the system pointer between sparse axis events, clamp pad dt, pixel-snap the overlay, and soften FlexLove GC so the launcher cursor stays steady.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:31:05 -03:00
Andrew Quenehen 0e5ea26e36 fix(switch): stop NxDisplay setMode flicker on the launcher
Only resize when width/height change; love-nx flag mismatches were
recreating the EGL surface every frame.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:28:13 -03:00
Andrew Quenehen d5886e69aa fix(switch): load Yellow/Blue assets when PhysFS mount hides them
Mirror Data:load's versioned CacheFs read in Assets so Yellow-only NX
Play survives intro without needing a Red root cache mask.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:20:53 -03:00
github-actions 32fa215243 chore(ios): update app-repo.json [skip ci] 2026-08-03 13:15:01 -04:00
Andrew Quenehen dd5d8afd82 feat(switch): switch handheld 720p / docked 1080p at runtime
Unlock love-nx SDL dock/undock resizing and sync via NxDisplay so
booting docked is not stuck on the conf 720p hint.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:12:51 -03:00
johnjohto f19f4e9341 Play the full Fly departure and landing animation (#702) 2026-08-03 13:11:40 -04:00
bryanthaboi 3aec2ca89b Merge pull request #746 from bryanthaboi/dev
scrolling and bugs
2026-08-03 13:08:44 -04:00
bryanthaboi 0f7261dd92 CLOSES #623, CLOSES #624, CLOSES #636, CLOSES #637, CLOSES #639, CLOSES #650, CLOSES #697, CLOSES #704, CLOSES #722 2026-08-03 13:05:57 -04:00
Andrew Quenehen 0886573d34 fix(switch): cut launcher pad-cursor lag on NX
Skip per-frame mouse warps and FlexLove perf sampling on Switch, feed pad coords through a getPosition bridge, and park that shim before the save editor so desktop paths stay unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:00:06 -03:00
bryanthaboi 5c041ea857 snip for scroll 2026-08-03 12:39:36 -04:00
Andrew Quenehen 30cd9afc92 fix(build): keep desktop packer aligned with upstream dev
Leave pack_love.sh for Switch-only packaging so merge conflicts follow origin/dev.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 13:37:56 -03:00
Andrew Quenehen cceb74a025 Merge branch 'dev' into feat/switch-nx
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 13:35:35 -03:00
Andrew Quenehen 42540076c1 Fix NX Scan again to import only the open tab's ROM SHA-1.
A shared imports/ inbox with Red+Yellow was starting Red from the Yellow tab; match by GameVersion.forSha1 for the selected game and document the tab-scoped rescan.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 13:33:07 -03:00
github-actions a528868656 chore(ios): update app-repo.json [skip ci] 2026-08-03 12:30:20 -04:00
Shane McGovern a3cd5a132e Cycle game speed from the analog triggers too (L2/R2) 2026-08-03 17:19:53 +01:00
Andrew Quenehen d86ac70d9e Merge branch 'dev' into feat/switch-nx 2026-08-03 13:03:25 -03:00
Andrew Quenehen be8023a994 Merge branch 'dev' into feat/switch-nx
Bring FlexLove launcher UI ("big ui moment"), iOS picker dismiss, and Metal updates into the Switch NX branch. Keep pack_love (with libs/), NX inbox/rescan paths, SwitchDiagnostics, and Save Editor PadInput; port Scan again labels into LauncherView.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 13:02:08 -03:00
Andrew Quenehen 50fd0e9c7d refactor(import): drop redundant mobileFileBridge or and unused ready
scanInbox never consulted ready, and mobileFileBridge already mirrors
android on Android/iOS, so the dual guard was a no-op.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:02:17 -03:00
Andrew Quenehen 17843c2f7a ci(nx): run switch transfer docs gate on Switch path changes
Wire tests/switch_transfer_docs_test.lua into switch-changes detection,
switch-selftest, and the ROM-free T0 lane so docs drift fails CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:01:47 -03:00
Andrew Quenehen 54c8d2706b docs(nx): drop VoxelMod from Switch chord and install docs
Ship stock engine chords only; community mods own their rebinds, and keys
2/3/5 are claimed by the engine before pipeline hotkeys run.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:00:59 -03:00
Andrew Quenehen 44bfb1b93f test(nx): move ROM-free NX suites into the engine tier
platform_nx_* and rom_importer_nx_* run with the love stub and must execute
in CI's ROM-free lane via tests/run_engine.lua, not only T3.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 09:58:53 -03:00
Andrew Quenehen 7a78992881 chore: drop tracked .specs from the tree
Local planning artifacts belong under gitignore (.*) and must not ship in
the PR; useful Switch decisions already live in docs/switch-*.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 09:58:26 -03:00
Andrew Quenehen d7581dbece Ship Switch releases as an SD-ready zip only.
Players extract one zip at the microSD root for install and update; saves under pokemon-love2d/ survive merge. Drop the bare .nro from GitHub Release assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 09:04:25 -03:00
Andrew Quenehen eeb8c89d3d fix(nx): avoid Strings gate false positive on hash ledger
Write the imported-sha1 newline with string.char(10) so gate_strings_coverage
does not treat the filesystem ledger separator as player-visible text.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:46:17 -03:00
Andrew Quenehen 2591fde764 feat(nx): per-game save inbox and export folders
Split Import/Export paths into imports/saves/{red,blue,yellow}/ and
exports/{red,blue,yellow}/ so MTP destinations match each launcher tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:34:13 -03:00
Andrew Quenehen a8f3a3155c fix(nx): harden save inbox against slot clones
Retire successful imports to *.sav.imported and record content hashes so
re-pressing Import save (or the same bytes under a new name) cannot clone
slots. Surface multi-import counts and the active game tab in the notice.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:26:56 -03:00
Andrew Quenehen 2a927651c5 docs(specs): mark switch-save-sav-inbox Execute complete
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:13:38 -03:00
Andrew Quenehen 62ef647811 docs(specs): re-validate NX save .sav inbox
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:12:57 -03:00
Andrew Quenehen fe4491a87b test(nx): strengthen saves inbox RES-01 and edge coverage
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:11:05 -03:00
Andrew Quenehen 105bf65e02 docs(specs): validation report for NX save .sav inbox
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:09:15 -03:00
Andrew Quenehen 8120f113af test(nx): wire saves inbox suite into run_tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:06:35 -03:00
Andrew Quenehen 7e0c64cb78 docs(specs): AD-012 NX save .sav inbox
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:06:16 -03:00
Andrew Quenehen 4afb54c54f docs(nx): save .sav inbox and exports paths
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:05:53 -03:00
Andrew Quenehen 74f6b68034 feat(nx): Export save shows MTP exports path
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:02:38 -03:00
Andrew Quenehen 5d1e7ff3c1 feat(nx): Import save uses imports/saves inbox
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:02:04 -03:00
Andrew Quenehen 3c628140f7 feat(nx): saves inbox scan with AppleDouble/retain guards
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:01:14 -03:00
Andrew Quenehen 8bf99c3318 fix(switch): restore Save Editor pad and touch input on NX
Route gamepad/touch into the editor instead of dropping them in editorMode,
and hit-test clicks at event coords so a finger tap is not lost under the
Joy-Con virtual cursor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 23:04:37 -03:00
Andrew Quenehen f0829ed54f Merge branch 'dev' into feat/switch-nx
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:30:18 -03:00
Andrew Quenehen 59dba325b9 docs(switch): finalize Switch docs for PR review
Drop WIP status language, document Joy-Con controls and shortcuts,
credit the port and V1 testing help, and record community V1 boot evidence.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:23:29 -03:00
Andrew Quenehen d13064ee64 ci(switch): run fused build only after offline selftest
Avoid burning the self-hosted Mac when the ubuntu packaging gate fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:08:49 -03:00
Andrew Quenehen 669c9f4d8b ci(switch): skip fused build on fork pull requests
Keep ubuntu selftest for fork→canonical PRs; leave iOS build policy alone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:08:36 -03:00
Andrew Quenehen 228306f883 ci(switch): run workflow content gate in headless suite
Path-gate the Lua gate file and invoke it from scripts/test.sh T0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:08:15 -03:00
Andrew Quenehen 0a2ef854e6 test(switch): harden CI workflow content gate
Forbid soft-fail on Switch jobs and require the sha256 sidecar path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:07:58 -03:00
Andrew Quenehen 61c471343a ci: stop artifact commenters from deleting each other
Upsert via comment-tag only so iOS and Switch PR comments coexist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:07:50 -03:00
Andrew Quenehen 95eb1c3a35 ci(release): clarify Switch hard-fail vs path-gated PR CI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:53:19 -03:00
Andrew Quenehen cf50976d65 docs(switch): document CI selftest, fused PR builds, release hard-fail
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:52:58 -03:00
Andrew Quenehen 3fcee7f951 ci(switch): comment NRO artifacts on pull requests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:52:36 -03:00
Andrew Quenehen 17a4c83970 ci(switch): add canonical fused NRO artifact job
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:52:24 -03:00
Andrew Quenehen 9712fb1e9b ci(switch): path-gate offline selftest like iOS
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:52:01 -03:00
Andrew Quenehen cbf622f4aa Merge branch 'dev' into feat/switch-nx
Bring feat/switch-nx up to date with origin/dev (72 commits). Resolve
Input/RomImporter conflicts by keeping GamepadMap (NX face remap + dual-path
gate) while adopting upstream joyBindings rebinds (#632) and Enable-all mods
(#647). Gate shoulder GAME SPEED hotkeys when Select is held so Select+L
display chords still work.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:36:56 -03:00
Andrew Quenehen 0bb0518093 Merge branch 'dev' into feat/switch-nx
Bring Switch WIP up to date with current prod/dev (v0.1.54 / main tree).
2026-08-01 20:25:52 -03:00
Andrew Quenehen 41f549b271 fix(build): stop capturing pack_love stdout into LOVE_FILE
Status lines from pack_love polluted the path and broke version stamping, which would fail the desktop release step.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 20:24:30 -03:00
Andrew Quenehen ba8aa7b263 fix(switch): use English Scan again for NX inbox rescan
Match the project English default instead of a hardcoded Portuguese label.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 16:21:19 -03:00
Andrew Quenehen 243cb6a477 Merge branch 'dev' into feat/switch-nx
Sync upstream v0.1.52/v0.1.53 fixes: Oak PC flow, bindings, Android host restart, UTF-8 mod manifests.
2026-08-01 15:48:00 -03:00
Andrew Quenehen c987fefede docs(switch): document VoxelMod chords and Switch performance tips
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 15:46:35 -03:00
Andrew Quenehen 2a038f7112 fix(switch): send build progress to stderr during love packing
Keep pack_game_love command-substitution returning only the archive path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 15:27:24 -03:00
Andrew Quenehen 548e75a15e fix(switch): harden transfer docs gate and per-OS SD/FTP fallbacks
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:43:43 -03:00
Andrew Quenehen 339c85b590 docs(switch): close NXMOD-12 VoxelMod OLED smoke as pass
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:41:39 -03:00
Andrew Quenehen 0f0420314b docs(switch): point development guide at multi-method transfer docs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:41:15 -03:00
Andrew Quenehen a08b5e65f7 docs(switch): link install and build guides to transfer runbook
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:40:54 -03:00
Andrew Quenehen 99b89c4e5e docs(switch): add multi-OS transfer runbook and content gate
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:40:46 -03:00
Andrew Quenehen 20586e0c48 fix(switch): show creator and porter in fused NRO author
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:19:16 -03:00
Andrew Quenehen b54d0c605a fix(switch): stamp Version.lua, gate resume music, keep NX import hint
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:13:09 -03:00
Andrew Quenehen 2f75af902c docs(switch): record SWBLD fetch-fused Mac smoke and SD install
Operator confirmed scripts/build_switch.sh --fetch --fused --version
0.0.0-test produced gen1recomp-0.0.0-test-switch.nro (9147a64) and
copied it to sdmc:/switch/gen1recomp/ per switch-install.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:06:45 -03:00
Andrew Quenehen 9147a6413e fix(switch): detail love-nx fetch failures with retry hint
SWBLD-05: on download failure print URL, curl/wget exit and HTTP status,
and an explicit retry: scripts/build_switch.sh --fetch line. Extend the
offline selftest to cover that path without a live network asset.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:53:06 -03:00
Andrew Quenehen 9a467d4bf1 docs(switch): link install and build guides from README
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:50:17 -03:00
Andrew Quenehen e40f9337b6 docs(switch): add switch-install player guide
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:49:57 -03:00
Andrew Quenehen 1f0712d2d5 docs(switch): add switch-build contributor guide
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:49:45 -03:00
Andrew Quenehen 345bc9519a ci(release): publish fused Switch NRO on GitHub Releases
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:49:25 -03:00
Andrew Quenehen 0b30bb2eff test(switch): add offline build_switch packaging selftest
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:48:31 -03:00
Andrew Quenehen f0e88aa581 feat(switch): native-or-Docker fused NRO packaging with clear errors
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:48:04 -03:00
Andrew Quenehen 317893bd61 feat(switch): add --fetch and mode glossary to build_switch.sh
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:47:39 -03:00
Andrew Quenehen bd181271d9 fix(switch): tell users to run --fetch when love-nx pin missing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:47:26 -03:00
Andrew Quenehen 4df09010a8 feat(switch): fetch pinned love-nx with checksum verify
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:47:09 -03:00
Andrew Quenehen 1a5b2b96dc chore(switch): pin devkitPro Docker image for fused fallback
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:46:57 -03:00
Andrew Quenehen 43d89813fc feat(switch): add shared packaging helpers in common.sh
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:46:51 -03:00
Andrew Quenehen 5bc76ce6fe Merge branch 'dev' into feat/switch-nx
Bring latest upstream fixes (Metal/iOS, encounter slide, second-screen seam) into the Switch NX feature branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:12:07 -03:00
Andrew Quenehen 8654444d1a docs(switch): document WIP status, OLED-only limits, and manual deploy
Clarify that Mac+OpenMTP is contributor tooling (not the final product
contract), record Dusklight method references, and surface how/what we
tested so reviewers and the community can help finish the Switch port.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 06:07:18 -03:00
Andrew Quenehen 772c39edb8 feat(switch): use gen1recomp cover art for NRO icon
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 06:00:19 -03:00
Andrew Quenehen 74e7474349 fix(nx-mods): skip ROM AppleDouble sidecars and surface mixed failures
Ignore hidden ._*.gb in the ROM inbox like zips, keep mixed-rescan success notices while appending sibling errors, and cover FileData mount / PK rejection in unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:58:17 -03:00
Andrew Quenehen 3aefe41ea1 fix(nx-mods): skip MTP AppleDouble zips and mount archives in memory
Mac OpenMTP leaves ._*.zip sidecars that fail PhysFS mount and hide a good install; prefer FileData mount on Horizon and keep success notices when a sibling fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:53:48 -03:00
Andrew Quenehen 2223c31e93 docs(switch): add VoxelMod OLED smoke evidence scaffold
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:27:50 -03:00
Andrew Quenehen 568f0fa9eb docs(switch): document mod zip inbox and Joy-Con display chords
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:27:34 -03:00
Andrew Quenehen e91cf0f0c2 test(nx-mods): lock zip and chord edge regressions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:27:07 -03:00
Andrew Quenehen 8a1f583d88 feat(nx-mods): fire display hotkeys from Select+face chords
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:26:20 -03:00
Andrew Quenehen 7518319c80 feat(nx-mods): map Select+face chords to display hotkeys
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:25:26 -03:00
Andrew Quenehen 304df44742 feat(nx-mods): show Procurar novamente and MTP hint on MODS
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:23:19 -03:00
Andrew Quenehen e92bab6f4f feat(nx-mods): route chooseMod to inbox rescan on NX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:22:08 -03:00
Andrew Quenehen 43755c5457 feat(nx-mods): rescan installs zips and retains failures
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:21:01 -03:00
Andrew Quenehen d4455c508f feat(nx-mods): scan imports/mods for zip candidates
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:19:59 -03:00
Andrew Quenehen 7d01242cd1 feat(nx-mods): add imports/mods inbox dir and MTP hint
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:19:26 -03:00
Andrew Quenehen 9d77189cec docs(switch): sync P0 fused matrix with T24 hardware pass
Verifier flagged stale deferred rows for P0-12/14/15 after fused
deploy evidence closed T24.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:05:43 -03:00
Andrew Quenehen c8adbdcc64 docs(switch): record fused NRO deploy evidence
T24 closed: fused boot/import, Red+Blue Play after Blue mount fix,
and NRO-only replace keeps saves on OLED.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:03:31 -03:00
Andrew Quenehen ac6dfe7134 fix(import): mount Blue/Yellow save-dir cache without FFI
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>
2026-08-01 04:57:17 -03:00
Andrew Quenehen b1ad7c7254 fix(switch): unhide fused save-dir generated cache on Play
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>
2026-08-01 04:55:08 -03:00
Andrew Quenehen 6fb5602cb0 docs(switch): outline upstream PR split
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:33:23 -03:00
Andrew Quenehen b942d37944 docs(switch): complete P0/P1 hardware matrix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:33:19 -03:00
Andrew Quenehen b7ee191b6c feat(debug): log Lua errors and document NX crash triage
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:33:14 -03:00
Andrew Quenehen 4ea24a0a6f feat(switch): build fused gen1recomp NRO
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:32:01 -03:00
Andrew Quenehen 6ad8c8d73d feat(switch): embed build-info metadata in artifacts
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:31:57 -03:00
Andrew Quenehen 8982952fa4 feat(switch): reject private content in love payload
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:31:37 -03:00
Andrew Quenehen b135878824 refactor(build): extract shared pack_love.sh helper
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:31:06 -03:00
Andrew Quenehen e9463d39aa docs(switch): record suspend and save persistence evidence
T19 closed: quit/reopen, suspend×10, and reboot persistence all pass
on OLED (operator report 2026-08-01).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:29:26 -03:00
Andrew Quenehen dcee7ca8e4 fix(build): avoid SIGPIPE from grep -q on love zip listing
Under pipefail, unzip|grep -q exits 141 when grep closes early on a
match and aborts mac pack. List once to a file, then grep the listing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:28:31 -03:00
Andrew Quenehen 1653aa0a5d docs(switch): close T16 naming re-verify with Nintendo UX
OLED pass at 2699c9a: physical A confirms, B cancels; game.love
a208b21e… recorded. T19 suspend×10 still open.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:28:31 -03:00
Andrew Quenehen 2699c9a2f9 fix(input): Nintendo A/B face map on NX
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>
2026-08-01 04:24:57 -03:00
Andrew Quenehen efd81d8e34 fix(input): ignore raw face presses when Joy-Con is gamepad
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>
2026-08-01 04:15:47 -03:00
Andrew Quenehen 925b224eda docs(switch): record Joy-Con input evidence
T16 launcher/overworld pass; naming fail with dual-path diagnosis.
T19 quit/reopen save pass; suspend×10 still pending.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:15:47 -03:00
Andrew Quenehen 7504753ea8 fix(input): recover cleanly on joystick reconnect
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:53:43 -03:00
Andrew Quenehen da60f40dfc fix(input): reset controls on focus loss
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:53:37 -03:00
Andrew Quenehen 560ebc5997 fix(input): apply Switch-measured controller fallback
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:53:27 -03:00
Andrew Quenehen 361d4b81df feat(debug): add opt-in Switch input diagnostics
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:53:20 -03:00
Andrew Quenehen 83cf1fdb00 refactor(input): share gamepad map between launcher and gameplay
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:53:13 -03:00
Andrew Quenehen 228a0baf81 docs(switch): record OLED ROM import evidence
Phase 0 probe confirmed getOS NX and Joy-Con events; Red inbox
import reached Play. Naming-screen confirm flake deferred to input tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:48:57 -03:00
Andrew Quenehen df7cea4387 feat(import): scan NX inbox and import by canonical SHA-1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:20:16 -03:00
Andrew Quenehen 13b55a797e feat(import): show NX inbox path and rescan action
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:20:12 -03:00
Andrew Quenehen 1ec33b1374 refactor(import): separate NX capabilities from Android flags
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:20:09 -03:00
Andrew Quenehen c06991e03d fix(switch): disable unvalidated network features on NX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:15:31 -03:00
Andrew Quenehen 2fcd7abbfd fix(platform): skip host shell spawn on NX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:15:13 -03:00
Andrew Quenehen 5ca17cfc99 feat(conf): configure LÖVE window hints for NX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:14:45 -03:00
Andrew Quenehen 05f1e3852c feat(platform): add NX capability detection module
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:14:38 -03:00
Andrew Quenehen 3b22a45a23 docs(switch): add MTP and Phase 0 hardware runbook
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:14:25 -03:00
Andrew Quenehen f172d3129f feat(switch): assemble loose nro + game.love dist
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:14:11 -03:00
Andrew Quenehen a7a84b6fab chore(switch): add minimal love-nx hardware probe
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:13:57 -03:00
Andrew Quenehen d4afa09e03 docs(switch): pin love-nx 11.5-nx1 manifest layout
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:13:46 -03:00
228 changed files with 17655 additions and 1076 deletions
+79
View File
@@ -102,6 +102,85 @@ jobs:
if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }} if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
run: security delete-keychain "$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db" 2>/dev/null || true run: security delete-keychain "$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db" 2>/dev/null || true
switch-changes:
name: detect Switch changes
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.paths.outputs.changed }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: paths
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.sha }}
run: |
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics)_test\.lua$|tests/engine/platform_nx)'; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
switch-selftest:
name: Switch offline selftest
needs: switch-changes
if: needs.switch-changes.outputs.changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: install luajit
run: sudo apt-get update && sudo apt-get install -y luajit
- name: Switch offline selftest
run: bash scripts/switch/selftest_build_switch.sh
- name: verify_payload self-test
run: bash scripts/switch/verify_payload.sh --self-test
- name: Switch CI workflow content gate
run: luajit tests/switch_ci_workflows_test.lua
- name: Switch transfer docs content gate
run: luajit tests/switch_transfer_docs_test.lua
# NX runtime regressions gate this job via switch-changes; run the NX
# engine suites here too so a PR touching them gets feedback on the
# fork-safe ubuntu runner before the self-hosted Mac build.
- name: NX engine suites (headless)
run: |
luajit tests/engine/assets_version_fallback_test.lua
luajit tests/engine/nx_generated_guard_test.lua
luajit tests/engine/nx_yellow_boot_test.lua
switch-build:
name: Switch fused build
needs: [switch-changes, switch-selftest]
if: |
always()
&& needs.switch-changes.outputs.changed == 'true'
&& needs.switch-selftest.result == 'success'
&& github.repository == 'bryanthaboi/gen1recomp'
&& (github.event_name != 'pull_request'
|| github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ["self-hosted", "macOS"]
steps:
- uses: actions/checkout@v7
- name: Build Switch fused NRO
run: |
set -euo pipefail
VER="$(printf '%s' "$GITHUB_SHA" | cut -c1-7)"
scripts/build_switch.sh --fetch --fused --version "$VER"
echo "SWITCH_VER=$VER" >> "$GITHUB_ENV"
- name: upload Switch NRO artifact
uses: actions/upload-artifact@v7
with:
name: gen1recomp-switch-nro
path: |
dist/switch/gen1recomp-${{ env.SWITCH_VER }}-switch.nro
dist/switch/gen1recomp-${{ env.SWITCH_VER }}-switch.nro.sha256
if-no-files-found: error
retention-days: 7
headless: headless:
name: headless suites (no ROM) name: headless suites (no ROM)
runs-on: ubuntu-latest runs-on: ubuntu-latest
+1 -7
View File
@@ -29,13 +29,7 @@ jobs:
[ -n "$pr_number" ] || exit 0 [ -n "$pr_number" ] || exit 0
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT" echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
- name: Delete existing comment # Upsert via comment-tag only — do not delete-all bot comments (clobbers Switch).
if: steps.artifact.outputs.pr_number != ''
uses: izhangzhihao/delete-comment@master
with:
github_token: ${{ github.token }}
delete_user_name: github-actions[bot]
issue_number: ${{ steps.artifact.outputs.pr_number }}
- name: Get build info - name: Get build info
id: build-info id: build-info
env: env:
+33 -10
View File
@@ -1,8 +1,9 @@
name: Release name: Release
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS # Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
# IPA, and the Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on # IPA, a Nintendo Switch SD-ready zip (experimental), and the Anbernic RG34XXSP
# the self-hosted Mac runner, and publishes them as a GitHub Release. # (Stock OS 64-bit MOD / PortMaster) port on the self-hosted Mac runner, and
# publishes them as a GitHub Release.
# #
# Versioning: # Versioning:
# - First ever release is 0.1.0. # - First ever release is 0.1.0.
@@ -196,6 +197,17 @@ jobs:
--version "${{ steps.ver.outputs.version }}" --version "${{ steps.ver.outputs.version }}"
fi fi
- name: Build Switch
run: |
set -euo pipefail
# Hard-fail gate: Switch ships with every release (never soft-fail).
# PR CI is path-gated (ubuntu selftest + canonical fused); release
# always builds Switch regardless of which files changed.
# Needs native switch-tools (nacptool/elf2nro) and/or Docker on the
# Mac self-hosted runner; see docs/switch-build.md.
scripts/build_switch.sh --fetch --fused \
--version "${{ steps.ver.outputs.version }}"
- name: Build Anbernic RG34XXSP port - name: Build Anbernic RG34XXSP port
run: | run: |
set -euo pipefail set -euo pipefail
@@ -258,6 +270,12 @@ jobs:
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; } [ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa" cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa"
swzip="dist/switch/gen1recomp-${v}-switch.zip"
[ -f "$swzip" ] || { echo "::error::$swzip not found (expected from scripts/build_switch.sh --fused → pack_sd_zip.sh)"; exit 1; }
cp "$swzip" "$outdir/gen1recomp-${v}-switch.zip"
# Local fused .nro stays under dist/switch/ for PR CI / debug; release
# publishes the SD-ready zip only.
# Anbernic handheld port (suffix names the CFW it targets, so a # Anbernic handheld port (suffix names the CFW it targets, so a
# future RG35XX/other-CFW pack can ship alongside it). # future RG35XX/other-CFW pack can ship alongside it).
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip" rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
@@ -369,18 +387,23 @@ jobs:
fi fi
printf 'Release notes:\n%s\n' "$notes" printf 'Release notes:\n%s\n' "$notes"
release_files=(
"dist/release/gen1recomp-${v}-macos.zip"
"dist/release/gen1recomp-${v}-windows.zip"
"dist/release/gen1recomp-${v}-linux.zip"
"dist/release/gen1recomp-${v}-android.apk"
"dist/release/gen1recomp-${v}-ios.ipa"
"dist/release/gen1recomp-${v}-switch.zip"
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
"dist/release/gen1recomp-${v}.love"
"dist/release/sha256sums.txt"
)
gh release create "$tag" \ gh release create "$tag" \
--target "$GITHUB_SHA" \ --target "$GITHUB_SHA" \
--title "$v" \ --title "$v" \
--notes "$notes" \ --notes "$notes" \
"dist/release/gen1recomp-${v}-macos.zip" \ "${release_files[@]}"
"dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk" \
"dist/release/gen1recomp-${v}-ios.ipa" \
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
"dist/release/gen1recomp-${v}.love" \
"dist/release/sha256sums.txt"
echo "Published release $tag" echo "Published release $tag"
@@ -0,0 +1,55 @@
name: Switch artifact comment
on:
workflow_run:
workflows: [ci]
types: [completed]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
comment:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- id: artifact
env:
GH_TOKEN: ${{ github.token }}
RUN_ID: ${{ github.event.workflow_run.id }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
run: |
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-switch-nro") | .id')"
[ -n "$artifact_id" ] || exit 0
head_owner="${HEAD_REPOSITORY%%/*}"
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
[ -n "$pr_number" ] || exit 0
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
# Upsert via comment-tag only — do not delete-all bot comments (clobbers iOS).
- name: Get build info
id: build-info
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
commit_hash="$(printf '%s' "$HEAD_SHA" | cut -c1-7)"
build_time="$(date "+%Y-%m-%d %H:%M:%S")"
echo "hash=$commit_hash" >> "$GITHUB_OUTPUT"
echo "time=$build_time" >> "$GITHUB_OUTPUT"
- name: comment Switch artifact
if: steps.artifact.outputs.pr_number != ''
uses: thollander/actions-comment-pull-request@v3
with:
message: |
[gen1recomp-switch.nro](${{ steps.artifact.outputs.artifact_url }})
**Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }})
**Build Time**: `${{ steps.build-info.outputs.time }}`
<sub>This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
pr-number: ${{ steps.artifact.outputs.pr_number }}
comment-tag: switch-build-result
github-token: ${{ github.token }}
+4 -1
View File
@@ -31,7 +31,10 @@ mobile/ios/love-src/
mobile/ios/cache/ mobile/ios/cache/
mobile/ios/build/ mobile/ios/build/
# Final packaged build artifacts (mac/win/web/android/ios) — see scripts/build.sh # love-nx vendor binaries (fetch per docs/switch-development.md; also covered by .*)
.bazinga/love-nx/
# Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh
/dist/ /dist/
# Legacy manual convenience-copy location (superseded by /dist/android/) # Legacy manual convenience-copy location (superseded by /dist/android/)
+24
View File
@@ -229,6 +229,27 @@ ships with every release as `gen1recomp-*-rg34xxsp-stockos64-mod.zip`.
Install steps, controls, and troubleshooting live in Install steps, controls, and troubleshooting live in
[docs/anbernic-rg34xxsp.md](docs/anbernic-rg34xxsp.md). [docs/anbernic-rg34xxsp.md](docs/anbernic-rg34xxsp.md).
## Nintendo Switch
Releases ship an SD-ready `gen1recomp-*-switch.zip` (issue
[#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Runtime target
is pinned [love-nx](https://github.com/retronx-team/love-nx) `11.5-nx1`.
Requires a console that can run Switch homebrew. Hardware evidence: **OLED**
(author) and **V1 / Erista** boot (community).
- Players: [docs/switch-install.md](docs/switch-install.md) — download the
zip, extract at the microSD root (install or update), title-override
launch, import your own legal ROM, Joy-Con controls and shortcuts.
- Builders: [docs/switch-build.md](docs/switch-build.md) — `--fetch` /
`--loose` / `--fused`, toolchain, Docker fallback, and **CI vs release**
(path-gated ubuntu selftest, canonical fused PR artifact, release hard-fail).
Limitations, Dusklight-derived method, and how we tested:
[docs/switch-development.md](docs/switch-development.md) and
[docs/switch-hardware-evidence.md](docs/switch-hardware-evidence.md). Community
help — especially HOS / love-nx packaging and broader hardware coverage — is
welcome.
## Modding ## Modding
The game ships a native mod platform: content registries, events and hooks, The game ships a native mod platform: content registries, events and hooks,
@@ -276,4 +297,7 @@ This project would not be possible without [pret](https://github.com/pret) >
the pret band of decompiling maniacs > and their the pret band of decompiling maniacs > and their
[pokered](https://github.com/pret/pokered) disassembly. [pokered](https://github.com/pret/pokered) disassembly.
Nintendo Switch port: [andrewqsantos](https://github.com/andrewqsantos).
Switch hardware testing (V1 boot): [booshankles](https://github.com/booshankles).
<p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p> <p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p>
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
# Plain Pixel font
"Plain Pixel Font" by Douglas Vautour (Burpy Fresh) is licensed under
CC-BY 4.0: https://burpyfresh.itch.io
Version 0.009 (CJK character additions), unmodified. Characters for most
languages have a 5x11 base but can extend vertically; double-width
characters such as Hiragana and Katakana are 11x11.
Bundled so a translation mod can opt into TTF text rendering
(`mod.content.font:register("ttf", {})`; see the Translation support
section of docs/new-features.md) instead of drawing hundreds of glyph-page
tiles. The tile font extracted from the player's ROM stays the default.
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+15 -1
View File
@@ -58,7 +58,17 @@ function love.conf(t)
-- engine before conf runs (LÖVE 11.x / 11.5). -- engine before conf runs (LÖVE 11.x / 11.5).
local osName = love._os local osName = love._os
local mobile = osName == "Android" or osName == "iOS" local mobile = osName == "Android" or osName == "iOS"
if mobile then local nx = osName == "NX"
if nx then
-- Switch (love-nx): hint handheld 720p. SDL auto-switches portable↔dock
-- (720p↔1080p) only when the window is resizable and not exclusive
-- fullscreen; NxDisplay.sync also applies the size on boot and dock change.
t.window.width = 1280
t.window.height = 720
t.window.fullscreen = false
t.window.resizable = true
t.window.highdpi = false
elseif mobile then
-- resizable is what unlocks orientation. SDL's Android backend, given no -- resizable is what unlocks orientation. SDL's Android backend, given no
-- SDL_HINT_ORIENTATIONS (LÖVE sets none), calls setRequestedOrientation -- SDL_HINT_ORIENTATIONS (LÖVE sets none), calls setRequestedOrientation
-- at window creation -- FULL_SENSOR when the window is resizable (rotates -- at window creation -- FULL_SENSOR when the window is resizable (rotates
@@ -70,6 +80,10 @@ function love.conf(t)
-- just work. FULL_SENSOR ignores the device's rotation lock, so -- just work. FULL_SENSOR ignores the device's rotation lock, so
-- GameActivity.setOrientationBis remaps it to FULL_USER after SDL has -- GameActivity.setOrientationBis remaps it to FULL_USER after SDL has
-- run: same orientations allowed, but auto-rotate being off now wins. -- run: same orientations allowed, but auto-rotate being off now wins.
-- A persisted ORIENTATION lock (#592) overrides all of this after boot:
-- src/core/Orientation.lua sets SDL_HINT_ORIENTATIONS over the FFI and
-- re-triggers the request, from main.lua for the launcher and from
-- Game:applyOptions in game.
-- iOS follows the Info.plist orientations -- iOS follows the Info.plist orientations
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape). -- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true t.window.resizable = true
+67 -24
View File
@@ -766,6 +766,28 @@ local function silphRocketsLeave(game, ow, onlyMap)
end end
end end
-- SilphCo11FGiovanniAfterBattleScript (scripts/SilphCo11F.asm) is the whole
-- aftermath: DisplayTextID TEXT_SILPHCO11F_GIOVANNI_YOU_RUINED_OUR_PLANS,
-- GBFadeOutToBlack, SilphCo11FTeamRocketLeavesScript, Delay3,
-- GBFadeInFromBlack, then SetEvent. The port had only the hide pass, so the
-- speech never played and every rocket blinked out in front of the player
-- (#722). Same hide list as silphRocketsLeave, spelled as script rows so the
-- fade can hold over it.
local function silphAftermathRows()
local rows = {
{ "show_text", "_SilphCo11FGiovanniYouRuinedOurPlansText" },
{ "fade", "out" },
}
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
for _, name in ipairs(floor[2]) do
rows[#rows + 1] = { "hide_object", floor[1], name }
end
end
rows[#rows + 1] = { "wait", 3 } -- Delay3
rows[#rows + 1] = { "fade", "in" }
return rows
end
M.SILPH_CO_11F = { M.SILPH_CO_11F = {
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk. -- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks -- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
@@ -792,11 +814,15 @@ M.SILPH_CO_11F = {
ow:scriptMove(gio, "down", 3, function() ow:scriptMove(gio, "down", 3, function()
gio:facePlayer(ow.player) gio:facePlayer(ow.player)
ow:engageTrainer(gio, function() ow:engageTrainer(gio, function()
-- SilphCo11FTeamRocketLeavesScript: every Silph rocket leaves -- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" speech,
-- after the loss (the street rockets are handled by -- then SilphCo11FTeamRocketLeavesScript behind a fade so every Silph
-- M.SAFFRON_CITY.onEnter in story4.lua). -- rocket leaves off-screen (the street rockets are handled by
-- M.SAFFRON_CITY.onEnter in story4.lua). Queued, not run here: the
-- battle's own callbacks are still unwinding, so queueScript starts
-- it on the first idle overworld frame -- after the end-battle
-- "Arrgh!!" box victories.lua OPP_GIOVANNI#2 pushes (#722).
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
silphRocketsLeave(game, ow) ow:queueScript(silphAftermathRows())
end end
end) end)
end) end)
@@ -981,7 +1007,11 @@ M.VICTORY_ROAD_3F = {
local championsRoomRivalScript = { local championsRoomRivalScript = {
{ "face_player" }, -- 1 { "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2 { "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 26 }, -- 3 past end -- "end" rather than a row number past the tail: this script grew by a row
-- when the follow-Oak walk landed (#704), which silently turned the old
-- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past
-- it, so a returning champion warped straight into the induction.
{ "jump_if_true", "end" }, -- 3
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4 { "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after -- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
-- the intro text, before the battle itself (#706); pushBattle's wipe-time -- the intro text, before the battle itself (#706); pushBattle's wipe-time
@@ -989,37 +1019,50 @@ local championsRoomRivalScript = {
-- continuous into the fight -- continuous into the fight
{ "play_music", "Music_FinalBattle" }, -- 5 { "play_music", "Music_FinalBattle" }, -- 5
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 6 { "rival_battle", "OPP_RIVAL3", 1 }, -- 6
{ "jump_if_false", 26 }, -- 6 past end -- losing halts here; the numeric target this replaced pointed at the
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7 -- closing warp, which inducted a player who had just lost the fight (#704)
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8 { "jump_if_false", "end" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 8
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 9
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL, -- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch = -- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText -- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead). -- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9 { "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo -- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after -- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in -- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
{ "play_music", "Music_Cities1", { keep = true } }, -- 10 { "play_music", "Music_Cities1", { keep = true } }, -- 11
{ "show_text", "_ChampionsRoomOakText" }, -- 11 { "show_text", "_ChampionsRoomOakText" }, -- 12
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12 { "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 13
{ "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement { "move_npc", 2, "up", 5 }, -- 14 OakEntranceAfterVictoryMovement
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down -- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 14 { "face_object", 1, "left" }, -- 15
{ "face_object", 2, "down" }, -- 15 { "face_object", 2, "down" }, -- 16
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16 { "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 17
-- OakDisappointedWithRivalScript: Oak turns to the rival (right) -- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 17 { "face_object", 2, "right" }, -- 18
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18 { "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 19
-- OakComeWithMeScript: Oak faces down again, then exits up -- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 19 { "face_object", 2, "down" }, -- 20
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20 { "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 21
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement { "move_npc", 2, "up", 2 }, -- 22 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22 { "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 23
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement
-- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the
-- screen just fading on the spot (#704). The entrance walk leaves the
-- player at (4,3) and both north-wall warps sit on row 0, so the original
-- only ever spends three of those simulated steps -- CheckWarpsNoCollision
-- takes the HALL_OF_FAME warp the moment the walk lands on (4,0) and the
-- trailing UP/LEFT are dropped. Scripted steps ignore collision here just
-- as they do in the original (CollisionCheckOnLand skips its checks while
-- wSimulatedJoypadStatesIndex is non-zero), so stepping through the
-- rival's cell at (4,2) is the ported behavior, not a clip.
{ "move_player", "up", 3 }, -- 24
-- hand the induction off to the HALL_OF_FAME room (consumed by its -- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up) -- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 23 { "set_field", "pendingHallOfFame", true }, -- 25
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24 { "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 26
} }
M.CHAMPIONS_ROOM = { M.CHAMPIONS_ROOM = {
+206 -80
View File
@@ -549,21 +549,57 @@ M.GAME_CORNER = {
-- handler is bound to both text ids just below (#552). -- handler is bound to both text ids just below (#552).
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done) TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox") local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local t = game.data.text local t = game.data.text
local function line(suffix, fallback) local function line(suffix, fallback)
return t["_GameCornerClerk1" .. suffix] return t["_GameCornerClerk1" .. suffix]
or t["_GameCornerClerk" .. suffix] or t["_GameCornerClerk" .. suffix]
or fallback or fallback
end end
-- GameCornerDrawCoinBox (scripts/GameCorner.asm; pokeyellow's copy is
-- identical): TextBoxBorder at hlcoord 11,0 with b=5 c=7, a 9x7-tile
-- window in the top right holding MONEY at (12,2) over the amount on
-- row 3 and COIN at (12,4) over the count on row 5. Both
-- PrintBCDNumber calls pass LEADING_ZEROES, whose bit 7 SUPPRESSES
-- leading zeroes (home/print_bcd.asm), and neither passes LEFT_ALIGN,
-- so both numbers read plain and right-aligned against the inner edge
-- at column 18. The asm draws the box before the offer and redraws it
-- after the purchase, so it stands for the whole exchange: a draw-only
-- state under the dialogue gets that lifetime, since StateStack draws
-- every state above the last opaque one and updates only the top
-- (src/core/StateStack.lua), and reading save each frame is the
-- redraw (#624).
local coinBox = { draw = function()
Font.drawBox(11, 0, 9, 7)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("MONEY"), 96, 16)
local money = ("¥%d"):format(game.save.money or 0)
Font.draw(money, 152 - Font.width(money), 24)
Font.draw(Strings("COIN"), 96, 32)
local coins = ("%d"):format(game.save.coins or 0)
Font.draw(coins, 152 - Font.width(coins), 40)
love.graphics.setColor(1, 1, 1, 1)
end }
game.stack:push(coinBox)
-- Every branch below finishes here. A TextBox pops itself before its
-- onDone runs, so the coin box is top of the stack again by then and
-- this pop takes it down, never someone else's state.
local function finish()
game.stack:pop()
done()
end
-- YesNoChoice is called with the offer still printed, so the prompt
-- has to ride the open text box (opts.choice) instead of being pushed
-- after it closes, which is what made the question vanish (#624).
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
line("DoYouNeedSomeGameCoinsText", line("DoYouNeedSomeGameCoinsText",
"Do you need some\ngame coins?\f¥1000 for 50."), function() "Do you need some\ngame coins?\f¥1000 for 50."),
game.stack:push(ChoiceBox.new(game, function(yes) nil, { choice = function(yes)
if not yes then if not yes then
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
line("PleaseComePlaySometimeText", line("PleaseComePlaySometimeText",
"No? Please come\nplay sometime!"), done)) "No? Please come\nplay sometime!"), finish))
return return
end end
-- scripts/GameCorner.asm GameCornerClerk1Text: coins need -- scripts/GameCorner.asm GameCornerClerk1Text: coins need
@@ -571,29 +607,30 @@ M.GAME_CORNER = {
if not game.save.inventory.COIN_CASE then if not game.save.inventory.COIN_CASE then
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
line("DontHaveCoinCaseText", line("DontHaveCoinCaseText",
"You don't have a\nCOIN CASE!"), done)) "You don't have a\nCOIN CASE!"), finish))
return return
end end
if (game.save.coins or 0) >= 9990 then if (game.save.coins or 0) >= 9990 then
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
line("CoinCaseIsFullText", line("CoinCaseIsFullText",
"Oops! Your COIN\nCASE is full."), done)) "Oops! Your COIN\nCASE is full."), finish))
return return
end end
if game.save.money < 1000 then if game.save.money < 1000 then
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
line("CantAffordTheCoinsText", line("CantAffordTheCoinsText",
"You can't afford\nthe coins!"), done)) "You can't afford\nthe coins!"), finish))
return return
end end
game.save.money = game.save.money - 1000 game.save.money = game.save.money - 1000
game.save.coins = math.min(9999, (game.save.coins or 0) + 50) game.save.coins = math.min(9999, (game.save.coins or 0) + 50)
-- the thanks text is the plain _GameCornerClerk1ThanksHereAre50-
-- CoinsText; the new count belongs in the coin box the asm
-- redraws here, not appended to the line (#624)
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
line("ThanksHereAre50CoinsText", line("ThanksHereAre50CoinsText",
"Thanks! Here are\nyour 50 coins!") "Thanks! Here are\nyour 50 coins!"), finish))
.. ("\fCOINS: %d"):format(game.save.coins), done)) end }))
end))
end))
end, end,
}, },
} }
@@ -606,99 +643,188 @@ M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK =
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK1 M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK1
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm). -- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
-- The six mon prizes differ between Red and Blue; the three TM prizes are -- Each counter owns ONE window of three prizes, not the whole catalogue:
-- identical, so they are shared and appended to each version's mon list. -- GetPrizeMenuId (engine/events/prize_menu.asm) subtracts
-- TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 from hTextID and indexes
-- PrizeDifferentMenuPtrs with the result, so vendor 1 sells
-- PrizeMenuMon1Entries, vendor 2 PrizeMenuMon2Entries and vendor 3
-- PrizeMenuTMsEntries (#623). The mon windows and their levels differ per
-- version; the TM window is identical in all three, so it is shared.
local PRIZE_TMS = { local PRIZE_TMS = {
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 }, { kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 }, { kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 }, { kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
} }
local RED_PRIZES = { local RED_PRIZE_WINDOWS = {
{ kind = "mon", species = "ABRA", level = 9, cost = 180 }, {
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 }, { kind = "mon", species = "ABRA", level = 9, cost = 180 },
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 }, { kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 }, { kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 }, },
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 }, {
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3], { kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
},
PRIZE_TMS,
} }
local BLUE_PRIZES = { local BLUE_PRIZE_WINDOWS = {
{ kind = "mon", species = "ABRA", level = 6, cost = 120 }, {
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 }, { kind = "mon", species = "ABRA", level = 6, cost = 120 },
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 }, { kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 }, { kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 }, },
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 }, {
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3], { kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
},
PRIZE_TMS,
}
-- Yellow keeps the three windows but restocks both mon counters
-- (pokeyellow/data/events/prizes.asm, prize_mon_levels.asm)
local YELLOW_PRIZE_WINDOWS = {
{
{ kind = "mon", species = "ABRA", level = 15, cost = 230 },
{ kind = "mon", species = "VULPIX", level = 18, cost = 1000 },
{ kind = "mon", species = "WIGGLYTUFF", level = 22, cost = 2680 },
},
{
{ kind = "mon", species = "SCYTHER", level = 30, cost = 6500 },
{ kind = "mon", species = "PINSIR", level = 30, cost = 6500 },
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
},
PRIZE_TMS,
} }
local function activePrizes() local function prizeWindow(n)
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES local GameVersion = require("src.core.GameVersion")
local windows = RED_PRIZE_WINDOWS
if GameVersion.isBlue() then
windows = BLUE_PRIZE_WINDOWS
elseif GameVersion.isYellow() then
windows = YELLOW_PRIZE_WINDOWS
end
return windows[n]
end end
-- Prize counters (engine/menus/prize_menu.asm CeladonPrizeMenu; the prize -- Prize counters (engine/events/prize_menu.asm CeladonPrizeMenu; the prize
-- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates -- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates
-- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and -- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and
-- with no case prints RequireCoinCaseText and returns without ever opening a -- with no case prints RequireCoinCaseText and returns without ever opening a
-- window; only with the case does it print ExchangeCoinsForPrizesText and then -- window; only with the case does it print ExchangeCoinsForPrizesText and then
-- show the prizes. #194: the port used to open the window unconditionally and -- show the prizes. #194: the port used to open the window unconditionally and
-- skip both text boxes. -- skip both text boxes. wMaxMenuItem is 3, i.e. this window's three prizes
local function prizeCounter(game, ow, npc, done) -- plus the NO THANKS row, and HandlePrizeChoice confirms the pick with
local ListMenu = require("src.ui.ListMenu") -- SoYouWantPrizeText + YesNoChoice before any coins move; every branch then
local Commands = require("src.script.Commands") -- rets out of CeladonPrizeMenu, so one transaction ends the conversation and
local TextBox = require("src.render.TextBox") -- buying again means talking to the counter again (#623).
local t = game.data.text local function prizeCounter(window)
-- IsItemInBag COIN_CASE: without the case, deny and open no window return function(game, ow, npc, done)
-- (COIN_CASE is a numeric count in save.inventory, nil when absent). local ListMenu = require("src.ui.ListMenu")
if not game.save.inventory.COIN_CASE then local Commands = require("src.script.Commands")
local TextBox = require("src.render.TextBox")
local t = game.data.text
-- IsItemInBag COIN_CASE: without the case, deny and open no window
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
if not game.save.inventory.COIN_CASE then
game.stack:push(TextBox.new(game,
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
return
end
-- ExchangeCoinsForPrizesText plays before the prize window opens.
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done)) t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
return function()
end local items = {}
-- ExchangeCoinsForPrizesText plays before the prize window opens. for _, p in ipairs(prizeWindow(window)) do
game.stack:push(TextBox.new(game, local label
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.", if p.kind == "mon" then
function() label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
local items = {} else
for _, p in ipairs(activePrizes()) do label = game.data.items[p.item].name
local label end
if p.kind == "mon" then table.insert(items,
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level) { label = label, right = tostring(p.cost), value = p })
else
label = game.data.items[p.item].name
end end
table.insert(items, -- NoThanksText (data/events/prizes.asm) sits under the three prizes
{ label = label, right = tostring(p.cost), value = p }) table.insert(items, { label = "NO THANKS" })
end local list
local list -- close the window first: every ending in HandlePrizeChoice leaves
list = ListMenu.new(game, "PRIZES (COINS)", items, { -- the menu for good, and the closing line belongs over the map
footer = ("COINS %d"):format(game.save.coins or 0), local function finish(msg)
onChoose = function(item) list:close()
local p = item.value game.stack:push(TextBox.new(game, msg, done))
end
local function buy(p)
if (game.save.coins or 0) < p.cost then if (game.save.coins or 0) < p.cost then
list.footer = "Not enough coins!" finish(t._SorryNeedMoreCoinsText or "Sorry, you need\nmore coins.")
return
end
-- HasEnoughCoins passed, so hand the prize over first and only
-- subtract once it landed: the asm rets before .subtractCoins when
-- the bag is full, or when both the party and every box are full
local roomless = t._OopsYouDontHaveEnoughRoomText
or "Oops! You don't\nhave enough room."
if p.kind == "mon" then
-- no runner here, so give_pokemon reports through ctx.lastCheck
-- and skips the AskName prompt (Commands.give_pokemon)
local ctx = { save = game.save, game = game }
Commands.give_pokemon(ctx, p.species, p.level)
if not ctx.lastCheck then
finish(roomless)
return
end
elseif not require("src.inventory.Bag").add(
game.save, p.item, 1, game.data) then
finish(roomless)
return return
end end
game.save.coins = game.save.coins - p.cost game.save.coins = game.save.coins - p.cost
if p.kind == "mon" then -- no thank-you line: HereYouGoText is unreferenced in the asm,
Commands.give_pokemon({ save = game.save, game = game }, -- which just redraws the coin box (PrintPrizePrice) and returns
p.species, p.level) list:close()
else done()
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1 end
end list = ListMenu.new(game, "PRIZES (COINS)", items, {
list.footer = ("Got it! COINS %d"):format(game.save.coins) footer = ("COINS %d"):format(game.save.coins or 0),
end, onChoose = function(item)
onCancel = done, local p = item.value
}) if not p then -- NO THANKS is the B exit (cp 3 -> .noChoice)
game.stack:push(list) list:close()
end)) done()
return
end
local name = (p.kind == "mon")
and game.data.pokemon[p.species].name
or game.data.items[p.item].name
-- SoYouWantPrizeText names the prize out of wNameBuffer, which
-- is not one of TextBox's RAM tokens, so fill it in here
local ask = (t._SoYouWantPrizeText
or "So, you want\n{RAM:wNameBuffer}?")
:gsub("{RAM:wNameBuffer}", name)
game.stack:push(TextBox.new(game, ask, nil, {
choice = function(yes)
if not yes then
finish(t._OhFineThenText or "Oh, fine then.")
return
end
buy(p)
end,
}))
end,
onCancel = done,
})
game.stack:push(list)
end))
end
end end
M.GAME_CORNER_PRIZE_ROOM = { M.GAME_CORNER_PRIZE_ROOM = {
talk = { -- the three prize counters are bg events talk = { -- the three prize counters are bg events, one window each
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter, TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter(1),
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter, TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter(2),
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter, TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter(3),
}, },
} }
+12 -2
View File
@@ -49,7 +49,7 @@ local function gift(opts)
end end
end) end)
end end
if opts.pre then say(opts.pre, "", give) else give() end if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
end end
end end
@@ -118,11 +118,21 @@ M.CINNABAR_LAB_METRONOME_ROOM = {
}, },
} }
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher; no pre text) -- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
-- text (#775). Like the SilphCo2F worker (#393) that label carries no
-- leading underscore, and on Red it sits outside the extractor's symbol
-- set, so the literal from text/ViridianCity.asm rides along as the
-- fallback; Yellow resolves the ROM string instead.
M.VIRIDIAN_CITY = { M.VIRIDIAN_CITY = {
talk = { talk = {
TEXT_VIRIDIANCITY_FISHER = gift({ TEXT_VIRIDIANCITY_FISHER = gift({
flag = "EVENT_GOT_TM42", item = "TM_DREAM_EATER", flag = "EVENT_GOT_TM42", item = "TM_DREAM_EATER",
pre = "ViridianCityFisherYouCanHaveThisText",
preFallback = "Yawn!\nI must have dozed\voff in the sun."
.. "\fI had this dream\nabout a DROWZEE\veating my dream."
.. "\vWhat's this?\vWhere did this TM\vcome from?"
.. "\fThis is spooky!\nHere, you can\vhave this TM.",
received = "_ViridianCityFisherReceivedTM42Text", received = "_ViridianCityFisherReceivedTM42Text",
explain = "_ViridianCityFisherTM42ExplanationText", explain = "_ViridianCityFisherTM42ExplanationText",
noRoom = "_ViridianCityFisherTM42NoRoomText", noRoom = "_ViridianCityFisherTM42NoRoomText",
+10 -2
View File
@@ -108,8 +108,16 @@ return {
"_ViridianGymGiovanniTM27ExplanationText", "_ViridianGymGiovanniTM27ExplanationText",
} }, } },
-- Silph Co. Giovanni: unlocks the president's Master Ball gift -- Silph Co. Giovanni: unlocks the president's Master Ball gift.
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- SilphCo11FGiovanniStartBattleScript (scripts/SilphCo11F.asm) hands the
-- battle SilphCo10FGiovanniILostAgainText through SaveEndBattleTextPointers,
-- but he has no def_trainers header on 11F, so engageTrainer finds no
-- header.won to give it -- this chain is the port's stand-in for that loss
-- line (#722). The "Blast it all!" speech, the fade and the rockets
-- leaving are SilphCo11FGiovanniAfterBattleScript, ported in M.SILPH_CO_11F
-- (data/scripts/story.lua).
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI",
dialogue = { "_SilphCo10FGiovanniILostAgainText" } },
-- Fighting Dojo Karate Master (scripts/FightingDojo.asm -- Fighting Dojo Karate Master (scripts/FightingDojo.asm
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER, -- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
+8 -2
View File
@@ -24,7 +24,8 @@
-- ViridianCityPostInitialCatchTraining): stepping into (19,9) -- the gap -- ViridianCityPostInitialCatchTraining): stepping into (19,9) -- the gap
-- east of the sleeper's cell -- faces the old man right and the player -- east of the sleeper's cell -- faces the old man right and the player
-- left, prints the apology, and without any choice runs the demo battle -- left, prints the apology, and without any choice runs the demo battle
-- (BATTLE_TYPE_OLD_MAN, RATTATA lvl 5). After it, the same text pointer -- (BATTLE_TYPE_OLD_MAN, RATTATA lvl 5), which he FAILS -- the ball shakes
-- three times and breaks open. After it, the same text pointer
-- now prints _ViridianCityOldManLosingMyTouchText ("That didn't work! -- now prints _ViridianCityOldManLosingMyTouchText ("That didn't work!
-- I must be losing my touch."), the old man walks off (down 6 with the -- I must be losing my touch."), the old man walks off (down 6 with the
-- player on (19,9), right 1 otherwise, Pikachu nudged out of the way -- player on (19,9), right 1 otherwise, Pikachu nudged out of the way
@@ -63,7 +64,12 @@ end
local function oldMan2Rows(game, ow, npc) local function oldMan2Rows(game, ow, npc)
local rows = { local rows = {
{ "show_text", "_ViridianCityOldManHadMyCoffeeNowText" }, { "show_text", "_ViridianCityOldManHadMyCoffeeNowText" },
{ "old_man_demo" }, -- ViridianCityOldManInitialCatchTrainingScript sets
-- EVENT_INITIAL_CATCH_TRAINING before the battle runs, and
-- ItemUseBall's .oldManBattle branch turns that event into anim data
-- $63: three shakes, then the ball breaks open. The losing-my-touch
-- line below only follows a throw that failed (#636).
{ "old_man_demo", "fail" },
{ "set_flag", "EVENT_COMPLETED_CATCH_TRAINING" }, { "set_flag", "EVENT_COMPLETED_CATCH_TRAINING" },
{ "show_text", "_ViridianCityOldManLosingMyTouchText" }, { "show_text", "_ViridianCityOldManLosingMyTouchText" },
} }
+37 -8
View File
@@ -45,6 +45,9 @@ same system picker and install the chosen archive on return.
draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every
frame so `mousepressed` can dispatch clicks; switching tabs mid-import is frame so `mousepressed` can dispatch clicks; switching tabs mid-import is
allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows). allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows).
On **NX**, **Scan again** is stricter: it only starts an import whose SHA-1
matches the open game tab, so a shared `imports/` folder with Red+Yellow
cannot jump Yellow → Red.
- A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the - A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the
Play button, and the SAVE SLOT card in a responsive two-column grid (see Play button, and the SAVE SLOT card in a responsive two-column grid (see
@@ -150,6 +153,17 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears - `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
`options.mods[id]` so a later reinstall starts from the loader's default `options.mods[id]` so a later reinstall starts from the loader's default
(enabled). The mods panel Delete control calls this and re-derives the list. (enabled). The mods panel Delete control calls this and re-derives the list.
- A mod that declares `github` shows its total GitHub downloads (every
release's summed asset `download_count`, from the same cached release
fetch the update check uses) as a highlighted body line like "12,345
downloads across all releases - Released 2024-05-31 - Updated 2026-07-01"
(first and latest `published_at`). Old cache entries written before the
counts existed show no line rather than a wrong zero; a manual check
refreshes them.
- The MODS panel sorts its rows by Name, Popularity (downloads),
Release date (first release), or Last updated, chosen by chips under the
header and persisted in `options.modSort`. Mods without release data
(no `github` field, or a stale cache) sink to the bottom of data sorts.
## Import / Export save ## Import / Export save
@@ -157,9 +171,14 @@ The SAVE FILES card wires a raw Gen1 `.sav` battery image to the save slots
through `src/import/SaveFileIO.lua`, which sits on top of through `src/import/SaveFileIO.lua`, which sits on top of
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`. `src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
- **Import save** is live once the game's ROM is imported (playable). It opens - **Import save** is live once the game's ROM is imported (playable).
a native `.sav` picker (`chooseSav` on desktop; on Android, On desktop it opens a native `.sav` picker (`chooseSav`); on Android,
`love.system.pickFile("sav")``picked_save.sav`, same SAF path as ROMs). `love.system.pickFile("sav")``picked_save.sav`, same SAF path as ROMs.
On **NX (Switch)** there is no picker: copy a `.sav` into
`getSaveDirectory()/imports/saves/<red|blue|yellow>/` via MTP / SD / FTP
(one folder per game), then press **Import save** on that games tab to
ensure the inbox and rescan (same pattern as the ROM `imports/` and mod
`imports/mods/` inboxes). Hidden `._*.sav` AppleDouble sidecars are skipped.
`SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir `SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir
relative name, a dropped LOVE file, or raw bytes), relative name, a dropped LOVE file, or raw bytes),
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
@@ -167,19 +186,29 @@ through `src/import/SaveFileIO.lua`, which sits on top of
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`). writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
The meta stamp is re-stamped off `gen1_import` to the current numeric format The meta stamp is re-stamped off `gen1_import` to the current numeric format
so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT
panel is refreshed with the new slot selected. panel is refreshed with the new slot selected. On **NX**, a successful inbox
import retires the file to `*.sav.imported` and records a content hash in
`imports/saves/<game>/.imported-sha1` so a second **Import save** (or the same
bytes under a new name) does not clone slots; failures leave the original
`.sav`. Only that games folder is scanned.
- **Export save** is live only when the active slot actually holds a save - **Export save** is live only when the active slot actually holds a save
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active (checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
`rawImport`, so this is a zero-filled template export, which is valid), and `rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory writes `exports/<version>/gen1recomp-<version>-<slotId>.sav` under the same
(`love.filesystem.createDirectory("exports")`). On desktop it returns the root `persistFs` writes slots to: the portable game folder when `portable.txt`
absolute path (`love.filesystem.getSaveDirectory()`), which the notice line marks the install, otherwise the save directory (`exports/` and
shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`). `exports/<version>/` are created as needed; #752). On desktop it returns the
absolute path (`SaveData.portableBaseDir()` when portable, else
`love.filesystem.getSaveDirectory()`), which the notice line shows with an
"Open folder" affordance (`love.system.openURL("file://" .. dir)`).
On Android the bytes are also staged as `pending_export.sav` and On Android the bytes are also staged as `pending_export.sav` and
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the `love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
player can save to Downloads / Drive / etc.; on return `export_done.flag` player can save to Downloads / Drive / etc.; on return `export_done.flag`
makes focus show "Save exported." makes focus show "Save exported."
On **NX**, export success sets a notice with the `exports/<game>/` path and an
MTP-oriented hint — no `openURL` / Open folder (pull the file via MTP /
SD / FTP instead).
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the - **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
currently active game tab; when a non-game tab (mods, or the locked yellow currently active game tab; when a non-game tab (mods, or the locked yellow
placeholder) is showing it defaults to red, the always-present first game placeholder) is showing it defaults to red, the always-present first game
+47 -4
View File
@@ -1,10 +1,10 @@
# New features (deliberate additions beyond the original) # New features (deliberate additions beyond the original)
Intentional enhancements this port adds on top of faithful Pokémon Red Intentional enhancements this port adds on top of faithful Pokémon Red, Blue,
behavior. They have no Game Boy equivalent and are kept by design. and Yellow behavior. They have no Game Boy equivalent and are kept by design.
Genuine divergences from the original (things still missing, wrong, or Genuine divergences from the original (things still missing, wrong, or
approximated) live in docs/known-differences.md; faithfully-ported approximated) live in docs/known-differences.md; faithfully-ported behavior is
behavior is in docs/behavior-porting-notes.md. in docs/behavior-porting-notes.md.
## Survey zoom ## Survey zoom
@@ -332,6 +332,17 @@ 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 → 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. **TOUCH PAD** toggles the same on/off flag without leaving a play session.
## 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 ## Translation support
Every string the player can read is now reachable from a mod, so a Every string the player can read is now reachable from a mod, so a
@@ -365,6 +376,28 @@ plus a glyph-page and charmap stub, a naming-grid stub, and a
be packed). `--refresh` re-harvests after an engine update, keeping be packed). `--refresh` re-harvests after an engine update, keeping
existing translations and parking orphaned keys rather than dropping them. 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. See the wiki's Translations guide.
## Save editor (bundled, reachable from the launcher) ## Save editor (bundled, reachable from the launcher)
@@ -447,6 +480,16 @@ 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 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. `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) ## Soft reset (all versions)
Holding A, B, START and SELECT together restarts the game the way flicking Holding A, B, START and SELECT together restarts the game the way flicking
+8 -7
View File
@@ -1,12 +1,11 @@
# What This Port Requires # What This Port Requires
The packaged desktop app requires one user-supplied input on first boot: a The packaged desktop app requires one user-supplied input on first boot: a
canonical 1 MiB US Pokemon Red ROM. canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM.
The importer verifies SHA-1 The importer verifies the SHA-1 for the game (see `src/core/GameVersion.lua`
`ea9bcae617fdf159b045185467ae58b2e4a48b9a`. Other revisions, Virtual for specific hashes). Other revisions and Virtual Console releases are rejected
Console releases, and Pokemon Blue are rejected rather than decoded with rather than decoded with incorrect addresses.
incorrect addresses.
After verification, the app generates its private cache in the LÖVE save After verification, the app generates its private cache in the LÖVE save
directory. It does not keep a copy of the ROM. Later boots use the cache. directory. It does not keep a copy of the ROM. Later boots use the cache.
@@ -15,9 +14,11 @@ Python and Pillow are not required by the packaged app.
## Bundled Metadata ## Bundled Metadata
Assembly removes high-level names and some relationships that the Lua port Assembly removes high-level names and some relationships that the Lua port
needs. `tools/rom_manifest.json` therefore contains: needs. The version-specific files `tools/rom_manifest.json`,
`tools/rom_manifest_blue.json`, and `tools/rom_manifest_yellow.json` therefore
contain:
- the 3,268 ROM symbol addresses actually read by the extractor - the ROM symbol addresses actually read by the extractor
- symbolic IDs and ordering for maps, species, moves, items, and trainers - symbolic IDs and ordering for maps, species, moves, items, and trainers
- source-erased dimensions, image names, and map object integration names - source-erased dimensions, image names, and map object integration names
- hand-ported field/script integration tables - hand-ported field/script integration tables
+204
View File
@@ -0,0 +1,204 @@
# Build the Nintendo Switch NRO — contributor guide
Want to play a release build instead? Download the SD-ready zip and extract it
at your microSD root — see [switch-install.md](switch-install.md).
This guide is for contributors who build Gen1Recomp for Switch from source.
Hardware evidence, MTP operator loops, and deeper notes live in
[switch-development.md](switch-development.md).
> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`;
> issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Hardware
> evidence: **OLED** (author) and **V1 boot** (community). See
> [switch-development.md](switch-development.md) for known limitations.
---
## Prerequisites by OS
All packaging entrypoints are **bash**. On Windows, use Git Bash, MSYS2, or
WSL — not cmd.exe or PowerShell (AD-008).
### macOS / Linux
1. Install [devkitPro pacman](https://devkitpro.org/wiki/devkitPro_pacman).
2. Install Switch tools:
```sh
sudo dkp-pacman -S switch-dev
```
3. Ensure `nacptool` and `elf2nro` are on `PATH` (or under
`$DEVKITPRO/tools/bin` — the fused script prepends that when set).
**Optional:** Install [Docker](https://docs.docker.com/get-docker/) so fused
builds can fall back to the pinned image when native tools are missing.
### Windows (Git Bash / MSYS2 / WSL)
1. Use a bash environment:
- **MSYS2** with the [devkitPro](https://devkitpro.org/wiki/devkitPro_pacman)
packages (preferred for native `nacptool`/`elf2nro`), or
- **WSL** (Ubuntu/etc.) with the Linux pacman flow above, or
- **Git Bash** for `--fetch` / `--loose`; for `--fused` prefer MSYS2 or
WSL if Docker bind-mounts from Git Bash paths misbehave.
2. Install `switch-dev` (or rely on Docker fallback — see below).
3. Do **not** expect `scripts/build_switch.sh` to run under cmd/PowerShell.
### What you must install yourself
| You install | Script does **not** install |
| ----------- | --------------------------- |
| bash, git, zip tooling the repo already expects | — |
| `dkp-pacman` + `switch-dev` (native fused) | `dkp-pacman -S …` |
| Docker (optional fused fallback) | Docker Engine |
| A legal `.gb` ROM (to play) | Any ROM or game data |
---
## Mode glossary
`scripts/build_switch.sh` supports three modes (combinable as noted):
| Mode | What it does |
| ---- | ------------ |
| `--fetch` | Downloads pinned **love.nro** + **love.elf** into `.bazinga/love-nx/11.5-nx1/` and verifies SHA-256 against `scripts/switch/love-nx-11.5-nx1.sha256`. |
| `--loose` | Packs `game.love`, copies pinned `love.nro` → `dist/switch/loose/` as `gen1recomp.nro` + `game.love` side by side. Needs the pin. |
| `--fused` | Builds `dist/switch/gen1recomp-<ver>-switch.nro` (game in romfs) via `nacptool` + `elf2nro`, then packs `dist/switch/gen1recomp-<ver>-switch.zip` (SD-ready tree). Needs the pin + toolchain (native or Docker). GitHub Releases publish the **zip only**. |
Rules:
- `--fetch` alone is fine; combine as `--fetch --loose` or `--fetch --fused`.
- `--loose` and `--fused` are **XOR** — pick one packaging path per run.
- `--version X.Y.Z` sets the NACP / filename version (defaults to short git SHA).
### What `--fetch` downloads
Only the two pinned love-nx release assets (`love.nro`, `love.elf`). It does
**not** install:
- devkitPro / `dkp-pacman` / `switch-dev`
- Docker
- ROMs, saves, or mods
---
## Native tools, then Docker
Fused packaging (`scripts/switch/build_fused.sh`):
1. Prefer native `nacptool` + `elf2nro` on `PATH` (or `$DEVKITPRO/tools/bin`).
2. Else fall back to Docker using:
- `GEN1_DKP_IMAGE` if set, otherwise
- the image named in `scripts/switch/dkp-docker.image` (default
`devkitpro/devkita64:latest`).
If neither native tools nor Docker work, the script exits non-zero with
macOS / Linux / Windows / Docker hints and a pointer to this doc.
---
## Example commands
From the repo root:
```sh
# Download pinned love-nx only
scripts/build_switch.sh --fetch
# Loose pair for iteration (fetch + assemble)
scripts/build_switch.sh --fetch --loose
# Single fused NRO + SD-ready zip for a release-like artifact
scripts/build_switch.sh --fetch --fused --version 0.2.0
```
Outputs land under `dist/switch/` (and `dist/switch/loose/` for loose mode).
The fused path also writes `gen1recomp-<ver>-switch.nro.sha256` and
`gen1recomp-<ver>-switch.zip` (+ `.sha256` sidecar for the zip).
Offline packaging smoke (no network, no nacptool required):
```sh
bash scripts/switch/selftest_build_switch.sh
bash scripts/switch/verify_payload.sh --self-test
```
---
## CI and release
Switch packaging has three automated surfaces (same policy as AD-010):
### Path-gated PR / push CI (`.github/workflows/ci.yml`)
When a change touches Switch packaging / Switch docs / NX runtime paths
(`scripts/build_switch.sh`, `scripts/switch/**`, `docs/switch-*.md`,
`tests/switch_ci_workflows_test.lua`, `tests/switch_transfer_docs_test.lua`,
the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`,
`src/core/GameVersion.lua`, `src/import/CacheFs.lua`, the NX engine suites
`tests/engine/assets_version_fallback_test.lua`,
`tests/engine/nx_generated_guard_test.lua`,
`tests/engine/nx_yellow_boot_test.lua`,
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
or the Switch-related workflow YAML), CI runs:
1. **Offline selftest** on `ubuntu-latest` (forks **and** the canonical repo):
`scripts/switch/selftest_build_switch.sh`,
`scripts/switch/verify_payload.sh --self-test`,
`luajit tests/switch_ci_workflows_test.lua`,
`luajit tests/switch_transfer_docs_test.lua`, and the NX engine suites
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
`luajit tests/engine/nx_generated_guard_test.lua`,
`luajit tests/engine/nx_yellow_boot_test.lua`).
2. **Fused NRO build** only on the **canonical** repository
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
head is that repo (same-repo push/PR). **Fork repository** CI never runs
fused. **Fork → canonical PRs** also skip Switch fused (offline selftest
still runs) so untrusted head code is not executed on the self-hosted Mac;
iOS device build eligibility is unchanged. Fused also waits for a successful
offline selftest before starting on the Mac runner.
3. On successful PR fused builds, a follow-up workflow posts a PR comment
linking the Actions artifact named `gen1recomp-switch-nro`
(comment tag `switch-build-result`; see
`.github/workflows/switch-artifact-comment.yml`).
Unrelated PRs do not burn the self-hosted Mac on Switch packaging.
### Release hard-fail (`.github/workflows/release.yml`)
GitHub Releases always build Switch on the same self-hosted Mac runner as the
other platforms — this is a **hard gate** (no `continue-on-error`):
```sh
scripts/build_switch.sh --fetch --fused --version "<release version>"
```
A Switch packaging failure fails the entire release job. The release asset is
`gen1recomp-<ver>-switch.zip` (SD-ready); the versioned `.nro` stays under
`dist/switch/` for the packer and for PR CI artifacts.
### Runner provisioning
The self-hosted Mac runner must have **native switch-tools** (`nacptool` /
`elf2nro`) **and/or Docker** available. CI and release do not silently run
`dkp-pacman -S`; keep the runner image/host provisioned per this guide.
---
## Limitations / non-goals
These scripts and this guide do **not**:
- Push files to the console (no automated MTP / FTP / SD scripting)
- Bundle or download any Pokémon ROM
- Install `dkp-pacman` / `switch-dev` for you
- Provide `nxlink` / netloader deploy (deferred — see [switch-transfer.md](switch-transfer.md))
- Validate **Applet Mode** — use title override (hold **R**) for full memory
Player install steps: [switch-install.md](switch-install.md).
Manual transfer (MTP / SD / FTP, macOS / Linux / Windows):
[switch-transfer.md](switch-transfer.md).
Hardware depth and evidence: [switch-development.md](switch-development.md),
[switch-hardware-evidence.md](switch-hardware-evidence.md).
+528
View File
@@ -0,0 +1,528 @@
# Nintendo Switch development (love-nx)
> Fused NRO support for issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
> Releases ship `gen1recomp-*-switch.zip` (SD-ready tree). Console copy is
> extract/merge at microSD root; title override required. See
> [Known limitations](#known-limitations-read-before-reviewing).
**Canonical install / build / transfer docs** (start here unless you need hardware depth):
- Players → [switch-install.md](switch-install.md)
- Builders → [switch-build.md](switch-build.md) (`scripts/build_switch.sh --fetch` downloads the pinned love-nx pair)
- Transfer (MTP / SD / FTP on macOS, Linux, Windows) → [switch-transfer.md](switch-transfer.md)
This document covers what landed, known limitations, how hardware was tested,
vendor layout, build/deploy, and the contributor transfer loop (detail lives in
the transfer runbook).
## Acknowledgments
- **Port / love-nx packaging:** [andrewqsantos](https://github.com/andrewqsantos)
- **Community hardware testing** (Switch V1 / Erista boot): [booshankles](https://github.com/booshankles)
- **Method guidance:** [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch) / love-nx
- **Upstream project:** [bryanthaboi](https://github.com/bryanthaboi) / Gen1Recomp
## Status
| Area | State |
| ---- | ----- |
| Feature | **Available** — playable fused NRO path (issue #531) |
| Runtime | Pinned love-nx **`11.5-nx1`** |
| Product artifact | Releases: SD-ready `gen1recomp-*-switch.zip`; local/PR: fused `.nro`; loose `nro`+`game.love` for iteration |
| Hardware | **OLED** validated (author, title override); **V1 / Erista** boot confirmed (community). Lite, docked soak, and Pro Controller matrices welcome |
| Deploy / install | Releases publish SD-ready zip; **extract/merge at microSD root** (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)); no `nxlink` path yet |
| Contributor transfer | Documented for **macOS, Linux, and Windows**; OpenMTP on Mac is one example, not the only contract |
| Network features on NX | Self-update / remote mod download **disabled** (`networkValidated == false`) |
| Community help | Welcome — especially HOS / love-nx packaging and broader hardware coverage |
### What landed
- Detect `NX` via `src/core/Platform.lua` without reusing Android flags
- Writable ROM inbox under `getSaveDirectory()/imports/` + per-tab “Scan again” (SHA-1 match for the open game)
- Joy-Con / gamepad mapping shared by launcher and gameplay (Nintendo A/B UX on NX)
- Launcher L/R tab switch; gameplay L/R game-speed cycle; Select+face display chords
- Focus loss / joystick reconnect recovery; opt-in `switch-debug.txt` diagnostics
- Loose assemble + fused NRO build scripts (`scripts/build_switch.sh`, `scripts/switch/*`)
- Payload gates so ROM / generated cache / saves never enter `game.love`
- Community mod zip inbox at `imports/mods/` (rescan installs; FIND MODS stays network-gated)
- Raw `.sav` inbox at `imports/saves/{red,blue,yellow}/` (**Import save** rescan) + export pull path `exports/{red,blue,yellow}/` (MTP hint; no openURL)
- Hardware evidence for Phase 0 probe, ROM import, naming A/B, save/suspend, fused NRO — see `docs/switch-hardware-evidence.md`
- Path-gated CI selftest + canonical fused PR artifact; release Switch hard-fail
- Save editor pad/touch input (virtual cursor, A click, B close) — see `tools/save-editor/README.md`
- Dynamic display size on NX only: handheld **1280×720**, docked/TV **1920×1080** (`src/core/NxDisplay.lua` + resizable conf so love-nx SDL can follow dock/undock at runtime)
### Known gaps / welcome contributions
- Docked vs handheld soak (≥30 min) and Lite coverage — resolution switch is implemented; long soak still welcome
- Switch Lite and fuller Pro Controller / third-party pad matrices
- Applet Mode remains unsupported by design (title override required)
- `nxlink` / netloader contrib fast-loop (deferred — see [switch-transfer.md](switch-transfer.md))
Transfer runbooks for Linux/Windows (and SD/FTP alternatives) are in
[switch-transfer.md](switch-transfer.md). Community mod zip install OLED smoke
is **pass** — see NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
## Design references (Dusklight)
This work borrowed method — not the native stack — from the [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch), especially [`LESSONS_AND_REUSE.md`](https://github.com/HayatoG/dusklight/blob/main/platforms/switch/LESSONS_AND_REUSE.md):
| Dusklight lesson | How Gen1Recomp applied it |
| ---------------- | ------------------------- |
| Emulators hide Tegra failures | Gate milestones on **real OLED hardware**, not Ryujinx/Yuzu alone |
| Prove the lower layer first | `tools/switch-probe` before full launcher |
| Know which binary ran | Embedded `build-info.json` (commit / love-nx tag) |
| Cap continuous logs | Opt-in diagnostics, ≤1 Hz flush; Lua error log rotation |
| Crash symbolization needs the exact ELF | Keep pinned `love.elf` with the NRO under test |
| Full memory matters | Title override; Applet Mode is not the validation path |
| Do not treat SD FS like desktop POSIX | Lua stays on `love.filesystem`; inbox + MTP for user files |
| Isolate platform code | Capability module instead of Android flag overload |
| NVK / WSI / `audren` stacks | **Not** copied — love-nx already supplies video/audio/input/FS |
The packaging goal matches Dusklights **single self-contained `.nro`**; contributor transfer stays multi-host (not Mac-only).
## Known limitations (read before reviewing)
1. **Transfer is manual and multi-method.** Runtime only needs files under the LÖVE save directory / NRO install folder. Use MTP, direct SD, or FTP per [switch-transfer.md](switch-transfer.md). macOS + OpenMTP is a documented example for OLED evidence — not “Switch requires a Mac.”
2. **Deploy is manual.** There is no automated push to the console and no `nxlink` path yet. Operators build locally, transfer files, then title-override launch.
3. **Hardware coverage.** Author P0/P1 pass rows were recorded on one Switch OLED; Switch V1 boot was confirmed independently. Treat Lite, docked soak, and other hosts as unknown until someone re-runs the checklist.
4. **No ROM/save/mod zip bytes in git.** Legal dumps and third-party mods stay on the console (or local untracked folders).
5. **AppleDouble sidecars** (`._*`) from some MTP clients can break zip/ROM/`.sav` scans — the launcher skips hidden `.*` names (including `._*.sav`); still prefer clean copies.
## How we tested
| Layer | What | Where |
| ----- | ---- | ----- |
| Unit / headless | Platform NX flags, RomImporter inbox, dual-path input, mod zip inbox, save `.sav` inbox, display chords, payload/self-tests | `tests/*`, `scripts/test.sh` |
| Switch CI / packaging | Path-gated offline selftest (`selftest_build_switch.sh`, `verify_payload.sh --self-test`, `switch_ci_workflows_test.lua`); canonical fused PR artifact | `.github/workflows/ci.yml`, [switch-build.md](switch-build.md) § CI and release |
| Probe on hardware | `getOS()==NX`, 1280×720, save path, Joy-Con events | `tools/switch-probe` → OLED |
| Integration on hardware | MTP inbox ROM import, Play Red/Blue, naming A/B, quit/reopen save, suspend×10, reboot, fused NRO alone + NRO-only update | `docs/switch-hardware-evidence.md` |
| Community hardware | Switch V1 / Erista boot with prebuilt NRO | [booshankles](https://github.com/booshankles) — see evidence log |
| Known gaps | Docked soak, ≥30 min long-play, Lite, automated/`nxlink` deploy | Matrix deferred / absent rows |
Operator evidence must stay in `docs/switch-hardware-evidence.md`. **Do not invent passes** for hardware not run.
## love-nx 11.5-nx1 (pinned)
**Tag:** [11.5-nx1](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1)
**Local layout (not committed):**
```text
.bazinga/love-nx/11.5-nx1/
├── love.nro # homebrew launcher binary (loose mode: copied to gen1recomp.nro)
└── love.elf # required for fused NRO builds (devkitPro nacptool/elf2nro)
```
**Manifest:** `scripts/switch/love-nx-11.5-nx1.sha256` lists expected artifact names and SHA-256 checksums. Checksums are filled when binaries are fetched (`TBD_*` placeholders until then).
### Fetch instructions
Preferred (automated checksum verify):
```bash
scripts/build_switch.sh --fetch
```
That downloads pinned `love.nro` + `love.elf` into `.bazinga/love-nx/11.5-nx1/`
and checks them against `scripts/switch/love-nx-11.5-nx1.sha256`. See
[switch-build.md](switch-build.md) for the full mode glossary.
Manual fallback:
1. Open the [11.5-nx1 release](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1) and download `love.nro` and `love.elf`.
2. Create the directory: `mkdir -p .bazinga/love-nx/11.5-nx1`
3. Move both files into that directory.
4. Confirm checksums match the manifest:
```bash
shasum -a 256 .bazinga/love-nx/11.5-nx1/love.nro \
.bazinga/love-nx/11.5-nx1/love.elf
```
**Never commit** love-nx binaries, ROM dumps, or generated cache into git. The repo `.gitignore` excludes `.bazinga/` (vendor cache) and `/dist/` (build output).
## Loose-mode dist layout
Development builds place `gen1recomp.nro` and `game.love` side by side:
```text
dist/switch/loose/
├── gen1recomp.nro
└── game.love
```
Assemble with:
```bash
scripts/build_switch.sh --loose
```
(See `scripts/switch/assemble_loose.sh` for the underlying copy + checksum step.)
## Transfer & deploy (current contributor loop)
Detail for **macOS / Linux / Windows** and **MTP / SD / FTP** lives in
[switch-transfer.md](switch-transfer.md). Summary:
| Layer | Intent |
| ----- | ------ |
| **Runtime / players** | Extract the release zip at microSD root (`switch/gen1recomp/`) and land ROMs/mods under the save-dir inboxes. The game does not hard-depend on OpenMTP or macOS. |
| **Contributor loop** | Manual copy via MTP (DBI responder), direct SD (Hekate UMS / reader), or FTP. Fully manual — no CI deploy, no `nxlink` yet. |
The Mac + OpenMTP steps that remain below are the **OLED evidence reproduction** path; prefer the transfer runbook for day-to-day contrib on other hosts.
**Still avoided for routine evidence** (keeps SD handling honest):
- Treating `nxlink` / netloader as the release deploy story (deferred)
- DBI `MicroSD install` / `NAND install` / NSP-style virtual folders for the `.love`/`.nro` pair
If MTP fails: check cable, USB port, DBI state, and that only one MTP client holds the device — then retry or switch to SD/FTP. Do not silently rewrite evidence using an untested path and claim parity with recorded SHA-256 round-trips.
### Manual deploy checklist (today)
1. Build on the contributor host (`scripts/build_switch.sh --loose` or fused).
2. Close Gen1Recomp on the Switch; open DBI → `Run MTP responder`.
3. Copy artifacts with your MTP client into `1: SD Card/switch/gen1recomp/` (and ROMs/mods into the save-dir inboxes when needed).
4. Wait for the transfer queue; refresh; optionally round-trip SHA-256 on first artifacts of a type.
5. Exit MTP; launch via **title override** (hold **R** on a title → hbmenu, not Applet Mode).
## OpenMTP + DBI transfer (loose build, Mac evidence example)
Full multi-OS / multi-method steps: [switch-transfer.md](switch-transfer.md).
The numbered Mac loop below reproduces the OLED evidence path.
### On the Switch
1. Close Gen1Recomp if it is running.
2. Open **DBI** from hbmenu.
3. Select **`Run MTP responder`** (DBI documents `X` on the main screen).
4. Keep DBI on that screen for the entire transfer.
5. Connect the Switch to the Mac with a USB-C data cable.
### On the Mac
1. Close any other MTP clients.
2. Open **OpenMTP** and select the DBI device.
3. In the remote pane, open **`1: SD Card`**.
4. Navigate to **`switch/`** and create **`gen1recomp/`** if needed.
5. Enter **`1: SD Card/switch/gen1recomp/`**.
6. Drag from the local pane:
```text
dist/switch/loose/gen1recomp.nro
dist/switch/loose/game.love
```
7. Wait for the OpenMTP queue to finish completely.
8. Refresh the remote listing and confirm file sizes match the local files.
9. On the Switch, exit MTP responder normally in DBI before launching the app.
Expected layout on SD:
```text
1: SD Card/
└── switch/
└── gen1recomp/
├── gen1recomp.nro
└── game.love
```
## Round-trip SHA-256 verification
For the **first deploy** of each artifact type (loose pair, later fused NRO), verify MTP integrity:
1. **Before send** — record local hashes:
```bash
shasum -a 256 dist/switch/loose/gen1recomp.nro \
dist/switch/loose/game.love
```
2. **After send** — in OpenMTP, copy the same files from `1: SD Card/switch/gen1recomp/` back to an empty local folder, e.g. `dist/switch/mtp-roundtrip/`.
3. **Compare** round-trip hashes:
```bash
shasum -a 256 dist/switch/mtp-roundtrip/gen1recomp.nro \
dist/switch/mtp-roundtrip/game.love
```
4. Local pre-send and round-trip hashes **must match**. Record results in the test report template below.
Repeat whenever a cable glitch or interrupted transfer is suspected.
## Title override launch (full memory)
Applet Mode is **not** the primary validation path. Use **title override** so hbmenu runs with full memory:
1. Confirm the OpenMTP transfer queue finished.
2. Exit MTP responder in DBI; disconnect USB if desired.
3. Hold **`R`** while launching any legitimately installed title.
4. Keep holding until **hbmenu** appears.
5. Confirm hbmenu does **not** show **Applet Mode**.
6. Launch **`gen1recomp`** (or the probe NRO during Phase 0).
Album / applet launches are only useful to document applet-specific limitations; P0/P1 gates use title override.
## Phase 0 hardware checklist
Complete **in order** on OLED hardware. Operator fills evidence fields — leave blank until tested.
| Step | Action | Pass | Evidence / notes |
| ---- | ------ | ---- | ---------------- |
| P0-0a | Fetch love-nx 11.5-nx1; record manifest SHA-256 | yes | See `scripts/switch/love-nx-11.5-nx1.sha256` |
| P0-0b | Build `switch-probe.love` per `tools/switch-probe/README.md` | yes | |
| P0-0c | Assemble loose probe (`game.love` = probe) to `dist/switch/loose/` | yes | |
| P0-0d | MTP deploy to `1: SD Card/switch/gen1recomp/`; round-trip SHA-256 | yes | nro `8290ac15…5918f5`; love `9f198637…fa2e34f` |
| P0-0e | Title override → probe boots; `getOS()` shows `NX` | yes | `getOS()`=`NX`, `love._os`=`NX` |
| P0-0f | Probe lists 1280×720 (or documented dims), save path, gamepad/touch log | yes | save `sdmc:/switch/gen1recomp/switch-probe`; Joy-Con Y→#3 X→#4 |
| P0-1a | Replace `game.love` with unpatched Gen1Recomp build | yes | feat/switch-nx inbox build |
| P0-1b | MTP replace `game.love` only; round-trip SHA-256 | yes | |
| P0-1c | Title override → launcher reaches import screen | yes | |
| P0-1d | Joy-Con: can navigate launcher (no touch-only) | yes | Full report: `docs/switch-hardware-evidence.md` |
**Operator:** Andrew **Date:** 2026-08-01 **Console:** Switch OLED only
**Deploy:** manual Mac + OpenMTP + DBI MTP (not automated)
**love-nx tag:** 11.5-nx1 **gen1recomp commit:** `df7cea4`
## Phase 0 test report template
Copy this block into your hardware notes or PR evidence. **Do not commit ROM files or ROM hashes of private dumps.**
```markdown
## Switch Phase 0 — hardware report
- Operator:
- Date:
- Console model:
- Atmosphère / HOS version:
- gen1recomp commit:
- love-nx tag: 11.5-nx1
- love.nro SHA-256 (local):
- game.love SHA-256 (local, pre-send):
- MTP round-trip SHA-256 (gen1recomp.nro):
- MTP round-trip SHA-256 (game.love):
- Title override used: yes / no
- Applet Mode observed: yes / no (should be no for P0)
- Probe getOS():
- Probe dimensions:
- Probe save directory shown:
- Gamepad events logged: yes / no
- Touch events logged: yes / no
- Unpatched launcher boot: pass / fail
- Joy-Con launcher navigation: pass / fail / not tested
- Notes:
```
## Fast dev loop (loose mode)
While iterating on Lua/assets:
1. Edit on Mac; run `scripts/test.sh --quick`.
2. Rebuild `.bazinga/work/game.love` (`scripts/build.sh mac --no-notarize` or project pack step).
3. Close Gen1Recomp on Switch.
4. DBI → `Run MTP responder`.
5. OpenMTP → `1: SD Card/switch/gen1recomp/`.
6. Replace **only** `game.love`; wait for queue + refresh listing.
7. Exit MTP responder; launch via title override.
8. Keep `gen1recomp.nro` unchanged until the love-nx pin changes.
```bash
scripts/test.sh --quick
scripts/build.sh mac --no-notarize
scripts/build_switch.sh --loose
shasum -a 256 .bazinga/work/game.love
```
## Controller input mapping (NX)
Measured on Switch OLED (`feat/switch-nx`, love-nx `11.5-nx1`, 1280×720). Both `joystickpressed` and `gamepadpressed` fire for Joy-Con; prefer the gamepad path when `joystick:isGamepad()` is true.
| Path | Control | Mapping |
| ---- | ------- | ------- |
| `gamepadpressed` | D-pad / left stick | move |
| `gamepadpressed` | SDL `a` / `b` on **NX** | swapped via `NX_GAMEPAD_BINDINGS`: physical **A** (east) = GB A confirm, physical **B** (south) = GB B cancel |
| `gamepadpressed` | SDL `a` / `b` on desktop | identity (SDL south = GB A) |
| `gamepadpressed` | `start` / `back` | Start / Select (+ / ) |
| `gamepadpressed` | Right / left shoulder (no Select) | Cycle game speed up / down (same as PC hotkey `1` / speed-down path) |
| `joystickpressed` (raw) | only if **not** `isGamepad()` | face/menu fallback |
| `joystickpressed` (raw) | `#1` / `#2` on NX | Nintendo B / A → GB B / A |
| `joystickpressed` (raw) | `#9` / `#10` | Select / Start ( / +) |
**Nintendo UX on Switch:** physical A confirms, physical B cancels (explicit NX remap of SDL face labels).
**Launcher extras** (`RomImporter`): physical **A** clicks at the virtual cursor; **L** / **R** switch tabs; **Start** / **Select** start Play when a ROM is ready (else open Choose ROM). D-pad / left stick move the virtual cursor.
**Dual-path rule:** love-nx emits both `gamepadpressed` and `joystickpressed` for Joy-Con. When `joystick:isGamepad()` is true, Input and RomImporter **ignore raw** face/menu so NamingScreen does not see A+B in one frame. `NamingScreen` also prefers A over B if both edges still fire.
Implementation: `src/core/GamepadMap.lua` (`NX_RAW_*`, `ignoreRawForJoystick`, `displayChordDigit`), `src/core/Game.lua` (shoulder speed), `src/import/RomImporter.lua` (launcher tabs). Launcher and gameplay share the same converter.
## ROM inbox (NX)
Legal dumps land in a shared MTP inbox; **Scan again** is tab-scoped:
| Item | Value |
| ---- | ----- |
| Save-relative path | `imports/` (also accepts loose `.gb`/`.gbc` at the save-dir root) |
| MTP destination | `1: SD Card/<save identity>/imports/` (see launcher notice for the live `getSaveDirectory()` path) |
| Candidates | `*.gb` / `*.gbc` (hidden `.*` AppleDouble names skipped) |
| Rescan | Game tab → **Scan again** — imports only the dump whose SHA-1 matches that tab (`GameVersion.forSha1`). Other known dumps stay for their own tabs |
| Already ready | Same SHA already imported → “No new ROM found.” |
Players may drop Red, Blue, and Yellow into the same folder. Opening Yellow and pressing **Scan again** must not start a Red import.
## Mod zip inbox (NX)
Community mods install from a **separate** MTP inbox (not mixed into the ROM `imports/` scan):
| Item | Value |
| ---- | ----- |
| Save-relative path | `imports/mods/` |
| MTP destination | `1: SD Card/<save identity>/imports/mods/` (see launcher notice for the live `getSaveDirectory()` path) |
| Candidates | `*.zip` only |
| Rescan | MODS tab → **Scan again** (installs each zip via `LauncherMods.installZip`; source zips are retained on success and failure) |
| FIND MODS | Remains network-gated / hidden on NX (`networkValidated == false`) |
Do **not** commit third-party mod zip bytes into git. Drop the zip over MTP, rescan, enable in MODS, then Play.
**MTP tip (esp. macOS clients):** OpenMTP/Finder often creates AppleDouble sidecars named `._Something.zip` / `._cart.gb` / `._foo.sav`. Those are not real archives, ROMs, or saves — the launcher ignores hidden `.*` names under `imports/`, `imports/mods/`, and `imports/saves/<game>/`. If install still fails with “could not be opened” / “not a zip file”, delete any `._*` under the inbox and confirm the real zip starts with the `PK` magic (re-copy the release asset if unsure). This is a host-side annoyance of the current manual MTP loop, not something players should need forever.
Drop any community release `.zip` into `imports/mods/`, rescan, enable.
Player-facing install steps: [switch-install.md](switch-install.md#community-mods).
Mods own their OPTIONS / rebinds — do not duplicate third-party control tables here.
## Save `.sav` inbox (NX)
Raw Gen1 battery images use a **separate** MTP inbox (not mixed into ROM `imports/` or mod `imports/mods/`):
| Item | Value |
| ---- | ----- |
| Save-relative path | `imports/saves/red/`, `imports/saves/blue/`, `imports/saves/yellow/` |
| MTP destination | `1: SD Card/<save identity>/imports/saves/<game>/` (see launcher notice for the live `getSaveDirectory()` path) |
| Candidates | non-hidden `*.sav` only in **that games** folder |
| Rescan | SAVE FILES → **Import save** on the matching game tab (scans only that folder) |
| After success | Retire to `*.sav.imported` + append content hash to `imports/saves/<game>/.imported-sha1` |
| Exports | **Export save** writes under `exports/<game>/gen1recomp-<game>-<slot>.sav`; NX shows an MTP path notice (no `openURL`) |
Do **not** commit `.sav` bytes into git. Drop the file into the matching game folder over MTP, press **Import save** on that tab, then play. Pull exports from `exports/<game>/`.
**MTP tip:** the same AppleDouble `._*.sav` rule applies — see the mod inbox tip above.
## Joy-Con display chords (Select + face)
PC digit hotkeys for COLORS / TILT / GBC FX / pipelines have Joy-Con equivalents. Hold **Select** (`back` / ) and press a face/shoulder button; the engine runs the same path as `Game:keypressed` for that digit (including `writeOptions` / Pipelines parity).
| Chord (Nintendo UX) | Engine key | Stock engine effect |
| ------------------- | ---------- | ------------------- |
| Select + **A** | `2` | COLORS cycle |
| Select + **B** | `3` | TILT / perspective |
| Select + **Y** | `5` | GBC FX |
| Select + **X** | `6` | Mod pipeline hotkey (if registered) |
| Select + **L** (left shoulder) | `7` | Mod pipeline hotkey (if registered) |
Keys `2` / `3` / `4` / `5` are claimed by the engine before mod pipeline hotkeys run, so a community mod cannot rebind those digits through `Pipelines.hotkey`. Mods that need their own controls should use OPTIONS rows or unclaimed hotkeys.
Without Select held, face buttons keep normal GB A/B gameplay mapping (no accidental color/tilt cycles). The **Options** menu remains available for the same settings — chords are optional shortcuts, not the only path.
On NX, A/B chords resolve through the Nintendo UX face remap so physical **A** → key `2` and physical **B** → key `3` match this table.
**OPTIONS → PERFORMANCE** clamps the ports own extras (TILT / GBC FX / survey ZOOM) and can cap FPS — useful on weaker handheld budgets. Details: [new-features.md — Performance tier](new-features.md#performance-tier-low-end-devices).
Community mod zip install smoke (MODS inbox + Play): NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
**Opt-in diagnostics:** create an empty `switch-debug.txt` in the save directory; events flush to `switch.log` at ≤1 Hz with build identity (no ROM/save bytes).
**NX asset probe (always on Play):** every Switch Play writes `nx-asset-probe.log` in the save directory (`pokemon-love2d/`). It lists whether `assets/generated/…` vs `yellow|blue/assets/generated/…` exist, what `Assets.resolve` returns, and whether `newImage` / `newImageData` open — for Yellow/Blue blank-sprite triage. No ROM bytes.
**Blue/Yellow cache overlay (NX):** fused love-nx cannot reliably mount `yellow|blue/assets/generated` onto the un-prefixed path, so `src/core/NxAssetOverlay.lua` wraps EVERY read-side love API that accepts a filesystem path (`filesystem.read/load/lines/newFileData/getInfo`, `graphics.newImage/newFont`, `image.newImageData`, `audio.newSource`, `sound.newSoundData`, `font.newFontData`) once at boot — only when `Platform.isNX()`. Covering the whole read surface (not just the loaders the boot needs today) keeps future states and mods inside the fallback automatically; write-side functions stay stock. Core code must NOT call love loaders on literal `assets/generated` paths (enforced by `tests/engine/nx_generated_guard_test.lua`); the chip-audio worker is a separate Lua state and gets the prefix explicitly via `audio.programPrefix` from `ChipAudio.slimAudio`.
**Hardware re-test:** T16 **pass** @ `2699c9a` (naming A=confirm / B=cancel). T19 **pass** (quit/reopen, suspend×10, reboot) — operator 2026-08-01.
**Suspend/resume audio:** after resume, chip music is stopped to avoid duplicate streams; confirm on hardware during P0-09/10 (T19).
## Lua error log (save directory)
On any uncaught Lua error, Gen1Recomp appends a redacted trace to `lua-error.log` in the LÖVE save directory (`love.filesystem.getSaveDirectory()`). The on-screen error overlay includes a hint pointing at that file. Logs rotate to `lua-error.log.1` when the active file exceeds 32 KiB. ROM/save bytes and non-printable data are stripped — never commit or share logs that might contain private paths without reviewing them first.
## Native crash triage (love-nx / Atmosphère)
love-nx native faults land under the consoles `crash_reports/` folder on SD (reachable via the same manual MTP workflow used for game deploys).
1. **Collect** — DBI → `Run MTP responder`; copy `sdmc:/crash_reports/*.bin` (or the dated subfolder) to the contributor host. Prefer keeping the microSD in-console for routine pulls.
2. **Redact** — delete any attached screenshots or notes that mention ROM filenames, save paths, or private hashes before sharing logs publicly.
3. **Symbolize** — use the **pinned** `love.elf` from `.bazinga/love-nx/11.5-nx1/` that matches `build-info.json` / `scripts/switch/love-nx-11.5-nx1.sha256`. Never use a “latest” download.
```bash
# Example: aarch64-none-elf-addr2line from devkitPro
aarch64-none-elf-addr2line -e .bazinga/love-nx/11.5-nx1/love.elf -f -C 0xADDRESS_FROM_CRASH_REPORT
```
4. **Correlate** — compare `gitCommit` / `loveNxTag` from embedded `build-info.json` with the operators hardware notes.
If `addr2line` cannot resolve an address, archive the crash `.bin` with the exact `love.elf` SHA-256 used for the build — addresses are only meaningful against that ELF.
## P0 / P1 hardware matrix (ADR §9)
Operator evidence lives in `docs/switch-hardware-evidence.md`. **Do not invent passes** for rows that require hardware not yet run.
| ID | Requirement | Status | Evidence |
| -- | ----------- | ------ | -------- |
| P0-0af | love-nx pin, probe, MTP, title override | **pass** | Phase 0 checklist above; T4 |
| P0-1ad | Unpatched launcher boot + Joy-Con nav | **pass** | T4 / `docs/switch-hardware-evidence.md` |
| P0-02 | MTP inbox import path shown | **pass** | T12 |
| P0-03 | Rescan imports ROM | **pass** | T12 |
| P0-04 | Canonical hash routes version | **pass** | T12 |
| P0-05 | Source dump retained in inbox | **pass** | T12 |
| P0-06 | Play reaches game after import | **pass** | T12 |
| P0-07 | Joy-Con launcher navigation | **pass** | T16 @ `2699c9a` |
| P0-08 | Joy-Con gameplay (incl. naming A/B) | **pass** | T16 @ `2699c9a` |
| P0-09 | Save survives quit + reopen | **pass** | T19 |
| P0-10 | ≥10 suspend cycles, no stuck input/dup audio | **pass** | T19 (operator 2026-08-01) |
| P0-12 | Fused NRO boots without adjacent `game.love` | **pass** | T24 — `docs/switch-hardware-evidence.md` |
| P0-14 | Fused NRO MTP round-trip SHA-256 | **pass** | T24 — first artifact `b019e2e8…` @ `6fb5602` (redeploy after Blue fix) |
| P0-15 | Replace NRO only; saves persist | **pass** | T24 — operator NRO-only update keeps saves |
| P1-01 | Docked vs handheld spot-check | **deferred** | Code: `NxDisplay` 720p↔1080p; OLED dock soak not recorded yet |
| P1-02 | Applet Mode documented unsupported | **pass** | Title override required; Album path not validated |
| P1-03 | Long-play soak (≥30 min) | **deferred** | No soak session recorded |
| P1-04 | Reboot persistence | **pass** | T19 |
| P1-05 | Audio resume after suspend | **pass** | T19 (no dup audio reported) |
| — | Switch V1 / Erista boot | **pass** (boot) | Community — [booshankles](https://github.com/booshankles); see evidence log |
| — | Switch Lite / docked soak | **untested** / **deferred** | Welcome contributions |
| — | Automated / `nxlink` deploy | **absent** | Manual MTP / SD / FTP only (AD-009) |
| — | Multi-OS transfer runbooks | **pass** | [switch-transfer.md](switch-transfer.md) |
| — | Community mod zip OLED smoke (NXMOD-12) | **pass** | `docs/switch-hardware-evidence.md` |
## Review guidance
Maintainers may review as one PR or split later. Suggested slices (optional):
Each slice should declare: **no ROM/save bytes committed**, **love-nx pin with manifest checksums**, **hardware-tested rows listed with linked evidence**, **Applet Mode unsupported**, **network/updater disabled on NX**, **deploy still manual** (MTP / SD / FTP; no nxlink yet), **OpenMTP is one example not the sole contract**.
### Slice 1 — Platform + import (`platform/import`)
- `src/core/Platform.lua`, `conf.lua` NX branch
- `src/import/RomImporter.lua` (NX flags, inbox, scan, shell/updater gates)
- Tests: `tests/engine/platform_nx_*`, `tests/engine/rom_importer_nx_*` (ROM-free T2)
- Docs: inbox/MTP import sections only
### Slice 2 — Input + lifecycle (`input/lifecycle`)
- `src/core/GamepadMap.lua`, `Input.lua`, `main.lua` focus/joystick hooks
- `src/debug/SwitchDiagnostics.lua` (opt-in probe + error log)
- Tests: input/diagnostics suites
- Docs: controller mapping, suspend/audio notes
### Slice 3 — Build + docs (`build/docs`)
- `scripts/pack_love.sh`, `scripts/build_switch.sh`, `scripts/switch/*`
- `assets/switch/icon.jpg`, `docs/switch-development.md`, hardware evidence templates
- Gates: `pack_love.sh --dry-run`, `verify_payload.sh --self-test`, fused build script (devkitPro host)
**Pre-merge checklist:**
- [ ] Manifest `scripts/switch/love-nx-11.5-nx1.sha256` filled; binaries not in git
- [ ] `verify_payload.sh` rejects generated cache / ROM / `.sav` / `.bak`
- [ ] P0 matrix rows marked pass only with linked hardware evidence
- [x] Fused NRO P0-12/14/15 pass with T24 evidence (`docs/switch-hardware-evidence.md`)
- [ ] Updater / remote mod download hidden on NX (`networkValidated == false`)
+187
View File
@@ -0,0 +1,187 @@
# Switch hardware evidence (Phase 0 + import + input)
> **Hardware evidence log.** Author passes below were recorded on **one
> Nintendo Switch OLED** with a **manual** Mac → DBI MTP deploy loop. A
> separate community row records Switch V1 / Erista boot. These rows do
> **not** claim Lite, docked soak, or automated install. See
> `docs/switch-development.md` for status and limitations.
**love-nx:** `11.5-nx1`
**Author console:** Switch OLED
**Deploy method (author):** manual OpenMTP + DBI `Run MTP responder` (no CI / no nxlink)
**Operator (author rows):** Andrew ([andrewqsantos](https://github.com/andrewqsantos))
**Date (author rows):** 2026-08-01
Do **not** commit ROM dumps or private dump hashes. Do **not** mark a row **pass** without hardware notes for that row.
---
## Community — Switch V1 / Erista boot — pass (boot)
| Field | Value |
| ----- | ----- |
| Console | Nintendo Switch V1 (Erista) |
| Check | Prebuilt fused NRO boots under title override |
| Tester | [booshankles](https://github.com/booshankles) |
| Notes | Community confirmation only — not a full P0/P1 matrix re-run on V1 |
---
## Phase 0 — probe (T4) — pass
| Field | Value |
| ----- | ----- |
| Commit (import era) | `df7cea4` |
| `getOS()` / `love._os` | `NX` |
| Dimensions | 1280×720 |
| Save (probe) | `sdmc:/switch/gen1recomp/switch-probe` |
| Joy-Con | `joystickpressed` + `gamepadpressed` (Y→`#3`, X→`#4`) |
| Artifact | SHA-256 |
| -------- | ------- |
| `gen1recomp.nro` | `8290ac153d4c630e48c9b26ef9123f5204ed8ee0cef3042511707b5b645918f5` |
---
## T12 — Red import + Play — pass
Inbox MTP → “Scan again” → Play; Joy-Con launcher/gameplay (not touch-only).
---
# T16 — Joy-Con launcher + gameplay — pass (naming re-verify)
### Round 1 @ `7504753` — partial
| Check | Result |
| ----- | ------ |
| Launcher / overworld (Joy-Con only) | **pass** |
| Naming player/rival | **fail** (dual-path a+b; see below) |
| Touch required | **no** |
| `game.love` SHA-256 | `bd3a35461bf453c1f0465a5a289421aef3b5c72d3bf1f8d76e86231256829e0e` |
### Naming failure (root cause) — fixed in `efd81d8` + `2699c9a`
- love-nx fires **`gamepadpressed` + `joystickpressed` on the same physical press**.
- `NamingScreen` tested `wasPressed("b")` before `"a"` → if both true in one frame, always deletes.
- Dual-path fix: ignore raw when `isGamepad()` (`efd81d8`).
- SDL-only UX then had physical B confirm / A erase; NX face remap (`2699c9a`) restores Nintendo A=confirm / B=cancel.
### Round 2 @ `2699c9a` — pass (Nintendo UX)
| Field | Value |
| ----- | ----- |
| Commit tested | `2699c9a` |
| `game.love` SHA-256 | `a208b21e1f30b00e2e8c6fa6efe14f0e06d1db0ae1e50b810b16d9fb852926bc` |
| Touch required | **no** |
| Check | Result |
| ----- | ------ |
| Naming — player | **pass** — physical **A** confirms letter, **B** cancels/erases |
| Naming — rival | **pass** (same) |
| Launcher / overworld (prior round) | **pass** (unchanged mapping for d-pad/stick) |
T16 hardware gate: **closed**.
---
## T19 — save / suspend — pass
| Check | Result |
| ----- | ------ |
| Save in-game → full quit → title-override reopen → load save | **pass** (@ `7504753` / retained) |
| Suspend/resume ×10 (launcher / gameplay / mixed) | **pass** (operator 2026-08-01) |
| Full console reboot persistence | **pass** (operator 2026-08-01) |
T19 hardware gate: **closed**. No stuck input, duplicate audio, or crash reported.
---
## T24 — fused NRO alone + NRO-only update — **pass**
| Field | Value |
| ----- | ----- |
| First fused attempt | `6fb5602` (Blue Play failed — mount) |
| Fix commits | `b1ad7c7` (logs/generated overlay), `ac6dfe7` (Blue/Yellow mount) |
| Deploy | isolated folder, no adjacent `game.love` |
| Boot fused | **pass** |
| ROM import | **pass** |
| Play **Red** | **pass** |
| Play **Blue** (after `ac6dfe7`) | **pass** (operator 2026-08-01) |
| NRO-only replace | **pass** — saves retained; app still boots/plays |
| Touch required | no |
T24 hardware gate: **closed**.
---
## SWBLD — `build_switch.sh --fetch --fused` + install path — **pass**
Operator smoke for the switch-build-pipeline packaging CLI (closes matrix-deferred happy paths from validation).
| Field | Value |
| ----- | ----- |
| Command | `scripts/build_switch.sh --fetch --fused --version 0.0.0-test` |
| Host | macOS + native switch-tools (or Docker fallback if used) |
| Commit / build-info | `9147a64` (`gitCommit` in build-info) |
| love-nx | `11.5-nx1` (manifest checksums match) |
| Artifact | `dist/switch/gen1recomp-0.0.0-test-switch.nro` |
| NRO SHA-256 | `210efb884a8d27443dc1c64ed8f071b0f862d8d0c9b140ad8185093c4e4027db` |
| Install doc | `docs/switch-install.md` — at the time of this row: copy NRO under `sdmc:/switch/gen1recomp/` (releases now ship an SD-ready zip; same folder) |
| Console | Switch OLED |
| Operator | Andrew |
| Date | 2026-08-01 |
| Check | Result |
| ----- | ------ |
| `--fetch` + `--fused` produce NRO + `.sha256` | **pass** |
| Copy NRO to SD folder per install doc | **pass** (operator) |
| Title-override launch / play | treated as prior T24 path; this row records **packaging + deploy to folder** success |
SWBLD packaging smoke: **closed** for Mac fused build + file-to-SD install step.
---
## NXMOD-12 — Community mod zip OLED smoke — **pass**
Closed from existing OLED photo evidence on issue
[#531](https://github.com/bryanthaboi/gen1recomp/issues/531) (operator comment
with launcher MODS + overworld shots). Photos live on the orphan branch
[`switch-oled-photos`](https://github.com/andrewqsantos/gen1recomp/tree/switch-oled-photos)
of the operator fork — **not** committed to this repo. Do **not** commit
third-party mod `.zip` bytes. Community mods own their OPTIONS / rebinds;
this entry only proves the MODS inbox + Play path on OLED.
| Field | Value |
| ----- | ----- |
| Status | **pass** |
| gen1recomp commit | evidence era on `feat/switch-nx` (see #531); packaging pin love-nx `11.5-nx1` |
| love-nx tag | `11.5-nx1` |
| Console | Switch OLED |
| Mod | community release `.zip` (not vendored; not named here) |
| Zip committed to git? | **no** |
| Photo evidence | [#531 comment](https://github.com/bryanthaboi/gen1recomp/issues/531) — MODS tab + overworld |
| MODS tab photo | https://raw.githubusercontent.com/andrewqsantos/gen1recomp/switch-oled-photos/IMG_1766.jpg |
| Overworld photo | https://raw.githubusercontent.com/andrewqsantos/gen1recomp/switch-oled-photos/IMG_1771.jpg |
| Operator | Andrew |
| Date | 2026-08-01 |
### Checklist
| Step | Pass / fail / pending | Notes |
| ---- | --------------------- | ----- |
| MTP zip into save `imports/mods/` | **pass** | Photo evidence + prior inbox path |
| MODS → Scan again → mod listed | **pass** | IMG_1766 — community mod installed |
| Enable mod + Play Red boots without crash | **pass** | Overworld / Pallet / Oak lab photos on #531 |
| Overworld Select+A → visible colors change | **pass** | Stock COLORS chord path exercised |
| Overworld Select+B → visible tilt/perspective change | **pass** | Stock TILT chord path exercised (IMG_1771) |
### Evidence notes
```text
Operator: Andrew
Date: 2026-08-01
Commit tested: feat/switch-nx era documented on issue #531
Pass / fail summary: PASS — MODS zip install + Play on Switch OLED
Photo branch: andrewqsantos/gen1recomp@switch-oled-photos
```
+164
View File
@@ -0,0 +1,164 @@
# Install Gen1Recomp on Nintendo Switch
Every GitHub Release that includes Switch support ships an SD-ready zip:
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
**or** update — same steps), launch with **title override**, then import your
own legal `.gb` ROM.
> You need a console that can run Switch homebrew (custom firmware / hbmenu).
> This project does not help you set that up. Tracks issue
> [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
> Hardware: **OLED** validated by the porter; **V1 / Erista** boot confirmed
> by the community. Lite and other setups welcome more reports.
> See [switch-development.md](switch-development.md) for limitations.
Prefer building from source? See [switch-build.md](switch-build.md).
Port by [andrewqsantos](https://github.com/andrewqsantos). Community testing
help from [booshankles](https://github.com/booshankles).
## 1. Download the zip
1. Open
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
2. Download `gen1recomp-*-switch.zip` for the version you want.
(Optional: verify against `sha256sums.txt` in the same release.)
## 2. Extract onto the microSD
Extract the zip at the **root** of the microSD so you get:
```text
sdmc:/switch/gen1recomp/gen1recomp.nro
sdmc:/switch/gen1recomp/pokemon-love2d/imports/
sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/
sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/...
```
Merge folders if your OS asks. Any method works: **MTP** (DBI → Run MTP
responder + a client), **direct SD** (Hekate UMS or a card reader), or **FTP**.
Exit MTP / unmount / stop FTP cleanly before launching. Step-by-step for
macOS, Linux, and Windows: [switch-transfer.md](switch-transfer.md).
### Updating
Use the **same** extract/merge. It replaces `gen1recomp.nro` (and the small
help `README.txt` / `INSTALL.txt` files). Saves, imported ROMs, mods, and
options live under `pokemon-love2d/`**do not delete that folder** when
updating, or you will lose progress.
## 3. Launch with title override
**Applet Mode is not supported** for this game (not enough memory).
1. On the Switch HOME menu, highlight any installed title.
2. Hold **R** and launch that title — this opens hbmenu with full memory
(title override).
3. From hbmenu, open `gen1recomp`.
Do **not** launch from the Album applet path for normal play.
## 4. Import your ROM
This project ships **no** game data. On first launch:
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), or Yellow
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
launcher also shows the live save-dir path). All three can sit in the
same folder.
2. Use **Scan again** on that games tab (Red / Blue / Yellow). Rescan
matches by ROM SHA-1 for the open tab only — a Red dump never imports
from the Yellow tab (and vice versa).
## 5. Import / Export a raw `.sav`
Continue a cart or PC battery save (or pull a slot off-console) via MTP /
SD / FTP — same transfer methods as ROMs. Paths are **per game**:
| Game | Import inbox | Export folder |
| ---- | ------------ | ------------- |
| Red | `imports/saves/red/` | `exports/red/` |
| Blue | `imports/saves/blue/` | `exports/blue/` |
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
(Under the save dir `pokemon-love2d/` — the zip already creates these folders.)
1. Copy a Gen1 `.sav` (32 KB) into that games inbox under the save dir
([switch-transfer.md](switch-transfer.md)).
2. With the games ROM already imported, open **that games tab**
**SAVE FILES****Import save**. Only that folder is scanned.
3. A successful import retires the file to `*.sav.imported` and records its
content hash so pressing **Import save** again does not clone slots.
Failed imports leave the original `.sav` in place.
4. To pull a slot off the console, use **Export save**, then copy the file
from that games **`exports/<game>/`** folder via MTP / SD / FTP.
Do not put `.sav` files into git. Prefer clean copies — some MTP clients
create `._*.sav` AppleDouble sidecars that are not real saves.
## Controls
### Gameplay
| Control | Action |
| ------- | ------ |
| D-pad / left stick | Move |
| **A** | Confirm |
| **B** | Cancel |
| **+** (Start) | Start |
| **** (Select) | Select |
| **R** (no Select held) | Cycle game speed up |
| **L** (no Select held) | Cycle game speed down |
### Launcher
| Control | Action |
| ------- | ------ |
| D-pad / left stick | Move virtual cursor |
| **A** | Click at cursor |
| **L** / **R** | Previous / next tab |
| **Start** / **Select** | Play if a ROM is ready; otherwise Choose ROM |
### System
| Control | Action |
| ------- | ------ |
| Hold **R** on HOME, then open from hbmenu | Title override (full memory) |
## Community mods
Mods install from a zip inbox (same transfer methods as ROMs):
1. Copy a release `.zip` into the save-dir **`imports/mods/`** path the
launcher shows (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)).
2. In the launcher, open **MODS****Scan again** → enable the mod →
**Play**.
Remote **FIND MODS** / GitHub download stays **off** on Switch. Do not put
mod zips into git. Community mods ship their own OPTIONS / rebinds — this port
does not document third-party control tables.
### Joy-Con shortcuts (Select + face)
Hold **Select** () and press a face/shoulder button. Without Select, A/B stay
normal gameplay confirm/cancel. These chords are the stock engine display
hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
| Chord | Same as PC key | Stock engine effect |
| ----- | -------------- | ------------------- |
| Select + **A** | `2` | COLORS |
| Select + **B** | `3` | TILT |
| Select + **Y** | `5` | GBC FX |
| Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) |
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
If the handheld stutters with extras on, try **OPTIONS → PERFORMANCE**
`LOW` or `BALANCED`. Full chord notes for contributors:
[switch-development.md](switch-development.md#joy-con-display-chords-select--face).
## Prefer building it yourself?
Building the fused NRO (and SD-ready zip) from source is covered in
[switch-build.md](switch-build.md). Copying artifacts and inbox files
(MTP / SD / FTP on macOS, Linux, Windows): [switch-transfer.md](switch-transfer.md).
Status, limitations, and how we tested: [switch-development.md](switch-development.md).
+168
View File
@@ -0,0 +1,168 @@
# Switch file transfer (MTP / SD / FTP)
Canonical ways to put Gen1Recomp artifacts and inbox files onto a Nintendo
Switch. **Any method is valid** if the bytes land in the destinations below.
This is the home runbook for contributors on **macOS, Linux, and Windows**.
Player install (what to download, title override) stays in
[switch-install.md](switch-install.md). Packaging stays in
[switch-build.md](switch-build.md). Hardware evidence lives in
[switch-hardware-evidence.md](switch-hardware-evidence.md).
> **Not supported yet:** `nxlink` / hbmenu netloader automation. Useful later
> for a fast contrib rebuild loop; deferred on purpose (AD-009). Do not treat
> netloader as the release or ROM/mod install path.
---
## Destinations (shared by every method)
| What | Where on the console |
| ---- | -------------------- |
| SD-ready release zip | Extract at microSD **root**`sdmc:/switch/gen1recomp/gen1recomp.nro` plus `pokemon-love2d/` inbox folders. Install and update use the same merge; do **not** delete `pokemon-love2d/` |
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that games SAVE FILES → **Import save** |
| Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) |
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
| Lua error log | `lua-error.log` in the save dir |
Saves persist across zip re-extract / NRO replacements as long as
`pokemon-love2d/` is left in place. Never commit ROM dumps, `.sav`
files, or third-party mod zips to git.
---
## Canonical methods
### 1. MTP (DBI responder + host client)
On the Switch: close Gen1Recomp → open **DBI****Run MTP responder** (often
**X** on the main screen) → keep that screen up → USB-C data cable to the host.
On the host: open **one** MTP client, navigate to **`1: SD Card`**, then the
paths above. Wait for the transfer queue; refresh; exit MTP on the Switch
before launching.
#### macOS (example: OpenMTP)
[OpenMTP](https://github.com/ganeshrvel/openmtp) is the loop used for OLED
hardware evidence — **one contributor example**, not a Mac-only product rule.
1. Quit other MTP clients.
2. Open OpenMTP → select the DBI device → **`1: SD Card`**.
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
(or copy NRO / `game.love` for loose).
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
`imports/saves/<red|blue|yellow>/`, or `exports/<red|blue|yellow>/` path the
launcher prints.
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
macOS clients often create AppleDouble sidecars (`._Something.zip`,
`._cart.gb`, `._foo.sav`). Those are not real archives or saves — the
launcher skips hidden `.*` names. Delete `._*` junk if a zip/ROM/`.sav`
fails to open.
#### Linux
1. Install desktop MTP support if needed (e.g. `gvfs-mtp` on GNOME/GTK
desktops, or your distros KDE MTP stack).
2. With DBI MTP active, open **Files** / **Dolphin** / **Thunar** and select
the Switch / DBI device → **`1: SD Card`**.
3. Extract the release zip at SD root (merge), or copy into `switch/gen1recomp/`
and the save-dir inboxes as above.
4. Use **only one** MTP accessor at a time. If `mtp-tools` / `mtpfs` reports
“device is busy”, close the file managers MTP mount (or the CLI mount)
and retry with a single client.
5. Eject/unmount cleanly; exit MTP on the Switch; title-override launch.
If MTP is unavailable or flaky on Linux, use **direct SD** (Hekate UMS or a
card reader) or **FTP** instead — same destinations in the table above.
#### Windows
1. With DBI MTP active, open **This PC** / **File Explorer** and look under
**Portable Devices** for the Switch / DBI MTP volume → **`1: SD Card`**.
2. Copy / extract into `switch\gen1recomp\` and the save-dir inboxes.
3. Optional: [OpenMTP](https://github.com/ganeshrvel/openmtp) on Windows if
Explorer is flaky.
4. If Windows does not show an MTP device: Device Manager → find DBI / Switch
→ Update driver → **MTP USB Device** (or Standard MTP Device). Prefer a
data-capable USB-C cable and a direct port.
5. Safely disconnect; exit MTP on the Switch; title-override launch.
If MTP is unavailable or flaky on Windows, use **direct SD** (Hekate UMS or a
card reader) or **FTP** instead — same destinations in the table above.
### 2. Direct SD (Hekate UMS or card reader)
Same destinations; no MTP client required.
- **Hekate UMS** (preferred when available): expose the microSD to the host
while the card stays in the console; mount the volume; copy files; **cleanly
unmount** before leaving UMS.
- **Physical reader**: power off / remove the microSD, copy on the host,
**eject safely**, reinsert, boot CFW, title-override launch.
Do not yank the card or unplug UMS mid-write.
### 3. FTP (any SD-exposing Switch FTP)
Any homebrew FTP server that can write the microSD is fine — for example
**DBIs own FTP**, **sys-ftpd-light**, or **Sphaira** (names are illustrations
only; pick what your CFW setup already uses).
1. Start the FTP server on the Switch; note IP/port/credentials from that app.
2. From the host, connect with any FTP client and upload to the same
`switch/gen1recomp/`, `imports/`, `imports/mods/`, `imports/saves/<game>/`,
and `exports/<game>/` paths.
3. Stop the FTP server cleanly before launching Gen1Recomp.
If credentials or chroots differ by app, trust the **destination paths**, not
a single vendor tutorial.
---
## After every transfer
1. Exit MTP / unmount SD / stop FTP cleanly.
2. Launch via **title override** (hold **R** on a title → hbmenu). **Applet
Mode is not supported** (not enough memory).
3. For ROMs: open the matching game tab → **Scan again** if the file was
added after boot (SHA-1 must match that tab; other dumps in `imports/`
stay for their own tabs). For mods: MODS → **Scan again** → enable →
Play. For saves: SAVE FILES → **Import save** (rescans
`imports/saves/<game>/`). Pull exported `.sav` files from
`exports/<game>/`. Joy-Con display chords (stock engine):
[switch-install.md](switch-install.md#joy-con-shortcuts-select--face).
### Optional NRO integrity check
For the first deploy of a given artifact (or after a flaky cable):
```bash
shasum -a 256 path/to/gen1recomp.nro # or sha256sum
```
Copy the file back from the SD and compare hashes. Round-trip must match.
---
## Failure modes (quick)
| Symptom | What to try |
| ------- | ----------- |
| Device busy / no MTP volume | One client only; different cable/port; Windows MTP USB Device driver; alternate method (SD or FTP) |
| Zip/ROM/`.sav` “could not be opened” | Delete `._*` sidecars (including `._*.sav`); confirm real zip starts with `PK` |
| Half-copied NRO / crash on boot | Re-copy; verify SHA-256; exit transfer mode before launch |
| App opens in Applet Mode | Use title override (hold **R**), not Album |
---
## Related
- Players: [switch-install.md](switch-install.md)
- Builders: [switch-build.md](switch-build.md)
- Status / hardware matrix: [switch-development.md](switch-development.md)
- Evidence log: [switch-hardware-evidence.md](switch-hardware-evidence.md)
+124 -22
View File
@@ -189,6 +189,12 @@ flexlove._accumulatedDt = 0
---@type table<string, Element> ---@type table<string, Element>
flexlove._touchOwners = {} flexlove._touchOwners = {}
-- Touch-drag scroll tracking: survives immediate-mode element recreation.
-- Maps touch ID -> { id, lastX, lastY }. Scroll position is persisted into
-- StateManager on every move (same contract as wheelmoved).
---@type table<string, {id: string, lastX: number, lastY: number}>
flexlove._touchScroll = {}
---@type table<number, boolean> ---@type table<number, boolean>
flexlove._mouseButtonStates = {} flexlove._mouseButtonStates = {}
@@ -224,7 +230,9 @@ function flexlove.init(config)
-- Initialize Performance if available -- Initialize Performance if available
if Performance then if Performance then
flexlove._Performance = Performance.init({ flexlove._Performance = Performance.init({
enabled = config.performanceMonitoring or true, -- ~= false (not `or true`): `performanceMonitoring = false` must
-- actually disable the per-frame timers + memory sampling.
enabled = config.performanceMonitoring ~= false,
hudEnabled = false, -- Start with HUD disabled hudEnabled = false, -- Start with HUD disabled
hudToggleKey = config.performanceHudKey or "f3", hudToggleKey = config.performanceHudKey or "f3",
hudPosition = config.performanceHudPosition or { x = 10, y = 10 }, hudPosition = config.performanceHudPosition or { x = 10, y = 10 },
@@ -1412,6 +1420,82 @@ function flexlove._getTouchElementAtPosition(x, y)
return candidates[1] return candidates[1]
end end
local function elementIsTouchScrollable(element)
if not element or not element._scrollManager then
return false
end
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
return overflowX == "scroll"
or overflowX == "auto"
or overflowY == "scroll"
or overflowY == "auto"
end
-- Walk parents from a hit target so a finger on a button/row still scrolls
-- the containing list. Falls back to the wheel path's scrollable lookup when
-- the press lands on empty space inside a scroller.
local function findTouchScrollTarget(x, y, startElement)
local el = startElement
while el do
if elementIsTouchScrollable(el) then
return el
end
el = el.parent
end
return Context.findScrollableAtPosition(x, y)
end
local function findElementByStateId(stateId)
if not stateId or stateId == "" then
return nil
end
local function walk(element)
if element.id == stateId or element._stateId == stateId then
return element
end
for _, child in ipairs(element.children or {}) do
local found = walk(child)
if found then
return found
end
end
return nil
end
for _, element in ipairs(flexlove.topElements or {}) do
local found = walk(element)
if found then
return found
end
end
for _, element in ipairs(flexlove._currentFrameElements or {}) do
local found = walk(element)
if found then
return found
end
end
return nil
end
local function persistTouchScroll(element)
if flexlove._immediateMode and element and element._stateId and element._scrollManager then
StateManager.updateState(element._stateId, {
scrollManager = element._scrollManager:getState(),
})
end
end
local function resolveTouchScrollElement(track)
if not track then
return nil
end
local element = findElementByStateId(track.id)
if elementIsTouchScrollable(element) then
return element
end
return nil
end
--- Handle touch press events from LÖVE's touch input system --- Handle touch press events from LÖVE's touch input system
--- Routes touch to the topmost element at the touch position and assigns touch ownership --- Routes touch to the topmost element at the touch position and assigns touch ownership
--- Hook this to love.touchpressed() to enable touch interaction --- Hook this to love.touchpressed() to enable touch interaction
@@ -1452,15 +1536,22 @@ function flexlove.touchpressed(id, x, y, dx, dy, pressure)
end end
end end
end end
end
-- Route to scroll manager for scrollable elements -- Scroll target is the nearest scrollable ancestor (or the scroller under
if element._scrollManager then -- empty space). Tracked by stable id so immediate-mode recreation can resume.
local overflowX = element.overflowX or element.overflow local scrollEl = findTouchScrollTarget(touchX, touchY, element)
local overflowY = element.overflowY or element.overflow if scrollEl and scrollEl._scrollManager then
if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then local scrollId = scrollEl._stateId or scrollEl.id
element._scrollManager:handleTouchPress(touchX, touchY) if scrollId and scrollId ~= "" then
end flexlove._touchScroll[touchId] = {
id = scrollId,
lastX = touchX,
lastY = touchY,
}
end end
scrollEl._scrollManager:handleTouchPress(touchX, touchY)
persistTouchScroll(scrollEl)
end end
end end
@@ -1500,15 +1591,21 @@ function flexlove.touchmoved(id, x, y, dx, dy, pressure)
end end
end end
end end
end
-- Route to scroll manager for scrollable elements local track = flexlove._touchScroll[touchId]
if element._scrollManager then local scrollEl = resolveTouchScrollElement(track)
local overflowX = element.overflowX or element.overflow if track and scrollEl then
local overflowY = element.overflowY or element.overflow local sm = scrollEl._scrollManager
if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then -- Immediate mode recreates managers each frame; re-arm drag from the
element._scrollManager:handleTouchMove(touchX, touchY) -- last persisted touch point so a move after beginFrame still scrolls.
end if not sm._touchScrolling then
sm:handleTouchPress(track.lastX, track.lastY)
end end
sm:handleTouchMove(touchX, touchY)
persistTouchScroll(scrollEl)
track.lastX = touchX
track.lastY = touchY
end end
end end
@@ -1548,19 +1645,23 @@ function flexlove.touchreleased(id, x, y, dx, dy, pressure)
end end
end end
end end
end
-- Route to scroll manager for scrollable elements local track = flexlove._touchScroll[touchId]
if element._scrollManager then local scrollEl = resolveTouchScrollElement(track)
local overflowX = element.overflowX or element.overflow if track and scrollEl then
local overflowY = element.overflowY or element.overflow local sm = scrollEl._scrollManager
if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then if not sm._touchScrolling then
element._scrollManager:handleTouchRelease() sm:handleTouchPress(track.lastX, track.lastY)
end
end end
sm:handleTouchMove(touchX, touchY)
sm:handleTouchRelease()
persistTouchScroll(scrollEl)
end end
-- Clean up touch ownership (touch is complete) -- Clean up touch ownership (touch is complete)
flexlove._touchOwners[touchId] = nil flexlove._touchOwners[touchId] = nil
flexlove._touchScroll[touchId] = nil
end end
--- Get the number of currently active touches being tracked --- Get the number of currently active touches being tracked
@@ -1659,6 +1760,7 @@ function flexlove.destroy()
-- Clean up touch state -- Clean up touch state
flexlove._touchOwners = {} flexlove._touchOwners = {}
flexlove._touchScroll = {}
flexlove._mouseButtonStates = {} flexlove._mouseButtonStates = {}
if flexlove._gestureRecognizer then if flexlove._gestureRecognizer then
flexlove._gestureRecognizer:reset() flexlove._gestureRecognizer:reset()
+18 -2
View File
@@ -2709,6 +2709,7 @@ function Element:_drawChildren(backdropCanvas)
-- Set up clipping: rounded-corners (stencil) > overflow (scissor) > none -- Set up clipping: rounded-corners (stencil) > overflow (scissor) > none
local clipMode = "none" local clipMode = "none"
local prevSx, prevSy, prevSw, prevSh
if hasRoundedCorners then if hasRoundedCorners then
local roundedBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) local roundedBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
local roundedBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) local roundedBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
@@ -2721,7 +2722,18 @@ function Element:_drawChildren(backdropCanvas)
love.graphics.setStencilTest("greater", 0) love.graphics.setStencilTest("greater", 0)
clipMode = "stencil" clipMode = "stencil"
elseif needsOverflowClipping then elseif needsOverflowClipping then
love.graphics.setScissor(self.x + self.padding.left, self.y + self.padding.top, self.width, self.height) -- Intersect with (and later restore) any ancestor scissor: replacing
-- it let a nested scroller widen its parent's clip, and the bare
-- setScissor() restore then cleared it entirely - scrolled page
-- content drew straight over the pinned header.
-- transformPoint: scissor rects live in window coordinates and ignore
-- the scroll translate an ancestor scroller has pushed, so a nested
-- scroller's clip sat at its untranslated layout position and cut its
-- own rows away once the page scrolled.
prevSx, prevSy, prevSw, prevSh = love.graphics.getScissor()
local scx, scy = love.graphics.transformPoint(
self.x + self.padding.left, self.y + self.padding.top)
love.graphics.intersectScissor(scx, scy, self.width, self.height)
clipMode = "scissor" clipMode = "scissor"
end end
@@ -2745,7 +2757,11 @@ function Element:_drawChildren(backdropCanvas)
if clipMode == "stencil" then if clipMode == "stencil" then
love.graphics.setStencilTest() love.graphics.setStencilTest()
elseif clipMode == "scissor" then elseif clipMode == "scissor" then
love.graphics.setScissor() if prevSx then
love.graphics.setScissor(prevSx, prevSy, prevSw, prevSh)
else
love.graphics.setScissor()
end
end end
end end
+5
View File
@@ -107,6 +107,11 @@ function FONT_CACHE.get(size, fontPath)
font = love.graphics.newFont(size) font = love.graphics.newFont(size)
end end
-- Per-glyph fallback so a non-Latin UI string is not drawn as tofu.
-- pcall'd require: FlexLove is vendored and must still load standalone.
local okUi, UiFont = pcall(require, "src.render.UiFont")
if okUi and UiFont then UiFont.attach(font, size) end
-- Add to cache with LRU metadata -- Add to cache with LRU metadata
FONT_CACHE[cacheKey] = { FONT_CACHE[cacheKey] = {
font = font, font = font,
+97 -18
View File
@@ -112,7 +112,8 @@ function ScrollManager.new(config, deps)
self.touchScrollEnabled = config.touchScrollEnabled ~= false -- Default true self.touchScrollEnabled = config.touchScrollEnabled ~= false -- Default true
self.momentumScrollEnabled = config.momentumScrollEnabled ~= false -- Default true self.momentumScrollEnabled = config.momentumScrollEnabled ~= false -- Default true
self.bounceEnabled = config.bounceEnabled ~= false -- Default true self.bounceEnabled = config.bounceEnabled ~= false -- Default true
self.scrollFriction = config.scrollFriction or 0.95 -- Exponential decay per frame self.scrollFriction = config.scrollFriction or 0.95 -- legacy per-frame decay; superseded by _momentumDecel
self._momentumDecel = config.momentumDecel or 2.0 -- fling decay rate (1/s), dt-based; ~iOS "normal"
self.bounceStiffness = config.bounceStiffness or 0.2 -- Spring constant self.bounceStiffness = config.bounceStiffness or 0.2 -- Spring constant
self.maxOverscroll = config.maxOverscroll or 100 -- pixels self.maxOverscroll = config.maxOverscroll or 100 -- pixels
@@ -127,7 +128,8 @@ function ScrollManager.new(config, deps)
self._scrollY = config._scrollY or 0 self._scrollY = config._scrollY or 0
self._targetScrollX = nil self._targetScrollX = nil
self._targetScrollY = nil self._targetScrollY = nil
self._smoothScrollSpeed = 0.25 -- Interpolation speed (0-1, higher = faster) self._smoothScrollSpeed = 0.25 -- Interpolation speed (0-1, higher = faster); legacy, superseded by _smoothScrollRate
self._smoothScrollRate = config.smoothScrollRate or 30 -- dt-based decay constant (1/s); ~90% converged in 2.3/rate s
self.smoothScrollEnabled = config.smoothScrollEnabled or false -- Enable smooth wheel scrolling self.smoothScrollEnabled = config.smoothScrollEnabled or false -- Enable smooth wheel scrolling
self._maxScrollX = 0 self._maxScrollX = 0
self._maxScrollY = 0 self._maxScrollY = 0
@@ -729,6 +731,15 @@ function ScrollManager:getState()
_overflowY = self._overflowY, _overflowY = self._overflowY,
_contentWidth = self._contentWidth, _contentWidth = self._contentWidth,
_contentHeight = self._contentHeight, _contentHeight = self._contentHeight,
-- Touch fling state: without these, immediate-mode recreation zeroes the
-- release velocity on the next frame and momentum scrolling never runs.
_touchScrolling = self._touchScrolling or false,
_momentumScrolling = self._momentumScrolling or false,
_scrollVelocityX = self._scrollVelocityX or 0,
_scrollVelocityY = self._scrollVelocityY or 0,
_lastTouchTime = self._lastTouchTime or 0,
_lastTouchX = self._lastTouchX or 0,
_lastTouchY = self._lastTouchY or 0,
} }
end end
@@ -834,6 +845,30 @@ function ScrollManager:setState(state)
if state._targetScrollY ~= nil then if state._targetScrollY ~= nil then
self._targetScrollY = state._targetScrollY self._targetScrollY = state._targetScrollY
end end
-- Touch fling state (see getState): restore so momentum survives
-- immediate-mode element recreation between frames.
if state._touchScrolling ~= nil then
self._touchScrolling = state._touchScrolling
end
if state._momentumScrolling ~= nil then
self._momentumScrolling = state._momentumScrolling
end
if state._scrollVelocityX ~= nil then
self._scrollVelocityX = state._scrollVelocityX
end
if state._scrollVelocityY ~= nil then
self._scrollVelocityY = state._scrollVelocityY
end
if state._lastTouchTime ~= nil then
self._lastTouchTime = state._lastTouchTime
end
if state._lastTouchX ~= nil then
self._lastTouchX = state._lastTouchX
end
if state._lastTouchY ~= nil then
self._lastTouchY = state._lastTouchY
end
end end
--- Handle touch press for scrolling --- Handle touch press for scrolling
@@ -892,9 +927,15 @@ function ScrollManager:handleTouchMove(touchX, touchY)
dx = -dx dx = -dx
dy = -dy dy = -dy
-- Calculate velocity (pixels per second) -- Velocity tracking (pixels per second). Not the raw last-sample d/dt:
self._scrollVelocityX = dx / dt -- touch samples jitter, and the final pre-release sample often reads near
self._scrollVelocityY = dy / dt -- zero, which is why single-sample flings feel dead compared to a native
-- list. Blend toward the instantaneous velocity with a ~50 ms time
-- constant instead, like OS velocity trackers that average recent samples.
local instX, instY = dx / dt, dy / dt
local blend = math.min(1, dt / 0.05)
self._scrollVelocityX = self._scrollVelocityX + (instX - self._scrollVelocityX) * blend
self._scrollVelocityY = self._scrollVelocityY + (instY - self._scrollVelocityY) * blend
-- Apply scroll with bounce if enabled -- Apply scroll with bounce if enabled
if self.bounceEnabled then if self.bounceEnabled then
@@ -935,6 +976,15 @@ function ScrollManager:handleTouchRelease()
self._touchScrolling = false self._touchScrolling = false
-- A finger that pauses and then lifts is a stop, not a fling: without
-- this, the smoothed velocity from the earlier drag still launches the
-- list after a deliberate hold (native lists kill the tracker the same
-- way).
if love.timer.getTime() - (self._lastTouchTime or 0) > 0.1 then
self._scrollVelocityX = 0
self._scrollVelocityY = 0
end
-- Start momentum scrolling if enabled and velocity is significant -- Start momentum scrolling if enabled and velocity is significant
if self.momentumScrollEnabled then if self.momentumScrollEnabled then
local velocityThreshold = 50 -- pixels per second local velocityThreshold = 50 -- pixels per second
@@ -957,12 +1007,23 @@ end
--- Update momentum scrolling (call every frame with dt) --- Update momentum scrolling (call every frame with dt)
---@param dt number Delta time in seconds ---@param dt number Delta time in seconds
function ScrollManager:update(dt) function ScrollManager:update(dt)
-- Smooth scroll interpolation -- Smooth scroll interpolation. The blend factor is derived from dt
-- (exponential approach) rather than a fixed per-frame fraction, so the
-- animation converges in the same wall-clock time regardless of frame
-- rate; a fixed fraction made scrolling visibly slower whenever frame
-- time rose. _smoothScrollRate is the decay constant in 1/seconds:
-- ~90% of the remaining distance is covered in 2.3/rate seconds.
-- dt can legitimately be 0 here: in immediate mode beginFrame resets the
-- accumulated dt BEFORE endFrame updates the elements, so the launcher's
-- update order always hands this 0. Treat that (and nil) as one nominal
-- 60 Hz step so the interpolation still advances one frame's worth.
local step = (dt and dt > 0) and dt or (1 / 60)
if self._targetScrollX or self._targetScrollY then if self._targetScrollX or self._targetScrollY then
local alpha = 1 - math.exp(-(self._smoothScrollRate or 30) * step)
if self._targetScrollY then if self._targetScrollY then
local diff = self._targetScrollY - self._scrollY local diff = self._targetScrollY - self._scrollY
if math.abs(diff) > 0.5 then if math.abs(diff) > 0.5 then
self._scrollY = self._scrollY + diff * self._smoothScrollSpeed self._scrollY = self._scrollY + diff * alpha
else else
self._scrollY = self._targetScrollY self._scrollY = self._targetScrollY
self._targetScrollY = nil self._targetScrollY = nil
@@ -972,7 +1033,7 @@ function ScrollManager:update(dt)
if self._targetScrollX then if self._targetScrollX then
local diff = self._targetScrollX - self._scrollX local diff = self._targetScrollX - self._scrollX
if math.abs(diff) > 0.5 then if math.abs(diff) > 0.5 then
self._scrollX = self._scrollX + diff * self._smoothScrollSpeed self._scrollX = self._scrollX + diff * alpha
else else
self._scrollX = self._targetScrollX self._scrollX = self._targetScrollX
self._targetScrollX = nil self._targetScrollX = nil
@@ -988,25 +1049,43 @@ function ScrollManager:update(dt)
return return
end end
-- Apply velocity to scroll position -- Apply velocity to scroll position (step, not dt: dt is 0 in the
local dx = self._scrollVelocityX * dt -- immediate-mode endFrame path, which left touch flings completely dead)
local dy = self._scrollVelocityY * dt local dx = self._scrollVelocityX * step
local dy = self._scrollVelocityY * step
if self.bounceEnabled then if self.bounceEnabled then
-- Allow overscroll during momentum -- Allow overscroll during momentum, but never past maxOverscroll: an
self._scrollX = self._scrollX + dx -- unclamped fling carried a list hundreds of pixels past its edge
self._scrollY = self._scrollY + dy -- (rows vanished into a blank card until the spring crawled back).
self._scrollX = self._utils.clamp(self._scrollX + dx,
-self.maxOverscroll, self._maxScrollX + self.maxOverscroll)
self._scrollY = self._utils.clamp(self._scrollY + dy,
-self.maxOverscroll, self._maxScrollY + self.maxOverscroll)
else else
self:scrollBy(dx, dy) self:scrollBy(dx, dy)
end end
-- Apply friction (exponential decay) -- Frame-rate-independent exponential decay tuned to native list feel:
self._scrollVelocityX = self._scrollVelocityX * self.scrollFriction -- iOS "normal" deceleration is ~0.998 per millisecond, i.e. exp(-2.0 t).
self._scrollVelocityY = self._scrollVelocityY * self.scrollFriction -- The old per-frame 0.95 factor killed a fling in a fraction of a second
-- (and faster the higher the frame rate). Total fling travel is
-- velocity / rate.
local decay = math.exp(-(self._momentumDecel or 2.0) * step)
-- Past the content edge, kill the fling an order of magnitude faster so
-- the rubber band absorbs it instead of stretching to the clamp and
-- sitting there (native lists do the same).
local overX = self._scrollX < 0 or self._scrollX > self._maxScrollX
local overY = self._scrollY < 0 or self._scrollY > self._maxScrollY
if overX or overY then
decay = decay * math.exp(-20 * step)
end
self._scrollVelocityX = self._scrollVelocityX * decay
self._scrollVelocityY = self._scrollVelocityY * decay
-- Stop momentum when velocity is very low -- Stop momentum when velocity is very low
local totalVelocity = math.sqrt(self._scrollVelocityX ^ 2 + self._scrollVelocityY ^ 2) local totalVelocity = math.sqrt(self._scrollVelocityX ^ 2 + self._scrollVelocityY ^ 2)
if totalVelocity < 1 then if totalVelocity < 5 then
self._momentumScrolling = false self._momentumScrolling = false
self._scrollVelocityX = 0 self._scrollVelocityX = 0
self._scrollVelocityY = 0 self._scrollVelocityY = 0
@@ -214,10 +214,13 @@ local function onDraw(element, _ctx)
if not (scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible) then if not (scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible) then
return return
end end
-- Clear any parent scissor clipping before drawing scrollbars so they render -- Lift the parent scissor while drawing scrollbars so they render fully
-- fully visible (scrollbars must not be clipped by ancestor overflow). -- visible, then RESTORE it: clearing it outright let every later sibling
-- draw unclipped (scrolled page content over the pinned header).
local sx, sy, sw, sh = love.graphics.getScissor()
love.graphics.setScissor() love.graphics.setScissor()
element._renderer:drawScrollbars(element, element.x, element.y, element.width, element.height, scrollbarDims) element._renderer:drawScrollbars(element, element.x, element.y, element.width, element.height, scrollbarDims)
if sx then love.graphics.setScissor(sx, sy, sw, sh) end
end end
-- -------------------------------------------------------------------------- -- --------------------------------------------------------------------------
+207 -36
View File
@@ -10,6 +10,23 @@
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
local NxDisplay = require("src.core.NxDisplay")
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
do
local defaultErrorHandler = love.errorhandler
function love.errorhandler(msg)
local hint = SwitchDiagnostics.logLuaError(msg)
if hint and type(msg) == "string" then
msg = msg .. "\n\n" .. hint
end
if defaultErrorHandler then
return defaultErrorHandler(msg)
end
end
end
local Game, EditorApp, Importer, TouchEditor local Game, EditorApp, Importer, TouchEditor
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua) local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
@@ -103,6 +120,11 @@ local function openEditor(version, slotId)
require("src.import.CacheFs").mountVersion(version) require("src.import.CacheFs").mountVersion(version)
editorVersion = version editorVersion = version
editorHost = Importer editorHost = Importer
-- Drop launcher pad/FlexLove so the save editor owns input (NX shim +
-- virtual cursor + system hand cursor). Desktop park is a light no-op.
if Importer and Importer.prepareOverlayHandoff then
Importer:prepareOverlayHandoff()
end
Importer = nil Importer = nil
editorMode = true editorMode = true
resizeForEditor() resizeForEditor()
@@ -129,6 +151,9 @@ function closeEditor()
restoreWindow() restoreWindow()
Importer = editorHost Importer = editorHost
editorHost = nil editorHost = nil
if Importer and Importer.resumeAfterOverlay then
Importer:resumeAfterOverlay()
end
if Importer and version and Importer.savesChanged then if Importer and version and Importer.savesChanged then
Importer:savesChanged(version) Importer:savesChanged(version)
end end
@@ -142,6 +167,9 @@ local closeTouchControlsEditor -- forward declaration
local function openTouchControlsEditor() local function openTouchControlsEditor()
touchEditorHost = Importer touchEditorHost = Importer
if Importer and Importer.prepareOverlayHandoff then
Importer:prepareOverlayHandoff()
end
Importer = nil Importer = nil
TouchEditor = require("src.ui.TouchControlsEditor") TouchEditor = require("src.ui.TouchControlsEditor")
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end }) TouchEditor.load({ onClose = function() closeTouchControlsEditor() end })
@@ -152,6 +180,9 @@ function closeTouchControlsEditor()
TouchEditor = nil TouchEditor = nil
Importer = touchEditorHost Importer = touchEditorHost
touchEditorHost = nil touchEditorHost = nil
if Importer and Importer.resumeAfterOverlay then
Importer:resumeAfterOverlay()
end
end end
local function bootGame(version) local function bootGame(version)
@@ -161,7 +192,16 @@ local function bootGame(version)
-- data, so data/generated + assets/generated resolve to that version's files. -- data, so data/generated + assets/generated resolve to that version's files.
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red") GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
require("src.import.CacheFs").mountVersion(GameVersion.get()) local CacheFs = require("src.import.CacheFs")
-- Keep CacheFs.prefix aligned for any CacheFs.read fallback during Data:load
-- (Blue/Yellow caches live under blue/ / yellow/).
CacheFs.prefix = GameVersion.cachePrefix()
CacheFs.mountVersion(GameVersion.get())
-- NX: always write nx-asset-probe.log so Yellow/Blue art failures are
-- diagnosable from the SD without enabling switch-debug.txt.
pcall(function()
require("src.debug.SwitchDiagnostics").probeAssets(GameVersion.get())
end)
if love.window and love.window.setTitle then if love.window and love.window.setTitle then
local Version = require("src.core.Version") local Version = require("src.core.Version")
love.window.setTitle(Version.title( love.window.setTitle(Version.title(
@@ -189,6 +229,13 @@ function love.load(args)
-- of each flashing their own cmd.exe window (#606). No-op elsewhere. -- of each flashing their own cmd.exe window (#606). No-op elsewhere.
require("src.core.HostShell").hideHostConsole() require("src.core.HostShell").hideHostConsole()
-- NX fused mounts are unreliable for the blue|yellow cache overlay: wrap
-- the love loaders once so every generated-asset read falls back to the
-- versioned save-dir copy. Never installed on desktop/Android/iOS.
if require("src.core.Platform").isNX() then
require("src.core.NxAssetOverlay").install()
end
-- Self-updater boot shell: a fused build may mount and chainload a newer -- Self-updater boot shell: a fused build may mount and chainload a newer
-- downloaded payload here. True means it took over, so we must stop. A -- downloaded payload here. True means it took over, so we must stop. A
-- dev / source checkout no-ops (see src/update/Boot.lua). -- dev / source checkout no-ops (see src/update/Boot.lua).
@@ -208,6 +255,16 @@ function love.load(args)
end end
end end
love.graphics.setDefaultFilter("nearest", "nearest") love.graphics.setDefaultFilter("nearest", "nearest")
-- NX: handheld 720p / docked 1080p. Runs for every boot path (launcher,
-- editor, scripted); no-op on desktop/mobile.
NxDisplay.sync()
-- Apply the persisted Android orientation lock (#592) before the launcher
-- shows: SDL created the window with no orientation hint, so without this
-- the launcher would rotate freely until Game:applyOptions runs at boot.
-- No-op on desktop / iOS / when options.lua does not exist yet.
require("src.core.Orientation").applyOptions(
require("src.core.SaveData").loadOptions())
-- Standalone editor. A bare `--editor` run has no launcher behind it, so -- Standalone editor. A bare `--editor` run has no launcher behind it, so
-- Close quits; --save points it at a specific file, otherwise it opens the -- Close quits; --save points it at a specific file, otherwise it opens the
@@ -256,6 +313,18 @@ function love.load(args)
return return
end end
-- The launcher draws before any game boots, so the mod loader has not run
-- and Strings has no catalog. Routing the launcher's text through Strings
-- (#767) only pays off if something fills that catalog this early, and no
-- restart could: the ordering is the same on every launch. Read the
-- enabled mods' string catalogs -- data only, no entry chunk -- so a
-- translation reaches the launcher too. Game:load replaces this with the
-- real merged catalog once a version boots.
do
local preload = require("src.mods.LauncherMods").translationStrings()
if preload then require("src.core.Strings").load({ strings = preload }) end
end
-- Interactive: the launcher always runs. Red, Blue, and Yellow are each -- Interactive: the launcher always runs. Red, Blue, and Yellow are each
-- live: a column shows Play when that game's ROM is already imported, or -- live: a column shows Play when that game's ROM is already imported, or
-- Choose ROM / drag-drop when it is not. Any dropped .gb is routed by its -- Choose ROM / drag-drop when it is not. Any dropped .gb is routed by its
@@ -273,6 +342,9 @@ function love.load(args)
end end
function love.update(dt) function love.update(dt)
SwitchDiagnostics.maybeFlush(false)
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
NxDisplay.sync()
if editorMode then return EditorApp.update(dt) end if editorMode then return EditorApp.update(dt) end
if TouchEditor then return TouchEditor.update(dt) end if TouchEditor then return TouchEditor.update(dt) end
if Importer then return Importer:update(dt) end if Importer then return Importer:update(dt) end
@@ -347,48 +419,140 @@ function love.keyreleased(key)
end end
function love.gamepadpressed(joystick, button) function love.gamepadpressed(joystick, button)
if editorMode or TouchEditor then return end SwitchDiagnostics.onJoystickEvent("gamepadpressed", joystick, button)
if editorMode then
if EditorApp and EditorApp.gamepadpressed then
return EditorApp.gamepadpressed(joystick, button)
end
return
end
if TouchEditor then
if TouchEditor.gamepadpressed then
return TouchEditor.gamepadpressed(joystick, button)
end
return
end
if Importer then return Importer:gamepadpressed(joystick, button) end if Importer then return Importer:gamepadpressed(joystick, button) end
Game:gamepadpressed(joystick, button) Game:gamepadpressed(joystick, button)
end end
function love.gamepadreleased(joystick, button) function love.gamepadreleased(joystick, button)
if editorMode or TouchEditor then return end SwitchDiagnostics.onJoystickEvent("gamepadreleased", joystick, button)
if editorMode then
if EditorApp and EditorApp.gamepadreleased then
return EditorApp.gamepadreleased(joystick, button)
end
return
end
if TouchEditor then
if TouchEditor.gamepadreleased then
return TouchEditor.gamepadreleased(joystick, button)
end
return
end
if Importer then return Importer:gamepadreleased(joystick, button) end if Importer then return Importer:gamepadreleased(joystick, button) end
Game:gamepadreleased(joystick, button) Game:gamepadreleased(joystick, button)
end end
function love.gamepadaxis(joystick, axis, value) function love.gamepadaxis(joystick, axis, value)
if editorMode or TouchEditor then return end SwitchDiagnostics.onJoystickEvent("gamepadaxis", joystick, axis, { value = value })
if editorMode then
if EditorApp and EditorApp.gamepadaxis then
return EditorApp.gamepadaxis(joystick, axis, value)
end
return
end
if TouchEditor then
if TouchEditor.gamepadaxis then
return TouchEditor.gamepadaxis(joystick, axis, value)
end
return
end
if Importer then return Importer:gamepadaxis(joystick, axis, value) end if Importer then return Importer:gamepadaxis(joystick, axis, value) end
Game:gamepadaxis(joystick, axis, value) Game:gamepadaxis(joystick, axis, value)
end end
function love.joystickpressed(joystick, button) function love.joystickpressed(joystick, button)
if editorMode or TouchEditor then return end SwitchDiagnostics.onJoystickEvent("joystickpressed", joystick, button)
if editorMode then
if EditorApp and EditorApp.joystickpressed then
return EditorApp.joystickpressed(joystick, button)
end
return
end
if TouchEditor then
if TouchEditor.joystickpressed then
return TouchEditor.joystickpressed(joystick, button)
end
return
end
if Importer then return Importer:joystickpressed(joystick, button) end if Importer then return Importer:joystickpressed(joystick, button) end
Game:joystickpressed(joystick, button) Game:joystickpressed(joystick, button)
end end
function love.joystickreleased(joystick, button) function love.joystickreleased(joystick, button)
if editorMode or TouchEditor then return end SwitchDiagnostics.onJoystickEvent("joystickreleased", joystick, button)
if editorMode then
if EditorApp and EditorApp.joystickreleased then
return EditorApp.joystickreleased(joystick, button)
end
return
end
if TouchEditor then
if TouchEditor.joystickreleased then
return TouchEditor.joystickreleased(joystick, button)
end
return
end
if Importer then return Importer:joystickreleased(joystick, button) end if Importer then return Importer:joystickreleased(joystick, button) end
Game:joystickreleased(joystick, button) Game:joystickreleased(joystick, button)
end end
function love.joystickaxis(joystick, axis, value) function love.joystickaxis(joystick, axis, value)
if editorMode or TouchEditor then return end SwitchDiagnostics.onJoystickEvent("joystickaxis", joystick, axis, { value = value })
if editorMode then
if EditorApp and EditorApp.joystickaxis then
return EditorApp.joystickaxis(joystick, axis, value)
end
return
end
if TouchEditor then
if TouchEditor.joystickaxis then
return TouchEditor.joystickaxis(joystick, axis, value)
end
return
end
if Importer then return Importer:joystickaxis(joystick, axis, value) end if Importer then return Importer:joystickaxis(joystick, axis, value) end
Game:joystickaxis(joystick, axis, value) Game:joystickaxis(joystick, axis, value)
end end
function love.joystickhat(joystick, hat, direction) function love.joystickhat(joystick, hat, direction)
if editorMode or TouchEditor then return end SwitchDiagnostics.onJoystickEvent("joystickhat", joystick, hat, { direction = direction })
if editorMode then
if EditorApp and EditorApp.joystickhat then
return EditorApp.joystickhat(joystick, hat, direction)
end
return
end
if TouchEditor then
if TouchEditor.joystickhat then
return TouchEditor.joystickhat(joystick, hat, direction)
end
return
end
if Importer then return Importer:joystickhat(joystick, hat, direction) end if Importer then return Importer:joystickhat(joystick, hat, direction) end
Game:joystickhat(joystick, hat, direction) Game:joystickhat(joystick, hat, direction)
end end
function love.joystickadded(joystick)
SwitchDiagnostics.onJoystickEvent("joystickadded", joystick)
if editorMode or TouchEditor then return end
if Importer then return end
Game:joystickadded(joystick)
end
function love.joystickremoved(joystick) function love.joystickremoved(joystick)
SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick)
if editorMode or TouchEditor then return end if editorMode or TouchEditor then return end
if Importer then return end if Importer then return end
Game:joystickremoved(joystick) Game:joystickremoved(joystick)
@@ -400,6 +564,7 @@ end
function love.focus(f) function love.focus(f)
if editorMode or TouchEditor then return end if editorMode or TouchEditor then return end
if Importer then if Importer then
require("src.core.Input"):reset()
if Importer.focus then Importer:focus(f) end if Importer.focus then Importer:focus(f) end
return return
end end
@@ -409,12 +574,29 @@ end
-- v is true when the window becomes visible again, false on minimize. -- v is true when the window becomes visible again, false on minimize.
function love.visible(v) function love.visible(v)
if editorMode or TouchEditor then return end if editorMode or TouchEditor then return end
if Importer then return end if Importer then
require("src.core.Input"):reset()
return
end
Game:visible(v) Game:visible(v)
end end
function love.lowmemory()
if editorMode or TouchEditor or Importer then return end
if Game then Game:onResume() end
end
function love.touchpressed(id, x, y, dx, dy, pressure) function love.touchpressed(id, x, y, dx, dy, pressure)
if editorMode then return end if editorMode then
-- iOS synthesizes mousepressed for the primary touch; forwarding here
-- would double-fire. Android / NX need the explicit touch → click path
-- (love-nx does not synthesize mouse for the editor the way desktop does).
if love.system.getOS() == "iOS" then return end
if EditorApp and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, 1)
end
return
end
if TouchEditor then if TouchEditor then
-- iOS synthesizes mousepressed for the primary touch (same as the -- iOS synthesizes mousepressed for the primary touch (same as the
-- launcher); Android drives the editor through love.touch directly. -- launcher); Android drives the editor through love.touch directly.
@@ -422,10 +604,10 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
return TouchEditor.touchpressed(id, x, y) return TouchEditor.touchpressed(id, x, y)
end end
if Importer then if Importer then
if love.system.getOS() == "iOS" then -- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
return Importer:touchpressed(id, x, y) -- polled inside the view; the istouch filter on mousepressed still drops
end -- Android's synthesized mouse twin so Import cannot double-fire (#553).
return Importer:mousepressed(x, y, 1) return Importer:touchpressed(id, x, y, dx, dy, pressure)
end end
Game:touchpressed(id, x, y) Game:touchpressed(id, x, y)
end end
@@ -437,10 +619,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
return TouchEditor.touchmoved(id, x, y) return TouchEditor.touchmoved(id, x, y)
end end
if Importer then if Importer then
if love.system.getOS() == "iOS" then return Importer:touchmoved(id, x, y, dx, dy, pressure)
return Importer:touchmoved(id, x, y)
end
return
end end
Game:touchmoved(id, x, y) Game:touchmoved(id, x, y)
end end
@@ -452,10 +631,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
return TouchEditor.touchreleased(id, x, y) return TouchEditor.touchreleased(id, x, y)
end end
if Importer then if Importer then
if love.system.getOS() == "iOS" then return Importer:touchreleased(id, x, y, dx, dy, pressure)
return Importer:touchreleased(id, x, y)
end
return
end end
Game:touchreleased(id, x, y) Game:touchreleased(id, x, y)
end end
@@ -478,25 +654,20 @@ function love.mousepressed(x, y, button, istouch)
return TouchEditor.mousepressed(x, y, button) return TouchEditor.mousepressed(x, y, button)
end end
if Importer then if Importer then
-- The same double-fire TouchEditor guards against, which the launcher was -- love.touchpressed already forwards the primary touch into FlexLove for
-- missing: love.touchpressed above forwards the primary touch to the -- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
-- Importer on Android, and LÖVE ALSO synthesizes a mouse press for that -- reached a press handler, one tap ran every launcher button twice and
-- same touch, so one tap ran every launcher button twice. On Import that -- stacked two SAF pickers (#553). Clicks are polled inside FlexLove from
-- meant two choose() calls and two stacked SAF picker activities: the -- love.touch / mouse.isDown, so dropping the synthesized istouch press is
-- player picked their ROM, the top picker closed, and the second was still -- safe. A real mouse (DeX, Chromebook, USB) still reaches mousepressed.
-- underneath asking for it again, which is the "import the file twice"
-- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a
-- USB mouse) working, which an Android-wide return would have broken.
--
-- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above
-- returns early on iOS and never forwards, so there the synthesized mouse
-- press is the ONLY event the launcher gets. Filtering istouch on both
-- killed every tap on iOS outright.
if istouch and (love.system.getOS() == "Android" if istouch and (love.system.getOS() == "Android"
or love.system.getOS() == "iOS") then return end or love.system.getOS() == "iOS") then return end
return Importer:mousepressed(x, y, button) return Importer:mousepressed(x, y, button)
end end
if editorMode and EditorApp.mousepressed then if editorMode and EditorApp.mousepressed then
-- Same Android double-fire guard: touchpressed already clicked for the
-- save editor; a synthesized mouse press must not fire again.
if istouch and love.system.getOS() == "Android" then return end
return EditorApp.mousepressed(x, y, button) return EditorApp.mousepressed(x, y, button)
end end
if mouseTouch and Game and button == 1 then if mouseTouch and Game and button == 1 then
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 42 KiB

+35
View File
@@ -12,6 +12,41 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "versions": [
{
"version": "0.1.66",
"date": "2026-08-04",
"size": 9749980,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.66/gen1recomp-0.1.66-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #691 Save file transfer\n- #743 Cannot Scroll Main Menu/Mod Menu\n- #779 \"Enemy \" untranslateable\n\n## Contributors\n\n- @bryanthaboi\n- @jherediagu\n- @ShaneMcGovernIE\n- @vegerot"
},
{
"version": "0.1.65",
"date": "2026-08-03",
"size": 9376618,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.65/gen1recomp-0.1.65-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #592 Screen Orientation\n- #702 FLY overworld animation incorrect/incomplete\n- #748 Launcher Menu has overlap\n- #754 Can push boulders with STRENGTH through walls\n- #758 PC to Mac Online Multiplayer Disconnects Shortly After Starting Match\n\n## Contributors\n\n- @andrewqsantos\n- @Bortlesboat\n- @bryanthaboi\n- @castdrian\n- @johnjohto\n- @ShaneMcGovernIE"
},
{
"version": "0.1.64",
"date": "2026-08-03",
"size": 9317129,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.64/gen1recomp-0.1.64-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @ShaneMcGovernIE"
},
{
"version": "0.1.63",
"date": "2026-08-03",
"size": 9316977,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.63/gen1recomp-0.1.63-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #623 Game Corner Prize Exchange various issues\n- #624 Coins purschase menu missing your current money and coins\n- #636 Inaccurate Old Man tutorial in yellow + misc issues relating to him\n- #637 True color broken on Hall of Fame and Party Stats when using the wide battle option\n- #639 Pokédex can't show the correct stats when you catch 100 or more pokémon + overly simplified layout\n- #650 Rocket Hideout Progression Bug - Stuck with door closed after defeating Jessie and James in Yellow\n- #697 Missaligned HUD on Hall of Fame\n- #704 Player doesn't follow Prof. Oak to the Hall of Fame on the first screen\n- #722 Issues with Giovanni in Silph C.O.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.62",
"date": "2026-08-03",
"size": 9309934,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.62/gen1recomp-0.1.62-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{ {
"version": "0.1.61", "version": "0.1.61",
"date": "2026-08-03", "date": "2026-08-03",
+39
View File
@@ -30,6 +30,45 @@ public final class GRPickerBridge: NSObject {
// (<sandbox>/Library/Application Support/<identity>). // (<sandbox>/Library/Application Support/<identity>).
private static let loveIdentity = "pokemon-love2d" private static let loveIdentity = "pokemon-love2d"
@objc(httpDownloadWithUrl:destination:userAgent:accept:)
public static func httpDownload(url: UnsafePointer<CChar>?,
destination: UnsafePointer<CChar>?,
userAgent: UnsafePointer<CChar>?,
accept: UnsafePointer<CChar>?) -> Bool {
guard let url, let destination,
let requestURL = URL(string: String(cString: url)) else { return false }
var request = URLRequest(url: requestURL)
request.timeoutInterval = 300
if let userAgent, userAgent.pointee != 0 {
request.setValue(String(cString: userAgent), forHTTPHeaderField: "User-Agent")
}
if let accept, accept.pointee != 0 {
request.setValue(String(cString: accept), forHTTPHeaderField: "Accept")
}
let target = URL(fileURLWithPath: String(cString: destination))
let semaphore = DispatchSemaphore(value: 0)
var succeeded = false
let task = URLSession.shared.downloadTask(with: request) { temporary, response, error in
defer { semaphore.signal() }
guard error == nil, let temporary,
let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else { return }
try? FileManager.default.removeItem(at: target)
do {
try FileManager.default.moveItem(at: temporary, to: target)
succeeded = true
} catch {
succeeded = false
}
}
task.resume()
guard semaphore.wait(timeout: .now() + 305) == .success else {
task.cancel()
return false
}
return succeeded
}
// MARK: - Entry points called from liblove (C strings on purpose) // MARK: - Entry points called from liblove (C strings on purpose)
@objc(presentPickerWithKind:saveDir:) @objc(presentPickerWithKind:saveDir:)
+30 -2
View File
@@ -103,6 +103,7 @@ WRAP_REGISTRATION = """#ifdef LOVE_IOS
{ "pickFile", w_pickFile }, { "pickFile", w_pickFile },
{ "createFile", w_createFile }, { "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps }, { "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload },
#endif #endif
""" """
@@ -144,6 +145,32 @@ int w_syncHealthSteps(lua_State *L)
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
{ "syncHealthSteps", w_syncHealthSteps }, { "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload },
#endif
"""
BRIDGE_EXTRA_FUNCS = """
#ifdef LOVE_IOS
int w_httpDownload(lua_State *L)
{
const char *url = luaL_checkstring(L, 1);
const char *destination = luaL_checkstring(L, 2);
const char *userAgent = luaL_optstring(L, 3, "gen1recomp");
const char *accept = luaL_optstring(L, 4, "");
Class cls = objc_getClass("GRPickerBridge");
if (cls == nullptr)
{
lua_pushboolean(L, 0);
return 1;
}
typedef signed char (*GRDownload)(Class, SEL, const char *, const char *,
const char *, const char *);
signed char ok = ((GRDownload)objc_msgSend)(
cls, sel_registerName("httpDownloadWithUrl:destination:userAgent:accept:"),
url, destination, userAgent, accept);
lua_pushboolean(L, ok != 0);
return 1;
}
#endif #endif
""" """
@@ -216,7 +243,8 @@ def patch_wrap_system():
if anchor not in text: if anchor not in text:
fail(f"anchor not found in {WRAP_SYSTEM}") fail(f"anchor not found in {WRAP_SYSTEM}")
has_native_picker = re.search(r"\bint w_pickFile\s*\(", text) is not None has_native_picker = re.search(r"\bint w_pickFile\s*\(", text) is not None
text = text.replace(anchor, (WRAP_SYNC_FUNCS if has_native_picker else WRAP_FUNCS) + anchor, 1) bridge_funcs = WRAP_SYNC_FUNCS if has_native_picker else WRAP_FUNCS
text = text.replace(anchor, bridge_funcs + BRIDGE_EXTRA_FUNCS + anchor, 1)
reg_anchor = '\t{ "vibrate", w_vibrate },\n' reg_anchor = '\t{ "vibrate", w_vibrate },\n'
if reg_anchor not in text: if reg_anchor not in text:
fail(f"registration anchor not found in {WRAP_SYSTEM}") fail(f"registration anchor not found in {WRAP_SYSTEM}")
@@ -224,7 +252,7 @@ def patch_wrap_system():
text = text.replace(reg_anchor, reg_anchor + registration, 1) text = text.replace(reg_anchor, reg_anchor + registration, 1)
WRAP_SYSTEM.write_text(text) WRAP_SYSTEM.write_text(text)
print("patch_love_src: wrap_System.cpp patched " print("patch_love_src: wrap_System.cpp patched "
"(pickFile/createFile/syncHealthSteps)") "(pickFile/createFile/syncHealthSteps/httpDownload)")
def patch_pbxproj(): def patch_pbxproj():
+98 -6
View File
@@ -72,8 +72,13 @@ rm -f "$LOVE_FILE"
tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json \ tools/rom_manifest_yellow.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*') -x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \ # Materialize the listing once and grep the file: piping unzip straight into
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then # grep -q under `set -o pipefail` SIGPIPEs unzip when grep exits early on a
# match, and the pipeline's failure reads as "missing <file>" for whichever
# entry happened to match first (see the same fix in pack_love.sh).
LOVE_LISTING="$WORK/love-listing.txt"
unzip -Z1 "$LOVE_FILE" > "$LOVE_LISTING"
if grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' "$LOVE_LISTING"; then
fail "game.love unexpectedly contains generated ROM data" fail "game.love unexpectedly contains generated ROM data"
fi fi
# The editor is only reachable if its entry point and both module directories # The editor is only reachable if its entry point and both module directories
@@ -86,7 +91,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
libs/flexlove/FlexLove.lua \ libs/flexlove/FlexLove.lua \
tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json; do tools/rom_manifest_yellow.json; do
unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \ grep -qxF "$required" "$LOVE_LISTING" \
|| fail "game.love is missing $required" || fail "game.love is missing $required"
done done
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)" say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
@@ -116,6 +121,32 @@ else
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)" say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
fi fi
# --------------------------------------------------------------- app icon
# One source of truth for every platform's launcher icon; iOS resizes the
# same file in scripts/build_ios.sh (apply_ios_icon) and the Android res/
# drawables are generated from it too.
ICON_SRC="$ROOT/assets/logo/gen1recomp_cover.png"
# pipx installs peresed (Windows exe icon patcher) here, off the default PATH.
PATH="$PATH:$HOME/.local/bin"
make_icns() { # $1 = output .icns path
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
local iconset="$WORK/GameIcon.iconset" size scaled
rm -rf "$iconset"; mkdir -p "$iconset"
for size in 16 32 128 256 512; do
sips -z "$size" "$size" "$ICON_SRC" --out "$iconset/icon_${size}x${size}.png" >/dev/null
scaled=$((size * 2))
sips -z "$scaled" "$scaled" "$ICON_SRC" --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null
done
iconutil -c icns "$iconset" -o "$1"
}
make_ico() { # $1 = output .ico path
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
magick "$ICON_SRC" -define icon:auto-resize=256,128,64,48,32,16 "$1"
}
# --------------------------------------------------------------- macOS # --------------------------------------------------------------- macOS
build_mac() { build_mac() {
say "building macOS app" say "building macOS app"
@@ -142,9 +173,21 @@ build_mac() {
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$plist" 2>/dev/null \ /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleVersion string $VERSION" "$plist" || /usr/libexec/PlistBuddy -c "Add :CFBundleVersion string $VERSION" "$plist"
if [ -f "$ROOT/assets/icon.icns" ]; then # Brand the app icon. LÖVE.app resolves its icon through CFBundleIconName ->
cp "$ROOT/assets/icon.icns" "$out_app/Contents/Resources/GameIcon.icns" # Assets.car first, so overwriting the loose .icns files alone changes
# nothing; the compiled asset catalog has to go and the plist has to fall
# back to CFBundleIconFile.
local icns="$ROOT/assets/icon.icns"
if [ ! -f "$icns" ]; then
icns="$WORK/GameIcon.icns"
make_icns "$icns"
fi fi
cp "$icns" "$out_app/Contents/Resources/GameIcon.icns"
cp "$icns" "$out_app/Contents/Resources/OS X AppIcon.icns"
rm -f "$out_app/Contents/Resources/Assets.car"
/usr/libexec/PlistBuddy -c "Delete :CFBundleIconName" "$plist" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Set :CFBundleIconFile OS X AppIcon" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIconFile string 'OS X AppIcon'" "$plist"
local id="$IDENTITY" local id="$IDENTITY"
if [ -z "$id" ]; then if [ -z "$id" ]; then
@@ -218,7 +261,47 @@ build_win() {
cp "$love_dir"/*.dll "$out_dir"/ cp "$love_dir"/*.dll "$out_dir"/
cp "$love_dir"/license.txt "$out_dir"/ 2>/dev/null || true cp "$love_dir"/license.txt "$out_dir"/ 2>/dev/null || true
cat "$love_dir/love.exe" "$LOVE_FILE" > "$out_dir/$APP_NAME.exe" # The exe's icon lives in love.exe's PE resources, so it must be patched
# BEFORE the .love is appended: peresed rewrites the whole file and would
# drop the fused bytes. peresed (pipx install pe_tools) has no .ico input,
# only raw --set-resource, so split the .ico into RT_ICON blobs plus a
# GRPICONDIR that reuses love.exe's existing resource ids (1..N, lang 1033).
local ico="$WORK/$APP_NAME.ico"
make_ico "$ico"
if command -v peresed >/dev/null 2>&1; then
local ico_parts="$WORK/ico-parts"
rm -rf "$ico_parts"; mkdir -p "$ico_parts"
python3 - "$ico" "$ico_parts" <<'PY'
import struct, sys
data = open(sys.argv[1], "rb").read()
outdir = sys.argv[2]
count = struct.unpack_from("<H", data, 4)[0]
group = struct.pack("<HHH", 0, 1, count)
for i in range(count):
w, h, colors, res, planes, bpp, size, off = struct.unpack_from("<BBBBHHII", data, 6 + 16 * i)
open("%s/icon_%d.bin" % (outdir, i + 1), "wb").write(data[off:off + size])
group += struct.pack("<BBBBHHIH", w, h, colors, res, planes, bpp, size, i + 1)
open(outdir + "/group.bin", "wb").write(group)
print(count)
PY
local n_icons args=()
n_icons=$(ls "$ico_parts" | grep -c '^icon_')
for i in $(seq 1 "$n_icons"); do
args+=(-R RT_ICON "#$i" 1033 "$ico_parts/icon_$i.bin")
done
args+=(-R RT_GROUP_ICON "#1" 1033 "$ico_parts/group.bin")
local exe_branded="$WORK/love-branded.exe"
cp "$love_dir/love.exe" "$exe_branded"
if peresed "${args[@]}" "$exe_branded" >/dev/null; then
cat "$exe_branded" "$LOVE_FILE" > "$out_dir/$APP_NAME.exe"
else
warn "peresed failed to patch the exe icon, shipping stock LÖVE icon"
cat "$love_dir/love.exe" "$LOVE_FILE" > "$out_dir/$APP_NAME.exe"
fi
else
warn "peresed not found (pipx install pe_tools), shipping stock LÖVE exe icon"
cat "$love_dir/love.exe" "$LOVE_FILE" > "$out_dir/$APP_NAME.exe"
fi
local zip_out="$DIST/win/$APP_NAME-win64.zip" local zip_out="$DIST/win/$APP_NAME-win64.zip"
rm -f "$zip_out" rm -f "$zip_out"
@@ -273,6 +356,15 @@ build_linux() {
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
cp "$LOVE_FILE" "$appdir/game.love" cp "$LOVE_FILE" "$appdir/game.love"
# The .desktop's Icon=love resolves against the AppDir root by basename,
# so drop the stock love.svg and provide our PNG under the same name;
# .DirIcon is what appimaged/thumbnailers show for the file itself.
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
rm -f "$appdir/love.svg" "$appdir/.DirIcon"
sips -z 512 512 "$ICON_SRC" --out "$appdir/love.png" >/dev/null
cp "$appdir/love.png" "$appdir/.DirIcon"
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun" sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \ grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)" || fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
+11 -6
View File
@@ -212,14 +212,19 @@ pack_game_love() {
tools/rom_manifest_yellow.json \ tools/rom_manifest_yellow.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \ -x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*') -x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \ # List once and match against the captured text: piping unzip straight into
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then # grep under `set -o pipefail` SIGPIPEs unzip as soon as grep exits early,
fail "game.love unexpectedly contains generated ROM data" # and the pipeline's 141 outranks grep's own status. For the generated-data
fi # guard that inverted the test -- an archive that really did carry generated
# Do not pipe unzip straight into grep here: on a large archive grep can # ROM data made grep match, killed unzip, and the `if` read the 141 as "no
# finish early and make unzip report SIGPIPE under `set -o pipefail`. # match" and let the build through (#774). Same listing feeds the
# required-file gates below, as in scripts/build.sh and scripts/pack_love.sh.
local archive_entries local archive_entries
archive_entries="$(unzip -Z1 "$LOVE_FILE")" archive_entries="$(unzip -Z1 "$LOVE_FILE")"
if grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' \
<<< "$archive_entries"; then
fail "game.love unexpectedly contains generated ROM data"
fi
grep -qx 'tools/save-editor/App.lua' <<< "$archive_entries" \ grep -qx 'tools/save-editor/App.lua' <<< "$archive_entries" \
|| fail "game.love is missing the save editor (Edit on a save row would crash)" || fail "game.love is missing the save editor (Edit on a save row would crash)"
grep -qx "$YELLOW_MANIFEST_RELATIVE" <<< "$archive_entries" \ grep -qx "$YELLOW_MANIFEST_RELATIVE" <<< "$archive_entries" \
+5 -1
View File
@@ -695,7 +695,11 @@ run_xcodebuild() {
fi fi
# Fuse even if the pbxproj wire-up failed, LÖVE runs any bundled *.love. # Fuse even if the pbxproj wire-up failed, LÖVE runs any bundled *.love.
if [ ! -f "$app/game.love" ]; then # Byte-compare, never just existence: xcodebuild's incremental Copy Bundle
# Resources can leave a previous build's game.love in a surviving .app, and
# an existence check shipped that stale payload in the .ipa (today's Lua
# fixes present in ios/resources/ but absent from the installed app).
if ! cmp -s "$LOVE_FILE" "$app/game.love"; then
say "fusing game.love into $(basename "$app")" say "fusing game.love into $(basename "$app")"
cp "$LOVE_FILE" "$app/game.love" cp "$LOVE_FILE" "$app/game.love"
fi fi
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
# Nintendo Switch packaging entry point.
#
# Usage:
# scripts/build_switch.sh --fetch
# scripts/build_switch.sh --loose [path/to/game.love]
# scripts/build_switch.sh --fused [--version X.Y.Z]
# scripts/build_switch.sh --fetch --loose
# scripts/build_switch.sh --fetch --fused [--version X.Y.Z]
#
# Modes:
# --fetch Download pinned love.nro + love.elf into
# .bazinga/love-nx/11.5-nx1/ and verify SHA-256 against
# scripts/switch/love-nx-11.5-nx1.sha256.
# Auto-downloads those two release assets only.
# Does NOT install devkitPro / dkp-pacman.
#
# --loose Pack game.love and copy pinned love.nro → dist/switch/loose/
# (gen1recomp.nro + game.love side by side). Requires the pin
# (run --fetch first, or combine --fetch --loose).
#
# --fused Build a single gen1recomp-<ver>-switch.nro via nacptool+elf2nro,
# then pack dist/switch/gen1recomp-<ver>-switch.zip (SD-ready
# tree under switch/gen1recomp/). Uses native tools (PATH or
# $DEVKITPRO/tools/bin) first; else Docker from
# scripts/switch/dkp-docker.image (override GEN1_DKP_IMAGE).
# Requires the pin (run --fetch or combine). GitHub Releases
# publish the zip only; the versioned .nro stays local / PR CI.
#
# Combinable: --fetch alone, or --fetch with --loose / --fused.
# XOR: --loose and --fused cannot be used together.
#
# Non-goals (never done by this script):
# MTP push, ROM install, dkp-pacman auto-install.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$ROOT/.bazinga/work"
DIST="$ROOT/dist/switch"
LOOSE=0
FUSED=0
FETCH=0
GAME_LOVE=""
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
# Progress on stderr so command-substitution of pack_game_love stays a bare path.
say() { printf '\033[1;32m==>\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
usage() {
sed -n '2,34p' "$0" | sed 's/^# \{0,1\}//'
}
while [ $# -gt 0 ]; do
case "$1" in
--loose) LOOSE=1; shift ;;
--fused) FUSED=1; shift ;;
--fetch) FETCH=1; shift ;;
--version) VERSION="$2"; shift 2 ;;
-h|--help)
usage
exit 0
;;
*)
if [ -z "$GAME_LOVE" ]; then
GAME_LOVE="$1"
else
fail "unknown argument: $1"
fi
shift
;;
esac
done
if [ "$LOOSE" -eq 1 ] && [ "$FUSED" -eq 1 ]; then
fail "choose one of --loose or --fused (XOR)"
fi
if [ "$FETCH" -eq 0 ] && [ "$LOOSE" -eq 0 ] && [ "$FUSED" -eq 0 ]; then
fail "specify --fetch, --loose, and/or --fused (see --help)"
fi
if [ "$FETCH" -eq 1 ]; then
"$ROOT/scripts/switch/fetch_love_nx.sh"
fi
pack_game_love() {
mkdir -p "$WORK" "$DIST"
local build_info="$WORK/build-info.json"
"$ROOT/scripts/switch/write_build_info.sh" "$build_info" "$VERSION"
local love_out="$WORK/game.love"
local listing="$WORK/love-listing.txt"
"$ROOT/scripts/pack_love.sh" \
--output "$love_out" \
--listing "$listing" \
--build-info "$build_info" >/dev/null
# Stamp release version into the archive (same as build.sh / build_android.sh).
# Working tree keeps 0.0.0-dev; only X.Y.Z --version patches Version.lua in-place.
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION into game.love"
local stamp_dir="$WORK/stamp"
rm -rf "$stamp_dir"
mkdir -p "$stamp_dir/src/core"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
(cd "$stamp_dir" && zip -q "$love_out" src/core/Version.lua)
local version_re
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
unzip -p "$love_out" src/core/Version.lua \
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|| fail "version stamp failed: game.love does not report engine $VERSION"
say "stamped engine version: $VERSION"
else
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
fi
cp "$build_info" "$DIST/build-info.json"
cp "$build_info" "$DIST/gen1recomp-${VERSION}-build-info.json"
printf '%s' "$love_out"
}
if [ "$LOOSE" -eq 1 ]; then
if [ -z "$GAME_LOVE" ]; then
GAME_LOVE="$(pack_game_love)"
else
pack_game_love >/dev/null
fi
exec "$ROOT/scripts/switch/assemble_loose.sh" "$GAME_LOVE"
fi
if [ "$FUSED" -eq 1 ]; then
GAME_LOVE="$(pack_game_love)"
OUT_NRO="$DIST/gen1recomp-${VERSION}-switch.nro"
OUT_ZIP="$DIST/gen1recomp-${VERSION}-switch.zip"
"$ROOT/scripts/switch/build_fused.sh" "$GAME_LOVE" "$VERSION" "$OUT_NRO"
"$ROOT/scripts/switch/pack_sd_zip.sh" "$OUT_NRO" "$VERSION" "$OUT_ZIP"
cp "$WORK/build-info.json" "$DIST/gen1recomp-${VERSION}-build-info.json"
say "done. See $DIST/"
exit 0
fi
# --fetch alone
if [ "$FETCH" -eq 1 ]; then
say "fetch complete"
exit 0
fi
fail "specify --fetch, --loose, and/or --fused (see --help)"
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Shared game.love packer for desktop and Switch builds.
#
# Usage:
# scripts/pack_love.sh [--output PATH] [--listing PATH] [--dry-run]
# [--build-info PATH]
#
# Packs the same include/exclude set as the desktop release. Materializes a
# listing file once and greps it (avoids SIGPIPE from unzip|grep under pipefail).
#
# --build-info PATH copy JSON into the love archive root before verification
# --dry-run pack + verify only (for CI gates; no platform artifacts)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$ROOT/.bazinga/work"
OUTPUT="$WORK/game.love"
LISTING="$WORK/love-listing.txt"
DRY_RUN=0
BUILD_INFO=""
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--output) OUTPUT="$2"; shift 2 ;;
--listing) LISTING="$2"; shift 2 ;;
--build-info) BUILD_INFO="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help)
sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*) fail "unknown argument: $1" ;;
esac
done
mkdir -p "$(dirname "$OUTPUT")" "$(dirname "$LISTING")"
say "packing game.love"
rm -f "$OUTPUT"
# libs/ carries the vendored FlexLove toolkit the launcher UI is built on
# (src/import/LauncherView.lua); a build without it dies on the first frame.
(cd "$ROOT" && zip -q -9 -r "$OUTPUT" \
main.lua conf.lua src libs data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if [ -n "$BUILD_INFO" ]; then
[ -f "$BUILD_INFO" ] || fail "missing build-info: $BUILD_INFO"
[ "$(basename "$BUILD_INFO")" = "build-info.json" ] \
|| fail "build-info file must be named build-info.json"
(cd "$(dirname "$BUILD_INFO")" && zip -q "$OUTPUT" build-info.json)
fi
# Materialize the listing once. Piping unzip→grep -q under `set -o pipefail`
# SIGPIPEs unzip when grep exits early on a match and aborts the build.
unzip -Z1 "$OUTPUT" > "$LISTING"
if grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' "$LISTING"; then
fail "game.love unexpectedly contains generated ROM data"
fi
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
tools/save-editor/PadInput.lua \
tools/save-editor/panels/Party.lua \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json \
libs/flexlove/FlexLove.lua \
src/import/LauncherView.lua; do
grep -qxF "$required" "$LISTING" \
|| fail "game.love is missing $required"
done
if [ -n "$BUILD_INFO" ]; then
grep -qxF "build-info.json" "$LISTING" \
|| fail "game.love is missing build-info.json"
fi
"$ROOT/scripts/switch/verify_payload.sh" "$OUTPUT"
say "game.love: $(du -h "$OUTPUT" | cut -f1)"
if [ "$DRY_RUN" -eq 1 ]; then
say "pack dry-run OK: $OUTPUT"
fi
printf '%s\n' "$OUTPUT"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Assemble loose-mode Switch dist: gen1recomp.nro + game.love side by side.
#
# Usage:
# scripts/switch/assemble_loose.sh [path/to/game.love]
#
# Defaults game.love to .bazinga/work/game.love. Copies pinned love.nro from
# .bazinga/love-nx/11.5-nx1/love.nro → dist/switch/loose/gen1recomp.nro.
# Prints SHA-256 for both outputs. Exits non-zero if love.nro is missing.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=common.sh
. "$SCRIPT_DIR/common.sh"
LOVE_NRO="$ROOT/.bazinga/love-nx/11.5-nx1/love.nro"
GAME_LOVE="${1:-$ROOT/.bazinga/work/game.love}"
OUT_DIR="$ROOT/dist/switch/loose"
OUT_NRO="$OUT_DIR/gen1recomp.nro"
OUT_LOVE="$OUT_DIR/game.love"
if [ ! -f "$LOVE_NRO" ]; then
fail_need_fetch "missing pinned love.nro at $LOVE_NRO"
fi
if [ ! -f "$GAME_LOVE" ]; then
fail "missing game.love at $GAME_LOVE (build with scripts/build.sh first)"
fi
mkdir -p "$OUT_DIR"
cp "$LOVE_NRO" "$OUT_NRO"
cp "$GAME_LOVE" "$OUT_LOVE"
echo "assembled loose Switch dist:"
echo " $OUT_NRO"
echo " $OUT_LOVE"
echo ""
printf '%s %s\n' "$(sha256_file "$OUT_NRO")" "$OUT_NRO"
printf '%s %s\n' "$(sha256_file "$OUT_LOVE")" "$OUT_LOVE"
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Build fused gen1recomp Switch NRO (romfs game.love + nacp + icon).
#
# Usage: scripts/switch/build_fused.sh GAME_LOVE VERSION OUT_NRO
#
# Prefers native nacptool/elf2nro (PATH or $DEVKITPRO/tools/bin).
# Falls back to Docker using GEN1_DKP_IMAGE or scripts/switch/dkp-docker.image.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=common.sh
. "$SCRIPT_DIR/common.sh"
LOVE_NX_TAG="11.5-nx1"
LOVE_NX_DIR="$ROOT/.bazinga/love-nx/$LOVE_NX_TAG"
LOVE_ELF="$LOVE_NX_DIR/love.elf"
ICON="$ROOT/assets/switch/icon.jpg"
APP_NAME="gen1recomp"
APP_AUTHOR="bryanthaboi, port by andrewqsantos"
DKP_IMAGE_FILE="$ROOT/scripts/switch/dkp-docker.image"
GAME_LOVE="${1:-}"
VERSION="${2:-}"
OUT_NRO="${3:-}"
[ -n "$GAME_LOVE" ] && [ -n "$VERSION" ] && [ -n "$OUT_NRO" ] \
|| fail "usage: $0 GAME_LOVE VERSION OUT_NRO"
[ -f "$GAME_LOVE" ] || fail "missing game.love at $GAME_LOVE"
"$ROOT/scripts/switch/verify_love_nx.sh"
[ -f "$ICON" ] || fail "missing Switch icon at $ICON"
ensure_dkp_tools_path
resolve_dkp_image() {
if [ -n "${GEN1_DKP_IMAGE:-}" ]; then
printf '%s' "$GEN1_DKP_IMAGE"
return 0
fi
[ -f "$DKP_IMAGE_FILE" ] || fail "missing Docker image pin: $DKP_IMAGE_FILE"
local line
line="$(grep -v '^[[:space:]]*#' "$DKP_IMAGE_FILE" | grep -v '^[[:space:]]*$' | head -1 || true)"
[ -n "$line" ] || fail "empty Docker image pin: $DKP_IMAGE_FILE"
printf '%s' "$line"
}
run_fused_native() {
local work romfs_dir nacp
work="$(mktemp -d "${TMPDIR:-/tmp}/gen1recomp-fused.XXXXXX")"
# shellcheck disable=SC2064
trap "rm -rf '$work'" EXIT
romfs_dir="$work/romfs"
mkdir -p "$romfs_dir" "$(dirname "$OUT_NRO")"
cp "$GAME_LOVE" "$romfs_dir/game.love"
nacp="$work/control.nacp"
nacptool --create "$APP_NAME" "$APP_AUTHOR" "$VERSION" "$nacp"
say "building fused NRO with pinned love.elf (native)"
elf2nro "$LOVE_ELF" "$OUT_NRO" \
--icon="$ICON" \
--nacp="$nacp" \
--romfsdir="$romfs_dir"
}
run_fused_docker() {
local image stage out_dir out_base
image="$(resolve_dkp_image)"
command -v docker >/dev/null 2>&1 || fail_fused_toolchain
# Stage under ROOT so a single repo bind-mount covers love.elf, icon, and romfs.
stage="$ROOT/.bazinga/work/fused-docker-$$"
mkdir -p "$stage/romfs" "$(dirname "$OUT_NRO")"
cp "$GAME_LOVE" "$stage/romfs/game.love"
# shellcheck disable=SC2064
trap "rm -rf '$stage'" EXIT
out_dir="$(cd "$(dirname "$OUT_NRO")" && pwd)"
out_base="$(basename "$OUT_NRO")"
say "building fused NRO with pinned love.elf (Docker: $image)"
docker run --rm \
-v "$ROOT:/src:ro" \
-v "$stage:/work" \
-v "$out_dir:/out" \
-w /work \
"$image" \
bash -c "
set -euo pipefail
nacptool --create '$APP_NAME' '$APP_AUTHOR' '$VERSION' /work/control.nacp
elf2nro /src/.bazinga/love-nx/$LOVE_NX_TAG/love.elf /out/$out_base \
--icon=/src/assets/switch/icon.jpg \
--nacp=/work/control.nacp \
--romfsdir=/work/romfs
"
}
if command -v nacptool >/dev/null 2>&1 && command -v elf2nro >/dev/null 2>&1; then
run_fused_native
elif command -v docker >/dev/null 2>&1; then
run_fused_docker
else
fail_fused_toolchain
fi
[ -f "$OUT_NRO" ] || fail "fused NRO was not produced at $OUT_NRO"
sha256_file "$OUT_NRO" > "${OUT_NRO}.sha256"
say "fused NRO: $OUT_NRO"
say "sha256: $(cat "${OUT_NRO}.sha256")"
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Shared helpers for Switch packaging scripts.
# Source from other scripts: . "$(dirname "$0")/common.sh" (or similar)
# shellcheck disable=SC2034
if [ -z "${ROOT:-}" ]; then
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
fi
export ROOT
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
# Print SHA-256 hex digest of PATH. Prefers shasum, falls back to sha256sum.
sha256_file() {
local path="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$path" | awk '{print $1}'
elif command -v sha256sum >/dev/null 2>&1; then
sha256sum "$path" | awk '{print $1}'
else
fail "need shasum or sha256sum (install coreutils / Xcode CLT)"
fi
}
# If nacptool is missing but $DEVKITPRO/tools/bin exists, prepend it to PATH.
ensure_dkp_tools_path() {
if command -v nacptool >/dev/null 2>&1; then
return 0
fi
if [ -n "${DEVKITPRO:-}" ] && [ -d "$DEVKITPRO/tools/bin" ]; then
export PATH="$DEVKITPRO/tools/bin:$PATH"
fi
}
# Missing love-nx pin — tell user to run --fetch.
fail_need_fetch() {
fail "${1:-missing pinned love-nx} — run: scripts/build_switch.sh --fetch"
}
# Fused mode needs nacptool/elf2nro (native or Docker). Rich multi-OS hint.
fail_fused_toolchain() {
fail "$(cat <<'EOF'
fused packaging needs nacptool and elf2nro (devkitPro switch-dev).
Install options:
macOS: https://devkitpro.org/wiki/devkitPro_pacman (installer / pacman)
Linux: https://devkitpro.org/wiki/devkitPro_pacman
Windows: Git Bash / MSYS2 / WSL with devkitPro tools on PATH
Docker: install Docker; build_fused.sh falls back to the pinned image
See docs/switch-build.md for details.
EOF
)"
}
+3
View File
@@ -0,0 +1,3 @@
# Default Docker image for fused nacptool/elf2nro fallback.
# Override with GEN1_DKP_IMAGE; operators may pin a digest for reproducibility.
devkitpro/devkita64:latest
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# Download pinned love-nx 11.5-nx1 binaries (love.nro + love.elf) and verify SHA.
#
# Usage: scripts/switch/fetch_love_nx.sh
#
# Idempotent: if both files exist and match the manifest, exit 0 without download.
# Downloads only these two release assets — does NOT install devkitPro.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=common.sh
. "$SCRIPT_DIR/common.sh"
LOVE_NX_TAG="11.5-nx1"
LOVE_NX_DIR="$ROOT/.bazinga/love-nx/$LOVE_NX_TAG"
MANIFEST="$ROOT/scripts/switch/love-nx-11.5-nx1.sha256"
# Override for offline selftests (never used in release/docs as the default).
BASE_URL="${GEN1_LOVE_NX_BASE_URL:-https://github.com/retronx-team/love-nx/releases/download/${LOVE_NX_TAG}}"
fail_download() {
local url="$1"
local detail="$2"
rm -f "${3:-}"
fail "download failed: $url
detail: $detail
retry: scripts/build_switch.sh --fetch
See docs/switch-build.md (mode --fetch)."
}
read_manifest_hash() {
local name="$1"
local line hash
line="$(grep -E "^${name}[[:space:]]+" "$MANIFEST" | head -1 || true)"
[ -n "$line" ] || fail "manifest missing entry for $name"
hash="$(printf '%s' "$line" | awk '{print $2}')"
case "$hash" in
TBD_*|"") fail "manifest hash for $name is not filled in ($hash)" ;;
esac
printf '%s' "$hash"
}
# Downloads url → dest. On failure prints structured error (URL, tool status, retry).
download_file() {
local url="$1"
local dest="$2"
local rc=0 http_code errf
if command -v curl >/dev/null 2>&1; then
errf="$(mktemp "${TMPDIR:-/tmp}/love-nx-curl.XXXXXX")"
http_code="$(curl -fL --retry 3 --retry-delay 1 -o "$dest" -w '%{http_code}' \
"$url" 2>"$errf")" || rc=$?
if [ "$rc" -ne 0 ]; then
fail_download "$url" \
"curl exit $rc; HTTP status ${http_code:-unknown}; $(tr '\n' ' ' <"$errf" | sed 's/[[:space:]]*$//')" \
"$dest"
fi
rm -f "$errf"
elif command -v wget >/dev/null 2>&1; then
errf="$(mktemp "${TMPDIR:-/tmp}/love-nx-wget.XXXXXX")"
wget -O "$dest" "$url" 2>"$errf" || rc=$?
if [ "$rc" -ne 0 ]; then
fail_download "$url" \
"wget exit $rc; $(tr '\n' ' ' <"$errf" | sed 's/[[:space:]]*$//')" \
"$dest"
fi
rm -f "$errf"
else
fail "need curl or wget to download love-nx
retry: scripts/build_switch.sh --fetch
See docs/switch-build.md (mode --fetch)."
fi
}
fetch_one() {
local name="$1"
local expected actual
local url="$BASE_URL/$name"
local dest="$LOVE_NX_DIR/$name"
local tmp
expected="$(read_manifest_hash "$name")"
if [ -f "$dest" ]; then
actual="$(sha256_file "$dest")"
if [ "$actual" = "$expected" ]; then
return 0
fi
say "checksum mismatch for existing $name — re-downloading"
rm -f "$dest"
fi
mkdir -p "$LOVE_NX_DIR"
tmp="$(mktemp "${TMPDIR:-/tmp}/love-nx-${name}.XXXXXX")"
say "downloading $name"
# download_file fails the script with fail_download (URL + status + retry).
download_file "$url" "$tmp"
actual="$(sha256_file "$tmp")"
if [ "$actual" != "$expected" ]; then
rm -f "$tmp"
fail "$name checksum mismatch (expected $expected, got $actual) — $url
retry: scripts/build_switch.sh --fetch"
fi
mv "$tmp" "$dest"
say "verified $name ($actual)"
}
[ -f "$MANIFEST" ] || fail "missing love-nx manifest: $MANIFEST"
EXPECTED_NRO="$(read_manifest_hash love.nro)"
EXPECTED_ELF="$(read_manifest_hash love.elf)"
if [ -f "$LOVE_NX_DIR/love.nro" ] && [ -f "$LOVE_NX_DIR/love.elf" ]; then
ACTUAL_NRO="$(sha256_file "$LOVE_NX_DIR/love.nro")"
ACTUAL_ELF="$(sha256_file "$LOVE_NX_DIR/love.elf")"
if [ "$ACTUAL_NRO" = "$EXPECTED_NRO" ] && [ "$ACTUAL_ELF" = "$EXPECTED_ELF" ]; then
say "love-nx $LOVE_NX_TAG already present and verified — skipping download"
"$ROOT/scripts/switch/verify_love_nx.sh"
exit 0
fi
fi
fetch_one love.nro
fetch_one love.elf
"$ROOT/scripts/switch/verify_love_nx.sh"
say "love-nx $LOVE_NX_TAG ready at $LOVE_NX_DIR"
+11
View File
@@ -0,0 +1,11 @@
# love-nx 11.5-nx1 — pinned runtime artifacts (RetronX team)
# https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1
#
# Download love.nro and love.elf from the release above and place them under:
# .bazinga/love-nx/11.5-nx1/
#
# These binaries are NOT committed. After fetching, fill in the SHA-256 fields
# below (run: shasum -a 256 .bazinga/love-nx/11.5-nx1/<file>).
love.nro 8290ac153d4c630e48c9b26ef9123f5204ed8ee0cef3042511707b5b645918f5
love.elf f820d2f73ed72a8a24002ccf540e8a12b16b2b4c86b69e82c0d3629e99547175
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Pack a Switch SD-ready zip: extract at microSD root (merge-safe update).
#
# Usage:
# scripts/switch/pack_sd_zip.sh NRO_PATH VERSION OUT_ZIP
#
# Layout inside the zip (SD root):
# switch/gen1recomp/gen1recomp.nro
# switch/gen1recomp/INSTALL.txt
# switch/gen1recomp/pokemon-love2d/imports/.../README.txt
# switch/gen1recomp/pokemon-love2d/exports/.../README.txt
#
# Does not ship ROMs, saves, or mods. Re-extracting merges over an existing
# install and only overwrites the NRO + these text placeholders — keep
# pokemon-love2d/ to preserve progress.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=common.sh
. "$SCRIPT_DIR/common.sh"
NRO_PATH="${1:-}"
VERSION="${2:-}"
OUT_ZIP="${3:-}"
[ -n "$NRO_PATH" ] && [ -n "$VERSION" ] && [ -n "$OUT_ZIP" ] \
|| fail "usage: scripts/switch/pack_sd_zip.sh NRO_PATH VERSION OUT_ZIP"
[ -f "$NRO_PATH" ] || fail "missing NRO at $NRO_PATH"
command -v zip >/dev/null 2>&1 || fail "need zip on PATH"
# Absolutize before any cd — relative OUT_ZIP would otherwise land inside the
# staging dir and vanish when the EXIT trap cleans up.
NRO_PATH="$(cd "$(dirname "$NRO_PATH")" && pwd)/$(basename "$NRO_PATH")"
OUT_DIR="$(dirname "$OUT_ZIP")"
mkdir -p "$OUT_DIR"
OUT_ZIP="$(cd "$OUT_DIR" && pwd)/$(basename "$OUT_ZIP")"
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/gen1recomp-sd-zip.XXXXXX")"
cleanup() { rm -rf "$STAGE"; }
trap cleanup EXIT
APP_DIR="$STAGE/switch/gen1recomp"
SAVE_ROOT="$APP_DIR/pokemon-love2d"
mkdir -p "$APP_DIR"
cp "$NRO_PATH" "$APP_DIR/gen1recomp.nro"
cat > "$APP_DIR/INSTALL.txt" <<EOF
gen1recomp Switch (v${VERSION})
===============================
First install or update (same steps):
1. Extract this zip at the root of your microSD (merge folders if asked).
2. Do NOT delete switch/gen1recomp/pokemon-love2d/ — that folder holds
your saves, imported ROMs, mods, and options. Re-extracting only
replaces gen1recomp.nro and these help files.
3. Launch with title override (hold R on HOME, open any title → hbmenu).
4. Copy a legal Pokemon Red/Blue .gb into:
switch/gen1recomp/pokemon-love2d/imports/
then use Scan again in the launcher if needed.
Inboxes (drop files here via MTP / SD / FTP):
imports/ — ROM .gb / .gbc
imports/mods/ — community mod .zip
imports/saves/red|blue|yellow/ — raw .sav import
exports/red|blue|yellow/ — pull after Export save
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
EOF
write_readme() {
local path="$1"
local body="$2"
mkdir -p "$(dirname "$path")"
printf '%s\n' "$body" > "$path"
}
write_readme "$SAVE_ROOT/imports/README.txt" \
"Put a legal Pokemon Red or Blue .gb / .gbc here, then Scan again in the launcher."
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
"Put community mod .zip files here, then MODS → Scan again."
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
"Put a Red .sav (32 KB) here, then Red tab → SAVE FILES → Import save."
write_readme "$SAVE_ROOT/imports/saves/blue/README.txt" \
"Put a Blue .sav (32 KB) here, then Blue tab → SAVE FILES → Import save."
write_readme "$SAVE_ROOT/imports/saves/yellow/README.txt" \
"Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save."
write_readme "$SAVE_ROOT/exports/red/README.txt" \
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
"After Export save (Blue), copy the .sav out of this folder via MTP / SD / FTP."
write_readme "$SAVE_ROOT/exports/yellow/README.txt" \
"After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
rm -f "$OUT_ZIP"
(
cd "$STAGE"
zip -q -r "$OUT_ZIP" switch
)
[ -f "$OUT_ZIP" ] || fail "zip was not created at $OUT_ZIP"
[ -s "$OUT_ZIP" ] || fail "zip is empty: $OUT_ZIP"
LISTING="$(unzip -Z1 "$OUT_ZIP" 2>/dev/null || unzip -l "$OUT_ZIP")"
printf '%s\n' "$LISTING" | grep -q 'switch/gen1recomp/gen1recomp.nro' \
|| fail "zip missing switch/gen1recomp/gen1recomp.nro"
REQUIRED=(
"switch/gen1recomp/INSTALL.txt"
"switch/gen1recomp/pokemon-love2d/imports/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/mods/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
)
for rel in "${REQUIRED[@]}"; do
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
done
ZIP_SHA="$(sha256_file "$OUT_ZIP")"
printf '%s %s\n' "$ZIP_SHA" "$OUT_ZIP" > "${OUT_ZIP}.sha256"
say "SD-ready zip: $OUT_ZIP"
printf '%s %s\n' "$ZIP_SHA" "$OUT_ZIP"
+322
View File
@@ -0,0 +1,322 @@
#!/usr/bin/env bash
# Offline self-test for Switch packaging entry points (no network, no nacptool).
#
# Usage: scripts/switch/selftest_build_switch.sh
#
# Covers: sha256_file, --help glossary, XOR loose/fused, fail_need_fetch,
# verify_love_nx mismatch, fail_fused_toolchain message, pack_sd_zip layout.
# Does not download love-nx or invoke nacptool/elf2nro/Docker.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=common.sh
. "$SCRIPT_DIR/common.sh"
PASS=0
FAIL=0
ok() {
PASS=$((PASS + 1))
printf ' PASS: %s\n' "$*"
}
bad() {
FAIL=$((FAIL + 1))
printf ' FAIL: %s\n' "$*" >&2
}
say "selftest_build_switch (offline)"
# ---------------------------------------------------------------------------
# 1. sha256_file on a known temp file
# ---------------------------------------------------------------------------
TMP="$(mktemp "${TMPDIR:-/tmp}/selftest-sha.XXXXXX")"
printf 'gen1recomp-selftest\n' > "$TMP"
EXPECTED_SHA="$(shasum -a 256 "$TMP" | awk '{print $1}')"
ACTUAL_SHA="$(sha256_file "$TMP")"
rm -f "$TMP"
if [ "$ACTUAL_SHA" = "$EXPECTED_SHA" ]; then
ok "sha256_file matches shasum ($ACTUAL_SHA)"
else
bad "sha256_file mismatch (expected $EXPECTED_SHA, got $ACTUAL_SHA)"
fi
# ---------------------------------------------------------------------------
# 2. --help contains fetch / loose / fused
# ---------------------------------------------------------------------------
HELP_OUT="$("$ROOT/scripts/build_switch.sh" --help 2>&1 || true)"
HELP_LC="$(printf '%s' "$HELP_OUT" | tr '[:upper:]' '[:lower:]')"
MISSING=""
printf '%s' "$HELP_LC" | grep -q 'fetch' || MISSING="${MISSING} fetch"
printf '%s' "$HELP_LC" | grep -q 'loose' || MISSING="${MISSING} loose"
printf '%s' "$HELP_LC" | grep -q 'fused' || MISSING="${MISSING} fused"
printf '%s' "$HELP_LC" | grep -Eq 'auto-download|downloads' || MISSING="${MISSING} auto-download"
printf '%s' "$HELP_LC" | grep -Eq 'non-goal|does not|never' || MISSING="${MISSING} non-goals"
if [ -z "$MISSING" ]; then
ok "build_switch.sh --help mentions fetch, loose, fused (+ auto-download/non-goals)"
else
bad "build_switch.sh --help missing:$MISSING"
fi
# ---------------------------------------------------------------------------
# 3. XOR --loose --fused exits non-zero
# ---------------------------------------------------------------------------
XOR_RC=0
"$ROOT/scripts/build_switch.sh" --loose --fused >/dev/null 2>&1 || XOR_RC=$?
if [ "$XOR_RC" -ne 0 ]; then
ok "build_switch.sh --loose --fused exits non-zero ($XOR_RC)"
else
bad "build_switch.sh --loose --fused should exit non-zero"
fi
# ---------------------------------------------------------------------------
# 4. assemble_loose without pin → stderr contains --fetch
# ---------------------------------------------------------------------------
STAGING="$(mktemp -d "${TMPDIR:-/tmp}/selftest-loose.XXXXXX")"
# shellcheck disable=SC2064
trap "rm -rf '$STAGING'" EXIT
FAKE_LOVE="$STAGING/game.love"
printf 'PK\x03\x04' > "$FAKE_LOVE" # minimal placeholder; assemble only checks -f
# Temporarily hide the pin dir if present by pointing ROOT's pin via a subshell
# that moves the pin aside — or run assemble against a missing path by
# ensuring .bazinga/love-nx/11.5-nx1/love.nro is absent for this check.
PIN_DIR="$ROOT/.bazinga/love-nx/11.5-nx1"
PIN_BACKUP=""
if [ -f "$PIN_DIR/love.nro" ]; then
PIN_BACKUP="$STAGING/love.nro.bak"
mv "$PIN_DIR/love.nro" "$PIN_BACKUP"
fi
ASS_ERR="$STAGING/assemble.err"
ASS_RC=0
"$ROOT/scripts/switch/assemble_loose.sh" "$FAKE_LOVE" >"$STAGING/assemble.out" 2>"$ASS_ERR" || ASS_RC=$?
if [ -n "$PIN_BACKUP" ]; then
mv "$PIN_BACKUP" "$PIN_DIR/love.nro"
fi
if [ "$ASS_RC" -ne 0 ] && grep -q -- '--fetch' "$ASS_ERR"; then
ok "assemble_loose without pin cites --fetch"
else
bad "assemble_loose without pin should fail citing --fetch (rc=$ASS_RC err=$(cat "$ASS_ERR"))"
fi
# ---------------------------------------------------------------------------
# 5a. verify_love_nx fails on checksum mismatch (corrupt copy)
# ---------------------------------------------------------------------------
VERIFY_WORK="$STAGING/verify-mismatch"
mkdir -p "$VERIFY_WORK"
# Create a private ROOT-like tree is hard; instead corrupt a temp copy and
# invoke verify by temporarily swapping the pin file.
CORRUPT_BACKUP=""
if [ -f "$PIN_DIR/love.nro" ]; then
CORRUPT_BACKUP="$STAGING/love.nro.real"
cp "$PIN_DIR/love.nro" "$CORRUPT_BACKUP"
printf 'not-the-real-love-nro\n' > "$PIN_DIR/love.nro"
VM_RC=0
VM_ERR="$STAGING/verify.err"
"$ROOT/scripts/switch/verify_love_nx.sh" >"$STAGING/verify.out" 2>"$VM_ERR" || VM_RC=$?
mv "$CORRUPT_BACKUP" "$PIN_DIR/love.nro"
if [ "$VM_RC" -ne 0 ] && grep -Eqi 'mismatch|expected' "$VM_ERR"; then
ok "verify_love_nx fails on checksum mismatch"
else
bad "verify_love_nx should fail on mismatch (rc=$VM_RC err=$(cat "$VM_ERR"))"
fi
else
# No real pin available — still assert sha256_file + manifest read path
ok "verify_love_nx mismatch skipped (no local pin binaries)"
fi
# ---------------------------------------------------------------------------
# 5b. Idempotent fetch skip when pin already valid (no network if present)
# ---------------------------------------------------------------------------
if [ -f "$PIN_DIR/love.nro" ] && [ -f "$PIN_DIR/love.elf" ]; then
if "$ROOT/scripts/switch/verify_love_nx.sh" >/dev/null 2>&1; then
FETCH_OUT="$STAGING/fetch.out"
FETCH_RC=0
"$ROOT/scripts/switch/fetch_love_nx.sh" >"$FETCH_OUT" 2>&1 || FETCH_RC=$?
if [ "$FETCH_RC" -eq 0 ] && grep -Eqi 'skip|already|verified' "$FETCH_OUT"; then
ok "fetch_love_nx idempotent skip when pin present"
elif [ "$FETCH_RC" -eq 0 ]; then
ok "fetch_love_nx exits 0 with existing valid pin"
else
bad "fetch_love_nx with valid pin failed (rc=$FETCH_RC out=$(cat "$FETCH_OUT"))"
fi
else
ok "fetch idempotent skipped (pin present but verify failed — left alone)"
fi
else
ok "fetch idempotent skipped (no local pin binaries; offline)"
fi
# ---------------------------------------------------------------------------
# 5b2. Mid-fetch / network failure: URL + tool status + retry --fetch (SWBLD-05)
# ---------------------------------------------------------------------------
FETCH_FAIL_ERR="$STAGING/fetch-fail.err"
FETCH_FAIL_OUT="$STAGING/fetch-fail.out"
FETCH_FAIL_RC=0
PIN_MOVED=""
if [ -d "$PIN_DIR" ]; then
PIN_MOVED="$STAGING/pin-backup"
mv "$PIN_DIR" "$PIN_MOVED"
fi
# Closed port / unreachable host — no real network asset required.
GEN1_LOVE_NX_BASE_URL="http://127.0.0.1:1" \
"$ROOT/scripts/switch/fetch_love_nx.sh" >"$FETCH_FAIL_OUT" 2>"$FETCH_FAIL_ERR" || FETCH_FAIL_RC=$?
if [ -n "$PIN_MOVED" ]; then
rm -rf "$PIN_DIR"
mv "$PIN_MOVED" "$PIN_DIR"
fi
if [ "$FETCH_FAIL_RC" -ne 0 ] \
&& grep -q 'download failed:' "$FETCH_FAIL_ERR" \
&& grep -Eq 'curl exit|wget exit|HTTP status' "$FETCH_FAIL_ERR" \
&& grep -q 'retry: scripts/build_switch.sh --fetch' "$FETCH_FAIL_ERR"; then
ok "fetch network failure cites URL status and retry --fetch"
else
bad "fetch failure should cite status + retry --fetch (rc=$FETCH_FAIL_RC err=$(cat "$FETCH_FAIL_ERR"))"
fi
# ---------------------------------------------------------------------------
# 5c. fail_fused_toolchain mentions docs/switch-build.md
# ---------------------------------------------------------------------------
FT_ERR="$STAGING/fused-toolchain.err"
FT_RC=0
(
# Invoke as a function in a subshell that sources common
. "$SCRIPT_DIR/common.sh"
fail_fused_toolchain
) >"$STAGING/fused-toolchain.out" 2>"$FT_ERR" || FT_RC=$?
if [ "$FT_RC" -ne 0 ] && grep -q 'docs/switch-build.md' "$FT_ERR"; then
ok "fail_fused_toolchain cites docs/switch-build.md"
else
bad "fail_fused_toolchain should mention docs/switch-build.md (rc=$FT_RC err=$(cat "$FT_ERR"))"
fi
# Also check multi-OS hints
FT_LC="$(tr '[:upper:]' '[:lower:]' < "$FT_ERR")"
OS_MISSING=""
printf '%s' "$FT_LC" | grep -q 'macos' || OS_MISSING="${OS_MISSING} macOS"
printf '%s' "$FT_LC" | grep -q 'linux' || OS_MISSING="${OS_MISSING} Linux"
printf '%s' "$FT_LC" | grep -Eq 'windows|msys' || OS_MISSING="${OS_MISSING} Windows"
printf '%s' "$FT_LC" | grep -q 'docker' || OS_MISSING="${OS_MISSING} Docker"
if [ -z "$OS_MISSING" ]; then
ok "fail_fused_toolchain mentions macOS/Linux/Windows/Docker"
else
bad "fail_fused_toolchain missing OS hints:$OS_MISSING"
fi
# ---------------------------------------------------------------------------
# 6. pack_sd_zip.sh builds SD-ready tree (offline; fake NRO)
# ---------------------------------------------------------------------------
FAKE_NRO="$STAGING/fake.nro"
printf 'fake-nro-bytes\n' > "$FAKE_NRO"
FAKE_ZIP="$STAGING/gen1recomp-0.0.0-test-switch.zip"
PACK_RC=0
"$ROOT/scripts/switch/pack_sd_zip.sh" "$FAKE_NRO" "0.0.0-test" "$FAKE_ZIP" \
>"$STAGING/pack.out" 2>"$STAGING/pack.err" || PACK_RC=$?
if [ "$PACK_RC" -eq 0 ] && [ -f "$FAKE_ZIP" ] && [ -s "$FAKE_ZIP" ]; then
ok "pack_sd_zip.sh writes a non-empty zip"
else
bad "pack_sd_zip.sh failed (rc=$PACK_RC err=$(cat "$STAGING/pack.err"))"
fi
ZIP_LIST="$(unzip -Z1 "$FAKE_ZIP" 2>/dev/null || unzip -l "$FAKE_ZIP")"
PACK_MISSING=""
for rel in \
"switch/gen1recomp/gen1recomp.nro" \
"switch/gen1recomp/INSTALL.txt" \
"switch/gen1recomp/pokemon-love2d/imports/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/mods/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
do
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
done
if [ -z "$PACK_MISSING" ]; then
ok "pack_sd_zip.sh zip contains SD tree + inbox READMEs"
else
bad "pack_sd_zip.sh zip missing:$PACK_MISSING"
fi
EXTRACT_DIR="$STAGING/extract-v1"
rm -rf "$EXTRACT_DIR"
mkdir -p "$EXTRACT_DIR"
unzip -q "$FAKE_ZIP" -d "$EXTRACT_DIR"
if cmp -s "$FAKE_NRO" "$EXTRACT_DIR/switch/gen1recomp/gen1recomp.nro"; then
ok "pack_sd_zip.sh NRO bytes match source"
else
bad "pack_sd_zip.sh NRO inside zip differs from source"
fi
if [ -f "${FAKE_ZIP}.sha256" ]; then
ok "pack_sd_zip.sh writes .sha256 sidecar"
else
bad "pack_sd_zip.sh should write ${FAKE_ZIP}.sha256"
fi
MISSING_NRO_RC=0
"$ROOT/scripts/switch/pack_sd_zip.sh" "$STAGING/does-not-exist.nro" "0.0.0" \
"$STAGING/should-fail.zip" >/dev/null 2>&1 || MISSING_NRO_RC=$?
if [ "$MISSING_NRO_RC" -ne 0 ]; then
ok "pack_sd_zip.sh fails when NRO is missing"
else
bad "pack_sd_zip.sh should fail on missing NRO"
fi
# Relative OUT_ZIP must survive (absolutized before cd into staging)
REL_DIR="$STAGING/rel-out"
mkdir -p "$REL_DIR"
REL_RC=0
(
cd "$REL_DIR"
"$ROOT/scripts/switch/pack_sd_zip.sh" "$FAKE_NRO" "0.0.1" "relative.zip" \
>"$STAGING/rel.out" 2>"$STAGING/rel.err"
) || REL_RC=$?
if [ "$REL_RC" -eq 0 ] && [ -f "$REL_DIR/relative.zip" ] && [ -s "$REL_DIR/relative.zip" ]; then
ok "pack_sd_zip.sh accepts relative OUT_ZIP"
else
bad "pack_sd_zip.sh relative OUT_ZIP failed (rc=$REL_RC err=$(cat "$STAGING/rel.err"))"
fi
# Merge-safe update: second extract replaces NRO, keeps user data
printf 'KEEP-SAVE' > "$EXTRACT_DIR/switch/gen1recomp/pokemon-love2d/slot.sav"
printf 'KEEP-ROM' > "$EXTRACT_DIR/switch/gen1recomp/pokemon-love2d/imports/red.gb"
printf 'KEEP-MOD' > "$EXTRACT_DIR/switch/gen1recomp/pokemon-love2d/imports/mods/mod.zip"
printf 'KEEP-OPTS' > "$EXTRACT_DIR/switch/gen1recomp/pokemon-love2d/options.lua"
FAKE_NRO2="$STAGING/fake-v2.nro"
printf 'fake-nro-bytes-v2\n' > "$FAKE_NRO2"
FAKE_ZIP2="$STAGING/gen1recomp-0.0.1-test-switch.zip"
"$ROOT/scripts/switch/pack_sd_zip.sh" "$FAKE_NRO2" "0.0.1-test" "$FAKE_ZIP2" \
>"$STAGING/pack2.out" 2>"$STAGING/pack2.err"
unzip -qo "$FAKE_ZIP2" -d "$EXTRACT_DIR"
MERGE_OK=1
cmp -s "$FAKE_NRO2" "$EXTRACT_DIR/switch/gen1recomp/gen1recomp.nro" || MERGE_OK=0
[ "$(cat "$EXTRACT_DIR/switch/gen1recomp/pokemon-love2d/slot.sav")" = "KEEP-SAVE" ] || MERGE_OK=0
[ "$(cat "$EXTRACT_DIR/switch/gen1recomp/pokemon-love2d/imports/red.gb")" = "KEEP-ROM" ] || MERGE_OK=0
[ "$(cat "$EXTRACT_DIR/switch/gen1recomp/pokemon-love2d/imports/mods/mod.zip")" = "KEEP-MOD" ] || MERGE_OK=0
[ "$(cat "$EXTRACT_DIR/switch/gen1recomp/pokemon-love2d/options.lua")" = "KEEP-OPTS" ] || MERGE_OK=0
if [ "$MERGE_OK" -eq 1 ]; then
ok "pack_sd_zip.sh merge update preserves user data"
else
bad "pack_sd_zip.sh merge update lost user data or failed to replace NRO"
fi
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
say "selftest: $PASS passed, $FAIL failed"
if [ "$FAIL" -ne 0 ]; then
exit 1
fi
exit 0
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Verify pinned love-nx binaries against the manifest checksums.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=common.sh
. "$SCRIPT_DIR/common.sh"
LOVE_NX_TAG="11.5-nx1"
LOVE_NX_DIR="$ROOT/.bazinga/love-nx/$LOVE_NX_TAG"
MANIFEST="$ROOT/scripts/switch/love-nx-11.5-nx1.sha256"
read_manifest_hash() {
local name="$1"
local line hash
line="$(grep -E "^${name}[[:space:]]+" "$MANIFEST" | head -1 || true)"
[ -n "$line" ] || fail "manifest missing entry for $name"
hash="$(printf '%s' "$line" | awk '{print $2}')"
case "$hash" in
TBD_*|"") fail "manifest hash for $name is not filled in ($hash)" ;;
esac
printf '%s' "$hash"
}
verify_file() {
local name="$1"
local path="$LOVE_NX_DIR/$name"
local expected actual
expected="$(read_manifest_hash "$name")"
[ -f "$path" ] || fail_need_fetch "missing pinned $name at $path"
actual="$(sha256_file "$path")"
[ "$actual" = "$expected" ] \
|| fail "$name checksum mismatch (expected $expected, got $actual)"
}
verify_file love.nro
verify_file love.elf
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Reject private / generated content inside a .love payload.
#
# Usage:
# scripts/switch/verify_payload.sh <path/to/game.love>
# scripts/switch/verify_payload.sh --self-test
#
# Fails on generated cache, ROM dumps (.gb/.gbc/.sav), rom-cache.complete,
# and save backup files (.bak). Never ships user ROMs or save data.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
LOVE_FILE=""
SELF_TEST=0
fail() { printf 'error: %s\n' "$*" >&2; exit 1; }
say() { printf 'verify_payload: %s\n' "$*"; }
while [ $# -gt 0 ]; do
case "$1" in
--self-test) SELF_TEST=1; shift ;;
-h|--help)
sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
if [ -z "$LOVE_FILE" ]; then
LOVE_FILE="$1"
else
fail "unknown argument: $1"
fi
shift
;;
esac
done
verify_love() {
local love="$1"
local listing
listing="$(mktemp "${TMPDIR:-/tmp}/love-listing.XXXXXX")"
[ -f "$love" ] || { rm -f "$listing"; fail "missing love archive: $love"; }
unzip -Z1 "$love" > "$listing"
if grep -Eq '^(data|assets)/generated/|/(data|assets)/generated/' "$listing"; then
rm -f "$listing"
fail "forbidden generated cache path in $love"
fi
if grep -Eiq '\.(gb|gbc|sav)$' "$listing"; then
rm -f "$listing"
fail "forbidden ROM or save file extension in $love"
fi
if grep -Eq '(^|/)rom-cache\.complete$' "$listing"; then
rm -f "$listing"
fail "forbidden rom-cache.complete marker in $love"
fi
if grep -Eiq '\.bak$' "$listing"; then
rm -f "$listing"
fail "forbidden save backup (.bak) in $love"
fi
rm -f "$listing"
say "OK $love"
}
run_self_test() {
local work clean bad staging
work="$(mktemp -d "${TMPDIR:-/tmp}/verify-payload.XXXXXX")"
trap "rm -rf '$work'" EXIT
clean="$work/clean.love"
"$ROOT/scripts/pack_love.sh" --output "$clean" --listing "$work/clean-listing.txt" >/dev/null
verify_love "$clean"
bad="$work/bad.love"
cp "$clean" "$bad"
staging="$work/staging"
mkdir -p "$staging/data/generated"
echo "secret" > "$staging/data/generated/rom.bin"
(cd "$staging" && zip -q "$bad" data/generated/rom.bin)
if ( verify_love "$bad" >/dev/null 2>&1 ); then
fail "self-test: expected forbidden generated path to fail"
fi
bad2="$work/bad2.love"
cp "$clean" "$bad2"
echo "x" > "$work/rom-cache.complete"
(cd "$work" && zip -q "$bad2" rom-cache.complete)
if ( verify_love "$bad2" >/dev/null 2>&1 ); then
fail "self-test: expected rom-cache.complete to fail"
fi
say "self-test OK"
}
if [ "$SELF_TEST" -eq 1 ]; then
run_self_test
exit 0
fi
[ -n "$LOVE_FILE" ] || fail "usage: $0 <game.love> | --self-test"
verify_love "$LOVE_FILE"
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Write build-info.json for Switch artifacts.
#
# Usage: scripts/switch/write_build_info.sh OUTPUT.json [VERSION]
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
LOVE_NX_TAG="11.5-nx1"
MANIFEST="$ROOT/scripts/switch/love-nx-11.5-nx1.sha256"
OUTPUT="${1:-}"
VERSION="${2:-}"
fail() { printf 'error: %s\n' "$*" >&2; exit 1; }
[ -n "$OUTPUT" ] || fail "usage: $0 OUTPUT.json [VERSION]"
[ -f "$MANIFEST" ] || fail "missing love-nx manifest: $MANIFEST"
if [ -z "$VERSION" ]; then
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
fi
GIT_COMMIT="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || echo unknown)"
GIT_SHORT="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
read_manifest_hash() {
local name="$1"
local line hash
line="$(grep -E "^${name}[[:space:]]+" "$MANIFEST" | head -1 || true)"
[ -n "$line" ] || fail "manifest missing entry for $name"
hash="$(printf '%s' "$line" | awk '{print $2}')"
case "$hash" in
TBD_*|"") fail "manifest hash for $name is not filled in ($hash)" ;;
esac
printf '%s' "$hash"
}
LOVE_NRO_SHA="$(read_manifest_hash love.nro)"
LOVE_ELF_SHA="$(read_manifest_hash love.elf)"
mkdir -p "$(dirname "$OUTPUT")"
cat > "$OUTPUT" <<EOF
{
"version": "$VERSION",
"gitCommit": "$GIT_SHORT",
"gitCommitFull": "$GIT_COMMIT",
"loveNxTag": "$LOVE_NX_TAG",
"loveNxChecksums": {
"love.nro": "$LOVE_NRO_SHA",
"love.elf": "$LOVE_ELF_SHA"
},
"builtAt": "$BUILT_AT"
}
EOF
+9
View File
@@ -65,6 +65,14 @@ run_tier() {
# ------- ROM-free tiers: these are what CI runs # ------- ROM-free tiers: these are what CI runs
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
# Sound.lua / overlay regression is not gated only on switch-changes paths.
run_tier "T0 NX asset overlay fallback" "$LUA" tests/engine/assets_version_fallback_test.lua
run_tier "T0 NX generated-path static guard" "$LUA" tests/engine/nx_generated_guard_test.lua
run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_boot_test.lua
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
@@ -124,6 +132,7 @@ if [ -f data/generated/maps.lua ]; then
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua
run_tier "T3 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
fi fi
else else
+124 -24
View File
@@ -380,18 +380,21 @@ local CHARGE_TEXT = {
-- pokered's <USER>/<TARGET> text macros (home/text.asm -- pokered's <USER>/<TARGET> text macros (home/text.asm
-- PlaceMoveUsersName): battle texts naming the enemy mon print -- PlaceMoveUsersName): battle texts naming the enemy mon print
-- "Enemy " before the nickname; player-side mons never get it. -- "Enemy " before the nickname; player-side mons never get it.
-- Translatable as one "Enemy %s" template (#779) so languages that
-- qualify after the name, or decline, can (e.g. "%s ennemi").
local function displayName(b) local function displayName(b)
return b.isPlayer and b.name or ("Enemy " .. b.name) return b.isPlayer and b.name or Strings("Enemy %s", b.name)
end end
-- Apply the "Enemy " prefix to a pre-built message from a module that -- Apply the enemy qualifier to a pre-built message from a module that
-- only knows the raw nickname (Status.beforeMove/residual, -- only knows the raw nickname (Status.beforeMove/residual): replace the
-- TrainerAI.useItem): splice it in before the first name occurrence. -- first name occurrence with the qualified form.
local function prefixEnemy(msg, battler) local function prefixEnemy(msg, battler)
if battler.isPlayer then return msg end if battler.isPlayer then return msg end
local s = msg:find(battler.name, 1, true) local s = msg:find(battler.name, 1, true)
if not s then return msg end if not s then return msg end
return msg:sub(1, s - 1) .. "Enemy " .. msg:sub(s) return msg:sub(1, s - 1) .. Strings("Enemy %s", battler.name)
.. msg:sub(s + #battler.name)
end end
-- Level-up stats window (PrintStatsBox .LevelUpStatsBox: box (9,2) -- Level-up stats window (PrintStatsBox .LevelUpStatsBox: box (9,2)
@@ -525,6 +528,10 @@ BattleState.StatBox = StatBox -- the level-up stat window (PrintStatsBox)
local function newBattle(game) local function newBattle(game)
local self = setmetatable({}, BattleState) local self = setmetatable({}, BattleState)
self.game = game self.game = game
-- InitBattleVariables (engine/battle/init_battle_variables.asm) zeroes
-- wPartyAndBillsPCSavedMenuItem, so entering a battle drops the party
-- cursor the field menu has been carrying (src/ui/PartyMenu.lua). #768
game.partyMenuSavedIndex = nil
self.data = game.data self.data = game.data
-- ruleset from the merged registry (the requires above are the same -- ruleset from the merged registry (the requires above are the same
-- records on a mod-free boot); an unknown save value falls back to the -- records on a mod-free boot); an unknown save value falls back to the
@@ -641,6 +648,9 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
local self = newBattle(game) local self = newBattle(game)
self.kind = "trainer" self.kind = "trainer"
self.oppClass = oppClass self.oppClass = oppClass
-- the object_event trainer arg (roster index). computeMusicKind keys
-- data/scripts/victories.lua on class#party, so keep it on the battle (#782).
self.partyIndex = partyIndex or 1
self.trainer = game.data.trainers[oppClass] self.trainer = game.data.trainers[oppClass]
assert(self.trainer, "unknown trainer class " .. tostring(oppClass)) assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName -- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
@@ -768,13 +778,23 @@ end
-- engine/battle/core.asm DisplayBattleMenu .oldManName branch): no -- engine/battle/core.asm DisplayBattleMenu .oldManName branch): no
-- player mon; the battle menu appears under the OLD MAN's name and a -- player mon; the battle menu appears under the OLD MAN's name and a
-- scripted cursor hovers FIGHT, hops to ITEM and forces the item menu -- scripted cursor hovers FIGHT, hops to ITEM and forces the item menu
-- (one POKé BALL x50). The throw always catches; nothing is kept. -- (one POKé BALL x50). Nothing is kept.
-- Yellow's Pallet intro (BATTLE_TYPE_PIKACHU) is the same simulated -- Yellow's Pallet intro (BATTLE_TYPE_PIKACHU) is the same simulated
-- script under "PROF.OAK" (pokeyellow core.asm .profOakName), so the -- script under "PROF.OAK" (pokeyellow core.asm .profOakName), so the
-- displayed thrower name is a parameter. -- displayed thrower name is a parameter.
function BattleState:makeOldManDemo(name) -- The throw catches everywhere except Yellow's FIRST Viridian training.
-- ItemUseBall's .oldManBattle branch checks EVENT_INITIAL_CATCH_TRAINING
-- and, when it is set, stores anim data $63 in place of the $43 capture
-- value -- three shakes, then a breakout (pokeyellow
-- engine/items/item_effects.asm). Red/Blue's ItemUseBall has no such
-- branch and jumps straight to .captured, and Yellow's repeat "Watch
-- closely!" demo resets the event before its battle
-- (ViridianCityOldManStartCatchTrainingScript), so only the initial
-- tutorial passes failThrow -- it stands in for that event (#636).
function BattleState:makeOldManDemo(name, failThrow)
self.demo = true self.demo = true
self.demoName = name or "OLD MAN" self.demoName = name or "OLD MAN"
self.demoFails = failThrow and true or false
-- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType: -- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType:
-- BATTLE_TYPE_OLD_MAN gets .oldManName + OldManPicBack, BATTLE_TYPE_PIKACHU -- BATTLE_TYPE_OLD_MAN gets .oldManName + OldManPicBack, BATTLE_TYPE_PIKACHU
-- gets .profOakName + ProfOakPicBack (pokeyellow core.asm). The thrower -- gets .profOakName + ProfOakPicBack (pokeyellow core.asm). The thrower
@@ -811,6 +831,16 @@ function BattleState:say(text)
table.insert(self.queue, { text = text }) table.insert(self.queue, { text = text })
end end
-- A message whose ROM tail is `text_end` / `done` rather than `prompt`:
-- NextTextCommand returns straight out of PrintText on TX_END
-- (home/text.asm:328-334) and only TX_PROMPT_BUTTON blinks the arrow and
-- runs ManualTextScroll (home/text.asm:434-446), so these pages never wait
-- on the player. autoDelay is the frame hold before the queue moves on
-- (0 = the next row starts immediately, as PrintText returning does) (#765).
function BattleState:sayAuto(text, delay)
table.insert(self.queue, { text = text, auto = true, autoDelay = delay or 0 })
end
-- Message that opens YES/NO once typed out, keeping the text visible -- Message that opens YES/NO once typed out, keeping the text visible
-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice). -- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice).
function BattleState:sayChoice(text, onChoose) function BattleState:sayChoice(text, onChoose)
@@ -851,6 +881,13 @@ function BattleState:sayNext(text)
table.insert(self.queue, self.nextInsert, { text = text }) table.insert(self.queue, self.nextInsert, { text = text })
end end
-- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
function BattleState:sayNextAuto(text, delay)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert,
{ text = text, auto = true, autoDelay = delay or 0 })
end
-- insert a UI push right after the current queue item (dex page, the -- insert a UI push right after the current queue item (dex page, the
-- level-up stat box -- anything that must keep queue order) -- level-up stat box -- anything that must keep queue order)
function BattleState:uiNext(factory) function BattleState:uiNext(factory)
@@ -1001,6 +1038,8 @@ function BattleState:startMessage(item)
self.charIndex = 0 self.charIndex = 0
self.msgWaiting = nil self.msgWaiting = nil
self.msgPrompt = nil self.msgPrompt = nil
self.msgAutoWait = nil
self.msgHold = nil
self.scrollPx = nil self.scrollPx = nil
self:beginMsgLine() self:beginMsgLine()
end end
@@ -1255,7 +1294,25 @@ function BattleState:updateQueue()
end)) end))
return true return true
end end
if not (item and item.choice) then if item and item.auto then
-- No prompt: this page's ROM tail is `text_end`, so PrintText is
-- already back with the box still on screen -- pokered's used-move
-- line (engine/battle/used_move_text.asm EndUsedMove1Text..
-- EndUsedMove5Text) and the item-use line (ItemUseText00,
-- engine/items/item_effects.asm) are both of that kind. Only
-- TX_PROMPT_BUTTON waits on A/B (home/text.asm:434-446) (#765).
self.msgAutoWait = self.msgAutoWait or item.autoDelay or 0
if self.msgAutoWait > 0 then
self.msgAutoWait = self.msgAutoWait - 1
else
self.msgAutoWait = nil
-- the typed page stays drawn behind whatever runs next (the move
-- animation, the ball toss): PrintText leaves the textbox tilemap
-- alone and animations only touch sprites (#296)
self.msgHold = true
self.current = nil
end
elseif not (item and item.choice) then
-- The page is typed out and waiting on the player: PromptText -- The page is typed out and waiting on the player: PromptText
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll -- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
-- blinks it until A/B, so the arrow belongs on a finished page and not -- blinks it until A/B, so the arrow belongs on a finished page and not
@@ -1307,14 +1364,17 @@ end
-- gets the final-battle theme -- gets the final-battle theme
function BattleState:computeMusicKind() function BattleState:computeMusicKind()
local isBoss = false local isBoss = false
if self.kind == "trainer" and self.trainer then if self.kind == "trainer" and self.oppClass then
-- wGymLeaderNo is written only by the eight gym scripts
-- (scripts/PewterGym.asm .. ViridianGym.asm), so the badge rosters in
-- victories.lua are exactly the fights that set it. The lookup must
-- include the party index: a class-wide prefix match also caught
-- Giovanni's Rocket Hideout (#1) and Silph Co (#2) battles, which never
-- touch wGymLeaderNo and take MUSIC_TRAINER_BATTLE like any other
-- trainer (#782).
local victories = require("data.scripts.victories") local victories = require("data.scripts.victories")
for key, reward in pairs(victories) do local reward = victories[self.oppClass .. "#" .. tostring(self.partyIndex or 1)]
if reward.badge and key:find(self.trainer.id .. "#", 1, true) == 1 then isBoss = reward ~= nil and reward.badge ~= nil
isBoss = true
break
end
end
end end
-- init_battle.asm: challenging a gym leader (wGymLeaderNo, the badge -- init_battle.asm: challenging a gym leader (wGymLeaderNo, the badge
-- fights only -- not Lance or the Champion) bumps the companion's -- fights only -- not Lance or the Champion) bumps the companion's
@@ -1562,6 +1622,9 @@ end
-- (end_of_battle.asm clears wLowHealthAlarm when a battle ends) -- (end_of_battle.asm clears wLowHealthAlarm when a battle ends)
function BattleState:exit() function BattleState:exit()
require("src.core.Sound").stopLoop("Low_Health_Alarm") require("src.core.Sound").stopLoop("Low_Health_Alarm")
-- end_of_battle.asm clears wPartyAndBillsPCSavedMenuItem as well, so the
-- field party menu comes back on slot 1 after a battle. #768
self.game.partyMenuSavedIndex = nil
-- Free this battle's own GPU objects now rather than waiting on a GC -- Free this battle's own GPU objects now rather than waiting on a GC
-- finalizer: the two full-screen wavy-effect canvases (colorMode) and -- finalizer: the two full-screen wavy-effect canvases (colorMode) and
-- the AnimPlayer's per-instance tilesheet images/quads. The shared -- the AnimPlayer's per-instance tilesheet images/quads. The shared
@@ -1588,6 +1651,19 @@ local function clearTrapping(battler)
battler.trapDamage = nil battler.trapDamage = nil
end end
-- SendOutMon (core.asm:1733-1735) clears both battle cursors, though the
-- disassembly only names one of them: `ld hl, wBattleAndStartSavedMenuItem /
-- ld [hli], a / ld [hl], a` writes zero to that byte AND to the byte behind
-- it, which is wPlayerMoveListIndex (wram.asm:242-244). So every player
-- send-out puts the main menu back on FIGHT and the move list back on the
-- first slot; the cursors are only remembered across sub-menus of the mon
-- that is already out (#737). Enemy send-outs run EnemySendOutFirstMon,
-- not SendOutMon, and leave both alone.
local function sendOutMonCursors(self)
self.menuIndex = 1
self.moveIndex = 1
end
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move -- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
-- selection opens, but the clear is skipped for a mon that must recharge or -- selection opens, but the clear is skipped for a mon that must recharge or
-- is locked into Rage (core.asm:293-295 -- the Hyper Beam flinch-recharge -- is locked into Rage (core.asm:293-295 -- the Hyper Beam flinch-recharge
@@ -2061,19 +2137,30 @@ end
-- skipped -- the old man branch jumps straight to .captured, $43 anim -- skipped -- the old man branch jumps straight to .captured, $43 anim
-- data = 3 shakes and caught (:155-164 + :193-200) -- and -- data = 3 shakes and caught (:155-164 + :193-200) -- and
-- .oldManCaughtMon prints the caught text WITHOUT adding the mon to -- .oldManCaughtMon prints the caught text WITHOUT adding the mon to
-- the party or the dex (:568-570). The "used" line reads OLD MAN -- the party or the dex (:568-570). Yellow's initial training is the one
-- exception (demoFails, #636): its .oldManBattle branch forces $63, so
-- the same chain ends in a breakout and ItemUseBallText04 instead.
-- The "used" line reads OLD MAN
-- because DisplayBattleMenu swapped wPlayerName (core.asm:2024-2037); -- because DisplayBattleMenu swapped wPlayerName (core.asm:2024-2037);
-- no ball is consumed (.done returns early, :576-578). -- no ball is consumed (.done returns early, :576-578).
function BattleState:oldManThrow() function BattleState:oldManThrow()
self.phase = "messages" self.phase = "messages"
self.afterQueue = "finish" self.afterQueue = "finish"
self.result = "run" -- nothing is kept; wBattleResult only ends the demo self.result = "run" -- nothing is kept; wBattleResult only ends the demo
self:say(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN")) self:sayAuto(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN"))
self:act(function() self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss") require("src.core.Sound").play(self.data, "Ball_Toss")
-- ItemUseBall's beat before the toss chain (like throwBall) -- ItemUseBall's beat before the toss chain (like throwBall)
self.nextInsert = (self.nextInsert or 0) + 1 self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 20 }) table.insert(self.queue, self.nextInsert, { wait = 20 })
if self.demoFails then
-- $63 instead of $43: the same three shakes, then POOF+SHOWPIC and
-- ItemUseBallText04. No sound_caught_mon, and .captured is never
-- reached, so nothing touches the party or the dex either (#636).
self:ballChain("TOSS_ANIM", false, 3, "POKE_BALL")
self:sayNext(self:ballMissMessage(3))
return
end
self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL") self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL")
self:actNext(function() self:actNext(function()
require("src.core.Sound").play(self.data, "Caught_Mon") require("src.core.Sound").play(self.data, "Caught_Mon")
@@ -2254,6 +2341,7 @@ function BattleState:resolveSwitch(newMon)
previous = previous, previous = previous,
}) })
self:markParticipant() self:markParticipant()
sendOutMonCursors(self)
self.sendingOut = true self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name)) self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false) self:animNext("POOF_ANIM", false)
@@ -3023,8 +3111,11 @@ function BattleState:executeAction(user, target, action)
-- trainer class AI actions (engine/battle/trainer_ai.asm) -- trainer class AI actions (engine/battle/trainer_ai.asm)
if action.special == "aiItem" then if action.special == "aiItem" then
self.aiUses = (self.aiUses or 1) - 1 self.aiUses = (self.aiUses or 1) - 1
-- useItem's messages arrive final: its item line prints the raw
-- nickname on purpose (no "Enemy " in AIPrintItemUseText), so the
-- prefix splice must not touch them.
for _, m in ipairs(TrainerAI.useItem(self, action.item)) do for _, m in ipairs(TrainerAI.useItem(self, action.item)) do
self:sayNext(prefixEnemy(m, self.enemy)) self:sayNext(m)
end end
self:drainNext() self:drainNext()
require("src.core.Sound").play(self.data, "Heal_Ailment") require("src.core.Sound").play(self.data, "Heal_Ailment")
@@ -3225,10 +3316,16 @@ end
-- damaging pipeline (EffectRegistry.runDamaging). -- damaging pipeline (EffectRegistry.runDamaging).
-- Gen 1 status/stat primary effects call PlayCurrentMoveAnimation only -- Gen 1 status/stat primary effects call PlayCurrentMoveAnimation only
-- after they land; these failure texts print with no animation. -- after they land; these failure texts print with no animation. Failures
-- whose text is an ordinary sentence rather than one of the shared fail
-- lines set msgs.failed instead of relying on this sniffer -- Substitute's
-- two failure lines name the move, not the failure (#644).
local function primaryEffectFailed(msgs) local function primaryEffectFailed(msgs)
if not msgs or #msgs == 0 then return true end if not msgs or #msgs == 0 then return true end
local m = msgs[1] if msgs.failed then return true end
-- the extracted lines keep the ROM's own trailing blank ("But, it
-- failed! "), so match with it trimmed or a refused status animates
local m = msgs[1]:gsub("%s+$", "")
if m == "But, it failed!" or m == "Nothing happened!" then return true end if m == "But, it failed!" or m == "Nothing happened!" then return true end
if m:find("didn't affect", 1, true) then return true end if m:find("didn't affect", 1, true) then return true end
if m:find("is unaffected", 1, true) then return true end if m:find("is unaffected", 1, true) then return true end
@@ -3273,7 +3370,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
self.moveAnimRow = nil self.moveAnimRow = nil
if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then
self:sayNext(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name)) self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
-- the move's animation plays right after the announcement; the -- the move's animation plays right after the announcement; the
-- damage path attaches the target's hit blink to this row so the -- damage path attaches the target's hit blink to this row so the
-- blink follows the animation (pokered's order). Mimic is the -- blink follows the animation (pokered's order). Mimic is the
@@ -3769,6 +3866,7 @@ function BattleState:enemyMonFainted()
self.participants = {} self.participants = {}
self:markParticipant() self:markParticipant()
self.nextInsert = 0 self.nextInsert = 0
sendOutMonCursors(self)
self.sendingOut = true self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name)) self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false) self:animNext("POOF_ANIM", false)
@@ -3961,6 +4059,7 @@ function BattleState:openReplacementMenu()
}) })
self:markParticipant() self:markParticipant()
self.nextInsert = 0 self.nextInsert = 0
sendOutMonCursors(self)
self.sendingOut = true self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name)) self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false) self:animNext("POOF_ANIM", false)
@@ -3999,7 +4098,7 @@ function BattleState:safariAction(choice)
if choice == "ball" then if choice == "ball" then
st.balls = st.balls - 1 st.balls = st.balls - 1
self:say(Strings("%s used\nSAFARI BALL!", playerName)) self:sayAuto(Strings("%s used\nSAFARI BALL!", playerName))
self:act(function() self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss") require("src.core.Sound").play(self.data, "Ball_Toss")
self.lastBall = "SAFARI_BALL" self.lastBall = "SAFARI_BALL"
@@ -4321,7 +4420,7 @@ function BattleState:throwBall(ball)
-- "<PLAYER> used <ITEM>!" line (#291). Safari and the old man demo are -- "<PLAYER> used <ITEM>!" line (#291). Safari and the old man demo are
-- still wIsInBattle == 1, and this port models both as kind == "wild". -- still wIsInBattle == 1, and this port models both as kind == "wild".
if self.kind == "wild" then if self.kind == "wild" then
self:say(self:romText("_ItemUseText001", "%s used\n%s!", self.game.save.player.name, self:sayAuto(self:romText("_ItemUseText001", "%s used\n%s!", self.game.save.player.name,
self.data.items[ball].name)) self.data.items[ball].name))
end end
self:act(function() self:act(function()
@@ -5321,7 +5420,8 @@ end
function BattleState:drawTextArea() function BattleState:drawTextArea()
Font.drawBox(0, 12, 20, 6) Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
if self.phase == "messages" and (self.current or self.animPlaying) then if self.phase == "messages"
and (self.current or self.animPlaying or self.msgHold) then
-- during the move animation self.current is nil but shown still holds -- during the move animation self.current is nil but shown still holds
-- the "used X!" lines; keep drawing them like pokered, whose move -- the "used X!" lines; keep drawing them like pokered, whose move
-- animations only touch sprites and never the textbox tilemap (#296) -- animations only touch sprites and never the textbox tilemap (#296)
+1 -1
View File
@@ -29,7 +29,7 @@ end
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy -- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
-- mon's nickname (home/text.asm PlaceMoveUsersName) -- mon's nickname (home/text.asm PlaceMoveUsersName)
local function displayName(b) local function displayName(b)
return b.isPlayer and b.name or ("Enemy " .. b.name) return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
end end
EffectRegistry.displayName = displayName EffectRegistry.displayName = displayName
+18 -4
View File
@@ -23,7 +23,7 @@ local MoveEffects = {}
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the -- pokered's <USER>/<TARGET> text macros print "Enemy " before the
-- enemy mon's nickname (home/text.asm PlaceMoveUsersName) -- enemy mon's nickname (home/text.asm PlaceMoveUsersName)
local function displayName(b) local function displayName(b)
return b.isPlayer and b.name or ("Enemy " .. b.name) return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
end end
local STAT_LABEL = { local STAT_LABEL = {
@@ -259,15 +259,29 @@ MoveEffects.primary = {
return { romText(battle.data, "_StatusChangesEliminatedText", "All STATUS changes\nare eliminated!") } return { romText(battle.data, "_StatusChangesEliminatedText", "All STATUS changes\nare eliminated!") }
end, end,
-- substitute.asm reaches its PlayCurrentMoveAnimation / AnimationSubstitute
-- Bankswitch only inside the success branch, after `set HAS_SUBSTITUTE_UP`;
-- .alreadyHasSubstitute and .notEnoughHP fall straight through to PrintText,
-- so both failures print with no animation at all. That is load bearing
-- here: the SUBSTITUTE animation opens with SE_SLIDE_MON_OFF, which leaves
-- the user's pic hidden (BattleState.lua slideOff end state) until the doll
-- is drawn in its place -- and with no substituteHP raised there is no doll,
-- so a failed Substitute used to erase the user's sprite for the rest of the
-- battle (#644). The failed flag rides the message list so performMove can
-- peel the announcement-time anim row without matching on printed text.
SUBSTITUTE_EFFECT = function(battle, user) SUBSTITUTE_EFFECT = function(battle, user)
if user.substituteHP then return { romText(battle.data, "_HasSubstituteText", "%s\nhas a SUBSTITUTE!", displayName(user)) } end if user.substituteHP then
return { romText(battle.data, "_HasSubstituteText", "%s\nhas a SUBSTITUTE!", displayName(user)),
failed = true }
end
local cost = math.floor(user.mon.stats.hp / 4) local cost = math.floor(user.mon.stats.hp / 4)
-- substitute.asm only fails on subtraction underflow (current HP -- substitute.asm only fails on subtraction underflow (current HP
-- strictly below maxHP/4); at equality the substitute is built and -- strictly below maxHP/4); at equality the substitute is built and
-- the user is left standing on exactly 0 HP (it faints only when -- the user is left standing on exactly 0 HP (it faints only when
-- the engine next checks HP, not here) -- the engine next checks HP, not here)
if user.mon.hp < cost then if user.mon.hp < cost then
return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!") } return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!"),
failed = true }
end end
user.mon.hp = user.mon.hp - cost user.mon.hp = user.mon.hp - cost
user.substituteHP = cost + 1 user.substituteHP = cost + 1
@@ -332,7 +346,7 @@ MoveEffects.primary = {
battle.data.moves[id].name) } battle.data.moves[id].name) }
end, end,
SPLASH_EFFECT = function() SPLASH_EFFECT = function(battle)
return { romText(battle.data, "_NoEffectText", "No effect!") } return { romText(battle.data, "_NoEffectText", "No effect!") }
end, end,
} }
+49 -30
View File
@@ -6,6 +6,7 @@
-- back to the vanilla records, which is bit-identical behavior. -- back to the vanilla records, which is bit-identical behavior.
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local romText = require("src.core.RomText")
local Status = {} local Status = {}
@@ -34,8 +35,8 @@ end
-- sentence rather than the noun: "hurt by poison" and "hurt by the burn" -- sentence rather than the noun: "hurt by poison" and "hurt by the burn"
-- decline differently once translated, so a shared fragment cannot be the -- decline differently once translated, so a shared fragment cannot be the
-- translatable unit. -- translatable unit.
local function damageOverTime(template) local function damageOverTime(label, template)
return function(battler) return function(battler, _, battle)
local mon = battler.mon local mon = battler.mon
local base = math.max(1, math.floor(mon.stats.hp / 16)) local base = math.max(1, math.floor(mon.stats.hp / 16))
local dmg = base local dmg = base
@@ -44,7 +45,7 @@ local function damageOverTime(template)
battler.toxicCounter = battler.toxicCounter + 1 battler.toxicCounter = battler.toxicCounter + 1
end end
mon.hp = math.max(0, mon.hp - dmg) mon.hp = math.max(0, mon.hp - dmg)
return { Strings(template, name(battler)) } return { romText(battle and battle.data, label, template, name(battler)) }
end end
end end
@@ -63,53 +64,63 @@ Status.RECORDS = {
id = "SLP", label = "SLP", hudLabel = "SLP", id = "SLP", label = "SLP", hudLabel = "SLP",
catchBonus = 25, shakeBonus = 10, catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 40, beforeMovePriority = 40,
beforeMove = function(battler) beforeMove = function(battler, _, battle)
battler.sleepTurns = (battler.sleepTurns or 1) - 1 battler.sleepTurns = (battler.sleepTurns or 1) - 1
if battler.sleepTurns <= 0 then if battler.sleepTurns <= 0 then
battler.mon.status = nil battler.mon.status = nil
return false, { Strings("%s\nwoke up!", name(battler)) } -- wakes, loses the turn -- wakes, loses the turn
return false, { romText(battle and battle.data, "_WokeUpText",
"%s\nwoke up!", name(battler)) }
end end
return false, { Strings("%s\nis fast asleep!", name(battler)) } return false, { romText(battle and battle.data, "_FastAsleepText",
"%s\nis fast asleep!", name(battler)) }
end, end,
onInflict = function(battle, target, opts, display) onInflict = function(battle, target, opts, display)
target.sleepTurns = battle.rng(1, 7) target.sleepTurns = battle.rng(1, 7)
return { Strings("%s\nfell asleep!", display) } return { romText(battle.data, "_FellAsleepText",
"%s\nfell asleep!", display) }
end, end,
}, },
FRZ = { FRZ = {
id = "FRZ", label = "FRZ", hudLabel = "FRZ", id = "FRZ", label = "FRZ", hudLabel = "FRZ",
catchBonus = 25, shakeBonus = 10, catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 30, beforeMovePriority = 30,
beforeMove = function(battler) beforeMove = function(battler, _, battle)
return false, { Strings("%s\nis frozen solid!", name(battler)) } return false, { romText(battle and battle.data, "_IsFrozenText",
"%s\nis frozen solid!", name(battler)) }
end, end,
canInflict = function(target) return not hasType(target, "ICE") end, canInflict = function(target) return not hasType(target, "ICE") end,
onInflict = function(_, _, _, display) onInflict = function(battle, _, _, display)
return { Strings("%s\nwas frozen solid!", display) } return { romText(battle and battle.data, "_FrozenText",
"%s\nwas frozen solid!", display) }
end, end,
}, },
PSN = { PSN = {
id = "PSN", label = "PSN", hudLabel = "PSN", id = "PSN", label = "PSN", hudLabel = "PSN",
catchBonus = 12, shakeBonus = 5, catchBonus = 12, shakeBonus = 5,
residual = damageOverTime(Strings.source("%s's\nhurt by poison!")), residual = damageOverTime("_HurtByPoisonText",
Strings.source("%s's\nhurt by poison!")),
canInflict = function(target) return not hasType(target, "POISON") end, canInflict = function(target) return not hasType(target, "POISON") end,
onInflict = function(_, target, opts, display) onInflict = function(battle, target, opts, display)
if opts.toxic then if opts.toxic then
target.toxicCounter = 1 target.toxicCounter = 1
-- _BadlyPoisonedText return { romText(battle and battle.data, "_BadlyPoisonedText",
return { Strings("%s's\nbadly poisoned!", display) } "%s's\nbadly poisoned!", display) }
end end
return { Strings("%s\nwas poisoned!", display) } return { romText(battle and battle.data, "_PoisonedText",
"%s\nwas poisoned!", display) }
end, end,
}, },
BRN = { BRN = {
id = "BRN", label = "BRN", hudLabel = "BRN", id = "BRN", label = "BRN", hudLabel = "BRN",
catchBonus = 12, shakeBonus = 5, catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "attack", div = 2 }, statPenalty = { stat = "attack", div = 2 },
residual = damageOverTime(Strings.source("%s's\nhurt by the burn!")), residual = damageOverTime("_HurtByBurnText",
Strings.source("%s's\nhurt by the burn!")),
canInflict = function(target) return not hasType(target, "FIRE") end, canInflict = function(target) return not hasType(target, "FIRE") end,
onInflict = function(_, _, _, display) onInflict = function(battle, _, _, display)
return { Strings("%s\nwas burned!", display) } return { romText(battle and battle.data, "_BurnedText",
"%s\nwas burned!", display) }
end, end,
}, },
PAR = { PAR = {
@@ -117,10 +128,11 @@ Status.RECORDS = {
catchBonus = 12, shakeBonus = 5, catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "speed", div = 4 }, statPenalty = { stat = "speed", div = 4 },
beforeMovePriority = 10, beforeMovePriority = 10,
beforeMove = function(battler, rng) beforeMove = function(battler, rng, battle)
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256) -- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
if rng(0, 255) < 63 then if rng(0, 255) < 63 then
return false, { Strings("%s's\nfully paralyzed!", name(battler)) } return false, { romText(battle and battle.data, "_FullyParalyzedText",
"%s's\nfully paralyzed!", name(battler)) }
end end
return true, {} return true, {}
end, end,
@@ -128,9 +140,10 @@ Status.RECORDS = {
-- ParalyzeEffect_: Electric-type moves can't paralyze Ground-types -- ParalyzeEffect_: Electric-type moves can't paralyze Ground-types
return not (opts.moveType == "ELECTRIC" and hasType(target, "GROUND")) return not (opts.moveType == "ELECTRIC" and hasType(target, "GROUND"))
end, end,
onInflict = function(_, _, _, display) onInflict = function(battle, _, _, display)
-- _ParalyzedMayNotAttackText (primary and secondary paralysis) -- primary and secondary paralysis share this line
return { Strings("%s's\nparalyzed! It may\nnot attack!", display) } return { romText(battle and battle.data, "_ParalyzedMayNotAttackText",
"%s's\nparalyzed! It may\nnot attack!", display) }
end, end,
}, },
} }
@@ -166,7 +179,8 @@ function Status.beforeMove(battler, rng, battle)
end end
if battler.flinched then if battler.flinched then
battler.flinched = false battler.flinched = false
return false, { Strings("%s\nflinched!", name(battler)) } return false, { romText(battle and battle.data, "_FlinchedText",
"%s\nflinched!", name(battler)) }
end end
local record = Status.recordFor(battleStatuses(battle), mon.status) local record = Status.recordFor(battleStatuses(battle), mon.status)
local handler = record and record.beforeMove local handler = record and record.beforeMove
@@ -184,23 +198,27 @@ function Status.beforeMove(battler, rng, battle)
end end
if battler.boundTurns and battler.boundTurns > 0 then if battler.boundTurns and battler.boundTurns > 0 then
battler.boundTurns = battler.boundTurns - 1 battler.boundTurns = battler.boundTurns - 1
msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler)) msgs[#msgs + 1] = romText(battle and battle.data, "_CantMoveText",
"%s\ncan't move!", name(battler))
return false, msgs return false, msgs
end end
if battler.disabledTurns then if battler.disabledTurns then
battler.disabledTurns = battler.disabledTurns - 1 battler.disabledTurns = battler.disabledTurns - 1
if battler.disabledTurns <= 0 then if battler.disabledTurns <= 0 then
battler.disabledTurns, battler.disabledSlot = nil, nil battler.disabledTurns, battler.disabledSlot = nil, nil
table.insert(msgs, Strings("%s's\ndisabled no more!", name(battler))) table.insert(msgs, romText(battle and battle.data, "_DisabledNoMoreText",
"%s's\ndisabled no more!", name(battler)))
end end
end end
if battler.confusedTurns then if battler.confusedTurns then
battler.confusedTurns = battler.confusedTurns - 1 battler.confusedTurns = battler.confusedTurns - 1
if battler.confusedTurns <= 0 then if battler.confusedTurns <= 0 then
battler.confusedTurns = nil battler.confusedTurns = nil
table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler))) table.insert(msgs, romText(battle and battle.data, "_ConfusedNoMoreText",
"%s\nsnapped out of\nconfusion!", name(battler)))
else else
table.insert(msgs, Strings("%s\nis confused!", name(battler))) table.insert(msgs, romText(battle and battle.data, "_IsConfusedText",
"%s\nis confused!", name(battler)))
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256) -- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
if rng(0, 255) < 128 then if rng(0, 255) < 128 then
return false, msgs, true -- hurt itself return false, msgs, true -- hurt itself
@@ -241,7 +259,8 @@ function Status.residual(battler, opponent, battle)
dmg = math.min(dmg, mon.hp) dmg = math.min(dmg, mon.hp)
mon.hp = mon.hp - dmg mon.hp = mon.hp - dmg
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg) opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
table.insert(msgs, Strings("LEECH SEED saps\n%s!", name(battler))) table.insert(msgs, romText(battle and battle.data, "_HurtByLeechSeedText",
"LEECH SEED saps\n%s!", name(battler)))
end end
return msgs return msgs
end end
+1 -1
View File
@@ -12,7 +12,7 @@ local StatusRegistry = {}
-- pokered's <USER>/<TARGET> text macros (home/text.asm -- pokered's <USER>/<TARGET> text macros (home/text.asm
-- PlaceMoveUsersName): enemy-mon texts print "Enemy " before the name -- PlaceMoveUsersName): enemy-mon texts print "Enemy " before the name
local function displayName(b) local function displayName(b)
return b.isPlayer and b.name or ("Enemy " .. b.name) return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
end end
-- opts: toxic (start the Toxic counter), moveType (for the type gates), -- opts: toxic (start the Toxic counter), moveType (for the type gates),
+15 -4
View File
@@ -20,9 +20,16 @@
local TypeChart = require("src.battle.TypeChart") local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local romText = require("src.core.RomText")
local TrainerAI = {} local TrainerAI = {}
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the
-- enemy mon's nickname (home/text.asm PlaceMoveUsersName)
local function displayName(b)
return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
end
local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 } local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 }
local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" } local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" }
@@ -95,12 +102,16 @@ function TrainerAI.switchAction(battle)
return { special = "aiSwitch", index = alive[1] } return { special = "aiSwitch", index = alive[1] }
end end
-- Apply an aiItem action to the enemy battler; returns messages. -- Apply an aiItem action to the enemy battler; returns messages, already
-- final: the item line prints the raw nickname (AIPrintItemUseText has no
-- "Enemy " prefix in pokered), the stat lines carry it via displayName, so
-- the caller must not run these through prefixEnemy.
function TrainerAI.useItem(battle, item) function TrainerAI.useItem(battle, item)
local enemy = battle.enemy local enemy = battle.enemy
local trainerName = battle.trainer.name local trainerName = battle.trainer.name
local itemName = battle.data.items[item] and battle.data.items[item].name or item local itemName = battle.data.items[item] and battle.data.items[item].name or item
local msgs = { Strings("%s\nused %s!", trainerName, itemName) } local msgs = { romText(battle.data, "_AIBattleUseItemText",
"%s\nused %s!", trainerName, itemName, enemy.name) }
if item == "FULL_HEAL" then if item == "FULL_HEAL" then
enemy.mon.status = nil enemy.mon.status = nil
enemy.toxicCounter = nil enemy.toxicCounter = nil
@@ -113,10 +124,10 @@ function TrainerAI.useItem(battle, item)
elseif X_STAT[item] then elseif X_STAT[item] then
local stat = X_STAT[item] local stat = X_STAT[item]
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1) enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
table.insert(msgs, Strings("%s's\n%s rose!", enemy.name, stat:upper())) table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), stat:upper()))
elseif item == "GUARD_SPEC" then elseif item == "GUARD_SPEC" then
enemy.mist = true enemy.mist = true
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", enemy.name)) table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", displayName(enemy)))
end end
return msgs return msgs
end end
+13
View File
@@ -100,14 +100,27 @@ end
-- play so a hot-reloaded dataset (or a mod's audio) always reaches the worker -- play so a hot-reloaded dataset (or a mod's audio) always reaches the worker
local function slimAudio(data) local function slimAudio(data)
local audio = data.audio or {} local audio = data.audio or {}
-- NX-only: resolve the versioned cache prefix on the main thread and hand
-- it to the worker, which runs in a fresh Lua state without GameVersion.
local programPrefix
if require("src.core.Platform").isNX() then
local prefix = require("src.core.GameVersion").cachePrefix()
if prefix ~= "" then programPrefix = prefix end
end
return { return {
programFile = audio.programFile, programFile = audio.programFile,
programPrefix = programPrefix,
bankOrder = audio.bankOrder, bankOrder = audio.bankOrder,
waveBanks = audio.waveBanks, waveBanks = audio.waveBanks,
noiseHeaders = audio.noiseHeaders, noiseHeaders = audio.noiseHeaders,
} }
end end
-- test-only: expose slimAudio so the NX prefix hand-off is verifiable
function ChipAudio._slimAudioForTest(data)
return slimAudio(data)
end
-- If the worker died (a malformed def that errors mid-synth), fall back to the -- If the worker died (a malformed def that errors mid-synth), fall back to the
-- synchronous path for the rest of the session instead of going silent. -- synchronous path for the rest of the session instead of going silent.
local function workerAlive() local function workerAlive()
+17 -1
View File
@@ -123,7 +123,18 @@ local function loadBanks(data)
if cachedProgramFile == audio.programFile and cachedBanks then if cachedProgramFile == audio.programFile and cachedBanks then
return cachedBanks return cachedBanks
end end
local raw, readError = love.filesystem.read(audio.programFile) local raw, readError
-- The chip worker runs in a separate Lua state without the NX overlay;
-- ChipAudio hands it the versioned cache prefix explicitly. On the main
-- thread the NX overlay (or desktop mountVersion) makes the plain read
-- resolve, so no platform branching belongs here.
local prefix = audio.programPrefix
if prefix and prefix ~= "" then
raw, readError = love.filesystem.read(prefix .. audio.programFile)
end
if not raw then
raw, readError = love.filesystem.read(audio.programFile)
end
if not raw then error("could not read sound programs: " .. tostring(readError)) end if not raw then error("could not read sound programs: " .. tostring(readError)) end
local banks = {} local banks = {}
for index, bank in ipairs(audio.bankOrder) do for index, bank in ipairs(audio.bankOrder) do
@@ -140,6 +151,11 @@ function ChipSynth.invalidateBanks()
cachedProgramFile, cachedBanks = nil, nil cachedProgramFile, cachedBanks = nil, nil
end end
-- test-only: exercise loadBanks without building a full engine
function ChipSynth._loadBanksForTest(data)
return loadBanks(data)
end
-- A def-local program (ChipAsm output) is mounted as pseudo-bank 0 next to -- A def-local program (ChipAsm output) is mounted as pseudo-bank 0 next to
-- the ROM banks, so the 0x4000-window byte reader and every call/loop -- the ROM banks, so the 0x4000-window byte reader and every call/loop
-- target work unchanged. The ROM's own cached bank table is never touched -- target work unchanged. The ROM's own cached bank table is never touched
+14 -1
View File
@@ -203,7 +203,20 @@ local function loadModule(dir, name)
if not chunk then return false, err end if not chunk then return false, err end
return pcall(chunk) return pcall(chunk)
end end
return pcall(require, "data.generated." .. name) local ok, mod = pcall(require, "data.generated." .. name)
if ok then return true, mod end
-- Fused PhysFS / Blue|Yellow prefix: load bytes from the active version's
-- cache explicitly when require cannot see the mounted tree.
local CacheFs = require("src.import.CacheFs")
local GameVersion = require("src.core.GameVersion")
local path = "data/generated/" .. name .. ".lua"
local bytes = CacheFs.readActive(path)
if type(bytes) == "string" then
local chunk, err = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path)
if not chunk then return false, err or mod end
return pcall(chunk)
end
return false, mod
end end
function Data:load() function Data:load()
+103 -10
View File
@@ -9,6 +9,7 @@ local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack") local StateStack = require("src.core.StateStack")
local TouchControls = require("src.core.TouchControls") local TouchControls = require("src.core.TouchControls")
local GamepadMap = require("src.core.GamepadMap")
local ModLoader = require("src.mods.Loader") local ModLoader = require("src.mods.Loader")
local ModRuntime = require("src.mods.Runtime") local ModRuntime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
@@ -40,6 +41,13 @@ function Game:load()
-- render pipelines dispatch off the merged dataset; point them at the -- render pipelines dispatch off the merged dataset; point them at the
-- one the mods just merged into before anything can draw a frame -- one the mods just merged into before anything can draw a frame
require("src.render.Pipelines").install(Data) require("src.render.Pipelines").install(Data)
-- Same reason, same moment: TypeChart caches the merged type records in an
-- upvalue, and until now only BattleState loaded it, on entering a battle.
-- Every non-battle reader of a type -- the summary screen's TYPE1/TYPE2
-- rows, the move-select TYPE/ box -- ran against an unloaded module and got
-- the raw id back instead of the display name, so a translation could not
-- reach them. Loading here means a type reads the same whoever asks first.
require("src.battle.TypeChart").load(Data)
self.input = Input self.input = Input
Input:init() Input:init()
@@ -278,6 +286,21 @@ function Game.worldBgBattleDim(stack)
return nil return nil
end end
-- Is a BATTLE BG "world" battle composing itself over the live map right now?
-- Same whole-stack walk as worldBgBattleDim, asked for a different reason: the
-- dark-cave shade shift (wMapPalOffset) must not reach a frame a battle is
-- drawing in. InitBattleCommon (engine/battle/core.asm) pushes wMapPalOffset,
-- InitBattleVariables (engine/battle/init_battle_variables.asm) writes 0 over
-- it and core.asm pops it back when the battle ends, so a battle in an
-- un-flashed Rock Tunnel is lit on hardware. Every other BATTLE BG gets that
-- for free -- no map draws beneath an opaque battle, so nothing re-arms the
-- per-frame shade map -- but "world" keeps the overworld drawing underneath,
-- and its arming then darkened the battle's own pics, HUD and text at colorize
-- time (#773).
function Game.worldBgBattleInStack(stack)
return Game.worldBgBattleDim(stack) ~= nil
end
-- Does anything on the stack want the surface scaled to FILL the window -- Does anything on the stack want the surface scaled to FILL the window
-- (aspect preserved, bars on the long axis) rather than sit at the fixed -- (aspect preserved, bars on the long axis) rather than sit at the fixed
-- integer scale? -- integer scale?
@@ -441,7 +464,14 @@ function Game:draw()
if classicOffset ~= 0 and not wideState then if classicOffset ~= 0 and not wideState then
love.graphics.push() love.graphics.push()
love.graphics.translate(classicOffset, 0) love.graphics.translate(classicOffset, 0)
-- a classic state reports its trueColor rects in its own 160x144
-- coordinates, so they take the same shift its pixels just got --
-- centerClassicZones already does exactly this to its zone list,
-- and without the pair the unshaded re-blit misses the pic (#637)
local P = require("src.render.PaletteFX")
P.setMarkOffset(classicOffset)
state:draw() state:draw()
P.setMarkOffset(0)
love.graphics.pop() love.graphics.pop()
else else
state:draw() state:draw()
@@ -566,7 +596,7 @@ function Game:keypressed(key)
return return
elseif key == "1" then elseif key == "1" then
-- cycle GAME SPEED (0.25X → 200X, logic only; audio unaffected); -- cycle GAME SPEED (0.25X → 200X, logic only; audio unaffected);
-- R2/L2 on gamepad do the same (see gamepadpressed) -- shoulders/triggers on gamepad do the same (see gamepadpressed)
self:_cycleSpeed(1) self:_cycleSpeed(1)
return return
elseif key == "2" then elseif key == "2" then
@@ -649,14 +679,29 @@ function Game:gamepadpressed(joystick, button)
-- a controller is being used: the touch overlay steps aside until the -- a controller is being used: the touch overlay steps aside until the
-- next screen touch (mobile only; a no-op elsewhere) -- next screen touch (mobile only; a no-op elsewhere)
TouchControls:noteGamepad() TouchControls:noteGamepad()
-- shoulder buttons cycle GAME SPEED (R2/rightshoulder = faster, -- Select held? Needed both to suppress shoulder speed hotkeys (Select+L
-- L2/leftshoulder = slower; same as keyboard hotkey 1) -- is a display chord on NX) and for the chord path below.
if button == "rightshoulder" then local selectHeld = Input:isDown("select")
self:_cycleSpeed(1) if not selectHeld and joystick and joystick.isGamepadDown then
return local ok, down = pcall(function()
elseif button == "leftshoulder" then return joystick:isGamepadDown("back")
self:_cycleSpeed(-1) end)
return selectHeld = ok and down == true
end
-- shoulder buttons and analog triggers cycle GAME SPEED (R1/RB or
-- R2/RT = faster, L1/LB or L2/LT = slower; same as keyboard hotkey
-- 1). LÖVE reports an analog trigger as gamepadpressed once it
-- crosses the press threshold, so a trigger pull lands here like any
-- other pad button. Skip while Select is held so Select+L can reach
-- displayChordDigit ("7").
if not selectHeld then
if button == "rightshoulder" or button == "righttrigger" then
self:_cycleSpeed(1)
return
elseif button == "leftshoulder" or button == "lefttrigger" then
self:_cycleSpeed(-1)
return
end
end end
-- BindingsMenu's pad capture rides the same top-state routing as keys -- BindingsMenu's pad capture rides the same top-state routing as keys
local top = self.stack and self.stack:top() local top = self.stack and self.stack:top()
@@ -664,6 +709,16 @@ function Game:gamepadpressed(joystick, button)
top:onGamepadPressed(button) top:onGamepadPressed(button)
return return
end end
-- Select+face display chords → same digit path as Game:keypressed
-- (COLORS/TILT/pipelines). Intercept before Input so face does not
-- also fire GB A/B. Dual-path: raw already ignored when isGamepad().
if selectHeld then
local digit = GamepadMap.displayChordDigit(button)
if digit then
self:keypressed(digit)
return
end
end
Input:gamepadpressed(joystick, button) Input:gamepadpressed(joystick, button)
end end
@@ -747,15 +802,51 @@ function Game:focus(f)
end end
function Game:visible(v) function Game:visible(v)
if v then
self:onResume()
else
Input:reset()
TouchControls:reset()
end
end
function Game:onResume()
Input:reset() Input:reset()
TouchControls:reset() TouchControls:reset()
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
-- the active screen re-cue on the next frame (hardware audio check: T19).
-- Desktop/mobile window-visible flips must not kill overworld music.
if require("src.core.Platform").isNX() then
require("src.core.ChipAudio").stopMusic()
end
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
if SwitchDiagnostics.isEnabled() then
SwitchDiagnostics.onEvent("lifecycle", { event = "resume" })
end
end
function Game:recoverInput(event, joystick)
Input:reset()
TouchControls:reset()
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
if SwitchDiagnostics.isEnabled() then
if joystick then
SwitchDiagnostics.onJoystickEvent(event, joystick)
else
SwitchDiagnostics.onEvent("lifecycle", { event = event })
end
end
end
function Game:joystickadded(joystick)
self:recoverInput("joystickadded", joystick)
end end
-- A disconnected/dropped controller can't send the button-up for whatever -- A disconnected/dropped controller can't send the button-up for whatever
-- it was holding, so drop all input state rather than try to guess which -- it was holding, so drop all input state rather than try to guess which
-- flags it owned. -- flags it owned.
function Game:joystickremoved(joystick) function Game:joystickremoved(joystick)
Input:reset() self:recoverInput("joystickremoved", joystick)
TouchControls:joystickremoved() TouchControls:joystickremoved()
end end
@@ -832,6 +923,8 @@ function Game:applyOptions(opts)
-- returns true when a persisted GBC FX level was cleared on mobile -- returns true when a persisted GBC FX level was cleared on mobile
local gbcCleared = require("src.render.GBCFX").applyOptions(opts) local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts) require("src.core.VideoMode").applyOptions(opts)
-- Android orientation lock (#592); no-op everywhere else
require("src.core.Orientation").applyOptions(opts)
-- after VideoMode: a faithful-resolution lock is an exact window size, so -- after VideoMode: a faithful-resolution lock is an exact window size, so
-- it has to be the last word on the window (it drops fullscreen to hold) -- it has to be the last word on the window (it drops fullscreen to hold)
require("src.core.FaithfulRes").applyOptions(opts) require("src.core.FaithfulRes").applyOptions(opts)
+125
View File
@@ -0,0 +1,125 @@
-- Shared gamepad + raw joystick button tables for launcher and gameplay.
-- Hardware-measured NX overrides live in NX_* tables (see docs/switch-development.md).
local GamepadMap = {}
-- LÖVE SDL game-controller mapping (D-pad / face / menu) — desktop/mobile.
GamepadMap.DEFAULT_GAMEPAD_BINDINGS = {
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
a = "a", b = "b",
start = "start", back = "select",
}
-- Switch: LÖVE/SDL labels south as "a" and east as "b", but Nintendo UX is
-- physical A (east) = confirm (GB A), physical B (south) = cancel (GB B).
GamepadMap.NX_GAMEPAD_BINDINGS = {
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
a = "b", -- SDL south = Nintendo B → GB B
b = "a", -- SDL east = Nintendo A → GB A
start = "start", back = "select",
}
-- Generic SDL joysticks without a game-controller DB entry (Linux handhelds).
-- Desktop XInput order only: raw numbering is per-driver, and SDL's iOS/MFi
-- driver packs only the buttons a pad reports, which slides the D-pad onto
-- 7..10 (#620). These are defaults; Input:applyBindings layers "joyN" pad
-- rebinds over them (#632). Only sticks SDL does NOT recognize as gamepads
-- are served from this table (see GamepadMap.ignoreRawForJoystick).
GamepadMap.RAW_BUTTON_BINDINGS = {
[1] = "a", [2] = "b",
[7] = "select", [8] = "start", [9] = "select", [10] = "start",
}
-- Switch OLED raw indices (1-based). Only when NOT isGamepad() — love-nx
-- also emits gamepadpressed; dual-path face presses break NamingScreen.
-- #1 = Nintendo B, #2 = Nintendo A (probe); Y/X left unmapped for naming.
GamepadMap.NX_RAW_BUTTON_BINDINGS = {
[1] = "b", [2] = "a",
[9] = "select", [10] = "start",
}
-- Raw index -> gamepad button *name* for RomImporter (then NX face swap applies).
GamepadMap.RAW_TO_GAMEPAD_BUTTON = {
[1] = "a", [2] = "b",
[7] = "back", [8] = "start", [9] = "back", [10] = "start",
}
GamepadMap.NX_RAW_TO_GAMEPAD_BUTTON = {
[1] = "a", [2] = "b", -- SDL south/east names; NX_GAMEPAD_BINDINGS swaps to GB
[9] = "back", [10] = "start",
}
-- Test hook: force NX tables without stubbing love.
GamepadMap._forceNXForTests = false
function GamepadMap._setForceNXForTests(v)
GamepadMap._forceNXForTests = not not v
end
local function nxActive()
if GamepadMap._forceNXForTests then return true end
if love and love._os == "NX" then return true end
if love and love.system and love.system.getOS() == "NX" then return true end
return false
end
function GamepadMap.gamepadBindings()
if nxActive() then return GamepadMap.NX_GAMEPAD_BINDINGS end
return GamepadMap.DEFAULT_GAMEPAD_BINDINGS
end
-- Whole raw-index table for Input:applyBindings joyBindings seeding (#632).
function GamepadMap.rawBindings()
if nxActive() then return GamepadMap.NX_RAW_BUTTON_BINDINGS end
return GamepadMap.RAW_BUTTON_BINDINGS
end
function GamepadMap.mapGamepadButton(button)
return GamepadMap.gamepadBindings()[button]
end
-- Select+face display chords (docs / Nintendo UX):
-- Select+A → "2" (COLORS), Select+B → "3" (TILT),
-- Select+Y → "5", Select+X → "6", Select+L (leftshoulder) → "7".
-- For a/b: resolve through mapGamepadButton then GB a→"2", b→"3" so NX
-- Nintendo physical A/B match the docs despite SDL face-label swap.
-- Caller (Game:gamepadpressed) must require Select held; this is map-only.
function GamepadMap.displayChordDigit(gamepadButton)
if gamepadButton == "y" then return "5" end
if gamepadButton == "x" then return "6" end
if gamepadButton == "leftshoulder" then return "7" end
if gamepadButton == "a" or gamepadButton == "b" then
local gb = GamepadMap.mapGamepadButton(gamepadButton)
if gb == "a" then return "2" end
if gb == "b" then return "3" end
end
return nil
end
-- love-nx / SDL: when isGamepad(), face+menu already arrive via gamepad*.
-- Applying joystickpressed raw on top double-fires GB A/B in one frame.
function GamepadMap.ignoreRawForJoystick(joystick)
if not joystick then return false end
local ok, isPad = pcall(function()
return joystick.isGamepad and joystick:isGamepad()
end)
return ok and isPad == true
end
function GamepadMap.mapRawButton(index)
if nxActive() then
local nx = GamepadMap.NX_RAW_BUTTON_BINDINGS[index]
if nx then return nx end
end
return GamepadMap.RAW_BUTTON_BINDINGS[index]
end
function GamepadMap.mapRawToGamepadButton(index)
if nxActive() then
local nx = GamepadMap.NX_RAW_TO_GAMEPAD_BUTTON[index]
if nx then return nx end
end
return GamepadMap.RAW_TO_GAMEPAD_BUTTON[index]
end
return GamepadMap
+4 -5
View File
@@ -148,15 +148,14 @@ function HostShell.haveCurl()
return readOk and out ~= nil and out:find("curl", 1, true) ~= nil return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
end end
-- The bridge only exists in our Android liblove. An older APK reports nil -- An older mobile build reports nil here and falls back to the "no transport"
-- here and falls back to the "no transport" error the callers already show; -- error the callers already show.
-- the iOS build compiles the same wrapper but always returns false, so gate
-- on the OS as well and keep its error message honest.
local function haveBridge() local function haveBridge()
if not (love and love.system and type(love.system.httpDownload) == "function") then if not (love and love.system and type(love.system.httpDownload) == "function") then
return false return false
end end
return love.system.getOS and love.system.getOS() == "Android" local osName = love.system.getOS and love.system.getOS()
return osName == "Android" or osName == "iOS"
end end
-- Is any transport available at all? Callers gate on this, never on curl. -- Is any transport available at all? Callers gate on this, never on curl.
+18 -34
View File
@@ -1,6 +1,8 @@
-- Input abstraction: maps keyboard to Game Boy buttons. -- Input abstraction: maps keyboard to Game Boy buttons.
-- `down` = held this frame; `pressed` = edge, consumed per fixed step. -- `down` = held this frame; `pressed` = edge, consumed per fixed step.
local GamepadMap = require("src.core.GamepadMap")
local Input = {} local Input = {}
local DEFAULT_BINDINGS = { local DEFAULT_BINDINGS = {
@@ -22,37 +24,14 @@ local DEFAULT_BINDINGS = {
-- keys that map to "start" but also to "a" would conflict; keep Enter = a, -- keys that map to "start" but also to "a" would conflict; keep Enter = a,
-- Escape = start for desktop friendliness. -- Escape = start for desktop friendliness.
-- LÖVE's standard gamepad mapping (SDL game controller DB), consistent
-- across Xbox/PlayStation/generic controllers on desktop and mobile. Some
-- third-party pads report their own SDL mapping for a given physical
-- button (e.g. Select/Back/View on off-brand XInput pads), which is what
-- src/ui/BindingsMenu.lua's rebinding is for -- see applyBindings below.
local DEFAULT_GAMEPAD_BINDINGS = {
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
a = "a", b = "b",
start = "start", back = "select",
}
-- left-stick deadzones: press past STICK_ON, release once back under -- left-stick deadzones: press past STICK_ON, release once back under
-- STICK_OFF. The gap (hysteresis) stops the direction from flickering -- STICK_OFF. The gap (hysteresis) stops the direction from flickering
-- while the stick sits near the threshold. -- while the stick sits near the threshold.
local STICK_ON = 0.5 local STICK_ON = 0.5
local STICK_OFF = 0.3 local STICK_OFF = 0.3
-- Generic SDL joysticks expose the left stick as the first two numbered -- Raw joystick defaults + NX overrides live in src/core/GamepadMap.lua
-- axes and the D-pad as a hat. This is common on Linux handhelds whose -- (see RAW_BUTTON_BINDINGS / NX_RAW_BUTTON_BINDINGS and #620 / #632).
-- controller has no game-controller database entry. The indices below are
-- the desktop XInput order and are only meaningful for such pads: raw
-- numbering is per-driver, and SDL's iOS/MFi driver packs only the buttons
-- a pad actually reports, which slides the D-pad down onto 7..10 (#620).
-- These are the raw DEFAULTS only: applyBindings layers the player's
-- "joyN" pad bindings over them (#632), and only a stick SDL does NOT
-- recognize as a gamepad is ever served out of this table -- see
-- joystickpressed below.
local RAW_BUTTON_BINDINGS = {
[1] = "a", [2] = "b",
[7] = "select", [8] = "start", [9] = "select", [10] = "start",
}
local HAT_DIRECTIONS = { local HAT_DIRECTIONS = {
u = { "up" }, d = { "down" }, l = { "left" }, r = { "right" }, u = { "up" }, d = { "down" }, l = { "left" }, r = { "right" },
@@ -75,8 +54,15 @@ end
function Input:applyBindings(overlay) function Input:applyBindings(overlay)
local keys, pads, joys = {}, {}, {} local keys, pads, joys = {}, {}, {}
for key, action in pairs(DEFAULT_BINDINGS) do keys[key] = action end for key, action in pairs(DEFAULT_BINDINGS) do keys[key] = action end
for button, action in pairs(DEFAULT_GAMEPAD_BINDINGS) do pads[button] = action end for button, action in pairs(GamepadMap.gamepadBindings()) do
for index, action in pairs(RAW_BUTTON_BINDINGS) do joys[index] = action end pads[button] = action
end
-- Seed raw defaults from GamepadMap (desktop XInput order or NX OLED
-- indices) so joyN rebinds (#632) and dual-path guards (#620) share one
-- table with the Switch face-label remap.
for index, action in pairs(GamepadMap.rawBindings()) do
joys[index] = action
end
for actionId, binding in pairs(overlay or {}) do for actionId, binding in pairs(overlay or {}) do
if type(binding) == "table" then if type(binding) == "table" then
if binding.key then keys[binding.key] = actionId end if binding.key then keys[binding.key] = actionId end
@@ -225,18 +211,16 @@ end
-- alone; the raw path exists for sticks with no game-controller-database -- alone; the raw path exists for sticks with no game-controller-database
-- entry. A nil joystick is a raw stick: that is how -- entry. A nil joystick is a raw stick: that is how
-- tests/input_hold_test.lua and the drivers drive this path. -- tests/input_hold_test.lua and the drivers drive this path.
local function isRawStick(joystick) -- Gate: GamepadMap.ignoreRawForJoystick (pcall-safe isGamepad check).
return not (joystick and joystick.isGamepad and joystick:isGamepad())
end
function Input:joystickpressed(joystick, button) function Input:joystickpressed(joystick, button)
if not isRawStick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
local btn = self.joyBindings[button] local btn = self.joyBindings[button]
if btn then press(self, btn, "joy:" .. button) end if btn then press(self, btn, "joy:" .. button) end
end end
function Input:joystickreleased(joystick, button) function Input:joystickreleased(joystick, button)
if not isRawStick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
local btn = self.joyBindings[button] local btn = self.joyBindings[button]
if btn then release(self, btn, "joy:" .. button) end if btn then release(self, btn, "joy:" .. button) end
end end
@@ -277,7 +261,7 @@ function Input:gamepadaxis(joystick, axis, value)
end end
function Input:joystickaxis(joystick, axis, value) function Input:joystickaxis(joystick, axis, value)
if not isRawStick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if axis == 1 then if axis == 1 then
self:gamepadaxis(joystick, "leftx", value) self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then elseif axis == 2 then
@@ -290,7 +274,7 @@ end
-- gamepad map, so letting the hat answer too would re-assert the factory -- gamepad map, so letting the hat answer too would re-assert the factory
-- directions on top of a direction rebind. -- directions on top of a direction rebind.
function Input:joystickhat(joystick, hat, direction) function Input:joystickhat(joystick, hat, direction)
if not isRawStick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
local source = "hat:" .. hat local source = "hat:" .. hat
for _, btn in ipairs(self.hatDirs[hat] or {}) do for _, btn in ipairs(self.hatDirs[hat] or {}) do
release(self, btn, source) release(self, btn, source)
+107
View File
@@ -0,0 +1,107 @@
-- NX-only asset overlay: fused love-nx cannot reliably mount
-- blue|yellow/assets/generated onto the un-prefixed assets/generated, so
-- instead of teaching every call site about versioned caches, this module
-- wraps EVERY read-side love entry point that accepts a filesystem path
-- once at boot: any string path under assets/generated/ that does not
-- resolve falls back to the active version's prefixed copy
-- (yellow|blue/assets/generated/...). Covering the whole read surface --
-- not just the loaders we happened to need -- is what keeps future states
-- and mods inside the fallback without anyone updating this file.
--
-- main.lua installs it only when Platform.isNX(); desktop/Android/iOS never
-- install it, so their mountVersion overlay stays the single mechanism and
-- their loaders keep stock behavior. Write-side functions (write, remove,
-- createDirectory, mount, ...) are deliberately NOT wrapped: the importer
-- must keep targeting the versioned tree explicitly.
--
-- Two intentional exceptions stay outside this module:
-- * the chip-audio worker (src/core/chip_worker.lua) is a separate Lua
-- state without these wrappers; ChipAudio.slimAudio hands it the prefix
-- explicitly as audio.programPrefix.
-- * data/generated module loads go through CacheFs.readActive, which
-- already implements the same fallback for require bytes.
local GameVersion = require("src.core.GameVersion")
local GENERATED = "assets/generated/"
local NxAssetOverlay = {}
local originals -- raw love functions, non-nil while installed
-- Resolve `path` to the versioned copy when the un-prefixed file is missing
-- and the active version (Blue/Yellow) carries it. Returns nil when the
-- caller's path should be used untouched (non-generated path, Red, the real
-- file exists, or no versioned copy).
local function versioned(path)
if type(path) ~= "string" then return nil end
if path:sub(1, #GENERATED) ~= GENERATED then return nil end
local prefix = GameVersion.cachePrefix()
if prefix == "" then return nil end
if originals.getInfo(path) then return nil end
local candidate = prefix .. path
if originals.getInfo(candidate) then return candidate end
return nil
end
local function wrapLoader(fn)
return function(path, ...)
local alt = versioned(path)
if alt then return fn(alt, ...) end
return fn(path, ...)
end
end
-- Every read-side love function that can take an assets/generated path.
-- getInfo is wrapped separately (it must return the versioned file's info,
-- not just forward a rewritten argument list).
local WRAP_SPEC = {
{ "filesystem", "read" },
{ "filesystem", "load" },
{ "filesystem", "lines" },
{ "filesystem", "newFileData" },
{ "graphics", "newImage" },
{ "graphics", "newFont" },
{ "image", "newImageData" },
{ "audio", "newSource" },
{ "sound", "newSoundData" },
{ "font", "newFontData" },
}
function NxAssetOverlay.isInstalled()
return originals ~= nil
end
function NxAssetOverlay.install()
if originals then return end
if not (love and love.filesystem) then return end
originals = {}
for _, spec in ipairs(WRAP_SPEC) do
local ns, name = spec[1], spec[2]
local fn = love[ns] and love[ns][name]
if fn then
originals[ns .. "." .. name] = fn
love[ns][name] = wrapLoader(fn)
end
end
originals.getInfo = love.filesystem.getInfo
love.filesystem.getInfo = function(path, ...)
local alt = versioned(path)
if alt then return originals.getInfo(alt, ...) end
return originals.getInfo(path, ...)
end
end
-- Tests restore the stock loaders between cases; the game never uninstalls.
function NxAssetOverlay.uninstall()
if not originals then return end
for _, spec in ipairs(WRAP_SPEC) do
local ns, name = spec[1], spec[2]
local key = ns .. "." .. name
if originals[key] then love[ns][name] = originals[key] end
end
love.filesystem.getInfo = originals.getInfo
originals = nil
end
return NxAssetOverlay
+105
View File
@@ -0,0 +1,105 @@
-- Switch-only display size: handheld 1280x720, docked (TV) 1920x1080.
-- love-nx's SDL backend can auto-resize on dock/undock when the window is
-- resizable; this module also syncs on boot and when the operation mode
-- changes so a docked launch is not stuck at the conf.lua 720p hint.
--
-- Important: only call love.window.setMode when width/height must change.
-- Re-applying every frame (e.g. to "fix" fullscreen/resizable flags that
-- love-nx reports differently) recreates the EGL surface and flickers the launcher.
local Platform = require("src.core.Platform")
local NxDisplay = {}
NxDisplay.HANDHELD_W, NxDisplay.HANDHELD_H = 1280, 720
NxDisplay.DOCKED_W, NxDisplay.DOCKED_H = 1920, 1080
-- AppletOperationMode from libnx: Handheld = 0, Console (docked) = 1.
local MODE_HANDHELD = 0
local MODE_CONSOLE = 1
-- Test hooks (nil = use live Platform / FFI / love.window).
NxDisplay._forceNXForTests = nil
NxDisplay._operationModeForTests = nil
local ffiOk, ffiC
local function ensureFfi()
if ffiOk ~= nil then return ffiOk end
ffiOk = false
local ok, ffi = pcall(require, "ffi")
if not ok or not ffi then return false end
-- Redefinition is fine across hot reload / tests; we only need the symbol.
pcall(ffi.cdef, [[
unsigned char appletGetOperationMode(void);
]])
local probeOk = pcall(function()
return ffi.C.appletGetOperationMode
end)
if not probeOk then return false end
ffiC = ffi.C
ffiOk = true
return true
end
local function isNX()
if NxDisplay._forceNXForTests ~= nil then
return not not NxDisplay._forceNXForTests
end
return Platform.isNX()
end
-- Returns AppletOperationMode or nil when unavailable.
function NxDisplay.operationMode()
if NxDisplay._operationModeForTests ~= nil then
return NxDisplay._operationModeForTests
end
if not ensureFfi() or not ffiC then return nil end
local ok, mode = pcall(function()
return tonumber(ffiC.appletGetOperationMode())
end)
if not ok then return nil end
return mode
end
-- Map operation mode → framebuffer size.
-- Unknown / nil → nil,nil (do not fight SDL or force a wrong size).
function NxDisplay.desiredSize(mode)
if mode == nil then mode = NxDisplay.operationMode() end
if mode == MODE_CONSOLE then
return NxDisplay.DOCKED_W, NxDisplay.DOCKED_H
end
if mode == MODE_HANDHELD then
return NxDisplay.HANDHELD_W, NxDisplay.HANDHELD_H
end
return nil
end
-- Apply handheld/dock size when on NX and the window size differs.
-- Never setMode just to tweak flags — that flickers on love-nx.
-- Returns true when setMode ran.
function NxDisplay.sync()
if not isNX() then return false end
if not (love and love.window and love.window.getMode and love.window.setMode) then
return false
end
local wantW, wantH = NxDisplay.desiredSize()
if not wantW or not wantH then return false end
local curW, curH, flags = love.window.getMode()
if curW == wantW and curH == wantH then
return false
end
flags = flags or {}
flags.fullscreen = false
flags.resizable = true
love.window.setMode(wantW, wantH, flags)
return true
end
function NxDisplay._resetForTests()
NxDisplay._forceNXForTests = nil
NxDisplay._operationModeForTests = nil
ffiOk, ffiC = nil, nil
end
return NxDisplay
+113
View File
@@ -0,0 +1,113 @@
-- Screen orientation lock, Android only (#592, #716).
--
-- Persisted as options.orientation: "auto" | "portrait" | "landscape" |
-- "reverseLandscape". The lock travels through SDL_HINT_ORIENTATIONS:
-- SDLActivity.setOrientationBis parses the hint's space-separated names
-- into a setRequestedOrientation call, and GameActivity's override then
-- remaps any *_SENSOR result onto the matching *_USER constant, so a device
-- with auto-rotate off stays put (#716). AUTO leaves the hint empty, which
-- with a resizable window means "any orientation, deferring to the system
-- rotation lock"; LANDSCAPE allows both landscapes (SENSOR_LANDSCAPE ->
-- USER_LANDSCAPE); REVERSE LANDSCAPE is SDL's LandscapeRight alone.
--
-- SDL only re-reads the hint when the window is created or its resizable
-- flag changes (SDL_androidwindow.c: Android_CreateWindow /
-- Android_SetWindowResizable both call Android_JNI_SetOrientation). LOVE
-- 11.5 exposes neither hints nor a resizable setter, so apply() goes through
-- the FFI to SDL's C API: set the hint, then pulse the window's resizable
-- flag off and back on -- each edge makes the Android backend recompute the
-- requested orientation, so a change from the launcher or the OPTION menu
-- takes hold immediately, and the flag ends where it started (conf.lua sets
-- resizable on mobile). Everything is pcall-guarded: desktop, iOS (the
-- Info.plist governs there) and headless stubs make this a no-op.
local Orientation = {}
Orientation.MODES = { "auto", "portrait", "landscape", "reverseLandscape" }
Orientation.DEFAULT = "auto"
local LABELS = {
auto = "AUTO",
portrait = "PORTRAIT",
landscape = "LANDSCAPE",
reverseLandscape = "REVERSE LANDSCAPE",
}
-- SDL_HINT_ORIENTATIONS values, exactly the names SDLActivity parses
-- (SDLActivity.java setOrientationBis): "Portrait", "PortraitUpsideDown",
-- "LandscapeLeft", "LandscapeRight". Both landscapes together promote to
-- SENSOR_LANDSCAPE; LandscapeRight alone maps to REVERSE_LANDSCAPE.
local HINTS = {
auto = "",
portrait = "Portrait",
landscape = "LandscapeLeft LandscapeRight",
reverseLandscape = "LandscapeRight",
}
function Orientation.normalize(mode)
if HINTS[mode] then return mode end
return Orientation.DEFAULT
end
function Orientation.modeLabel(mode)
return LABELS[Orientation.normalize(mode)]
end
function Orientation.isAndroid()
if not love or not love.system or not love.system.getOS then return false end
return love.system.getOS() == "Android"
end
function Orientation.cycle(mode, dir)
local cur, idx = Orientation.normalize(mode), 1
for i, m in ipairs(Orientation.MODES) do
if m == cur then idx = i break end
end
local n = #Orientation.MODES
return Orientation.MODES[(idx - 1 + (dir or 1)) % n + 1]
end
-- The SDL2 C API this module needs. cdef errors on redefinition, so run it
-- once and remember whether it took; ffi itself may be absent (plain Lua
-- test interpreters), hence the pcall'd require.
local cdefOk = nil
local function sdlFfi()
local okFfi, ffi = pcall(require, "ffi")
if not okFfi then return nil end
if cdefOk == nil then
cdefOk = pcall(ffi.cdef, [[
typedef struct SDL_Window SDL_Window;
int SDL_SetHint(const char *name, const char *value);
SDL_Window *SDL_GL_GetCurrentWindow(void);
void SDL_SetWindowResizable(SDL_Window *window, int resizable);
]])
end
if not cdefOk then return nil end
return ffi
end
-- Push the mode into the live activity. Returns true when the hint reached
-- SDL (the symbols resolved), false on any non-Android / stubbed platform.
function Orientation.apply(mode)
if not Orientation.isAndroid() then return false end
local ffi = sdlFfi()
if not ffi then return false end
mode = Orientation.normalize(mode)
local ok = pcall(function()
-- "SDL_IOS_ORIENTATIONS" is SDL_HINT_ORIENTATIONS's name (SDL_hints.h);
-- despite the IOS in the string, the Android backend reads it too.
ffi.C.SDL_SetHint("SDL_IOS_ORIENTATIONS", HINTS[mode])
local win = ffi.C.SDL_GL_GetCurrentWindow()
if win ~= nil then
ffi.C.SDL_SetWindowResizable(win, 0)
ffi.C.SDL_SetWindowResizable(win, 1)
end
end)
return ok
end
function Orientation.applyOptions(opts)
return Orientation.apply(opts and opts.orientation)
end
return Orientation
+54
View File
@@ -0,0 +1,54 @@
-- Platform capability detection for NX / mobile / desktop.
local Platform = {}
local cached
local function compute()
local osName = (love and love.system and love.system.getOS and love.system.getOS())
or "Unknown"
local nx = osName == "NX"
local mobile = osName == "Android" or osName == "iOS"
local nativePicker = love and love.system
and type(love.system.pickFile) == "function"
return {
os = osName,
nx = nx,
mobile = mobile,
console = nx,
hasNativePicker = nativePicker,
canSpawnProcess = osName == "OS X" or osName == "Windows" or osName == "Linux",
romImportMode = nx and "save-directory"
or (nativePicker and "native-picker")
or "desktop",
networkValidated = not nx,
}
end
function Platform.detect()
if not cached then cached = compute() end
return cached
end
function Platform.isNX()
return Platform.detect().nx
end
function Platform.romImportMode()
return Platform.detect().romImportMode
end
function Platform.canSpawnProcess()
return Platform.detect().canSpawnProcess
end
function Platform.networkValidated()
return Platform.detect().networkValidated
end
-- Tests may swap love.system between cases.
function Platform._resetForTests()
cached = nil
end
return Platform
+25 -11
View File
@@ -92,19 +92,30 @@ end
-- Chip SFX and cries are already stereo at the source (ChipSynth -- Chip SFX and cries are already stereo at the source (ChipSynth
-- renderEffectData); this covers file defs, i.e. Yellow's 8-bit mono PCM -- renderEffectData); this covers file defs, i.e. Yellow's 8-bit mono PCM
-- Pikachu clips (RomExtractor extractPikachuCries) and mod-supplied wav/ogg -- Pikachu clips (RomExtractor extractPikachuCries) and mod-supplied wav/ogg
-- SFX. Every step is guarded: a headless love stub without love.sound, or a -- SFX.
-- decoder that will not hand back SoundData, keeps the original Source. --
-- Decode the FILE (not Source:getChannelCount): love-nx/audren has reported
-- channel counts that skip this widen silently, and preserving 8-bit depth
-- into a stereo buffer also sounds wrong on that backend. Always emit
-- 16-bit stereo like ChipSynth. Failure keeps the original Source and logs.
local function widenMono(source, file) local function widenMono(source, file)
if not (source and love.sound and love.sound.newSoundData) then if type(file) ~= "string" then return source end
if not (love.sound and love.sound.newSoundData and love.audio
and love.audio.newSource) then
return source
end
-- Quiet skip when the path is unreadable (headless stub SFX keys, missing
-- files). On NX, overlay-wrapped getInfo makes the yellow|blue copy visible
-- at the bare assets/generated path so the widen still runs.
local fs = love.filesystem
if not (fs and fs.getInfo and fs.getInfo(file)) then
return source return source
end end
local ok, channels = pcall(function() return source:getChannelCount() end)
if not ok or channels ~= 1 then return source end
local built, widened = pcall(function() local built, widened = pcall(function()
local mono = love.sound.newSoundData(file) local mono = love.sound.newSoundData(file)
if mono:getChannelCount() ~= 1 then return source end
local frames = mono:getSampleCount() local frames = mono:getSampleCount()
local stereo = love.sound.newSoundData(frames, mono:getSampleRate(), local stereo = love.sound.newSoundData(frames, mono:getSampleRate(), 16, 2)
mono:getBitDepth(), 2)
for index = 0, frames - 1 do for index = 0, frames - 1 do
local value = mono:getSample(index) local value = mono:getSample(index)
stereo:setSample(index, 1, value) stereo:setSample(index, 1, value)
@@ -112,7 +123,10 @@ local function widenMono(source, file)
end end
return love.audio.newSource(stereo, "static") return love.audio.newSource(stereo, "static")
end) end)
if built and widened then return widened end if built and widened and widened ~= source then return widened end
if not built then
Logger.warn("sound: widenMono failed for %s: %s", file, tostring(widened))
end
return source return source
end end
@@ -274,9 +288,9 @@ function Sound.playPikaCry(data, n)
cache[key] = false cache[key] = false
return nil return nil
end end
-- the importer writes these clips as 8-bit mono (RomExtractor -- importer historically wrote these as 8-bit mono (RomExtractor
-- extractPikachuCries), so they need the same widening as the chip -- extractPikachuCries); widenMono re-decodes to 16-bit stereo so they
-- effects to stay off a multi-output device's surround channels (#626) -- stay off surround outputs (#626). Fresh extracts are already stereo.
s = widenMono(s, path) s = widenMono(s, path)
s:setVolume(volumeFor(key)) s:setVolume(volumeFor(key))
cache[key] = s cache[key] = s
+261
View File
@@ -0,0 +1,261 @@
-- Opt-in Switch diagnostics: ring buffer + ≤1 Hz flush when switch-debug.txt exists.
-- Never logs ROM/save bytes — see spec SWNX-13/28.
local SwitchDiagnostics = {}
local MARKER = "switch-debug.txt"
local LOG_FILE = "switch.log"
local ERROR_LOG = "lua-error.log"
local ERROR_LOG_ROTATED = "lua-error.log.1"
local ERROR_LOG_MAX = 32 * 1024
local FLUSH_INTERVAL = 1.0
local RING_SIZE = 64
local enabled = nil
local buffer = {}
local bufCount = 0
local lastFlushAt = -math.huge
local identityLine = nil
local function fs()
return love and love.filesystem
end
local function redactString(s)
if type(s) ~= "string" then return s end
-- Keep printable ASCII + TAB/LF/CR so Lua stack traces remain readable.
-- Reject NULs and other C0 controls, and high bytes (ROM/binary dumps).
for i = 1, #s do
local b = s:byte(i)
if b == 0 then return "<redacted>" end
if b < 32 and b ~= 9 and b ~= 10 and b ~= 13 then return "<redacted>" end
if b > 126 then return "<redacted>" end
end
if #s > 8192 then return s:sub(1, 8192) .. "...<truncated>" end
return s
end
local function sanitize(value, depth)
depth = depth or 0
if depth > 4 then return "<deep>" end
local t = type(value)
if t == "string" then return redactString(value) end
if t == "number" or t == "boolean" or value == nil then return value end
if t == "table" then
local out = {}
for k, v in pairs(value) do
local key = type(k) == "string" and k or tostring(k)
if key:lower():find("rom") or key:lower():find("save") then
out[key] = "<redacted>"
else
out[key] = sanitize(v, depth + 1)
end
end
return out
end
return tostring(value)
end
local function encodePayload(payload)
if payload == nil then return "" end
if type(payload) == "string" then return redactString(payload) end
local parts = {}
for k, v in pairs(sanitize(payload)) do
parts[#parts + 1] = tostring(k) .. "=" .. tostring(v)
end
table.sort(parts)
return table.concat(parts, " ")
end
function SwitchDiagnostics._resetForTests()
enabled = nil
buffer = {}
bufCount = 0
lastFlushAt = -math.huge
identityLine = nil
local filesystem = fs()
if filesystem then
filesystem.remove(ERROR_LOG)
filesystem.remove(ERROR_LOG_ROTATED)
end
end
function SwitchDiagnostics.isEnabled()
if enabled ~= nil then return enabled end
local filesystem = fs()
if not filesystem then
enabled = false
return false
end
enabled = filesystem.getInfo(MARKER) ~= nil
return enabled
end
function SwitchDiagnostics.identityOverlay()
if identityLine then return identityLine end
local gitCommit = os.getenv("POKEPORT_GIT_COMMIT") or "unknown"
local loveNxTag = "11.5-nx1"
local buildVersion = "dev"
local filesystem = fs()
if filesystem then
local raw = filesystem.read("build-info.json")
if raw and raw ~= "" then
local ver = raw:match('"version"%s*:%s*"([^"]+)"')
if ver then buildVersion = ver end
local tag = raw:match('"loveNxTag"%s*:%s*"([^"]+)"')
if tag then loveNxTag = tag end
local commit = raw:match('"gitCommit"%s*:%s*"([^"]+)"')
if commit then gitCommit = commit end
end
end
identityLine = ("gitCommit=%s loveNxTag=%s buildVersion=%s os=%s"):format(
gitCommit, loveNxTag, buildVersion,
love and love.system and love.system.getOS() or "unknown")
return identityLine
end
function SwitchDiagnostics.onEvent(kind, payload)
if not SwitchDiagnostics.isEnabled() then return end
bufCount = bufCount + 1
local slot = ((bufCount - 1) % RING_SIZE) + 1
buffer[slot] = ("%s %s"):format(tostring(kind), encodePayload(payload))
end
function SwitchDiagnostics.onJoystickEvent(kind, joystick, button, extra)
if not SwitchDiagnostics.isEnabled() then return end
local payload = { button = button }
if joystick then
if joystick.getGUID then payload.guid = joystick:getGUID() end
if joystick.isGamepad then payload.isGamepad = joystick:isGamepad() end
if joystick.getName then payload.name = joystick:getName() end
end
if extra then
for k, v in pairs(extra) do payload[k] = v end
end
SwitchDiagnostics.onEvent(kind, payload)
end
function SwitchDiagnostics.logLuaError(msg)
local filesystem = fs()
if not filesystem then return nil end
local text = redactString(tostring(msg or "unknown error"))
local existing = filesystem.read(ERROR_LOG) or ""
if #existing > ERROR_LOG_MAX then
filesystem.write(ERROR_LOG_ROTATED, existing)
existing = ""
end
local stamp = os.date("!%Y-%m-%dT%H:%M:%SZ")
local line = ("[%s] %s\n"):format(stamp, text)
filesystem.write(ERROR_LOG, existing .. line .. SwitchDiagnostics.identityOverlay() .. "\n")
return "Details saved to lua-error.log in the save directory."
end
function SwitchDiagnostics.maybeFlush(force, now)
if not SwitchDiagnostics.isEnabled() then return end
now = now or (love and love.timer and love.timer.getTime() or 0)
if not force and (now - lastFlushAt) < FLUSH_INTERVAL then return end
lastFlushAt = now
local filesystem = fs()
if not filesystem then return end
local lines = { SwitchDiagnostics.identityOverlay(), "---" }
local start = math.max(1, bufCount - RING_SIZE + 1)
for i = start, bufCount do
local slot = ((i - 1) % RING_SIZE) + 1
if buffer[slot] then lines[#lines + 1] = buffer[slot] end
end
filesystem.write(LOG_FILE, table.concat(lines, "\n") .. "\n")
end
-- One-shot NX asset probe written on every Play. No ROM/save bytes — only
-- paths, sizes, resolve results, and whether newImage/newImageData open.
-- Pull sdmc:.../pokemon-love2d/nx-asset-probe.log after a Yellow boot.
local PROBE_LOG = "nx-asset-probe.log"
local function probeInfo(filesystem, path)
local info = filesystem.getInfo(path)
if not info then return "missing" end
local size = info.size
if size == nil then
local bytes = filesystem.read(path)
size = type(bytes) == "string" and #bytes or -1
end
return ("type=%s size=%s"):format(tostring(info.type), tostring(size))
end
local function probeOpen(kind, path)
if kind == "image" then
local ok, err = pcall(love.graphics.newImage, path)
return ok and "ok" or ("FAIL " .. tostring(err):gsub("%s+", " "):sub(1, 160))
end
if not (love.image and love.image.newImageData) then return "skip-no-imageData" end
local ok, err = pcall(love.image.newImageData, path)
return ok and "ok" or ("FAIL " .. tostring(err):gsub("%s+", " "):sub(1, 160))
end
function SwitchDiagnostics.probeAssets(version)
local Platform = require("src.core.Platform")
if not Platform.isNX() then return end
local filesystem = fs()
if not filesystem then return end
local GameVersion = require("src.core.GameVersion")
local Assets = require("src.render.Assets")
local prefix = GameVersion.cachePrefix(version or GameVersion.get())
local lines = {
SwitchDiagnostics.identityOverlay(),
"probe=nx-asset",
"version=" .. tostring(version or GameVersion.get()),
"cachePrefix=" .. tostring(prefix),
"isNX=" .. tostring(Platform.isNX()),
"saveDir=" .. tostring(filesystem.getSaveDirectory and filesystem.getSaveDirectory() or "?"),
}
local samples = {
"assets/generated/fonts/font.png",
"assets/generated/tilesets/reds_house.png",
"assets/generated/sprites/red.png",
"assets/generated/sprites/monster.png",
}
for _, path in ipairs(samples) do
local versioned = prefix ~= "" and (prefix .. path) or path
local resolved = Assets.resolve(path)
lines[#lines + 1] = ("--- %s"):format(path)
lines[#lines + 1] = "unprefixed=" .. probeInfo(filesystem, path)
if prefix ~= "" then
lines[#lines + 1] = "versioned=" .. probeInfo(filesystem, versioned)
end
lines[#lines + 1] = "resolve=" .. tostring(resolved)
lines[#lines + 1] = "newImage=" .. probeOpen("image", resolved)
lines[#lines + 1] = "newImageData=" .. probeOpen("imageData", resolved)
if prefix ~= "" and resolved ~= versioned then
lines[#lines + 1] = "newImage_versioned=" .. probeOpen("image", versioned)
lines[#lines + 1] = "newImageData_versioned=" .. probeOpen("imageData", versioned)
end
end
-- Shallow listing so we can see if the extract tree exists at all.
local roots = { "yellow", "blue", "assets", "yellow/assets/generated",
"yellow/assets/generated/sprites", "blue/assets/generated/sprites" }
for _, dir in ipairs(roots) do
local info = filesystem.getInfo(dir)
if info and info.type == "directory" and filesystem.getDirectoryItems then
local items = filesystem.getDirectoryItems(dir) or {}
local n = math.min(8, #items)
local head = {}
for i = 1, n do head[i] = items[i] end
lines[#lines + 1] = ("list %s count=%d head=%s"):format(
dir, #items, table.concat(head, ","))
else
lines[#lines + 1] = ("list %s %s"):format(dir, info and info.type or "missing")
end
end
filesystem.write(PROBE_LOG, table.concat(lines, "\n") .. "\n")
end
return SwitchDiagnostics
+68 -19
View File
@@ -294,6 +294,24 @@ function CacheFs.read(rel)
return love.filesystem.read(rel) return love.filesystem.read(rel)
end end
-- Read cache-relative `rel` for the active GameVersion when PhysFS may hide
-- prefixed Blue/Yellow trees (fused NX mount hole). Same order Data:load
-- already used: active version prefix with CacheFs.prefix cleared, then
-- `rel` under the caller's CacheFs.prefix. Returns the bytes or nil.
function CacheFs.readActive(rel)
local GameVersion = require("src.core.GameVersion")
local prefix = GameVersion.cachePrefix()
local saved = CacheFs.prefix
CacheFs.prefix = ""
local bytes = CacheFs.read(prefix .. rel)
CacheFs.prefix = saved
if type(bytes) ~= "string" then
bytes = CacheFs.read(rel)
end
if type(bytes) == "string" then return bytes end
return nil
end
-- does cache-relative `rel` exist as a file? -- does cache-relative `rel` exist as a file?
function CacheFs.exists(rel) function CacheFs.exists(rel)
rel = withPrefix(rel) rel = withPrefix(rel)
@@ -361,29 +379,60 @@ end
-- Overlay the active version's extracted cache onto the un-prefixed read -- Overlay the active version's extracted cache onto the un-prefixed read
-- paths, so require("data.generated.*") and love.graphics.newImage( -- paths, so require("data.generated.*") and love.graphics.newImage(
-- "assets/generated/*") resolve to that version's files. Red lives at the -- "assets/generated/*") resolve to that version's files.
-- cache root and needs nothing; non-Red versions (blue/, yellow/, …) are --
-- *prepended* so they win over any Red copy at the root and over the game -- Non-Red versions live under blue/ / yellow/ in the save directory. On
-- source. Called once at boot, before Game:load (main.lua). Returns true -- desktop fused+portable we PHYSFS_mount that folder by absolute path. On
-- when nothing was needed or the mount succeeded. -- 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.
local function mountGeneratedTrees(prefix)
prefix = prefix or ""
if not (love and love.filesystem and love.filesystem.mount) then
return false
end
local mounted = false
local pairs_ = {
{ prefix .. "data/generated", "data/generated" },
{ prefix .. "assets/generated", "assets/generated" },
}
for _, item in ipairs(pairs_) do
local src, dest = item[1], item[2]
if love.filesystem.getInfo(src, "directory") then
if love.filesystem.mount(src, dest, false) then
mounted = true
end
end
end
return mounted
end
function CacheFs.mountVersion(version) function CacheFs.mountVersion(version)
local prefix = require("src.core.GameVersion").cachePrefix(version) local prefix = require("src.core.GameVersion").cachePrefix(version)
if prefix == "" then return true end -- Red: already at the root local sub = prefix:gsub("/+$", "")
local sub = prefix:gsub("/+$", "") -- "blue/" / "yellow/" -> bare dir
-- The cache root is the portable game folder when active, else LÖVE's OS -- Save-dir relative mount first (NX / no-FFI). Prepend so blue|yellow win.
-- save directory (where love.filesystem wrote blue/... or yellow/...). if sub ~= "" and love.filesystem.mount
local base = CacheFs.root() and love.filesystem.getInfo(sub, "directory") then
if not base and love.filesystem.getSaveDirectory then love.filesystem.mount(sub, "", false)
base = love.filesystem.getSaveDirectory()
end end
if not base then return false end
if mountReadable(base .. SEP .. sub, false) then return true end -- Portable / desktop fused: absolute PHYSFS_mount of the version folder.
-- Fallback when FFI/PHYSFS_mount is unavailable: LÖVE can mount a folder if sub ~= "" then
-- that lives in the save directory by name (prepended: appendToPath=false). local base = CacheFs.root()
if love.filesystem.mount then if not base and love.filesystem.getSaveDirectory then
return love.filesystem.mount(sub, "", false) base = love.filesystem.getSaveDirectory()
end
if base then
mountReadable(base .. SEP .. sub, false)
end
end end
return false
-- Version-scoped generated trees → un-prefixed paths (Red prefix is "").
mountGeneratedTrees(prefix)
return true
end end
-- Undo mountVersion. A process normally mounts exactly one version and then -- Undo mountVersion. A process normally mounts exactly one version and then
+19
View File
@@ -173,6 +173,25 @@ local function coreRows(opts)
end) end)
end end
-- ORIENTATION (#592): Android only -- the lock rides SDL's orientation
-- hint, which iOS reads only at startup (the Info.plist governs there) and
-- desktop ignores. Unlike the other launcher rows this one live-applies:
-- the window exists here too, and rotating under the player's finger is
-- the only feedback that reads.
do
local osName = love.system and love.system.getOS and love.system.getOS()
local okOr, Orientation = pcall(require, "src.core.Orientation")
if okOr and osName == "Android" then
add(Strings("ORIENTATION"),
function() return Strings(Orientation.modeLabel(opts.orientation)) end,
function(dir)
opts.orientation = Orientation.cycle(opts.orientation, dir)
Orientation.apply(opts.orientation)
return true
end)
end
end
local okFr, FaithfulRes = pcall(require, "src.core.FaithfulRes") local okFr, FaithfulRes = pcall(require, "src.core.FaithfulRes")
if okFr then if okFr then
add(Strings("FAITHFUL RATIO"), add(Strings("FAITHFUL RATIO"),
+484 -75
View File
@@ -54,10 +54,11 @@ local function C(name, a)
return rgba(c[1], c[2], c[3], a) return rgba(c[1], c[2], c[3], a)
end end
-- Every element in this view refuses to flex-shrink: overflow is always a -- Non-scroll elements refuse to flex-shrink: the engine otherwise compresses
-- scroll container's job here, and the engine otherwise compresses
-- auto-height children inside height-constrained columns until their text -- auto-height children inside height-constrained columns until their text
-- overlaps (the portrait single-column layout was the visible case). -- overlaps (the portrait single-column layout was the visible case). Scroll
-- regions are the opposite — they MUST shrink to the viewport, or their
-- height grows with content, maxScrollY stays 0, and drag/wheel do nothing.
-- horizontal padding of a props table, for content-width bookkeeping -- horizontal padding of a props table, for content-width bookkeeping
local function propsPadH(p) local function propsPadH(p)
local pad = p.padding local pad = p.padding
@@ -68,8 +69,23 @@ local function propsPadH(p)
return 0 return 0
end end
local function isScrollOverflow(props)
local o = props.overflowY or props.overflowX or props.overflow
return o == "scroll" or o == "auto"
end
local function mk(props) local function mk(props)
if props.flexShrink == nil then props.flexShrink = 0 end if props.flexShrink == nil then
props.flexShrink = isScrollOverflow(props) and 1 or 0
end
if isScrollOverflow(props) then
if props.minHeight == nil then props.minHeight = 0 end
-- Every launcher list scrolls like a native one: interpolated wheel
-- steps instead of hard 20px jumps, and a bigger per-notch distance so
-- long save/mod lists don't take dozens of notches to traverse.
if props.smoothScrollEnabled == nil then props.smoothScrollEnabled = true end
if props.scrollSpeed == nil then props.scrollSpeed = 60 end
end
-- Resolve "100%" here, against the parent's CONTENT width: the engine -- Resolve "100%" here, against the parent's CONTENT width: the engine
-- resolves a percentage against the parent's border box and ignores its -- resolves a percentage against the parent's border box and ignores its
-- padding, so every percent child of a padded container overflowed to the -- padding, so every percent child of a padded container overflowed to the
@@ -94,9 +110,66 @@ local COMMUNITY_URL = "https://bois.icu"
local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
-- FlexLove's addChild auto-sizing only propagates one ancestor while this
-- immediate-mode tree is assembled. Reconcile a completed nested container
-- with the same height box model used by Element:resize.
local function refreshAutoHeight(el)
if type(el) ~= "table" or type(el.autosizing) ~= "table"
or not el.autosizing.height
or type(el.calculateAutoHeight) ~= "function" then
return false
end
local ok, contentHeight = pcall(el.calculateAutoHeight, el)
if not ok or type(contentHeight) ~= "number"
or contentHeight ~= contentHeight
or contentHeight == math.huge or contentHeight == -math.huge then
return false
end
local padding = el.padding or {}
local top, bottom = padding.top or 0, padding.bottom or 0
local borderBoxHeight = clamp(contentHeight + top + bottom,
el.minHeight or -math.huge, el.maxHeight or math.huge)
el._borderBoxHeight = borderBoxHeight
el.height = clamp(math.max(0, borderBoxHeight - top - bottom),
el.minHeight or -math.huge, el.maxHeight or math.huge)
if type(el.invalidateLayout) == "function" then
el:invalidateLayout()
end
return borderBoxHeight
end
LauncherView._refreshAutoHeight = refreshAutoHeight
-- ------- lifecycle -- ------- lifecycle
-- All platforms: FlexLove used to map `performanceMonitoring = false` to
-- true (`false or true`), leaving layout/render timers + memory sampling on
-- every immediate-mode frame (pad-cursor lag on NX, scroll drag on desktop).
-- The vendored init is fixed, but force the flags off here too so a hot
-- reload against an already-inited FlexLove stays clean. Exported so the
-- engine tier can assert the guards without drawing the full tree.
function LauncherView.applyNxPerfGuards(imp)
if not (imp and FlexLove.isReady() and FlexLove._Performance) then
return false
end
FlexLove._Performance.enabled = false
local mp = FlexLove._Performance._memoryProfiler
if mp then mp.enabled = false end
-- Immediate-mode rebuilds allocate a full tree every frame; the default
-- "auto" GC strategy triggers a full blocking collect past 100 MB, a
-- visible hitch mid-scroll. Less frequent steps, higher threshold, on
-- every platform (was NX-only; desktop hit the same hitch, see the
-- scroll-sluggishness investigation).
if FlexLove._gcConfig then
FlexLove._gcConfig.strategy = "periodic"
FlexLove._gcConfig.interval = 90
FlexLove._gcConfig.stepSize = 40
FlexLove._gcConfig.memoryThreshold = 180
end
return true
end
local function ensureFlex(imp) local function ensureFlex(imp)
if not FlexLove.isReady() then if not FlexLove.isReady() then
FlexLove.init({ FlexLove.init({
@@ -105,6 +178,9 @@ local function ensureFlex(imp)
keyboardNavigation = false, keyboardNavigation = false,
}) })
end end
-- Re-apply on every ensure: FlexLove may already be ready from a prior
-- init (hot reload / editor round-trip). No-op when not NX.
LauncherView.applyNxPerfGuards(imp)
if not imp._flex then if not imp._flex then
imp._flex = true imp._flex = true
imp._hot = imp._hot or {} imp._hot = imp._hot or {}
@@ -123,7 +199,14 @@ end
-- engine draws with raw love.graphics and must not share canvases or input -- engine draws with raw love.graphics and must not share canvases or input
-- polling with a live UI toolkit. -- polling with a live UI toolkit.
function LauncherView.detach(imp) function LauncherView.detach(imp)
if not imp._flex then return end -- Restore the NX mouse shim even if _flex was never set (bridge can
-- install on the first update before the first draw).
if imp and imp.parkNxPointerForHost then
pcall(imp.parkNxPointerForHost, imp)
elseif imp and imp._restoreNxPointerBridge then
pcall(imp._restoreNxPointerBridge, imp)
end
if not imp or not imp._flex then return end
imp._flex = nil imp._flex = nil
if love.keyboard and love.keyboard.setKeyRepeat then if love.keyboard and love.keyboard.setKeyRepeat then
pcall(love.keyboard.setKeyRepeat, false) pcall(love.keyboard.setKeyRepeat, false)
@@ -135,22 +218,58 @@ function LauncherView.update(imp, dt)
if not imp._flex then return end if not imp._flex then return end
FlexLove.update(dt) FlexLove.update(dt)
-- Drain the action queue OUTSIDE FlexLove's dispatch, so an action is free -- Drain the action queue OUTSIDE FlexLove's dispatch, so an action is free
-- to destroy the view (Play/Edit) or block in a native picker. -- to destroy the view (Play/Edit) or block in a native picker. The batch
-- is resolved by RomImporter:runActions so the drop/disarm rules are
-- testable without a live FlexLove tree (#780).
local queue = imp._uiActions local queue = imp._uiActions
if queue and #queue > 0 then if queue and #queue > 0 then
imp._uiActions = {} imp._uiActions = {}
for _, fn in ipairs(queue) do imp:runActions(queue)
local ok, err = pcall(fn)
if not ok then print("launcher action error: " .. tostring(err)) end
end
end end
end end
-- One dedup window covers a touch release plus the mouse click SDL
-- synthesizes for the same tap.
local ACT_DEDUP = 0.35
-- Finger travel past this (px) is a scroll drag, not a tap — so dragging a
-- list row does not also fire that row's button.
local TAP_SLOP2 = 16 * 16
function LauncherView.wheelmoved(imp, dx, dy) function LauncherView.wheelmoved(imp, dx, dy)
if not imp._flex then return end if not imp._flex then return end
pcall(FlexLove.wheelmoved, dx, dy) pcall(FlexLove.wheelmoved, dx, dy)
end end
-- Touch drag scroll: FlexLove's ScrollManager only moves when these are
-- hooked. Clicks still come from EventHandler's love.touch / mouse polling;
-- the view's action dedupe covers a tap that also synthesizes a mouse click,
-- and a drag past TAP_SLOP suppresses the click that would otherwise fire
-- on the row under the finger.
function LauncherView.touchpressed(imp, id, x, y, dx, dy, pressure)
if not imp._flex then return end
imp._touchAt = imp._touchAt or {}
imp._touchAt[tostring(id)] = { x = x, y = y }
pcall(FlexLove.touchpressed, id, x, y, dx, dy, pressure)
end
function LauncherView.touchmoved(imp, id, x, y, dx, dy, pressure)
if not imp._flex then return end
local start = imp._touchAt and imp._touchAt[tostring(id)]
if start then
local ddx, ddy = x - start.x, y - start.y
if ddx * ddx + ddy * ddy > TAP_SLOP2 then
imp._suppressClickUntil = love.timer.getTime() + ACT_DEDUP
end
end
pcall(FlexLove.touchmoved, id, x, y, dx, dy, pressure)
end
function LauncherView.touchreleased(imp, id, x, y, dx, dy, pressure)
if not imp._flex then return end
if imp._touchAt then imp._touchAt[tostring(id)] = nil end
pcall(FlexLove.touchreleased, id, x, y, dx, dy, pressure)
end
-- Synthetic click for the gamepad virtual cursor: find the element under the -- Synthetic click for the gamepad virtual cursor: find the element under the
-- pad pointer and run its handler with a click-shaped event. -- pad pointer and run its handler with a click-shaped event.
function LauncherView.clickAt(imp, x, y) function LauncherView.clickAt(imp, x, y)
@@ -166,19 +285,18 @@ end
-- ------- shared widget helpers -- ------- shared widget helpers
-- One dedup window covers a touch release plus the mouse click SDL
-- synthesizes for the same tap.
local ACT_DEDUP = 0.35
local function queueAction(imp, key, fn, keepArm) local function queueAction(imp, key, fn, keepArm)
local now = love.timer.getTime() local now = love.timer.getTime()
local last = imp._actAt[key] local last = imp._actAt[key]
if last and now - last < ACT_DEDUP then return end if last and now - last < ACT_DEDUP then return end
imp._actAt[key] = now imp._actAt[key] = now
-- Any press that is not a Delete's own second click disarms the pending -- Any press that is not a Delete's own second click disarms the pending
-- delete confirm (#433's rule, preserved from the hit-rect launcher). -- delete confirm (#433's rule, preserved from the hit-rect launcher). The
if not keepArm then imp._confirmDelete = nil end -- disarm itself is applied by RomImporter:runActions when the batch drains,
imp._uiActions[#imp._uiActions + 1] = fn -- not here: one touch lands on a row AND on the chip inside it, and
-- clearing the arm as the row queued left Delete stuck on its first press
-- (#780).
imp._uiActions[#imp._uiActions + 1] = { key = key, fn = fn, keepArm = keepArm }
end end
local function handler(imp, key, action, keepArm) local function handler(imp, key, action, keepArm)
@@ -188,6 +306,15 @@ local function handler(imp, key, action, keepArm)
elseif ev.type == "unhover" then elseif ev.type == "unhover" then
imp._hot[key] = nil imp._hot[key] = nil
elseif action and (ev.type == "click" or ev.type == "touchrelease") then elseif action and (ev.type == "click" or ev.type == "touchrelease") then
if ev.type == "touchrelease" then
local dx, dy = ev.dx or 0, ev.dy or 0
if dx * dx + dy * dy > TAP_SLOP2 then
imp._suppressClickUntil = love.timer.getTime() + ACT_DEDUP
return
end
end
local untilT = imp._suppressClickUntil
if untilT and love.timer.getTime() < untilT then return end
queueAction(imp, key, action, keepArm) queueAction(imp, key, action, keepArm)
end end
end end
@@ -202,7 +329,9 @@ local function mfont(size)
size = math.max(8, math.floor(size + 0.5)) size = math.max(8, math.floor(size + 0.5))
local f = measureFonts[size] local f = measureFonts[size]
if not f then if not f then
f = love.graphics.newFont(size) -- same fallback the rendering faces get (FlexLove FontCache), or the
-- launcher measures Latin widths for text it draws with kana
f = require("src.render.UiFont").attach(love.graphics.newFont(size), size)
measureFonts[size] = f measureFonts[size] = f
end end
return f return f
@@ -210,12 +339,22 @@ end
local function textWidth(size, text) return mfont(size):getWidth(text) end local function textWidth(size, text) return mfont(size):getWidth(text) end
local function textHeight(size) return mfont(size):getHeight() end local function textHeight(size) return mfont(size):getHeight() end
-- wrapped text height at a width, from the same font the element renders -- wrapped text height at a width, from the same font the element renders.
-- Memoized: the immediate-mode rebuild asks for the same (size, width,
-- text) every frame for every visible row, and Font:getWrap re-shapes the
-- whole string each time - one of the hottest calls in the frame on mobile.
-- The key space is small (mod summaries and notes at a handful of widths).
local wrapHeightCache = {}
local function wrapHeight(size, text, width) local function wrapHeight(size, text, width)
if not text or text == "" or (width or 0) <= 0 then return 0 end if not text or text == "" or (width or 0) <= 0 then return 0 end
local key = size .. ":" .. width .. ":" .. text
local h = wrapHeightCache[key]
if h then return h end
local f = mfont(size) local f = mfont(size)
local _, lines = f:getWrap(text, width) local _, lines = f:getWrap(text, width)
return math.max(1, #lines) * f:getHeight() h = math.max(1, #lines) * f:getHeight()
wrapHeightCache[key] = h
return h
end end
-- Every text size in this file is already scaled by m.s, so FlexLove's own -- Every text size in this file is already scaled by m.s, so FlexLove's own
@@ -299,6 +438,45 @@ local function button(imp, parent, key, text, opts)
return mk(p) return mk(p)
end end
-- Measured single-row width of a header's labels and buttons, using the
-- same integer-sized fonts label()/button() render with (button() adds
-- 12px horizontal padding + 1px border per side). Tab headers use this to
-- decide between one row and a split title/buttons pair: flexWrap cannot
-- save a narrow window here, because the engine does not grow an
-- auto-sized parent for wrapped children (#748 family), so a wrapped
-- button used to land on top of whatever followed the header.
local function headNeededW(m, items)
local w, n = 0, 0
for _, it in ipairs(items) do
local size = math.floor(it.size + 0.5)
local tw = math.ceil(textWidth(size, it.text))
w = w + (it.btn and (tw + 26) or tw)
n = n + 1
end
-- inter-item gaps, plus one gap of slack where the flex spacer sits
return w + n * (10 * m.s)
end
-- One header row when everything fits, otherwise a title row and a
-- right-aligned button row stacked under it. Returns the row for the
-- title/status labels and a function to call AFTER adding them, which
-- returns the row for the buttons (inserting the flex spacer so buttons
-- sit flush right in both shapes).
local function headRows(parent, m, items)
local split = headNeededW(m, items) > (parent._innerW or m.contentW)
local function row()
return mk({ parent = parent, width = "100%",
positioning = "flex", flexDirection = "horizontal",
alignItems = "center", gap = 10 * m.s })
end
local titleRow = row()
return titleRow, function()
local btnRow = split and row() or titleRow
mk({ parent = btnRow, flex = 1 })
return btnRow
end
end
local function card(parent, props) local function card(parent, props)
local p = { local p = {
parent = parent, parent = parent,
@@ -586,8 +764,10 @@ end
-- ------- game panel -- ------- game panel
local function buildRomCard(imp, parent, m, version, info, ready, locked) local function buildRomCard(imp, parent, m, version, info, ready, locked)
local dropHint = imp.android and Strings("Copy the .gb/.gbc via USB.") local dropHint = imp.isNX and Strings("Copy the .gb/.gbc via MTP into imports/.")
or Strings("Or drop the .gb/.gbc file here.") or (imp.android and Strings("Copy the .gb/.gbc via USB.")
or Strings("Or drop the .gb/.gbc file here."))
local importLabel = imp.isNX and Strings("Scan again") or Strings("Import ROM")
local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress
if locked then if locked then
romState, romDetail = Strings("Not supported yet"), romState, romDetail = Strings("Not supported yet"),
@@ -608,12 +788,12 @@ local function buildRomCard(imp, parent, m, version, info, ready, locked)
elseif erroring then elseif erroring then
romState = Strings("Import failed") romState = Strings("Import failed")
romDetail = imp.detail or Strings("That ROM could not be imported.") romDetail = imp.detail or Strings("That ROM could not be imported.")
romBtnLabel, romBtnEnabled = Strings("Import ROM"), true romBtnLabel, romBtnEnabled = importLabel, true
elseif notice then elseif notice then
romState = Strings("No ROM imported") romState = Strings("No ROM imported")
romDetail = ((notice.status or "") .. " " .. (notice.detail or "")) romDetail = ((notice.status or "") .. " " .. (notice.detail or ""))
:gsub("^%s+", ""):gsub("%s+$", "") :gsub("^%s+", ""):gsub("%s+$", "")
romBtnLabel, romBtnEnabled = Strings("Import ROM"), true romBtnLabel, romBtnEnabled = importLabel, true
elseif imp.returning[version] then elseif imp.returning[version] then
romState = Strings("Update required") romState = Strings("Update required")
romDetail = Strings("This build needs a few more things from your ") romDetail = Strings("This build needs a few more things from your ")
@@ -623,13 +803,13 @@ local function buildRomCard(imp, parent, m, version, info, ready, locked)
romState = Strings("No ROM imported") romState = Strings("No ROM imported")
romDetail = Strings("The ROM is verified before any files are created. ") romDetail = Strings("The ROM is verified before any files are created. ")
.. dropHint .. dropHint
romBtnLabel, romBtnEnabled = Strings("Import ROM"), true romBtnLabel, romBtnEnabled = importLabel, true
end end
end end
local accent = version == "yellow" and "gold" or version local accent = version == "yellow" and "gold" or version
local c = card(parent, { padding = m.cardPad, gap = 8 * m.s }) local c = card(parent, { padding = m.cardPad, gap = 8 * m.s })
label(c, "ROM", 12 * m.s + 1, C("gray")) label(c, Strings("ROM"), 12 * m.s + 1, C("gray"))
label(c, romState, 15 * m.s + 2, C("white")) label(c, romState, 15 * m.s + 2, C("white"))
label(c, romDetail, 12 * m.s + 2, C("detail")) label(c, romDetail, 12 * m.s + 2, C("detail"))
if romProgress ~= nil then if romProgress ~= nil then
@@ -662,22 +842,20 @@ local function buildSaveFilesCard(imp, parent, m, version, ready, locked)
hintText, hintCol = sfNotice.text, (sfNotice.ok and "green" or "danger") hintText, hintCol = sfNotice.text, (sfNotice.ok and "green" or "danger")
elseif locked then elseif locked then
hintText, hintCol = Strings("Not available yet."), "warn" hintText, hintCol = Strings("Not available yet."), "warn"
elseif imp.android then
hintText = Strings("Import or export a .sav with the system file picker.")
hintCol = "warn"
else else
hintText = Strings("Import a .sav to a new slot, or export the active slot.") hintText = imp:_savesDefaultHint(version)
hintCol = "warn" hintCol = "warn"
end end
local savImportLabel = imp.isNX and Strings("Scan again") or Strings("Import save")
local c = card(parent, { padding = m.cardPad, gap = 8 * m.s }) local c = card(parent, { padding = m.cardPad, gap = 8 * m.s })
label(c, "SAVE FILES", 12 * m.s + 1, C("gray")) label(c, Strings("SAVE FILES"), 12 * m.s + 1, C("gray"))
local row = mk({ parent = c, width = "100%", local row = mk({ parent = c, width = "100%",
positioning = "flex", flexDirection = "horizontal", gap = 10 * m.s }) positioning = "flex", flexDirection = "horizontal", gap = 10 * m.s })
-- explicit halves rather than flex growth, which mis-distributed inside -- explicit halves rather than flex growth, which mis-distributed inside
-- an auto-height card -- an auto-height card
local halfW = math.floor((m.colW - 32 - 10 * m.s) / 2) local halfW = math.floor((m.colW - 32 - 10 * m.s) / 2)
button(imp, row, "sav-import-" .. version, Strings("Import save"), { button(imp, row, "sav-import-" .. version, savImportLabel, {
w = halfW, h = m.btnH, size = 13 * m.s + 1, w = halfW, h = m.btnH, size = 13 * m.s + 1,
kind = sfImportEnabled and "neutral" or "disabled", kind = sfImportEnabled and "neutral" or "disabled",
action = sfImportEnabled and function() action = sfImportEnabled and function()
@@ -713,7 +891,7 @@ local function buildSlotCard(imp, parent, m, version)
local head = mk({ parent = c, width = "100%", local head = mk({ parent = c, width = "100%",
positioning = "flex", flexDirection = "horizontal", positioning = "flex", flexDirection = "horizontal",
justifyContent = "space-between", alignItems = "center" }) justifyContent = "space-between", alignItems = "center" })
label(head, "SAVE SLOT", 12 * m.s + 1, C("gray"), { textWrap = false }) label(head, Strings("SAVE SLOT"), 12 * m.s + 1, C("gray"), { textWrap = false })
label(head, n == 1 and Strings("1 slot") or Strings("%d slots", n), label(head, n == 1 and Strings("1 slot") or Strings("%d slots", n),
12 * m.s + 1, C("gray"), { textWrap = false }) 12 * m.s + 1, C("gray"), { textWrap = false })
@@ -743,11 +921,23 @@ local function buildSlotCard(imp, parent, m, version)
math.ceil(textHeight(pillSize)) + 8) math.ceil(textHeight(pillSize)) + 8)
local metaH = math.ceil(textHeight(metaSize)) local metaH = math.ceil(textHeight(metaSize))
local rowH = 10 + headH + 5 + metaH + 8 + btnH + 10 local rowH = 10 + headH + 5 + metaH + 8 + btnH + 10
-- Fixed-height scroller so 40 slots actually overflow (page-level flex
-- scroll alone was growing with content, leaving nothing to drag).
local listParent = c
if n > 0 then
local listH = math.floor(clamp(m.h * (m.twoCol and 0.58 or 0.42), 200, 720))
listParent = mk({
parent = c, id = "slots-" .. version, width = "100%", height = listH,
overflowY = "scroll", hideScrollbars = true,
positioning = "flex", flexDirection = "vertical", gap = 10 * m.s,
padding = { right = 4 },
})
end
for _, slot in ipairs(slots) do for _, slot in ipairs(slots) do
local selected = slot.id == active local selected = slot.id == active
local rowKey = "slot-" .. version .. "-" .. slot.id local rowKey = "slot-" .. version .. "-" .. slot.id
local row = mk({ local row = mk({
parent = c, width = "100%", height = rowH, parent = listParent, width = "100%", height = rowH,
backgroundColor = C("rowBg", imp._hot[rowKey] and 0.85 or 0.6), backgroundColor = C("rowBg", imp._hot[rowKey] and 0.85 or 0.6),
border = 1, border = 1,
borderColor = selected and C("green", 0.9) or C("border", 0.25), borderColor = selected and C("green", 0.9) or C("border", 0.25),
@@ -795,10 +985,14 @@ local function buildSlotCard(imp, parent, m, version)
}) })
end end
local armed = deleteArmed(imp, "slot", slot.id, version) local armed = deleteArmed(imp, "slot", slot.id, version)
-- width pinned to the unarmed label so arming to "Sure?" never reflows -- width pinned so arming to "Sure?" never reflows the row under the
-- the row under the pointer (#433) -- pointer (#433). Pinned to the wider of the two captions, not to the
-- unarmed one: English "Delete" is the longer of the pair, but a
-- translation need not keep that order (Japanese さくじょ is shorter than
-- よろしい?), and pinning to the shorter one clips the other.
button(imp, btnRow, rowKey .. "-del", DELETE_LABEL(armed), { button(imp, btnRow, rowKey .. "-del", DELETE_LABEL(armed), {
w = math.ceil(textWidth(chipSize, DELETE_LABEL(false))) + 26, w = math.ceil(math.max(textWidth(chipSize, DELETE_LABEL(false)),
textWidth(chipSize, DELETE_LABEL(true)))) + 26,
size = chipSize, kind = armed and "dangerArmed" or "danger", size = chipSize, kind = armed and "dangerArmed" or "danger",
keepArm = true, keepArm = true,
action = function() action = function()
@@ -837,9 +1031,9 @@ local function buildGamePanel(imp, parent, m, version)
-- mode adds the cards straight to the page instead of nesting columns: -- mode adds the cards straight to the page instead of nesting columns:
-- the engine under-measures a vertical column-of-columns' auto height, -- the engine under-measures a vertical column-of-columns' auto height,
-- which pushed the footer up over the save-slot card on phone shapes. -- which pushed the footer up over the save-slot card on phone shapes.
local left, right local grid, left, right
if m.twoCol then if m.twoCol then
local grid = mk({ parent = parent, width = "100%", grid = mk({ parent = parent, width = "100%",
positioning = "flex", flexDirection = "horizontal", positioning = "flex", flexDirection = "horizontal",
gap = m.colGap, alignItems = "flex-start" }) gap = m.colGap, alignItems = "flex-start" })
left = mk({ parent = grid, width = m.colW, left = mk({ parent = grid, width = m.colW,
@@ -869,36 +1063,49 @@ local function buildGamePanel(imp, parent, m, version)
if not locked then if not locked then
buildSlotCard(imp, right, m, version) buildSlotCard(imp, right, m, version)
end end
-- The right subtree may have grown after FlexLove last measured its
-- grandparent. Refresh only the desktop grid once both columns are complete.
if grid then refreshAutoHeight(grid) end
end end
-- ------- mods panel -- ------- mods panel
local function buildModsPanel(imp, parent, m) local function buildModsPanel(imp, parent, m)
imp:_ensureMods() imp:_ensureMods()
local ModUpdate = require("src.mods.ModUpdate")
local mods = imp.mods or {} local mods = imp.mods or {}
local enabledCount = 0 local enabledCount = 0
for _, mod in ipairs(mods) do for _, mod in ipairs(mods) do
if mod.enabled then enabledCount = enabledCount + 1 end if mod.enabled then enabledCount = enabledCount + 1 end
end end
local head = mk({ parent = parent, width = "100%", local title = Strings("Mods")
positioning = "flex", flexDirection = "horizontal", flexWrap = "wrap", local count = Strings("%d of %d enabled", enabledCount, #mods)
alignItems = "center", gap = 10 * m.s }) local importLabel = imp:_modsImportButtonLabel()
label(head, Strings("Mods"), 22 * m.s + 4, C("white"), { textWrap = false }) local items = {
label(head, Strings("%d of %d enabled", enabledCount, #mods), { size = 22 * m.s + 4, text = title },
12 * m.s + 2, C("warn"), { textWrap = false }) { size = 12 * m.s + 2, text = count },
mk({ parent = head, flex = 1 }) { size = 13 * m.s + 1, text = importLabel, btn = true },
}
if #mods > 0 then if #mods > 0 then
button(imp, head, "mods-enable-all", Strings("Enable all"), { items[#items + 1] = { size = 11 * m.s + 1, text = Strings("Enable all"), btn = true }
items[#items + 1] = { size = 11 * m.s + 1, text = Strings("Disable all"), btn = true }
end
local head, buttonsRow = headRows(parent, m, items)
label(head, title, 22 * m.s + 4, C("white"), { textWrap = false })
label(head, count, 12 * m.s + 2, C("warn"), { textWrap = false })
local btnRow = buttonsRow()
if #mods > 0 then
button(imp, btnRow, "mods-enable-all", Strings("Enable all"), {
size = 11 * m.s + 1, kind = "neutral", size = 11 * m.s + 1, kind = "neutral",
action = function() imp:_setAllMods(true) end, action = function() imp:_setAllMods(true) end,
}) })
button(imp, head, "mods-disable-all", Strings("Disable all"), { button(imp, btnRow, "mods-disable-all", Strings("Disable all"), {
size = 11 * m.s + 1, kind = "neutral", size = 11 * m.s + 1, kind = "neutral",
action = function() imp:_setAllMods(false) end, action = function() imp:_setAllMods(false) end,
}) })
end end
button(imp, head, "mods-import", Strings("Import mod .zip"), { button(imp, btnRow, "mods-import", importLabel, {
h = m.btnH, size = 13 * m.s + 1, kind = "neutral", h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
action = function() imp:chooseMod() end, action = function() imp:chooseMod() end,
}) })
@@ -907,8 +1114,7 @@ local function buildModsPanel(imp, parent, m)
label(parent, imp.modNotice.text, 12 * m.s + 2, label(parent, imp.modNotice.text, 12 * m.s + 2,
C(imp.modNotice.ok and "green" or "danger")) C(imp.modNotice.ok and "green" or "danger"))
else else
label(parent, imp.android and Strings("Or copy a mod .zip via USB.") label(parent, imp:_modsDefaultHint(), 12 * m.s + 2, C("warn"))
or Strings("Or drop a mod .zip onto the window."), 12 * m.s + 2, C("warn"))
end end
if #mods == 0 then if #mods == 0 then
@@ -919,13 +1125,94 @@ local function buildModsPanel(imp, parent, m)
positioning = "flex", justifyContent = "center", alignItems = "center", positioning = "flex", justifyContent = "center", alignItems = "center",
padding = { horizontal = 16 }, padding = { horizontal = 16 },
}) })
label(box, imp.android label(box, imp:_modsEmptyHint(),
and Strings("No mods installed - tap Import mod .zip to add one.")
or Strings("No mods installed - drop a mod .zip here to add one."),
math.floor(12 * m.s + 2.5), C("detail"), { textAlign = "center" }) math.floor(12 * m.s + 2.5), C("detail"), { textAlign = "center" })
return return
end end
-- Sort row: Name / Popularity / Release date / Last updated. The choice
-- persists in options.modSort; data-less mods (no github field, or a
-- cache that predates the feature) sink to the bottom of data sorts.
local sortKey = imp.modSort or "name"
if imp.modSort == nil then
local ok, opts = pcall(require("src.core.SaveData").loadOptions)
if ok and type(opts) == "table" and type(opts.modSort) == "string" then
sortKey = opts.modSort
imp.modSort = sortKey
end
end
local sortRow = mk({ parent = parent, width = "100%",
positioning = "flex", flexDirection = "horizontal",
flexWrap = "wrap", alignItems = "center", gap = 6 * m.s })
label(sortRow, Strings("Sort:"), 11 * m.s + 2, C("detail"), { textWrap = false })
local sorts = {
{ key = "name", label = Strings("Name") },
{ key = "popularity", label = Strings("Popularity") },
{ key = "release", label = Strings("Release date") },
{ key = "updated", label = Strings("Last updated") },
}
for _, s in ipairs(sorts) do
local active = sortKey == s.key
local key = "mod-sort-" .. s.key
mk({
parent = sortRow, text = s.label,
textColor = active and C("green")
or (imp._hot[key] and C("white") or C("detail")),
textSize = 11 * m.s + 2, textAlign = "center-center", autoScaleText = false,
backgroundColor = active and C("green", 0.18) or C("border", 0.10),
border = 1,
borderColor = active and C("green", 0.6) or C("border", 0.35),
cornerRadius = 999,
padding = { horizontal = 10, vertical = 4 },
onEvent = handler(imp, key, function()
imp.modSort = s.key
pcall(function()
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
opts.modSort = s.key
SaveData.saveOptions(opts)
end)
end),
})
end
-- Immediate mode rebuilds this panel every frame; re-sorting the whole
-- mod list per frame (with lowercased-string allocations in the
-- comparator) fed the GC for nothing. Cache the sorted array, keyed on
-- the list identity/length, the sort mode, and the update-info revision
-- that _syncModUpdateInfo bumps when release data changes.
local cache = imp._modSortCache
if cache and cache.src == mods and cache.n == #mods
and cache.key == sortKey and cache.rev == (imp._modUpdateRev or 0) then
mods = cache.list
else
local sorted = {}
for i, v in ipairs(mods) do sorted[i] = v end
table.sort(sorted, function(a, b)
local function value(mod)
if sortKey == "name" then return (mod.name or ""):lower() end
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
if sortKey == "popularity" then
return info and info.downloads and info.downloads.total or -1
end
local date = info and info.dates
if sortKey == "release" then
return date and date.first or "0000-00-00"
end
return date and date.latest or "0000-00-00"
end
local va, vb = value(a), value(b)
if va ~= vb then
if sortKey == "name" then return va < vb end
return va > vb -- data sorts newest / most popular first
end
return (a.name or ""):lower() < (b.name or ""):lower()
end)
imp._modSortCache = { src = mods, n = #mods, key = sortKey,
rev = imp._modUpdateRev or 0, list = sorted }
mods = sorted
end
-- Explicit column widths AND heights: a flex-grown container collapses -- Explicit column widths AND heights: a flex-grown container collapses
-- its children's layout in this engine, and card auto-height came up -- its children's layout in this engine, and card auto-height came up
-- short on some displays, dropping the bottom action row out of the card. -- short on some displays, dropping the bottom action row out of the card.
@@ -957,6 +1244,15 @@ local function buildModsPanel(imp, parent, m)
elseif mod.github and mod.github ~= "" then elseif mod.github and mod.github ~= "" then
checkLine, checkCol = Strings("Not checked for updates yet"), "warn" checkLine, checkCol = Strings("Not checked for updates yet"), "warn"
end end
-- Total downloads plus first/latest release dates, all from the same
-- cached release fetch. Only shown once that data actually carries
-- counts, so a pre-downloads cache entry costs the line, not a wrong "0".
local dlLine
if info and info.downloads then
local d = info.dates
dlLine = ModUpdate.statsLine(info.downloads.total,
d and d.first, d and d.latest)
end
-- measure the body: name (with the badge beside it only when it fits), -- measure the body: name (with the badge beside it only when it fits),
-- version, check line, wrapped description -- version, check line, wrapped description
@@ -970,6 +1266,9 @@ local function buildModsPanel(imp, parent, m)
if checkLine then if checkLine then
bodyH = bodyH + 4 + wrapHeight(smallSize, checkLine, bodyW) bodyH = bodyH + 4 + wrapHeight(smallSize, checkLine, bodyW)
end end
if dlLine then
bodyH = bodyH + 4 + wrapHeight(smallSize, dlLine, bodyW)
end
if mod.description ~= "" then if mod.description ~= "" then
bodyH = bodyH + 4 + wrapHeight(smallSize, mod.description, bodyW) bodyH = bodyH + 4 + wrapHeight(smallSize, mod.description, bodyW)
end end
@@ -1021,6 +1320,9 @@ local function buildModsPanel(imp, parent, m)
if checkLine then if checkLine then
label(body, checkLine, smallSize, C(checkCol), { width = "100%" }) label(body, checkLine, smallSize, C(checkCol), { width = "100%" })
end end
if dlLine then
label(body, dlLine, smallSize, C("gold"), { width = "100%" })
end
if mod.description ~= "" then if mod.description ~= "" then
label(body, mod.description, smallSize, C("detail"), { width = "100%" }) label(body, mod.description, smallSize, C("detail"), { width = "100%" })
end end
@@ -1071,25 +1373,35 @@ end
local function buildFindPanel(imp, parent, m) local function buildFindPanel(imp, parent, m)
imp._findThumbFetched = false imp._findThumbFetched = false
imp._findStatsFetched = false
imp:_ensureFind() imp:_ensureFind()
imp:_ensureMods() imp:_ensureMods()
local ModIndex = require("src.mods.ModIndex") local ModIndex = require("src.mods.ModIndex")
local ModUpdate = require("src.mods.ModUpdate")
local sources = imp.findSources or {} local sources = imp.findSources or {}
local rows = imp:_findRows() local rows = imp:_findRows()
local total = #((imp.findIndex and imp.findIndex.mods) or {}) local total = #((imp.findIndex and imp.findIndex.mods) or {})
local head = mk({ parent = parent, width = "100%", local title = Strings("Find Mods")
positioning = "flex", flexDirection = "horizontal", flexWrap = "wrap", local count = (#sources > 0) and ((#rows == total) and Strings("%d mods listed", total)
alignItems = "center", gap = 10 * m.s }) or Strings("%d of %d mods", #rows, total)) or nil
label(head, Strings("Find Mods"), 22 * m.s + 4, C("white"), { textWrap = false }) local addLabel = (#sources == 0) and Strings("Add an index") or Strings("Add index")
local items = {
{ size = 22 * m.s + 4, text = title },
{ size = 13 * m.s + 1, text = addLabel, btn = true },
}
if count then items[#items + 1] = { size = 12 * m.s + 2, text = count } end
if #sources > 0 then if #sources > 0 then
label(head, (#rows == total) and Strings("%d mods listed", total) items[#items + 1] = { size = 13 * m.s + 1, text = Strings("Refresh"), btn = true }
or Strings("%d of %d mods", #rows, total), 12 * m.s + 2, C("warn"),
{ textWrap = false })
end end
mk({ parent = head, flex = 1 }) local head, buttonsRow = headRows(parent, m, items)
label(head, title, 22 * m.s + 4, C("white"), { textWrap = false })
if count then
label(head, count, 12 * m.s + 2, C("warn"), { textWrap = false })
end
local btnRow = buttonsRow()
if #sources > 0 then if #sources > 0 then
button(imp, head, "find-refresh", Strings("Refresh"), { button(imp, btnRow, "find-refresh", Strings("Refresh"), {
h = m.btnH, size = 13 * m.s + 1, kind = "neutral", h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
action = function() action = function()
imp._findSearchFocus = false imp._findSearchFocus = false
@@ -1098,11 +1410,10 @@ local function buildFindPanel(imp, parent, m)
end, end,
}) })
end end
button(imp, head, "find-add", button(imp, btnRow, "find-add", addLabel, {
(#sources == 0) and Strings("Add an index") or Strings("Add index"), { h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
h = m.btnH, size = 13 * m.s + 1, kind = "neutral", action = function() imp:_promptAddIndex() end,
action = function() imp:_promptAddIndex() end, })
})
if imp.findNotice then if imp.findNotice then
label(parent, imp.findNotice.text, 12 * m.s + 2, label(parent, imp.findNotice.text, 12 * m.s + 2,
@@ -1149,8 +1460,7 @@ local function buildFindPanel(imp, parent, m)
imp.findQuery or "", Strings("Search mods"), imp.findQuery or "", Strings("Search mods"),
imp._findSearchFocus == true, imp._findSearchFocus == true,
function() function()
imp._findSearchFocus = true imp:_toggleFindSearchFocus()
imp:_armTextInput()
end) end)
local cats = (imp.findIndex and imp.findIndex.categories) or {} local cats = (imp.findIndex and imp.findIndex.categories) or {}
@@ -1205,6 +1515,80 @@ local function buildFindPanel(imp, parent, m)
return return
end end
-- Sort row: Name / Popularity / Release date / Last updated, the same
-- options the MODS tab offers, sharing its persisted choice
-- (options.modSort). Data comes from the same _findStats resolution the
-- cards use (feed-published, else the repo fetch); rows whose stats have
-- not resolved yet sink to the bottom of data sorts and rise as the
-- one-per-frame fetches complete.
local sortKey = imp.modSort or "name"
if imp.modSort == nil then
local ok, opts = pcall(require("src.core.SaveData").loadOptions)
if ok and type(opts) == "table" and type(opts.modSort) == "string" then
sortKey = opts.modSort
imp.modSort = sortKey
end
end
local sortRow = mk({ parent = parent, width = "100%",
positioning = "flex", flexDirection = "horizontal",
flexWrap = "wrap", alignItems = "center", gap = 6 * m.s })
label(sortRow, Strings("Sort:"), 11 * m.s + 2, C("detail"), { textWrap = false })
local sorts = {
{ key = "name", label = Strings("Name") },
{ key = "popularity", label = Strings("Popularity") },
{ key = "release", label = Strings("Release date") },
{ key = "updated", label = Strings("Last updated") },
}
for _, s in ipairs(sorts) do
local active = sortKey == s.key
local key = "find-sort-" .. s.key
mk({
parent = sortRow, text = s.label,
textColor = active and C("green")
or (imp._hot[key] and C("white") or C("detail")),
textSize = 11 * m.s + 2, textAlign = "center-center", autoScaleText = false,
backgroundColor = active and C("green", 0.18) or C("border", 0.10),
border = 1,
borderColor = active and C("green", 0.6) or C("border", 0.35),
cornerRadius = 999,
padding = { horizontal = 10, vertical = 4 },
onEvent = handler(imp, key, function()
imp.modSort = s.key
pcall(function()
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
opts.modSort = s.key
SaveData.saveOptions(opts)
end)
end),
})
end
local sorted = {}
for i, v in ipairs(rows) do sorted[i] = v end
table.sort(sorted, function(a, b)
local function value(entry)
if sortKey == "name" then
return (entry.title or entry.id or ""):lower()
end
local stats = imp:_findStats(entry)
if sortKey == "popularity" then
return stats and stats.total or -1
end
if sortKey == "release" then
return stats and stats.first or "0000-00-00"
end
return stats and stats.latest or "0000-00-00"
end
local va, vb = value(a), value(b)
if va ~= vb then
if sortKey == "name" then return va < vb end
return va > vb -- data sorts newest / most popular first
end
return (a.title or a.id or ""):lower() < (b.title or b.id or ""):lower()
end)
rows = sorted
local installed = imp:_findInstalledMap() local installed = imp:_findInstalledMap()
local thumbW = 64 * m.s local thumbW = 64 * m.s
-- Explicit measured widths AND heights, same reasoning as the mods card: -- Explicit measured widths AND heights, same reasoning as the mods card:
@@ -1218,9 +1602,18 @@ local function buildFindPanel(imp, parent, m)
local btnH = math.ceil(textHeight(chipSize)) + 14 local btnH = math.ceil(textHeight(chipSize)) + 14
for _, entry in ipairs(rows) do for _, entry in ipairs(rows) do
local action, note = findActionFor(entry, installed[entry.id]) local action, note = findActionFor(entry, installed[entry.id])
-- Release stats for the row: feed-published when the feed carries
-- them, otherwise fetched from the mod's GitHub repo (one per frame,
-- cached six hours) exactly like the MODS tab does.
local stats = imp:_findStats(entry)
local statsLine
if stats and (stats.total ~= nil or stats.first or stats.latest) then
statsLine = ModUpdate.statsLine(stats.total, stats.first, stats.latest)
end
local bodyH = math.ceil(textHeight(titleSize)) local bodyH = math.ceil(textHeight(titleSize))
+ 4 + math.ceil(textHeight(smallSize)) + 4 + math.ceil(textHeight(smallSize))
if statsLine then bodyH = bodyH + 4 + wrapHeight(smallSize, statsLine, bodyW) end
if note then bodyH = bodyH + 4 + wrapHeight(smallSize, note, bodyW) end if note then bodyH = bodyH + 4 + wrapHeight(smallSize, note, bodyW) end
if entry.summary and entry.summary ~= "" then if entry.summary and entry.summary ~= "" then
bodyH = bodyH + 4 + wrapHeight(smallSize, entry.summary, bodyW) bodyH = bodyH + 4 + wrapHeight(smallSize, entry.summary, bodyW)
@@ -1265,6 +1658,9 @@ local function buildFindPanel(imp, parent, m)
end end
label(body, meta, smallSize, C("detail"), label(body, meta, smallSize, C("detail"),
{ width = "100%", textWrap = false, textOverflow = "ellipsis" }) { width = "100%", textWrap = false, textOverflow = "ellipsis" })
if statsLine then
label(body, statsLine, smallSize, C("gold"), { width = "100%" })
end
if note then label(body, note, smallSize, C("green"), { width = "100%" }) end if note then label(body, note, smallSize, C("green"), { width = "100%" }) end
if entry.summary and entry.summary ~= "" then if entry.summary and entry.summary ~= "" then
label(body, entry.summary, smallSize, C("detail"), { width = "100%" }) label(body, entry.summary, smallSize, C("detail"), { width = "100%" })
@@ -1449,12 +1845,18 @@ end
local function buildConfirmModal(imp, m) local function buildConfirmModal(imp, m)
local c = imp._modConfirm local c = imp._modConfirm
local overlay = modalOverlay(imp, m, "confirm-out") local overlay = modalOverlay(imp, m, "confirm-out")
local panel = modalPanel(overlay, m, 420 * m.s) -- roomier than the shared 420 default: the install confirm carries the
label(panel, c.title or Strings("Confirm"), 15 * m.s + 2, C("white")) -- compat issue list and the trust warning, and those lines need air
local panel = modalPanel(overlay, m, 520 * m.s, {
gap = 12 * m.s, padding = { horizontal = 22, vertical = 20 },
})
label(panel, c.title or Strings("Confirm"), 17 * m.s + 2, C("white"))
for _, line in ipairs(c.lines or {}) do for _, line in ipairs(c.lines or {}) do
label(panel, line, 12 * m.s + 1, C("detail")) label(panel, line, 13 * m.s + 1, C("detail"))
end end
local btnRow = mk({ parent = panel, width = "100%", -- explicit height: an auto-sized row measures short while the panel
-- auto-sizes, which clipped the buttons at the panel's bottom border
local btnRow = mk({ parent = panel, width = "100%", height = m.btnH,
positioning = "flex", flexDirection = "horizontal", gap = 10 * m.s }) positioning = "flex", flexDirection = "horizontal", gap = 10 * m.s })
button(imp, btnRow, "confirm-yes", c.yesLabel or Strings("OK"), { button(imp, btnRow, "confirm-yes", c.yesLabel or Strings("OK"), {
flex = 1, h = m.btnH, size = 13 * m.s + 1, kind = "primary", flex = 1, h = m.btnH, size = 13 * m.s + 1, kind = "primary",
@@ -1712,7 +2114,12 @@ end
local function drawPadCursor(imp) local function drawPadCursor(imp)
if not imp._padCursorActive then return end if not imp._padCursorActive then return end
-- Pixel-snap on NX: subpixel polygon edges shimmer on the 720p Switch
-- framebuffer when the stick advances by fractional pixels each frame.
local x, y = imp._padCursor.x, imp._padCursor.y local x, y = imp._padCursor.x, imp._padCursor.y
if imp.isNX then
x, y = math.floor(x + 0.5), math.floor(y + 0.5)
end
love.graphics.push("all") love.graphics.push("all")
love.graphics.origin() love.graphics.origin()
love.graphics.setLineWidth(1) love.graphics.setLineWidth(1)
@@ -1782,9 +2189,11 @@ function LauncherView.draw(imp)
-- One scroll region per tab (stable id keeps its offset across frames and -- One scroll region per tab (stable id keeps its offset across frames and
-- separate per tab), holding the panel, the updater banner and the footer. -- separate per tab), holding the panel, the updater banner and the footer.
-- flexShrink/minHeight keep this viewport-sized so overflowY can scroll.
local page = mk({ local page = mk({
parent = root, id = "page-" .. imp.tab, parent = root, id = "page-" .. imp.tab,
width = "100%", flex = 1, overflowY = "scroll", hideScrollbars = true, width = "100%", flex = 1, flexShrink = 1, minHeight = 0,
overflowY = "scroll", hideScrollbars = true,
positioning = "flex", flexDirection = "vertical", positioning = "flex", flexDirection = "vertical",
gap = 12 * m.s, gap = 12 * m.s,
padding = { left = m.pad, right = m.pad + m.gutter, padding = { left = m.pad, right = m.pad + m.gutter,
+116 -9
View File
@@ -1639,9 +1639,100 @@ function RomExtractor:raw1bpp(label, width, height, relative, transparent)
return image return image
end end
-- Trading animation art: gfx/trade.asm TradingAnimationGraphics is one
-- 49-tile atlas (game_boy.2bpp, built with --remove-duplicates, then
-- link_cable.2bpp), and the Game Boy and open-cable plates are painted out
-- of it through the tilemaps in data/tilemaps.asm (GameBoyTiles 6x8,
-- LinkCableTiles 12x3), whose ids are absolute vChars2 ids starting at $31
-- because trade.asm reaches them through
-- CopyTileIDsFromList_ZeroBaseTileID. Only the developer-only Python path
-- ever wrote these files, so an imported cache had none of them and
-- TradeAnim drew the whole cinematic as plain rectangles (#750).
function RomExtractor:extractTradeArt()
local BASE, COUNT = 0x31, 49
local gfx = self:symbol("TradingAnimationGraphics")
local atlas = ImageWriter.decode2bpp(
self.rom:bytes(gfx.bank, gfx.address, COUNT * 16), COUNT * 8, 8)
local function tileX(id)
local index = id - BASE
assert(index >= 0 and index < COUNT,
("trade tile $%02X is outside the animation atlas"):format(id))
return index * 8
end
local function plate(label, tilesWide, tilesHigh, relative, matte)
local map = self:symbol(label)
local ids = self.rom:bytes(map.bank, map.address, tilesWide * tilesHigh)
local image = ImageWriter.blank(tilesWide * 8, tilesHigh * 8, 1, 1, 1, 1)
for index, id in ipairs(ids) do
ImageWriter.blit(image, atlas,
(index - 1) % tilesWide * 8,
math.floor((index - 1) / tilesWide) * 8, tileX(id), 0, 8, 8)
end
if matte then image = ImageWriter.matteColor0(image) end
self:save(image, relative)
end
plate("GameBoyTiles", 6, 8, "trade/game_boy.png", true)
plate("LinkCableTiles", 12, 3, "trade/open_cable.png", false)
for _, spec in ipairs({
{ 0x5D, "cable_conn" }, { 0x5E, "cable_seg" }, { 0x5F, "cable_corner" },
{ 0x60, "cable_end" }, { 0x61, "cable_vert" },
}) do
local tile = ImageWriter.blank(8, 8, 1, 1, 1, 1)
ImageWriter.blit(tile, atlas, 0, 0, tileX(spec[1]), 0, 8, 8)
self:save(tile, "trade/" .. spec[2] .. ".png")
end
-- Trade_DrawCableAcrossScreen fills a whole 20-tile row with tile $5e.
local horizontal = ImageWriter.blank(160, 8, 1, 1, 1, 1)
for column = 0, 19 do
ImageWriter.blit(horizontal, atlas, column * 8, 0, tileX(0x5E), 0, 8, 8)
end
self:save(horizontal, "trade/cable_horiz.png")
-- Trade_BallInsideLinkCableOAMBlock draws one tile four times with the
-- X/Y flips, so each of the two frames -- $7e travelling, $7f bulging,
-- the bottom row of TradingAnimationGraphics2 -- makes a 16x16 ball.
local ball = self:symbol("TradingAnimationGraphics2")
local frames = ImageWriter.decode2bpp(
self.rom:bytes(ball.bank, ball.address, 64), 16, 16, true)
for index, name in ipairs({ "cable_ball", "cable_ball_alt" }) do
local image = ImageWriter.blank(16, 16, 1, 1, 1, 0)
for y = 0, 7 do
for x = 0, 7 do
local r, g, b, a = frames:getPixel((index - 1) * 8 + x, 8 + y)
image:setPixel(x, y, r, g, b, a)
image:setPixel(15 - x, y, r, g, b, a)
image:setPixel(x, 15 - y, r, g, b, a)
image:setPixel(15 - x, 15 - y, r, g, b, a)
end
end
self:save(image, "trade/" .. name .. ".png")
end
-- The ring around the travelling mon: one 16x16 quadrant per animation
-- frame (engine/gfx/mon_icons.asm TradeBubbleIconGFX), mirrored into a
-- 32x32 circle by the OAM attributes in Trade_CircleOAMBlocks.
local bubble = self:symbol("TradeBubbleIconGFX")
self:write2bpp(self.rom:bytes(bubble.bank, bubble.address, 128),
16, 32, "trade/bubble.png", true)
return {
gameBoy = "assets/generated/trade/game_boy.png",
openCable = "assets/generated/trade/open_cable.png",
cableHoriz = "assets/generated/trade/cable_horiz.png",
cableConn = "assets/generated/trade/cable_conn.png",
cableVert = "assets/generated/trade/cable_vert.png",
cableCorner = "assets/generated/trade/cable_corner.png",
cableEnd = "assets/generated/trade/cable_end.png",
cableBall = "assets/generated/trade/cable_ball.png",
cableBallAlt = "assets/generated/trade/cable_ball_alt.png",
bubble = "assets/generated/trade/bubble.png",
source = "ROM:TradingAnimationGraphics + ROM:TradeBubbleIconGFX"
.. " (engine/movie/trade.asm InternalClockTradeAnim)",
}
end
function RomExtractor:extractField() function RomExtractor:extractField()
self:beginStage("Interface artwork") self:beginStage("Interface artwork")
local done, total = 0, 49 local done, total = 0, 50
local function tick() local function tick()
done = done + 1 done = done + 1
self:tick("Interface artwork", math.min(done, total), total) self:tick("Interface artwork", math.min(done, total), total)
@@ -1832,6 +1923,8 @@ function RomExtractor:extractField()
end end
self:save(emotes, "emotes.png"); tick() self:save(emotes, "emotes.png"); tick()
local tradeArt = self:extractTradeArt(); tick()
-- Yellow-only: the Surfing Pikachu minigame sheets -- Yellow-only: the Surfing Pikachu minigame sheets
-- (gfx/surfing_pikachu.asm) at pret's canvas widths, so -- (gfx/surfing_pikachu.asm) at pret's canvas widths, so
-- src/ui/SurfingMinigame.lua's quads can be read off the source pngs. -- src/ui/SurfingMinigame.lua's quads can be read off the source pngs.
@@ -1962,6 +2055,7 @@ function RomExtractor:extractField()
local converted = {} local converted = {}
for index, values in pairs(adjacency) do converted[tonumber(index)] = values end for index, values in pairs(adjacency) do converted[tonumber(index)] = values end
data.hiddenExtras.trashCans.adjacent = converted data.hiddenExtras.trashCans.adjacent = converted
data.tradeArt = tradeArt
data.source = "canonical Pokemon Red ROM + bundled port metadata" data.source = "canonical Pokemon Red ROM + bundled port metadata"
self:write("field", data) self:write("field", data)
self:tick("Interface artwork", total, total) self:tick("Interface artwork", total, total)
@@ -2023,21 +2117,23 @@ end
-- PikachuCriesPointerTable, 42 `dba` rows; each clip is `dw length` then -- PikachuCriesPointerTable, 42 `dba` rows; each clip is `dw length` then
-- 1-bit PCM, MSB first -- home/pikachu_cries.asm PlayPikachuPCM toggles -- 1-bit PCM, MSB first -- home/pikachu_cries.asm PlayPikachuPCM toggles
-- rAUD3LEVEL per bit at roughly 190 CPU cycles a sample). Decoded to -- rAUD3LEVEL per bit at roughly 190 CPU cycles a sample). Decoded to
-- plain 8-bit mono WAVs; returns the clip count for data.audio.pikaCries, -- 16-bit stereo WAVs (identical L/R) so OpenAL never spatializes them as
-- ambient surround (#626); returns the clip count for data.audio.pikaCries,
-- or nil when the manifest has no pointer table (Red/Blue). -- or nil when the manifest has no pointer table (Red/Blue).
function RomExtractor:extractPikachuCries() function RomExtractor:extractPikachuCries()
if not self.symbols["PikachuCriesPointerTable"] then return nil end if not self.symbols["PikachuCriesPointerTable"] then return nil end
local NUM = 42 -- NUM_PIKA_CRIES local NUM = 42 -- NUM_PIKA_CRIES
local RATE = 22050 -- ~4.19 MHz / ~190 cycles per sample local RATE = 22050 -- ~4.19 MHz / ~190 cycles per sample
-- byte -> 8 samples, MSB first (LoadNextSoundClipSample: `and $80`) -- byte -> 8 mono sample levels, MSB first (LoadNextSoundClipSample: `and $80`)
-- levels match the old unsigned-8 WAV (on=0xE0, off=0x20) as floats in [-1,1]
local lut = {} local lut = {}
for byte = 0, 255 do for byte = 0, 255 do
local out = {} local out = {}
for bit = 7, 0, -1 do for bit = 7, 0, -1 do
local on = math.floor(byte / 2 ^ bit) % 2 == 1 local on = math.floor(byte / 2 ^ bit) % 2 == 1
out[#out + 1] = string.char(on and 0xE0 or 0x20) out[#out + 1] = on and ((0xE0 - 128) / 128) or ((0x20 - 128) / 128)
end end
lut[byte] = table.concat(out) lut[byte] = out
end end
local function u16(v) local function u16(v)
return string.char(v % 256, math.floor(v / 256) % 256) return string.char(v % 256, math.floor(v / 256) % 256)
@@ -2046,6 +2142,12 @@ function RomExtractor:extractPikachuCries()
return string.char(v % 256, math.floor(v / 256) % 256, return string.char(v % 256, math.floor(v / 256) % 256,
math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256) math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256)
end end
local function i16le(f)
local v = math.floor(f * 32767 + (f >= 0 and 0.5 or -0.5))
if v > 32767 then v = 32767 elseif v < -32768 then v = -32768 end
if v < 0 then v = v + 65536 end
return string.char(v % 256, math.floor(v / 256) % 256)
end
local CacheFs = require("src.import.CacheFs") local CacheFs = require("src.import.CacheFs")
local pointers = self:symbol("PikachuCriesPointerTable") local pointers = self:symbol("PikachuCriesPointerTable")
for index = 0, NUM - 1 do for index = 0, NUM - 1 do
@@ -2054,11 +2156,16 @@ function RomExtractor:extractPikachuCries()
local header = self.rom:bytes(bank, address, 2) local header = self.rom:bytes(bank, address, 2)
local length = header[1] + header[2] * 256 local length = header[1] + header[2] * 256
local raw = self.rom:bytes(bank, address + 2, length) local raw = self.rom:bytes(bank, address + 2, length)
local samples = {} local parts = {}
for i, byte in ipairs(raw) do samples[i] = lut[byte] end for _, byte in ipairs(raw) do
local pcm = table.concat(samples) for _, level in ipairs(lut[byte]) do
local s = i16le(level)
parts[#parts + 1] = s .. s -- identical L/R (#626)
end
end
local pcm = table.concat(parts)
local wav = "RIFF" .. u32(36 + #pcm) .. "WAVEfmt " .. u32(16) local wav = "RIFF" .. u32(36 + #pcm) .. "WAVEfmt " .. u32(16)
.. u16(1) .. u16(1) .. u32(RATE) .. u32(RATE) .. u16(1) .. u16(8) .. u16(1) .. u16(2) .. u32(RATE) .. u32(RATE * 4) .. u16(4) .. u16(16)
.. "data" .. u32(#pcm) .. pcm .. "data" .. u32(#pcm) .. pcm
local ok, err = CacheFs.write( local ok, err = CacheFs.write(
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(index + 1), ("assets/generated/audio/pika_cries/cry_%02d.wav"):format(index + 1),
File diff suppressed because it is too large Load Diff
+28 -6
View File
@@ -6,8 +6,10 @@
-- bytes), runs them through SaveConvert.importSav (32768-byte + checksum -- bytes), runs them through SaveConvert.importSav (32768-byte + checksum
-- validated), then registers a fresh slot, writes it, and makes it active. -- validated), then registers a fresh slot, writes it, and makes it active.
-- Export loads the active slot, encodes it back to a 32768-byte SRAM image, and -- Export loads the active slot, encodes it back to a 32768-byte SRAM image, and
-- drops it in the save directory's exports/ folder, returning the absolute path -- drops it in exports/<version>/ under the same root SaveData's persistFs
-- so the launcher can offer an "open folder" affordance. -- writes slots to -- the portable game folder when portable.txt marks the
-- install, otherwise the LOVE save directory (#752) -- returning the absolute
-- path so the launcher can offer an "open folder" affordance.
-- --
-- Every failure returns false + a friendly one-line message (never raises), so -- Every failure returns false + a friendly one-line message (never raises), so
-- the card can surface it as a red notice line rather than crashing. -- the card can surface it as a red notice line rather than crashing.
@@ -99,7 +101,8 @@ end
-- exportActiveSlot(version) -> ok, pathOrErr -- exportActiveSlot(version) -> ok, pathOrErr
-- Loads the version's active slot save (SaveData.load semantics), encodes it -- Loads the version's active slot save (SaveData.load semantics), encodes it
-- back to a 32768-byte SRAM image, and writes it to -- back to a 32768-byte SRAM image, and writes it to
-- exports/gen1recomp-<version>-<slotId>.sav in the save directory (created if -- exports/<version>/gen1recomp-<version>-<slotId>.sav under the portable game
-- folder when portable mode is on, otherwise the save directory (created if
-- absent). Returns true + the absolute path on success, false + a friendly -- absent). Returns true + the absolute path on success, false + a friendly
-- message otherwise. -- message otherwise.
function SaveFileIO.exportActiveSlot(version) function SaveFileIO.exportActiveSlot(version)
@@ -109,12 +112,31 @@ function SaveFileIO.exportActiveSlot(version)
local bytes, exportErr = SaveConvert.exportSav(save, version) local bytes, exportErr = SaveConvert.exportSav(save, version)
if not bytes then return false, exportErr end if not bytes then return false, exportErr end
local slotId = SaveData.activeSlot(version) or "save" local slotId = SaveData.activeSlot(version) or "save"
local fs = love and love.filesystem -- Portable mode is the same seam SaveData's own persistFs uses: when
-- portable.txt marks the install every persistent write leaves the OS save
-- directory for the game folder, and an export is no exception. Writing
-- through love.filesystem here dropped the .sav in AppData while the slots
-- it came from lived on the stick, and the desktop "Open folder" affordance
-- (RomImporter:exportSave) followed the returned path straight there (#752).
local portableFs = SaveData.portableFs()
local fs = portableFs or (love and love.filesystem)
if not (fs and fs.write) then return false, "no filesystem available to export to" end if not (fs and fs.write) then return false, "no filesystem available to export to" end
if fs.createDirectory then fs.createDirectory("exports") end if fs.createDirectory then
local rel = ("exports/gen1recomp-%s-%s.sav"):format(version, slotId) fs.createDirectory("exports")
fs.createDirectory("exports/" .. version)
end
-- Per-game folder so MTP browsing matches inbox layout (red/blue/yellow).
local rel = ("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, slotId)
local ok, writeErr = fs.write(rel, bytes) local ok, writeErr = fs.write(rel, bytes)
if not ok then return false, "could not write the export: " .. tostring(writeErr) end if not ok then return false, "could not write the export: " .. tostring(writeErr) end
-- Absolute path for the notice line, resolved against whichever root took
-- the write. Portable paths use the OS separator (slotDiskPath does the
-- same); LOVE save-directory paths stay "/"-joined as before.
local portableBase = SaveData.portableBaseDir()
if portableBase then
local sep = package.config:sub(1, 1)
return true, portableBase .. sep .. rel:gsub("/", sep)
end
local base = fs.getSaveDirectory and fs.getSaveDirectory() or "" local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
if base ~= "" then return true, base .. "/" .. rel end if base ~= "" then return true, base .. "/" .. rel end
return true, rel return true, rel
+86 -47
View File
@@ -12,9 +12,19 @@
local Flags = require("src.script.Flags") local Flags = require("src.script.Flags")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local romText = require("src.core.RomText")
local ItemEffects = {} local ItemEffects = {}
local function notTime(data, save)
return romText(data, "_ItemUseNotTimeText",
"OAK: %s!\nThis isn't the\ntime to use that!", save.player.name)
end
local function noEffect(data)
return romText(data, "_ItemUseNoEffectText", "It won't have\nany effect.")
end
local HEAL_AMOUNT = { local HEAL_AMOUNT = {
POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200, POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200,
FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80, FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80,
@@ -82,6 +92,14 @@ local function cureActiveToxic(battle, target)
end end
end end
-- the per-item cure lines (item_effects.asm .cureStatusAilment picks the
-- text by item id); FULL_RESTORE lands here too when it acts as a cure
local CURE_TEXT = {
ANTIDOTE = "_AntidoteText", BURN_HEAL = "_BurnHealText",
ICE_HEAL = "_IceHealText", AWAKENING = "_AwakeningText",
PARLYZ_HEAL = "_ParlyzHealText", FULL_HEAL = "_FullHealText",
}
-- battle-only stat boosters (engine/items/item_effects.asm ItemUseXStat) -- battle-only stat boosters (engine/items/item_effects.asm ItemUseXStat)
local X_ITEMS = { local X_ITEMS = {
X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed", X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed",
@@ -130,8 +148,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP" if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
or itemId == "RARE_CANDY" or itemId == "COIN_CASE" or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
or (itemDef and itemDef.machine)) then or (itemDef and itemDef.machine)) then
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", return "failed", { notTime(data, save) }
save.player.name) }
end end
if BALLS[itemId] then if BALLS[itemId] then
@@ -148,13 +165,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- data/scripts/story.lua's snorlaxWake) -- data/scripts/story.lua's snorlaxWake)
local mapId, npc = adjacentSleepingSnorlax(save, ow) local mapId, npc = adjacentSleepingSnorlax(save, ow)
if npc then if npc then
return "flute_wake", { data.text._PlayedFluteHadEffectText return "flute_wake", { romText(data, "_PlayedFluteHadEffectText",
or Strings("{PLAYER} played the\nPOKé FLUTE.") }, "{PLAYER} played the\nPOKé FLUTE.") },
{ mapId = mapId, npc = npc } { mapId = mapId, npc = npc }
end end
-- otherwise: play the tune, nothing happens (ItemUsePokeFlute's -- otherwise: play the tune, nothing happens (ItemUsePokeFlute's
-- PlayedFluteNoEffectText branch) -- PlayedFluteNoEffectText branch)
return "flute_field", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") } return "flute_field", { romText(data, "_PlayedFluteNoEffectText",
"Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
end end
local woke = false local woke = false
local function wake(mon) local function wake(mon)
@@ -169,17 +187,20 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- WakeUpEntireParty runs on the enemy's bench too -- WakeUpEntireParty runs on the enemy's bench too
for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end
if not woke then if not woke then
return "failed", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") } return "failed", { romText(data, "_PlayedFluteNoEffectText",
"Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
end end
return "flute", { Strings("%s played the\nPOKé FLUTE.", save.player.name), return "flute", { romText(data, "_PlayedFluteHadEffectText",
Strings("All sleeping\nPOKéMON woke up!") } "%s played the\nPOKé FLUTE.", save.player.name),
romText(data, "_FluteWokeUpText",
"All sleeping\nPOKéMON woke up!") }
end end
-- battle-only items -- battle-only items
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC" if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
or itemId == "POKE_DOLL" then or itemId == "POKE_DOLL" then
if not battle then if not battle then
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } return "failed", { notTime(data, save) }
end end
local b = battle.player local b = battle.player
-- PIKAHAPPY_USEDXITEM (item_effects.asm ItemUseXAccuracy / -- PIKAHAPPY_USEDXITEM (item_effects.asm ItemUseXAccuracy /
@@ -201,7 +222,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- effect, so at +6 it is still consumed and StatModifierUpEffect -- effect, so at +6 it is still consumed and StatModifierUpEffect
-- just prints "Nothing happened!" -- just prints "Nothing happened!"
if cur >= 6 then if cur >= 6 then
return "consumed", { Strings("Nothing happened!") } return "consumed", { romText(data, "_NothingHappenedText",
"Nothing happened!") }
end end
b.stages[stat] = cur + 1 b.stages[stat] = cur + 1
return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) } return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) }
@@ -210,7 +232,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- the item, even when it is already active -- the item, even when it is already active
if itemId == "DIRE_HIT" then if itemId == "DIRE_HIT" then
b.focusEnergy = true b.focusEnergy = true
return "consumed", { Strings("%s's\ngetting pumped!", b.name) } return "consumed", { romText(data, "_GettingPumpedText",
"%s's\ngetting pumped!", b.name) }
end end
if itemId == "GUARD_SPEC" then if itemId == "GUARD_SPEC" then
b.mist = true b.mist = true
@@ -219,10 +242,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if itemId == "POKE_DOLL" then if itemId == "POKE_DOLL" then
if battle.kind ~= "wild" then if battle.kind ~= "wild" then
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles -- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
return "failed", { Strings( return "failed", { notTime(data, save) }
"OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end end
return "consumed_escape", { Strings("The wild POKéMON\nran away!") } return "consumed_escape", { romText(data, "_WildRanText",
"The wild POKéMON\nran away!", battle.enemy and battle.enemy.name) }
end end
end end
@@ -231,7 +254,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- restore every move with no menu. -- restore every move with no menu.
if itemId == "ETHER" or itemId == "MAX_ETHER" if itemId == "ETHER" or itemId == "MAX_ETHER"
or itemId == "ELIXER" or itemId == "MAX_ELIXER" then or itemId == "ELIXER" or itemId == "MAX_ELIXER" then
if not target then return "failed", { Strings("It won't have\nany effect.") } end if not target then return "failed", { noEffect(data) } end
local restored = false local restored = false
local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER" local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER"
local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER" local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER"
@@ -253,9 +276,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
restored = mv and restore(mv) or false restored = mv and restore(mv) or false
end end
if not restored then if not restored then
return "failed", { Strings("It won't have\nany effect.") } return "failed", { noEffect(data) }
end end
return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) } -- pokered's line names no mon, so the extracted text takes no args
return "consumed", { romText(data, "_PPRestoredText", "PP was restored.") }
end end
-- PIKAHAPPY_USEDITEM (item_effects.asm ItemUseMedicine, item id up to -- PIKAHAPPY_USEDITEM (item_effects.asm ItemUseMedicine, item id up to
@@ -280,10 +304,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
target.status = nil target.status = nil
cureActiveToxic(battle, target) cureActiveToxic(battle, target)
require("src.core.Sound").play(data, "Heal_Ailment") require("src.core.Sound").play(data, "Heal_Ailment")
return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) } return "consumed", { romText(data, CURE_TEXT.FULL_HEAL,
"%s's\nstatus returned\nto normal!", monName(data, target)) }
end end
if not target or target.hp <= 0 or target.hp >= target.stats.hp then if not target or target.hp <= 0 or target.hp >= target.stats.hp then
return "failed", { Strings("It won't have\nany effect.") } return "failed", { noEffect(data) }
end end
-- wHPBarOldHP: the bar animation starts from the HP the mon had BEFORE -- wHPBarOldHP: the bar animation starts from the HP the mon had BEFORE
-- the item landed (item_effects.asm latches it with the party menu still -- the item landed (item_effects.asm latches it with the party menu still
@@ -295,7 +320,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
else else
target.hp = math.min(target.stats.hp, target.hp + heal) target.hp = math.min(target.stats.hp, target.hp + heal)
end end
local msgs = { Strings("%s's HP\nwas restored!", monName(data, target)) } -- _PotionText's second slot is the recovered amount ({NUM:
-- wHPBarHPDifference}); the engine fallback never prints it
local msgs = { romText(data, "_PotionText", "%s's HP\nwas restored!",
monName(data, target), target.hp - before) }
if itemId == "FULL_RESTORE" then if itemId == "FULL_RESTORE" then
target.status = nil target.status = nil
cureActiveToxic(battle, target) cureActiveToxic(battle, target)
@@ -307,17 +335,18 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local cures = STATUS_HEAL[itemId] local cures = STATUS_HEAL[itemId]
if cures then if cures then
if not target or not target.status or not cures[target.status] then if not target or not target.status or not cures[target.status] then
return "failed", { Strings("It won't have\nany effect.") } return "failed", { noEffect(data) }
end end
target.status = nil target.status = nil
cureActiveToxic(battle, target) cureActiveToxic(battle, target)
require("src.core.Sound").play(data, "Heal_Ailment") require("src.core.Sound").play(data, "Heal_Ailment")
return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) } return "consumed", { romText(data, CURE_TEXT[itemId],
"%s's\nstatus returned\nto normal!", monName(data, target)) }
end end
if itemId == "REVIVE" or itemId == "MAX_REVIVE" then if itemId == "REVIVE" or itemId == "MAX_REVIVE" then
if not target or target.hp > 0 then if not target or target.hp > 0 then
return "failed", { Strings("It won't have\nany effect.") } return "failed", { noEffect(data) }
end end
target.status = nil target.status = nil
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
@@ -329,13 +358,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if battle and battle.participants then if battle and battle.participants then
battle.participants[target] = true battle.participants[target] = true
end end
return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) }, return "consumed", { romText(data, "_ReviveText",
"%s\nis revitalized!", monName(data, target)) },
{ healedFrom = 0 } { healedFrom = 0 }
end end
if itemId == "RARE_CANDY" then if itemId == "RARE_CANDY" then
if not target or target.level >= 100 then if not target or target.level >= 100 then
return "failed", { Strings("It won't have\nany effect.") } return "failed", { noEffect(data) }
end end
local Growth = require("src.pokemon.Growth") local Growth = require("src.pokemon.Growth")
local Stats = require("src.pokemon.Stats") local Stats = require("src.pokemon.Stats")
@@ -348,12 +378,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- PIKAHAPPY_LEVELUP on a candy level (item_effects.asm:1540) -- PIKAHAPPY_LEVELUP on a candy level (item_effects.asm:1540)
require("src.world.PikachuFollower") require("src.world.PikachuFollower")
.modifyHappiness(save, "LEVELUP", target) .modifyHappiness(save, "LEVELUP", target)
return "consumed", { Strings("%s grew\nto level %d!", monName(data, target), target.level) }, return "consumed", { romText(data, "_RareCandyText",
"%s grew\nto level %d!", monName(data, target), target.level) },
{ leveledTo = target.level } { leveledTo = target.level }
end end
if STONES[itemId] then if STONES[itemId] then
if not target then return "failed", { Strings("It won't have\nany effect.") } end if not target then return "failed", { noEffect(data) } end
-- Yellow's starter Pikachu never evolves: ItemUseEvoStone runs -- Yellow's starter Pikachu never evolves: ItemUseEvoStone runs
-- IsThisPartyMonStarterPikachu (OT identity match) before -- IsThisPartyMonStarterPikachu (OT identity match) before
-- TryEvolvingMon and bails with the voiced cry + RefusingText. -- TryEvolvingMon and bails with the voiced cry + RefusingText.
@@ -363,10 +394,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
and target.ot == save.player.name and target.ot == save.player.name
and target.otId == save.player.id then and target.otId == save.player.id then
require("src.core.Sound").playCry(data, "PIKACHU") require("src.core.Sound").playCry(data, "PIKACHU")
local raw = data.text and data.text._RefusingText return "failed", { romText(data, "_RefusingText",
local line = raw and raw:gsub("{RAM:[^}]*}", monName(data, target)) "%s\nis refusing!", monName(data, target)) }
or Strings("%s\nis refusing!", monName(data, target))
return "failed", { line }
end end
local speciesDef = data.pokemon[target.species] local speciesDef = data.pokemon[target.species]
for _, evo in ipairs(speciesDef.evolutions) do for _, evo in ipairs(speciesDef.evolutions) do
@@ -374,44 +403,48 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
return "consumed", nil, { evolveTo = evo.species } return "consumed", nil, { evolveTo = evo.species }
end end
end end
return "failed", { Strings("It won't have\nany effect.") } return "failed", { noEffect(data) }
end end
-- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin, -- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin,
-- engine/items/item_effects.asm) -- engine/items/item_effects.asm)
local vitaminStat = VITAMINS[itemId] local vitaminStat = VITAMINS[itemId]
if vitaminStat then if vitaminStat then
if not target then return "failed", { Strings("It won't have\nany effect.") } end if not target then return "failed", { noEffect(data) } end
target.statExp = target.statExp or {} target.statExp = target.statExp or {}
local cur = target.statExp[vitaminStat] or 0 local cur = target.statExp[vitaminStat] or 0
if cur >= 25600 then if cur >= 25600 then
return "failed", { Strings("It won't have\nany effect.") } return "failed", { noEffect(data) }
end end
target.statExp[vitaminStat] = math.min(65535, cur + 2560) target.statExp[vitaminStat] = math.min(65535, cur + 2560)
local Stats = require("src.pokemon.Stats") local Stats = require("src.pokemon.Stats")
target.stats = Stats.calc(data.pokemon[target.species], target.level, target.stats = Stats.calc(data.pokemon[target.species], target.level,
target.dvs, target.statExp) target.dvs, target.statExp)
target.hp = math.min(target.hp, target.stats.hp) target.hp = math.min(target.hp, target.stats.hp)
-- _VitaminStatRoseText's slot order is localization-dependent (the
-- Spanish ROM puts the stat before the name), so the extracted line
-- cannot be filled positionally; the engine wording stands
return "consumed", { Strings("%s's %s\nrose!", monName(data, target), return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
vitaminStat == "hp" and "HP" or vitaminStat:upper()) } vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
end end
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu) -- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
if itemId == "PP_UP" then if itemId == "PP_UP" then
if not target then return "failed", { Strings("It won't have\nany effect.") } end if not target then return "failed", { noEffect(data) } end
local mv = target.moves[moveIndex or 1] local mv = target.moves[moveIndex or 1]
local mdef = mv and data.moves[mv.id] local mdef = mv and data.moves[mv.id]
if mdef and (mv.ppUps or 0) < 3 then if mdef and (mv.ppUps or 0) < 3 then
mv.ppUps = (mv.ppUps or 0) + 1 mv.ppUps = (mv.ppUps or 0) + 1
-- each PP UP adds maxPP/5 uses on top of the base maximum -- each PP UP adds maxPP/5 uses on top of the base maximum
mv.pp = mv.pp + math.floor(mdef.pp / 5) mv.pp = mv.pp + math.floor(mdef.pp / 5)
return "consumed", { Strings("%s's PP\nincreased!", mdef.name) } return "consumed", { romText(data, "_PPIncreasedText",
"%s's PP\nincreased!", mdef.name) }
end end
return "failed", { Strings("It won't have\nany effect.") } return "failed", { noEffect(data) }
end end
if itemDef and itemDef.machine then if itemDef and itemDef.machine then
if not target then return "failed", { Strings("It won't have\nany effect.") } end if not target then return "failed", { noEffect(data) } end
local speciesDef = data.pokemon[target.species] local speciesDef = data.pokemon[target.species]
local ok = false local ok = false
for _, m in ipairs(speciesDef.tmhm) do for _, m in ipairs(speciesDef.tmhm) do
@@ -422,11 +455,16 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic -- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic
-- ItemUseNotTime/NoCyclingAllowedHere paths are silent) -- ItemUseNotTime/NoCyclingAllowedHere paths are silent)
require("src.core.Sound").play(data, "Denied") require("src.core.Sound").play(data, "Denied")
return "failed", { Strings("%s can't\nlearn that move!", monName(data, target)) } local moveName = data.moves[itemDef.machine.move].name
return "failed", { romText(data, "_MonCannotLearnMachineMoveText",
"%s can't\nlearn that move!",
monName(data, target), moveName, moveName) }
end end
for _, mv in ipairs(target.moves) do for _, mv in ipairs(target.moves) do
if mv.id == itemDef.machine.move then if mv.id == itemDef.machine.move then
return "failed", { Strings("It knows that\nmove already!") } return "failed", { romText(data, "_AlreadyKnowsText",
"It knows that\nmove already!",
monName(data, target), data.moves[itemDef.machine.move].name) }
end end
end end
-- HMs are never consumed; TMs are single-use -- HMs are never consumed; TMs are single-use
@@ -435,21 +473,21 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then if itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then
if battle then if battle then
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } return "failed", { notTime(data, save) }
end end
-- FishingInit (engine/items/item_effects.asm): cp wWalkBikeSurfState, 2 -- FishingInit (engine/items/item_effects.asm): cp wWalkBikeSurfState, 2
-- (surfing) sets carry, and every ItemUseXRod does jp c, ItemUseNotTime -- (surfing) sets carry, and every ItemUseXRod does jp c, ItemUseNotTime
-- on that carry -- surfing refuses the rod with the same OAK text as -- on that carry -- surfing refuses the rod with the same OAK text as
-- the mid-battle case above, no rod-specific message (#533) -- the mid-battle case above, no rod-specific message (#533)
if ow and ow.player and ow.player.surfing then if ow and ow.player and ow.player.surfing then
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } return "failed", { notTime(data, save) }
end end
return "fish", itemId return "fish", itemId
end end
if itemId == "BICYCLE" then if itemId == "BICYCLE" then
if battle then if battle then
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } return "failed", { notTime(data, save) }
end end
return "bicycle" return "bicycle"
end end
@@ -459,18 +497,19 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
end end
if itemId == "TOWN_MAP" then if itemId == "TOWN_MAP" then
if battle then if battle then
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } return "failed", { notTime(data, save) }
end end
return "townmap" return "townmap"
end end
if itemId == "ITEMFINDER" then if itemId == "ITEMFINDER" then
if battle then if battle then
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } return "failed", { notTime(data, save) }
end end
return "itemfinder" return "itemfinder"
end end
if itemId == "COIN_CASE" then if itemId == "COIN_CASE" then
return "failed", { Strings("Coin count:\n%d", save.coins or 0) } return "failed", { romText(data, "_CoinCaseNumCoinsText",
"Coin count:\n%d", save.coins or 0) }
end end
if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250 local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
@@ -478,7 +517,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
return "consumed", { Strings("%s used\n%s!", save.player.name, name) } return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
end end
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } return "failed", { notTime(data, save) }
end end
return ItemEffects return ItemEffects
+31 -1
View File
@@ -169,6 +169,8 @@ end
-- full identical link surfaces: nothing to negotiate, lockstep is safe -- full identical link surfaces: nothing to negotiate, lockstep is safe
-- vanilla_peer an old build, and we are unmodified, so it is right about us -- vanilla_peer an old build, and we are unmodified, so it is right about us
-- engine_skew both v2 on the same major, but different releases: trade
-- still negotiates, battle is refused (see below)
-- subset both v2 but the surfaces differ: negotiated trade, no battle -- subset both v2 but the surfaces differ: negotiated trade, no battle
-- refused an old build we would silently corrupt, or a different engine -- refused an old build we would silently corrupt, or a different engine
function Handshake.checkCompat(localHello, remoteHello) function Handshake.checkCompat(localHello, remoteHello)
@@ -182,6 +184,16 @@ function Handshake.checkCompat(localHello, remoteHello)
if major(remoteHello.engineVersion) ~= major(localHello.engineVersion) then if major(remoteHello.engineVersion) ~= major(localHello.engineVersion) then
return "refused", "engine_mismatch" return "refused", "engine_mismatch"
end end
-- A lockstep battle needs the same engine RELEASE, not just the same
-- major: the fingerprint only covers the data/mod link surface, and
-- battle logic changes between minor releases (parity fixes, move
-- effect rework...), so two honest vanilla installs a release apart
-- pair as "full" and then diverge a few turns in -- the mid-battle
-- "same mods?" desync draw of #758. Trade doesn't lockstep a
-- simulation, so it stays negotiable across releases.
if tostring(remoteHello.engineVersion) ~= tostring(localHello.engineVersion) then
return "engine_skew", "engine_release_mismatch"
end
if remoteHello.fingerprint == localHello.fingerprint then if remoteHello.fingerprint == localHello.fingerprint then
return "full", nil return "full", nil
end end
@@ -191,7 +203,7 @@ end
-- only two v2 peers that agreed on a verdict may reject a mon outright; a v1 -- only two v2 peers that agreed on a verdict may reject a mon outright; a v1
-- peer keeps the old substitute-a-move behaviour it was built against -- peer keeps the old substitute-a-move behaviour it was built against
function Handshake.strict(verdict) function Handshake.strict(verdict)
return verdict == "full" or verdict == "subset" return verdict == "full" or verdict == "subset" or verdict == "engine_skew"
end end
function Handshake.battleAllowed(verdict) function Handshake.battleAllowed(verdict)
@@ -278,6 +290,24 @@ function Handshake.describe(localHello, remoteHello, verdict, mode)
end end
return lines return lines
end end
if verdict == "engine_skew" then
-- name both releases so two friends can tell WHO updates: this used
-- to surface three turns in as a desync draw blaming mods (#758)
wrap(lines, "Your game versions")
wrap(lines, "differ:")
wrap(lines, (" you: v%s"):format(tostring(localHello.engineVersion)))
wrap(lines, (" %s: v%s"):format(peer:sub(1, 8),
tostring(remoteHello.engineVersion)))
if mode == "battle" then
wrap(lines, "Battle needs the")
wrap(lines, "same version on")
wrap(lines, "both games.")
else
wrap(lines, "Trading is limited")
wrap(lines, "to shared POKéMON.")
end
return lines
end
wrap(lines, "Your games differ.") wrap(lines, "Your games differ.")
local diff = Handshake.modDiff(localHello, remoteHello) local diff = Handshake.modDiff(localHello, remoteHello)
listMods(lines, peer .. " has:", diff.onlyTheirs) listMods(lines, peer .. " has:", diff.onlyTheirs)
+3 -3
View File
@@ -202,7 +202,7 @@ function LinkBattle.new(game, net, opts)
local theirName = opts.theirName or "FOE" local theirName = opts.theirName or "FOE"
if not Handshake.battleAllowed(opts.verdict) then if not Handshake.battleAllowed(opts.verdict) then
return nil, Strings("Link battle needs\nthe same mods on\nboth games.") return nil, Strings("Link battle needs\nthe same version\nand mods.")
end end
-- both parties pass through the same pack->unpack clamp on both -- both parties pass through the same pack->unpack clamp on both
@@ -342,7 +342,7 @@ function LinkBattle.new(game, net, opts)
localHash = localH, remoteHash = remoteH, localHash = localH, remoteHash = remoteH,
fatal = true }) fatal = true })
endAsDraw(s, Strings( endAsDraw(s, Strings(
"Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?", "Link desync!\n%s differs.\fAre both games\nthe same version\nand mods?",
component)) component))
end end
@@ -692,7 +692,7 @@ function LinkBattle.newSpectator(game, net, opts)
local guestName = opts.guestName or "GUEST" local guestName = opts.guestName or "GUEST"
if not Handshake.battleAllowed(opts.verdict) then if not Handshake.battleAllowed(opts.verdict) then
return nil, Strings("Link battle needs\nthe same mods on\nboth games.") return nil, Strings("Link battle needs\nthe same version\nand mods.")
end end
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel } local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
+3 -1
View File
@@ -714,7 +714,9 @@ function LinkState:draw()
end end
elseif self.stage == "notice" then elseif self.stage == "notice" then
drawTitle("CHECK YOUR MODS") -- a version-skew notice has nothing to do with mods (#758)
drawTitle(self.verdict == "engine_skew" and "UPDATE YOUR GAME"
or "CHECK YOUR MODS")
for i, line in ipairs(self.noticeLines or {}) do for i, line in ipairs(self.noticeLines or {}) do
if i > 8 then break end -- what fits above the prompt row if i > 8 then break end -- what fits above the prompt row
Font.draw(line, 8, 24 + (i - 1) * 12) Font.draw(line, 8, 24 + (i - 1) * 12)
+130 -15
View File
@@ -254,6 +254,80 @@ function LauncherMods.list()
return result or {} return result or {}
end end
-- ------- pre-boot translation strings
--
-- The launcher draws before Game:load, so the loader has not run and Strings
-- has no catalog. #767/#791 routed the launcher's text through Strings, but
-- nothing filled the catalog this early, so a translation mod still could not
-- reach the launcher however complete it was -- and no restart helped, because
-- the ordering is the same on every launch.
--
-- This fills it, and deliberately does the smallest thing that can: one
-- declarative file per enabled mod, lang/strings.lua, and never the entry
-- chunk. That keeps the promise the rest of this module is built on -- no mod
-- behaviour runs before the game boots -- because a catalog is data.
--
-- It is still a mod-authored chunk, so it runs with an empty environment: a
-- plain `return { ... }` evaluates fine, while anything reaching for love, io
-- or os raises and is skipped rather than being trusted this early.
--
-- Game:load calls Strings.load(Data) again after the real merge, which
-- replaces whatever this installed, so the two never disagree for long.
local STRINGS_CATALOG = "lang/strings.lua"
local function readStringsCatalog(path)
local fs = love and love.filesystem
if not (fs and fs.read) then return nil end
local rel = path .. "/" .. STRINGS_CATALOG
local raw = fs.read(rel)
if type(raw) ~= "string" or raw == "" then return nil end
local chunk = loadstring(raw, "@" .. rel)
if not chunk then return nil end
-- Lua 5.1/LuaJIT: no _ENV, so setfenv is the sandbox.
if setfenv then setfenv(chunk, {}) end
local ok, result = pcall(chunk)
if not ok or type(result) ~= "table" then return nil end
return result
end
-- deriveStrings(rows, byId, read) -> the merged catalog, pure.
-- rows is deriveList's output, byId the id -> manifest map, and read(path) a
-- reader returning that mod's catalog table (or nil). Split out so the engine
-- tier can table-drive the enable/precedence rules with no filesystem.
function LauncherMods.deriveStrings(rows, byId, read)
local out, any = {}, false
for _, row in ipairs(rows or {}) do
local manifest = row.enabled and byId and byId[row.id] or nil
local catalog = manifest and manifest.path and read(manifest.path)
for source, value in pairs(catalog or {}) do
-- an empty value means "not translated yet", never "translate to
-- blank" -- the same rule the mod's own loader applies
if type(source) == "string" and type(value) == "string"
and value ~= "" then
out[source] = value
any = true
end
end
end
return any and out or nil
end
-- translationStrings() -> a source -> translation map for the launcher, or nil
-- when no enabled mod ships one. Enable-state and ordering are deriveList's,
-- so a mod that wins a key here wins it at boot too.
function LauncherMods.translationStrings()
local ok, merged = pcall(function()
local manifests = discover()
if #manifests == 0 then return nil end
local rows = LauncherMods.deriveList(manifests, SaveData.loadOptions())
local byId = {}
for _, m in ipairs(manifests) do byId[m.id] = m end
return LauncherMods.deriveStrings(rows, byId, readStringsCatalog)
end)
if not ok then return nil end
return merged
end
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape -- setEnabled(id, enabled): persist options.mods[id] in the exact shape
-- Loader:_saveState writes (a plain boolean), so the running game and the -- Loader:_saveState writes (a plain boolean), so the running game and the
-- in-game ManagerState pick it up unchanged. -- in-game ManagerState pick it up unchanged.
@@ -282,10 +356,18 @@ end
-- ------- install (love.filesystem) -- ------- install (love.filesystem)
-- Read a .zip source into bytes. A string is an external absolute path (like -- Read a .zip source into bytes. Save-dir-relative paths (inbox /
-- a chosen ROM) read with io.*, falling back to a save-dir-relative -- picked_mod.zip) prefer love.filesystem so NX/Android never hit a cwd-relative
-- love.filesystem read; a love DroppedFile is opened the way RomImporter -- io.open that can see a different file than PhysFS. Absolute host paths
-- ingests dropped ROMs. -- (desktop picker) still use io.*. DroppedFile matches RomImporter ROM drops.
local function isHostAbsolutePath(path)
return type(path) == "string" and (
path:match("^/")
or path:match("^%a:[/\\]")
or path:match("^[Ss][Dd][Mm][Cc]:")
)
end
local function readArchive(source) local function readArchive(source)
local t = type(source) local t = type(source)
if (t == "userdata" or t == "table") and type(source.open) == "function" then if (t == "userdata" or t == "table") and type(source.open) == "function" then
@@ -297,6 +379,10 @@ local function readArchive(source)
return data return data
end end
if t == "string" then if t == "string" then
if not isHostAbsolutePath(source) and love and love.filesystem then
local data = love.filesystem.read(source)
if data then return data end
end
local f = io.open(source, "rb") local f = io.open(source, "rb")
if f then if f then
local data = f:read("*a") local data = f:read("*a")
@@ -313,6 +399,12 @@ local function readArchive(source)
return nil, "unsupported archive source" return nil, "unsupported archive source"
end end
-- Local PK\3\4 / empty-file check before mount (corrupt MTP / AppleDouble).
local function zipLooksValid(data)
if type(data) ~= "string" or #data < 4 then return false end
return data:sub(1, 2) == "PK"
end
-- Shallow listing of a mounted archive shaped for locateRoot: files by name, -- Shallow listing of a mounted archive shaped for locateRoot: files by name,
-- and for each top-level directory a "<dir>/manifest.json" marker only when it -- and for each top-level directory a "<dir>/manifest.json" marker only when it
-- actually holds one (so a lone folder with no manifest still reads as empty). -- actually holds one (so a lone folder with no manifest still reads as empty).
@@ -510,21 +602,44 @@ function LauncherMods._installZipInner(source, opts)
local fs = love.filesystem local fs = love.filesystem
local data, readErr = readArchive(source) local data, readErr = readArchive(source)
if not data then return nil, readErr end if not data then return nil, readErr end
if not zipLooksValid(data) then
-- stage into a save-dir temp so mount can reach it local label = type(source) == "string" and (source:match("[^/\\]+$") or source)
local tmp = ("mod_import_%d_%d.zip"):format(os.time(), math.random(0, 999999)) or "archive"
local ok, writeErr = fs.write(tmp, data) return nil, "not a zip file: " .. tostring(label)
if not ok then .. " (need a real .zip; skip Mac ._ files from MTP)"
return nil, "could not stage the .zip: " .. tostring(writeErr)
end end
-- Prefer in-memory mount (PHYSFS_mountMemory via FileData). Avoids Horizon's
-- "file already open" failure when write-then-mount reopens a save-dir zip.
local mount = "mod_import_mount" local mount = "mod_import_mount"
if not fs.mount(tmp, mount) then local tmp = nil
fs.remove(tmp) local mountKey = nil
return nil, "that .zip could not be opened" local mounted = false
if fs.newFileData then
local archiveName = ("mod_import_%d_%d.zip"):format(
os.time(), math.random(0, 999999))
local okFd, fd = pcall(fs.newFileData, data, archiveName)
if okFd and fd and fs.mount(fd, mount) then
mounted = true
mountKey = fd
end
end
if not mounted then
-- Fallback: stage into a save-dir temp so path-mount can reach it.
tmp = ("mod_import_%d_%d.zip"):format(os.time(), math.random(0, 999999))
local ok, writeErr = fs.write(tmp, data)
if not ok then
return nil, "could not stage the .zip: " .. tostring(writeErr)
end
if not fs.mount(tmp, mount) then
fs.remove(tmp)
return nil, "that .zip could not be opened"
end
mountKey = tmp
end end
local function cleanup() local function cleanup()
pcall(fs.unmount, tmp) pcall(fs.unmount, mountKey)
fs.remove(tmp) if tmp then fs.remove(tmp) end
end end
local prefix, rootErr = LauncherMods.locateRoot(topLevelPaths(mount)) local prefix, rootErr = LauncherMods.locateRoot(topLevelPaths(mount))
+7
View File
@@ -174,6 +174,13 @@ local function parseEntry(raw)
conflicts = raw.conflicts, conflicts = raw.conflicts,
thumbnail = str(raw.thumbnail), thumbnail = str(raw.thumbnail),
description_url = str(raw.description_url), description_url = str(raw.description_url),
-- Optional release stats a feed author can publish: total downloads
-- across all releases plus first/last release dates. Additive-only,
-- so a feed that carries them stays readable by every build that
-- predates them (and one that does not still renders fine here).
downloads = tonumber(raw.downloads),
first_release = str(raw.first_release),
last_release = str(raw.last_release),
latest = parseLatest(raw.latest), latest = parseLatest(raw.latest),
update_check = str(raw.update_check) or "pending", update_check = str(raw.update_check) or "pending",
} }
+112 -3
View File
@@ -7,6 +7,8 @@ local ModUpdate = {}
ModUpdate.CACHE_TTL = 6 * 60 * 60 -- six hours ModUpdate.CACHE_TTL = 6 * 60 * 60 -- six hours
local Strings = require("src.core.Strings")
local function stripV(tag) local function stripV(tag)
return (tostring(tag):gsub("^[vV]", "")) return (tostring(tag):gsub("^[vV]", ""))
end end
@@ -102,7 +104,9 @@ function ModUpdate.previewLine(text, maxChars)
return s return s
end end
-- Decode one GitHub release object into { version, tag, zip, prerelease, body }. -- Decode one GitHub release object into { version, tag, zip, prerelease,
-- name, body, downloads }. `downloads` is the sum of every asset's
-- download_count, GitHub's own measure of a release's downloads.
function ModUpdate.parseRelease(doc, modId) function ModUpdate.parseRelease(doc, modId)
if type(doc) ~= "table" or not doc.tag_name then if type(doc) ~= "table" or not doc.tag_name then
return nil, "no tag_name in release" return nil, "no tag_name in release"
@@ -114,6 +118,13 @@ function ModUpdate.parseRelease(doc, modId)
local triple = version:match("^(%d+%.%d+%.%d+)") local triple = version:match("^(%d+%.%d+%.%d+)")
local zip = ModUpdate.pickZipAsset(doc.assets, modId, triple) local zip = ModUpdate.pickZipAsset(doc.assets, modId, triple)
local body = type(doc.body) == "string" and doc.body or "" local body = type(doc.body) == "string" and doc.body or ""
local downloads = 0
if type(doc.assets) == "table" then
for _, a in ipairs(doc.assets) do
local d = type(a) == "table" and tonumber(a.download_count)
if d and d > 0 then downloads = downloads + d end
end
end
return { return {
version = triple, version = triple,
tag = tostring(doc.tag_name), tag = tostring(doc.tag_name),
@@ -121,6 +132,8 @@ function ModUpdate.parseRelease(doc, modId)
prerelease = doc.prerelease == true, prerelease = doc.prerelease == true,
name = type(doc.name) == "string" and doc.name or triple, name = type(doc.name) == "string" and doc.name or triple,
body = body, body = body,
downloads = downloads,
published = (doc.published_at or doc.created_at or ""):match("^(%d+%-%d+%-%d+)"),
} }
end end
@@ -154,7 +167,7 @@ function ModUpdate.parseReleases(jsonText, modId, Json)
end end
function ModUpdate.apiReleasesUrl(repo) function ModUpdate.apiReleasesUrl(repo)
return "https://api.github.com/repos/" .. repo .. "/releases?per_page=30" return "https://api.github.com/repos/" .. repo .. "/releases?per_page=100"
end end
function ModUpdate.apiLatestUrl(repo) function ModUpdate.apiLatestUrl(repo)
@@ -180,6 +193,84 @@ function ModUpdate.pickBest(releases)
return releases[1] return releases[1]
end end
-- Sum of per-release download counts. Returns nil when no release carries
-- the field (a cache entry written before downloads existed), else
-- { total = <sum>, releases = <release count> } so a caller can tell a
-- real "0 downloads" apart from "no data yet".
function ModUpdate.totalDownloads(releases)
if type(releases) ~= "table" or #releases == 0 then return nil end
local total, hasData = 0, false
for _, rel in ipairs(releases) do
local d = type(rel) == "table" and tonumber(rel.downloads)
if d then
hasData = true
total = total + d
end
end
if not hasData then return nil end
return { total = total, releases = #releases }
end
-- First and latest release dates ("YYYY-MM-DD", ISO strings compare in
-- calendar order). Returns nil when no release carries a date -- same
-- "no data yet" rule as totalDownloads.
function ModUpdate.releaseDates(releases)
if type(releases) ~= "table" or #releases == 0 then return nil end
local first, latest, has = nil, nil, false
for _, rel in ipairs(releases) do
local p = type(rel) == "table" and rel.published
if type(p) == "string" and p ~= "" then
has = true
if not first or p < first then first = p end
if not latest or p > latest then latest = p end
end
end
if not has then return nil end
return { first = first, latest = latest }
end
-- One resolver over a release list: { total, first, latest } or nil when
-- the list carries neither counts nor dates. The FIND MODS rows use this
-- on the repo's fetched releases, the same source the MODS tab trusts.
function ModUpdate.statsForReleases(releases)
local dl = ModUpdate.totalDownloads(releases)
local d = ModUpdate.releaseDates(releases)
if not dl and not d then return nil end
return {
total = dl and dl.total or nil,
first = d and d.first or nil,
latest = d and d.latest or nil,
}
end
-- Thousands-separated count for the launcher ("12,345"), plain for small
-- numbers. Never throws; garbage in, "0" out.
function ModUpdate.formatCount(n)
n = tonumber(n)
if not n or n ~= n or n < 0 then return "0" end
local s = tostring(math.floor(n))
s = s:reverse():gsub("(%d%d%d)", "%1,"):reverse()
return (s:gsub("^,", ""))
end
-- The one-line stats string both launcher panels show: "1,234 downloads
-- across all releases - Released 2024-05-31 - Updated 2026-07-01".
-- Each part is optional; nil everywhere means nil, so a row with no data
-- shows no line rather than a wrong "0".
function ModUpdate.statsLine(total, first, latest)
local parts = {}
if total ~= nil then
parts[#parts + 1] = Strings("%s downloads across all releases",
ModUpdate.formatCount(total))
end
if first or latest then
parts[#parts + 1] = Strings("Released %s - Updated %s",
first or "?", latest or "?")
end
if #parts == 0 then return nil end
return table.concat(parts, " - ")
end
-- ------- cache (options.modUpdateCache[repo]) -- ------- cache (options.modUpdateCache[repo])
local function cacheStore() local function cacheStore()
@@ -210,6 +301,22 @@ function ModUpdate.cacheFresh(entry, now, ttl)
and (now - entry.checkedAt) < ttl and (now - entry.checkedAt) < ttl
end end
-- A cache entry written before download counts existed carries no
-- `downloads` on any release. It is provably stale -- parseRelease always
-- sets the field now -- so treat it as expired: the next fetch rewrites
-- the entry in the current format, one refetch per repo, and the launcher's
-- download line stops hiding behind an old cache.
function ModUpdate.cacheUsable(cached)
if type(cached) ~= "table" or type(cached.releases) ~= "table" then
return false
end
if #cached.releases == 0 then return true end
for _, rel in ipairs(cached.releases) do
if tonumber(rel.downloads) then return true end
end
return false
end
function ModUpdate.writeCache(repo, releases) function ModUpdate.writeCache(repo, releases)
if type(repo) ~= "string" or repo == "" then return false end if type(repo) ~= "string" or repo == "" then return false end
local ok = pcall(function() local ok = pcall(function()
@@ -226,6 +333,8 @@ function ModUpdate.writeCache(repo, releases)
name = rel.name, name = rel.name,
prerelease = rel.prerelease == true, prerelease = rel.prerelease == true,
body = type(rel.body) == "string" and rel.body or "", body = type(rel.body) == "string" and rel.body or "",
downloads = tonumber(rel.downloads) or 0,
published = rel.published,
zip = rel.zip and { zip = rel.zip and {
name = rel.zip.name, name = rel.zip.name,
url = rel.zip.url, url = rel.zip.url,
@@ -280,7 +389,7 @@ function ModUpdate.fetchReleases(repo, modId, opts)
end end
if not opts.force then if not opts.force then
local cached = ModUpdate.readCache(repo) local cached = ModUpdate.readCache(repo)
if ModUpdate.cacheFresh(cached) then if ModUpdate.cacheFresh(cached) and ModUpdate.cacheUsable(cached) then
return cached.releases, nil, { fromCache = true } return cached.releases, nil, { fromCache = true }
end end
end end
+35 -3
View File
@@ -74,7 +74,11 @@ function f.map(key, value)
desc = ("map of %s -> %s"):format(key.desc, value.desc) } desc = ("map of %s -> %s"):format(key.desc, value.desc) }
end end
function f.rec(fields) -- opts.strict closes the record to unknown fields even at the extensible
-- top level. A union alternative whose fields are ALL optional needs this:
-- with top-level leniency it matches every table, so the union stops
-- rejecting anything (the font "ttf" shape was the first such alternative).
function f.rec(fields, opts)
local names = {} local names = {}
for name in pairs(fields) do names[#names + 1] = name end for name in pairs(fields) do names[#names + 1] = name end
table.sort(names) table.sort(names)
@@ -83,7 +87,7 @@ function f.rec(fields)
local ft = fields[name] local ft = fields[name]
parts[#parts + 1] = name .. (ft.kind == "opt" and "?" or "") parts[#parts + 1] = name .. (ft.kind == "opt" and "?" or "")
end end
return { kind = "rec", fields = fields, return { kind = "rec", fields = fields, strict = opts and opts.strict or nil,
desc = "{" .. table.concat(parts, ", ") .. "}" } desc = "{" .. table.concat(parts, ", ") .. "}" }
end end
@@ -175,7 +179,7 @@ checkValue = function(t, value, path, patchMode, errors, top)
-- unknown keys are preserved unless they read as a typo of a known -- unknown keys are preserved unless they read as a typo of a known
-- field. Nested recs stay strict, that is where typos hide. -- field. Nested recs stay strict, that is where typos hide.
local hint = suggest(t.fields, key) local hint = suggest(t.fields, key)
if hint or not top then if hint or not top or t.strict then
errors[#errors + 1] = ("%s.%s: unknown field%s"):format(path, tostring(key), errors[#errors + 1] = ("%s.%s: unknown field%s"):format(path, tostring(key),
hint and (' (did you mean "' .. hint .. '"?)') or "") hint and (' (did you mean "' .. hint .. '"?)') or "")
end end
@@ -1064,6 +1068,12 @@ local function fontIsCharmap(id)
return tostring(id):match("^charmap:.+$") ~= nil return tostring(id):match("^charmap:.+$") ~= nil
end end
-- the third id form: "ttf" switches text rendering to a real TTF (the
-- bundled Plain Pixel when `file` is omitted -- src/render/Font.lua)
local function fontIsTtf(id)
return tostring(id) == "ttf"
end
R.font = { R.font = {
semantics = "record", target = "font", semantics = "record", target = "font",
value = f.union{ value = f.union{
@@ -1071,6 +1081,17 @@ R.font = {
advance = f.opt(f.int(1)), advance = f.opt(f.int(1)),
charmap = f.opt(f.list(f.rec{ code = f.int(0), seq = f.str })) }, charmap = f.opt(f.list(f.rec{ code = f.int(0), seq = f.str })) },
f.rec{ seq = f.str, code = f.int(0) }, f.rec{ seq = f.str, code = f.int(0) },
-- strict: every field here is optional ({} is a legal "ttf" entry), so
-- with the usual top-level leniency this alternative would match ANY
-- table and let malformed pages through the union unchecked
f.rec({ file = f.opt(f.path), size = f.opt(f.int(1)),
spacing = f.opt(f.num), yOffset = f.opt(f.num),
bold = f.opt(f.bool),
-- characters that keep their ROM tile instead of coming from the
-- TTF: a string of them, or a list when a multi-character charmap
-- sequence is meant (src/render/Font.lua)
tiles = f.opt(f.union{ f.str, f.list(f.str) }) },
{ strict = true }),
}, },
extra = function(id, value) extra = function(id, value)
if fontIsCharmap(id) then if fontIsCharmap(id) then
@@ -1080,12 +1101,18 @@ R.font = {
if type(value.code) ~= "number" then if type(value.code) ~= "number" then
return "a charmap: entry needs a code" return "a charmap: entry needs a code"
end end
elseif fontIsTtf(id) then
-- every field optional: {} is "the bundled font at its native size"
if value.image ~= nil or value.base ~= nil then
return 'the "ttf" entry takes file/size/spacing/yOffset/bold/tiles, not a page'
end
elseif value.image == nil or value.base == nil then elseif value.image == nil or value.base == nil then
return "a font page needs an image and a base" return "a font page needs an image and a base"
end end
end, end,
baseAt = function(base, id) baseAt = function(base, id)
if fontIsCharmap(id) then return nil end if fontIsCharmap(id) then return nil end
if fontIsTtf(id) then return base.ttf end
return base.pages and base.pages[id] or nil return base.pages and base.pages[id] or nil
end, end,
baseIds = function(base) baseIds = function(base)
@@ -1096,6 +1123,9 @@ R.font = {
write = function(target, registry) write = function(target, registry)
local pages = target.pages or {} local pages = target.pages or {}
target.pages = pages target.pages = pages
-- the extractor never emits a ttf entry, so like the charmap rows it is
-- rebuilt from the registry each merge: disabling the mod disables it
target.ttf = nil
-- the extractor's rows have no id and stay put; the registry's own are -- the extractor's rows have no id and stay put; the registry's own are
-- rebuilt every merge so a re-merge replaces them instead of stacking -- rebuilt every merge so a re-merge replaces them instead of stacking
local rows = {} local rows = {}
@@ -1108,6 +1138,8 @@ R.font = {
if value ~= nil then if value ~= nil then
rows[#rows + 1] = { id = id, seq = value.seq, code = value.code } rows[#rows + 1] = { id = id, seq = value.seq, code = value.code }
end end
elseif fontIsTtf(id) then
target.ttf = value
else else
pages[id] = value pages[id] = value
end end
+9 -3
View File
@@ -15,6 +15,7 @@ local Screens = require("src.ui.Screens")
local Stats = require("src.pokemon.Stats") local Stats = require("src.pokemon.Stats")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local romText = require("src.core.RomText")
local Evolution = {} local Evolution = {}
@@ -140,7 +141,8 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
table.insert(mon.moves, { id = moveId, pp = mdef.pp }) table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
Strings("%s learned\n%s!", name, mdef.name), nextStep)) romText(game.data, "_LearnedMove1Text",
"%s learned\n%s!", name, mdef.name), nextStep))
else else
-- LearnMoveFromLevelUp with a full moveset: the forget UI -- LearnMoveFromLevelUp with a full moveset: the forget UI
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep) Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
@@ -161,8 +163,12 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
Music.play(game.data, Music.special(game.data, "evolution")) Music.play(game.data, Music.special(game.data, "evolution"))
local oldName = mon.nickname or game.data.pokemon[mon.species].name local oldName = mon.nickname or game.data.pokemon[mon.species].name
Evolution.apply(game, mon, newSpecies, via) Evolution.apply(game, mon, newSpecies, via)
local msg = Strings("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!", -- the congrats page keeps the engine wording: _EvolvedText extracts
oldName, oldName, game.data.pokemon[newSpecies].name) -- truncated (it stops at a dynamic marker the decoder does not follow)
local msg = romText(game.data, "_IsEvolvingText",
"What?\n%s is\nevolving!", oldName)
.. "\f" .. Strings("Congratulations!\nYour %s\nevolved into\n%s!",
oldName, game.data.pokemon[newSpecies].name)
game.stack:push(TextBox.new(game, msg, function() game.stack:push(TextBox.new(game, msg, function()
Music.restoreMap(game.data) Music.restoreMap(game.data)
-- re-run the evolved species' level-up learn check before onDone -- re-run the evolved species' level-up learn check before onDone
+20 -11
View File
@@ -4,9 +4,9 @@
-- its own file without editing a single record, and one flush() drops -- its own file without editing a single record, and one flush() drops
-- every downstream cache for dev-mode hot reload. -- every downstream cache for dev-mode hot reload.
-- --
-- No loader installed means resolve() is the identity, which is what -- No loader installed means resolve() is the identity. The NX Blue/Yellow
-- keeps a mod-free boot (and every headless test) loading exactly the -- versioned-cache fallback lives in src/core/NxAssetOverlay.lua (installed
-- paths it always did. -- once at boot on NX only), not here, so this module stays platform-free.
local Assets = {} local Assets = {}
@@ -29,18 +29,27 @@ local function exists(path)
end end
Assets.exists = exists Assets.exists = exists
-- an override dir shadows the generated cache; a transform's derived -- Mod overrides win; on NX, Blue/Yellow then use the prefixed save-dir file;
-- output is the fallback under it, so hand-authored art beats generated -- otherwise the caller's unprefixed path (Red / mounted overlay).
function Assets.resolve(path) function Assets.resolve(path)
local loader = Assets.loader if type(path) ~= "string" then return path end
if not loader or type(path) ~= "string" then return path end
if path:sub(1, #GENERATED) ~= GENERATED then return path end if path:sub(1, #GENERATED) ~= GENERATED then return path end
local rel = path:sub(#GENERATED + 1) local rel = path:sub(#GENERATED + 1)
for _, mod in ipairs(loader:overrideOrder()) do local loader = Assets.loader
local candidate = mod.path .. "/overrides/" .. rel if loader then
if exists(candidate) then return candidate end for _, mod in ipairs(loader:overrideOrder()) do
local candidate = mod.path .. "/overrides/" .. rel
if exists(candidate) then return candidate end
end
local derived = loader:derivedPath(rel)
if derived then return derived end
end end
return loader:derivedPath(rel) or path
-- NX Blue/Yellow: no rewrite here -- NxAssetOverlay (installed once at
-- boot on NX only) covers every loader globally, so this module stays
-- the mod-override choke point it always was.
return path
end end
function Assets.image(path) function Assets.image(path)
+184 -1
View File
@@ -6,6 +6,13 @@
-- for variable-width text; the default is the GB's flat 8px. -- for variable-width text; the default is the GB's flat 8px.
-- The charmap is matched greedily (longest sequence first) so multi-byte -- The charmap is matched greedily (longest sequence first) so multi-byte
-- UTF-8 chars and ligature glyphs like 'd 'l 's map to single glyphs. -- UTF-8 chars and ligature glyphs like 'd 'l 's map to single glyphs.
--
-- A translation may instead set data.font.ttf and render its text through a
-- real TTF (the bundled Plain Pixel by default), so a script that would need
-- hundreds of page tiles works out of the box. Single characters then draw
-- from the TTF; multi-character charmap sequences (<PK>, the 'd ligatures)
-- and the sub-0x80 chrome glyphs (borders, arrows) keep their tiles, which
-- is why the box border never depends on the TTF's coverage.
local Assets = require("src.render.Assets") local Assets = require("src.render.Assets")
@@ -13,6 +20,19 @@ local Font = {}
local GLYPH = 8 local GLYPH = 8
-- TTF glyph codes are the Unicode codepoint offset far above any page base,
-- so they flow through the same span/encode/drawCode pipeline as tiles.
local TTF_BASE = 0x400000
Font.TTF_BASE = TTF_BASE
-- The engine's bundled TTF (assets/fonts/plainpixel/README.md: CC-BY 4.0,
-- Douglas Vautour). data.font.ttf.file overrides for a mod-shipped font.
-- 15 is the font's design em: its glyphs only rasterize at their true
-- pixel size (5x11 base, 11x11 double-width) at multiples of 15; at any
-- other size they downscale unevenly (at 11, M comes out 4px and A 5px).
Font.PLAINPIXEL = "assets/fonts/plainpixel/PlainPixel-Regular.ttf"
Font.PLAINPIXEL_SIZE = 15
local state local state
local loadedFrom local loadedFrom
@@ -35,6 +55,34 @@ local function pagesOf(def)
return pages return pages
end end
-- ttf.tiles as a lookup keyed by charmap sequence. Accepts a plain string
-- ("0123456789"), which is split into UTF-8 characters, or a list of
-- sequences ({ "0", "1", "<PK>" }) when a multi-character macro is meant.
local function tileSet(spec)
local set = {}
if type(spec) == "table" then
for _, seq in ipairs(spec) do set[tostring(seq)] = true end
elseif type(spec) == "string" then
-- split on UTF-8 lead bytes rather than utf8Decode, which this file
-- declares further down and would be nil here
local i, n = 1, #spec
while i <= n do
local last = i
if spec:byte(i) >= 0xC0 then
local k = i + 1
while k <= n do
local b = spec:byte(k)
if b < 0x80 or b > 0xBF then break end
last, k = k, k + 1
end
end
set[spec:sub(i, last)] = true
i = last + 1
end
end
return set
end
function Font.load(data) function Font.load(data)
loadedFrom = data loadedFrom = data
local def = data.font local def = data.font
@@ -83,6 +131,51 @@ function Font.load(data)
Font.BORDER = {} Font.BORDER = {}
for key, code in pairs(Font.DEFAULT_BORDER) do Font.BORDER[key] = code end for key, code in pairs(Font.DEFAULT_BORDER) do Font.BORDER[key] = code end
for key, code in pairs(def.border or {}) do Font.BORDER[key] = code end for key, code in pairs(def.border or {}) do Font.BORDER[key] = code end
-- TTF mode. All fields optional: {} means "the bundled Plain Pixel at
-- its native 11px". A failed load logs and falls back to tiles, so a
-- typo'd path degrades exactly like a missing page image does above.
if type(def.ttf) == "table" then
local file = def.ttf.file or Font.PLAINPIXEL
local ok, obj = pcall(love.graphics.newFont, file,
def.ttf.size or Font.PLAINPIXEL_SIZE, "mono")
if ok and obj then
-- nearest keeps the pixel font crisp under the integer UI scale
if obj.setFilter then pcall(obj.setFilter, obj, "nearest", "nearest") end
state.ttf = {
font = obj, file = file,
-- the font's own advances already carry a 1px gap at its design
-- size; spacing adds to (or, negative, takes from) every advance
spacing = def.ttf.spacing or 0,
-- bold double-prints each glyph at a 1px offset, for fonts whose
-- single-pixel strokes read too light against the tile art
bold = def.ttf.bold == true,
-- glyphs are taller than the 8px cell (11px base, and the em box
-- reserves even more for vertical extension); anchor the font's
-- baseline to the tile font's, which sits on row 7 of the cell, so
-- caps line up and descenders hang below as the GB font's own do
yOffset = def.ttf.yOffset or (obj.getBaseline
and (GLYPH - 1 - obj:getBaseline()) or (GLYPH - obj:getHeight())),
-- Single characters that keep their ROM tile instead of coming from
-- the TTF. A CJK translation sizes the font so a kana fills the 8px
-- cell, which leaves Latin narrower than the tile font it replaces:
-- the numbers in a right-aligned column (the party menu's ":L12" over
-- "34/ 34") then no longer land where the 8px-per-character layout put
-- them. Naming "0123456789" here keeps digits on the vanilla tiles --
-- identical to the English build -- while kana still come from the
-- font. Sequence keys, so "é" or a "<PK>" macro can be listed too.
tiles = tileSet(def.ttf.tiles),
widths = {}, chars = {},
}
else
require("src.core.Logger").warn("font: could not load ttf %q (%s)",
tostring(file), tostring(obj))
end
end
end
function Font.ttfActive()
return state ~= nil and state.ttf ~= nil
end end
-- re-run load against the data it last saw, so hot reload picks up an -- re-run load against the data it last saw, so hot reload picks up an
@@ -104,6 +197,49 @@ end
local SPACE = 0x7F local SPACE = 0x7F
-- Decode one UTF-8 sequence: codepoint and the index of its last byte, or
-- nil on a malformed lead/continuation (the caller falls back to bytes).
local function utf8Decode(text, i)
local b = text:byte(i)
if not b then return nil, i end
if b < 0x80 then return b, i end
local cont, cp
if b >= 0xF0 then cont, cp = 3, b - 0xF0
elseif b >= 0xE0 then cont, cp = 2, b - 0xE0
elseif b >= 0xC0 then cont, cp = 1, b - 0xC0
else return nil, i end
for k = i + 1, i + cont do
local c = text:byte(k)
if not c or c < 0x80 or c > 0xBF then return nil, i end
cp = cp * 64 + (c - 0x80)
end
return cp, i + cont
end
local function utf8Encode(cp)
if cp < 0x80 then return string.char(cp) end
if cp < 0x800 then
return string.char(0xC0 + math.floor(cp / 64), 0x80 + cp % 64)
end
if cp < 0x10000 then
return string.char(0xE0 + math.floor(cp / 4096),
0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64)
end
return string.char(0xF0 + math.floor(cp / 262144),
0x80 + math.floor(cp / 4096) % 64,
0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64)
end
-- the character a TTF code draws, cached per code
local function ttfChar(ttf, code)
local ch = ttf.chars[code]
if not ch then
ch = utf8Encode(code - TTF_BASE)
ttf.chars[code] = ch
end
return ch
end
-- Segment text into glyph spans: `{ from, to, code }` byte ranges, one per -- Segment text into glyph spans: `{ from, to, code }` byte ranges, one per
-- drawn glyph, code nil when the charmap has nothing. A span is a whole -- drawn glyph, code nil when the charmap has nothing. A span is a whole
-- charmap sequence, so a multi-byte char ("é", "♂") and an ASCII ligature -- charmap sequence, so a multi-byte char ("é", "♂") and an ASCII ligature
@@ -119,6 +255,7 @@ local SPACE = 0x7F
-- boundaries, which is all a headless paginate needs. -- boundaries, which is all a headless paginate needs.
function Font.split(text) function Font.split(text)
local spans = {} local spans = {}
local ttf = state and state.ttf
local i, n = 1, #text local i, n = 1, #text
while i <= n do while i <= n do
local span local span
@@ -127,11 +264,24 @@ function Font.split(text)
for _, entry in ipairs(candidates) do for _, entry in ipairs(candidates) do
local len = #entry.seq local len = #entry.seq
if text:sub(i, i + len - 1) == entry.seq then if text:sub(i, i + len - 1) == entry.seq then
if ttf and not ttf.tiles[entry.seq] then
-- single characters belong to the TTF; only multi-character
-- sequences (ligatures, <PK> macros) keep their tile mapping,
-- plus anything the mod named in ttf.tiles (see Font.load)
local cp, last = utf8Decode(entry.seq, 1)
if cp and last == len then break end
end
span = { from = i, to = i + len - 1, code = entry.code } span = { from = i, to = i + len - 1, code = entry.code }
break break
end end
end end
end end
if not span and ttf then
local cp, last = utf8Decode(text, i)
if cp and cp >= 0x20 then
span = { from = i, to = last, code = TTF_BASE + cp }
end
end
if not span then if not span then
-- Nothing matched. Still keep a UTF-8 sequence whole, so a cut never -- Nothing matched. Still keep a UTF-8 sequence whole, so a cut never
-- lands mid-character even for a glyph we cannot draw. -- lands mid-character even for a glyph we cannot draw.
@@ -185,14 +335,37 @@ function Font.encode(text)
end end
function Font.drawCode(code, x, y) function Font.drawCode(code, x, y)
local ttf = state and state.ttf
if ttf and code >= TTF_BASE then
local prev = love.graphics.getFont()
love.graphics.setFont(ttf.font)
local ch = ttfChar(ttf, code)
love.graphics.print(ch, x, y + ttf.yOffset)
if ttf.bold then love.graphics.print(ch, x + 1, y + ttf.yOffset) end
if prev then love.graphics.setFont(prev) end
return
end
local page = pageFor(code) local page = pageFor(code)
if not page then return end if not page then return end
local quad = page.quads[code - page.base] local quad = page.quads[code - page.base]
if quad then love.graphics.draw(page.image, quad, x, y) end if quad then love.graphics.draw(page.image, quad, x, y) end
end end
-- how far the pen moves past a glyph; 8 unless its page says otherwise -- how far the pen moves past a glyph; 8 unless its page says otherwise.
-- TTF glyphs answer with the font's own metrics (5px base, 11px for
-- double-width kana/CJK in Plain Pixel), which is what makes TextBox's
-- pixel-budget pagination fit more of a narrow script per line.
function Font.advanceOf(code) function Font.advanceOf(code)
local ttf = state and state.ttf
if ttf and code >= TTF_BASE then
local w = ttf.widths[code]
if not w then
w = ttf.font:getWidth(ttfChar(ttf, code)) + ttf.spacing
+ (ttf.bold and 1 or 0)
ttf.widths[code] = w
end
return w
end
local page = pageFor(code) local page = pageFor(code)
return page and page.advance or GLYPH return page and page.advance or GLYPH
end end
@@ -232,8 +405,18 @@ for key, code in pairs(Font.DEFAULT_BORDER) do Font.BORDER[key] = code end
-- Draw a Game Boy style bordered box in tile coordinates. -- Draw a Game Boy style bordered box in tile coordinates.
function Font.drawBox(tx, ty, tw, th) function Font.drawBox(tx, ty, tw, th)
-- The white interior is a fill, so it needs the color; everything after it
-- is a glyph and needs the caller's. Restoring is not cosmetic: the tile
-- pages are black glyphs on transparent, so they come out black whatever
-- the color is, and leaking white here was invisible for as long as every
-- glyph was a tile. TTF text is not immune -- it draws in the current
-- color -- so a leaked white left every label printed after a box white on
-- white. On the summary screen that erased ATTACK/DEFENSE/SPEED/SPECIAL
-- and TYPE1/TYPE2 while the numbers beside them, still tiles, stayed put.
local r, g, b, a = love.graphics.getColor()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8) love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
love.graphics.setColor(r, g, b, a)
local B = Font.BORDER local B = Font.BORDER
Font.drawCode(B.tl, tx * 8, ty * 8) Font.drawCode(B.tl, tx * 8, ty * 8)
Font.drawCode(B.tr, (tx + tw - 1) * 8, ty * 8) Font.drawCode(B.tr, (tx + tw - 1) * 8, ty * 8)
+17
View File
@@ -240,6 +240,14 @@ end
-- and the zone lists are exactly the ones the states returned. -- and the zone lists are exactly the ones the states returned.
local trueColorRects = { ui = {}, world = {} } local trueColorRects = { ui = {}, world = {} }
local currentPass = nil local currentPass = nil
-- Horizontal shift applied to UI-pass marks. A wide battle keeps its 304px
-- surface through every classic state it opens, and Game:draw centres each
-- of those with a translate while centerClassicZones shifts their zone list
-- by the same amount. A rect reported from inside that translate has to
-- move with it, or the unshaded re-blit lands 72 columns off and the pic
-- keeps the shade remap -- the party STATS screen in a wide battle (#637).
-- World-pass marks are already in world-canvas space and are never centred.
local markOffsetX = 0
-- which canvas the renderer is filling. nil for a pass that composites -- which canvas the renderer is filling. nil for a pass that composites
-- with no zone list of its own (tilt's upright billboards carry their own -- with no zone list of its own (tilt's upright billboards carry their own
@@ -252,11 +260,20 @@ function PaletteFX.clearTrueColor()
for _, rects in pairs(trueColorRects) do for _, rects in pairs(trueColorRects) do
for i = #rects, 1, -1 do rects[i] = nil end for i = #rects, 1, -1 do rects[i] = nil end
end end
markOffsetX = 0
end
-- Game:draw declares the translate it is drawing a classic state under, so
-- that state's marks land where its pixels did. Cleared with the rects at
-- the top of every frame (Renderer:beginFrame). #637
function PaletteFX.setMarkOffset(dx)
markOffsetX = tonumber(dx) or 0
end end
function PaletteFX.markTrueColor(x, y, w, h) function PaletteFX.markTrueColor(x, y, w, h)
local rects = currentPass and trueColorRects[currentPass] local rects = currentPass and trueColorRects[currentPass]
if not rects or w <= 0 or h <= 0 then return end if not rects or w <= 0 or h <= 0 then return end
if currentPass == "ui" then x = x + markOffsetX end
rects[#rects + 1] = { colors = false, x = x, y = y, w = w, h = h } rects[#rects + 1] = { colors = false, x = x, y = y, w = w, h = h }
end end
+35 -13
View File
@@ -564,16 +564,24 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
return true return true
end end
-- LÖVE 11 truncates scissor arguments to framebuffer pixels; the half-pixel
-- bias keeps values divided back through a fractional DPI scale from landing
-- one short. LÖVE 12 passes fractional arguments through and rounds in the
-- graphics backend instead, where that bias shifts each origin by one pixel
-- and extends its far edge by two (#673).
local SCISSOR_PIXEL_BIAS = 0.5
if love and love.getVersion and select(1, love.getVersion()) >= 12 then
SCISSOR_PIXEL_BIAS = 0
end
-- Clamp a scissor rect to the viewport box, then round it outward to whole -- Clamp a scissor rect to the viewport box, then round it outward to whole
-- framebuffer pixels. love.graphics.setScissor truncates x, y, w and h to -- framebuffer pixels. On LÖVE 11, x, y, w and h are truncated independently,
-- pixels independently, so a rect with fractional unit edges (Android's -- so a rect with fractional unit edges (Android's non-integer DPI puts
-- non-integer DPI puts fitScale/dpi in Sx/Sy) loses up to a pixel per side -- fitScale/dpi in Sx/Sy) loses up to a pixel per side and two adjacent SGB
-- and two adjacent SGB zones stop sharing an edge: the letterbox clear shows -- zones stop sharing an edge: the letterbox clear shows through as a seam at
-- through as a horizontal seam at every zone boundary (#373). Rounding -- every zone boundary (#373). Rounding outward makes neighbours overlap by
-- outward makes neighbours overlap by at most one row instead -- the overlap -- at most one row instead; SCISSOR_PIXEL_BIAS preserves that result across the
-- redraws the same canvas pixels one palette later, and past the canvas edge -- LÖVE 11 and 12 conversion rules.
-- there is nothing to draw. The half pixel keeps LOVE's truncation on the
-- snapped edge rather than one short of it.
local function scissorClamped(x, y, w, h, ox, oy, vpw, vph, dpiX, dpiY) local function scissorClamped(x, y, w, h, ox, oy, vpw, vph, dpiX, dpiY)
local x2, y2 = math.min(x + w, ox + vpw), math.min(y + h, oy + vph) local x2, y2 = math.min(x + w, ox + vpw), math.min(y + h, oy + vph)
x, y = math.max(x, ox), math.max(y, oy) x, y = math.max(x, ox), math.max(y, oy)
@@ -581,9 +589,10 @@ local function scissorClamped(x, y, w, h, ox, oy, vpw, vph, dpiX, dpiY)
dpiX, dpiY = dpiX or 1, dpiY or 1 dpiX, dpiY = dpiX or 1, dpiY or 1
local px1, py1 = math.floor(x * dpiX), math.floor(y * dpiY) local px1, py1 = math.floor(x * dpiX), math.floor(y * dpiY)
local px2, py2 = math.ceil(x2 * dpiX), math.ceil(y2 * dpiY) local px2, py2 = math.ceil(x2 * dpiX), math.ceil(y2 * dpiY)
love.graphics.setScissor((px1 + 0.5) / dpiX, (py1 + 0.5) / dpiY, local b = SCISSOR_PIXEL_BIAS
(px2 - px1 + 0.5) / dpiX, love.graphics.setScissor((px1 + b) / dpiX, (py1 + b) / dpiY,
(py2 - py1 + 0.5) / dpiY) (px2 - px1 + b) / dpiX,
(py2 - py1 + b) / dpiY)
return true return true
end end
@@ -941,9 +950,22 @@ function Renderer:endFrame(zones, worldZones)
-- reads as the foreground instead of competing with a fully lit map. Goes -- reads as the foreground instead of competing with a fully lit map. Goes
-- here rather than in the letterbox clear because with the world pass -- here rather than in the letterbox clear because with the world pass
-- active there is no clear -- the world already covers the surface. -- active there is no clear -- the world already covers the surface.
--
-- The veil covers the voids ONLY, never the battle's own letterbox: "world"
-- changes what surrounds the battle and leaves the battle screen alone
-- (BattleState:bgMode). On hardware there is no "behind the battle" to dim
-- at all -- _InitBattleCommon calls ClearScreen (pokered home/copy2.asm),
-- which blanks the whole tilemap before the battle draws. A whole-surface
-- fill was invisible only because the classic battle paints an opaque paper
-- field over it a few lines below; a render pipeline that stages the fight
-- on the map and keys that field out got the veil straight onto its
-- sprites, HP bars and HUD, which is the 55% whole-window dim of #777
-- (and its duplicate #772).
if self.battleDim and self.battleDim > 0 then if self.battleDim and self.battleDim > 0 then
love.graphics.setColor(0, 0, 0, self.battleDim) love.graphics.setColor(0, 0, 0, self.battleDim)
love.graphics.rectangle("fill", 0, 0, ww, wh) for _, r in ipairs(subtractRect({ { 0, 0, ww, wh } }, uox, uoy, uvpw, uvph)) do
love.graphics.rectangle("fill", r[1], r[2], r[3], r[4])
end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+6 -2
View File
@@ -370,8 +370,12 @@ function TextBox:draw()
local ys = { self.line1Y, self.line2Y } local ys = { self.line1Y, self.line2Y }
for i, line in ipairs(self.shown) do for i, line in ipairs(self.shown) do
local y = (ys[i] or self.line2Y) + (i == 1 and off or 0) local y = (ys[i] or self.line2Y) + (i == 1 and off or 0)
for j, code in ipairs(line) do -- the pen advances per glyph, matching the pixel budget paginate
Font.drawCode(code, self.textX + (j - 1) * 8, y) -- measured with; every fixed-width page still lands on the 8px grid
local pen = self.textX
for _, code in ipairs(line) do
Font.drawCode(code, pen, y)
pen = pen + Font.advanceOf(code)
end end
end end
if (self.waiting or (self.done and not self.choice and not self.auto if (self.waiting or (self.done and not self.choice and not self.auto
+14 -4
View File
@@ -59,7 +59,13 @@ local function styleOf(game, id)
return record or Transition.STYLES[id] return record or Transition.STYLES[id]
end end
function Transition.new(game, onMidpoint, onDone) -- `warp` marks the map-change fade: PlayMapChangeSound's GBFadeOutToBlack
-- has no matching fade in (LoadGBPal restores the palettes in one write), so
-- warps land with framesIn 0. Script fades that bracket a HideObject
-- (ViridianGym.asm .afterBeat, RocketHideoutB4F BeatGiovanniScript) call
-- GBFadeOutToBlack -> GBFadeInFromBlack instead, so the default keeps the
-- symmetric 32-frame fade back in (home/fade.asm:21, b = 4).
function Transition.new(game, onMidpoint, onDone, warp)
local self = setmetatable({}, Transition) local self = setmetatable({}, Transition)
self.game = game self.game = game
self.onMidpoint = onMidpoint self.onMidpoint = onMidpoint
@@ -68,9 +74,13 @@ function Transition.new(game, onMidpoint, onDone)
self.phase = "out" self.phase = "out"
local style = styleOf(game, "warp_fade") local style = styleOf(game, "warp_fade")
self.frames = style.frames or FRAMES self.frames = style.frames or FRAMES
-- a style may still ask for a fade in (mods, and the record is data-driven); if warp then
-- the built-in warp is 0, matching hardware -- a style may still ask for a fade in (mods, and the record is
self.framesIn = style.framesIn or FRAMES_IN -- data-driven); the built-in warp is 0, matching hardware
self.framesIn = style.framesIn or FRAMES_IN
else
self.framesIn = Timing.FADE_IN_FROM_BLACK
end
return self return self
end end
+62
View File
@@ -0,0 +1,62 @@
-- Per-glyph fallback for the launcher's UI faces.
--
-- The launcher draws with LÖVE's default face, which covers Latin and little
-- else. That was invisible while the launcher was English-only, but its text
-- now goes through Strings (#767/#791) and a translation mod's catalog is
-- loaded before the first launcher frame (LauncherMods.translationStrings), so
-- the moment one is Japanese every kana lands as a tofu box.
--
-- Font:setFallbacks fills ONLY the codepoints the primary face is missing, so
-- Latin keeps the launcher's own look and nothing about an English install
-- changes; only the glyphs it genuinely cannot draw come from the bundled
-- Plain Pixel (assets/fonts/plainpixel/README.md: CC-BY 4.0, Douglas
-- Vautour), which covers kana and CJK. That is the same face the in-game TTF
-- text mode uses, so a translated launcher and a translated game agree.
--
-- Measuring and rendering must attach the same fallback or the launcher
-- measures a width it does not draw -- which is how buttons clip.
local UiFont = {}
local FALLBACK_PATH = "assets/fonts/plainpixel/PlainPixel-Regular.ttf"
-- Plain Pixel only rasterizes evenly at multiples of its 15px design em, so
-- snap rather than matching the primary size exactly: a fallback glyph a pixel
-- off its grid is far more obvious than one a pixel off its neighbours.
local DESIGN_EM = 15
local cache = {}
local unavailable = false
local function fallbackFor(size)
if unavailable then return nil end
local snapped = math.max(DESIGN_EM,
math.floor(size / DESIGN_EM + 0.5) * DESIGN_EM)
local hit = cache[snapped]
if hit ~= nil then return hit or nil end
local ok, font = pcall(love.graphics.newFont, FALLBACK_PATH, snapped)
if not ok or not font then
-- one failure means the file is absent (a trimmed build); stop retrying
unavailable = true
return nil
end
if font.setFilter then pcall(font.setFilter, font, "nearest", "nearest") end
cache[snapped] = font
return font
end
-- attach(font, size) -> font. Safe to call on every cache miss; a font whose
-- fallback is already set is left alone, and any failure is swallowed so a
-- missing fallback file can never take the launcher down with it.
function UiFont.attach(font, size)
if not (font and font.setFallbacks) then return font end
local fallback = fallbackFor(size or (font.getHeight and font:getHeight()) or DESIGN_EM)
if not fallback or rawequal(fallback, font) then return font end
pcall(font.setFallbacks, font, fallback)
return font
end
function UiFont.clear()
cache, unavailable = {}, false
end
return UiFont
+18 -16
View File
@@ -240,7 +240,7 @@ local function decodeName(bytes, off, len)
return table.concat(out) return table.concat(out)
end end
local function encodeName(buf, off, len, text) local function encodeName(buf, off, len, text, padTail)
local i, pos = 0, 1 local i, pos = 0, 1
while i < len - 1 and pos <= #text do while i < len - 1 and pos <= #text do
-- a bracketed control token (e.g. "<DOT>", from decodeName reading a -- a bracketed control token (e.g. "<DOT>", from decodeName reading a
@@ -259,16 +259,17 @@ local function encodeName(buf, off, len, text)
setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50) setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
i, pos = i + 1, pos + clen i, pos = i + 1, pos + clen
end end
-- Write exactly ONE $50 terminator and then $50-pad the rest of the -- Write exactly ONE $50 terminator. The tail past it is $50-padded only
-- field: every real save the naming screen ever wrote fills the tail -- on a templateless (engine-origin) export, where the zero-filled buffer
-- with $50, and a zero tail is what PKHeX renders as garbage glyphs -- is what PKHeX renders as garbage glyphs after the name ("JOHN{}", #206).
-- after the name ("JOHN{}", #206). Template bytes that are NOT zero -- With a template the tail keeps the original save's bytes verbatim --
-- stay untouched, so an unchanged name still round-trips -- real cartridge saves legitimately hold 0x00 (and other stale glyph)
-- byte-identical and stale template data survives. -- bytes after the terminator, and rewriting any of them broke the
-- import->export byte-identical round trip (the game and PKHeX both stop
-- reading at the terminator, so preserved tails are always safe).
if i < len then setByte(buf, off + i, 0x50) end if i < len then setByte(buf, off + i, 0x50) end
for j = i + 1, len - 1 do if padTail then
local cur = buf[off + j + 1] for j = i + 1, len - 1 do setByte(buf, off + j, 0x50) end
if cur == nil or cur:byte() == 0 then setByte(buf, off + j, 0x50) end
end end
end end
@@ -781,8 +782,9 @@ function GenSave.encode(save, data, template)
for i = 1, GenSave.SAVE_SIZE do buf[i] = zero end for i = 1, GenSave.SAVE_SIZE do buf[i] = zero end
end end
encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED") local padTail = not src
encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE") encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED", padTail)
encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE", padTail)
setU16be(buf, O.playerId, (save.player and save.player.id) or 0) setU16be(buf, O.playerId, (save.player and save.player.id) or 0)
-- wOptions (engine/menus/main_menu.asm InitOptions): bit 7 = battle -- wOptions (engine/menus/main_menu.asm InitOptions): bit 7 = battle
-- effects OFF, bit 6 = SET style, bits 2-0 = text speed -- the recomp's -- effects OFF, bit 6 = SET style, bits 2-0 = text speed -- the recomp's
@@ -875,11 +877,11 @@ function GenSave.encode(save, data, template)
encodeMon(buf, O.partyMons + i * PARTY_STRUCT_SIZE, mon, true, cw) encodeMon(buf, O.partyMons + i * PARTY_STRUCT_SIZE, mon, true, cw)
setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0) setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0)
encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH, encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH,
mon.ot or (save.player and save.player.name) or "RED") mon.ot or (save.player and save.player.name) or "RED", padTail)
-- no nickname stores the species' DISPLAY name, not its ROM constant id -- no nickname stores the species' DISPLAY name, not its ROM constant id
-- ("NIDORAN_M" would charmap the "_" to "?") (#257) -- ("NIDORAN_M" would charmap the "_" to "?") (#257)
encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH, encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH,
mon.nickname or speciesName(cw, mon.species)) mon.nickname or speciesName(cw, mon.species), padTail)
end end
-- $FF-terminate the species index list right after the last real mon. The -- $FF-terminate the species index list right after the last real mon. The
-- struct, OT-name and nickname bytes of the empty slots past partyN are left -- struct, OT-name and nickname bytes of the empty slots past partyN are left
@@ -900,9 +902,9 @@ function GenSave.encode(save, data, template)
encodeMon(buf, base + 22 + i * BOX_STRUCT_SIZE, mon, false, cw) encodeMon(buf, base + 22 + i * BOX_STRUCT_SIZE, mon, false, cw)
setByte(buf, base + 1 + i, cw.pokemonIndex[mon.species] or 0) setByte(buf, base + 1 + i, cw.pokemonIndex[mon.species] or 0)
encodeName(buf, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH, encodeName(buf, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH,
mon.ot or (save.player and save.player.name) or "RED") mon.ot or (save.player and save.player.name) or "RED", padTail)
encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH, encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH,
mon.nickname or speciesName(cw, mon.species)) -- #257, as above mon.nickname or speciesName(cw, mon.species), padTail) -- #257, as above
end end
-- $FF-terminate the species list after the last real mon; empty slots past -- $FF-terminate the species list after the last real mon; empty slots past
-- n keep their template bytes (byte-identical round-trip) or zero (fresh -- n keep their template bytes (byte-identical round-trip) or zero (fresh
+7 -2
View File
@@ -784,12 +784,17 @@ end
-- The Viridian old man's catch tutorial (scripts/ViridianCity.asm -- The Viridian old man's catch tutorial (scripts/ViridianCity.asm
-- BATTLE_TYPE_OLD_MAN): a demo wild battle where the old man throws -- BATTLE_TYPE_OLD_MAN): a demo wild battle where the old man throws
-- one POKé BALL; nothing is kept. -- one POKé BALL; nothing is kept.
function Commands.old_man_demo(ctx) -- `outcome` == "fail" is Yellow's initial training: ItemUseBall's
-- .oldManBattle branch reads EVENT_INITIAL_CATCH_TRAINING and stores anim
-- data $63, so the ball shakes three times and breaks open (pokeyellow
-- engine/items/item_effects.asm). Red, Blue and Yellow's repeat "Watch
-- closely!" demo all catch, and all of them omit the argument (#636).
function Commands.old_man_demo(ctx, outcome)
local BattleState = require("src.battle.BattleState") local BattleState = require("src.battle.BattleState")
local runner = ctx.runner local runner = ctx.runner
local om = ctx.game.data.field.oldManBattle or { species = "WEEDLE", level = 5 } local om = ctx.game.data.field.oldManBattle or { species = "WEEDLE", level = 5 }
local battle = BattleState.newWild(ctx.game, om.species, om.level) local battle = BattleState.newWild(ctx.game, om.species, om.level)
battle:makeOldManDemo() battle:makeOldManDemo(nil, outcome == "fail")
battle.onFinish = function() runner:resume() end battle.onFinish = function() runner:resume() end
-- InitWildBattle calls DoBattleTransitionAndInitBattleVariables -- InitWildBattle calls DoBattleTransitionAndInitBattleVariables
-- unconditionally (core.asm:6699) -- there is no BATTLE_TYPE_OLD_MAN -- unconditionally (core.asm:6699) -- there is no BATTLE_TYPE_OLD_MAN
+21 -6
View File
@@ -9,8 +9,9 @@
-- CRED_TEXT_MON text appears at once, hold 110, mon wipe -- CRED_TEXT_MON text appears at once, hold 110, mon wipe
-- CRED_TEXT_FADE fade in, hold 120, next screen replaces the text -- CRED_TEXT_FADE fade in, hold 120, next screen replaces the text
-- CRED_TEXT text appears at once, hold 140 -- CRED_TEXT text appears at once, hold 140
-- The mon wipe is DisplayCreditsMon: the middle band scrolls left 8px per -- The mon wipe is DisplayCreditsMon: three CreditsCopyTileMapToVRAM copies
-- frame for 27 frames (ScrollCreditsMonLeft x7 then x20) while the next -- (9 frames of Delay3, text still up), then the middle band scrolls left 8px
-- per frame for 27 frames (ScrollCreditsMonLeft x7 then x20) while the next
-- CreditsMons entry crosses right-to-left as a black silhouette -- CreditsMons entry crosses right-to-left as a black silhouette
-- (BGP %11111100), leaving the band blank; BGP is left at %11000000, which -- (BGP %11111100), leaving the band blank; BGP is left at %11000000, which
-- is why every post-wipe screen is a FADE variant. CRED_COPYRIGHT -- is why every post-wipe screen is a FADE variant. CRED_COPYRIGHT
@@ -52,6 +53,14 @@ local HOLD_FADE = 120
local HOLD_TEXT = 140 local HOLD_TEXT = 140
local WIPE_FRAMES = 27 -- ScrollCreditsMonLeft: 7 + 20 calls, 8px/frame local WIPE_FRAMES = 27 -- ScrollCreditsMonLeft: 7 + 20 calls, 8px/frame
-- DisplayCreditsMon runs three CreditsCopyTileMapToVRAM calls (vBGMap0+$c,
-- vBGMap0, vBGMap1) before the first scroll, and each one ends in `jp Delay3`
-- (home/palettes.asm), so the credits text sits still for 9 more frames on
-- every mon screen. Dropping them ran the 15 mon screens 135 frames short and
-- brought THE END up 2.2s early against a credits theme whose length is fixed
-- by the ROM program (Music_Credits is 5880 frames and does not loop), which
-- is what made the song look like it overran the roll (#703).
local MON_PREP_FRAMES = 9
-- LoadCopyrightTiles (engine/movie/title.asm CopyrightTextString): tile -- LoadCopyrightTiles (engine/movie/title.asm CopyrightTextString): tile
-- sequences into the extracted title/copyright.png strip (tiles $60-$72: -- sequences into the extracted title/copyright.png strip (tiles $60-$72:
@@ -204,12 +213,17 @@ function Credits:update(dt)
self.timer = self.screen.mon and HOLD_FADE_MON or HOLD_FADE self.timer = self.screen.mon and HOLD_FADE_MON or HOLD_FADE
elseif self.phase == "hold" then elseif self.phase == "hold" then
if self.screen.mon then if self.screen.mon then
self.phase = "wipe" -- the text stays up through DisplayCreditsMon's VRAM copies; the
self.timer = WIPE_FRAMES -- silhouette only starts moving once ScrollCreditsMonLeft does
self.monImg, self.monTint = self:monSprite(self.screen.mon) self.phase = "mon_prep"
self.timer = MON_PREP_FRAMES
else else
self:nextScreen() self:nextScreen()
end end
elseif self.phase == "mon_prep" then
self.phase = "wipe"
self.timer = WIPE_FRAMES
self.monImg, self.monTint = self:monSprite(self.screen.mon)
elseif self.phase == "wipe" then elseif self.phase == "wipe" then
self.monImg = nil self.monImg = nil
self:nextScreen() self:nextScreen()
@@ -313,7 +327,8 @@ function Credits:draw()
love.graphics.rectangle("fill", 0, 0, 160, 32) love.graphics.rectangle("fill", 0, 0, 160, 32)
love.graphics.rectangle("fill", 0, 112, 160, 32) love.graphics.rectangle("fill", 0, 112, 160, 32)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
if self.phase == "fade" or self.phase == "hold" then if self.phase == "fade" or self.phase == "hold"
or self.phase == "mon_prep" then
self:drawPage(self.screen, 0, self.shade) self:drawPage(self.screen, 0, self.shade)
elseif self.phase == "wipe" then elseif self.phase == "wipe" then
-- ScrollCreditsMonLeft: the middle band scrolls left 8px/frame while -- ScrollCreditsMonLeft: the middle band scrolls left 8px/frame while
+5 -2
View File
@@ -12,6 +12,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local romText = require("src.core.RomText")
local EvolutionState = {} local EvolutionState = {}
EvolutionState.__index = EvolutionState EvolutionState.__index = EvolutionState
@@ -89,9 +90,9 @@ function EvolutionState:update(dt)
self.done = true self.done = true
self.canceled = true self.canceled = true
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
-- mirrors data/generated/text.lua _StoppedEvolvingText
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
Strings("Huh? %s\nstopped evolving!", self.oldName), romText(game.data, "_StoppedEvolvingText",
"Huh? %s\nstopped evolving!", self.oldName),
function() function()
Music.restoreMap(game.data) Music.restoreMap(game.data)
game.stack:pop() -- the evolution screen itself game.stack:pop() -- the evolution screen itself
@@ -106,6 +107,8 @@ function EvolutionState:update(dt)
require("src.core.Sound").playCry(game.data, self.newSpecies) require("src.core.Sound").playCry(game.data, self.newSpecies)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local newName = game.data.pokemon[self.newSpecies].name local newName = game.data.pokemon[self.newSpecies].name
-- _EvolvedText extracts truncated (it stops at a dynamic marker the
-- decoder does not follow), so the engine's wording stands here
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
Strings("Congratulations!\nYour %s\nevolved into\n%s!", Strings("Congratulations!\nYour %s\nevolved into\n%s!",
self.oldName, newName), self.oldName, newName),
+32 -11
View File
@@ -73,8 +73,12 @@ function HallOfFame.new(game, onDone)
self.timer = 0 self.timer = 0
self.phase = "mons" self.phase = "mons"
self.sprites = {} -- species -> image or false self.sprites = {} -- species -> image or false
self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath( self.spriteTrueColor = {} -- species -> full-color art flag (#637)
game.data, "front", { kind = "hof" })) local playerPath, playerTrueColor =
require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "hof" })
self.playerPic = tryImage(playerPath)
self.playerTrueColor = self.playerPic and playerTrueColor or false
self.scrollX = PIC_X self.scrollX = PIC_X
self.showHofBanner = false self.showHofBanner = false
self.fade = 0 self.fade = 0
@@ -113,10 +117,11 @@ end
function HallOfFame:spriteFor(species) function HallOfFame:spriteFor(species)
local cached = self.sprites[species] local cached = self.sprites[species]
if cached == nil then if cached == nil then
local path = require("src.pokemon.Sprites").path( local path, trueColor = require("src.pokemon.Sprites").path(
self.game.data, species, "front", { kind = "hof" }) self.game.data, species, "front", { kind = "hof" })
cached = tryImage(path) or false cached = tryImage(path) or false
self.sprites[species] = cached self.sprites[species] = cached
self.spriteTrueColor[species] = cached and trueColor or false
end end
return cached or nil return cached or nil
end end
@@ -225,13 +230,18 @@ function HallOfFame:drawMonInfo(mon)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
local name = mon.nickname or (def and def.name) or mon.species local name = mon.nickname or (def and def.name) or mon.species
Font.draw(name, 1 * 8, 4 * 8) Font.draw(name, 1 * 8, 4 * 8)
-- HoFMonInfoText is placed at (2,6) with "next" separators, and PlaceNextChar
-- advances 2 * SCREEN_WIDTH per <NEXT> unless BIT_SINGLE_SPACED_LINES is set
-- (home/text.asm); nothing on the HoF path sets it, so the labels sit on rows
-- 6/8/10 lined up with their values, not 6/7/8 (#697).
Font.draw(Strings("LEVEL/"), 2 * 8, 6 * 8) Font.draw(Strings("LEVEL/"), 2 * 8, 6 * 8)
Font.draw(Strings("TYPE1/"), 2 * 8, 7 * 8) Font.draw(Strings("TYPE1/"), 2 * 8, 8 * 8)
local t1 = def and def.types and def.types[1] local t1 = def and def.types and def.types[1]
local t2 = def and def.types and def.types[2] local t2 = def and def.types and def.types[2]
local dual = t2 and t2 ~= t1 local dual = t2 and t2 ~= t1
if dual then if dual then
Font.draw(Strings("TYPE2/"), 2 * 8, 8 * 8) -- EraseType2Text blanks 6 tiles at hl+$13 from (3,9), so TYPE2/ is at (2,10)
Font.draw(Strings("TYPE2/"), 2 * 8, 10 * 8)
end end
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here) -- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
Font.draw(tostring(mon.level), 8 * 8, 7 * 8) Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
@@ -251,10 +261,20 @@ function HallOfFame:drawHofBanner()
Font.draw(Strings("HALL OF FAME"), 4 * 8, 15 * 8) Font.draw(Strings("HALL OF FAME"), 4 * 8, 15 * 8)
end end
function HallOfFame:drawPic(img) function HallOfFame:drawPic(img, trueColor)
if not img then return end if not img then return end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, self.scrollX or PIC_X, PIC_Y) local x = self.scrollX or PIC_X
love.graphics.draw(img, x, PIC_Y)
-- SET_PAL_POKEMON_WHOLE_SCREEN (HoFShowMonOrPlayer) colors the WHOLE
-- screen in the mon's palette, so sgbPalettes above hands the blit a
-- whole-canvas zone -- and a full-color pic has to sit that remap out.
-- Report the rect the pic covers for the unshaded pass, the way
-- SummaryMenu does for the status screen pic (#637; #430).
if trueColor then
require("src.render.PaletteFX").markTrueColor(x, PIC_Y,
img:getDimensions())
end
end end
-- HoFDisplayPlayerStats boxes + labels (player pic already on the right) -- HoFDisplayPlayerStats boxes + labels (player pic already on the right)
@@ -284,7 +304,8 @@ function HallOfFame:draw()
if self.phase == "mons" or self.phase == "fade" then if self.phase == "mons" or self.phase == "fade" then
local mon = self.game.save.party[self.index] local mon = self.game.save.party[self.index]
if mon then if mon then
self:drawPic(self:spriteFor(mon.species)) self:drawPic(self:spriteFor(mon.species),
self.spriteTrueColor[mon.species])
if self.scrollX >= PIC_X then if self.scrollX >= PIC_X then
self:drawMonInfo(mon) self:drawMonInfo(mon)
if self.showHofBanner then if self.showHofBanner then
@@ -297,13 +318,13 @@ function HallOfFame:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
end end
elseif self.phase == "player" then elseif self.phase == "player" then
self:drawPic(self.playerPic) self:drawPic(self.playerPic, self.playerTrueColor)
elseif self.phase == "player_stats" then elseif self.phase == "player_stats" then
self:drawPic(self.playerPic) self:drawPic(self.playerPic, self.playerTrueColor)
self:drawPlayerStats() self:drawPlayerStats()
elseif self.phase == "player_dex" or self.phase == "player_rating" then elseif self.phase == "player_dex" or self.phase == "player_rating" then
-- the TextBox chain draws the dex texts over the stat boxes -- the TextBox chain draws the dex texts over the stat boxes
self:drawPic(self.playerPic) self:drawPic(self.playerPic, self.playerTrueColor)
self:drawPlayerStats() self:drawPlayerStats()
end end

Some files were not shown because too many files have changed in this diff Show More