Compare commits

..

47 Commits

Author SHA1 Message Date
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
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
177 changed files with 24831 additions and 1332 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.
+4 -3
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.
+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 -1
View File
@@ -11,10 +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**
## 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
+14 -9
View File
@@ -95,18 +95,22 @@ auto-rotates like a RetroArch one. Item `frame` rects are top-left plus size in
`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, and the skin stretches to the
window the way Delta does rather than letterboxing. Host functions map to
`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 the one thing that does not come across: Delta's own templates
are all-PDF and this engine has no rasterizer, so such a skin is refused with
the message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files are
an older, incompatible schema and are refused by name.
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
@@ -268,6 +272,7 @@ exported file** opens that folder.
## Not implemented
Delta skins whose art is PDF only. Rasterizing them needs a PDF renderer this
engine does not carry, so they are refused with a message rather than imported
half-drawn.
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.
+7 -5
View File
@@ -327,6 +327,7 @@ local function returnToLauncher()
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()
@@ -386,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
+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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -8,4 +8,5 @@
<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>
@@ -398,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();
@@ -411,6 +415,7 @@ public class GameActivity extends SDLActivity {
@Override
protected void onPause() {
secondaryHostResumed = false;
if (vibrator != null) {
Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel();
@@ -426,6 +431,7 @@ public class GameActivity extends SDLActivity {
@Override
public void onResume() {
super.onResume();
secondaryHostResumed = true;
onHostResume();
requestGameAudioFocus();
registerAudioDeviceCallback();
@@ -1933,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;
@@ -1963,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();
@@ -2035,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();
});
@@ -2045,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 {
+28
View File
@@ -12,6 +12,34 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"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",
+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)
+29
View File
@@ -156,6 +156,7 @@ int w_syncHealthSteps(lua_State *L)
""" % MARKER
WRAP_REGISTRATION = """#ifdef LOVE_IOS
{ "getDeviceModel", w_getDeviceModel },
{ "pickFile", w_pickFile },
{ "pickFileKinds", w_pickFileKinds },
{ "createFile", w_createFile },
@@ -202,6 +203,7 @@ int w_syncHealthSteps(lua_State *L)
"""
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
{ "getDeviceModel", w_getDeviceModel },
{ "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload },
{ "httpRequest", w_httpRequest },
@@ -210,6 +212,33 @@ WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
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);

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")
+40 -21
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"
# 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 \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
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,18 +116,26 @@ 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
say "stamping engine version $VERSION into game.love"
stamp_dir="$WORK/stamp"
rm -rf "$stamp_dir"
mkdir -p "$stamp_dir/src/core"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
unzip -p "$LOVE_FILE" src/core/Version.lua \
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|| fail "version stamp failed: game.love does not report engine $VERSION"
say "stamped engine version: $VERSION"
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"
mkdir -p "$stamp_dir/src/core"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
unzip -p "$LOVE_FILE" src/core/Version.lua \
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|| fail "version stamp failed: game.love does not report engine $VERSION"
say "stamped engine version: $VERSION"
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
+171 -65
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,8 +1568,13 @@ 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
pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4))
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
@@ -1563,6 +1582,27 @@ function BattleState:sendOutText(name)
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)
@@ -2659,22 +2707,28 @@ function BattleState:resolveSwitch(newMon)
self.phase = "messages"
self.afterQueue = "menu"
self:act(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)
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player,
previous = previous,
})
self:markParticipant()
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:queueSendOutAnim(false)
-- 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)
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player,
previous = previous,
})
self:markParticipant()
sendOutMonCursors(self)
self.sendingOut = true
self:sayNextAuto(self:sendOutText(self.player.name))
self:queueSendOutAnim(false)
end)
end)
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
@@ -2703,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)
@@ -3585,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
@@ -4079,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
@@ -4256,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),
@@ -4277,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()
@@ -4296,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)
@@ -4309,34 +4394,41 @@ function BattleState:enemyMonFainted()
self:act(function()
local mon = shiftSwitchMon
if not mon then return end
local previous = self.player
self.player = makeBattler(self.data, mon, true, self.game.save)
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
battler = self.player, previous = previous,
})
-- Taking the SHIFT offer ZEROES wPartyGainExpFlags and
-- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon
-- (EnemySendOutFirstMon tail, core.asm:1436-1443), and SwitchPlayerMon
-- then FLAG_SETs only the mon coming in (core.asm:2424-2433). Without
-- the reset the mon that was out when the enemy fainted -- marked by
-- the send-out act above, which mirrors EnemySendOut's own re-flag
-- (core.asm:1276-1289) -- stayed a participant, so the exp divisor in
-- enemyMonFainted counted two mons and the switch-in earned half the
-- next KO (#275). Voluntary switches (resolveSwitch) and post-faint
-- replacements (openReplacementMenu) must NOT do this: pokered's
-- party-menu SwitchPlayerMon keeps the outgoing mon flagged, which is
-- the deliberate exp-share, and a fainted mon is already dropped by
-- onFaint mirroring RemoveFaintedPlayerMon (core.asm:1002-1007).
self.participants = {}
self:markParticipant()
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
-- hold, then the recall and the send-out
self.nextInsert = 0
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:queueSendOutAnim(false)
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)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
battler = self.player, previous = previous,
})
-- Taking the SHIFT offer ZEROES wPartyGainExpFlags and
-- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon
-- (EnemySendOutFirstMon tail, core.asm:1436-1443), and SwitchPlayerMon
-- then FLAG_SETs only the mon coming in (core.asm:2424-2433). Without
-- the reset the mon that was out when the enemy fainted -- marked by
-- the send-out act above, which mirrors EnemySendOut's own re-flag
-- (core.asm:1276-1289) -- stayed a participant, so the exp divisor in
-- enemyMonFainted counted two mons and the switch-in earned half the
-- next KO (#275). Voluntary switches (resolveSwitch) and post-faint
-- replacements (openReplacementMenu) must NOT do this: pokered's
-- party-menu SwitchPlayerMon keeps the outgoing mon flagged, which is
-- the deliberate exp-share, and a fainted mon is already dropped by
-- onFaint mirroring RemoveFaintedPlayerMon (core.asm:1002-1007).
self.participants = {}
self:markParticipant()
self.nextInsert = 0
sendOutMonCursors(self)
self.sendingOut = true
self:sayNextAuto(self:sendOutText(self.player.name))
self:queueSendOutAnim(false)
end)
end)
return
end
@@ -4526,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,
})
@@ -4805,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)
@@ -4826,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
@@ -4934,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
+21 -6
View File
@@ -116,11 +116,13 @@ 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 }
@@ -130,12 +132,14 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles)
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
if #raster == 0 then return nil 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
@@ -145,7 +149,7 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles)
end
end
if not chosen then chosen = raster[#raster].name end
return DeltaSkin.resolveName(chosen, opts)
return DeltaSkin.resolveName(chosen, opts), nil
end
function DeltaSkin.mergeEdges(base, item)
@@ -288,10 +292,12 @@ function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
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 = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles),
imagePath = imagePath,
pdfPath = pdfPath,
fullScreen = true,
normalized = true,
pixelCoords = false,
@@ -309,6 +315,14 @@ function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
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")
@@ -368,9 +382,6 @@ function DeltaSkin.parse(text, opts)
end
end
if #pages == 0 then return nil, "info.json has no usable representation" end
if #pdfFiles > 0 then
addWarning(warnings, "PDF artwork cannot be imported yet")
end
return {
pages = pages,
@@ -390,6 +401,10 @@ function DeltaSkin.needsConversion(skin)
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 }
+38 -11
View File
@@ -326,6 +326,9 @@ function Game:logicSpeed()
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
return 1
end
if Game.isFixedSpeedInStack and Game.isFixedSpeedInStack(self.stack) then
return 1
end
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
-- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's
-- core.logic_speed hook can return anything (0, negative, nil, NaN) and
@@ -475,6 +478,15 @@ function Game.speedCategoryInStack(stack)
return "menu"
end
function Game.isFixedSpeedInStack(stack)
local states = stack and stack.states
for i = #(states or {}), 1, -1 do
local state = states[i]
if state and (state.isFixedSpeed or state.isMinigame) then return true end
end
return false
end
-- Whether a state on the stack composes its own screen and so wants the
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
-- everything else here: the text box and YES/NO a battle puts up are states
@@ -970,7 +982,11 @@ end
-- parked the player until every direction was re-pressed (#799).
function Game:focus(f)
Input:reset()
if f then Input:reconcile() end
if f then
Input:reconcile()
local eng = self:syncEngine()
if eng then pcall(eng.noteResumed, eng) end
end
TouchControls:reset()
self:cancelPointers()
end
@@ -990,6 +1006,8 @@ function Game:onResume()
Input:reconcile()
TouchControls:reset()
self:cancelPointers()
local eng = self:syncEngine()
if eng then pcall(eng.noteResumed, eng) end
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
-- the active screen re-cue on the next frame (hardware audio check: T19).
-- Desktop/mobile window-visible flips must not kill overworld music.
@@ -1197,18 +1215,26 @@ end
function Game:syncEngine()
if self._syncOff then return nil end
if self._syncEngineRef then return self._syncEngineRef end
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
if not ok or type(SyncEngine) ~= "table" then
self._syncOff = true
return nil
end
local eng = SyncEngine.shared()
local eng = self._syncEngineRef
if not eng then
self._syncOff = true
return nil
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
if not ok or type(SyncEngine) ~= "table" then
self._syncOff = true
return nil
end
eng = SyncEngine.shared()
if not eng then
self._syncOff = true
return nil
end
self._syncEngineRef = eng
end
if type(eng.protectPlaythrough) == "function" then
local meta = self.save and self.save.meta
eng:protectPlaythrough(
(self.save and self.save.version) or require("src.core.GameVersion").get(),
type(meta) == "table" and meta.playthroughId or nil)
end
self._syncEngineRef = eng
return eng
end
@@ -1248,6 +1274,7 @@ function Game:applyOptions(opts)
-- after VideoMode: a faithful-resolution lock is an exact window size, so
-- it has to be the last word on the window (it drops fullscreen to hold)
require("src.core.FaithfulRes").applyOptions(opts)
require("src.core.ScreenPosition").applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
+5 -3
View File
@@ -3,7 +3,7 @@
-- everything under src/*/gen2 reaches shared services through here. Gen 1
-- Game:load cannot consume a Gen 2 cache -- different generated tables, save
-- shape and screen registry -- so main.lua's bootGame picks this owner when
-- GameVersion.isGold(), and the two never branch into each other.
-- GameVersion.generation() == 2, and the two never branch into each other.
--
-- Boot: copyright → GameFreak Presents → GS intro stub → title
-- (tilemap + Ho-Oh flap / clouds / trails) → Oak speech (Marill + shrink)
@@ -1660,8 +1660,9 @@ function Game2:drawScene(w, h)
-- row to opt into the step-down half, so CENTERED is the whole rule
-- here.
local s = self.world:fitScale()
local ox, oy = Chrome.fitOrigin(w, h, s)
G.push()
G.translate(math.floor((w - 160 * s) / 2), math.floor((h - 144 * s) / 2))
G.translate(ox, oy)
G.scale(s, s)
self.stack:draw()
G.pop()
@@ -1702,7 +1703,7 @@ function Game2:hotkey(key)
self:writeSave()
return true
elseif key == "f2" then
local loaded = Save.load("gold")
local loaded = Save.load()
if loaded then self:continueGame(loaded) end
return true
elseif key == "1" then
@@ -1982,6 +1983,7 @@ function Game2:applyOptions()
haptics = options.haptics,
})
require("src.core.VideoMode").applyOptions(options)
require("src.core.ScreenPosition").applyOptions(options)
require("src.core.FrameCap").applyOptions(options)
require("src.world.gen2.BorderFill").applyOptions(options)
local GBCFX = require("src.render.GBCFX")
+18 -4
View File
@@ -1,5 +1,5 @@
-- Which game this process is running: Red (the historical default), Blue,
-- Yellow, or Gold. One source of truth for everything that differs by
-- Yellow, Gold, or Silver. One source of truth for everything that differs by
-- version -- the accepted ROM hash, the import manifest, where the
-- extracted cache lives, and the save-file suffix -- so the importer,
-- cache mount, SaveData, title screen and palette all agree.
@@ -8,7 +8,8 @@
-- saves are untouched, but its extracted cache lives under red/ like Blue,
-- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once
-- by CacheFs.migrateLegacyRedCache. All supported versions can be imported
-- and selected side by side. Gold is Gen 2 (see docs/gold-phase1.md).
-- and selected side by side. Gold and Silver are Gen 2 (see
-- docs/gold-phase1.md).
--
-- Zero requires, so it loads during love.conf and under plain Lua for tools
-- and tests. The active version is a process-global set once at boot from
@@ -61,13 +62,26 @@ GameVersion.VERSIONS = {
manifest = "tools/rom_manifest_gold.json",
cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated
saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp
-- The only row that carries one; absent reads as 1 (GameVersion.generation)
-- Absent reads as 1 (GameVersion.generation)
generation = 2,
},
-- Gold's engine with edition-selected data; the manifest is derived from
-- Gold's by tools/make_silver_manifest.py.
silver = {
id = "silver",
label = "Silver",
displayName = "Pokemon Silver",
launcherName = "Silver (Beta)",
sha1 = "49b163f7e57702bc939d642a18f591de55d92dae",
manifest = "tools/rom_manifest_silver.json",
cachePrefix = "silver/", -- silver/data/generated, silver/assets/generated
saveSuffix = "_silver", -- save_silver.lua / .bak / .tmp
generation = 2,
},
}
-- Launcher column order.
GameVersion.ORDER = { "red", "blue", "yellow", "gold" }
GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver" }
GameVersion.current = "red"
+86 -13
View File
@@ -6,6 +6,49 @@ local IssueReport = {}
local FORM_URL = "https://github.com/bryanthaboi/gen1recomp/issues/new"
local TEMPLATE = "bug_report.yml"
local APPLE_MODELS = {
["iPhone14,2"] = "iPhone 13 Pro",
["iPhone14,3"] = "iPhone 13 Pro Max",
["iPhone14,4"] = "iPhone 13 mini",
["iPhone14,5"] = "iPhone 13",
["iPhone14,7"] = "iPhone 14",
["iPhone14,8"] = "iPhone 14 Plus",
["iPhone15,2"] = "iPhone 14 Pro",
["iPhone15,3"] = "iPhone 14 Pro Max",
["iPhone15,4"] = "iPhone 15",
["iPhone15,5"] = "iPhone 15 Plus",
["iPhone16,1"] = "iPhone 15 Pro",
["iPhone16,2"] = "iPhone 15 Pro Max",
["iPhone17,1"] = "iPhone 16 Pro",
["iPhone17,2"] = "iPhone 16 Pro Max",
["iPhone17,3"] = "iPhone 16",
["iPhone17,4"] = "iPhone 16 Plus",
["iPhone17,5"] = "iPhone 16e",
["Mac14,2"] = "MacBook Air (13-inch, M2)",
["Mac14,3"] = "Mac mini (M2)",
["Mac14,5"] = "MacBook Pro (14-inch, M2 Max)",
["Mac14,6"] = "MacBook Pro (16-inch, M2 Max)",
["Mac14,7"] = "MacBook Pro (13-inch, M2)",
["Mac14,9"] = "MacBook Pro (14-inch, M2 Pro)",
["Mac14,10"] = "MacBook Pro (16-inch, M2 Pro)",
["Mac14,12"] = "Mac mini (M2 Pro)",
["Mac14,13"] = "Mac Studio (M2 Max)",
["Mac14,14"] = "Mac Studio (M2 Ultra)",
["Mac14,15"] = "MacBook Air (15-inch, M2)",
["Mac15,3"] = "MacBook Pro (14-inch, M3)",
["Mac15,6"] = "MacBook Pro (14-inch, M3 Pro)",
["Mac15,7"] = "MacBook Pro (16-inch, M3 Pro)",
["Mac15,12"] = "MacBook Air (13-inch, M3)",
["Mac15,13"] = "MacBook Air (15-inch, M3)",
["Mac16,1"] = "MacBook Pro (14-inch, M4)",
["Mac16,5"] = "MacBook Pro (16-inch, M4 Max)",
["Mac16,6"] = "MacBook Pro (14-inch, M4 Max)",
["Mac16,7"] = "MacBook Pro (16-inch, M4 Pro)",
["Mac16,8"] = "MacBook Pro (14-inch, M4 Pro)",
["Mac16,10"] = "Mac mini (M4)",
["Mac16,11"] = "Mac mini (M4 Pro)",
}
local function clean(value)
if value == nil then return nil end
local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "")
@@ -36,6 +79,16 @@ local function commandValue(command)
return clean(value)
end
local function commandText(command)
if not io or type(io.popen) ~= "function" then return nil end
local ok, pipe = pcall(io.popen, command, "r")
if not ok or not pipe then return nil end
local readOK, value = pcall(pipe.read, pipe, "*a")
pcall(pipe.close, pipe)
if not readOK then return nil end
return clean(value)
end
local function percentEncode(value)
local text = tostring(value or "")
return (text:gsub("([^%w%-_%.~])", function(char)
@@ -66,6 +119,25 @@ local function loveVersion()
return result
end
local function friendlyModel(identifier)
identifier = clean(identifier)
if not identifier then return nil end
return APPLE_MODELS[identifier] or identifier
end
local function macModel()
local details = commandText("system_profiler SPHardwareDataType 2>/dev/null")
if details then
local name = clean(details:match("Model Name:%s*([^\r\n]+)"))
local chip = clean(details:match("Chip:%s*([^\r\n]+)"))
if name and chip and not name:find(chip, 1, true) then
return name .. " (" .. chip .. ")"
end
if name then return name end
end
return friendlyModel(commandValue("sysctl -n hw.model 2>/dev/null"))
end
local function appVersion()
local version = clean(Version.engine)
if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end
@@ -73,20 +145,25 @@ local function appVersion()
end
local function deviceModel(rawOS, system)
local model = clean(call(system.getModel))
if model then return model end
local nativeModel = clean(call(system.getDeviceModel))
if nativeModel then return friendlyModel(nativeModel) end
if rawOS == "OS X" or rawOS == "macOS" then
return commandValue("sysctl -n hw.model 2>/dev/null")
return macModel()
end
local model = clean(call(system.getModel))
if model and not model:lower():find("gpu", 1, true)
and not model:lower():find("renderer", 1, true) then
return friendlyModel(model)
end
if rawOS == "Windows" then
return commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL")
return friendlyModel(commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL"))
end
if rawOS == "Linux" then
return commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null")
return friendlyModel(commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null"))
end
if rawOS == "Android" then
return commandValue("getprop ro.product.model 2>/dev/null")
return friendlyModel(commandValue("getprop ro.product.model 2>/dev/null"))
end
return nil
end
@@ -121,7 +198,7 @@ local function metadata(options, context)
local window = love and love.window or {}
local rawOS = clean(call(system.getOS))
local model = deviceModel(rawOS, system)
local renderer, rendererVersion, _, rendererDevice = call(graphics.getRendererInfo)
local renderer, rendererVersion = call(graphics.getRendererInfo)
local width, height = call(graphics.getDimensions)
local pixelWidth, pixelHeight = call(graphics.getPixelDimensions)
local modeWidth, modeHeight, flags = call(window.getMode)
@@ -134,11 +211,7 @@ local function metadata(options, context)
if value then lines[#lines + 1] = "- " .. label .. ": " .. value end
end
add("Platform", formOS(rawOS))
local hardware = model
if rendererDevice and rendererDevice ~= model then
hardware = hardware and (hardware .. " (" .. rendererDevice .. ")") or rendererDevice
end
add("Device", hardware)
add("Device", model)
local rendererDetails = clean(renderer)
if rendererDetails and clean(rendererVersion) then
rendererDetails = rendererDetails .. " " .. clean(rendererVersion)
+1
View File
@@ -39,6 +39,7 @@ local function normalizeVersion(v)
b = "blue", blue = "blue",
y = "yellow", yellow = "yellow",
g = "gold", gold = "gold",
s = "silver", silver = "silver",
}
v = alias[v] or v
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
+1 -1
View File
@@ -230,7 +230,7 @@ end
function Music.play(data, song, loop, ctx)
if not song then return end
if not love.audio then return end -- headless test stub
if not (love and love.audio) then return end -- headless test stub
ctx = ctx or {}
song = selectSong(song, ctx)
+115
View File
@@ -0,0 +1,115 @@
-- Recover a raster from a PDF that is really a wrapped JPEG. Delta skins
-- ship artwork that way so iOS can scale it; LOVE has no PDF renderer, so
-- import pulls the embedded image out instead of refusing the skin. True
-- vector PDFs (no Image XObject, no JPEG) still fail.
local PdfImage = {}
local function isPdf(bytes)
return type(bytes) == "string" and bytes:sub(1, 5) == "%PDF-"
end
-- After the `stream` keyword the spec allows \n or \r\n before the bytes.
-- `endstream` also contains the letters "stream", so skip that match.
local function streamDataStart(bytes, from)
local s, e = bytes:find("stream", from, true)
while s do
if s == 1 or bytes:sub(s - 3, s - 1) ~= "end" then
local p = e + 1
if bytes:sub(p, p) == "\r" then p = p + 1 end
if bytes:sub(p, p) == "\n" then p = p + 1 end
return p, s
end
s, e = bytes:find("stream", e + 1, true)
end
return nil
end
local function dictWindow(bytes, imageAt)
local from = imageAt > 400 and (imageAt - 400) or 1
local to = math.min(#bytes, imageAt + 800)
return bytes:sub(from, to)
end
local function dictNumber(window, key)
-- Prefer an indirect ref so `/Length 5 0 R` is not read as length 5.
if window:find("/" .. key .. "%s+%d+%s+%d+%s+R") then return nil end
return tonumber(window:match("/" .. key .. "%s+(%d+)"))
end
local function dictFilter(window)
local named = window:match("/Filter%s*/(%w+)")
if named then return named end
return window:match("/Filter%s*%[%s*/(%w+)")
end
local function jpegIn(bytes, from, to)
if from < 1 then from = 1 end
if not to or to > #bytes then to = #bytes end
if to < from then return nil end
local region = bytes:sub(from, to)
local soi = region:find("\255\216\255", 1, true)
if not soi then return nil end
local eoi = region:find("\255\217", soi + 3, true)
if not eoi then return nil end
return region:sub(soi, eoi + 1)
end
local function candidate(data, width, height, ext)
if not data or data == "" then return nil end
return {
data = data,
ext = ext or "jpg",
width = width or 0,
height = height or 0,
}
end
local function bigger(a, b)
if not a then return b end
if not b then return a end
local as = (a.width or 0) * (a.height or 0)
local bs = (b.width or 0) * (b.height or 0)
if bs ~= as then return bs > as and b or a end
return #b.data > #a.data and b or a
end
-- Walk Image XObjects and take the largest DCTDecode (JPEG) stream.
local function fromImageXObjects(bytes)
local best
local i = 1
while true do
local s, e = bytes:find("/Subtype%s*/Image", i)
if not s then break end
local window = dictWindow(bytes, s)
local filter = dictFilter(window)
local width = dictNumber(window, "Width")
local height = dictNumber(window, "Height")
local dataStart = streamDataStart(bytes, e)
i = e + 1
if dataStart and filter == "DCTDecode" then
local es = bytes:find("endstream", dataStart, true)
local jpeg = jpegIn(bytes, dataStart, es and (es - 1) or nil)
best = bigger(best, candidate(jpeg, width, height, "jpg"))
end
end
return best
end
-- Image-to-PDF converters (3-Heights, Preview, etc.) leave a single JPEG
-- body even when /Length is an indirect object we do not resolve.
local function fromBareJpeg(bytes)
local jpeg = jpegIn(bytes, 1, #bytes)
if not jpeg then return nil end
return candidate(jpeg, 0, 0, "jpg")
end
function PdfImage.extract(bytes)
if not isPdf(bytes) then return nil, "not a pdf" end
local best = fromImageXObjects(bytes)
if not best then best = fromBareJpeg(bytes) end
if not best then return nil, "no extractable image" end
return best
end
return PdfImage
+19 -5
View File
@@ -285,6 +285,7 @@ function SaveData.defaultOptions()
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
-- src/core/FaithfulRes.lua. Ignored on mobile.
faithfulRes = 0,
screenPos = "center",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- graphics performance tier: auto | high | balanced | low. "auto"
@@ -1279,13 +1280,26 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
local isFresh = save == freshPlaythrough
if isFresh then freshPlaythrough = nil end
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
id = not isFresh and byVersion and byVersion[scope] or nil
local existing = byVersion and byVersion[scope]
id = not isFresh and existing or nil
if type(id) ~= "string" or id == "" then
id = SaveData.newPlaythroughId()
opts.playthroughIds = opts.playthroughIds or {}
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
opts.playthroughIds[version][scope] = id
SaveData.saveOptions(opts, injectedFs)
-- A fresh skeleton still gets its own id (two unsaved New Games sharing a
-- slot must stay distinct), and it is still persisted when the slot has no
-- binding yet -- that is the contract a tool relies on to resolve
-- `selected` at the title after a restart, before any normal SAVE.
--
-- What it must NOT do is OVERWRITE a binding that already exists. newGame()
-- marks a skeleton on the boot frame, before any save is loaded, and mods
-- initialise inside that window -- so a mod touching storage at init
-- replaced the real save's id with a throwaway, stranding that save's mod
-- storage and repeating on every launch.
if not (isFresh and type(existing) == "string" and existing ~= "") then
opts.playthroughIds = opts.playthroughIds or {}
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
opts.playthroughIds[version][scope] = id
SaveData.saveOptions(opts, injectedFs)
end
end
save.meta.playthroughId = id
return id
+66
View File
@@ -0,0 +1,66 @@
local ScreenPosition = {}
ScreenPosition.MODES = { "center", "upper", "top" }
ScreenPosition.DEFAULT = "center"
ScreenPosition.mode = ScreenPosition.DEFAULT
local LABELS = { center = "CENTER", upper = "UPPER", top = "TOP" }
function ScreenPosition.normalize(v)
if LABELS[v] then return v end
return ScreenPosition.DEFAULT
end
function ScreenPosition.label(v)
return LABELS[ScreenPosition.normalize(v)]
end
function ScreenPosition.cycle(v, dir)
v = ScreenPosition.normalize(v)
local modes = ScreenPosition.MODES
local cur = 1
for i, mode in ipairs(modes) do
if mode == v then cur = i break end
end
return modes[(cur - 1 + (dir or 1)) % #modes + 1]
end
function ScreenPosition.setMode(v)
ScreenPosition.mode = ScreenPosition.normalize(v)
end
function ScreenPosition.applyOptions(opts)
ScreenPosition.setMode(opts and opts.screenPos)
end
function ScreenPosition.safeTop()
local ok, SafeArea = pcall(require, "src.core.SafeArea")
if not ok then return 0 end
local okr, _, y = pcall(SafeArea.rect)
if not okr then return 0 end
return math.max(0, tonumber(y) or 0)
end
function ScreenPosition.skinActive(w, h)
local ok, TouchSkin = pcall(require, "src.core.TouchSkin")
if not ok or type(TouchSkin.viewport) ~= "function" then return false end
local okv, x = pcall(TouchSkin.viewport, w, h)
return okv and x ~= nil
end
function ScreenPosition.lift(viewH, contentH, safeTop)
if ScreenPosition.mode == "center" then return 0 end
viewH = tonumber(viewH) or 0
contentH = tonumber(contentH) or 0
local slack = viewH - contentH
if slack <= 0 then return 0 end
local centered = math.floor(slack / 2)
local target = ScreenPosition.mode == "top" and 0 or math.floor(slack / 4)
safeTop = math.floor(tonumber(safeTop) or 0)
if safeTop > 0 and target < safeTop then
target = math.min(safeTop, centered)
end
return centered - target
end
return ScreenPosition
+1 -1
View File
@@ -218,7 +218,7 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale)
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
local ok, GameVersion = pcall(require, "src.core.GameVersion")
if ok and GameVersion.isGold and GameVersion.isGold() then
if ok and GameVersion.generation and GameVersion.generation() == 2 then
margin = math.max(margin, math.min(ww * 0.10, 72))
end
return {
+105 -11
View File
@@ -432,6 +432,10 @@ function TouchSkin.parseNative(text)
aspectFromCfg = raw.fitAspect == true,
orient = (raw.orient == "portrait" or raw.orient == "landscape"
or raw.orient == "any") and raw.orient or nil,
screenFit = raw.screenFit == "remainder" and "remainder" or nil,
anchor = (raw.anchor == "top" or raw.anchor == "bottom"
or raw.anchor == "left" or raw.anchor == "right")
and raw.anchor or nil,
rect = { x = 0, y = 0, w = 1, h = 1 },
controls = {},
}
@@ -507,6 +511,8 @@ function TouchSkin.toNative(skin)
alphaMod = page.alphaMod,
aspect = page.aspect,
fitAspect = page.aspectFromCfg or nil,
screenFit = page.screenFit == "remainder" and "remainder" or nil,
anchor = page.anchor,
orient = (page.orient == "portrait" or page.orient == "landscape"
or page.orient == "any") and page.orient or nil,
controls = {},
@@ -570,6 +576,40 @@ local function loadImage(path)
return img
end
-- FileData so LOVE sniffs JPEG/PNG from the name, not a path inside the zip.
local function loadImageFromBytes(bytes, name)
if not bytes or bytes == "" then return nil end
if not (love and love.graphics and love.graphics.newImage) then return nil end
if not (love.filesystem and love.filesystem.newFileData) then return nil end
local key = "bytes:" .. tostring(name) .. ":" .. tostring(#bytes)
local cached = imageCache[key]
if cached then return cached end
local okFd, fd = pcall(love.filesystem.newFileData, bytes, name or "bezel.jpg")
if not okFd or not fd then return nil end
local ok, img = pcall(love.graphics.newImage, fd)
if not ok or not img then
if love.image and love.image.newImageData then
local okData, data = pcall(love.image.newImageData, fd)
if okData and data then ok, img = pcall(love.graphics.newImage, data) end
end
end
if not ok or not img then return nil end
if img.setFilter then img:setFilter("linear", "linear") end
imageCache[key] = img
return img
end
local function rasterizePdfPage(page, root)
if not page or page.image or not page.pdfPath then return end
local pdf = readFile(joinPath(root, page.pdfPath))
local raster = require("src.core.PdfImage").extract(pdf)
if not raster then return end
local name = tostring(page.pdfPath):gsub("%.[Pp][Dd][Ff]$", "") .. "." .. raster.ext
page.rasterData = raster.data
page.rasterName = name:match("([^/]+)$") or name
page.image = loadImageFromBytes(raster.data, page.rasterName)
end
local function pixelScalePending(page)
if page.pixelCoords then return true end
for _, ctl in ipairs(page.controls or {}) do
@@ -622,6 +662,8 @@ function TouchSkin.load(root, id)
for _, page in ipairs(skin.pages) do
if page.imagePath then
page.image = loadImage(joinPath(root, page.imagePath))
elseif page.pdfPath then
rasterizePdfPage(page, root)
end
if not applyPixelScale(page) then
return nil, "could not read " .. tostring(page.imagePath)
@@ -646,7 +688,7 @@ end
TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true }
TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
TouchSkin.PDF_ONLY_MESSAGE =
"This skin uses PDF artwork, which cannot be imported yet. "
"This skin uses PDF artwork with no extractable image. "
.. "Ask the author for a PNG version."
function TouchSkin.archiveId(name)
@@ -1246,12 +1288,27 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
and page.aspect and page.aspect > 0 and h > 0
if fit then
local displayAspect = w / h
local anchor = page.anchor
if displayAspect > page.aspect then
bw = h * page.aspect
bx = ox + (w - bw) * 0.5
local extra = w - bw
if anchor == "right" then
bx = ox + extra
elseif anchor == "left" then
bx = ox
else
bx = ox + extra * 0.5
end
else
bh = w / page.aspect
by = oy + (h - bh) * 0.5
local extra = h - bh
if anchor == "bottom" then
by = oy + extra
elseif anchor == "top" then
by = oy
else
by = oy + extra * 0.5
end
end
end
local r = page.rect
@@ -1308,18 +1365,55 @@ end
function TouchSkin.hasViewport()
local page = TouchSkin.page()
return page ~= nil and page.viewport ~= nil and TouchSkin.drawable()
if not page or not TouchSkin.drawable() then return false end
return page.viewport ~= nil or page.screenFit == "remainder"
end
-- Largest strip of (ox,oy,w,h) that does not overlap the overlay box.
local function remainderBox(ox, oy, w, h, bx, by, bw, bh)
local right, bottom = ox + w, oy + h
local cand = {
{ ox, oy, w, by - oy },
{ ox, by + bh, w, bottom - (by + bh) },
{ ox, oy, bx - ox, h },
{ bx + bw, oy, right - (bx + bw), h },
}
local best, bestArea
for _, r in ipairs(cand) do
if r[3] > 1 and r[4] > 1 then
local area = r[3] * r[4]
if not best or area > bestArea then
best, bestArea = r, area
end
end
end
if not best then return nil end
return best[1], best[2], best[3], best[4]
end
function TouchSkin.pageViewport(page, w, h, ox, oy)
if not page then return nil end
ox, oy = ox or 0, oy or 0
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
if page.viewport then
local v = page.viewport
local x, y = bx + v.x * bw, by + v.y * bh
local vw, vh = v.w * bw, v.h * bh
if vw <= 0 or vh <= 0 then return nil end
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
end
if page.screenFit == "remainder" then
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
if not x then return nil end
return x, y, vw, vh, false, false
end
return nil
end
function TouchSkin.viewport(w, h, ox, oy)
local page = TouchSkin.page()
if not page or not page.viewport or not TouchSkin.drawable() then return nil end
local v = page.viewport
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
local x, y = bx + v.x * bw, by + v.y * bh
local vw, vh = v.w * bw, v.h * bh
if vw <= 0 or vh <= 0 then return nil end
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
if not page or not TouchSkin.drawable() then return nil end
return TouchSkin.pageViewport(page, w, h, ox, oy)
end
return TouchSkin
+26 -13
View File
@@ -103,7 +103,7 @@ Save.PLAYER_STATES = {
}
local function saveNames(version)
version = version or "gold"
version = version or GameVersion.get()
-- Resolve the ACTIVE SLOT the same way SaveData does, and only fall back to
-- the flat save_<version>.lua when no slot is registered.
--
@@ -133,16 +133,22 @@ local function fs()
return love.filesystem
end
-- A fresh Gold save. `opts` carries what the intro collected: player name,
-- The blank-name fallback is the first PlayerNameArray row, which differs
-- per edition -- data/player_names.asm:12-23.
function Save.defaultPlayerName(version)
return (version or GameVersion.get()) == "silver" and "SILVER" or "GOLD"
end
-- A fresh Gen 2 save. `opts` carries what the intro collected: player name,
-- rival name, and the options the OPTION screen was left on.
function Save.newGame(opts)
opts = opts or {}
local save = {
format = Save.FORMAT,
version = "gold",
version = GameVersion.get(),
generation = 2,
player = {
name = opts.playerName or "GOLD",
name = opts.playerName or Save.defaultPlayerName(),
-- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd
-- (engine/menus/intro_menu.asm:41-49).
id = opts.trainerId or rand(0, 65535),
@@ -282,6 +288,7 @@ Save.DEFAULT_OPTIONS = {
musicFilter = 0, -- low-pass steps, 0 = off
haptics = "light",
touchControls = { enabled = true },
screenPos = "center",
}
function Save.defaultOptions()
@@ -302,7 +309,7 @@ end
Save.OPTIONS_KEY = "gold"
local SHARED_KEYS = {
touchControls = true, haptics = true,
touchControls = true, haptics = true, screenPos = true,
mods = true, modsByVersion = true, modsGen2 = true,
modOptions = true, modProfiles = true, modProfilesSeeded = true,
activeProfile = true,
@@ -374,10 +381,13 @@ end
function Save.normalize(save)
if type(save) ~= "table" then return nil end
save.format = save.format or Save.FORMAT
save.version = "gold"
if not (GameVersion.VERSIONS[save.version]
and GameVersion.generation(save.version) == 2) then
save.version = GameVersion.get()
end
save.generation = 2
save.player = save.player or {}
save.player.name = save.player.name or "GOLD"
save.player.name = save.player.name or Save.defaultPlayerName(save.version)
save.player.id = save.player.id or rand(0, 65535)
save.player.money = math.max(0, math.min(save.player.money or 0, Save.MAX_MONEY))
save.player.coins = math.max(0, math.min(save.player.coins or 0, Save.MAX_COINS))
@@ -753,21 +763,24 @@ end
-- copy is the witness that survives a crash mid-replace.
function Save.save(save)
if type(save) ~= "table" then return false, "no save" end
if (save.version or "gold") == "gold" then
Save.normalize(save)
local version = save.version
do
local ok, SaveData = pcall(require, "src.core.SaveData")
if ok and SaveData.activeSlot and not SaveData.activeSlot("gold") then
local id = SaveData.createSlot and SaveData.createSlot("gold")
if id and SaveData.setActiveSlot then SaveData.setActiveSlot("gold", id) end
if ok and SaveData.activeSlot and not SaveData.activeSlot(version) then
local id = SaveData.createSlot and SaveData.createSlot(version)
if id and SaveData.setActiveSlot then
SaveData.setActiveSlot(version, id)
end
end
end
local main, backup, tmp = saveNames(save.version)
local main, backup, tmp = saveNames(version)
local f = fs()
if not f then return false, "no filesystem" end
-- saveNames may now return a saves/<version>/<slot>.lua path, and
-- love.filesystem.write does not create missing parent directories.
local dir = main:match("^(.*)/[^/]+$")
if dir and f.createDirectory then f.createDirectory(dir) end
Save.normalize(save)
save.savedAt = os.time()
local encoded = SaveSerializer.encode(save)
if f.getInfo(main) then
+4 -2
View File
@@ -249,10 +249,12 @@ function SwitchDiagnostics.probeAssets(version)
end
-- Shallow listing so we can see if the extract tree exists at all.
local roots = { "yellow", "blue", "gold", "assets", "yellow/assets/generated",
local roots = { "yellow", "blue", "gold", "silver", "assets",
"yellow/assets/generated",
"yellow/assets/generated/sprites", "blue/assets/generated/sprites",
"gold/assets/generated", "gold/assets/generated/sprites",
"gold/data/generated" }
"gold/data/generated", "silver/assets/generated",
"silver/data/generated" }
for _, dir in ipairs(roots) do
local info = filesystem.getInfo(dir)
if info and info.type == "directory" and filesystem.getDirectoryItems then
+2 -1
View File
@@ -27,7 +27,8 @@ local ok, err = pcall(function()
CacheFs.prefix = prefix
local manifest = require("src.import.RomManifest").decode(version)
local RomExtractor = version == "gold"
local RomExtractor =
require("src.core.GameVersion").generation(version) == 2
and require("src.import.RomExtractorGen2")
or require("src.import.RomExtractor")
+18 -31
View File
@@ -332,6 +332,17 @@ local function coreRows(opts, hooks)
end)
end
local okSp, ScreenPos = pcall(require, "src.core.ScreenPosition")
if okSp then
add(Strings("SCREEN POS"),
function() return Strings(ScreenPos.label(opts.screenPos)) end,
function(dir)
opts.screenPos = ScreenPos.cycle(opts.screenPos, dir)
ScreenPos.setMode(opts.screenPos)
return true
end)
end
local okCap, FrameCap = pcall(require, "src.core.FrameCap")
if okCap then
add(Strings("MAX FPS"),
@@ -541,29 +552,6 @@ local function modRows(opts, mod)
return rows
end
local function troubleshootingRows(opts, hooks)
return {
{
label = Strings("SAFE MODE"),
actionLabel = function()
return SaveData.isSafeMode(opts) and Strings("Turn off") or Strings("Turn on")
end,
action = function()
SaveData.setSafeMode(opts, not SaveData.isSafeMode(opts))
return true
end,
},
{
label = Strings("REPORT ISSUE"),
actionLabel = Strings("Report bug"),
action = function()
if hooks and hooks.reportIssue then hooks.reportIssue(opts) end
return false
end,
},
}
end
-- ------- Gen 2 (Gold)
--
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
@@ -573,9 +561,10 @@ end
-- opened on the Gold tab used to offer a dozen controls that did nothing
-- and hide the seven that the cart itself has.
--
-- The block lives in options.lua under `gold`, which is exactly where
-- src/core/gen2/Save.lua loadOptions reads it, so an edit here is live on the
-- next boot the same way a Gen 1 edit is. Ladders mirror
-- The block lives in options.lua under `gold` (the historical key; Gold and
-- Silver share it the way the Gen 1 games share the flat namespace), which is
-- exactly where src/core/gen2/Save.lua loadOptions reads it, so an edit here
-- is live on the next boot the same way a Gen 1 edit is. Ladders mirror
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
local GEN2_KEY = "gold"
@@ -722,7 +711,9 @@ end
function LauncherSettings.open(hooks, version)
local opts = SaveData.loadOptions()
local sections
if version == "gold" then
local GameVersion = require("src.core.GameVersion")
if GameVersion.VERSIONS[version]
and GameVersion.generation(version) == 2 then
local block = opts[GEN2_KEY]
if type(block) ~= "table" then
block = {}
@@ -744,10 +735,6 @@ function LauncherSettings.open(hooks, version)
sections[#sections + 1] = { title = mod.name, rows = rows }
end
end
sections[#sections + 1] = {
title = Strings("TROUBLESHOOTING"),
rows = troubleshootingRows(opts, hooks),
}
return {
opts = opts,
version = version,
+421 -231
View File
@@ -14,9 +14,9 @@
-- height, and flex-shrink compressing text until it overlapped.
--
-- THE RULES THIS FILE FOLLOWS:
-- * Lists paginate (Kit.pager). The installed-mod list also scrolls inside
-- its viewport, so each of its pages can hold at least ten entries without
-- requiring a tall window. Pages still bound how many mod rows we visit.
-- * Short lists paginate (Kit.pager, perPage from Kit.rowsThatFit); the
-- installed-mod list is one continuous scroll instead, drawing only the
-- rows inside the region viewport so the window bounds the frame cost.
-- * Every click handler only QUEUES work (imp._uiActions); update() drains
-- the queue, so an action that tears the view down (Play, Edit save)
-- never runs inside the frame that dispatched it.
@@ -45,9 +45,6 @@ local COMMUNITY_URL = "https://bois.icu"
local ACT_DEDUP = 0.35
-- Finger travel past this (px) is a drag, not a tap.
local TAP_SLOP2 = 16 * 16
-- Installed mods should not turn into a one- or two-item pager on a compact
-- display. Keep a useful page size, then let the list viewport scroll.
local MIN_MODS_PER_PAGE = 10
local MIN_SKIN_ROWS = 4
local SKIN_FORMAT_LABEL = {
native = "GEN1",
@@ -81,15 +78,6 @@ local function setTabScroll(imp, value)
imp._tabScroll[tabKeyOf(imp)] = clamp(value, 0, tabScrollMax(imp))
end
local function modListWantsWheel(imp, wheel)
if imp.tab ~= "mods" or (imp._modScrollMax or 0) <= 0 then return false end
if not inRect(imp._modListRect, Kit.mouseX, Kit.mouseY) then return false end
if not inRect(imp._tabRegionRect, Kit.mouseX, Kit.mouseY) then return false end
local at = clamp(imp.modScroll or 0, 0, imp._modScrollMax)
if wheel < 0 then return at < imp._modScrollMax end
return at > 0
end
-- ------------------------------------------------------------- lifecycle
local function ensureState(imp)
@@ -136,12 +124,48 @@ function LauncherView.detach(imp)
Kit.clearCaches()
end
local function markNoDrag(imp, x, y, w, h)
if Kit.blockClicks then return end
local t = imp._noDragRects
if not t then t = {}; imp._noDragRects = t end
local n = (imp._noDragN or 0) + 1
imp._noDragN = n
local r = t[n]
if not r then r = {}; t[n] = r end
r.x, r.y, r.w, r.h = x, y, w, h
end
local function noDragAt(imp, x, y)
local rects = imp._noDragRects
for i = 1, imp._noDragN or 0 do
if inRect(rects[i], x, y) then return true end
end
return false
end
local function armMouse(imp, x, y)
if noDragAt(imp, x, y) then
imp._clickPt = { x = x, y = y }
return
end
local shielded = imp._modalUpNow
imp._mouseAt = {
x = x, y = y,
region = not shielded and tabScrollMax(imp) > 0
and inRect(imp._tabRegionRect, x, y) or false,
page = not shielded and (imp._pageScrollMax or 0) > 0 or false,
}
Kit.dragBegin(x, y)
end
-- ---------------------------------------------------------------- input
-- The kit is polled, not evented: update() samples the mouse and turns a
-- rising edge into a click point that the next draw consumes. Host-forwarded
-- mousepressed stays unused, exactly as before, so Android's synthesized
-- mouse path cannot double-fire a tap (#553) -- the dedup window below is the
-- other half of that guarantee.
-- The kit is polled, not evented: update() samples the mouse. A press arms
-- a drag that scrolls like a finger, and the click dispatches on RELEASE so
-- the drag can disqualify it, exactly like the touch path below; only the
-- cartridge (which owns its own spin-drag) keeps the press-down click.
-- Host-forwarded mousepressed stays unused, exactly as before, so Android's
-- synthesized mouse path cannot double-fire a tap (#553) -- the dedup window
-- below is the other half of that guarantee.
function LauncherView.update(imp, dt)
if not imp._flex then return end
if imp._launchFade then return end
@@ -166,7 +190,39 @@ function LauncherView.update(imp, dt)
if not touching and now >= (imp._suppressMouseUntil or 0)
and now >= (imp._suppressClickUntil or 0) then
local mx, my = love.mouse.getPosition()
imp._clickPt = { x = mx, y = my }
armMouse(imp, mx, my)
end
elseif down and imp._mouseAt then
local start = imp._mouseAt
local mx, my = love.mouse.getPosition()
local ddx, ddy = mx - start.x, my - start.y
if ddx * ddx + ddy * ddy > TAP_SLOP2 then
start.dragged = true
end
if start.dragged then
local last = start.lastY or start.y
local move = -(my - last)
if move ~= 0 and start.region then
local at, leftover = Kit.scrollHandoff(tabScrollAt(imp),
tabScrollMax(imp), move)
setTabScroll(imp, at)
move = leftover
end
if move ~= 0 and start.page and (imp._pageScrollMax or 0) > 0 then
local at, leftover = Kit.scrollHandoff(imp._pageScroll or 0,
imp._pageScrollMax, move)
imp._pageScroll = at
move = leftover
end
if move ~= 0 then Kit.dragAdd(move) end
end
start.lastY = my
elseif not down and imp._mouseAt then
local start = imp._mouseAt
imp._mouseAt = nil
Kit.dragEnd()
if not start.dragged then
imp._clickPt = { x = start.x, y = start.y }
end
end
imp._prevMouseDown = down
@@ -192,9 +248,6 @@ function LauncherView.touchpressed(imp, id, x, y)
imp._touchAt = imp._touchAt or {}
imp._touchAt[tostring(id)] = {
x = x, y = y,
modsList = imp.tab == "mods" and (imp._modScrollMax or 0) > 0
and inRect(imp._modListRect, x, y)
and inRect(imp._tabRegionRect, x, y),
region = tabScrollMax(imp) > 0 and inRect(imp._tabRegionRect, x, y),
}
end
@@ -207,19 +260,9 @@ function LauncherView.touchmoved(imp, id, x, y)
if ddx * ddx + ddy * ddy > TAP_SLOP2 then
start.dragged = true
end
-- A drag that began in the installed-mod viewport scrolls that page's
-- rows. Its pager remains available for moving to the next ten-plus
-- entries; a drag elsewhere keeps the normal short-window page scroll.
if start.dragged then
local last = start.lastY or start.y
local move = -(y - last)
if start.modsList then
local listMax = imp._modScrollMax or 0
local at, leftover = Kit.scrollHandoff(
clamp(imp.modScroll or 0, 0, listMax), listMax, move)
imp.modScroll = at
move = leftover
end
if move ~= 0 and start.region then
local at, leftover = Kit.scrollHandoff(tabScrollAt(imp),
tabScrollMax(imp), move)
@@ -258,6 +301,24 @@ function LauncherView.clickAt(imp, x, y)
imp._clickPt = { x = x, y = y }
end
-- Event-driven press: a macOS trackpad tap delivers press+release inside one
-- frame, so update()'s love.mouse.isDown poll never sees it. Arm the drag
-- from the press event under the poll's own suppression rules -- the poll's
-- release branch then mints the tap, still within the same frame for a
-- one-frame tap -- and mark the press seen so the poll cannot arm a second
-- one when isDown does catch it.
function LauncherView.mousepressed(imp, x, y)
if not imp._flex then return end
local now = love.timer.getTime()
local touching = imp._touchAt ~= nil and next(imp._touchAt) ~= nil
if touching or now < (imp._suppressMouseUntil or 0)
or now < (imp._suppressClickUntil or 0) then
return
end
if not imp._mouseAt then armMouse(imp, x, y) end
imp._prevMouseDown = true
end
-- Keyboard focus ring. Returns true when the key was consumed. Arrows arm
-- the ring; Enter only activates a focused control once the user has actually
-- used the arrows this session, so the long-standing "Enter plays the visible
@@ -343,7 +404,7 @@ end
local CART_COLOR = {
red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold,
gold = PAL.railAmber,
gold = PAL.railAmber, silver = PAL.railSilver,
}
local function cartColor(version)
return CART_COLOR[version] or PAL.green
@@ -406,16 +467,25 @@ local function cartQuad(project, x, y, w, h, z)
}
end
local function cartFacing(points)
local area = 0
for i = 1, #points do
local a, b = points[i], points[i % #points + 1]
area = area + a[1] * b[2] - b[1] * a[2]
end
return area > 0
end
local function cartPill(project, x, y, w, h, z, color, alpha)
local points, radius = {}, h / 2
for i = 0, 10 do
local a = math.pi + math.pi * i / 10
points[#points + 1] = { project(x + radius + math.cos(a) * radius,
local a = -math.pi / 2 + math.pi * i / 10
points[#points + 1] = { project(x + w - radius + math.cos(a) * radius,
y + radius + math.sin(a) * radius, z) }
end
for i = 0, 10 do
local a = math.pi * i / 10
points[#points + 1] = { project(x + w - radius + math.cos(a) * radius,
local a = math.pi / 2 + math.pi * i / 10
points[#points + 1] = { project(x + radius + math.cos(a) * radius,
y + radius + math.sin(a) * radius, z) }
end
cartPolygon(points, color, alpha)
@@ -495,6 +565,7 @@ end
local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
local state = cartridgeState(imp, version)
markNoDrag(imp, x, y, w, h)
local focused = Kit.focusable(key, x, y, w, h)
local hot = Kit.hover(x, y, w, h)
local active = state.active
@@ -596,7 +667,7 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
end
local halfW, halfH = w / 2, h / 2
local depth = math.max(8, w * 0.14)
local depth = math.max(6, w * 0.10)
local project = function(px, py, pz)
return cartProject(cx + pressX, cy + pressY, yaw, pitch,
px * pressedScale, py * pressedScale, pz * pressedScale)
@@ -628,51 +699,115 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
local side = { math.floor(shell[1] * 0.54), math.floor(shell[2] * 0.54),
math.floor(shell[3] * 0.54) }
cartPolygon(mainBack, side, 1)
cartPolygon(capBack, side, 1)
local frontFacing = cartFacing(mainFront)
if frontFacing then
cartPolygon(mainBack, side, 1)
cartPolygon(capBack, side, 1)
else
cartPolygon(mainFront, shell, 1)
cartPolygon(capFront, shell, 1)
end
cartPolygon({ mainFront[2], mainFront[3], mainBack[3], mainBack[2] }, side, 1)
cartPolygon({ mainFront[3], mainFront[4], mainBack[4], mainBack[3] }, side, 1)
cartPolygon({ mainFront[1], mainFront[2], mainBack[2], mainBack[1] }, side, 1)
cartPolygon({ mainFront[4], mainFront[1], mainBack[1], mainBack[4] }, side, 1)
cartPolygon({ capFront[2], capFront[3], capBack[3], capBack[2] }, side, 1)
cartPolygon({ capFront[1], capFront[2], capBack[2], capBack[1] }, side, 1)
cartPolygon({ capFront[4], capFront[1], capBack[1], capBack[4] }, side, 1)
cartPolygon(mainFront, shell, 1)
cartPolygon(capFront, shell, 1)
local faceZ = depth + 0.8
for i = 0, 4 do
local ry = mainTop + 7 + i * h * 0.025
cartPolygon(cartQuad(project, -halfW + 2, ry, w * 0.13, 2, faceZ), side, 0.7)
cartPolygon(cartQuad(project, halfW - w * 0.13 - 2, ry, w * 0.13, 2, faceZ), side, 0.7)
if frontFacing then
cartPolygon(mainFront, shell, 1)
cartPolygon(capFront, shell, 1)
else
cartPolygon(mainBack, side, 1)
cartPolygon(capBack, side, 1)
-- The tri-wing security screw: a domed brass head with three teardrop
-- recesses pinwheeled at 120 degrees.
local backZ = -(depth + 0.8)
local sd = math.min(w, h) * 0.11
cartPill(project, -sd * 0.62, -sd * 0.62, sd * 1.24, sd * 1.24, backZ,
{ math.floor(shell[1] * 0.4), math.floor(shell[2] * 0.4),
math.floor(shell[3] * 0.4) }, 0.9)
cartPill(project, -sd / 2, -sd / 2, sd, sd, backZ - 0.4,
{ 196, 186, 148 }, 1)
cartPill(project, -sd * 0.32, -sd * 0.32, sd * 0.64, sd * 0.64,
backZ - 0.6, { 220, 212, 178 }, 0.8)
local r = sd / 2
for k = 0, 2 do
local a = -math.pi / 2 + k * (2 * math.pi / 3)
local ux, uy = math.cos(a), math.sin(a)
local vx, vy = -uy, ux
local r0, r1 = r * 0.16, r * 0.82
local w0, w1 = r * 0.13, r * 0.3
cartPolygon({
{ project(ux * r0 + vx * w0, uy * r0 + vy * w0, backZ - 0.8) },
{ project(ux * r0 - vx * w0, uy * r0 - vy * w0, backZ - 0.8) },
{ project(ux * r1 - vx * w1, uy * r1 - vy * w1, backZ - 0.8) },
{ project(ux * r1 + vx * w1, uy * r1 + vy * w1, backZ - 0.8) },
}, { 112, 104, 76 }, 1)
end
end
local recessX, recessY = -w * 0.32, mainTop + h * 0.023
local recessW, recessH = w * 0.64, h * 0.24
cartPolygon(cartQuad(project, recessX, recessY, recessW, recessH, faceZ), shell, 0.88)
cartPill(project, recessX + w * 0.025, recessY + h * 0.025,
recessW - w * 0.05, h * 0.12, faceZ + 0.5, shell, 0.7)
cartPill(project, recessX + w * 0.045, recessY + h * 0.043,
recessW - w * 0.09, h * 0.083, faceZ + 0.8, side, 0.42)
local labelX, labelY = -w * 0.33, -h * 0.20
local labelW, labelH = w * 0.66, h * 0.55
local plate = cartQuad(project, labelX - 2, labelY - 2, labelW + 4, labelH + 4, faceZ + 0.8)
cartPolygon(plate, side, 0.95)
local labelPoints = cartQuad(project, labelX, labelY, labelW, labelH, faceZ + 1.2)
local label = cartridgeLabel(imp, version)
local mesh = label and cartLabelMesh(imp, version, label, labelPoints)
if mesh then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(mesh)
elseif label then
local artScale = math.min(labelW / label.width, labelH / label.height)
love.graphics.draw(label.image, labelPoints[1][1], labelPoints[1][2],
0, artScale, artScale)
if frontFacing then
local faceZ = depth + 0.8
-- The shell's grip grooves: a stack beside the label recess on the left,
-- and one below the top-right corner notch, like the DMG cart.
local grooveW = w * 0.115
local grooveH = math.max(1, h * 0.009)
for i = 0, 5 do
local ry = mainTop + h * 0.014 + i * h * 0.021
cartPolygon(cartQuad(project, -halfW + w * 0.02, ry,
grooveW, grooveH, faceZ), side, 0.7)
cartPolygon(cartQuad(project, halfW - grooveW - w * 0.02, ry,
grooveW, grooveH, faceZ), side, 0.7)
end
-- The thin diagonal mold ridge cut into each long side a little below
-- the grip grooves, mirrored left/right.
local function diagonal(x0, y0, x1, y1)
local dx, dy = x1 - x0, y1 - y0
local len = math.sqrt(dx * dx + dy * dy)
local nx, ny = -dy / len, dx / len
local t = math.max(0.6, h * 0.004)
cartPolygon({
{ project(x0 + nx * t, y0 + ny * t, faceZ) },
{ project(x0 - nx * t, y0 - ny * t, faceZ) },
{ project(x1 - nx * t, y1 - ny * t, faceZ) },
{ project(x1 + nx * t, y1 + ny * t, faceZ) },
}, side, 0.7)
end
local dgY = mainTop + h * 0.20
diagonal(-halfW + w * 0.006, dgY, -halfW + w * 0.085, dgY + h * 0.038)
diagonal(halfW - w * 0.006, dgY, halfW - w * 0.085, dgY + h * 0.038)
-- The Nintendo GAME BOY recess: one stadium pill sunk into the shell.
local pillX, pillW = -halfW + w * 0.17, w * 0.62
local pillY, pillH = mainTop + h * 0.024, h * 0.115
cartPill(project, pillX, pillY, pillW, pillH, faceZ + 0.5, side, 0.55)
local inX, inY = w * 0.008, h * 0.008
cartPill(project, pillX + inX, pillY + inY,
pillW - 2 * inX, pillH - 2 * inY, faceZ + 0.8,
{ math.floor(shell[1] * 0.92), math.floor(shell[2] * 0.92),
math.floor(shell[3] * 0.92) }, 1)
local labelX, labelY = -w * 0.33, -h * 0.20
local labelW, labelH = w * 0.66, h * 0.55
local plate = cartQuad(project, labelX - 2, labelY - 2, labelW + 4, labelH + 4, faceZ + 0.8)
cartPolygon(plate, side, 0.95)
local labelPoints = cartQuad(project, labelX, labelY, labelW, labelH, faceZ + 1.2)
local label = cartridgeLabel(imp, version)
local mesh = label and cartLabelMesh(imp, version, label, labelPoints)
if mesh then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(mesh)
elseif label then
local artScale = math.min(labelW / label.width, labelH / label.height)
love.graphics.draw(label.image, labelPoints[1][1], labelPoints[1][2],
0, artScale, artScale)
end
cartPolygon({
{ project(-w * 0.07, h * 0.37, faceZ + 1) },
{ project(w * 0.07, h * 0.37, faceZ + 1) },
{ project(0, h * 0.43, faceZ + 1) },
}, side, 0.70)
end
cartPolygon({
{ project(-w * 0.07, h * 0.37, faceZ + 1) },
{ project(w * 0.07, h * 0.37, faceZ + 1) },
{ project(0, h * 0.43, faceZ + 1) },
}, side, 0.70)
love.graphics.pop()
if not state.active and (Kit._activateId == key) then
@@ -920,16 +1055,21 @@ local GAME_TABS = {
label = "Yellow" },
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber,
label = "Gold" },
{ id = "silver", key = "tab-silver", letter = "S", color = PAL.railSilver,
label = "Silver" },
}
local HEADER_TABS = {
{ id = "mods", key = "tab-mods" },
{ id = "find", key = "tab-find" },
{ id = "skins", key = "tab-skins", glyph = true },
{ id = "bug", key = "tab-bug" },
}
for _, t in ipairs(HEADER_TABS) do
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
if t.glyph then t.opts.drawFn = drawSkinGlyph end
if t.glyph then
t.opts.drawFn = drawSkinGlyph
end
end
-- Which cartridge the dropdown is showing: the open game tab, else the last
@@ -1067,6 +1207,8 @@ local function buildHeader(imp, m)
or love.graphics.newImage("assets/launcher/mods.png")
imp._findIcon = imp._findIcon
or love.graphics.newImage("assets/launcher/find.png")
imp._bugIcon = imp._bugIcon
or love.graphics.newImage("assets/launcher/bug.png")
-- Game tabs keep their cartridge colours -- that is the one piece of brand
-- identity in the launcher, and "the red one" is how people actually refer
-- to these. The colour rides the outline and the glyph at rest and becomes
@@ -1077,6 +1219,7 @@ local function buildHeader(imp, m)
for _, t in ipairs(tabs) do
if t.id == "mods" then t.icon = imp._modsIcon end
if t.id == "find" then t.icon = imp._findIcon end
if t.id == "bug" then t.icon = imp._bugIcon end
end
local tabH = m.chip
local tx = m.x + m.pad
@@ -1086,16 +1229,11 @@ local function buildHeader(imp, m)
local tabGap = math.floor(6 * m.s)
local tabRowGap = math.floor(4 * m.s)
-- the cartridge dropdown, sized to its longest label so switching games
-- never reflows the row
-- the cartridge dropdown: just the game's initial and the caret; the
-- popup list carries the full names
local chrome0 = headerChrome(imp)
local game = currentGame(imp)
local labelW = 0
for _, g in ipairs(GAME_TABS) do
labelW = math.max(labelW, Kit.textWidth("tab", Strings(g.label)))
end
local dropW = math.min(tabRight - tabLeft,
tabH + labelW + math.floor(34 * m.s))
local dropW = math.min(tabRight - tabLeft, tabH + math.floor(24 * m.s))
chrome0.game.color = game.color
chrome0.game.letter = game.letter
chrome0.game.active = imp.tab == game.id
@@ -1105,7 +1243,7 @@ local function buildHeader(imp, m)
-- flip with it or it vanishes into the cartridge colour
local gameInvert = chrome0.game.active or gameHot
chrome0.game.ring = gameHot and not chrome0.game.active or nil
btn(imp, tx, ty, dropW, tabH, "tab-game", Strings(game.label), chrome0.game)
btn(imp, tx, ty, dropW, tabH, "tab-game", "", chrome0.game)
do
local cw = math.floor(7 * m.s)
local ccx = tx + dropW - math.floor(14 * m.s)
@@ -1119,7 +1257,7 @@ local function buildHeader(imp, m)
end
tx = tx + dropW + tabGap
for _, t in ipairs(tabs) do
local function headerTab(t)
local w = tabH
if tx > tabLeft and tx + w > tabRight then
tx = tabLeft
@@ -1132,6 +1270,11 @@ local function buildHeader(imp, m)
btn(imp, tx, ty, w, tabH, t.key, "", o)
tx = tx + w + tabGap
end
-- The bug-report chip sits LAST, past the sync chip.
local bugTab
for _, t in ipairs(tabs) do
if t.id == "bug" then bugTab = t else headerTab(t) end
end
do
local w = tabH
@@ -1153,6 +1296,7 @@ local function buildHeader(imp, m)
end
tx = tx + w + tabGap
end
if bugTab then headerTab(bugTab) end
-- `ty` has walked down with the wraps, so this stays correct at one row too.
y = ty + tabH + math.floor(8 * m.s)
@@ -1923,7 +2067,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
-- notice line
local noticeText, noticeCol
if safeMode then
noticeText, noticeCol = "Safe mode is on. All mods are disabled. Turn it off in Settings to change mod toggles.", PAL.yellow
noticeText, noticeCol = "Safe mode is on. All mods are disabled. Turn it off in the Bug tab to change mod toggles.", PAL.yellow
elseif imp.modNotice then
noticeText = imp.modNotice.text
noticeCol = imp.modNotice.ok and PAL.green or PAL.red
@@ -1936,7 +2080,6 @@ local function buildModsPanel(imp, x, y, w, availH, m)
cy = cy + buildModScopeRow(imp, x, cy, w, m)
if #mods == 0 then
imp.modScroll, imp._modScrollMax, imp._modListRect = 0, 0, nil
Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint())
return (cy - y) + math.floor(110 * m.s)
end
@@ -1977,159 +2120,139 @@ local function buildModsPanel(imp, x, y, w, availH, m)
end
-- A mod row is a fixed height: its details first, then a dedicated second
-- line of per-game checkboxes. Fixed because a page of uniform rows is
-- what lets perPage come from the viewport.
-- line of per-game checkboxes. Fixed row heights are what make the
-- cull below plain arithmetic.
local togH = math.floor(26 * m.s)
local gamesLabel = Strings("Enable for:")
local textH = Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(2 * m.s) + Kit.textHeight("small")
local rowH = math.floor(8 * m.s) + textH + math.floor(8 * m.s) + togH
+ math.floor(8 * m.s)
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local listH = availH - (cy - y) - pagerH - gap
local perPage = Kit.rowsThatFit(listH, rowH, gap, MIN_MODS_PER_PAGE, 20)
local first, last, cur, pages = Kit.pageBounds(page(imp, "mods"), #mods, perPage)
setPage(imp, "mods", cur)
local listTop = cy
local shown = math.max(0, last - first + 1)
local contentH = shown * rowH + math.max(0, shown - 1) * gap
local scrollMax = math.max(0, contentH - listH)
local scroll = clamp(imp.modScroll or 0, 0, scrollMax)
local lr = imp._modListRect
if not lr then lr = {}; imp._modListRect = lr end
lr.x, lr.y, lr.w, lr.h = x, listTop, w, listH
imp._modScrollMax = scrollMax
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks
and Kit.hit(x, listTop, w, listH) then
scroll = clamp(scroll - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax)
Kit.wheelY = 0
elseif scrollMax == 0 then
local wheelPage = Kit.wheelPage(x, listTop, w, listH, cur, #mods, perPage)
if wheelPage ~= cur then imp.modScroll = 0 end
setPage(imp, "mods", wheelPage)
end
imp.modScroll = scroll
Kit.pushClip(x, listTop, w, listH)
for i = first, last do
-- One continuous list: every row is laid out, the region scroll moves
-- through all of it, and only rows inside the region's viewport draw --
-- so the per-frame cost stays bounded by the window, not the list.
local view = imp._tabRegionRect
local viewTop = view and view.y or listTop
local viewBot = view and (view.y + view.h) or (listTop + availH)
for i = 1, #mods do
local mod = mods[i]
local ry = listTop + (i - first) * (rowH + gap) - scroll
local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
local isFullyDisabled = true
if mod.enabledByVersion then
for _, on in pairs(mod.enabledByVersion) do
if on then isFullyDisabled = false; break end
local ry = listTop + (i - 1) * (rowH + gap)
if ry + rowH >= viewTop and ry <= viewBot then
local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
local isFullyDisabled = true
if mod.enabledByVersion then
for _, on in pairs(mod.enabledByVersion) do
if on then isFullyDisabled = false; break end
end
else
isFullyDisabled = not mod.enabled
end
else
isFullyDisabled = not mod.enabled
end
local focused = Kit.focusable(rowKey, x, ry, w, rowH)
local hot = focused or Kit.hover(x, ry, w, rowH)
if isFullyDisabled then
Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted")
else
Kit.card(x, ry, w, rowH, hot)
end
local pad = math.floor(12 * m.s)
local px, inner = x + pad, w - 2 * pad
local ly = ry + math.floor(10 * m.s)
local togGap = math.floor(5 * m.s) + 1
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
-- These answer separate games, not a single shared install flag. The
-- importer receives the game id so an experimental confirmation also
-- applies only to the checkbox the player pressed.
local flipped = false
local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s)
Kit.text("micro", gamesLabel, px,
gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted)
local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s)
for _, game in ipairs(GameVersion.ORDER) do
local togKey = "mod-toggle-" .. mod.id .. "-" .. game
if modGameCheckbox(tx, gamesY, togH,
mod.enabledByVersion and mod.enabledByVersion[game] == true,
game, togKey, not safeMode) then
local version = game
queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end)
flipped = true
local focused = Kit.focusable(rowKey, x, ry, w, rowH)
local hot = focused or Kit.hover(x, ry, w, rowH)
if isFullyDisabled then
Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted")
else
Kit.card(x, ry, w, rowH, hot)
end
tx = tx + togH + togGap
end
-- The checkboxes sit inside the row's rect, so their press also passes the
-- row hit test; `flipped` gates the row action to everywhere else.
if not flipped
and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then
local id = mod.id
queueAction(imp, rowKey, function() imp._modActions = id end)
end
local textW = inner
local pad = math.floor(12 * m.s)
local px, inner = x + pad, w - 2 * pad
local ly = ry + math.floor(10 * m.s)
local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s)
-- the games the mod is for, beside its category: the same chip the
-- in-game manager shows (src/mods/ModTargets.lua)
local gamesW = mod.targets
and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0
local nameShown = Kit.ellipsize("button", mod.name,
textW - badgeW - gamesW - math.floor(12 * m.s))
local headingCol = isFullyDisabled and PAL.muted or PAL.heading
Kit.text("button", nameShown, px, ly, headingCol)
local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s)
Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge,
mod.experimental and PAL.yellow or PAL.muted)
if mod.targets then
Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW,
Kit.textHeight("button"), mod.targets,
mod.targetsHere == false and PAL.steel or PAL.blue)
end
ly = ly + Kit.textHeight("button") + math.floor(4 * m.s)
local togGap = math.floor(5 * m.s) + 1
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
-- version + status + update state
local statusText, statusCol = modStatusColor(mod.status)
local line = "v" .. tostring(mod.version or "?") .. " " .. statusText
Kit.text("small", line, px, ly, statusCol)
local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s)
if imp:_modInfoPending(mod.id) then
-- An inline spinner, because this row's release check is genuinely in
-- flight -- the list stays usable while it resolves.
Loader.dot(lx, ly, Kit.textHeight("small"))
Kit.text("small", Strings("Checking..."),
lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted)
elseif info and info.status == "available" then
Kit.text("small", Strings("v%s available", tostring(info.latest)),
lx, ly, PAL.yellow)
elseif info and info.status == "current" then
Kit.text("small", Strings("up to date"), lx, ly, PAL.muted)
elseif info and info.status == "error" then
Kit.text("small", Strings("check failed"), lx, ly, PAL.red)
end
ly = ly + Kit.textHeight("small") + math.floor(2 * m.s)
-- one line of description, or the download stats when we have them
-- (download count in green so popularity reads at a glance)
if info and info.downloads then
local d = info.dates
local dl = ModUpdate.downloadsLine(info.downloads.total)
local dates = ModUpdate.datesLine(d and d.first, d and d.latest)
local segs = {}
if dl then segs[#segs + 1] = { dl, PAL.green } end
if dates then
segs[#segs + 1] = { (dl and " - " or "") .. dates, PAL.detail }
-- These answer separate games, not a single shared install flag. The
-- importer receives the game id so an experimental confirmation also
-- applies only to the checkbox the player pressed.
local flipped = false
local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s)
Kit.text("micro", gamesLabel, px,
gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted)
local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s)
for _, game in ipairs(GameVersion.ORDER) do
local togKey = "mod-toggle-" .. mod.id .. "-" .. game
if modGameCheckbox(tx, gamesY, togH,
mod.enabledByVersion and mod.enabledByVersion[game] == true,
game, togKey, not safeMode) then
local version = game
queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end)
flipped = true
end
tx = tx + togH + togGap
end
-- The checkboxes sit inside the row's rect, so their press also passes the
-- row hit test; `flipped` gates the row action to everywhere else.
if not flipped
and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then
local id = mod.id
queueAction(imp, rowKey, function() imp._modActions = id end)
end
local textW = inner
local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s)
-- the games the mod is for, beside its category: the same chip the
-- in-game manager shows (src/mods/ModTargets.lua)
local gamesW = mod.targets
and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0
local nameShown = Kit.ellipsize("button", mod.name,
textW - badgeW - gamesW - math.floor(12 * m.s))
local headingCol = isFullyDisabled and PAL.muted or PAL.heading
Kit.text("button", nameShown, px, ly, headingCol)
local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s)
Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge,
mod.experimental and PAL.yellow or PAL.muted)
if mod.targets then
Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW,
Kit.textHeight("button"), mod.targets,
mod.targetsHere == false and PAL.steel or PAL.blue)
end
ly = ly + Kit.textHeight("button") + math.floor(4 * m.s)
-- version + status + update state
local statusText, statusCol = modStatusColor(mod.status)
local line = "v" .. tostring(mod.version or "?") .. " " .. statusText
Kit.text("small", line, px, ly, statusCol)
local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s)
if imp:_modInfoPending(mod.id) then
-- An inline spinner, because this row's release check is genuinely in
-- flight -- the list stays usable while it resolves.
Loader.dot(lx, ly, Kit.textHeight("small"))
Kit.text("small", Strings("Checking..."),
lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted)
elseif info and info.status == "available" then
Kit.text("small", Strings("v%s available", tostring(info.latest)),
lx, ly, PAL.yellow)
elseif info and info.status == "current" then
Kit.text("small", Strings("up to date"), lx, ly, PAL.muted)
elseif info and info.status == "error" then
Kit.text("small", Strings("check failed"), lx, ly, PAL.red)
end
ly = ly + Kit.textHeight("small") + math.floor(2 * m.s)
-- one line of description, or the download stats when we have them
-- (download count in green so popularity reads at a glance)
if info and info.downloads then
local d = info.dates
local dl = ModUpdate.downloadsLine(info.downloads.total)
local dates = ModUpdate.datesLine(d and d.first, d and d.latest)
local segs = {}
if dl then segs[#segs + 1] = { dl, PAL.green } end
if dates then
segs[#segs + 1] = { (dl and " - " or "") .. dates, PAL.detail }
end
segLine("small", segs, px, ly, textW)
elseif (mod.description or "") ~= "" then
Kit.text("small", Kit.ellipsize("small", mod.description, textW),
px, ly, PAL.detail)
end
segLine("small", segs, px, ly, textW)
elseif (mod.description or "") ~= "" then
Kit.text("small", Kit.ellipsize("small", mod.description, textW),
px, ly, PAL.detail)
end
end
Kit.popClip()
local pagerY = listTop + listH + gap
local newPage, newPagerH = Kit.pager(x, pagerY, w, cur, #mods, perPage, "mods")
if newPage ~= cur then imp.modScroll = 0 end
setPage(imp, "mods", newPage)
return pagerY + newPagerH - y
local contentH = #mods * rowH + (#mods - 1) * gap
return (listTop + contentH + gap) - y
end
-- ---------------------------------------------------------- find mods panel
@@ -2322,6 +2445,69 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
return cy + hintH - y
end
local function buildBugPanel(imp, x, y, w, availH, m)
local SaveData = require("src.core.SaveData")
local gap = m.gap
local pad = math.floor(16 * m.s)
local cy = y
local safeMode = imp:_safeModeEnabled()
Kit.text("button", Strings("Troubleshooting"), x, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + gap
if imp.issueNotice then
cy = cy + Kit.textWrapped("small", imp.issueNotice.text, x, cy, w,
imp.issueNotice.ok and PAL.green or PAL.red, 2) + gap
end
local switchW = math.floor(92 * m.s)
local switchH = math.max(m.btnH, Kit.tapMin())
local detail = safeMode
and Strings("All mods are disabled and their toggles are locked until safe mode is turned off.")
or Strings("Temporarily disable every mod while you reproduce a bug.")
local textW = math.max(0, w - 2 * pad - switchW - gap)
local detailH = Kit.wrapHeight("small", detail, textW, 3)
local safeH = math.max(switchH, Kit.textHeight("small") + math.floor(4 * m.s) + detailH)
+ 2 * pad
Kit.card(x, cy, w, safeH)
local textX = x + pad
local textY = cy + pad
Kit.text("small", Strings("Safe mode"), textX, textY, PAL.heading)
Kit.textWrapped("small", detail, textX,
textY + Kit.textHeight("small") + math.floor(4 * m.s), textW,
PAL.muted, 3)
local toggleX = x + w - pad - switchW
local toggleY = cy + math.floor((safeH - switchH) / 2)
local _, changed = Kit.toggle(toggleX, toggleY, switchW, switchH, safeMode,
"bug-safe-mode")
if changed then
queueAction(imp, "bug-safe-mode", function() imp:_toggleSafeMode() end)
end
cy = cy + safeH + gap
local reportLabel = Strings("Report a bug")
local reportW = math.min(w - 2 * pad,
Kit.textWidth("small", reportLabel) + math.floor(32 * m.s))
local reportDetail = Strings("Fill out the GitHub form with the available system information.")
local reportTextW = math.max(0, w - 2 * pad - reportW - gap)
local reportDetailH = Kit.wrapHeight("small", reportDetail, reportTextW, 3)
local reportH = math.max(m.btnH, Kit.textHeight("small") + math.floor(4 * m.s) + reportDetailH)
+ 2 * pad
Kit.card(x, cy, w, reportH)
Kit.text("small", Strings("Something not working?"), textX, cy + pad, PAL.heading)
Kit.textWrapped("small", reportDetail, textX,
cy + pad + Kit.textHeight("small") + math.floor(4 * m.s), reportTextW,
PAL.muted, 3)
btn(imp, x + w - pad - reportW,
cy + math.floor((reportH - m.btnH) / 2), reportW, m.btnH,
"bug-report", reportLabel, {
kind = "accent", font = "small",
action = function()
imp:_ensureMods()
imp:_reportIssue(SaveData.loadOptions(), nil)
end })
end
local function buildFindPanel(imp, x, y, w, availH, m)
imp:_ensureFind()
imp:_ensureMods()
@@ -2798,6 +2984,8 @@ local function buildConfirmModal(imp, m)
imp:_setAllMods(true, true)
elseif c.kind == "importOversize" then
imp:_importSave(c.version, c.source, true)
elseif c.kind == "largeImport" then
imp:_importRequiredSource(c.modId, c.importId, c.source, true)
else
imp:_toggleMod(c.id, true, c.version)
end
@@ -4366,7 +4554,7 @@ local function buildSyncHome(imp, m, eng)
local linked = eng:linked()
local codes = eng.codes
local body = linked
and Strings("This device is linked. Saves and the mod list sync when the launcher opens and a few seconds after each save.")
and Strings("This device is linked. Saves sync when the launcher opens, a few seconds after each save, and every few minutes while the app is running.")
or Strings(SYNC_HINT)
local innerW = w - 2 * pad
local hintH = Kit.wrapHeight("small", body, innerW, 5)
@@ -4751,17 +4939,18 @@ function LauncherView.draw(imp)
Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0)
imp._clickPt = nil
imp._wheelY = 0
imp._noDragN = 0
Theme.field()
-- Everything from here to buildModals sits UNDER any open modal, so the
-- whole stage draws shielded (no clicks, no hover, no focus ring) while
-- one is up; buildModals lowers the shield for the modal's own controls.
Kit.blockClicks = modalUp(imp)
imp._modalUpNow = modalUp(imp)
Kit.blockClicks = imp._modalUpNow
local step = Kit.scrollStep(m.s)
local nested = modListWantsWheel(imp, Kit.wheelY or 0)
if not nested then
do
local rect = imp._tabRegionRect
if rect then
setTabScroll(imp, (Kit.scrollWheel(tabScrollAt(imp), tabScrollMax(imp),
@@ -4769,8 +4958,7 @@ function LauncherView.draw(imp)
end
end
local scroll = math.max(0, math.min(imp._pageScroll or 0, scrollMax))
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not nested
and not Kit.blockClicks then
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks then
local moved = math.max(0, math.min(scroll - Kit.wheelY * step, scrollMax))
if moved ~= scroll then
scroll = moved
@@ -4778,7 +4966,7 @@ function LauncherView.draw(imp)
end
end
imp._pageScroll, imp._pageScrollMax = scroll, scrollMax
if (Kit.wheelY or 0) ~= 0 and not nested and not Kit.blockClicks
if (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks
and tabScrollMax(imp) > 0 then
local was = tabScrollAt(imp)
local to = Kit.scrollClamp(was - Kit.wheelY * step, tabScrollMax(imp))
@@ -4822,6 +5010,8 @@ function LauncherView.draw(imp)
contentH = buildFindPanel(imp, x, py, panelW, budgetH, m)
elseif imp.tab == "skins" then
contentH = buildSkinsPanel(imp, x, py, panelW, budgetH, m)
elseif imp.tab == "bug" then
contentH = buildBugPanel(imp, x, py, panelW, budgetH, m)
else
contentH = buildGamePanel(imp, x, py, panelW, availH, m, imp.tab, budgetH)
end
+198 -27
View File
@@ -4,6 +4,7 @@
-- (species order IS dex order, pics/tilesets are lz3-compressed rather than
-- pkmncompress'd, maps are grouped instead of flat). See docs/gold-phase1.md.
local bit = require("bit")
local GameVersion = require("src.core.GameVersion")
local ImageWriter = require("src.import.ImageWriter")
local LuaWriter = require("src.import.LuaWriter")
local Rom = require("src.import.Rom")
@@ -168,6 +169,10 @@ function RomExtractorGen2.new(romData, manifest, progress)
symbols = manifest.symbols,
progress = progress,
stage = 0,
-- _GOLD / _SILVER: the labels are shared, the data behind a handful of
-- them is not (gfx/misc.asm:9-20 vs :46-57).
edition = GameVersion.forSha1(manifest.romSha1) == "silver"
and "silver" or "gold",
}, RomExtractorGen2)
end
@@ -1641,22 +1646,40 @@ function RomExtractorGen2:extractTitle()
return 3
end
-- pret gfx/title/title_bg_gold.pal / title_fg.pal (5 BG pals, 2 OBJ pals).
local BG_PALS = {
local silver = self.edition == "silver"
-- pret gfx/title/title_bg_gold.pal / title_bg_silver.pal (5 BG pals);
-- GSTitleBGPals is the edition-selected include (engine/gfx/color.asm:1234).
local BG_PALS = silver and {
{ { 31, 31, 31 }, { 0, 12, 15 }, { 4, 8, 21 }, { 0, 0, 0 } },
{ { 31, 21, 0 }, { 15, 17, 15 }, { 4, 8, 21 }, { 0, 0, 17 } },
{ { 31, 31, 31 }, { 31, 0, 0 }, { 4, 8, 21 }, { 0, 0, 0 } },
{ { 31, 31, 31 }, { 24, 23, 25 }, { 4, 8, 21 }, { 8, 8, 9 } },
{ { 31, 31, 31 }, { 5, 10, 11 }, { 0, 12, 15 }, { 0, 0, 0 } },
} or {
{ { 31, 31, 31 }, { 18, 23, 31 }, { 15, 20, 31 }, { 0, 0, 0 } },
{ { 31, 21, 0 }, { 12, 14, 12 }, { 15, 20, 31 }, { 0, 0, 17 } },
{ { 31, 31, 31 }, { 31, 0, 0 }, { 15, 20, 31 }, { 0, 0, 0 } },
{ { 31, 31, 31 }, { 29, 25, 0 }, { 15, 20, 31 }, { 17, 10, 1 } },
{ { 31, 31, 31 }, { 23, 26, 31 }, { 18, 23, 31 }, { 0, 0, 0 } },
}
-- title_fg.pal: pal 0 = Ho-Oh silhouette (shades 1-3 are the same brown);
-- pal 1 = gold trail sparks (OAM_PAL1 on GSTitleTrail).
-- title_fg.pal, shared (GSTitleOBPals, engine/gfx/color.asm:1241): pal 0 =
-- Ho-Oh silhouette; pal 1 = gold trail sparks (OAM_PAL1 on GSTitleTrail).
local OBJ_HOOH = {
{ 31, 31, 31 }, { 7, 6, 3 }, { 7, 6, 3 }, { 7, 6, 3 },
}
local OBJ_TRAIL = {
{ 31, 31, 31 }, { 31, 31, 0 }, { 26, 22, 0 }, { 0, 0, 0 },
}
-- engine/movie/title.asm:134-141 + CopyPals (home/palettes.asm:190):
-- DmgToCgbObjPal0 %11100000 makes Silver's OBJ pal 0 {c0, c0, c2, c3}.
if silver then
OBJ_HOOH = {
OBJ_HOOH[1], OBJ_HOOH[1], OBJ_HOOH[3], OBJ_HOOH[4],
}
-- .OAMData_GSTitleTrail is attribute 0, not OAM_PAL1 (oam.asm:834-837).
OBJ_TRAIL = OBJ_HOOH
end
local function palColor(pal, shade)
local c = pal[shade + 1] or pal[4]
@@ -1671,8 +1694,9 @@ function RomExtractorGen2:extractTitle()
-- solid BLACK silhouette on a monochrome screen rather than the shaded pose
-- a straight decode gives. rOBP1 (%11111000) carries the gold trail.
local DMG_BGP = { 0, 2, 1, 3 }
local DMG_OBP0 = { 3, 3, 3, 3 }
local DMG_OBP1 = { 0, 2, 3, 3 }
-- engine/movie/title.asm:105-115: Silver writes %11110000 to both OBPs.
local DMG_OBP0 = silver and { 0, 0, 3, 3 } or { 3, 3, 3, 3 }
local DMG_OBP1 = silver and { 0, 0, 3, 3 } or { 0, 2, 3, 3 }
-- ImageWriter's four hardware shades, by shade number.
local DMG_SHADE = { 1, 2 / 3, 1 / 3, 0 }
@@ -1776,6 +1800,28 @@ function RomExtractorGen2:extractTitle()
-- Ho-Oh frames from OAMData_GSIntroHoOh1..5 (data/sprite_anims/oam.asm).
local hoohTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleScreenGFX4"), true)
-- .OAMData_GSIntroLugia1 / 2 (data/sprite_anims/oam.asm:736-773); the
-- spriteanimoam vtile offset is added per frame (core.asm:224-227).
local LUGIA_1 = {
{ -5, -2, 0, 0, 0x00 }, { -5, 0, 0, 0, 0x02 },
{ -4, -2, 0, 0, 0x04 }, { -4, 0, 0, 0, 0x06 },
{ -3, -1, 0, 0, 0x08 }, { -2, -1, 0, 0, 0x0a },
{ -1, -2, 0, 0, 0x0c }, { -1, 0, 0, 0, 0x0e },
{ 0, -2, 0, 0, 0x10 }, { 0, 0, 0, 0, 0x12 },
{ 1, -2, 0, 0, 0x14 }, { 1, 0, 0, 0, 0x16 },
{ 2, -2, 0, 0, 0x18 }, { 2, 0, 0, 0, 0x1a },
{ 3, -1, 0, 0, 0x1c }, { 4, -1, 0, 0, 0x1e },
}
local LUGIA_2 = {
{ -5, -2, 0, 0, 0x00 }, { -5, 0, 0, 0, 0x02 },
{ -4, -2, 0, 0, 0x04 }, { -4, 0, 0, 0, 0x06 },
{ -3, -1, 0, 0, 0x08 }, { -2, -1, 0, 0, 0x0a },
{ -1, -2, 0, 0, 0x0c }, { -1, 0, 0, 0, 0x0e },
{ 0, -2, 0, 0, 0x10 }, { 0, 0, 0, 0, 0x12 },
{ 1, -2, 0, 0, 0x14 }, { 1, 0, 0, 0, 0x16 },
{ 2, -2, 0, 0, 0x18 }, { 2, 0, 0, 0, 0x1a },
{ 3, -2, 0, 0, 0x1c }, { 4, -2, 0, 0, 0x1e },
}
local HOOH_FRAMES = {
{ -- 1
{ -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x04 },
@@ -1823,22 +1869,36 @@ function RomExtractorGen2:extractTitle()
{ 3, -2, 0, 0, 0x22 }, { 3, 0, 0, 0, 0x24 },
},
}
-- Frameset_GSIntroHoOhLugia (Gold): 1,2,3,4,3,5 with these durations.
local HOOH_SEQUENCE = {
-- Silver's five oamsets, as {layout, vtile base} (oam.asm:103-107).
local LUGIA_FRAMES = {
{ LUGIA_1, 0x00 }, { LUGIA_1, 0x20 }, { LUGIA_2, 0x40 },
{ LUGIA_2, 0x60 }, { LUGIA_1, 0x00 },
}
-- Frameset_GSIntroHoOhLugia (data/sprite_anims/framesets.asm:376-396):
-- Gold 1,2,3,4,3,5; Silver 2,1,2,3,3,4,4,3,2 on a faster clock.
local HOOH_SEQUENCE = silver and {
{ 2, 3 }, { 1, 7 }, { 2, 7 }, { 3, 7 }, { 3, 7 },
{ 4, 7 }, { 4, 7 }, { 3, 7 }, { 2, 3 },
} or {
{ 1, 10 }, { 2, 9 }, { 3, 10 }, { 4, 10 }, { 3, 9 }, { 5, 10 },
}
local hoohPaths, hoohGrayPaths = {}, {}
local originX, originY = 32, 24
for fi, oam in ipairs(HOOH_FRAMES) do
-- Lugia1/2 span x tiles -5..4, four tiles wider than Ho-Oh's -4..3.
local originX, originY = silver and 40 or 32, 24
local poseW = silver and 80 or 64
local frames = silver and LUGIA_FRAMES or HOOH_FRAMES
for fi, entry in ipairs(frames) do
local oam = silver and entry[1] or entry
local base = silver and entry[2] or 0
-- The pose starts EMPTY, not white: an OBJ's colour 0 is transparent
-- wherever it falls, so a gap enclosed by the bird shows the sky through
-- exactly like one outside it, and there is no matte to flood-fill.
local pose = ImageWriter.blank(64, 64, 0, 0, 0, 0)
local pose = ImageWriter.blank(poseW, 64, 0, 0, 0, 0)
for _, spr in ipairs(oam) do
local px = originX + spr[1] * 8 + spr[3]
local py = originY + spr[2] * 8 + spr[4]
blitSprite(pose, hoohTiles[spr[5] + 1], px, py)
blitSprite(pose, hoohTiles[spr[5] + 2], px, py + 8)
blitSprite(pose, hoohTiles[base + spr[5] + 1], px, py)
blitSprite(pose, hoohTiles[base + spr[5] + 2], px, py + 8)
end
local tinted = colorize(pose, function() return OBJ_HOOH end)
local rel = ("title/hooh_%d.png"):format(fi)
@@ -1855,14 +1915,20 @@ function RomExtractorGen2:extractTitle()
end
self:tick("Title screen", 3, 5)
-- Trail: TitleScreenGFX3 is raw 2bpp (8 tiles); Gold OAM uses one 8x16
-- on OAM_PAL1 (gold), not the Ho-Oh silhouette pal.
-- Trail: TitleScreenGFX3 is raw 2bpp; Gold's OAM is one 8x16, Silver's two
-- side by side, and only 4 of Silver's 8 copied tiles exist (title.asm:43).
local trailSym = self:symbol("TitleScreenGFX3")
local trailRaw = self.rom:bytes(trailSym.bank, trailSym.address, 8 * 16)
local trailTileCount = silver and 4 or 8
local trailRaw =
self.rom:bytes(trailSym.bank, trailSym.address, trailTileCount * 16)
local trailTiles = tilesFrom2bpp(trailRaw, true)
local trail = ImageWriter.blank(8, 16, 0, 0, 0, 0)
local trail = ImageWriter.blank(silver and 16 or 8, 16, 0, 0, 0, 0)
blitSprite(trail, trailTiles[1], 0, 0)
blitSprite(trail, trailTiles[2], 0, 8)
if silver then
blitSprite(trail, trailTiles[3], 8, 0)
blitSprite(trail, trailTiles[4], 8, 8)
end
local trailTint = colorize(trail, function() return OBJ_TRAIL end)
self:save(trailTint, "title/trail.png")
self:save(throughRegister(trail, DMG_OBP1), "title/trail_gray.png")
@@ -1908,8 +1974,11 @@ function RomExtractorGen2:extractTitle()
cloudsGray = "assets/generated/title/clouds_gray.png",
hoohFramesGray = hoohGrayPaths,
trailGray = "assets/generated/title/trail_gray.png",
-- Frameset_GSIntroHoOhLugia (Gold), frame index 1-based + duration frames.
-- Frameset_GSIntroHoOhLugia, frame index 1-based + duration frames.
hoohSequence = HOOH_SEQUENCE,
-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm:820-838).
hoohBobAmplitude = silver and 8 or 2,
hoohBobStep = silver and -1 or 1,
-- `depixel 12, 11` (engine/movie/title.asm). Two traps, and the port had
-- fallen into both, which is what put Ho-Oh off-centre:
-- * ldpixel's own comment calls its first tile argument the X one and is
@@ -1919,19 +1988,45 @@ function RomExtractorGen2:extractTitle()
-- the cursor two rows up on the box screen. So this is x 88, y 96.
-- * those are OAM coordinates, which are biased; a drawn object sits at
-- (x - 8, y - 16) on screen.
-- The pose canvas holds its own origin at (32, 24), so the sheet's corner
-- is (88 - 8 - 32, 96 - 16 - 24) -- and the bird's 64px width then lands
-- centred on the screen, 48 to 112.
hoohX = 48,
hoohY = 56,
-- The pose canvas holds its own origin, so the sheet's corner is
-- (88 - 8 - originX, 96 - 16 - originY) -- and the pose's width then lands
-- centred on the screen (Ho-Oh 48..112, Lugia 40..120).
hoohX = 80 - originX,
hoohY = 80 - originY,
trail = "assets/generated/title/trail.png",
copyright = "assets/generated/title/copyright.png",
copyrightSplash = "assets/generated/title/copyright_splash.png",
-- ScrollTitleScreenClouds: Gold decrements the cloud-band SCX every
-- 8 vblanks, so the strip slides 1px right. Silver does the same
-- decrement every frame.
cloudScrollEvery = 8,
-- ScrollTitleScreenClouds (engine/menus/intro_menu.asm:917-928): Gold
-- decrements the cloud-band SCX every 8 vblanks, so the strip slides 1px
-- right. Silver does the same decrement every frame.
cloudScrollEvery = silver and 1 or 8,
cloudY = 88,
-- BG pal 0 colour 2: the sky the widescreen bands have to match.
sky = {
BG_PALS[1][3][1] / 31, BG_PALS[1][3][2] / 31, BG_PALS[1][3][3] / 31,
},
-- The fill under the cloud/wave band: Gold's cloud field is BG pal 0
-- colour 0 (white), Silver's sea floor is colour 3 (black).
below = silver and {
BG_PALS[1][4][1] / 31, BG_PALS[1][4][2] / 31, BG_PALS[1][4][3] / 31,
} or {
BG_PALS[1][1][1] / 31, BG_PALS[1][1][2] / 31, BG_PALS[1][1][3] / 31,
},
-- UpdateTitleTrailSprite (engine/menus/intro_menu.asm:1069-1124). Silver's
-- `depixel 15, 11, 4, 0` is OAM (88, 124), less the bias and the (-16, -8)
-- corner .OAMData_GSTitleTrail draws from.
trailMode = silver and "silver" or "gold",
trailSpawns = silver and { { 72, 100 } } or {
{ 80, 88 }, { 104, 88 }, { 104, 88 }, { 120, 88 },
{ 120, 88 }, { 88, 88 },
},
trailSpawnEvery = 4,
trailStepX = 4,
trailStepY = silver and 0 or 1,
-- AnimSeq_GSTitleTrail (functions.asm:784-813) with wIntroSceneTimer 0.
trailBobAmplitude = silver and 3 or 2,
trailPhaseStep = silver and 7 or 3,
trailPhase = silver and 0 or nil,
}
self:write("title", data)
return data
@@ -5136,6 +5231,82 @@ function RomExtractorGen2:extractMenuGfx()
end
if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch end
-- Goldenrod Game Corner: Slot Machine graphics assets
if self.symbols["Slots1LZ"] then
local raw1 = self:decompressLz3Symbol("Slots1LZ")
self:write2bpp(raw1, 16, #raw1 / 4, "slots/gold_slots_1.png")
end
if self.symbols["Slots2LZ"] then
local raw2 = self:decompressLz3Symbol("Slots2LZ")
-- In Pokemon Gold ROM, Seven symbol (first 4 tiles = 64 bytes) has inverted bit polarity
for i = 1, math.min(64, #raw2) do
raw2[i] = bit.band(bit.bnot(raw2[i]), 0xFF)
end
self:write2bpp(raw2, 16, #raw2 / 4, "slots/gold_slots_2.png")
end
if self.symbols["Slots3LZ"] then
local raw3 = self:decompressLz3Symbol("Slots3LZ")
self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_3.png", true)
-- Slots3LZ is a 24px-wide (3 tiles), 240px-tall (30 tiles) sprite sheet containing:
-- Y=0: Golem 1 (Standing, 24x32)
-- Y=32: Golem 2 (Ball, 24x32)
-- Y=64: Chansey 1 (Standing / Step 1, 24x32)
-- Y=96: Chansey 2 (Step 2, 24x32)
-- Y=128: Chansey 3 (Step 3, 24x32)
-- Y=160: Chansey 4 (Arm raised / Step 4, 24x32)
-- Y=192: Chansey 5 (Egg Drop pose, 24x32)
-- Y=224: Egg (8x16 at X=0)
self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_actors.png", true)
end
if self.symbols["SlotsTilemap"] then
local symbol = self:symbol("SlotsTilemap")
local tm = self.rom:bytes(symbol.bank, symbol.address, 20 * 12)
self:save(tm, "slots/gold_slots.tilemap")
end
-- Goldenrod Game Corner: Card Flip graphics assets
if self.symbols["CardFlipLZ01"] then
local raw1 = self:decompressLz3Symbol("CardFlipLZ01")
self:write2bpp(raw1, 128, #raw1 / 32, "card_flip/card_flip_1.png")
end
if self.symbols["CardFlipLZ02"] then
local raw2 = self:decompressLz3Symbol("CardFlipLZ02")
self:write2bpp(raw2, 24, #raw2 / 6, "card_flip/card_flip_2.png")
end
if self.symbols["CardFlipLZ03"] then
local raw3 = self:decompressLz3Symbol("CardFlipLZ03")
self:write2bpp(raw3, 8, #raw3 / 2, "card_flip/card_flip_3.png")
end
if self.symbols["CardFlipOnButtonGFX"] then
local symbol = self:symbol("CardFlipOnButtonGFX")
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/on.png")
end
if self.symbols["CardFlipOffButtonGFX"] then
local symbol = self:symbol("CardFlipOffButtonGFX")
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/off.png")
end
if self.symbols["CardFlipTilemap"] then
local symbol = self:symbol("CardFlipTilemap")
local tm = self.rom:bytes(symbol.bank, symbol.address, 11 * 12)
self:save(tm, "card_flip/card_flip.tilemap")
end
out.slots = {
sheet1 = "assets/generated/slots/gold_slots_1.png",
sheet2 = "assets/generated/slots/gold_slots_2.png",
sheet3 = "assets/generated/slots/gold_slots_3.png",
tilemap = "assets/generated/slots/gold_slots.tilemap",
}
out.cardFlip = {
sheet1 = "assets/generated/card_flip/card_flip_1.png",
sheet2 = "assets/generated/card_flip/card_flip_2.png",
sheet3 = "assets/generated/card_flip/card_flip_3.png",
on = "assets/generated/card_flip/on.png",
off = "assets/generated/card_flip/off.png",
tilemap = "assets/generated/card_flip/card_flip.tilemap",
}
self:write("menu_gfx", out)
self:tick("Menu graphics", 1, 1)
return out
+77 -34
View File
@@ -142,6 +142,8 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
"assets/generated/audio/programs.bin",
},
}
-- Same Gen 2 extract, so a Silver cache is complete when the same files exist.
VERSION_REQUIRED_FILES_OVERRIDE.silver = VERSION_REQUIRED_FILES_OVERRIDE.gold
-- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup):
-- a dark neon arcade panel, one column per game.
@@ -335,7 +337,7 @@ function RomImporter.syncAndroidShortcuts(activeVersion)
return false
end
local allVersions = { "red", "blue", "yellow", "gold" }
local allVersions = GameVersion.ORDER
local ready = {}
local seen = {}
@@ -1266,6 +1268,7 @@ end
function RomImporter:_applyLastVersionTab()
local okLO, LO = pcall(require, "src.core.LaunchOptions")
if okLO and LO.pendingTab then return end
if os.getenv("POKEPORT_LAUNCHER_TAB") then return end
local okOpt, opts = pcall(function()
return require("src.core.SaveData").loadOptions()
end)
@@ -1337,7 +1340,7 @@ function RomImporter.new(onComplete, opts)
-- player at least arrives on the tab they asked for (src/core/LaunchOptions).
tab = (function()
local okLO, LO = pcall(require, "src.core.LaunchOptions")
return (okLO and LO.pendingTab) or "red"
return (okLO and LO.pendingTab) or os.getenv("POKEPORT_LAUNCHER_TAB") or "red"
end)(),
logo = love.graphics.newImage("assets/logo/logo.png"),
bcg = love.graphics.newImage("assets/logo/bcg.png"),
@@ -1360,11 +1363,11 @@ function RomImporter.new(onComplete, opts)
saveNotice = {},
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
-- (refreshed lazily on first draw and after any toggle/install/delete);
-- modScroll is the current paged list's inner scroll offset (px, clamped
-- in draw); modNotice is the last install/delete result { ok, text }.
-- modNotice is the last install/delete result { ok, text }.
-- requiredImportNotice stays inside the imported-files modal so validation
-- failures are visible beside the file picker that caused them.
mods = nil, modScroll = 0, modNotice = nil, requiredImportNotice = nil,
mods = nil, modNotice = nil, issueNotice = nil,
requiredImportNotice = nil,
-- Which game the MODS panel is answering for (a GameVersion id, nil =
-- every game). Rows resolve their enable-state and their "runs here"
-- verdict against it (src/mods/ModTargets.lua).
@@ -1420,7 +1423,8 @@ function RomImporter.new(onComplete, opts)
self.returning[version] =
(not ready) and marker ~= nil and marker ~= markerFor(version)
self.romName[version] = "pokemon_" .. info.id
.. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb")
.. ((info.id == "yellow" or GameVersion.generation(version) == 2)
and ".gbc" or ".gb")
end
RomImporter.syncAndroidShortcuts()
self:_applyLastVersionTab()
@@ -1533,6 +1537,9 @@ function RomImporter:focus(f)
self._modPress = nil
return
end
if type(self._sync) == "table" then
pcall(self._sync.noteResumed, self._sync)
end
if not (f and self.android and self.workState ~= "working") then return end
-- SAF create-document finished: GameActivity wrote export_done.flag.
if love.filesystem.getInfo("export_done.flag", "file") then
@@ -1685,7 +1692,7 @@ function RomImporter:startData(data, displayName)
end
if not isAcceptedRomSize(#data) then
self:setError(("Expected a 1 MiB Game Boy ROM (Red/Blue/Yellow) or a "
.. "2 MiB Game Boy Color ROM (Gold); this file is %.2f MiB.")
.. "2 MiB Game Boy Color ROM (Gold/Silver); this file is %.2f MiB.")
:format(#data / 1024 / 1024))
return
end
@@ -1693,7 +1700,8 @@ function RomImporter:startData(data, displayName)
local version = GameVersion.forSha1(actualHash)
if not version then
self:setError(("Unsupported ROM (SHA-1 %s). This needs a clean US Pokemon "
.. "Red, Blue, Yellow, or Gold dump; patched, trimmed or \"fixed\" dumps "
.. "Red, Blue, Yellow, Gold, or Silver dump; patched, trimmed or "
.. "\"fixed\" dumps "
.. "(tagged [b] or [BF]) never verify."):format(actualHash))
return
end
@@ -1770,7 +1778,7 @@ function RomImporter:_startExtractCoroutine(version, info, prefix, displayName)
local CacheFs = require("src.import.CacheFs")
CacheFs.prefix = prefix
local manifest = require("src.import.RomManifest").decode(version)
local RomExtractor = version == "gold"
local RomExtractor = GameVersion.generation(version) == 2
and require("src.import.RomExtractorGen2")
or require("src.import.RomExtractor")
local extractor = RomExtractor.new(self.romData, manifest,
@@ -2055,7 +2063,7 @@ function RomImporter:_importRequiredData(modId, importId, data)
return nil
end
function RomImporter:_importRequiredSource(modId, importId, source)
function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
local manifest = requiredManifest(self, modId)
local spec = manifest and requiredSpec(manifest, importId)
if not spec then
@@ -2063,14 +2071,31 @@ function RomImporter:_importRequiredSource(modId, importId, source)
self.modNotice = nil
return nil
end
local RequiredImports = require("src.mods.RequiredImports")
local info = love.filesystem.getInfo(source, "file")
local size = info and info.size or externalFileSize(source)
local sizeErr = require("src.mods.RequiredImports").sizeError(spec, size, false)
local sizeErr = RequiredImports.sizeError(spec, size, false)
if sizeErr then
requiredImportNotice(self, modId, importId, sizeErr)
self.modNotice = nil
return nil
end
if not confirmed and type(size) == "number"
and size > RequiredImports.LARGE_WARN_BYTES then
self._modConfirm = {
kind = "largeImport",
modId = modId, importId = importId, source = source,
title = Strings("Large import"),
lines = {
Strings("This is a large import (%s).",
RequiredImports.sizeLabel(size)),
Strings("Please ensure you have enough space on your"),
Strings("device before doing this."),
},
yesLabel = Strings("I understand"),
}
return nil
end
local data = love.filesystem.read(source)
if not data then data = readExternalPath(source) end
if not data then
@@ -2113,8 +2138,13 @@ function RomImporter:chooseRequiredImport(modId, importId)
if name:sub(1, 1) ~= "." then
local path = inbox .. "/" .. name
local info = love.filesystem.getInfo(path, "file")
local sizeErr = info and require("src.mods.RequiredImports")
.sizeError(spec, info.size, false)
local RequiredImports = require("src.mods.RequiredImports")
local sizeErr = info
and RequiredImports.sizeError(spec, info.size, false)
if info and not sizeErr
and info.size > RequiredImports.LARGE_WARN_BYTES then
return self:_importRequiredSource(modId, importId, path)
end
local data = not sizeErr and love.filesystem.read(path) or nil
if data and self:_importRequiredData(modId, importId, data) then return end
if sizeErr then lastError = sizeErr
@@ -2755,7 +2785,8 @@ function RomImporter:resumeAfterOverlay()
end
function RomImporter:_cycleTab(delta)
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins" }
local order = { "red", "blue", "yellow", "gold", "silver",
"mods", "find", "skins", "bug" }
local idx = 1
for i, id in ipairs(order) do
if id == self.tab then idx = i; break end
@@ -3092,8 +3123,10 @@ end
-- multi-monitor coords). Same contract as PadCursor.yieldToPointer for
-- the overlay hosts. Touch move/press/release must still reach
-- FlexLove.touch* or scroll containers never drag on phones.
function RomImporter:mousepressed()
function RomImporter:mousepressed(x, y, button)
self._padCursorActive = false
if button ~= 1 or not self._flex then return end
require("src.import.LauncherView").mousepressed(self, x, y)
end
function RomImporter:touchpressed(id, x, y, dx, dy, pressure)
@@ -3121,7 +3154,6 @@ function RomImporter:_switchTab(id)
self.tab = id
self._findSearchFocus = false
self._skinUrlFocus = false
self._modScrollMax, self._modListRect = 0, nil
self:_disarmTextInput()
-- the skins list is cheap and can change behind the launcher's back
-- (an export, a hand-dropped folder), so re-read it on every visit
@@ -3150,7 +3182,8 @@ function RomImporter:_ensureSkins(force)
format = skin and skin.format or nil,
pages = skin and #skin.pages or 0,
controls = controls,
screen = page ~= nil and page.viewport ~= nil,
screen = page ~= nil
and (page.viewport ~= nil or page.screenFit == "remainder"),
ok = skin ~= nil,
}
end
@@ -3629,9 +3662,6 @@ function RomImporter:_openSettings()
-- option block, and Gold's is not the flat Gen 1 one (#1100).
local hooks = {}
local version = self.tab
hooks.reportIssue = function(opts)
return self:_reportIssue(opts, version)
end
if self.onEditTouchControls then
local version = self.tab
hooks.editTouchControls = function()
@@ -3654,7 +3684,6 @@ function RomImporter:_openSettings()
end)
if ok and model then
self._settings = model
self._settingsSafeModeAtOpen = require("src.core.SaveData").isSafeMode(model.opts)
end
end
@@ -3669,22 +3698,36 @@ function RomImporter:_closeSettings()
local model = self._settings
if model then
model.save()
local safeMode = require("src.core.SaveData").isSafeMode(model.opts)
if safeMode ~= self._settingsSafeModeAtOpen then
self.mods = nil
self.safeMode = safeMode
self._modSortCache = nil
self._modInfoFetch = nil
end
end
self._settings = nil
self._settingsSafeModeAtOpen = nil
end
function RomImporter:_safeModeEnabled()
if self.safeMode == nil then
local SaveData = require("src.core.SaveData")
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
end
return self.safeMode == true
end
function RomImporter:_toggleSafeMode()
local SaveData = require("src.core.SaveData")
local options = SaveData.loadOptions()
local enabled = not SaveData.isSafeMode(options)
SaveData.setSafeMode(options, enabled)
SaveData.saveOptions(options)
self.safeMode = enabled
self.mods = nil
self._modSortCache = nil
self._modInfoFetch = nil
self.modNotice = nil
end
function RomImporter:_reportIssue(options, version)
self.issueNotice = nil
local ok, IssueReport = pcall(require, "src.core.IssueReport")
if not ok then
self.modNotice = { ok = false, text = "Could not prepare the issue report." }
self.issueNotice = { ok = false, text = "Could not prepare the issue report." }
return false
end
local opened, url, reason = IssueReport.open(options, {
@@ -3692,11 +3735,11 @@ function RomImporter:_reportIssue(options, version)
mods = self.mods,
})
if not opened then
self.modNotice = { ok = false, text = reason or "Could not open the issue report." }
self.issueNotice = { ok = false, text = reason or "Could not open the issue report." }
return false
end
self._lastIssueReportURL = url
if reason then self.modNotice = { ok = true, text = reason } end
if reason then self.issueNotice = { ok = true, text = reason } end
return true
end
@@ -4179,7 +4222,7 @@ end
-- Enabling an experimental mod arms a confirmation for that same game.
function RomImporter:_toggleMod(id, confirmed, version)
if self.safeMode then
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." }
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." }
return
end
local LauncherMods = require("src.mods.LauncherMods")
@@ -4223,7 +4266,7 @@ end
-- recovery action, and Delete is the only destructive one on this panel.
function RomImporter:_setAllMods(want, confirmed)
if self.safeMode then
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." }
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." }
return
end
local LauncherMods = require("src.mods.LauncherMods")
+2 -1
View File
@@ -262,7 +262,8 @@ function LinkBattle.new(game, net, opts)
self.opponentName = theirName
-- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2
-- takes PrintBeginningBattleText's .trainerBattle arm, link included
self.introText = Strings("%s wants\nto fight!", theirName)
self.introText = self:romText("_TrainerWantsToFightText",
"%s wants\nto fight!", theirName)
self.remoteHashes = {}
self.localHashes = {}
self.remoteParts = {}
+2 -2
View File
@@ -195,8 +195,8 @@ local function parseImports(value, field, required)
if value == nil then return end
assert(type(value) == "number" and value > 0 and value % 1 == 0,
field .. " " .. label .. " must be a positive integer")
assert(value <= 128 * 1024 * 1024,
field .. " " .. label .. " exceeds the 128 MiB hard limit")
assert(value <= 2 * 1024 * 1024 * 1024,
field .. " " .. label .. " exceeds the 2 GiB hard limit")
end
validateSize(size, "size")
validateSize(maxSize, "max_size")
+7 -1
View File
@@ -23,11 +23,17 @@ local function isRequired(spec)
end
RequiredImports.specs = allSpecs
RequiredImports.MAX_BYTES = 128 * 1024 * 1024
RequiredImports.MAX_BYTES = 2 * 1024 * 1024 * 1024
-- Past this, the launcher interposes a free-space warning before importing.
RequiredImports.LARGE_WARN_BYTES = 128 * 1024 * 1024
local function sizeLabel(bytes)
if bytes >= 1024 * 1024 * 1024 then
return ("%.1f GiB"):format(bytes / (1024 * 1024 * 1024))
end
return ("%.1f MiB"):format(bytes / (1024 * 1024))
end
RequiredImports.sizeLabel = sizeLabel
-- Check size before a caller reads an external or stored file into one large
-- Lua string. N64 sources may carry a 512-byte copier header, while stored
+14 -4
View File
@@ -15,6 +15,7 @@ local Runtime = require("src.mods.Runtime")
local GameViewport = require("src.render.GameViewport")
-- leaf module (no renderer dependency), so requiring it here cannot cycle
local FaithfulRes = require("src.core.FaithfulRes")
local ScreenPosition = require("src.core.ScreenPosition")
local Playfield = require("src.render.Playfield")
local Renderer = {}
@@ -94,6 +95,11 @@ local function displayMetrics()
return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow
end
local function positionLift(ph, contentPx, dpiY, cut)
if cut then return 0 end
return ScreenPosition.lift(ph, contentPx, ScreenPosition.safeTop() * dpiY)
end
function Renderer:init()
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
-- (#208). Every canvas below is sized in framebuffer pixels for the same
@@ -269,7 +275,7 @@ end
-- corners; flat mode returns exactly today's size (growth factor is 1 when
-- tilt is inactive).
function Renderer:worldViewSize()
local _, _, pw, ph, _, _, _, _, cut, grow = displayMetrics()
local _, _, pw, ph, _, dpiY, _, _, cut, grow = displayMetrics()
-- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
-- WHOLE display, so letterbox voids become more map instead of black bars.
-- That is why the lock appeared to do nothing in the overworld: it shrank
@@ -293,6 +299,9 @@ function Renderer:worldViewSize()
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
if vw % 2 ~= 0 then vw = vw + 1 end
if vh % 2 ~= 0 then vh = vh + 1 end
local _, uih = self:uiSize()
local lift = positionLift(ph, uih * self:fitScale(), dpiY, cut)
if lift > 0 then vh = vh + 2 * math.ceil(lift / sp) end
if Tilt.active() then
local g = Tilt.viewGrowth()
vw, vh = math.ceil(vw * g), math.ceil(vh * g)
@@ -774,8 +783,9 @@ function Renderer:frameRects()
r.uiw, r.uih = uiw, uih
r.vpw, r.vph = uiw * r.Sx, uih * r.Sy
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
r.lift = positionLift(ph, uih * Sp, dpiY, cut)
r.ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX
r.oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY
r.oy = (vy + math.floor((ph - uih * Sp) / 2) - r.lift) / dpiY
-- The UI has its own scale: it steps down as the survey zoom goes out (see
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
-- are identical to Sp/ox/oy and every rect below is what it always was.
@@ -795,7 +805,7 @@ function Renderer:frameRects()
r.Up, r.Ux, r.Uy = Up, Up / dpiX, Up / dpiY
r.uvpw, r.uvph = uiw * r.Ux, uih * r.Uy
r.uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX
r.uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY
r.uoy = (vy + math.max(0, math.floor((ph - uih * Up) / 2) - r.lift)) / dpiY
return r
end
@@ -1002,7 +1012,7 @@ function Renderer:endFrame(zones, worldZones)
local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight()
local wox = (vx + math.floor((pw - wvw * sp) / 2)) / dpiX
local woy = (vy + math.floor((ph - wvh * sp) / 2)) / dpiY
local woy = (vy + math.floor((ph - wvh * sp) / 2) - R.lift) / dpiY
-- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or
+13
View File
@@ -57,6 +57,8 @@ function TextBox.new(game, text, onDone, opts)
self.money = opts and opts.money
self.auto = opts and opts.auto
self.stay = opts and opts.stay
-- engine/events/hidden_events/cinnabar_gym_quiz.asm:119
self.preSound = opts and opts.preSound
-- opts.instant: put the LAST page up already typed, with no typewriter and
-- no page waits. A `yesorno` follows a `writetext` that has already been
-- read, so re-typing the line under the YES/NO box would be wrong -- the
@@ -270,6 +272,17 @@ end
function TextBox:update(dt)
local input = self.game.input
self.blink = (self.blink + 1) % 60
-- home/text.asm:506
if self.preSound then
if not self.preStarted then
self.preStarted = true
self.preSrc = self.preSound()
end
if self.preSrc and self.preSrc.isPlaying and self.preSrc:isPlaying() then
return
end
self.preSound, self.preSrc = nil, nil
end
-- A page or CONT advance blocks the whole box while the original's scroll
-- and clear run (src/core/Timing.lua TEXT_SCROLL_PAIR / TEXT_PAGE_CLEAR).
-- Nothing types and no input is read until it drains.
+4 -1
View File
@@ -228,7 +228,10 @@ SaveConvert.mergeDefaults = mergeDefaults
-- codec exists yet. Both directions answer with a plain message the
-- launcher's save card renders as-is, instead of pushing a Gen 2 save
-- table through Gen 1 offsets and surfacing a codec traceback.
local GEN2_SAV_UNSUPPORTED = { gold = "Pokemon Gold" }
local GEN2_SAV_UNSUPPORTED = {
gold = "Pokemon Gold",
silver = "Pokemon Silver",
}
-- importSav(bytes, version, gameVersion) -> saveTable, err
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
+2 -1
View File
@@ -254,7 +254,8 @@ function Commands.give_item(ctx, itemId, count, gotText)
Commands.show_text(ctx, gotText
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
else
Sound.play(ctx.game.data, jingle)
-- scripts/OaksLab.asm:1058
Commands.text_sound(ctx, jingle)
end
end
+25 -2
View File
@@ -7,6 +7,7 @@ SyncEngine.__index = SyncEngine
SyncEngine.UPLOAD_DEBOUNCE = 5
SyncEngine.AUTO_INTERVAL = 300
SyncEngine.RESUME_MIN_GAP = 60
SyncEngine.MAX_STEPS_PER_UPDATE = 8
local IDLE_STATUS = "Ready"
@@ -133,6 +134,7 @@ function SyncEngine.new(opts)
eng.modPlan = nil
eng.shareCode = nil
eng.clock = 0
eng.autoAt = SyncEngine.AUTO_INTERVAL
eng.queue = {}
eng.pending = nil
eng.uploadAt = nil
@@ -258,6 +260,11 @@ function SyncEngine:update(dt)
self.uploadAt = nil
if self.state.enabled and self:linked() then self:syncNow() end
end
if self.clock >= self.autoAt and not self:busy()
and (self.phase == "idle" or self.phase == "error")
and self.state.enabled and self:linked() then
self:syncNow()
end
local steps = 0
while not self.pending and #self.queue > 0
and steps < SyncEngine.MAX_STEPS_PER_UPDATE do
@@ -296,6 +303,7 @@ function SyncEngine:createAccount(label)
eng.phase = "idle"
eng.status = "Sync account created"
eng:_persist()
eng:syncNow()
end)
end
@@ -385,9 +393,24 @@ function SyncEngine:setEnabled(enabled)
return self.state.enabled
end
function SyncEngine:protectPlaythrough(version, playthroughId)
self.protectedKey = SyncState.key(version, playthroughId)
end
function SyncEngine:noteResumed()
if not (self.state.enabled and self:linked()) then return end
if self:busy() or self.phase == "conflict" then return end
if self.now() - (tonumber(self.state.lastSyncAt) or 0)
< SyncEngine.RESUME_MIN_GAP then
return
end
self:syncNow()
end
function SyncEngine:syncNow()
if not self:linked() then return false, "this device is not linked" end
if self.pending then return false, "sync is busy" end
self.autoAt = self.clock + SyncEngine.AUTO_INTERVAL
self.queue = {}
self.conflicts = {}
self.state.pendingConflicts = {}
@@ -437,13 +460,13 @@ function SyncEngine:_planFrom(remoteState)
self:_addConflict(entry, key, row)
elseif localChanged then
self:_queueUpload(entry, key, false)
elseif remoteChanged then
elseif remoteChanged and key ~= self.protectedKey then
self:_queueDownload(key, entry.version, entry.playthroughId, "replace")
end
end
end
for key, row in pairs(remote) do
if not seen[key] then
if not seen[key] and key ~= self.protectedKey then
local version, id = SyncState.splitKey(key)
if version and id then
self:_queueDownload(key, version, id, "replace", tonumber(row.rev))
+1 -1
View File
@@ -5,7 +5,7 @@ SyncMods.REV = 1
local function versions()
local ok, GameVersion = pcall(require, "src.core.GameVersion")
if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end
return { "red", "blue", "yellow", "gold" }
return { "red", "blue", "yellow", "gold", "silver" }
end
local function defaultDeps()
+4 -2
View File
@@ -185,14 +185,16 @@ local function release(game)
return
end
game.stack:push(TextBox.new(game,
Strings("Once released,\n%s is\ngone forever. OK?", name), nil, {
(t._OnceReleasedText or Strings("Once released,\n%s is\ngone forever. OK?", name))
:gsub("{RAM:wStringBuffer}", name), nil, {
defaultNo = true, noSound = true,
choice = function(yes)
if not yes then return end
table.remove(box, list.index)
require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game,
Strings("%s was\nreleased outside.\fBye %s!", name, name)))
((t._MonWasReleasedText or Strings("%s was\nreleased outside.\fBye %s!", name, name))
:gsub("{RAM:wStringBuffer}", name))))
list:removeCurrent()
end,
}))
+11
View File
@@ -21,6 +21,7 @@ local GameVersion = require("src.core.GameVersion")
local VideoMode = require("src.core.VideoMode")
local Orientation = require("src.core.Orientation")
local FaithfulRes = require("src.core.FaithfulRes")
local ScreenPosition = require("src.core.ScreenPosition")
local FrameCap = require("src.core.FrameCap")
local Performance = require("src.core.Performance")
local Logger = require("src.core.Logger")
@@ -443,6 +444,16 @@ local function buildRows(game)
FaithfulRes.apply(o.faithfulRes)
return true
end },
{ id = "screenPos", label = Strings("SCREEN POS"),
value = function(g)
return Strings(ScreenPosition.label(g.save.options.screenPos))
end,
step = function(g, dir)
local o = g.save.options
o.screenPos = ScreenPosition.cycle(o.screenPos, dir)
ScreenPosition.setMode(o.screenPos)
return true
end },
-- hard render cap (issue #88): bounds the present rate so a
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
-- is fixed-step off dt, so this touches presentation only.
+5 -2
View File
@@ -13,6 +13,7 @@ local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local QuantityBox = require("src.ui.QuantityBox")
local Strings = require("src.core.Strings")
local romText = require("src.core.RomText")
local ShopMenu = {}
@@ -57,7 +58,8 @@ local function buy(game, stock)
end
local cost = qty * def.price
-- _PokemartTellBuyPriceText + yes/no confirm
list.footer = Strings("%s?\nThat will be\n¥%d. OK?", def.name, cost)
list.footer = romText(game.data, "_PokemartTellBuyPriceText",
"%s?\nThat will be\n¥%d. OK?", def.name, cost)
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then
list.footer = greet
@@ -147,7 +149,8 @@ local function sell(game)
return
end
-- _PokemartTellSellPriceText + yes/no confirm
list.footer = Strings("I can pay you\n¥%d for that.", unit * qty)
list.footer = romText(game.data, "_PokemartTellSellPriceText",
"I can pay you\n¥%d for that.", unit * qty)
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then
list.footer = greet
+13 -11
View File
@@ -851,6 +851,7 @@ function Studio.detectViewport()
end
Studio.pushUndo()
page.viewport = rect
page.screenFit = nil
setStatus(("Screen detected: %dx%d px in the bezel art"):format(pw, ph))
Studio.dirty = true
end
@@ -860,8 +861,9 @@ function Studio.toggleViewport()
if not page then return end
if Studio.canvas().lockViewport then return end
Studio.pushUndo()
if page.viewport then
if page.viewport or page.screenFit == "remainder" then
page.viewport = nil
page.screenFit = nil
else
page.viewport = { x = 0.1, y = 0.05, w = 0.8, h = 0.45 }
end
@@ -892,7 +894,7 @@ function Studio.pageLabel(index)
local orient = TouchSkin.pageOrient(page)
local bits = { #(page.controls or {}) .. " controls" }
if orient then bits[#bits + 1] = orient end
if page.viewport then bits[#bits + 1] = "screen" end
if page.viewport or page.screenFit == "remainder" then bits[#bits + 1] = "screen" end
return name, table.concat(bits, " \194\183 ")
end
@@ -1092,10 +1094,9 @@ function Studio.snapLines(page, r, skipIndex)
end
local function viewportRect(page, r)
local v = page.viewport
if not v then return nil end
local bx, by, bw, bh = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y)
return bx + v.x * bw, by + v.y * bh, v.w * bw, v.h * bh
local x, y, w, h = TouchSkin.pageViewport(page, r.w, r.h, r.x, r.y)
if not x then return nil end
return x, y, w, h
end
local function handleRects(bx, by, bw, bh)
@@ -1138,7 +1139,7 @@ function Studio.beginCanvasDrag(mx, my, r)
end
local vx, vy, vw, vh = viewportRect(page, r)
if vx and not Studio.canvas().lockViewport then
if vx and not Studio.canvas().lockViewport and page.screenFit ~= "remainder" then
for _, h in ipairs(handleRects(vx, vy, vw, vh)) do
if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then
Studio.pushUndo()
@@ -1162,7 +1163,7 @@ function Studio.beginCanvasDrag(mx, my, r)
end
end
if vx and not Studio.canvas().lockViewport
if vx and not Studio.canvas().lockViewport and page.screenFit ~= "remainder"
and mx >= vx and mx <= vx + vw and my >= vy and my <= vy + vh then
Studio.selected = nil
Studio.pushUndo()
@@ -1270,7 +1271,7 @@ local function drawCanvas(x, y, w, h)
if vx then
Theme.strokeRounded(vx, vy, vw, vh, PAL.blue, 0.9, 2, 2)
Kit.text("small", "SCREEN", vx + 4 * Kit.scale, vy + 4 * Kit.scale, PAL.blue)
if not Studio.canvas().lockViewport then
if not Studio.canvas().lockViewport and page.screenFit ~= "remainder" then
local a = Studio.selectedControl() and 0.45 or 1
for _, hd in ipairs(handleRects(vx, vy, vw, vh)) do
Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.blue, a)
@@ -1374,7 +1375,7 @@ local function inspectorBody(x, y, w)
cy = cy + rowH + gap
if page then
local bezel = page.imagePath or "(none)"
local bezel = page.imagePath or page.rasterName or "(none)"
local pickW = 82 * Kit.scale
local cycleW = w - pickW - gap
if Kit.button(x, cy, cycleW, rowH, "Bezel: " .. bezel, { id = "bezel" }) then
@@ -1385,7 +1386,8 @@ local function inspectorBody(x, y, w)
Studio.importImageFile("bezel")
end
cy = cy + rowH + gap
local vpLabel = page.viewport and "Screen cutout: ON" or "Screen cutout: OFF"
local vpLabel = (page.viewport or page.screenFit == "remainder")
and "Screen cutout: ON" or "Screen cutout: OFF"
if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp",
enabled = not Studio.canvas().lockViewport }) then
Studio.toggleViewport()
+3 -1
View File
@@ -46,6 +46,7 @@
local Font = require("src.render.Font")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local romText = require("src.core.RomText")
local SlotMachine = {}
SlotMachine.__index = SlotMachine
@@ -357,7 +358,8 @@ function SlotMachine:resolveWin(win)
-- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port
-- shows it in the box while the screen flashes. LinedUpText follows.
self.yeah = (sym == "7")
self.message = Strings("%s lined up!\nScored %d coins!", sym, pay)
self.message = sym .. romText(self.game.data, "_LinedUpText",
" lined up!\nScored %d coins!", pay)
-- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not
-- credited until the player dismisses the "lined up" text (see startPayout).
self.stage = "flash"
+544 -198
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -73,7 +73,10 @@ function Editor.load(opts)
}
local optsTbl = SaveData.loadOptions()
local applied = optsTbl
if opts.version == "gold" then
local GameVersion = require("src.core.GameVersion")
local gen2 = GameVersion.VERSIONS[opts.version]
and GameVersion.generation(opts.version) == 2
if gen2 then
local gold = type(optsTbl.gold) == "table" and optsTbl.gold or {}
applied = {
touchControls = gold.touchControls,
@@ -151,7 +154,9 @@ local function persist()
skin = cfg.skin,
layouts = cfg.layouts,
}
if Editor.version == "gold" then
local GameVersion = require("src.core.GameVersion")
if GameVersion.VERSIONS[Editor.version]
and GameVersion.generation(Editor.version) == 2 then
opts.gold = type(opts.gold) == "table" and opts.gold or {}
opts.gold.touchControls = block
else
@@ -569,7 +574,7 @@ function Editor.new(game)
local state = { game = game, isOpaque = true }
Editor.hostPoll = true
Editor.load({
version = "gold",
version = require("src.core.GameVersion").get(),
hostPoll = true,
onClose = function()
Editor.hostPoll = false
+2 -2
View File
@@ -619,8 +619,8 @@ function BattleTransition:grid(w, h)
scale = math.max(1, math.floor(math.min(w / 160, h / 144)))
end
local size = 8 * scale
local ox = math.floor((w - 160 * scale) / 2)
local oy = math.floor((h - 144 * scale) / 2)
local Chrome = require("src.ui.gen2.Chrome")
local ox, oy = Chrome.fitOrigin(w, h, scale)
return size, ox, oy
end

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