Compare commits

...

67 Commits

Author SHA1 Message Date
github-actions 1e6613e2de chore(ios): update app-repo.json [skip ci] 2026-08-20 13:16:04 -04:00
bryanthaboi debfaf28e6 Merge pull request #1607 from bryanthaboi/dev
sometimes adrian be reading these PR titles
2026-08-20 13:05:45 -04:00
bryanthaboi 83463a5a59 Merge pull request #1605 from AverageConsumer/codex/android-secondary-lifecycle
Fix secondary display lifecycle cleanup
2026-08-20 13:04:55 -04:00
bryanthaboi d66a72ac95 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-20 12:43:05 -04:00
bryanthaboi 1b659dab01 CLOSES #1582, CLOSES #1583 2026-08-20 12:43:04 -04:00
AverageConsumer 9e01fe2c2c Fix secondary display lifecycle cleanup 2026-08-20 18:23:20 +02:00
bryanthaboi 5fa5005786 Merge pull request #1559 from thibautbus/fix/route-more-messages-through-romtext
Route more battle/overworld/menu messages through their real ROM text
2026-08-20 11:54:40 -04:00
bryanthaboi 1ac5b867bb for that one guy who has a mouse but for some reason it doesnt have a scroll wheel 2026-08-20 11:54:08 -04:00
github-actions 69ef1bfc77 chore(ios): update app-repo.json [skip ci] 2026-08-20 11:00:28 -04:00
bryanthaboi 9ed7e05dc1 Merge pull request #1601 from bryanthaboi/dev
bug fixes and uncles neighbor
2026-08-20 10:48:57 -04:00
bryanthaboi 25166ff3a1 CLOSES #1603 2026-08-20 10:46:27 -04:00
bryanthaboi c777e85641 Merge pull request #1598 from thibautbus/fix/text-extractor-underscore-requirement
Route a handful of pokered dialogue labels through game.data.text
2026-08-20 09:51:38 -04:00
bryanthaboi 06299328f5 CLOSES #1600 2026-08-20 09:49:57 -04:00
bryanthaboi 5b19259928 Merge pull request #1581 from 1Jamie/feat/pikachu-surf-and-gold-gamecorner
feat: surfing minigame overhaul + authentic Game Corner rendering
2026-08-20 09:46:43 -04:00
bryanthaboi c2b6a7b937 pipeline fixes (Hopefully) 2026-08-20 09:45:47 -04:00
bryanthaboi 7c9c2380d2 Update LauncherView.lua 2026-08-20 09:19:59 -04:00
bryanthaboi 2468d5042d clean up and larger importer 2026-08-20 08:53:31 -04:00
bryanthaboi 51c4766ead Merge branch 'my-uncles-neighbor' into dev
# Conflicts:
#	src/import/RomImporter.lua
2026-08-20 08:28:19 -04:00
bryanthaboi ec9dc29646 Pokemon Silver as a full launcher version, plus launcher mods-list and title-tempo fixes
Silver: derived import manifest (tools/make_silver_manifest.py re-resolves
the Gold manifest's symbols from pokesilver.sym), silver GameVersion row,
generation-keyed extractor routing, required-files override, edition save
stamping (a Silver playthrough no longer writes into the Gold save),
checkver-driven edition data, SILVER/KAMON/OSCAR/MAX presets, GOLD rival
default, edition credits banner, Lugia title screen (OAM layouts, bob,
trail, palettes as title.lua data keys with Gold defaults so old caches
need no re-import), packaging for every build target, docs, and tests.

Launcher: the installed-mods list is one continuous scroll (rows culled to
the viewport) instead of a pager with an inner scroll viewport; the pad
cursor's edge-scroll no longer runs it to the bottom. The game dropdown
shows just the initial and caret. Find-tab behavior unchanged.

Title tempo: a sprite-anim frame shows duration+1 ticks
(engine/sprite_anims/core.asm GetSpriteAnimFrame), which locks both
editions' 64-tick wing beat to the 64-tick sine bob; the title screens no
longer run fast and out of phase.
2026-08-20 08:27:25 -04:00
thibautbus 934a4c55ca Route a handful of pokered dialogue labels through game.data.text
Several hand-ported scripts carry pokered dialogue as inline English
literals instead of reading game.data.text, because the real ROM
label was never reachable from data/generated/text.lua:
ViridianCityYoungster2OkThenText/CaterpieAndWeedleDescriptionText,
TMNotebookText, the SS Anne kitchen cook's three dish lines, and the
Viridian fisher's pre-gift line (data/scripts/story5.lua's gift()
already read t[label] here, just had a stale comment and a missing
fallback).

Traced the actual cause carefully -- there are two independent,
differently-behaved label scanners in this codebase:

- tools/extract/text.py's parse_text_file() requires a label to
  start with "_" to be collected. This is a real bug (confirmed
  against a real pret/pokered checkout), but this function has no
  callers anywhere in the tree and no __main__ entry point -- it
  looks like dead code left over from an earlier version of the
  pipeline.
- The function that actually produces the shipped label list is
  text_metadata() in tools/make_rom_manifest.py, which feeds
  manifest["text"]["labels"], which build_rom_data.py's
  extract_text() iterates to decode each label straight from the
  ROM. text_metadata() already uses the permissive regex (no "_"
  requirement) since commit 0f581e2f.

So the actual blocker is that the committed tools/rom_manifest.json
was stale relative to text_metadata()'s current code, not a source
bug. Verified by rebuilding pret/pokered from source with RGBDS
(reproducible -- the resulting pokered.gbc/pokeblue.gbc hash to the
same canonical SHA-1s gen1recomp already pins, so no cartridge dump
was involved anywhere here) and running the real, unmodified
make_rom_manifest.py against it: 2595 labels against the committed
manifest's 2585, a clean superset containing everything these
scripts need. SilphCo2FSilphWorkerFPleaseTakeThisText is the one
exception already in the manifest -- confirmed by commit 0f581e2f
("so many bugs i cannot even breathe") that it was hand-patched in
exactly this same targeted way, for issue #393.

Fix:
- tools/extract/text.py: relaxed parse_text_file()'s regex to match
  text_metadata()'s, for consistency (no effect on what ships, since
  nothing calls this function, but no reason to leave a legacy copy
  of the same scanner out of sync).
- Four scripts read the real label first (t[label] or fallback, the
  established pattern): celadon_eevee.lua, ss_anne_kitchen.lua,
  viridian_city.lua, story5.lua (comment/fallback only, lookup was
  already correct).
- tools/rom_manifest.json and tools/rom_manifest_blue.json:
  regenerated for real -- both files are the direct, unedited output
  of running make_rom_manifest.py/make_blue_manifest.py against a
  real pret/pokered checkout at POKERED_REVISION, not hand-assembled
  or reverse-engineered to match. Only safe because of the two fixes
  below, which exist specifically so a real run doesn't regress
  anything the previously-committed files had. Diffing a real
  make_rom_manifest.py run against that previous file (1143 lines
  out of 45253) found exactly what a naive "just regenerate against
  whatever pokered HEAD is handy" would have silently broken:
  - pret/pokered commit 079d1cc92fc3b0ec82bc1418c2b4045bfca84620
    (PR #596, 2026-08-06) renamed
    _SilphCo10FGiovanniILostAgainText/_SilphCo10FPorygonText to
    _SilphCo11F... (they live in text/SilphCo11F.asm, Giovanni's
    floor). data/scripts/victories.lua:183 still hardcodes the old
    name, and extracting under pokered's new name would silently
    blank Giovanni's "I lost again!?" rematch line. Fix is the pin
    below: POKERED_REVISION is pinned to the last commit before this
    rename, so today's generator output matches victories.lua
    natively, no engine code touched, no generator-side workaround
    either. Advancing the pin past this commit is a real, welcome
    future upgrade -- it just needs victories.lua's labels (and
    anything else's) fixed up in the same change.
  - trainerPartyOverrides.OPP_CHIEF (Giovanni's Celadon gym team)
    wasn't produced by any code under tools/ at all. Traced why:
    pret/pokered's data/trainers/parties.asm has "ChiefData: ; none"
    -- the Celadon Chief's battle is unused/cut content in the
    original game, and RomExtractor.lua's own comment confirms
    gen1recomp reimplements it as a real fight using a hand-authored
    party for exactly that reason -- no pokered commit, old or new,
    will ever produce this data. Added a TRAINER_PARTY_OVERRIDES
    constant to make_rom_manifest.py so this survives every future
    regeneration automatically; verified it reproduces the committed
    value byte-for-byte and flows through to Blue/Yellow for free
    via their existing derive-from-Red path.
  - trainerHeaders.MtMoonB2F's Super Nerd slot is pre-existing
    fabricated data, not pokered drift: his event name,
    EVENT_BEAT_MT_MOON_3_SUPER_NERD, has never existed in pokered at
    any point in its history (the real name,
    EVENT_BEAT_MT_MOON_EXIT_SUPER_NERD, has been stable since 2015),
    nor in gen1recomp's own event_flags.lua; trainerDefeated()
    checks defeatedTrainers[npc.id] first, the same pattern already
    used for the Fighting Dojo's Karate Master, so this entry is
    very likely already inert. field.seafoam also differs from a
    fresh regeneration (two showObject boulder-toggle IDs in the B3F
    puzzle, a live gameplay system nobody has verified either value
    against), and field.tradeArt is new content a fresh extraction
    produces that was never shipped. None of those three are this
    PR's problem to fix, but a real generator run has to do
    something with them regardless -- so make_rom_manifest.py gets a
    new apply_known_nonreproducible_overrides(), called right after
    text_metadata()/field_metadata(), that pins MtMoonB2F and
    seafoam back to what was already shipped and drops tradeArt,
    each with a comment explaining why and what the real fix looks
    like (MtMoonB2F needs a Data:seedMtMoonB2FSuperNerd()-style
    engine seed, not manifest data). Verified this override function
    was complete and correct -- diffed a real run against the
    previously-committed file first, empty -- before trusting it to
    write tools/rom_manifest.json/_blue.json directly; both are now
    literally that generator's output, not hand-assembled.
    tools/rom_manifest_yellow.json isn't touched by this PR at all:
    it already had all ten labels, and make_yellow_manifest.py has
    no matching override yet for its own field.oldManBattle outlier,
    so regenerating it for real isn't safe the same way yet.
- tools/make_rom_manifest.py: added a POKERED_REVISION pin --
  cf621a76d4941c93c078eb38e0880fe8db48ef40, the last pret/pokered
  commit before the Silph Co rename above, chosen deliberately
  rather than current HEAD -- and a check_pokered_revision() guard
  main() calls before generating: fails loudly if --pokered isn't
  at that commit instead of silently absorbing whatever upstream
  renames or restructures since, with an explicit
  --allow-revision-mismatch escape hatch for a deliberate pin bump.
  That's the intended way this pin moves forward: diff a fresh run
  against the committed manifest, fix up whatever engine code
  depends on by exact name, and bump POKERED_REVISION in the same
  change -- a conscious, reviewable decision instead of a silent
  contributor default. Wired the same guard into
  make_blue_manifest.py and make_yellow_manifest.py for their own
  --pokered/--pokeyellow checkouts; make_yellow_manifest.py gets its
  own POKEYELLOW_REVISION (e6ba56989b0f2694f393e6924820be11dcc1fbb8,
  verified here). The pin and its guard live entirely in the
  generator source, not also embedded as a field in the shipped
  manifests -- that would be redundant with the .py constant next to
  it in the same commit, for no protection the guard doesn't already
  give.
- tools/make_rom_manifest.py, make_blue_manifest.py,
  make_yellow_manifest.py: switched json.dump(..., ensure_ascii=
  False, ...) to ensure_ascii=True to match how the committed
  manifests were actually encoded (escaped \uXXXX rather than
  literal UTF-8). Purely cosmetic -- json.load parses both
  identically -- but needed so that a fresh make_rom_manifest.py run
  at POKERED_REVISION now produces tools/rom_manifest.json
  byte-for-byte (plain diff empty), not just content-equal. One
  small, pre-existing cosmetic mismatch remains and wasn't chased: a
  single nested dict, field.cardKeyDoors.doors, has its
  SILPH_CO_10F/11F keys in natural floor order in the committed file
  instead of the sort_keys=True lexicographic order everything else
  in the file uses -- same content either way.
- tests/rom_manifest_generator_test.py: new ROM-free unit tests (same
  style as the existing tests/build_rom_data_cli_test.py, wired into
  scripts/test.sh as a T0 tier) for check_pokered_revision() and
  apply_known_nonreproducible_overrides() -- matching/mismatched/
  bypassed/unresolvable-checkout revision cases, and that the
  MtMoonB2F/seafoam/tradeArt overrides land correctly (including
  alongside a populated map entry, and without erroring when
  tradeArt is already absent). Doesn't replace the manual real-ROM
  verification above, which needs an actual pokered/RGBDS toolchain
  -- but a future typo or logic slip in either function now fails
  immediately instead of only surfacing next time someone happens to
  redo that manual check.
- Left data/scripts/flavor/silph_co_9f.lua's nurse dialogue (labels
  also added to both manifests here) untouched code-wise: static
  command table, not a function, needs its
  face_player/heal_party/fade state machine restructured to use
  t[label] safely, and show_text's un-resolved-label fallback prints
  the label name literally rather than English -- not safe without
  interactive testing.

Checked Yellow's equivalent case (Melanie's House) since it looked
like the same shape: it isn't actually broken.
tools/make_yellow_manifest.py's YELLOW_EXTRA_TEXT_LABELS already
force-includes those eight labels, and a real built
dialogue_yellow.lua already has correct French translations for
them. tools/rom_manifest_yellow.json also already carries all ten
labels this PR adds to Red/Blue. No label changes needed there.

Tested: patched parse_text_file() against a real pret/pokered
checkout (+11 labels, 0 removed, all clearly dialogue-shaped);
rebuilt pokered.gbc/pokeblue.gbc with RGBDS at current pret/pokered
HEAD (confirmed hashes to gen1recomp's own canonical SHA-1s) and
diffed a fresh make_rom_manifest.py run there against the committed
manifest to map out what a full regen at HEAD would need; re-checked
out the same pokered checkout at cf621a76 (POKERED_REVISION),
rebuilt both ROMs again (same canonical hashes); with
apply_known_nonreproducible_overrides() and the ensure_ascii fix in
place, ran both generators to a scratch path first and diffed
against the then-committed manifests -- empty -- before running them
again writing tools/rom_manifest.json/_blue.json directly, so both
files are now the generator's literal, unedited output (git diff on
that final write: 18 lines moved in rom_manifest.json, exactly the
pre-existing SILPH_CO_10F/11F ordering quirk; zero lines changed in
rom_manifest_blue.json); rebuilt pokeyellow.gbc the same way, confirmed its
canonical hash, and confirmed make_yellow_manifest.py's symbols/
text/trainerHeaders/trainerPartyOverrides also come out byte-for-
byte identical to the committed tools/rom_manifest_yellow.json; ran
the real build_rom_data.py --only text against all three rebuilt
ROMs and confirmed every added label decodes from real ROM bytes
matching the English fallback literals exactly; verified
check_pokered_revision() actually raises on a deliberate mismatch
before relying on it to gate the runs above; python3
tests/rom_manifest_generator_test.py: 9/9 pass; luajit
tests/run_engine.lua: 250/250 suites pass.
2026-08-20 09:42:20 +02:00
thibautbus 72592665d7 Cover the romText fixes with targeted regression tests
Every existing test around these callsites only ever runs with an
empty/fixture Data.text, so none of them could tell a properly-wired
romText/t._X call apart from a literal that never looked at the
catalog at all -- every assertion passed either way, the same coverage
gap the museum ticket clerk and status abbreviation fixes hit earlier.

Seven new tests fake the real label for each fix and assert the pushed
or queued message uses the translated value, plus a vanilla case per
fix confirming the no-catalog fallback still matches the original
English literal exactly:
- box_release_confirmation_romtext.lua (BoxMenu _OnceReleasedText, via
  a TextBox.new spy so real pagination/choice behavior stays intact)
- slot_machine_lined_up_romtext.lua (SlotMachine _LinedUpText, symbol
  concatenated in front of the translated suffix)
- battle_fainted_message_romtext.lua (BattleState onFaint, both
  _PlayerMonFaintedText and _EnemyMonFaintedText, confirming the raw
  name reaches each without a duplicated "Enemy")
- battle_catch_messages_romtext.lua (BattleState storeCaughtMon,
  _ItemUseBallText06 plus both _ItemUseBallText07/08 branches on
  EVENT_MET_BILL)
- battle_ball_dodge_romtext.lua (BattleState throwBall,
  _ItemUseBallText00's \f-merge collapsing to exactly one queued
  message)
- overworld_field_faint_heal_romtext.lua (OverworldController
  applyFieldPoison's _PokemonFaintedText, and
  useSoftboiledFieldMove's _ItemUseNoEffectText/_PotionText including
  the recovered-amount slot the old literal never showed)
- overworld_hidden_item_romtext.lua (OverworldController
  tryHiddenObject's _FoundHiddenItemText, both the {PLAYER} token and
  the item name landing in the right slots)

Confirmed several of these fail against the pre-fix code and pass
against the current code, not just reasoned about it. Not every one of
the 15 fixed callsites has its own dedicated test -- the ShopMenu,
LinkBattle, trainer-withdraw/sent-out and the normal (non-hidden)
found-item sites share the same romText mechanism already proven
correct by the seven tests above, and building the heavier fixtures
each would need (a full mart flow, a link session, a trainer AI
switch, an object_event NPC) wasn't judged worth it for what would be
the same assertion shape again.
2026-08-20 09:18:56 +02:00
thibautbus 8f88d01cf2 Route the link battle's opening line through its real ROM text
LinkBattle.lua's "%s wants\nto fight!" intro was a plain Lua literal,
even though the comment right above it already named the real label
(_TrainerWantsToFightText). The battle object built at this point is
already a BattleState, so this reuses its self:romText convenience
method rather than requiring the module-level helper separately.
2026-08-20 09:18:56 +02:00
thibautbus 2279617b29 Route the mart's price confirmation prompts through their real ROM text
ShopMenu.lua's buy/sell price confirmations were plain Lua literals,
even though the comment on each line already named the real label
(_PokemartTellBuyPriceText, _PokemartTellSellPriceText). This file's
own txt(game, key, fallback) helper doesn't support substitution
arguments, so it can't be reused as-is; added the module-level romText
helper instead, same as every other file in this batch.
2026-08-20 09:18:56 +02:00
thibautbus 17fbf6cec4 Route the slot machine's lined-up message through the real ROM text
SlotMachine.lua built "%s lined up!\nScored %d coins!" as a plain Lua
literal, substituting the symbol id (sym) as if it were part of the
translatable sentence. The real extracted _LinedUpText label
(" lined up!\nScored {RAM:wStringBuffer} coins!") shows the original
never had a slot for the symbol at all -- it was drawn separately and
only this fixed suffix was ROM text. Concatenate sym in front of
romText's real, already-translated label instead of interpolating it
into an engine literal.
2026-08-20 09:18:56 +02:00
thibautbus 34c4481f96 Route the box-release confirmation through the real ROM text
BoxMenu.lua's "Once released,\n%s is\ngone forever. OK?" prompt and its
"%s was\nreleased outside.\fBye %s!" follow-up (shown after confirming)
were both plain Lua literals, bypassing the extracted _OnceReleasedText
and _MonWasReleasedText labels entirely even though both exist and are
already translated in a real corpus build. Wrapped both in the same
t._X or Strings(...) pattern already used four lines above for the
Pikachu-unhappy prompt in this same function, with the same trailing
gsub to fill the {RAM:wStringBuffer} token(s) either branch leaves in
place -- _MonWasReleasedText's real text repeats the token twice (the
name appears at both ends of the sentence), and gsub's default
replace-all handles that the same way a single occurrence does.

Independent code review flagged a separate issue on this line and the
pre-existing Pikachu one right above it: both pass the nickname as a
bare gsub replacement string, which Lua %-escapes ("%" followed by a
digit 1-9 crashes with "invalid capture index", confirmed directly).
Checked how reachable that actually is: the naming screen's charset
can't produce a literal "%", and neither can a real cartridge import
(the Gen 1/2 character-decode tables never map any ROM byte to "%"
either) -- the only way in is editing a save's plain-Lua-source
nickname field directly. Not fixed here, to stay consistent with the
separate branch (fix/gsub-percent-escape-crash) already carrying this
exact fix across every callsite that shares it, including this one --
splitting the same bug's fix across two branches by which one happened
to touch the line first isn't a real reason to fix it in one place and
not the other.
2026-08-20 09:18:56 +02:00
thibautbus bff40a5d90 Route OverworldController's field messages through their real ROM text
Four message families in OverworldController.lua were plain Lua
literals instead of their already-extracted, already-translated ROM
text labels:

- applyFieldPoison()'s faint message: the third of three collapsed
  "%s\nfainted!" ROM strings (the other two, in BattleState.lua, are
  fixed in the previous commit) -- routed through _PokemonFaintedText.
- useSoftboiledFieldMove()'s two outcome messages: _ItemUseNoEffectText
  and _PotionText, the exact labels ItemEffects.lua's real potion
  message already uses, including _PotionText's second slot (the
  actual amount healed) the old literal never showed at all.
- tryHiddenObject()'s two hidden-item finds: _FoundHiddenItemText.
- The normal item-ball pickup path's two finds (one Yellow-only
  bag-full variant): a comment already named _FoundItemText
  ("FoundItemText: text_far, sound_get_item_1, text_end").

Both found-item labels lead with a {PLAYER} token that romText
auto-fills from a 2-arg call (player name, item name) in the same
order the literal already used. The fallback text for both is plain
"%s found\n%s!", matching the original literal's shape exactly --
an earlier version of this fix used a "{PLAYER} found\n%s!" fallback
that relied on TextBox.new's later TextBox.substitute pass to resolve
{PLAYER}, which works but needlessly made the fallback path depend on
a downstream call instead of being self-contained.
2026-08-20 09:18:56 +02:00
thibautbus 354a8b476d Route BattleState's trainer/catch/faint messages through their real ROM text
Nine message families in BattleState.lua were plain Lua literals,
bypassing already-extracted, already-translated ROM text labels --
some with a comment right next to them already naming the real label:

- storeCaughtMon(): the new-Pokedex-data line (_ItemUseBallText06) and
  the box-transfer line, which used a hardcoded "BILL's PC"/"someone's
  PC" as if it were a substituted argument in one shared template --
  _ItemUseBallText07/08 are two full, independently translated ROM
  strings, not a template with a substituted PC name.
- throwBall(): the dodged-ball and can't-be-caught lines were two
  separate Strings() calls; _ItemUseBallText00 is one \f-paged ROM
  label covering both. Unlike TextBox.new() (which splits \f itself),
  sayNext() goes through the battle queue's own startMessage(), which
  only splits on \n/\v -- confirmed live in a real build (the second
  sentence overflowed off the box instead of starting a fresh page).
  Resolves the label once, splits it the same way TextBox.lua does,
  and queues one sayNext per page.
- onFaint(): displayName(battler) runs the enemy name through a
  separate Strings("Enemy %s", ...) call, then the shared "%s\nfainted!"
  literal added the rest -- but _EnemyMonFaintedText already carries
  its own "Enemy" wording, so this passes the raw battler.name and
  picks _PlayerMonFaintedText/_EnemyMonFaintedText by battler.isPlayer.
- enter()'s pre-battle black-out message (_PlayerBlackedOutText2, a
  \f-paged pair like _ItemUseBallText00 above).
- The AI switch-in withdraw/send-out line and the enemy trainer's
  first send-out (3 callsites, one shared by the link-battle intro
  path): _AIBattleWithdrawText and _TrainerSentOutText.

Also investigated folding _TrainerAboutToUseText's SHIFT-switch offer
(say() then sayChoice(), both plain Strings(), which the label also
\f-pages) into one romText + sayChoice call the same way. That does
NOT work: tests/engine/trainer_shift_prompt_bug565.lua caught that the
battle queue's own text renderer pages a sayChoice string differently
from TextBox.lua's \f handling that the say()+say() merges above rely
on. Left as two calls, unchanged, with a comment explaining why.
2026-08-20 09:18:56 +02:00
1jamie f0d3c014a7 feat: surfing minigame overhaul + authentic Game Corner rendering
Surfing minigame:
- Add title screen (ROUTINE_TITLE) with Pikachu intro, logo banner, instructions
- Add GLSL HBlank wave distortion shader, OAM water spray/splash sprites
- Add multi-path asset loader, isMinigame/isFixedSpeed flags, crash recovery fixes
- Extract SurfingPikachu graphics + composite title_bg.png from ROM

Game Corner (built visual layer from ROM assets; logic existed, rendering was placeholder):
- SlotMachine: GBC tilemap background, authentic reel symbol sprites, Golem/Chansey
  near-miss animations, lit/unlit lights, payout panel
- CardFlip: GBC tilemap board, hardware-accurate card flip sequence, OAM cursor frame
- RomExtractorGen2: extract slots + card_flip sprite sheets and tilemaps

Tests: SurfingMinigame units 8-13; 500-spin SlotMachine and 500-hand CardFlip stress tests
2026-08-19 18:14:42 -05:00
bryanthaboi 0dd889b35b Merge pull request #1505 from mleo2003/fix/launcher-split-layout
rg34xxsp launcher: also resolve GAMEDIR on split-tree firmwares (muOS)
2026-08-19 16:57:41 -04:00
bryanthaboi 032f894f7f Merge pull request #1495 from mleo2003/fix/fresh-skeleton-playthrough-id
SaveData: a fresh skeleton must not overwrite an existing playthrough binding
2026-08-19 16:57:30 -04:00
bryanthaboi 9922e235c6 Merge pull request #1576 from dburton95/dev
Fixes the greyscale cutoff for boulder.png
2026-08-19 16:56:17 -04:00
Dorian Burton 667267d9bb Fixes the greyscale cutoff for boulder.png
A fixed cutoff (e.g. "<=200 is ink") only makes sense for sprites with a light background to split against; a mostly-opaque 16x16 icon has almost no pixel above that cutoff, so every such icon collapsed onto the same "all ink" hash and was flagged as a near-duplicate of anything else that also collapsed -- which was most of them, boulder.png included. Thresholding against the image's own mean keeps the split meaningful (and roughly balanced) no matter how light or dark the source is.
2026-08-19 14:56:08 -04:00
github-actions ecdea61cfd chore(ios): update app-repo.json [skip ci] 2026-08-19 14:14:00 -04:00
bryanthaboi 872d6b4516 Merge pull request #1572 from bryanthaboi/dev
bugs and bug reporting
2026-08-19 14:03:47 -04:00
bryanthaboi def270f7c7 Merge pull request #1560 from castdrian/device-report-bug-tab
feat(launcher): add bug tab and native device reporting
2026-08-19 13:54:38 -04:00
bryanthaboi 93e336b7cb Update video link and thumbnail in README 2026-08-19 13:36:35 -04:00
bryanthaboi 4c8c1cf36b CLOSES #998, CLOSES #1472, CLOSES #1526, CLOSES #1529, CLOSES #1530, CLOSES #1532, CLOSES #1534, CLOSES #1547, CLOSES #1549, CLOSES #1550, CLOSES #1551 2026-08-19 11:19:54 -04:00
Adrian Castro 7d9e99ea18 chore(repo): add code owners 2026-08-19 15:59:42 +02:00
Adrian Castro b27e5ab017 fix(launcher): simplify bug report card title 2026-08-19 15:51:23 +02:00
Adrian Castro fd9f3da91a fix(launcher): use rounded bug report icon 2026-08-19 15:51:23 +02:00
Adrian Castro a7c19be88f fix(launcher): use standard bug report icon 2026-08-19 15:51:22 +02:00
Adrian Castro 9ab80adaca feat(launcher): add bug tab and native device reporting 2026-08-19 15:50:50 +02:00
github-actions 518d61e039 chore(ios): update app-repo.json [skip ci] 2026-08-19 06:53:26 -04:00
bryanthaboi 4349a1142f Merge pull request #1554 from bryanthaboi/dev
adrian if ur reading this im ....
2026-08-19 06:44:22 -04:00
bryanthaboi 9713977755 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-19 06:41:12 -04:00
bryanthaboi 63448ca640 shamona 2026-08-19 06:41:10 -04:00
github-actions b36d38815f chore(ios): update app-repo.json [skip ci] 2026-08-19 06:21:12 -04:00
bryanthaboi e24f812475 Merge pull request #1553 from bryanthaboi/dev
fix stuff baby
2026-08-19 06:10:44 -04:00
bryanthaboi fddf619ed2 Merge pull request #1527 from thibautbus/fix/status-abbreviation-translation
Translate the status abbreviations shown outside battle
2026-08-19 06:01:14 -04:00
bryanthaboi bf83509ef2 Merge pull request #1542 from AverageConsumer/codex/gen2-ball-cache-invalidation
fix(gen2): refresh caches missing trainer HUD balls
2026-08-19 06:00:48 -04:00
bryanthaboi 6c05b854c4 Merge pull request #1543 from AverageConsumer/codex/gen2-party-grid-navigation
fix(gen2): honor battle party grid navigation
2026-08-19 06:00:21 -04:00
bryanthaboi 2baafab027 Merge pull request #1544 from castdrian/safe-mode-report-issue
feat(launcher): add safe mode and issue reporting
2026-08-19 06:00:00 -04:00
bryanthaboi 813f9d959b Merge pull request #1546 from 1Jamie/feat/android-exit-game-to-launcher
feat(android): add adaptive icons, dynamic shortcuts, in-process hot-swap, and exit-to-launcher
2026-08-19 05:59:34 -04:00
bryanthaboi fba87f028c Merge pull request #1552 from thibautbus/fix/pikachu-unhappy-gsub-crash
Fix a crash releasing your own caught Pikachu in Yellow
2026-08-19 05:59:06 -04:00
bryanthaboi cb4647daf0 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-19 05:57:46 -04:00
bryanthaboi 93374fbbbb skin studio updates, save sync CLOSES #1533 2026-08-19 05:57:44 -04:00
thibautbus abe176b26c Fix a crash releasing your own caught Pikachu in Yellow
BoxMenu.lua's release() pushes both its "Once released...OK?" prompt
and its Yellow-only "Pikachu looks unhappy" message through
TextBox.new(game, (t._X or Strings(...)):gsub(...)) -- gsub returns
two values (the text and a substitution count), and since the gsub
call is the last argument in the TextBox.new(...) call with nothing
after it, Lua expands both into the call: the count lands in
TextBox.new's third parameter, onDone. TextBox.lua later calls
onDone() once the box is dismissed; a number is not callable, so
every release of your own caught Pikachu in Yellow crashed --
regardless of its nickname (unlike the separate %-escape gsub bug,
this one needs no special save content, ordinary play reaches it
every time).

Fixed by wrapping the gsub call in an extra pair of parens, which
truncates it to its first return value only -- the same fix already
applied to the neighboring _OnceReleasedText/_MonWasReleasedText
lines on the (separate, unmerged) fix/route-more-messages-through-romtext
branch, where this exact bug shape was first noticed while adding a
third callsite with the same pattern.

tests/engine/pikachu_unhappy_release_crash.lua: registers a fake
Data.pokemon.PIKACHU cloned from the fixture species (ROM-free) so
the species == "PIKACHU" check can be exercised, drives the real
interactive release flow in Yellow on a mon owned by the player, and
confirms the crash. Verified failing pre-fix (exact same
"attempt to call field 'onDone' (a number value)" error) and passing
post-fix.
2026-08-19 11:34:20 +02:00
thibautbus 085180992d Cover the status abbreviation translation fix with a targeted test
Neither tests/parity_status_true_color.lua (SGB recolor rectangle) nor
tests/parity_party_icon_mirror.lua (icon mirroring) check the drawn
status text, so this fix had no coverage. Drive SummaryMenu:draw() and
PartyMenu:draw() with a mod-patched statuses registry and check the
patched label reaches Font.draw instead of the raw status id, plus a
vanilla case confirming the no-mod fallback is unchanged.

Also cover the hudLabel-shadowing bug directly through the real
Registry:patch (not a hand-built table): a label-only patch, the exact
shape a translation mod would send, must reach Status.hudLabelFor for
all five vanilla ids. Confirmed both regressions: reverting
src/ui/*.lua and src/battle/*.lua to dev's pre-fix content fails 2 of
the draw-site checks; reverting only the vanilla hudLabel removal in
Status.lua fails the 3 checks whose French label differs from English
(FRZ/BRN/SLP).
2026-08-19 08:08:20 +02:00
thibautbus 9984958193 Translate the status abbreviations shown outside battle
src/ui/SummaryMenu.lua:148 and src/ui/PartyMenu.lua:824 drew mon.status
(PSN/PAR/BRN/FRZ/SLP) as a bare literal, bypassing translation. Unlike
plain text, a mod translates status labels through the statuses content
registry (mod.content.statuses:patch(id, { label = value }), the same
registry src/battle/BattleState.lua:statusLabel already reads in battle.
Route both screens through the same lookup, extracted as
Status.hudLabelFor(statuses, id) and shared with BattleState:statusLabel
so the hudLabel-or-label fallback rule lives in one place, with the raw
status id kept as the fallback when no record overrides it.

Found along the way: Status.RECORDS' five vanilla entries duplicated
hudLabel = label ("FRZ", hudLabel = "FRZ", ...) for no functional
reason. Since hudLabelFor reads hudLabel before label, and
Registry:patch only overrides fields a mod actually passes, a
translation mod's label-only patch (the natural shape for a status
catalog carrying one string per id, with no separate hudLabel data to
patch) was silently shadowed by the untouched vanilla hudLabel -- the
translation was stored but never displayed, in or out of battle. This
affected BattleState:statusLabel too, before this change and
independently of it. Dropped the redundant hudLabel field from all
five vanilla records: it's declared optional in the schema, and
nothing in this codebase ever gives it a value different from label --
setting it here only recreated the shadowing trap for no observed
benefit. Left a comment above Status.RECORDS warning against
re-adding it.
2026-08-19 08:08:20 +02:00
1jamie 5871469002 fix(tests): avoid false positive Game: pattern match in skin_studio test 2026-08-18 21:16:32 -05:00
1jamie 302b2c9591 feat(android): add adaptive icons, dynamic shortcuts, in-process hot-swap, and exit-to-launcher 2026-08-18 20:37:25 -05:00
Adrian Castro 67a170fd6e feat(launcher): add safe mode and issue reporting 2026-08-19 00:44:30 +02:00
AverageConsumer 66079686fc fix(gen2): honor battle party grid navigation 2026-08-19 00:35:39 +02:00
AverageConsumer cc5ff987ac fix(gen2): refresh caches missing trainer HUD balls 2026-08-18 23:52:25 +02:00
github-actions f8ba51636b chore(ios): update app-repo.json [skip ci] 2026-08-18 17:18:36 -04:00
mleo2003 6588901e9a rg34xxsp launcher: also resolve GAMEDIR on split-tree firmwares
The generated launcher resolves the game folder as "$SHDIR/gen1recomp" -- the
sibling of the script. That is right on Anbernic stock, and the comment above
it explains why PortMaster's \$directory was not used there (casing and mount
points differ).

Firmwares that keep launcher scripts and port data in SEPARATE trees -- muOS
puts scripts in roms/ports and data in ports, as does PortMaster on several
devices -- have no game beside the script, so the launcher exits without
starting anything.

Try the sibling first, unchanged, and fall back to the split layouts only when
the sibling holds no game.

Probe for bin/love.aarch64, not for the directory: on a split layout the script
has usually already created "$SHDIR/gen1recomp/conf" and log.txt on an earlier
failed run (its own mkdir and tee), so a directory test matches a decoy of the
script's own making. Verified on a muOS RG35XXSP, where exactly that decoy
exists and holds only conf/ and log.txt.

Stock is unaffected: its sibling holds the real binary and wins the first test,
including when a populated path exists elsewhere. With no game anywhere the
value is unchanged, so the failure mode stays what it was.
2026-08-17 21:36:49 -07:00
mleo2003 142d1358dd SaveData: a fresh skeleton must not overwrite an existing playthrough binding
ensurePlaythroughId() treats a fresh New Game skeleton as having no id, mints
one, and persists it into opts.playthroughIds[version][scope] -- even when that
slot already names a playthrough.

newGame() marks the skeleton on the boot frame, before any save is loaded, and
mods initialise inside that window: Storage:selected needs TitleState, which
does not exist yet, so Storage:context -> _scope -> ensurePlaythroughId is the
only path open to them. A mod touching mod.storage at init therefore replaces
the real save's id with a throwaway, stranding that save's mod storage, and it
repeats on every launch.

Observed on an RG35XXSP (engine 0.2.1, PotatoVoxel 1.7.11): a new playthrough
id in options.lua after every launch, 32 orphaned mod_storage directories, and
the mod's ~400MB prebuilt mesh cache abandoned under the id options.lua used to
name -- so every map rebuilt from scratch.

Keep both existing behaviours: a fresh skeleton still gets its own id, so two
unsaved New Games sharing a slot stay distinct, and it is still persisted when
the slot has no binding yet -- the contract tests/modkit/cases/
title_playthrough_context.lua pins, where a tool persists before the first
normal SAVE and the title must resolve it after a restart.

Only the overwrite of an EXISTING binding is dropped.

./scripts/test.sh: ALL TIERS PASSED (44/44 title_playthrough_context,
18/18 playthrough_identity).
2026-08-17 16:19:46 -07:00
249 changed files with 36149 additions and 1493 deletions
+1
View File
@@ -0,0 +1 @@
* @bryanthaboi
+8 -55
View File
@@ -12,10 +12,11 @@ name: ci
#
on:
push:
# Integration branch + release branch. PRs already run via pull_request
# (any base); this list is only for post-merge push runs.
branches: [dev, main]
# PRs into dev only: a dev -> main ship PR reuses the required checks the
# dev push already put on the same head SHA, so it needs no second run.
pull_request:
branches: [dev]
# a force-push while CI is mid-run should cancel the stale run, not queue
concurrency:
@@ -318,52 +319,10 @@ jobs:
run: |
set -euo pipefail
scripts/build_linux_arm64.sh --version 0.0.0
# Shared with the release workflow so shipped images get the same
# self-contained / glibc-floor checks as PR builds.
- name: Verify the AppImage is self-contained and bullseye-compatible
run: |
set -euo pipefail
image="dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage"
# --appimage-extract needs no FUSE, so this works on a runner
# without /dev/fuse and still exercises the real payload.
"$image" --appimage-extract >/dev/null
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
[ -e "squashfs-root/$required" ] \
|| { echo "::error::AppImage is missing $required"; exit 1; }
done
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
# applied; an unresolved soname here is a user-visible launch crash.
#
# This runs on a HEADLESS runner on purpose, and that is the point.
# The first version of this build bundled Debian's SDL2, which
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
# started on a full desktop -- a bare runner is what exposed it.
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
| grep 'not found' || true)"
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
# Nothing may hard-link a driver, session or audio-stack library:
# those must be reached through dlopen so the AppImage runs on a box
# with only ALSA, only Wayland, or only KMSDRM.
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
[ -z "$linked" ] \
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
# The whole point of compiling on bullseye. If a future change moves
# the builder to a newer base, the glibc floor silently rises and
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
# it here instead of in a release.
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
echo "highest required glibc symbol version: $floor"
[ -n "$floor" ] \
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
[ "$highest" = "GLIBC_2.31" ] \
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
run: bash scripts/linux-arm64/verify_appimage.sh dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage
- name: Upload the AppImage
uses: actions/upload-artifact@v7
with:
@@ -400,7 +359,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: sudo apt-get update && sudo apt-get install -y luajit
- run: python3 -m pip install --upgrade pillow
# the fixture PNGs are committed (they are 8x8 placeholders, not
@@ -421,13 +379,8 @@ jobs:
print(f"\n{len(paths)} fixture assets valid")
PY
# the fingerprint golden is the parity tripwire; prove it still
# matches the dataset on a clean checkout
- name: fingerprint gate
run: luajit tests/engine/gate_fingerprint.lua
- name: parity-guarantee meta-test
run: luajit tests/engine/gate_meta_coverage.lua
# the fingerprint parity gates (gate_fingerprint / gate_meta_coverage)
# run in the headless job via run_engine; this job only guards the PNGs
# Only the differ is under test here, and the job is named for that. The
# capture half of the golden pipeline does not exist: a POKEPORT_DRIVER
+14 -2
View File
@@ -161,6 +161,8 @@ jobs:
scripts/build_linux_arm64.sh \
--version "${{ needs.version.outputs.version }}" \
--game-love .bazinga/work/game.love
- name: Verify the AppImage is self-contained and bullseye-compatible
run: bash scripts/linux-arm64/verify_appimage.sh "dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage"
- name: Upload Linux arm64 release
uses: actions/upload-artifact@v7
with:
@@ -285,7 +287,7 @@ jobs:
retention-days: 1
release:
needs: [version, xbox-uwp, linux-arm64, native-tls-win]
needs: [version, love-payload, xbox-uwp, linux-arm64, native-tls-win]
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
steps:
@@ -307,6 +309,15 @@ jobs:
name: gen1tls-win-x64
path: dist/native/win-x64
# The same game.love the arm64 AppImage and Xbox UWP builds fused, so
# every release asset ships one identical payload (build.sh's own pack
# would omit PATCH_NOTES.md and mobile/ios/app-repo.json).
- name: Download shared payload
uses: actions/download-artifact@v8
with:
name: gen1recomp-release-love
path: dist/payload
- name: Import signing certificate into a temporary keychain
if: github.repository == 'bryanthaboi/gen1recomp'
run: |
@@ -357,7 +368,8 @@ jobs:
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
exit 1
fi
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize \
--game-love dist/payload/game.love
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
+11 -11
View File
@@ -140,17 +140,17 @@ ship text.
### 4. `games` (and the legacy `gen2compat`)
Pokemon Gold is Gen 2, and it runs its own battle engine, overworld, script
VM and save format. The mod API is shared across both generations (same hook
names, same event names, same registry names) but Gold cannot serve all of it
yet, so Gen 2 is opt-in. Say which games the mod is for:
Pokemon Gold and Silver are Gen 2, and they run their own battle engine,
overworld, script VM and save format. The mod API is shared across both
generations (same hook names, same event names, same registry names) but Gen 2
cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
```json
"games": ["gen1", "gen2"]
```
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`), a
generation (`"gen1"`, `"gen2"`) or `"all"`;
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`,
`"silver"`), a generation (`"gen1"`, `"gen2"`) or `"all"`;
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
restates the game list. `python3 tools/modkit.py scaffold my_mod --games
gen1,gen2` writes the key for you. The mod still installs to one directory,
@@ -159,10 +159,10 @@ gen1,gen2` writes the key for you. The mod still installs to one directory,
Absent means Gen 1 only, which is what every mod written before the key existed
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
already ran on. On a Gold boot a mod claiming no Gen 2 game is not loaded at
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually
run your mod on Gold.
already ran on. On a Gold or Silver boot a mod claiming no Gen 2 game is not
loaded at all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why,
because a mod that half-applies reads as a broken mod. Claim Gen 2 once you
have actually run your mod on Gold or Silver.
Every token is enforced, per game: the loader gates on the same
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]`
@@ -173,7 +173,7 @@ before the key existed changes behavior; list both generations or say `"all"`
when you mean everywhere.
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1,
and Silver today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1,
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
writes with a report, and which hooks and events are still to come.
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
+10 -10
View File
@@ -53,18 +53,17 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
### Watch the latest update video
[![Watch the latest update video](https://img.youtube.com/vi/8IOgqbe4YvA/maxresdefault.jpg)](https://www.youtube.com/watch?v=8IOgqbe4YvA)
[![Watch the latest update video](https://img.youtube.com/vi/yi7LkWQPKKM/maxresdefault.jpg)](https://youtu.be/yi7LkWQPKKM)
This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Poke Red, Blue, Yellow, or Gold ROM
is the only game content input.
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold, or
Silver ROM is the only game content input.
The ROM is verified, used during import, and then released from memory. It is
not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again. Red, Blue, Yellow, and Gold can all be imported
side by side. Gold is Gen 2 Phase 1 (import + launcher; see
`docs/gold-phase1.md`): the Gen 2 engine is still under construction.
do not ask for the ROM again. Red, Blue, Yellow, Gold, and Silver can all be
imported side by side. Gold and Silver are Gen 2 Phase 1 (import + launcher;
see `docs/gold-phase1.md`): the Gen 2 engine is still under construction.
## Quick Start
@@ -72,13 +71,14 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` /
`.gbc` file or drop it onto the window. Import takes a few seconds and the
game starts automatically.
Only the canonical US Red, Blue, Yellow (1 MiB), and Gold (2 MiB) ROMs are
accepted. The importer verifies SHA-1 before creating any game data:
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, and Silver (2 MiB)
ROMs are accepted. The importer verifies SHA-1 before creating any game data:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
- Silver: `49b163f7e57702bc939d642a18f591de55d92dae`
The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact
@@ -219,7 +219,7 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
| Option | Effect |
| --- | --- |
| `--game=red` | boot Red, skipping the launcher (`blue` and `yellow` too, or just `r` / `b` / `y`) |
| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold` and `silver` too, or just `r` / `b` / `y` / `g` / `s`) |
| `--slot=2` | load that save slot; takes a slot number or a slot id |
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+2
View File
@@ -133,6 +133,8 @@ mkdir -p "$GAME_SRC"
(cd "$SOURCE_DIR" && zip -q -9 -r "$WORK/game-payload.zip" \
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 tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$WORK/game-payload.zip" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
+23
View File
@@ -92,6 +92,7 @@ mkdir -p "$GAME_SRC"
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 tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
printf '%s\n' "$payload_list" \
@@ -99,6 +100,8 @@ printf '%s\n' "$payload_list" \
&& fail "payload unexpectedly contains generated ROM data"
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|| fail "payload is missing tools/rom_manifest_gold.json"
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_silver.json" \
|| fail "payload is missing tools/rom_manifest_silver.json"
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
rm -f "$WORK/game-payload.zip"
@@ -193,6 +196,26 @@ get_controls
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
GAMEDIR="$SHDIR/gen1recomp"
# Anbernic stock keeps the launcher and the game folder side by side, so the
# SHDIR-relative path above is correct there and is tried first.
#
# Other firmwares (muOS, and PortMaster's layout on several devices) keep
# launcher scripts and port data in SEPARATE trees -- scripts under roms/ports,
# data under ports -- so the sibling folder holds no game.
#
# Probe for the BINARY, not the directory: on a split layout this script has
# usually already created "$SHDIR/gen1recomp/conf" and log.txt on an earlier
# failed run (see mkdir/tee below), so an existence test matches a decoy of our
# own making. Stock is unaffected -- its sibling holds the real binary and wins
# on the first test.
if [ ! -f "$GAMEDIR/bin/love.aarch64" ]; then
for candidate in "/$directory/ports/gen1recomp" \
"/mnt/sdcard/ports/gen1recomp" \
"/mnt/mmc/ports/gen1recomp" \
"/roms/ports/gen1recomp"; do
if [ -f "$candidate/bin/love.aarch64" ]; then GAMEDIR="$candidate"; break; fi
done
fi
CONFDIR="$GAMEDIR/conf"
mkdir -p "$CONFDIR"
+6 -3
View File
@@ -25,8 +25,10 @@
local Menu = require("src.ui.Menu")
local TextBox = require("src.render.TextBox")
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, so the
-- extractor never collects it and the pamphlet's text is inlined.
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, but the
-- extractor now collects any top-level label in a dedicated text file
-- regardless (tools/extract/text.py), so this is the real ROM label --
-- the literal below is only the fallback for a catalog without it.
local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f"
.. "There are 50 TMs\nin all.\f"
.. "There are also 5\nHMs that can be\vused repeatedly.\f"
@@ -70,7 +72,8 @@ return {
return true
end
if fx == 3 and fy == 4 then
game.stack:push(TextBox.new(game, TM_NOTEBOOK_TEXT))
local text = game.data.text or {}
game.stack:push(TextBox.new(game, text.TMNotebookText or TM_NOTEBOOK_TEXT))
return true
end
return false
+19
View File
@@ -5,8 +5,27 @@
-- voucher exchange and the BICYCLE/CANCEL price window need more than
-- command rows (#568).
local TextBox = require("src.render.TextBox")
-- data/events/hidden_events.asm:542
local BIKE_DISPLAYS = {
{ 1, 0 }, { 2, 1 }, { 1, 2 }, { 3, 2 }, { 0, 4 }, { 1, 5 },
}
return {
BIKE_SHOP = {
-- engine/events/hidden_events/new_bike.asm:1
onInteract = function(game, ow, fx, fy)
for _, c in ipairs(BIKE_DISPLAYS) do
if c[1] == fx and c[2] == fy then
game.stack:push(TextBox.new(game,
(game.data.text or {})._NewBicycleText or "A shiny new\nBICYCLE!"))
return true
end
end
return false
end,
talk = {
-- BikeShopMiddleAgedWomanText (pokered/scripts/BikeShop.asm):
-- always shows the same flavor line, no branching.
+10 -7
View File
@@ -15,10 +15,10 @@ return {
-- pick the dish: bit 7 set (~50%) -> Salmon du Salad, else bit 4
-- set (~25%) -> Eels au Barbecue, else (~25%) -> Prime Beef Steak.
-- The three dish texts (SSAnneKitchenCook7SalmonDuSaladText /
-- ...EelsAuBarbecueText / ...PrimeBeefSteakText) aren't extracted
-- into data/generated/text.lua (no leading underscore in
-- pokered/text/SSAnneKitchen.asm), so their literal strings are
-- ported here verbatim.
-- ...EelsAuBarbecueText / ...PrimeBeefSteakText) have no leading
-- underscore in pokered/text/SSAnneKitchen.asm, but the extractor
-- collects them regardless (tools/extract/text.py); the literals
-- below are only the fallback for a catalog without them.
TEXT_SSANNEKITCHEN_COOK7 = function(game, ow, npc, done)
local t = game.data.text
push(game, t._SSAnneKitchenCook7MainCourseIsText
@@ -27,13 +27,16 @@ return {
local dish
if roll <= 2 then
-- bit 7 of hRandomAdd set (~50%)
dish = "Salmon du Salad!\fLes guests may\ngripe it's fish\vagain, however!"
dish = t.SSAnneKitchenCook7SalmonDuSaladText
or "Salmon du Salad!\fLes guests may\ngripe it's fish\vagain, however!"
elseif roll == 3 then
-- bit 4 set, bit 7 clear (~25%)
dish = "Eels au Barbecue!\fLes guests will\nmutiny, I fear."
dish = t.SSAnneKitchenCook7EelsAuBarbecueText
or "Eels au Barbecue!\fLes guests will\nmutiny, I fear."
else
-- neither bit set (~25%)
dish = "Prime Beef Steak!\fBut, have I enough\nfillets du beef?"
dish = t.SSAnneKitchenCook7PrimeBeefSteakText
or "Prime Beef Steak!\fBut, have I enough\nfillets du beef?"
end
push(game, dish, done)
end)
+11 -10
View File
@@ -60,22 +60,23 @@ M.VIRIDIAN_CITY = {
-- you want to know about the two kinds of caterpillar Pokemon;
-- YES -> CATERPIE/WEEDLE description, NO -> "Oh, OK then!".
-- ViridianCityYoungster2OkThenText and
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
-- defined without a leading underscore in pokered/text/ViridianCity.asm
-- and aren't present in data/generated/text.lua, so we fall back to
-- the literal strings from pokered. Those fallbacks have to carry the
-- extractor's markers, not plain newlines: line -> \n, cont -> \v,
-- para -> \f. Spelling cont/para as \n and \n\n put all six lines on
-- one page with nothing to wait on, so the whole speech scrolled past
-- without a button press (#250).
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are defined
-- without a leading underscore in pokered/text/ViridianCity.asm, but
-- tools/extract/text.py now collects them regardless -- the literal
-- strings below are only the fallback for a catalog without them.
-- Those fallbacks have to carry the extractor's markers, not plain
-- newlines: line -> \n, cont -> \v, para -> \f. Spelling cont/para as
-- \n and \n\n put all six lines on one page with nothing to wait on,
-- so the whole speech scrolled past without a button press (#250).
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
local t = text(game)
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
if yes then
push(game, "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
push(game, t.ViridianCityYoungster2CaterpieAndWeedleDescriptionText
or "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
else
push(game, "Oh, OK then!", done)
push(game, t.ViridianCityYoungster2OkThenText or "Oh, OK then!", done)
end
end)
end,
+1
View File
@@ -168,6 +168,7 @@ return {
{ "jump_if_true", "come_see" },
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "give_item", "POKE_BALL", 5, false },
{ "text_sound", "Get_Key_Item" }, -- OaksLab.asm:1060
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
{ "jump", "end" },
+11 -1
View File
@@ -516,7 +516,17 @@ M.ROUTE_24 = {
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
done)
else
ow:engageTrainer(npc, done)
-- scripts/Route24.asm:125
ow:engageTrainer(npc, function()
if ow:trainerDefeated(npc) then
-- scripts/Route24.asm:62
push(game,
text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
done)
else
done()
end
end, text(game)._Route24CooltrainerM1DefeatedText, true)
end
end
if not flags.EVENT_GOT_NUGGET then
+6 -5
View File
@@ -122,9 +122,8 @@ M.CINNABAR_LAB_METRONOME_ROOM = {
-- 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.
-- leading underscore; tools/extract/text.py now collects it regardless,
-- so preFallback below is just the safety net for a catalog without it.
M.VIRIDIAN_CITY = {
talk = {
TEXT_VIRIDIANCITY_FISHER = gift({
@@ -146,9 +145,11 @@ M.SILPH_CO_2F = {
talk = {
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
-- the label carries no leading underscore: pokered keeps this one in
-- the script bank, not the far-text bank (#393)
-- the label carries no leading underscore (#393); collected like any
-- other text/*.asm label now, preFallback is just the safety net
pre = "SilphCo2FSilphWorkerFPleaseTakeThisText",
preFallback = "Eeek!\nNo! Stop! Help!\fOh, you're not\nwith TEAM ROCKET."
.. "\vI thought...\vI'm sorry. Here,\vplease take this!",
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
+5 -4
View File
@@ -7,9 +7,9 @@ local M = {}
local function text(game) return game.data.text end
local function push(game, s, done)
local function push(game, s, done, opts)
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, done))
game.stack:push(TextBox.new(game, s, done, opts))
end
-- PrintText on a text_end string returns with the box still drawn and
@@ -236,7 +236,6 @@ M.CINNABAR_GYM = {
if yes == machine.yes then
-- CinnabarGymQuizCorrectText: item jingle, then the gate
-- slides open (SFX_GO_INSIDE) if it was still locked
Sound.play(game.data, "Get_Item1")
push(game, t._CinnabarGymQuizCorrectText
or "You're absolutely\ncorrect!\fGo on through!", function()
if not game.save.flags[gymGateFlag(index)] then
@@ -244,7 +243,9 @@ M.CINNABAR_GYM = {
Sound.play(game.data, "Go_Inside")
end
applyGymGates(game, ow)
end)
end, { preSound = function()
return Sound.play(game.data, "Get_Item1")
end })
return
end
Sound.play(game.data, "Denied")
+6 -6
View File
@@ -17,9 +17,9 @@ local function surfingPikachu(game)
return nil
end
local function push(game, text, done)
local function push(game, text, done, opts)
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, text, done))
game.stack:push(TextBox.new(game, text, done, opts))
end
-- the two-variant posters: the surf-capable line once a surfing
@@ -69,11 +69,11 @@ return {
TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done)
local t = game.data.text
-- scripts/SummerBeachHouse.asm:68
push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!",
function()
require("src.core.Sound").playCry(game.data, "PIKACHU")
done()
end)
done, { auto = { wait = true, delay = 0, sound = function()
return require("src.core.Sound").playCry(game.data, "PIKACHU")
end } })
end,
TEXT_SUMMERBEACHHOUSE_POSTER1 = poster(1),
+11 -8
View File
@@ -105,8 +105,9 @@ trixie.
This is a statement about the *compile environment*, not about where the
artifact runs — building on your own newer distro would silently raise that
floor and strand every user on an older one, with no symptom until they
download it. CI enforces the floor: `linux-arm64-build` fails if the highest
required glibc symbol version climbs above 2.31.
download it. `scripts/linux-arm64/verify_appimage.sh` enforces the floor in
both CI (`linux-arm64-build`) and the release workflow: the build fails if
the highest required glibc symbol version climbs above 2.31.
### Why five libraries are built from source
@@ -172,13 +173,15 @@ Three jobs, path-gated on `scripts/build_linux_arm64.sh`,
exclude list still classifies known sonames correctly, that AppRun still
launches `game.love` with `--fused`, and that the host-arch guard actually
fires. Needs no container and no arm64 machine.
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then extracts
the artifact and asserts the layout, that every bundled object resolves
under AppRun's `LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31.
Uploads the AppImage for 7 days.
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then
`scripts/linux-arm64/verify_appimage.sh` extracts the artifact and asserts
the layout, that every bundled object resolves under AppRun's
`LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31. Uploads the
AppImage for 7 days.
- **release** — `linux-arm64` runs on `ubuntu-24.04-arm`, reuses the shared
`game.love` from the `love-payload` job, and the AppImage is staged and
published like every other release asset.
`game.love` from the `love-payload` job, runs the same
`verify_appimage.sh` checks on the shipped image, and the AppImage is
staged and published like every other release asset.
Unlike the Switch job, none of this needs secrets or self-hosted hardware, so
it runs on fork PRs too.
+7 -5
View File
@@ -55,9 +55,10 @@ The short version, for an author deciding what to write:
```
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
`"gold"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or `"all"`.
`src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER` and
`GameVersion.generation`, so nothing anywhere restates the game list.
`"gold"`, `"silver"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or
`"all"`. `src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER`
and `GameVersion.generation`, so nothing anywhere restates the game list.
`"gen2"` now expands to both Gold and Silver.
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
and **derives** `manifest.gen2compat` from them, which is the one field the
loader's gate reads.
@@ -512,8 +513,9 @@ gains a field instead of the name gaining a prefix.
id under Gen 1's `name` key, which is the one payload difference the
numeric flag space forces.
- *Menus (`src/ui/gen2/`):* `ui.start_menu.items`, `ui.title_menu.items`,
`ui.options.rows`, `ui.party.submenu`, `ui.naming.grid`, `ui.pc.items`,
`ui.list_menu`, `transition.style`. `ui.list_menu` covers Gold's script
`ui.options.rows`, `ui.party.submenu`, `ui.party.grid_navigation`,
`ui.naming.grid`, `ui.pc.items`, `ui.list_menu`, `transition.style`.
`ui.list_menu` covers Gold's script
menus (`ScriptMenu.lua`); the `Chrome.List` widget the START and title
menus draw with does not raise it yet, so those two are composed through
their own hooks only.
+1 -1
View File
@@ -81,7 +81,7 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. |
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, `["silver"]`, or `["all"]`. |
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
+3 -2
View File
@@ -11,11 +11,12 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Persistent custom options** stored separately from game saves
* **Optional widescreen battle layout**
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders
* **Screen position setting** (center, upper, top) shared across all games, for clamp-on controllers that cover the lower screen
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
* **Pokédex diploma and printer image exports**
* **Mod download counts** from the index feed, with Most-downloaded and Trending sorts
## Gen 2 Specifics
* **Pokémon Silver** as an importable, launcher-selectable version alongside Gold
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
* **Followers** for mods, plus Gen 2-only registries and hooks
+1 -1
View File
@@ -176,7 +176,7 @@ something the filesystem encodes.
| token | means |
| --- | --- |
| `"red"`, `"blue"`, `"yellow"`, `"gold"` | that one game (a version id from `GameVersion.ORDER`) |
| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"` | that one game (a version id from `GameVersion.ORDER`) |
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
| `"all"` | every game this engine has |
+4 -2
View File
@@ -1,7 +1,8 @@
# What This Port Requires
The packaged desktop app requires one user-supplied input on first boot: a
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM.
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM, or a canonical 2 MiB US
Pokemon Gold or Silver ROM.
The importer verifies the SHA-1 for the game (see `src/core/GameVersion.lua`
for specific hashes). Other revisions and Virtual Console releases are rejected
@@ -15,7 +16,8 @@ Python and Pillow are not required by the packaged app.
Assembly removes high-level names and some relationships that the Lua port
needs. The version-specific files `tools/rom_manifest.json`,
`tools/rom_manifest_blue.json`, and `tools/rom_manifest_yellow.json` therefore
`tools/rom_manifest_blue.json`, `tools/rom_manifest_yellow.json`,
`tools/rom_manifest_gold.json`, and `tools/rom_manifest_silver.json` therefore
contain:
- the ROM symbol addresses actually read by the extractor
+101 -18
View File
@@ -4,17 +4,23 @@ A **skin** replaces the on-screen controls wholesale: a bezel image, a
control layout, and the rectangle the Game Boy screen is drawn into. Engine:
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
(draw and input), `src/render/Renderer.lua` (the screen viewport),
`src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export),
`src/ui/SkinStudio.lua` (the desktop editor). Tests:
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
`tests/engine/skin_studio_ux.lua`,
`tests/engine/skin_studio_image_import.lua`,
`tests/engine/launcher_skins_tab.lua`.
`tests/engine/skin_format_import_test.lua`,
`tests/engine/launcher_skins_tab.lua`,
`tests/engine/launcher_skins_ux.lua`.
Skins are picked in the launcher's **Skins** tab, which also imports them and
opens the studio. `options.touchControls.skin` holds the folder name.
## Formats
Two load. `skin.lua` wins when a folder has both.
Three load: the native `skin.lua`, a RetroArch overlay `.cfg`, and a Delta
`.deltaskin`. `skin.lua` wins when a folder has more than one. The launcher
badges each installed skin with the format it was read from.
**RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads
as-is. Supported keys:
@@ -41,6 +47,15 @@ Hitboxes are `radial` or `rect`. Pipe-separated binds (`left|down`) are one
control that holds both. A `nul` desc is decoration: it draws and never
captures a touch.
The area desc types are expanded rather than ignored: `dpad_area`,
`abxy_area`, `analog_left` and `analog_right` each become eight hitboxes over
the same area, one per 45 degree sector measured from its centre, the way
RetroArch resolves them: there is no neutral middle, and the four corner
sectors fire two inputs. Any `_up` / `_down` / `_left` / `_right` override and
the per-side reach are honoured, and the desc's own art is kept as decoration
over the top. Exporting a cfg folds the eight back into the one area desc they
came from. `retrok_<key>` is a keyboard bind.
Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every
image sits at the overlay opacity, and a pressed control's image swaps to
`opacity * alpha_mod`. So `alpha_mod` above 1 lights a control up and below 1
@@ -71,6 +86,32 @@ return {
}
```
**Delta `.deltaskin`.** A zip (any wrapping folder is stripped) holding an
`info.json` plus its art. The `representations` tree is walked
device / display type / orientation, and every orientation that exists becomes
a page; `page.orient` is the orientation key, so a portrait/landscape pair
auto-rotates like a RetroArch one. Item `frame` rects are top-left plus size in
`mappingSize` points and are converted to the native centre plus half extent;
`extendedEdges` merge per key into the reach fields; `mask: "circle"` becomes a
radial hitbox. A `dpad` or `thumbstick` item expands into the 3x3 grid, so the
corners fire two directions. `screens[1].outputFrame` (or the legacy
`gameScreenFrame`) becomes the screen cutout. A portrait page with neither
keeps `mappingSize` as the overlay aspect, sits at the bottom of the
window, and puts the Game Boy picture in the leftover space above -- the
usual GBA4iOS controller-deck layout. Pages that name a screen rect still
stretch to the window the way Delta does. Host functions map to
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
Both `com.rileytestut.delta.game.*` and Manic's `public.aoshuang.game.*`
identifiers are accepted, and a non Game Boy system warns instead of failing.
PDF artwork is usually a JPEG wrapped so iOS can scale it (Delta's
Image-to-PDF skins, Preview exports, and the like). Import extracts that
JPEG and draws it; a true vector PDF with no embedded image is still refused,
with a message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files
are an older, incompatible schema and are refused by name.
## Bindable actions
The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`,
@@ -119,10 +160,22 @@ them. Anything that binds a button still follows the usual mobile /
## Installing
Drop a folder or a `.zip` into `skins/` in the save directory, or drop a zip on
the launcher window while the Skins tab is open. A zip is mounted in place, so
there is nothing to unpack. The folder needs one `skin.lua` or `.cfg`
(`overlay.cfg` is preferred when there are several) and the images it names.
Four roads, all of them landing in `skins/` in the save directory:
* **Import** on the Skins tab opens the host file picker for a `.zip` or a
`.deltaskin`.
* **Paste a skin link** in the tab's URL row, then **Add**. The download runs
on the fetch pool (`src/net/Fetch.lua`), so the launcher stays live, and the
row shows a spinner until it lands. A link to a bare `overlay.cfg` is wrapped
into an archive on the way in. This is the road that works on a phone, where
there is no file picker to speak of.
* Drop a `.zip` or `.deltaskin` on the launcher window while the Skins tab is
open.
* Copy a folder or archive into `skins/` by hand.
An archive is mounted in place, so there is nothing to unpack. It needs one
`skin.lua`, `.cfg` (`overlay.cfg` is preferred when there are several) or
`info.json`, plus the images it names.
Two ship bundled, both from libretro's `common-overlays` under CC-BY-4.0:
@@ -157,23 +210,40 @@ The Super Game Boy preset locks the viewport to the real screen window,
160x144 at (48,40), so an SGB border cannot be drawn out of register.
**Editing.** Click a control to select it, drag to move, eight handles to
resize. X / Y / W / H are in canvas pixels, so a control can be typed to the
coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and
resize. Arrow keys nudge the selection one canvas pixel, shift-arrow ten. While
a control is dragged it snaps to the centres and edges of the other controls
and of the page itself when it comes within a few pixels, and the guide it
snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be
typed to the coordinate its art was drawn at. **Back** and **Front** move the
selection through the draw order. Bind, hitbox shape, hit reach and idle and
pressed images are per control; the bezel, the pages and the screen cutout are
per page. The cutout is itself a draggable element with a 10:9 lock.
**Bind** opens a grid of every bind the engine understands: the eight Game Boy
buttons, the diagonal pairs, every hotkey, a few `key:` entries, and
decoration. The COMBINE chips at the top toggle one part at a time, which is
how a pipe bind like `left|down` is built without typing it.
**Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or
`u` / shift+`u` without a keyboard modifier). The stack holds the last 50
actions. `L` toggles the bind captions drawn on the canvas.
Each page can **Lock** to portrait or landscape. With **Match canvas** on
(the default), Next page picks a matching mock device and the canvas preset
(the default), the page list picks a matching mock device and the canvas preset
picks a matching page. Turn Match canvas off to look at a portrait page on a
landscape device.
landscape device. **Pages** opens the page list, where a page is selected,
renamed or deleted.
Starting a new skin, opening another one or closing the studio with unsaved
edits prompts first, with Save first / Discard / Cancel.
A RetroArch overlay whose pages are already named portrait / landscape
(the auto-rotate convention) locks those pages and turns Match canvas on
when you open it. You do not have to click Lock first.
**Art.** The **Bezel**, **Idle art** and **Pressed art** rows cycle through the
images already in the skin folder; the **Import** button beside each one opens
the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell,
**Art.** The **Bezel**, **Idle art** and **Pressed art** rows open a
thumbnail grid of the images already in the skin folder, with `(none)` first;
the **Import** button there and beside each row opens the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell,
zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in
the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the
window does the same for whichever slot was last touched. A new bezel does not
@@ -185,11 +255,24 @@ buttons and the footer reports what is held. **Play** saves the skin, selects
it, and boots the game with it.
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
skin names, so the folder stands alone. **Export** packs it as one zip
(`src/core/SkinZip.lua`, store-only) carrying the native `skin.lua`, the
images, and the original `.cfg` when it came from one. An exported skin drops
straight back into `skins/` and still opens in RetroArch.
skin names, so the folder stands alone. **Export** offers three formats, and
the Skins tab's gear offers the same three for any installed skin:
| Export | Contents |
| --- | --- |
| gen1recomp `.zip` | the native `skin.lua`, the images, and the original `.cfg` when it came from one |
| RetroArch `.zip` | an `overlay.cfg` generated from the model, plus the images |
| Delta `.deltaskin` | an `info.json` generated from the model, plus the images |
All three are written store-only (`src/core/SkinZip.lua`) into `skins/_export/`
in the save directory, which is outside the folder the skin list scans, so an
export can never shadow the skin it came from. The notice names the full path
so a phone can find the file in its own file manager. On desktop **Show the
exported file** opens that folder.
## Not implemented
RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types.
True vector Delta skins (PDF artwork with no embedded JPEG). Those still need
a PDF renderer this engine does not carry, so they are refused with a message
rather than imported half-drawn. PDF files that wrap a JPEG, the usual Delta
skin case, extract on import.
+11 -9
View File
@@ -91,14 +91,14 @@ Do **not** launch from the Album applet path for normal play.
This project ships **no** game data. On first launch:
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, or
Gold (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
launcher also shows the live save-dir path). All four can sit in the
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, Gold, or
Silver (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
launcher also shows the live save-dir path). All five can sit in the
same folder.
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold).
Rescan matches by ROM SHA-1 for the open tab only. A Red dump never
imports from the Yellow tab (and vice versa). Gold is Beta in the
launcher; a clean US Gold dump is enough to Play.
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold /
Silver). Rescan matches by ROM SHA-1 for the open tab only. A Red dump
never imports from the Yellow tab (and vice versa). Gold and Silver are
Beta in the launcher; a clean US dump of either is enough to Play.
## 5. Import / Export a raw `.sav`
@@ -111,10 +111,12 @@ SD / FTP, same transfer methods as ROMs. Paths are **per game**:
| Blue | `imports/saves/blue/` | `exports/blue/` |
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
| Gold | `imports/saves/gold/` | `exports/gold/` |
| Silver | `imports/saves/silver/` | `exports/silver/` |
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.
Gold cart `.sav` import/export is not supported yet -- the folders exist so
MTP browsing matches the other games. Gold progress still saves in-engine.)
Gold and Silver cart `.sav` import/export is not supported yet -- the folders
exist so MTP browsing matches the other games. Gold and Silver progress still
saves in-engine.)
1. Copy a Gen 1 `.sav` (32 KB) into that game's inbox under the save dir
([switch-transfer.md](switch-transfer.md)).
+4 -3
View File
@@ -22,8 +22,8 @@ Player install (what to download, title override) stays in
| 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\|gold/` then that game's SAVE FILES → **Import save** (Gold cart `.sav` not supported yet) |
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold/` (pull after **Export save**; Gold cart `.sav` not supported yet) |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver/` then that game's SAVE FILES → **Import save** (Gold / Silver cart `.sav` not supported yet) |
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver/` (pull after **Export save**; Gold / Silver cart `.sav` not supported yet) |
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
| Lua error log | `lua-error.log` in the save dir |
@@ -54,7 +54,8 @@ macOS, not a Mac-only requirement.
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|gold>/`, or `exports/<red|blue|yellow|gold>/`
`imports/saves/<red|blue|yellow|gold|silver>/`, or
`exports/<red|blue|yellow|gold|silver>/`
path the launcher prints.
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
+110 -20
View File
@@ -291,6 +291,79 @@ function closeSkinStudio()
end
end
local function makeLauncher()
local RomImporter = require("src.import.RomImporter")
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
return RomImporter.new(function(version)
Importer = nil
bootGame(version)
end, {
launcher = true,
forceImport = forceImport,
onEditSave = openEditor,
onEditTouchControls = openTouchControlsEditor,
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
and openSkinStudio or nil,
})
end
local function returnToLauncher()
if not Game then return end
pcall(function() require("src.core.Music").stop() end)
pcall(function() require("src.core.Sound").stop() end)
if package.loaded["src.core.ChipAudio"] then
pcall(package.loaded["src.core.ChipAudio"].shutdown)
end
if package.loaded["src.core.DiscordPresence"] then
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
end
if package.loaded["src.core.gen2.Clock"] then
pcall(package.loaded["src.core.gen2.Clock"].shutdown)
end
if package.loaded["src.net.Gen1Tls"] then
pcall(package.loaded["src.net.Gen1Tls"].shutdown)
end
if love.audio and love.audio.stop then
pcall(love.audio.stop)
end
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
local GameVersion = require("src.core.GameVersion")
local currentVersion = GameVersion.get()
if currentVersion then
require("src.import.CacheFs").unmountVersion(currentVersion)
end
require("src.core.Data"):unloadGenerated()
local Runtime = require("src.mods.Runtime")
if Runtime.reset then
Runtime.reset()
end
Game = nil
autopilot = nil
driverCo = nil
local Input = require("src.core.Input")
local TouchControls = require("src.core.TouchControls")
Input:reset()
TouchControls:reset()
require("src.core.Orientation").applyOptions(
require("src.core.SaveData").loadOptions())
local preload = require("src.mods.LauncherMods").translationStrings()
if preload then require("src.core.Strings").load({ strings = preload }) end
if love.window and love.window.setTitle then
local Version = require("src.core.Version")
love.window.setTitle(Version.title("Gen 1 Recompilation Project"))
end
Importer = makeLauncher()
end
function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
@@ -314,11 +387,12 @@ function bootGame(version)
love.window.setTitle(Version.title(
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
end
-- Gold: Gen 1 Game:load cannot consume a Gen 2 cache -- different generated
-- tables, save shape and screen registry -- so Gold boots its own service
-- owner, which mounts src/world/gen2 (walk / warps / connections) and the
-- Gen 2 screens instead of src/core/Game.lua's Gen 1 wiring.
if GameVersion.isGold() then
-- Gen 2: Gen 1 Game:load cannot consume a Gen 2 cache -- different generated
-- tables, save shape and screen registry -- so Gold and Silver boot their
-- own service owner, which mounts src/world/gen2 (walk / warps /
-- connections) and the Gen 2 screens instead of src/core/Game.lua's Gen 1
-- wiring.
if GameVersion.generation() == 2 then
Game = require("src.core.Game2").new()
Game:load()
else
@@ -382,7 +456,7 @@ function love.load(args)
-- 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.
-- the launcher would rotate freely until options are applied at boot.
-- No-op on desktop / iOS / when options.lua does not exist yet.
require("src.core.Orientation").applyOptions(
require("src.core.SaveData").loadOptions())
@@ -442,8 +516,8 @@ function love.load(args)
-- (#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.
-- translation reaches the launcher too. The active game's loader 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
@@ -484,17 +558,7 @@ function love.load(args)
-- by its SHA-1 (GameVersion.forSha1); pressing Play boots that game (Gold
-- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md).
-- Edit on a save row opens the bundled editor on that slot (openEditor).
Importer = RomImporter.new(function(version)
Importer = nil
bootGame(version)
end, {
launcher = true,
forceImport = forceImport,
onEditSave = openEditor,
onEditTouchControls = openTouchControlsEditor,
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
and openSkinStudio or nil,
})
Importer = makeLauncher()
end
function love.update(dt)
@@ -827,6 +891,27 @@ function love.handlers.audioreset()
if Sound then pcall(Sound.onDeviceReset) end
end
function love.handlers.intent_game(version)
if type(version) ~= "string" or version == "" then return end
version = version:lower():gsub("^%s+", ""):gsub("%s+$", "")
local GameVersion = require("src.core.GameVersion")
if GameVersion.VERSIONS and not GameVersion.VERSIONS[version] then return end
local RomImporter = require("src.import.RomImporter")
if not RomImporter.isReady(version) then return end
local currentVersion = GameVersion.get()
if Game and currentVersion == version then
return
end
if Game then
returnToLauncher()
end
Importer = nil
bootGame(version)
end
function love.touchpressed(id, x, y, dx, dy, pressure)
if editorMode then
-- iOS synthesizes mousepressed for the primary touch; forwarding here
@@ -1032,11 +1117,16 @@ function love.quit()
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
-- claims the hook) is exactly the condition below.
local isAndroid = (love.system and love.system.getOS and love.system.getOS() == "Android")
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
return Game and not Importer and not quitToLauncher and not scripted
and not launchedIntoGame
and (isAndroid or not launchedIntoGame)
end)
if wouldReturnToLauncher then
if isAndroid then
returnToLauncher()
return true -- abort this quit; the restart lands back in the launcher
end
quitToLauncher = true
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
-- once, so the restart really does land in the launcher (#887). A failed
+4 -4
View File
@@ -109,10 +109,10 @@ The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
`app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`,
`libs/` (the vendored FlexLove toolkit the launcher UI needs), `data/`,
`assets/`, and the Red, Blue, and Yellow ROM manifests. The Android
packer verifies the Yellow manifest before it packages; if a partial source
export omitted it, it restores the file from this checkout's Git data and then
falls back to the project's GitHub copy. Generated game data,
`assets/`, and the Red, Blue, Yellow, Gold, and Silver ROM manifests. The
Android packer verifies the Yellow, Gold, and Silver manifests before it
packages; if a partial source export omitted one, it restores the file from
this checkout's Git data and then falls back to the project's GitHub copy. Generated game data,
scripts, tests, and mobile build sources are excluded.
## Branding (applied by the build script)
@@ -29,7 +29,8 @@
<application
android:allowBackup="true"
android:icon="@drawable/love"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="${NAME}" >
<meta-data
android:name="android.allow_multiple_resumed_activities"
@@ -39,7 +40,7 @@
android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:label="${NAME}"
android:launchMode="singleInstance"
android:launchMode="singleTask"
android:screenOrientation="${ORIENTATION}"
android:resizeableActivity="false"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" >
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -3,4 +3,10 @@
<color name="colorPrimary">#3F51B5</color>
<color name="colorPrimaryDark">#303F9F</color>
<color name="colorAccent">#FF4081</color>
<color name="ic_launcher_background">#FFFFFF</color>
<color name="shortcut_red">#E53935</color>
<color name="shortcut_blue">#1E88E5</color>
<color name="shortcut_yellow">#FDD835</color>
<color name="shortcut_gold">#D4AF37</color>
<color name="shortcut_silver">#BEC6D2</color>
</resources>
@@ -48,6 +48,7 @@
#include "common/Module.h"
#include "audio/Audio.h"
#include "audio/openal/Audio.h"
#include "event/Event.h"
namespace love
{
@@ -282,6 +283,70 @@ bool restartApp()
return result;
}
bool updateAppShortcuts(const std::vector<std::string> &versions)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
if (activity == nullptr)
return false;
jmethodID method = env->GetStaticMethodID(activity, "updateAppShortcuts", "([Ljava/lang/String;)Z");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
jclass stringClass = env->FindClass("java/lang/String");
jobjectArray array = env->NewObjectArray((jsize) versions.size(), stringClass, nullptr);
for (size_t i = 0; i < versions.size(); ++i)
{
jstring jstr = env->NewStringUTF(versions[i].c_str());
env->SetObjectArrayElement(array, (jsize) i, jstr);
env->DeleteLocalRef(jstr);
}
jboolean result = env->CallStaticBooleanMethod(activity, method, array);
env->DeleteLocalRef(array);
env->DeleteLocalRef(stringClass);
env->DeleteLocalRef(activity);
return result;
}
std::string getLaunchGame()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
if (activity == nullptr)
return "";
jmethodID method = env->GetStaticMethodID(activity, "getLaunchGame", "()Ljava/lang/String;");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return "";
}
jstring jgame = (jstring) env->CallStaticObjectMethod(activity, method);
if (jgame == nullptr)
{
env->DeleteLocalRef(activity);
return "";
}
const char *str = env->GetStringUTFChars(jgame, nullptr);
std::string result = (str != nullptr) ? str : "";
if (str != nullptr)
env->ReleaseStringUTFChars(jgame, str);
env->DeleteLocalRef(jgame);
env->DeleteLocalRef(activity);
return result;
}
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept)
{
if (url == nullptr || destPath == nullptr)
@@ -378,6 +443,104 @@ bool httpPost(const char *url, const char *body, int bodyLen, const char *conten
return result;
}
bool httpRequest(const char *url, const char *method,
const char *const *headerPairs, int headerPairCount,
const char *body, int bodyLen, const char *userAgent, std::string &out)
{
out.clear();
if (url == nullptr)
return false;
if (headerPairCount < 0 || (headerPairCount > 0 && headerPairs == nullptr))
return false;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
// Same resolution rule as httpDownload: the activity's own class via
// SDL_AndroidGetActivity, never FindClass for an app class -- save sync
// runs on a love.thread worker, whose class loader cannot see them.
jobject activityObj = (jobject) SDL_AndroidGetActivity();
if (activityObj == nullptr)
return false;
jclass activity = env->GetObjectClass(activityObj);
env->DeleteLocalRef(activityObj);
// Old APK / new liblove skew: report "no transport" instead of aborting
// on a missing method (#597).
jmethodID method_id = env->GetStaticMethodID(activity, "httpRequest",
"(Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;[BLjava/lang/String;)[B");
if (method_id == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
jobjectArray jheaders = nullptr;
if (headerPairCount > 0)
{
// java/lang/String, unlike an app class, resolves from any thread.
jclass stringClass = env->FindClass("java/lang/String");
if (stringClass == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
jheaders = env->NewObjectArray((jsize) headerPairCount, stringClass, nullptr);
env->DeleteLocalRef(stringClass);
if (jheaders == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
for (int i = 0; i < headerPairCount; i++)
{
jstring field = env->NewStringUTF(headerPairs[i] != nullptr ? headerPairs[i] : "");
env->SetObjectArrayElement(jheaders, (jsize) i, field);
if (field != nullptr)
env->DeleteLocalRef(field);
}
}
jstring jurl = env->NewStringUTF(url);
jstring jmethod = env->NewStringUTF(method != nullptr ? method : "GET");
// raw bytes across the bridge, as httpPost does: a request body is JSON
// carrying a base64 save, and a jstring would run it through modified UTF-8
jbyteArray jbody = nullptr;
if (body != nullptr && bodyLen >= 0)
{
jbody = env->NewByteArray((jsize) bodyLen);
if (jbody != nullptr && bodyLen > 0)
env->SetByteArrayRegion(jbody, 0, (jsize) bodyLen, (const jbyte*) body);
}
jstring jua = env->NewStringUTF(userAgent != nullptr ? userAgent : "gen1recomp");
jobject result = env->CallStaticObjectMethod(activity, method_id, jurl, jmethod,
jheaders, jbody, jua);
env->DeleteLocalRef(jurl);
env->DeleteLocalRef(jmethod);
if (jheaders != nullptr)
env->DeleteLocalRef(jheaders);
if (jbody != nullptr)
env->DeleteLocalRef(jbody);
env->DeleteLocalRef(jua);
env->DeleteLocalRef(activity);
if (result == nullptr)
return false;
jbyteArray bytes = (jbyteArray) result;
jsize length = env->GetArrayLength(bytes);
if (length > 0)
{
out.resize((size_t) length);
env->GetByteArrayRegion(bytes, 0, length, (jbyte*) &out[0]);
}
env->DeleteLocalRef(result);
return true;
}
/*
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
* own class, never FindClass -- and the same tolerance for an old APK: a
@@ -1390,4 +1553,32 @@ Java_org_love2d_android_GameActivity_nativeAudioDeviceChanged(JNIEnv *env, jclas
love::audio::openal::pushAudioResetEvent();
}
static void pushGameIntentEvent(const char *game)
{
auto eventmodule = love::Module::getInstance<love::event::Event>(love::Module::M_EVENT);
if (eventmodule == nullptr || game == nullptr)
return;
std::vector<love::Variant> args;
args.push_back(love::Variant(std::string(game)));
love::event::Message *msg = new love::event::Message("intent_game", args);
eventmodule->push(msg);
msg->release();
}
extern "C" JNIEXPORT void JNICALL
Java_org_love2d_android_GameActivity_nativeOnGameIntent(JNIEnv *env, jclass cls, jstring game)
{
(void) cls;
if (game == nullptr)
return;
const char *str = env->GetStringUTFChars(game, nullptr);
if (str != nullptr)
{
pushGameIntentEvent(str);
env->ReleaseStringUTFChars(game, str);
}
}
#endif // LOVE_ANDROID
@@ -90,6 +90,16 @@ bool syncHealthSteps();
**/
bool restartApp();
/**
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
**/
bool updateAppShortcuts(const std::vector<std::string> &versions);
/**
* Returns the game version requested via initial launch Intent (if any).
**/
std::string getLaunchGame();
/**
* Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has
* no curl binary, so this is the transport src/core/HostShell.lua uses there
@@ -106,6 +116,21 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
**/
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent);
/**
* Blocking HTTPS request with a method, headers and a byte body
* (GameActivity.httpRequest). What save sync needs and neither of the two
* above can give it: PUT, per-request auth headers, and the response body of
* a 4xx as well as a 2xx. headerPairs is a flat name, value array of
* headerPairCount entries; body/userAgent may be null. `out` receives the
* Java side's envelope -- a head line of "STATUS <code>" or "ERROR <text>",
* a newline, then the raw response bytes. False means the platform has no
* such bridge at all (an old APK under a newer liblove), which the Lua side
* reports as "update the app" rather than as a failed request.
**/
bool httpRequest(const char *url, const char *method,
const char *const *headerPairs, int headerPairCount,
const char *body, int bodyLen, const char *userAgent, std::string &out);
/**
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
@@ -245,6 +245,25 @@ bool System::restartApp() const
#endif
}
bool System::updateShortcuts(const std::vector<std::string> &versions) const
{
#ifdef LOVE_ANDROID
return love::android::updateAppShortcuts(versions);
#else
LOVE_UNUSED(versions);
return false;
#endif
}
std::string System::getLaunchGame() const
{
#ifdef LOVE_ANDROID
return love::android::getLaunchGame();
#else
return "";
#endif
}
bool System::httpDownload(const char *url, const char *destPath,
const char *userAgent, const char *accept) const
{
@@ -274,6 +293,27 @@ bool System::httpPost(const char *url, const char *body, int bodyLen,
#endif
}
bool System::httpRequest(const char *url, const char *method,
const char *const *headerPairs, int headerPairCount,
const char *body, int bodyLen, const char *userAgent,
std::string &out) const
{
#ifdef LOVE_ANDROID
return love::android::httpRequest(url, method, headerPairs, headerPairCount,
body, bodyLen, userAgent, out);
#else
LOVE_UNUSED(url);
LOVE_UNUSED(method);
LOVE_UNUSED(headerPairs);
LOVE_UNUSED(headerPairCount);
LOVE_UNUSED(body);
LOVE_UNUSED(bodyLen);
LOVE_UNUSED(userAgent);
out.clear();
return false;
#endif
}
int System::tlsOpen(const char *host, int port) const
{
#ifdef LOVE_ANDROID
@@ -143,6 +143,9 @@ public:
**/
virtual bool restartApp() const;
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
virtual std::string getLaunchGame() const;
/**
* Blocking HTTPS GET into an absolute host path (Android only; false
* elsewhere). Android has no curl, which is what every other platform
@@ -159,6 +162,18 @@ public:
virtual bool httpPost(const char *url, const char *body, int bodyLen,
const char *contentType = nullptr, const char *userAgent = nullptr) const;
/**
* Blocking HTTPS request with a method, headers and a byte body (Android
* only; false elsewhere). Save sync needs PUT, auth headers and the body
* of a 4xx, none of which the two bridges above can express. headerPairs
* is a flat name, value array; `out` receives the response envelope
* ("STATUS <code>" or "ERROR <text>", a newline, then the raw body).
**/
virtual bool httpRequest(const char *url, const char *method,
const char *const *headerPairs, int headerPairCount,
const char *body, int bodyLen, const char *userAgent,
std::string &out) const;
/**
* TLS client sockets (Android only; every call fails elsewhere, where
* LuaSec or another provider is the answer). Non-blocking by contract:
@@ -22,6 +22,9 @@
#include "wrap_System.h"
#include "sdl/System.h"
#include <string>
#include <vector>
namespace love
{
namespace system
@@ -150,6 +153,57 @@ int w_httpPost(lua_State *L)
return 1;
}
/*
* love.system.httpRequest(url, method, headers, body, userAgent) -> envelope
*
* `headers` is a flat array of alternating header name and value strings, so
* it maps straight onto the Java bridge's String[] without any parsing here.
* The single return is the response envelope -- a head line of
* "STATUS <code>" or "ERROR <text>", a newline, then the raw body -- or nil
* where the build has no bridge, which src/core/HostShell.lua turns into an
* "update the app" notice rather than a failed request.
*/
int w_httpRequest(lua_State *L)
{
const char *url = luaL_checkstring(L, 1);
const char *method = luaL_optstring(L, 2, "GET");
std::vector<std::string> fields;
if (!lua_isnoneornil(L, 3))
{
luaL_checktype(L, 3, LUA_TTABLE);
size_t count = luax_objlen(L, 3);
for (size_t i = 1; i <= count; i++)
{
lua_rawgeti(L, 3, (int) i);
const char *field = lua_tostring(L, -1);
fields.push_back(field != nullptr ? field : "");
lua_pop(L, 1);
}
}
std::vector<const char *> pairs;
for (size_t i = 0; i < fields.size(); i++)
pairs.push_back(fields[i].c_str());
size_t bodyLen = 0;
const char *body = nullptr;
if (!lua_isnoneornil(L, 4))
body = luaL_checklstring(L, 4, &bodyLen);
const char *ua = luaL_optstring(L, 5, nullptr);
std::string out;
bool ok = instance()->httpRequest(url, method,
pairs.empty() ? nullptr : &pairs[0], (int) pairs.size(),
body, (int) bodyLen, ua, out);
if (!ok)
{
lua_pushnil(L);
return 1;
}
lua_pushlstring(L, out.data(), out.size());
return 1;
}
int w_hasBackgroundMusic(lua_State *L)
{
lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -229,6 +283,34 @@ int w_tlsClose(lua_State *L)
return 0;
}
int w_updateShortcuts(lua_State *L)
{
if (!lua_istable(L, 1))
return luaL_error(L, "Expected table of game version strings");
std::vector<std::string> versions;
int len = (int) luax_objlen(L, 1);
for (int i = 1; i <= len; ++i)
{
lua_rawgeti(L, 1, i);
if (lua_isstring(L, -1))
versions.push_back(lua_tostring(L, -1));
lua_pop(L, 1);
}
luax_pushboolean(L, instance()->updateShortcuts(versions));
return 1;
}
int w_getLaunchGame(lua_State *L)
{
std::string game = instance()->getLaunchGame();
if (game.empty())
lua_pushnil(L);
else
luax_pushstring(L, game);
return 1;
}
static const luaL_Reg functions[] =
{
{ "getOS", w_getOS },
@@ -243,8 +325,11 @@ static const luaL_Reg functions[] =
{ "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp },
{ "updateShortcuts", w_updateShortcuts },
{ "getLaunchGame", w_getLaunchGame },
{ "httpDownload", w_httpDownload },
{ "httpPost", w_httpPost },
{ "httpRequest", w_httpRequest },
{ "tlsOpen", w_tlsOpen },
{ "tlsStatus", w_tlsStatus },
{ "tlsSend", w_tlsSend },
@@ -24,6 +24,7 @@ import org.libsdl.app.SDLActivity;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
@@ -36,6 +37,7 @@ import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import android.Manifest;
@@ -67,8 +69,11 @@ import android.os.Vibrator;
import android.provider.Settings;
import android.util.Log;
import android.util.DisplayMetrics;
import android.view.*;
import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutManager;
import android.content.pm.PackageManager;
import android.graphics.drawable.Icon;
import android.view.*;
import androidx.annotation.Keep;
import androidx.core.app.ActivityCompat;
@@ -157,6 +162,10 @@ public class GameActivity extends SDLActivity {
private static native void nativeAudioDeviceChanged();
private static native void nativeOnGameIntent(String game);
private static String initialGame = "";
private AudioManager.OnAudioFocusChangeListener audioFocusListener = null;
private Object audioFocusRequest = null;
private Object audioDeviceCallback = null;
@@ -226,6 +235,10 @@ public class GameActivity extends SDLActivity {
embed = getResources().getBoolean(R.bool.embed);
needToCopyGameInArchive = embed;
Intent startIntent = getIntent();
if (startIntent != null && startIntent.hasExtra("game")) {
initialGame = startIntent.getStringExtra("game");
}
if (!embed) {
Intent intent = getIntent();
handleIntent(intent);
@@ -259,6 +272,12 @@ public class GameActivity extends SDLActivity {
@Override
protected void onNewIntent(Intent intent) {
Log.d("GameActivity", "onNewIntent() with " + intent);
if (intent != null && intent.hasExtra("game")) {
String game = intent.getStringExtra("game");
if (game != null && !game.isEmpty()) {
nativeOnGameIntent(game);
}
}
if (!embed) {
handleIntent(intent);
resetNative();
@@ -379,11 +398,15 @@ public class GameActivity extends SDLActivity {
@Override
protected void onDestroy() {
secondaryHostResumed = false;
if (vibrator != null) {
Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel();
}
unregisterSecondaryDisplayListener();
teardownSecondaryDisplay();
secondaryEnabled = false;
synchronized (secondaryFrameLock) { secondaryFrame = null; }
unregisterAudioDeviceCallback();
abandonAudioFocus();
onHostDestroy();
@@ -392,6 +415,7 @@ public class GameActivity extends SDLActivity {
@Override
protected void onPause() {
secondaryHostResumed = false;
if (vibrator != null) {
Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel();
@@ -407,6 +431,7 @@ public class GameActivity extends SDLActivity {
@Override
public void onResume() {
super.onResume();
secondaryHostResumed = true;
onHostResume();
requestGameAudioFocus();
registerAudioDeviceCallback();
@@ -671,6 +696,95 @@ public class GameActivity extends SDLActivity {
return true; // unreachable, but keeps the JNI signature honest
}
@Keep
public static String getLaunchGame() {
return initialGame != null ? initialGame : "";
}
@Keep
public static boolean updateAppShortcuts(String[] readyVersions) {
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (android.os.Build.VERSION.SDK_INT < 25) return false;
try {
Context context = self.getApplicationContext();
ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class);
if (shortcutManager == null) return false;
if (readyVersions == null || readyVersions.length == 0) {
shortcutManager.removeAllDynamicShortcuts();
return true;
}
List<ShortcutInfo> shortcuts = new ArrayList<>();
int maxShortcuts = Math.min(readyVersions.length, 4);
for (int i = 0; i < maxShortcuts; i++) {
String ver = readyVersions[i];
if (ver == null || ver.isEmpty()) continue;
String lower = ver.toLowerCase();
String shortLabel;
String longLabel;
int iconResId;
switch (lower) {
case "red":
shortLabel = "Play Red";
longLabel = "Play Red";
iconResId = context.getResources().getIdentifier("ic_shortcut_red", "drawable", context.getPackageName());
break;
case "blue":
shortLabel = "Play Blue";
longLabel = "Play Blue";
iconResId = context.getResources().getIdentifier("ic_shortcut_blue", "drawable", context.getPackageName());
break;
case "yellow":
shortLabel = "Play Yellow";
longLabel = "Play Yellow";
iconResId = context.getResources().getIdentifier("ic_shortcut_yellow", "drawable", context.getPackageName());
break;
case "gold":
shortLabel = "Play Gold";
longLabel = "Play Gold";
iconResId = context.getResources().getIdentifier("ic_shortcut_gold", "drawable", context.getPackageName());
break;
default:
String capitalized = lower.substring(0, 1).toUpperCase() + lower.substring(1);
shortLabel = "Play " + capitalized;
longLabel = "Play " + capitalized;
iconResId = context.getResources().getIdentifier("ic_shortcut_" + lower, "drawable", context.getPackageName());
break;
}
if (iconResId == 0) {
iconResId = context.getResources().getIdentifier("ic_launcher_foreground", "drawable", context.getPackageName());
}
Intent intent = new Intent(context, GameActivity.class);
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra("game", lower);
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
ShortcutInfo.Builder builder = new ShortcutInfo.Builder(context, "shortcut_" + lower)
.setShortLabel(shortLabel)
.setLongLabel(longLabel)
.setIntent(intent);
if (iconResId != 0) {
builder.setIcon(Icon.createWithResource(context, iconResId));
}
shortcuts.add(builder.build());
}
shortcutManager.setDynamicShortcuts(shortcuts);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not update shortcuts: " + e.getMessage());
return false;
}
}
/**
* Blocking HTTPS GET into destPath, exposed as love.system.httpDownload
* and used by src/core/HostShell.lua. Android ships no curl binary, so
@@ -847,6 +961,149 @@ public class GameActivity extends SDLActivity {
}
}
/** Response ceiling for httpRequest; anything larger is refused, not buffered. */
private static final int HTTP_REQUEST_MAX_RESPONSE = 4 * 1024 * 1024;
/** Builds an httpRequest envelope: one head line, a newline, then the body. */
private static byte[] httpEnvelope(String head, byte[] payload) {
byte[] prefix;
try {
prefix = (head + "\n").getBytes("UTF-8");
} catch (Exception e) {
prefix = (head + "\n").getBytes();
}
if (payload == null || payload.length == 0) return prefix;
byte[] out = new byte[prefix.length + payload.length];
System.arraycopy(prefix, 0, out, 0, prefix.length);
System.arraycopy(payload, 0, out, prefix.length, payload.length);
return out;
}
/** One-line, CR/LF-free failure text, so an envelope head stays one line. */
private static String httpErrorText(Exception e) {
String text = e.getMessage();
if (text == null || text.length() == 0) text = e.getClass().getSimpleName();
text = text.replace('\r', ' ').replace('\n', ' ');
if (text.length() > 160) text = text.substring(0, 160);
return text;
}
/**
* Blocking HTTPS request with a chosen method, headers and byte body,
* exposed as love.system.httpRequest and used by src/core/HostShell.lua
* for save sync. Sync needs PUT, per-request auth headers and the response
* body of a 4xx as well as a 2xx (a conflict answers 409 with the save
* that won), none of which httpDownload or httpPost above can express.
*
* Same rules as those two: https only, redirects followed by hand
* (re-sending method and body on each hop), 15s connect / 60s read, and
* blocking on the Lua/worker thread -- never the UI thread. Headers arrive
* as a flat name, value array; a field carrying CR or LF is refused rather
* than sent, so a header value can never inject a second header.
*
* The reply is an envelope: a head line of "STATUS &lt;code&gt;" or
* "ERROR &lt;text&gt;", a newline, then the raw response bytes.
*/
@Keep
public static byte[] httpRequest(String url, String method, String[] headerPairs,
byte[] body, String userAgent) {
if (url == null) return httpEnvelope("ERROR missing url", null);
String verb = method == null ? "GET" : method.toUpperCase(Locale.US);
if (!"GET".equals(verb) && !"POST".equals(verb)
&& !"PUT".equals(verb) && !"DELETE".equals(verb)) {
return httpEnvelope("ERROR unsupported request method", null);
}
if (headerPairs != null) {
if ((headerPairs.length % 2) != 0) {
return httpEnvelope("ERROR bad request header", null);
}
for (int i = 0; i < headerPairs.length; i++) {
String field = headerPairs[i];
if (field == null) return httpEnvelope("ERROR bad request header", null);
if (field.indexOf('\r') >= 0 || field.indexOf('\n') >= 0) {
return httpEnvelope("ERROR bad request header", null);
}
if ((i % 2) == 0 && field.length() == 0) {
return httpEnvelope("ERROR bad request header", null);
}
}
}
HttpURLConnection conn = null;
try {
String current = url;
for (int hop = 0; hop < 5; hop++) {
URL parsed = new URL(current);
if (!"https".equalsIgnoreCase(parsed.getProtocol())) {
return httpEnvelope("ERROR https only", null);
}
conn = (HttpURLConnection) parsed.openConnection();
conn.setInstanceFollowRedirects(false);
conn.setConnectTimeout(15000);
conn.setReadTimeout(60000);
conn.setRequestMethod(verb);
conn.setRequestProperty("User-Agent",
userAgent == null ? "gen1recomp" : userAgent);
if (headerPairs != null) {
for (int i = 0; i + 1 < headerPairs.length; i += 2) {
conn.setRequestProperty(headerPairs[i], headerPairs[i + 1]);
}
}
if (body != null && !"GET".equals(verb)) {
conn.setDoOutput(true);
conn.setFixedLengthStreamingMode(body.length);
OutputStream out = new BufferedOutputStream(conn.getOutputStream());
try {
out.write(body);
} finally {
try { out.close(); } catch (IOException ignored) {}
}
}
int code = conn.getResponseCode();
if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) {
String next = conn.getHeaderField("Location");
conn.disconnect();
conn = null;
if (next == null) {
return httpEnvelope("ERROR redirect without a location", null);
}
current = new URL(parsed, next).toString();
continue;
}
// A rejection's body is the diagnosis the caller wants, so 4xx
// and 5xx are read through getErrorStream rather than dropped.
InputStream in;
try {
in = conn.getInputStream();
} catch (IOException e) {
in = conn.getErrorStream();
}
ByteArrayOutputStream sink = new ByteArrayOutputStream();
if (in != null) {
InputStream reader = new BufferedInputStream(in);
try {
byte[] buf = new byte[16384];
int n;
while ((n = reader.read(buf)) > 0) {
if (sink.size() + n > HTTP_REQUEST_MAX_RESPONSE) {
return httpEnvelope("ERROR the reply was too large", null);
}
sink.write(buf, 0, n);
}
} finally {
try { reader.close(); } catch (IOException ignored) {}
}
}
return httpEnvelope("STATUS " + code, sink.toByteArray());
}
return httpEnvelope("ERROR too many redirects", null);
} catch (Exception e) {
Log.d("GameActivity", "httpRequest failed: " + e.getMessage());
return httpEnvelope("ERROR " + httpErrorText(e), null);
} finally {
if (conn != null) conn.disconnect();
}
}
/**
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive /
@@ -1682,6 +1939,7 @@ public class GameActivity extends SDLActivity {
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
private static volatile long secondaryRetryAfter;
private static volatile boolean secondaryEnabled = false;
private static volatile boolean secondaryHostResumed = false;
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
private static volatile int dualScreenDisplayMode = -1;
private static volatile byte[] secondaryFrame;
@@ -1712,7 +1970,7 @@ public class GameActivity extends SDLActivity {
if (self == null) return;
self.runOnUiThread(new Runnable() {
@Override public void run() {
if (on) {
if (on && secondaryHostResumed) {
self.refreshDualScreenDisplayMode();
self.registerSecondaryDisplayListener();
rebindSecondaryDisplay();
@@ -1784,9 +2042,11 @@ public class GameActivity extends SDLActivity {
private static void rebindSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return;
if (self == null || !secondaryHostResumed || !secondaryEnabled
|| secondaryOutputIsPreferred(self)) return;
self.runOnUiThread(() -> {
if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return;
if (!secondaryHostResumed || !secondaryEnabled
|| secondaryOutputIsPreferred(self)) return;
teardownSecondaryDisplay();
setupSecondaryDisplay();
});
@@ -1794,7 +2054,8 @@ public class GameActivity extends SDLActivity {
private static void setupSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryPresentation != null
if (self == null || !secondaryHostResumed || !secondaryEnabled
|| secondaryPresentation != null
|| secondaryActivity != null || secondaryActivityPending
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
try {
+42
View File
@@ -12,6 +12,48 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.2.12",
"date": "2026-08-20",
"size": 13737259,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.12/gen1recomp++-0.2.12-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1582 Sync not working between steamdeck and windows\n- #1583 Cant sync between iOS and windows\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @thibautbus"
},
{
"version": "0.2.11",
"date": "2026-08-20",
"size": 13735190,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.11/gen1recomp++-0.2.11-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #393 silph co. npc missing some dialogue\n- #1600 allow my uncle's neighbor to sit at the big kids table\n- #1603 pocket taco - type option \"screen position\"\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @dburton95\n- @mleo2003\n- @thibautbus"
},
{
"version": "0.2.10",
"date": "2026-08-19",
"size": 13662645,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.10/gen1recomp++-0.2.10-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #998 Jingles not finishing before game proceeds\n- #1472 Message for sending out Pokemon not closing automatically\n- #1526 No screen shake when getting poisoned\n- #1529 Messages missing when interacting with PC\n- #1530 No message for interacting with bikes in the bike shop\n- #1532 Thrash animation incomplete\n- #1534 Dialogue missing when switching out Pokemon\n- #1547 Save states can be used to bypass certain NPCs\n- #1549 Menu Cartridge 3D model has visual issues\n- #1550 Nugget Bridge Rocket repeating dialogue\n- #1551 No scripted dialogue after beating Nugget Bridge Rocket\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian"
},
{
"version": "0.2.9",
"date": "2026-08-19",
"size": 13656293,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.9/gen1recomp++-0.2.9-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.8",
"date": "2026-08-19",
"size": 13653911,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.8/gen1recomp++-0.2.8-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1502 Gold doesn't show trainer balls\n- #1533 Retroarch Skin Problem 2 (#1503)\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @castdrian\n- @thibautbus"
},
{
"version": "0.2.7",
"date": "2026-08-18",
"size": 13597177,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.7/gen1recomp++-0.2.7-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1005 (Android) Screen record mutes the game\n- #1291 Audio Crash\n- #1310 Incoming call crashes G1R\n- #1471 [Gold] #1117 still not fixed\n- #1528 Surfing Minigame doesn't play as intended\n- #1537 Shellder and Corsola missing from Rod encounter tables\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @castdrian"
},
{
"version": "0.2.6",
"date": "2026-08-18",
+21
View File
@@ -6,6 +6,27 @@
// Registered from a constructor so no LÖVE/SDL source needs to know about it.
#import <UIKit/UIKit.h>
#import <sys/utsname.h>
@interface GRDeviceBridge : NSObject
+ (NSString *)deviceModel;
@end
@implementation GRDeviceBridge
+ (NSString *)deviceModel
{
#if TARGET_OS_SIMULATOR
NSString *simulatorModel = NSProcessInfo.processInfo.environment[@"SIMULATOR_MODEL_IDENTIFIER"];
if (simulatorModel.length > 0) return simulatorModel;
#endif
struct utsname systemInfo;
if (uname(&systemInfo) == 0) {
NSString *model = [NSString stringWithUTF8String:systemInfo.machine];
if (model.length > 0) return model;
}
return @"";
}
@end
__attribute__((constructor))
static void GRBootstrapInstall(void)
+118
View File
@@ -67,6 +67,124 @@ public final class GRPickerBridge: NSObject {
return succeeded
}
// MARK: - General HTTP request (love.system.httpRequest)
private static let httpMaxResponse = 4 * 1024 * 1024
// URLSession turns a 301/302/303 POST into a GET on its own. Save sync
// signs a method and a body, so every hop re-sends the original request
// against the new URL instead, and only over https.
private final class GRRedirectKeeper: NSObject, URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
guard let original = task.originalRequest,
let target = request.url,
target.scheme?.lowercased() == "https" else {
completionHandler(nil)
return
}
var next = original
next.url = target
completionHandler(next)
}
}
private static let httpSession = URLSession(configuration: .ephemeral,
delegate: GRRedirectKeeper(),
delegateQueue: nil)
private static func httpEnvelope(_ head: String, _ payload: Data?) -> NSData {
var out = Data((head + "\n").utf8)
if let payload { out.append(payload) }
return out as NSData
}
private static func httpErrorText(_ error: Error) -> String {
var text = error.localizedDescription
.replacingOccurrences(of: "\r", with: " ")
.replacingOccurrences(of: "\n", with: " ")
if text.isEmpty { text = "the request failed" }
if text.count > 160 { text = String(text.prefix(160)) }
return text
}
/// Blocking HTTPS request with a chosen method, headers and byte body, the
/// iOS half of love.system.httpRequest (see the Android GameActivity one).
/// Headers arrive as "name: value" lines joined by newlines. The reply is
/// an envelope: a head line of "STATUS <code>" or "ERROR <text>", a
/// newline, then the raw response bytes -- read for 4xx and 5xx as well,
/// because a sync conflict answers 409 with the save that won.
@objc(httpRequestWithUrl:method:headers:body:bodyLength:userAgent:)
public static func httpRequest(url: UnsafePointer<CChar>?,
method: UnsafePointer<CChar>?,
headers: UnsafePointer<CChar>?,
body: UnsafePointer<UInt8>?,
bodyLength: Int32,
userAgent: UnsafePointer<CChar>?) -> NSData? {
guard let url, let requestURL = URL(string: String(cString: url)) else {
return httpEnvelope("ERROR missing url", nil)
}
guard requestURL.scheme?.lowercased() == "https" else {
return httpEnvelope("ERROR https only", nil)
}
let verb = (method.map { String(cString: $0) } ?? "GET").uppercased()
guard ["GET", "POST", "PUT", "DELETE"].contains(verb) else {
return httpEnvelope("ERROR unsupported request method", nil)
}
var request = URLRequest(url: requestURL)
request.httpMethod = verb
request.timeoutInterval = 60
request.setValue(userAgent.map { String(cString: $0) } ?? "gen1recomp",
forHTTPHeaderField: "User-Agent")
if let headers, headers.pointee != 0 {
for line in String(cString: headers).split(separator: "\n") {
guard let colon = line.firstIndex(of: ":") else {
return httpEnvelope("ERROR bad request header", nil)
}
let name = line[line.startIndex..<colon]
.trimmingCharacters(in: .whitespaces)
let value = line[line.index(after: colon)...]
.trimmingCharacters(in: .whitespaces)
if name.isEmpty {
return httpEnvelope("ERROR bad request header", nil)
}
request.setValue(value, forHTTPHeaderField: name)
}
}
if verb != "GET", let body, bodyLength > 0 {
request.httpBody = Data(bytes: body, count: Int(bodyLength))
}
let semaphore = DispatchSemaphore(value: 0)
var envelope = httpEnvelope("ERROR no response", nil)
let task = httpSession.dataTask(with: request) { data, response, error in
defer { semaphore.signal() }
if let error {
envelope = httpEnvelope("ERROR " + httpErrorText(error), nil)
return
}
guard let http = response as? HTTPURLResponse else {
envelope = httpEnvelope("ERROR no response", nil)
return
}
let payload = data ?? Data()
if payload.count > httpMaxResponse {
envelope = httpEnvelope("ERROR the reply was too large", nil)
return
}
envelope = httpEnvelope("STATUS \(http.statusCode)", payload)
}
task.resume()
guard semaphore.wait(timeout: .now() + 65) == .success else {
task.cancel()
return httpEnvelope("ERROR the request timed out", nil)
}
return envelope
}
// MARK: - Entry points called from liblove (C strings on purpose)
@objc(presentPickerWithKind:saveDir:)
+109 -2
View File
@@ -9,7 +9,8 @@ What it does:
1. Copies mobile/ios/native/ (GRPickerBridge.swift, GRHealthBridge.swift,
GRBootstrap.m) and the HealthKit entitlements into the LÖVE tree.
2. Patches liblove's wrap_System.cpp to expose love.system.pickFile,
love.system.createFile, and love.system.syncHealthSteps on iOS (each
love.system.createFile, love.system.syncHealthSteps,
love.system.httpDownload and love.system.httpRequest on iOS (each
calls a GR*Bridge Swift class through the Objective-C runtime, so
liblove never links against Swift directly).
3. Patches love.xcodeproj so the love-ios app target compiles the native
@@ -50,6 +51,7 @@ WRAP_INCLUDES = """
#include <objc/runtime.h>
#include <objc/message.h>
#include <string>
#include <vector>
#include "filesystem/Filesystem.h"
#endif
""" % MARKER
@@ -154,11 +156,13 @@ int w_syncHealthSteps(lua_State *L)
""" % MARKER
WRAP_REGISTRATION = """#ifdef LOVE_IOS
{ "getDeviceModel", w_getDeviceModel },
{ "pickFile", w_pickFile },
{ "pickFileKinds", w_pickFileKinds },
{ "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload },
{ "httpRequest", w_httpRequest },
#endif
"""
@@ -199,13 +203,42 @@ int w_syncHealthSteps(lua_State *L)
"""
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
{ "getDeviceModel", w_getDeviceModel },
{ "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload },
{ "httpRequest", w_httpRequest },
#endif
"""
BRIDGE_EXTRA_FUNCS = """
#ifdef LOVE_IOS
int w_getDeviceModel(lua_State *L)
{
Class cls = objc_getClass("GRDeviceBridge");
if (cls == nullptr)
{
lua_pushnil(L);
return 1;
}
typedef id (*GRObj)(Class, SEL);
id value = ((GRObj)objc_msgSend)(cls, sel_registerName("deviceModel"));
if (value == nullptr)
{
lua_pushnil(L);
return 1;
}
typedef const char *(*GRUTF8)(id, SEL);
const char *bytes = ((GRUTF8)objc_msgSend)(value,
sel_registerName("UTF8String"));
if (bytes == nullptr || bytes[0] == '\\0')
{
lua_pushnil(L);
return 1;
}
lua_pushstring(L, bytes);
return 1;
}
int w_httpDownload(lua_State *L)
{
const char *url = luaL_checkstring(L, 1);
@@ -226,6 +259,80 @@ int w_httpDownload(lua_State *L)
lua_pushboolean(L, ok != 0);
return 1;
}
// love.system.httpRequest(url, method, headers, body, userAgent) -> envelope
//
// The transport save sync needs: a chosen method, per-request auth headers,
// and the response body of a 4xx as well as a 2xx. `headers` is a flat array
// of alternating name and value strings, joined into "name: value" lines here
// because the Swift bridge takes C strings and no Foundation type may be
// NAMED in this translation unit (see w_pickFileKinds above).
//
// The single return is the response envelope -- a head line of
// "STATUS <code>" or "ERROR <text>", a newline, then the raw body -- or nil
// where the build carries no bridge at all, which src/core/HostShell.lua
// turns into an "update the app" notice rather than a failed request.
int w_httpRequest(lua_State *L)
{
const char *url = luaL_checkstring(L, 1);
const char *method = luaL_optstring(L, 2, "GET");
std::string headerBlob;
if (!lua_isnoneornil(L, 3))
{
luaL_checktype(L, 3, LUA_TTABLE);
std::vector<std::string> fields;
size_t count = luax_objlen(L, 3);
for (size_t i = 1; i <= count; i++)
{
lua_rawgeti(L, 3, (int) i);
const char *field = lua_tostring(L, -1);
fields.push_back(field != nullptr ? field : "");
lua_pop(L, 1);
}
for (size_t i = 0; i + 1 < fields.size(); i += 2)
headerBlob += fields[i] + ": " + fields[i + 1] + "\\n";
}
size_t bodyLen = 0;
const char *body = nullptr;
if (!lua_isnoneornil(L, 4))
body = luaL_checklstring(L, 4, &bodyLen);
const char *ua = luaL_optstring(L, 5, "gen1recomp");
Class cls = objc_getClass("GRPickerBridge");
if (cls == nullptr)
{
lua_pushnil(L);
return 1;
}
typedef id (*GRRequest)(Class, SEL, const char *, const char *,
const char *, const unsigned char *, int,
const char *);
id reply = ((GRRequest)objc_msgSend)(
cls,
sel_registerName("httpRequestWithUrl:method:headers:body:bodyLength:userAgent:"),
url, method, headerBlob.c_str(), (const unsigned char *) body,
(int) bodyLen, ua);
if (reply == nullptr)
{
lua_pushnil(L);
return 1;
}
// NSData read through the runtime, for the same reason as above: the
// bytes are copied out immediately, before any autorelease pool drains.
typedef const void *(*GRBytes)(id, SEL);
typedef unsigned long (*GRLength)(id, SEL);
const void *bytes = ((GRBytes)objc_msgSend)(reply, sel_registerName("bytes"));
unsigned long length = ((GRLength)objc_msgSend)(reply, sel_registerName("length"));
if (bytes == nullptr || length == 0)
{
lua_pushnil(L);
return 1;
}
lua_pushlstring(L, (const char *) bytes, (size_t) length);
return 1;
}
#endif
"""
@@ -308,7 +415,7 @@ def patch_wrap_system():
text = text.replace(reg_anchor, reg_anchor + registration, 1)
WRAP_SYSTEM.write_text(text)
print("patch_love_src: wrap_System.cpp patched "
"(pickFile/createFile/syncHealthSteps/httpDownload)")
"(pickFile/createFile/syncHealthSteps/httpDownload/httpRequest)")
def patch_public_documents():

Before

Width:  |  Height:  |  Size: 318 B

After

Width:  |  Height:  |  Size: 318 B

Before

Width:  |  Height:  |  Size: 687 B

After

Width:  |  Height:  |  Size: 687 B

@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats.
- Answers written to `mod.save` via `intro.oak_speech.answered`.
- Custom `toast_kid.png` sprite shown mid-speech.
+43
View File
@@ -0,0 +1,43 @@
# Silly Oak Intro Example
Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival,
player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`.
## Try it (play through yourself)
```sh
rm -rf mods/example_silly_oak
cp -r mods/examples/example_silly_oak mods/
love .
```
Then **NEW GAME** and mash A / pick the menus. Disable or delete
`mods/example_silly_oak` when you're done so vanilla boots clean.
## Headless check
```sh
luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
```
## Auto driver (screenshots + save asserts)
```sh
rm -rf mods/example_silly_oak
cp -r mods/examples/example_silly_oak mods/
SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \
POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love .
```
`POKEPORT_IDENTITY` keeps this run's save out of your normal slot.
## What it demonstrates
| Seam | Where |
|---|---|
| `hooks:wrap("intro.oak_speech.build")` | `main.lua` -- reshape the step list |
| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` -- anchored on vanilla step ids |
| step kinds `say` / `yesno` / `choice` | `main.lua` |
| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` |
| `events:on("intro.oak_speech.answered")` | `main.lua``mod.save` |
| `events:on("intro.oak_speech.finished")` | `main.lua` |
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

+107
View File
@@ -0,0 +1,107 @@
-- Gallery entry: reshape Oak's intro speech with extra questions, sprite
-- swaps (oak / rival / player / pokemon / a custom image), and answers
-- that land in mod.save.
--
-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered.
return function(mod)
local toastPic = mod.path .. "/assets/toast_kid.png"
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
steps = next(steps, speech)
-- after oak says hello, immediately derail
mod.ui.insertStepAfter(steps, "oak_welcome", {
id = "silly_quiz_intro",
kind = "say",
pic = "oak",
text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.",
})
mod.ui.insertStepAfter(steps, "silly_quiz_intro", {
id = "silly_toast",
kind = "yesno",
pic = "oak",
saveKey = "likes_toast",
text = "Do you like\ntoast?",
})
-- brand new sprite mid-speech
mod.ui.insertStepAfter(steps, "silly_toast", {
id = "silly_toast_kid",
kind = "say",
pic = { type = "image", path = toastPic },
reveal = "fade",
saveKey = nil,
text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.",
})
-- existing mon with a wipe + cry, parked after the real demo mon
mod.ui.insertStepAfter(steps, "demo_mon", {
id = "silly_mew",
kind = "say",
pic = { type = "pokemon", id = "MEW" },
reveal = "wipe",
cry = "MEW",
text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.",
})
mod.ui.insertStepAfter(steps, "silly_mew", {
id = "silly_snack",
kind = "choice",
pic = "oak",
saveKey = "snack",
text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.",
choices = { "BERRIES", "LEFTOVERS", "OLD ROD" },
})
-- swap to rival pic for a loaded question before naming him
mod.ui.insertStepBefore(steps, "ask_rival_name", {
id = "silly_trust",
kind = "choice",
pic = "rival",
reveal = "fade",
saveKey = "trusts_rival",
text = "Look at this kid.\nTrustworthy?",
choices = { "SURE", "NO" },
values = { true, false },
})
-- player pic for one last bit after both names are set
mod.ui.insertStepAfter(steps, "name_rival", {
id = "silly_pineapple",
kind = "yesno",
pic = "player",
saveKey = "pineapple_on_pizza",
text = "{PLAYER}. Be honest.\nPineapple on\vpizza?",
})
mod.ui.insertStepAfter(steps, "silly_pineapple", {
id = "silly_closing",
kind = "say",
pic = "oak",
text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.",
})
return steps
end)
-- every answered step with a saveKey lands in mod.save (and therefore
-- save.modData[mod.id] once the slot is written)
mod.events:on("intro.oak_speech.answered", function(ev)
if not ev.saveKey then return end
mod.save:set(ev.saveKey, ev.value)
mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value))
end)
mod.events:on("intro.oak_speech.finished", function(ev)
local answers = ev.answers or {}
for key, value in pairs(answers) do
if mod.save:get(key) == nil then
mod.save:set(key, value)
end
end
mod.save:set("quiz_done", true)
mod.log:info("silly oak quiz done")
end)
end
@@ -0,0 +1,15 @@
{
"id": "example_silly_oak",
"name": "Silly Oak Intro Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "UI",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save."
}
+21
View File
@@ -0,0 +1,21 @@
-- Sharing metadata for the manager detail pane.
return {
summary = "Oak asks dumb questions during the intro and remembers your answers.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "intro", "ui", "oak", "hooks" },
differences = {
changed = {
"Oak's NEW GAME speech gains extra questions and sprite beats",
},
added = {
"mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done",
"Custom Toast Kid pic mid-intro",
},
known = { "vanilla naming and the shrink-away still run" },
},
credits = {
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" },
},
compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,159 @@
-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors,
-- and answers landing in mod.save.
--
-- Needs an imported ROM dataset (data/generated/). Headless CI and a
-- fresh checkout without a ROM skip cleanly -- the gallery is also
-- covered by tests/mod_examples_tests.lua when generated data is present.
package.path = "./?.lua;./?/init.lua;" .. package.path
local function hasGenerated()
local handle = io.open("data/generated/constants.lua", "r")
if handle then handle:close() return true end
return false
end
if not hasGenerated() then
print("example_silly_oak_test skipped (needs data/generated/)")
os.exit(0)
end
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local OakSpeech = require("src.ui.OakSpeech")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
local mod = run.mod
T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state")
local ModUI = require("src.ui.ModUI")
local bucket = function()
return run.loader.modSave.example_silly_oak or {}
end
local toastPath = (mod.path or "mods/examples/example_silly_oak")
.. "/assets/toast_kid.png"
-- ------- build hook injects every silly beat around vanilla anchors
local speech = OakSpeech.new({
data = Data,
save = { player = { name = "RED", rival = "BLUE" } },
stack = { push = function() end, pop = function() end },
}, nil)
local steps = speech:buildSteps()
local ids = {}
for _, step in ipairs(steps) do ids[#ids + 1] = step.id end
local function has(id)
for _, x in ipairs(ids) do if x == id then return true end end
return false
end
T.check(has("oak_welcome") and has("name_player") and has("shrink"),
"vanilla anchors still present")
T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"),
"toast quiz beats injected")
T.check(has("silly_mew") and has("silly_snack"),
"MEW reveal and snack choice injected")
T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"),
"rival trust + pineapple beats injected")
-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask
local function indexOf(id)
for i, x in ipairs(ids) do if x == id then return i end end
return 0
end
T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"),
"Toast Kid shows before the demo mon")
T.check(indexOf("demo_mon") < indexOf("silly_mew"),
"MEW shows after the demo mon")
T.check(indexOf("silly_trust") < indexOf("ask_rival_name"),
"trust question is before rival naming")
T.check(indexOf("name_rival") < indexOf("silly_pineapple")
and indexOf("silly_pineapple") < indexOf("legend"),
"pineapple lands between rival name and the legend beat")
-- ------- step shapes cover choice / yesno / custom image / pokemon
local byId = {}
for _, step in ipairs(steps) do byId[step.id] = step end
T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no")
T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast")
T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice")
T.eq(#byId.silly_snack.choices, 3, "snack has three options")
T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image",
"Toast Kid uses a custom image pic")
T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon"
and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW",
"MEW beat uses pokemon pic + cry")
T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic")
-- ------- resolvePic covers trainer / pokemon / player / image shorthand
local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech)
local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech)
local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech)
local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data },
{ type = "pokemon", id = "MEW", flip = true }, speech)
local customImg = OakSpeech.resolvePic({ data = Data },
{ type = "image", path = toastPath }, speech)
-- headless love stub may return nil images; the call itself must not throw
T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata"
or type(oakImg) == "table",
"oak shorthand resolves without error")
T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata"
or type(rivalImg) == "table",
"rival shorthand resolves without error")
T.check(playerImg == speech.playerPic or playerImg == nil
or type(playerImg) == "userdata" or type(playerImg) == "table",
"player shorthand resolves without error")
T.check(mewFlip == true, "pokemon flip flag is honored")
T.check(customImg ~= nil or true, "custom image path is accepted")
-- ------- answered event writes mod.save (loader.modSave bucket)
Runtime.emit("intro.oak_speech.answered", {
saveKey = "likes_toast", value = true, label = "YES", index = 1,
step = byId.silly_toast, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3,
step = byId.silly_snack, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "trusts_rival", value = false, label = "NO", index = 2,
step = byId.silly_trust, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1,
step = byId.silly_pineapple, speech = speech,
})
Runtime.emit("intro.oak_speech.finished", {
speech = speech, answers = speech.answers,
})
local saved = bucket()
T.eq(saved.likes_toast, true, "likes_toast saved")
T.eq(saved.snack, "OLD ROD", "snack saved")
T.eq(saved.trusts_rival, false, "trusts_rival saved")
T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved")
T.eq(saved.quiz_done, true, "quiz_done stamped on finish")
-- ------- ModUI step helpers (public surface)
local tiny = {
{ id = "a", kind = "say" },
{ id = "b", kind = "say" },
}
ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" })
T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor")
ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" })
T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor")
ModUI.removeStep(tiny, "mid")
T.check(tiny[2].id ~= "mid", "removeStep drops by id")
run.release()
T.finish("example_silly_oak")
+21 -2
View File
@@ -6,6 +6,7 @@
#
# Usage: scripts/build.sh [mac|win|linux|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
# [--notary-profile NAME] [--no-notarize]
# [--game-love PATH] # fuse a prebuilt payload (scripts/pack_love.sh) instead of packing one
# [--release] # ios only: release config instead of debug
#
# Output: dist/mac/gen1recomp-macos.zip
@@ -36,6 +37,7 @@ NOTARY_PROFILE="notary-profile"
NOTARIZE=true
IOS_RELEASE=false
IOS_IPA=false
GAME_LOVE_IN=""
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
@@ -48,6 +50,7 @@ while [ $# -gt 0 ]; do
--identity) IDENTITY="$2"; shift ;;
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
--no-notarize) NOTARIZE=false ;;
--game-love) GAME_LOVE_IN="${2:?--game-love needs a path}"; shift ;;
--release) IOS_RELEASE=true ;;
--ipa) IOS_IPA=true ;;
*) fail "unknown argument: $1" ;;
@@ -62,16 +65,23 @@ mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
# launcher's Edit button on a save row opens it in-process (main.lua), and
# `--editor` / POKEPORT_EDITOR=1 opens it standalone. It is required through
# love.filesystem's require path, so it has to live inside the archive.
say "packing game.love"
LOVE_FILE="$WORK/game.love"
rm -f "$LOVE_FILE"
if [ -n "$GAME_LOVE_IN" ]; then
[ -f "$GAME_LOVE_IN" ] || fail "--game-love: no such file: $GAME_LOVE_IN"
say "using prebuilt payload: $GAME_LOVE_IN"
cp "$GAME_LOVE_IN" "$LOVE_FILE"
else
say "packing game.love"
# The launcher UI kit lives at src/ui/kit (inside src/, packed wholesale);
# the vendored libs/flexlove tree it replaced is gone.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
fi
# Materialize the listing once and grep the file: piping unzip straight into
# 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
@@ -90,7 +100,8 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
tools/save-editor/panels/Party.lua \
src/ui/kit/Kit.lua \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json; do
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json; do
grep -qxF "$required" "$LOVE_LISTING" \
|| fail "game.love is missing $required"
done
@@ -105,6 +116,13 @@ say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
# mistaken for a release. The stamp is then read back out of the archive and the
# build fails if it did not take.
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
if [ -n "$GAME_LOVE_IN" ]; then
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
unzip -p "$LOVE_FILE" src/core/Version.lua \
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|| fail "prebuilt payload does not report engine $VERSION (pack it with pack_love.sh --version $VERSION)"
say "prebuilt payload already stamped: $VERSION"
else
say "stamping engine version $VERSION into game.love"
stamp_dir="$WORK/stamp"
rm -rf "$stamp_dir"
@@ -117,6 +135,7 @@ if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|| fail "version stamp failed: game.love does not report engine $VERSION"
say "stamped engine version: $VERSION"
fi
else
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
fi
+54
View File
@@ -30,6 +30,8 @@ YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}"
SILVER_MANIFEST_RELATIVE="tools/rom_manifest_silver.json"
SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_silver.json}"
VERSION=""
PACKAGE_ONLY=false
@@ -177,6 +179,54 @@ ensure_gold_manifest() {
fail "Gold import manifest is unavailable. Git recovery failed and could not download $GOLD_MANIFEST_URL"
}
silver_manifest_is_valid() {
local path="$1"
python3 - "$path" <<'PY'
import json, pathlib, sys
try:
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
except (OSError, ValueError):
raise SystemExit(1)
raise SystemExit(0 if manifest.get("romSha1") ==
"49b163f7e57702bc939d642a18f591de55d92dae" else 1)
PY
}
ensure_silver_manifest() {
local manifest="$ROOT/$SILVER_MANIFEST_RELATIVE"
local staged
staged="$(mktemp)"
if silver_manifest_is_valid "$manifest"; then
rm -f "$staged"
return
fi
warn "Silver import manifest is missing or invalid; recovering it before packaging"
if git -C "$ROOT" show "HEAD:$SILVER_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \
&& silver_manifest_is_valid "$staged"; then
mkdir -p "$(dirname "$manifest")"
mv "$staged" "$manifest"
say "restored Silver import manifest from this checkout's Git data"
return
fi
if command -v curl >/dev/null 2>&1 \
&& curl --fail --location --retry 2 --connect-timeout 15 \
--output "$staged" "$SILVER_MANIFEST_URL" \
&& silver_manifest_is_valid "$staged"; then
mkdir -p "$(dirname "$manifest")"
mv "$staged" "$manifest"
say "downloaded Silver import manifest from the project repository"
return
fi
rm -f "$staged"
fail "Silver import manifest is unavailable. Git recovery failed and could not download $SILVER_MANIFEST_URL"
}
# --------------------------------------------------------------- branding
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
# Manifest still gets permission trims. Re-applied every build so refreshing
@@ -242,6 +292,7 @@ pack_game_love() {
say "packing game.love for love-android embed flavor"
ensure_yellow_manifest
ensure_gold_manifest
ensure_silver_manifest
mkdir -p "$EMBED_ASSETS"
rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save
@@ -256,6 +307,7 @@ pack_game_love() {
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
# List once and match against the captured text: piping unzip straight into
@@ -277,6 +329,8 @@ pack_game_love() {
|| fail "game.love is missing the Yellow ROM import manifest"
grep -qx 'tools/rom_manifest_gold.json' <<< "$archive_entries" \
|| fail "game.love is missing the Gold ROM import manifest"
grep -qx 'tools/rom_manifest_silver.json' <<< "$archive_entries" \
|| fail "game.love is missing the Silver ROM import manifest"
# This gate exists because the launcher's UI toolkit once lived outside
# src/ (libs/flexlove) and was added to scripts/build.sh's payload and to
# no other packager, so Android and iOS built an APK/IPA whose launcher
+2
View File
@@ -366,6 +366,8 @@ cp "$IN/game.love" "$APPDIR/game.love"
unzip -Z1 "$APPDIR/game.love" > "$WORK/love-listing.txt"
grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \
|| fail "game.love is missing tools/rom_manifest_gold.json"
grep -qxF "tools/rom_manifest_silver.json" "$WORK/love-listing.txt" \
|| fail "game.love is missing tools/rom_manifest_silver.json"
# The .desktop's Icon= resolves against the AppDir root by basename, and
# .DirIcon is what appimaged and file-manager thumbnailers read.
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
@@ -169,5 +169,7 @@ unzip -p "$temp_dir/game.love" src/core/Version.lua \
|| fail "shared payload version was not stamped"
grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|| fail "shared payload is missing tools/rom_manifest_gold.json"
grep -qxF "tools/rom_manifest_silver.json" "$temp_dir/love-listing.txt" \
|| fail "shared payload is missing tools/rom_manifest_silver.json"
say "Linux arm64 self-test passed"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Verifies a built arm64 AppImage is self-contained and bullseye-compatible.
# Usage: scripts/linux-arm64/verify_appimage.sh <AppImage>
set -euo pipefail
image="${1:?usage: verify_appimage.sh <AppImage>}"
[ -f "$image" ] || { echo "::error::no such AppImage: $image"; exit 1; }
image="$(cd "$(dirname "$image")" && pwd)/$(basename "$image")"
workdir="$(mktemp -d)"
trap 'rm -rf "$workdir"' EXIT
cd "$workdir"
# --appimage-extract needs no FUSE, so this works on a runner
# without /dev/fuse and still exercises the real payload.
"$image" --appimage-extract >/dev/null
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
[ -e "squashfs-root/$required" ] \
|| { echo "::error::AppImage is missing $required"; exit 1; }
done
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
# applied; an unresolved soname here is a user-visible launch crash.
#
# This runs on a HEADLESS runner on purpose, and that is the point.
# The first version of this build bundled Debian's SDL2, which
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
# started on a full desktop -- a bare runner is what exposed it.
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
| grep 'not found' || true)"
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
# Nothing may hard-link a driver, session or audio-stack library:
# those must be reached through dlopen so the AppImage runs on a box
# with only ALSA, only Wayland, or only KMSDRM.
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
[ -z "$linked" ] \
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
# The whole point of compiling on bullseye. If a future change moves
# the builder to a newer base, the glibc floor silently rises and
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
# it here instead of in a release.
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
echo "highest required glibc symbol version: $floor"
[ -n "$floor" ] \
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
[ "$highest" = "GLIBC_2.31" ] \
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
echo "AppImage verified: $image"
+2
View File
@@ -50,6 +50,7 @@ rm -f "$OUTPUT"
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
@@ -94,6 +95,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
tools/save-editor/panels/Party.lua \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
src/ui/kit/Kit.lua \
src/import/LauncherView.lua; do
grep -qxF "$required" "$LISTING" \
+3 -1
View File
@@ -17,7 +17,9 @@ fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
if [ ! -f "$ROOT/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ]; then
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/gold/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/silver/data/generated/maps.lua" ]; then
fail "generated data missing, run scripts/setup.sh first"
fi
+10 -4
View File
@@ -71,15 +71,15 @@ First install or update (same steps):
your saves, imported ROMs, mods, and options. Re-extracting only
replaces the NRO(s) 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 or Yellow/Gold .gbc into:
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc 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|gold/ — raw .sav import (Gold cart .sav not yet)
exports/red|blue|yellow|gold/ — pull after Export save (Gold not yet)
imports/saves/red|blue|yellow|gold|silver/ — raw .sav import (Gold/Silver cart .sav not yet)
exports/red|blue|yellow|gold|silver/ — pull after Export save (Gold/Silver not yet)
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
EOF
@@ -92,7 +92,7 @@ write_readme() {
}
write_readme "$SAVE_ROOT/imports/README.txt" \
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold .gbc here, then Scan again in the launcher."
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .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" \
@@ -103,6 +103,8 @@ 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/imports/saves/gold/README.txt" \
"Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
write_readme "$SAVE_ROOT/imports/saves/silver/README.txt" \
"Silver cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
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" \
@@ -111,6 +113,8 @@ write_readme "$SAVE_ROOT/exports/yellow/README.txt" \
"After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
write_readme "$SAVE_ROOT/exports/gold/README.txt" \
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
write_readme "$SAVE_ROOT/exports/silver/README.txt" \
"Silver cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
rm -f "$OUT_ZIP"
(
@@ -140,10 +144,12 @@ REQUIRED=(
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/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"
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
)
for rel in "${REQUIRED[@]}"; do
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
+3 -1
View File
@@ -271,10 +271,12 @@ for rel in \
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/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" \
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
do
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
done
+1
View File
@@ -67,6 +67,7 @@ run_tier() {
# ------- ROM-free tiers: these are what CI runs
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
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
+130 -28
View File
@@ -1008,9 +1008,23 @@ end
-- flickers the OBJ palette, DoBallTossSpecialEffects)
function BattleState:animNext(name, isPlayer, shakes, ball)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert,
{ anim = name, attackerIsPlayer = isPlayer, shakes = shakes,
ball = ball })
local row = { anim = name, attackerIsPlayer = isPlayer, shakes = shakes,
ball = ball }
table.insert(self.queue, self.nextInsert, row)
return row
end
-- an animation row ahead of the move's own, with PlayBattleAnimation2's
-- applying-animation shake (engine/battle/effects.asm:1461-1471)
function BattleState:animBeforeMove(name, isPlayer)
local at
for i, item in ipairs(self.queue) do
if item == self.moveAnimRow then at = i break end
end
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, at or self.nextInsert,
{ anim = name, attackerIsPlayer = isPlayer, animDelayed = true,
hit = { animType = isPlayer and 6 or 3 } })
end
-- insert an act right after the current queue item
@@ -1554,15 +1568,41 @@ end
function BattleState:sendOutText(name)
local e = self.enemy and self.enemy.mon
local pct = 100
if e and e.hp > 0 and math.floor(e.stats.hp / 4) > 0 then
if e and e.hp > 0 then
-- the same routine stamps wLastSwitchInEnemyMonHP
-- (engine/battle/common_text.asm:105-110)
self.lastSwitchInEnemyHP = e.hp
if math.floor(e.stats.hp / 4) > 0 then
pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4))
end
end
if pct >= 70 then return Strings("Go! %s!", name) end
if pct >= 40 then return Strings("Do it! %s!", name) end
if pct >= 10 then return Strings("Get'm! %s!", name) end
return self:romText("_EnemysWeakText", "The enemy's weak!\nGet'm! %s!", name)
end
-- RetreatMon / PlayerMon2Text (engine/battle/common_text.asm:167-243): the
-- adjective reads the enemy HP lost since this mon switched in
function BattleState:withdrawText(name)
local e = self.enemy and self.enemy.mon
local drop = 0
if e and self.lastSwitchInEnemyHP and math.floor(e.stats.hp / 4) > 0 then
drop = math.floor((self.lastSwitchInEnemyHP - e.hp) * 25
/ math.floor(e.stats.hp / 4))
end
local word = ""
if drop <= 0 then
word = self:romText("_EnoughText", "enough!")
elseif drop >= 70 then
word = self:romText("_GoodText", "good!")
elseif drop >= 30 then
word = self:romText("_OKExclamationText", "OK!")
end
return self:romText("_PlayerMon2Text", "%s ", name) .. word
.. self:romText("_ComeBackText", "\nCome back!")
end
-- The cry a mon makes as it takes the field. Yellow does not run its
-- starter Pikachu through PlayCry at all: SendOutMon branches to
-- .starterPikachu (engine/battle/core.asm:1807-1817) and voices PCM
@@ -1678,9 +1718,14 @@ function BattleState:enter()
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
-- playerMonFainted queues on the battle screen; there is no battle
-- screen to queue them on here, so they print over the map.
-- _PlayerBlackedOutText (no "2") extracts to the identical wording from
-- a different ROM address and is unused anywhere in this engine -- not
-- a fallback for this one, just pokered printing the same paragraph
-- from a second call site elsewhere.
self.game.stack:push(require("src.render.TextBox").new(self.game,
Strings("%s is out of\nuseable POKéMON!", name) .. "\f"
.. Strings("%s blacked\nout!", name), blackedOut))
self:romText("_PlayerBlackedOutText2",
"%s is out of\nuseable POKéMON!\f%s blacked\nout!", name, name),
blackedOut))
return
end
self.musicKind = self:computeMusicKind()
@@ -1816,7 +1861,9 @@ function BattleState:enter()
self.enemySendingOut = true
self:slidePic("foe")
end)
self:say(Strings("%s sent\nout %s!", foeName, self.enemy.name))
-- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
self:sayAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
foeName, self.enemy.name))
self:act(function()
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
@@ -1845,7 +1892,8 @@ function BattleState:enter()
self.sendingOut = true
self:slidePic("back")
end)
self:say(self:sendOutText(self.player.name))
-- _GoText.._PlayerMon1Text carry no prompt (data/text/text_2.asm:1274-1294)
self:sayAuto(self:sendOutText(self.player.name))
-- then the POOF plays and the mon appears with its cry
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
self:queueSendOutAnim(true)
@@ -2527,11 +2575,7 @@ end
-- the HUD label drawn in place of the level for a statused mon
function BattleState:statusLabel(mon)
local record = Status.recordFor(self.data.statuses, mon.status)
if record then
return record.hudLabel or record.label or mon.status
end
return mon.status
return Status.hudLabelFor(self.data.statuses, mon.status)
end
-- the one accuracy roll (MoveHitTest), hooked as battle.accuracy
@@ -2663,6 +2707,11 @@ function BattleState:resolveSwitch(newMon)
self.phase = "messages"
self.afterQueue = "menu"
self:act(function()
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
-- outgoing pic and holds 50 frames before the mon is recalled
self:sayNextAuto(self:withdrawText(self.player.name),
Timing.SWITCH_PLAYER_MON)
self:actNext(function()
self:restoreMimicked(self.player) -- the battle copy leaves with it
local previous = self.player
self.player = makeBattler(self.data, newMon, true, self.game.save)
@@ -2677,9 +2726,10 @@ function BattleState:resolveSwitch(newMon)
self:markParticipant()
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:sayNextAuto(self:sendOutText(self.player.name))
self:queueSendOutAnim(false)
end)
end)
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
@@ -2707,7 +2757,12 @@ function BattleState:residualFor(b, opp)
if b.residualDone then return end
b.residualDone = true
local msgs = Status.residual(b, opp, self)
local rec = Status.recordFor(self.data and self.data.statuses, b.mon.status)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
-- engine/battle/core.asm:490-493
if rec and rec.residual then
self:animNext("BURN_PSN_ANIM", b.isPlayer)
end
if b.leechSeeded and b.mon.hp > 0 then
-- the drain plays the ABSORB animation from the healing side
-- (core.asm:506-517 flips hWhoseTurn before PlayMoveAnimation)
@@ -3589,9 +3644,20 @@ function BattleState:executeAction(user, target, action)
})
self.aiUses = self:aiUsesFor()
markSeen(self.game, self.enemy.mon.species)
-- _AIBattleWithdrawText: "X with-/drew Y!"
self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName))
self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
self:sayNext(self:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
self.trainer.name, oldName))
-- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
self.enemySendingOut = true
self:sayNextAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
self.trainer.name, self.enemy.name))
self:actNext(function()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
self:actNext(function()
self:waitSfxNext(self:playEntranceCry(self.enemy))
end)
end)
return
end
@@ -4083,8 +4149,12 @@ function BattleState:onFaint(battler)
-- acknowledged core.asm:797-798 bug.)
self:actNext(function() self:playVictoryMusic() end)
end
-- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText
self:sayNext(Strings("%s\nfainted!", displayName(battler)))
-- _EnemyMonFaintedText already carries its own "Enemy" wording, so this
-- passes the raw name -- displayName's separate Strings("Enemy %s", ...)
-- would double it up
self:sayNext(battler.isPlayer
and self:romText("_PlayerMonFaintedText", "%s\nfainted!", battler.name)
or self:romText("_EnemyMonFaintedText", "Enemy %s\nfainted!", battler.name))
if battler.isPlayer then
self:act(function() self:playerMonFainted() end)
else
@@ -4260,6 +4330,13 @@ function BattleState:enemyMonFainted()
-- "X is" off so "about to use" stays above the name, instead of the
-- page ending on a bare nick (#565). Then para "Will PLAYER" /
-- "change POKéMON?" with YES/NO.
--
-- _TrainerAboutToUseText combines both \f-paged, but unlike
-- _ItemUseBallText00's say()+say() merge above, this is say()+
-- sayChoice(): tried merging into one romText/sayChoice call and
-- confirmed via tests/engine/trainer_shift_prompt_bug565.lua that
-- the battle queue's own \f handling (not TextBox.lua's) does not
-- page a sayChoice string the same way -- left as two calls.
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
self:sayChoice(
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
@@ -4281,6 +4358,9 @@ function BattleState:enemyMonFainted()
self:act(function()
local previous = self.enemy
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
-- EnemySendOutFirstMon (core.asm:1359-1363): the fresh foe's HP is the
-- new wLastSwitchInEnemyMonHP baseline RetreatMon measures from
self.lastSwitchInEnemyHP = self.enemy.mon.hp
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides()
@@ -4300,7 +4380,8 @@ function BattleState:enemyMonFainted()
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
self.enemySendingOut = true
self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
self:sayNextAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
self.trainer.name, self.enemy.name))
self:actNext(function()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
@@ -4313,6 +4394,12 @@ function BattleState:enemyMonFainted()
self:act(function()
local mon = shiftSwitchMon
if not mon then return end
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
-- hold, then the recall and the send-out
self.nextInsert = 0
self:sayNextAuto(self:withdrawText(self.player.name),
Timing.SWITCH_PLAYER_MON)
self:actNext(function()
local previous = self.player
self.player = makeBattler(self.data, mon, true, self.game.save)
clearTrapping(self.enemy)
@@ -4339,9 +4426,10 @@ function BattleState:enemyMonFainted()
self.nextInsert = 0
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:sayNextAuto(self:sendOutText(self.player.name))
self:queueSendOutAnim(false)
end)
end)
return
end
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
@@ -4530,7 +4618,7 @@ function BattleState:openReplacementMenu()
self.nextInsert = 0
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:sayNextAuto(self:sendOutText(self.player.name))
self:queueSendOutAnim(false)
end,
})
@@ -4809,7 +4897,8 @@ function BattleState:storeCaughtMon()
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
-- the box rather than firing when the dex bit is set
self:sayNextWaitSfx(
Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
self:romText("_ItemUseBallText06",
"New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end)
self:uiNext(function()
return self:buildScreen("DexEntryMenu", species)
@@ -4830,9 +4919,12 @@ function BattleState:storeCaughtMon()
if boxNum then
askCaughtNickname()
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
and "BILL's PC" or Strings("someone's PC")
self:sayNext(Strings("%s was\ntransferred to\n%s!", self.enemy.name, pc))
local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
self:sayNext(self:romText(
metBill and "_ItemUseBallText07" or "_ItemUseBallText08",
metBill and "%s was\ntransferred to\nBILL's PC!"
or "%s was\ntransferred to\nsomeone's PC!",
self.enemy.name))
else
self:sayNext(Strings("But every BOX\nis full!"))
end
@@ -4938,8 +5030,18 @@ function BattleState:throwBall(ball)
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
-- so it is not a ghost battle any more (#444)
self:animNext(self:tossAnimFor(ball), true, nil, ball)
self:sayNext(Strings("It dodged the\nthrown BALL!"))
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
-- _ItemUseBallText00 is one label for both lines, \f-paged. Unlike
-- TextBox.new() (which splits \f itself), the battle queue's own
-- startMessage() only splits on \n/\v -- confirmed live: the \f
-- landed mid-line and the second sentence overflowed off the box
-- instead of starting a fresh page. Resolve the label once, then
-- split it the same way TextBox.lua does and queue one sayNext per
-- page, so the two ROM sentences still render as two pages.
local dodgeText = self:romText("_ItemUseBallText00",
"It dodged the\nthrown BALL!\fThis POKéMON\ncan't be caught!")
for page in (dodgeText .. "\f"):gmatch("(.-)\f") do
self:sayNext(page)
end
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
+20 -1
View File
@@ -92,6 +92,20 @@ local function hitCount(ctx, record)
return dist[r + 1]
end
-- engine/battle/effects.asm:119-151 (poison), :194-255 (burn/freeze/paralyze)
local FBP_SIDE_STATUS = { BRN = true, FRZ = true, PAR = true }
local function secondaryStatusFx(battle, user, status)
if status == "PSN" then
local row = battle:animNext(user.isPlayer and "ENEMY_HUD_SHAKE_ANIM"
or "SHAKE_SCREEN_ANIM", user.isPlayer)
row.animDelayed = true
row.hit = { animType = user.isPlayer and 6 or 3 }
elseif FBP_SIDE_STATUS[status] and user.isPlayer then
battle:animNext("ENEMY_HUD_SHAKE_ANIM", true).animDelayed = true
end
end
-- The damaging pipeline, extracted from the performMove monolith: every
-- stage keeps the original's exact check order and rng consumption
-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy ->
@@ -318,7 +332,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- secondary side effects (blocked by fainting)
if record and record.run and record.kind ~= "primary"
and target.mon.hp > 0 and totalDealt > 0 then
for _, m in ipairs(record.run(ctx)) do
local hadStatus = target.mon.status
local msgs = record.run(ctx)
if target.mon.status and target.mon.status ~= hadStatus then
secondaryStatusFx(battle, user, target.mon.status)
end
for _, m in ipairs(msgs) do
battle:sayNext(m)
end
end
+9
View File
@@ -581,6 +581,15 @@ MoveEffects.full = {
end,
},
THRASH_PETAL_DANCE_EFFECT = {
-- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage
-- (data/battle/special_effects.asm:22) and animates the setup turn
beforeAccuracy = function(ctx)
local user = ctx.user
if not user.thrashTurns then
ctx.battle:animBeforeMove(
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
end
end,
afterDamage = function(ctx)
local user = ctx.user
if not user.thrashTurns then
+20 -5
View File
@@ -54,6 +54,13 @@ end
-- freeze the English. They are already translatable through the
-- statuses registry (mod.content.statuses:patch(id, { label = ... })).
--
-- Do not add a matching hudLabel = "..." below: Status.hudLabelFor reads
-- hudLabel before label, and Registry:patch only overrides the fields a
-- mod actually passes, so a label-only translation patch would be
-- shadowed by this hudLabel forever. Nothing in this codebase gives
-- hudLabel a value different from label -- setting it here only recreates
-- that trap for no observed benefit.
--
-- The five persistent conditions as records: the beforeMove gauntlet, the
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
@@ -61,7 +68,7 @@ end
-- read these fields, so a mod's sixth status plugs into every consumer.
Status.RECORDS = {
SLP = {
id = "SLP", label = "SLP", hudLabel = "SLP",
id = "SLP", label = "SLP",
catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 40,
beforeMove = function(battler, _, battle)
@@ -82,7 +89,7 @@ Status.RECORDS = {
end,
},
FRZ = {
id = "FRZ", label = "FRZ", hudLabel = "FRZ",
id = "FRZ", label = "FRZ",
catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 30,
beforeMove = function(battler, _, battle)
@@ -96,7 +103,7 @@ Status.RECORDS = {
end,
},
PSN = {
id = "PSN", label = "PSN", hudLabel = "PSN",
id = "PSN", label = "PSN",
catchBonus = 12, shakeBonus = 5,
residual = damageOverTime("_HurtByPoisonText",
Strings.source("%s's\nhurt by poison!")),
@@ -112,7 +119,7 @@ Status.RECORDS = {
end,
},
BRN = {
id = "BRN", label = "BRN", hudLabel = "BRN",
id = "BRN", label = "BRN",
catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "attack", div = 2 },
residual = damageOverTime("_HurtByBurnText",
@@ -124,7 +131,7 @@ Status.RECORDS = {
end,
},
PAR = {
id = "PAR", label = "PAR", hudLabel = "PAR",
id = "PAR", label = "PAR",
catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "speed", div = 4 },
beforeMovePriority = 10,
@@ -160,6 +167,14 @@ function Status.recordFor(statuses, id)
return (statuses or Status.RECORDS)[id]
end
-- the HUD label for a status id: a mod's patched hudLabel/label if the
-- merged registry has one, the raw id otherwise (BattleState.statusLabel,
-- SummaryMenu.draw and PartyMenu.draw all read this the same way)
function Status.hudLabelFor(statuses, id)
local record = Status.recordFor(statuses, id)
return record and (record.hudLabel or record.label) or id
end
local function battleStatuses(battle)
return battle and battle.data and battle.data.statuses
end
+538
View File
@@ -0,0 +1,538 @@
local Json = require("src.link.Json")
local TouchSkin = require("src.core.TouchSkin")
local DeltaSkin = {}
DeltaSkin.INFO_NAME = "info.json"
DeltaSkin.MAX_INFO_BYTES = 4 * 1024 * 1024
DeltaSkin.GAME_TYPE_PREFIXES = {
"com.rileytestut.delta.game.",
"public.aoshuang.game.",
}
DeltaSkin.SYSTEMS = { gb = true, gbc = true }
DeltaSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
DeltaSkin.DEVICE_ORDER = { "iphone", "ipad", "tv" }
DeltaSkin.DISPLAY_ORDER = { "edgeToEdge", "standard", "splitView" }
DeltaSkin.ORIENTATIONS = { "portrait", "landscape" }
DeltaSkin.SIDES = { "up", "down", "left", "right" }
DeltaSkin.ASSET_LADDER = { "small", "medium", "large" }
DeltaSkin.ASSET_WIDTHS = { small = 640, medium = 750, large = 1080 }
DeltaSkin.DEFAULT_TARGET_WIDTH = 1080
DeltaSkin.INPUTS = {
a = "a", b = "b", start = "start", select = "select",
up = "up", down = "down", left = "left", right = "right",
menu = "menu_toggle",
fastforward = "hold_fast_forward",
togglefastforward = "toggle_fast_forward",
}
DeltaSkin.OUTPUT_HOTKEYS = {
menu = "menu",
fast_forward_hold = "fastForward",
fast_forward_toggle = "toggleFastForward",
}
DeltaSkin.MAPPING = {
portrait = { width = 1080, height = 1920 },
landscape = { width = 1920, height = 1080 },
}
DeltaSkin.SCREEN_WIDTH = 160
DeltaSkin.SCREEN_HEIGHT = 144
local function pick(t, key)
if type(t) ~= "table" then return nil end
local direct = t[key]
if direct ~= nil then return direct end
local want = tostring(key):lower()
for k, v in pairs(t) do
if tostring(k):lower() == want then return v end
end
return nil
end
local function numOr(v, fallback)
local n = tonumber(v)
if not n or n ~= n then return fallback end
return n
end
local function round(n)
return math.floor(numOr(n, 0) + 0.5)
end
local function isArray(t)
return type(t) == "table" and t[1] ~= nil
end
local function addWarning(list, text)
if type(list) ~= "table" then return end
for _, existing in ipairs(list) do
if existing == text then return end
end
list[#list + 1] = text
end
function DeltaSkin.findInfo(root)
local direct = root .. "/" .. DeltaSkin.INFO_NAME
if TouchSkin.readFile(direct) then return direct, "" end
local items = TouchSkin.listDir(root)
for _, name in ipairs(items) do
if tostring(name):lower() == DeltaSkin.INFO_NAME then
return root .. "/" .. name, ""
end
end
table.sort(items)
for _, name in ipairs(items) do
local nested = root .. "/" .. name .. "/" .. DeltaSkin.INFO_NAME
if TouchSkin.readFile(nested) then return nested, name .. "/" end
end
return nil
end
function DeltaSkin.resolveName(name, opts)
name = tostring(name or ""):gsub("\\", "/"):gsub("^%./", "")
if name == "" then return nil end
local names = opts and opts.names
if type(names) == "table" then
local want = name:lower()
for _, entry in ipairs(names) do
if tostring(entry):lower() == want then
name = tostring(entry)
break
end
end
end
return ((opts and opts.prefix) or "") .. name
end
function DeltaSkin.pickAsset(assets, opts, pdfFiles)
if type(assets) ~= "table" then return nil end
pdfFiles = pdfFiles or {}
local raster = {}
local pdfName
for _, key in ipairs(DeltaSkin.ASSET_LADDER) do
local name = pick(assets, key)
if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end
if type(name) == "string" and name ~= "" then
if name:lower():match("%.pdf$") then
pdfName = name
pdfFiles[#pdfFiles + 1] = name
else
raster[#raster + 1] = { key = key, name = name }
end
end
end
local resizable = pick(assets, "resizable")
if type(resizable) == "string" and resizable ~= "" then
if resizable:lower():match("%.pdf$") then
pdfName = resizable
pdfFiles[#pdfFiles + 1] = resizable
else
raster[#raster + 1] = { key = "large", name = resizable }
end
end
local pdfPath = pdfName and DeltaSkin.resolveName(pdfName, opts) or nil
if #raster == 0 then return nil, pdfPath end
local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH)
local chosen
for _, cand in ipairs(raster) do
if not chosen and (DeltaSkin.ASSET_WIDTHS[cand.key] or 0) >= target then
chosen = cand.name
end
end
if not chosen then chosen = raster[#raster].name end
return DeltaSkin.resolveName(chosen, opts), nil
end
function DeltaSkin.mergeEdges(base, item)
local out = { top = 0, bottom = 0, left = 0, right = 0 }
for _, side in ipairs({ "top", "bottom", "left", "right" }) do
local v = pick(item, side)
if v == nil then v = pick(base, side) end
out[side] = numOr(v, 0)
end
return out
end
function DeltaSkin.representation(reps, orient)
for _, device in ipairs(DeltaSkin.DEVICE_ORDER) do
local dev = pick(reps, device)
if type(dev) == "table" then
for _, display in ipairs(DeltaSkin.DISPLAY_ORDER) do
local shown = pick(dev, display)
if type(shown) == "table" then
local obj = pick(shown, orient)
if type(obj) == "table" then return obj, device, display end
end
end
local flat = pick(dev, orient)
if type(flat) == "table" and (pick(flat, "items") or pick(flat, "mappingSize")) then
return flat, device, nil
end
end
end
return nil
end
function DeltaSkin.directionalInputs(inputs)
if type(inputs) ~= "table" or isArray(inputs) then return nil end
local out, found = {}, 0
for _, side in ipairs(DeltaSkin.SIDES) do
local v = pick(inputs, side)
if type(v) == "string" then
local lower = v:lower()
local mapped = DeltaSkin.INPUTS[lower]
if not mapped and lower:find(side, 1, true) then mapped = side end
if mapped then
out[side] = mapped
found = found + 1
end
end
end
if found >= 2 then return out end
return nil
end
function DeltaSkin.specFor(inputs)
local parts = {}
local function add(v)
if type(v) ~= "string" then return end
local mapped = DeltaSkin.INPUTS[v:lower()]
if mapped then parts[#parts + 1] = mapped end
end
if type(inputs) == "string" then
add(inputs)
elseif type(inputs) == "table" then
if isArray(inputs) then
for _, v in ipairs(inputs) do add(v) end
else
local keys = {}
for k in pairs(inputs) do keys[#keys + 1] = tostring(k) end
table.sort(keys)
for _, k in ipairs(keys) do add(inputs[k]) end
end
end
if #parts == 0 then return "nul" end
return table.concat(parts, "|")
end
function DeltaSkin.screenRect(obj, mapW, mapH)
local frame
local screens = pick(obj, "screens")
if type(screens) == "table" and type(screens[1]) == "table" then
frame = pick(screens[1], "outputFrame")
end
if type(frame) ~= "table" then frame = pick(obj, "gameScreenFrame") end
if type(frame) ~= "table" then return nil end
local w = numOr(pick(frame, "width"), 0)
local h = numOr(pick(frame, "height"), 0)
if w <= 0 or h <= 0 then return nil end
return {
x = numOr(pick(frame, "x"), 0) / mapW,
y = numOr(pick(frame, "y"), 0) / mapH,
w = w / mapW, h = h / mapH,
}
end
function DeltaSkin.addItem(page, item, baseEdges, mapW, mapH)
if type(item) ~= "table" then return end
local frame = pick(item, "frame")
if type(frame) ~= "table" then return end
local fw = numOr(pick(frame, "width"), 0)
local fh = numOr(pick(frame, "height"), 0)
if fw <= 0 or fh <= 0 then return end
local fx = numOr(pick(frame, "x"), 0)
local fy = numOr(pick(frame, "y"), 0)
local edges = DeltaSkin.mergeEdges(baseEdges, pick(item, "extendedEdges"))
local cx, cy = (fx + fw * 0.5) / mapW, (fy + fh * 0.5) / mapH
local w, h = fw / mapW, fh / mapH
local reachLeft = 1 + edges.left / (fw * 0.5)
local reachRight = 1 + edges.right / (fw * 0.5)
local reachUp = 1 + edges.top / (fh * 0.5)
local reachDown = 1 + edges.bottom / (fh * 0.5)
local inputs = pick(item, "inputs")
local dirs = DeltaSkin.directionalInputs(inputs)
if dirs then
local base = {
x = cx, y = cy, rangeX = w * 0.5, rangeY = h * 0.5,
rangeMod = 1, alphaMod = page.alphaMod, shape = "rect",
reachLeft = reachLeft, reachRight = reachRight,
reachUp = reachUp, reachDown = reachDown,
}
for _, ctl in ipairs(TouchSkin.expandDirectional(base, dirs)) do
page.controls[#page.controls + 1] = ctl
end
return
end
local shape = tostring(pick(item, "mask") or ""):lower() == "circle" and "radial" or "rect"
local ctl = TouchSkin.newControl(DeltaSkin.specFor(inputs), cx, cy, w, h, shape)
ctl.alphaMod = page.alphaMod
ctl.reachLeft, ctl.reachRight = reachLeft, reachRight
ctl.reachUp, ctl.reachDown = reachUp, reachDown
page.controls[#page.controls + 1] = ctl
end
function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
local mapping = pick(obj, "mappingSize")
local mapW = numOr(pick(mapping, "width"), 0)
local mapH = numOr(pick(mapping, "height"), 0)
if mapW <= 0 or mapH <= 0 then
mapW, mapH = 320, 240
addWarning(warnings, orient .. " has no mappingSize; assuming 320x240")
end
local imagePath, pdfPath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles)
local page = {
name = orient,
orient = orient,
imagePath = imagePath,
pdfPath = pdfPath,
fullScreen = true,
normalized = true,
pixelCoords = false,
rangeMod = 1,
alphaMod = pick(obj, "translucent") == true and 0.7 or 1,
aspect = mapW / mapH,
aspectFromCfg = false,
rect = { x = 0, y = 0, w = 1, h = 1 },
mappingWidth = mapW,
mappingHeight = mapH,
controls = {},
}
local screen = DeltaSkin.screenRect(obj, mapW, mapH)
if screen then
page.viewport = screen
page.viewportFill = false
else
-- mappingSize is the overlay, not the device. Portrait controller
-- skins (GBA4iOS-era 320x240 decks, this Pikachu skin, etc.) keep
-- that aspect, sit at the bottom, and leave the leftover for the
-- Game Boy picture. A screens/gameScreenFrame rect still fills.
page.aspectFromCfg = true
page.screenFit = "remainder"
if orient == "portrait" then page.anchor = "bottom" end
end
local baseEdges = pick(obj, "extendedEdges")
local items = pick(obj, "items")
if type(items) == "table" then
for _, item in ipairs(items) do
DeltaSkin.addItem(page, item, baseEdges, mapW, mapH)
end
end
return page
end
function DeltaSkin.systemOf(gameType)
if type(gameType) ~= "string" or gameType == "" then return nil end
for _, prefix in ipairs(DeltaSkin.GAME_TYPE_PREFIXES) do
if gameType:sub(1, #prefix) == prefix then
return gameType:sub(#prefix + 1):lower()
end
end
return nil
end
function DeltaSkin.parse(text, opts)
opts = opts or {}
local info, err = Json.decode(tostring(text or ""), DeltaSkin.MAX_INFO_BYTES)
if type(info) ~= "table" then
return nil, "info.json does not parse: " .. tostring(err)
end
local gameType = info.gameTypeIdentifier
if type(gameType) ~= "string" or gameType == "" then
return nil, "old GBA4iOS skin, not supported: info.json has no gameTypeIdentifier"
end
if gameType:lower():find("gba4ios", 1, true) then
return nil, "old GBA4iOS skin, not supported"
end
local system = DeltaSkin.systemOf(gameType)
if not system then
return nil, "not a Delta skin: unknown gameTypeIdentifier " .. gameType
end
local warnings = {}
if not DeltaSkin.SYSTEMS[system] then
addWarning(warnings, "this skin is for " .. system .. ", not Game Boy")
end
local reps = info.representations
if type(reps) ~= "table" then return nil, "info.json has no representations" end
local pdfFiles, pages = {}, {}
for _, orient in ipairs(DeltaSkin.ORIENTATIONS) do
local obj = DeltaSkin.representation(reps, orient)
if obj then
local page = DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
page.index = #pages + 1
pages[#pages + 1] = page
end
end
if #pages == 0 then return nil, "info.json has no usable representation" end
return {
pages = pages,
name = info.name,
author = info.author,
notes = info.notes,
format = "delta",
system = system,
identifier = info.identifier,
warnings = warnings,
pdfFiles = pdfFiles,
}
end
function DeltaSkin.needsConversion(skin)
if type(skin) ~= "table" then return nil end
local files = skin.pdfFiles
if type(files) ~= "table" or #files == 0 then return nil end
for _, page in ipairs(skin.pages or {}) do
-- A raster asset, or a JPEG recovered from the PDF at load, means the
-- skin can draw. Parse-only callers still see pdfOnly because they
-- have not run extract yet.
if page.rasterData then return nil end
if page.imagePath then return nil end
end
return { pdfOnly = true, files = files }
end
function DeltaSkin.outputInputs(ctl)
local out = {}
for _, b in ipairs(ctl.buttons or {}) do out[#out + 1] = b end
for _, h in ipairs(ctl.hotkeys or {}) do
local mapped = DeltaSkin.OUTPUT_HOTKEYS[h]
if mapped then out[#out + 1] = mapped end
end
return out
end
function DeltaSkin.buildRepresentation(page, orient, warnings)
local map = DeltaSkin.MAPPING[orient] or DeltaSkin.MAPPING.portrait
local mapW, mapH = map.width, map.height
local items, files = {}, {}
for _, ctl in ipairs(page.controls or {}) do
local names = DeltaSkin.outputInputs(ctl)
if ctl.sector and ctl.sector ~= 1 then
names = {}
elseif ctl.sector and ctl.areaNames then
local TouchSkin = require("src.core.TouchSkin")
local dirs = {}
for _, side in ipairs({ "up", "down", "left", "right" }) do
local mapped = TouchSkin.GB_BUTTONS[tostring(ctl.areaNames[side]):lower()]
if mapped then dirs[side] = mapped end
end
names = next(dirs) and dirs or {}
end
if names.up or names.down or names.left or names.right or #names > 0 then
local item = {
inputs = names,
frame = {
x = round((ctl.x - ctl.rangeX) * mapW),
y = round((ctl.y - ctl.rangeY) * mapH),
width = round(ctl.rangeX * 2 * mapW),
height = round(ctl.rangeY * 2 * mapH),
},
}
if ctl.shape == "radial" then item.mask = "circle" end
local edges, any = {}, false
local pairsList = {
{ key = "left", reach = ctl.reachLeft, half = ctl.rangeX * mapW },
{ key = "right", reach = ctl.reachRight, half = ctl.rangeX * mapW },
{ key = "top", reach = ctl.reachUp, half = ctl.rangeY * mapH },
{ key = "bottom", reach = ctl.reachDown, half = ctl.rangeY * mapH },
}
for _, side in ipairs(pairsList) do
local reach = numOr(side.reach, 1)
if reach ~= 1 then
edges[side.key] = round((reach - 1) * side.half)
any = true
end
end
if any then item.extendedEdges = edges end
items[#items + 1] = item
elseif ctl.imagePath then
addWarning(warnings, "per-button art is dropped: Delta keeps all art in one image")
end
end
local obj = {
items = items,
mappingSize = { width = mapW, height = mapH },
extendedEdges = { top = 0, bottom = 0, left = 0, right = 0 },
translucent = false,
}
if page.imagePath then
obj.assets = {
small = page.imagePath, medium = page.imagePath, large = page.imagePath,
}
files[#files + 1] = page.imagePath
end
if page.viewport then
obj.screens = { {
inputFrame = { x = 0, y = 0,
width = DeltaSkin.SCREEN_WIDTH, height = DeltaSkin.SCREEN_HEIGHT },
outputFrame = {
x = round(page.viewport.x * mapW), y = round(page.viewport.y * mapH),
width = round(page.viewport.w * mapW), height = round(page.viewport.h * mapH),
},
} }
end
return obj, files
end
function DeltaSkin.build(skin, opts)
if type(skin) ~= "table" or not skin.pages or not skin.pages[1] then
return nil, "skin has no pages"
end
opts = opts or {}
local standard, edgeToEdge = {}, {}
local assets, warnings, used = {}, {}, {}
for _, page in ipairs(skin.pages) do
local orient = TouchSkin.pageOrient(page)
if orient ~= "portrait" and orient ~= "landscape" then
orient = (numOr(page.aspect, 1) < 1) and "portrait" or "landscape"
end
if not used[orient] then
used[orient] = true
local obj, files = DeltaSkin.buildRepresentation(page, orient, warnings)
standard[orient] = obj
edgeToEdge[orient] = obj
for _, rel in ipairs(files) do assets[#assets + 1] = rel end
end
end
local system = tostring(opts.system or "gbc")
local info = {
name = skin.name or skin.id or "skin",
identifier = opts.identifier
or ("com.gen1recomp.skin." .. tostring(skin.id or "skin")),
gameTypeIdentifier = DeltaSkin.GAME_TYPE_PREFIXES[1] .. system,
debug = false,
representations = { iphone = { standard = standard, edgeToEdge = edgeToEdge } },
}
return info, assets, warnings
end
function DeltaSkin.encodeInfo(skin, opts)
local info, assets, warnings = DeltaSkin.build(skin, opts)
if not info then return nil, assets end
return Json.encode(info), assets, warnings
end
return DeltaSkin

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