Compare commits

..

33 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
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
122 changed files with 22549 additions and 1047 deletions
+8 -55
View File
@@ -12,10 +12,11 @@ name: ci
# #
on: on:
push: 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] 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: pull_request:
branches: [dev]
# a force-push while CI is mid-run should cancel the stale run, not queue # a force-push while CI is mid-run should cancel the stale run, not queue
concurrency: concurrency:
@@ -318,52 +319,10 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
scripts/build_linux_arm64.sh --version 0.0.0 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 - name: Verify the AppImage is self-contained and bullseye-compatible
run: | run: bash scripts/linux-arm64/verify_appimage.sh dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage
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; }
- name: Upload the AppImage - name: Upload the AppImage
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
@@ -400,7 +359,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- run: sudo apt-get update && sudo apt-get install -y luajit
- run: python3 -m pip install --upgrade pillow - run: python3 -m pip install --upgrade pillow
# the fixture PNGs are committed (they are 8x8 placeholders, not # the fixture PNGs are committed (they are 8x8 placeholders, not
@@ -421,13 +379,8 @@ jobs:
print(f"\n{len(paths)} fixture assets valid") print(f"\n{len(paths)} fixture assets valid")
PY PY
# the fingerprint golden is the parity tripwire; prove it still # the fingerprint parity gates (gate_fingerprint / gate_meta_coverage)
# matches the dataset on a clean checkout # run in the headless job via run_engine; this job only guards the PNGs
- name: fingerprint gate
run: luajit tests/engine/gate_fingerprint.lua
- name: parity-guarantee meta-test
run: luajit tests/engine/gate_meta_coverage.lua
# Only the differ is under test here, and the job is named for that. The # 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 # 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 \ scripts/build_linux_arm64.sh \
--version "${{ needs.version.outputs.version }}" \ --version "${{ needs.version.outputs.version }}" \
--game-love .bazinga/work/game.love --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 - name: Upload Linux arm64 release
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
@@ -285,7 +287,7 @@ jobs:
retention-days: 1 retention-days: 1
release: 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"') }} runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
steps: steps:
@@ -307,6 +309,15 @@ jobs:
name: gen1tls-win-x64 name: gen1tls-win-x64
path: dist/native/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 - name: Import signing certificate into a temporary keychain
if: github.repository == 'bryanthaboi/gen1recomp' if: github.repository == 'bryanthaboi/gen1recomp'
run: | run: |
@@ -357,7 +368,8 @@ jobs:
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)" echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
exit 1 exit 1
fi 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 \ unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; } || { 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`) ### 4. `games` (and the legacy `gen2compat`)
Pokemon Gold is Gen 2, and it runs its own battle engine, overworld, script Pokemon Gold and Silver are Gen 2, and they run their own battle engine,
VM and save format. The mod API is shared across both generations (same hook overworld, script VM and save format. The mod API is shared across both
names, same event names, same registry names) but Gold cannot serve all of it generations (same hook names, same event names, same registry names) but Gen 2
yet, so Gen 2 is opt-in. Say which games the mod is for: cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
```json ```json
"games": ["gen1", "gen2"] "games": ["gen1", "gen2"]
``` ```
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`), a Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`,
generation (`"gen1"`, `"gen2"`) or `"all"`; `"silver"`), a generation (`"gen1"`, `"gen2"`) or `"all"`;
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing `src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
restates the game list. `python3 tools/modkit.py scaffold my_mod --games 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, 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 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 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 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 already ran on. On a Gold or Silver boot a mod claiming no Gen 2 game is not
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a loaded at all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why,
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually because a mod that half-applies reads as a broken mod. Claim Gen 2 once you
run your mod on Gold. have actually run your mod on Gold or Silver.
Every token is enforced, per game: the loader gates on the same Every token is enforced, per game: the loader gates on the same
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]` `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. when you mean everywhere.
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold `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 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. 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 `docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
+9 -9
View File
@@ -55,16 +55,15 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
[![Watch the latest update video](https://img.youtube.com/vi/yi7LkWQPKKM/maxresdefault.jpg)](https://youtu.be/yi7LkWQPKKM) [![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, 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 or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold, or
is the only game content input. Silver ROM is the only game content input.
The ROM is verified, used during import, and then released from memory. It is 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 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 do not ask for the ROM again. Red, Blue, Yellow, Gold, and Silver can all be
side by side. Gold is Gen 2 Phase 1 (import + launcher; see imported side by side. Gold and Silver are Gen 2 Phase 1 (import + launcher;
`docs/gold-phase1.md`): the Gen 2 engine is still under construction. see `docs/gold-phase1.md`): the Gen 2 engine is still under construction.
## Quick Start ## 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 `.gbc` file or drop it onto the window. Import takes a few seconds and the
game starts automatically. game starts automatically.
Only the canonical US Red, Blue, Yellow (1 MiB), and Gold (2 MiB) ROMs are Only the canonical US Red, Blue, Yellow (1 MiB), Gold, and Silver (2 MiB)
accepted. The importer verifies SHA-1 before creating any game data: ROMs are accepted. The importer verifies SHA-1 before creating any game data:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a` - Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2` - Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1` - Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94` - Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
- Silver: `49b163f7e57702bc939d642a18f591de55d92dae`
The packaged app contains neither a ROM nor pre-extracted game data. Music, 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 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 | | 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 | | `--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 | | `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

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" \ (cd "$SOURCE_DIR" && zip -q -9 -r "$WORK/game-payload.zip" \
main.lua conf.lua src libs data assets tools/save-editor \ main.lua conf.lua src libs data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*') -x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$WORK/game-payload.zip" \ if unzip -Z1 "$WORK/game-payload.zip" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then | 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 \ main.lua conf.lua src libs data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*') -x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")" payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
printf '%s\n' "$payload_list" \ printf '%s\n' "$payload_list" \
@@ -99,6 +100,8 @@ printf '%s\n' "$payload_list" \
&& fail "payload unexpectedly contains generated ROM data" && fail "payload unexpectedly contains generated ROM data"
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \ printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|| fail "payload is missing 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" unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
rm -f "$WORK/game-payload.zip" rm -f "$WORK/game-payload.zip"
@@ -193,6 +196,26 @@ get_controls
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt" [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
GAMEDIR="$SHDIR/gen1recomp" 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" CONFDIR="$GAMEDIR/conf"
mkdir -p "$CONFDIR" mkdir -p "$CONFDIR"
+6 -3
View File
@@ -25,8 +25,10 @@
local Menu = require("src.ui.Menu") local Menu = require("src.ui.Menu")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, so the -- TMNotebookText (data/text/text_2.asm) has no leading underscore, but the
-- extractor never collects it and the pamphlet's text is inlined. -- 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" local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f"
.. "There are 50 TMs\nin all.\f" .. "There are 50 TMs\nin all.\f"
.. "There are also 5\nHMs that can be\vused repeatedly.\f" .. "There are also 5\nHMs that can be\vused repeatedly.\f"
@@ -70,7 +72,8 @@ return {
return true return true
end end
if fx == 3 and fy == 4 then 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 return true
end end
return false return false
+10 -7
View File
@@ -15,10 +15,10 @@ return {
-- pick the dish: bit 7 set (~50%) -> Salmon du Salad, else bit 4 -- pick the dish: bit 7 set (~50%) -> Salmon du Salad, else bit 4
-- set (~25%) -> Eels au Barbecue, else (~25%) -> Prime Beef Steak. -- set (~25%) -> Eels au Barbecue, else (~25%) -> Prime Beef Steak.
-- The three dish texts (SSAnneKitchenCook7SalmonDuSaladText / -- The three dish texts (SSAnneKitchenCook7SalmonDuSaladText /
-- ...EelsAuBarbecueText / ...PrimeBeefSteakText) aren't extracted -- ...EelsAuBarbecueText / ...PrimeBeefSteakText) have no leading
-- into data/generated/text.lua (no leading underscore in -- underscore in pokered/text/SSAnneKitchen.asm, but the extractor
-- pokered/text/SSAnneKitchen.asm), so their literal strings are -- collects them regardless (tools/extract/text.py); the literals
-- ported here verbatim. -- below are only the fallback for a catalog without them.
TEXT_SSANNEKITCHEN_COOK7 = function(game, ow, npc, done) TEXT_SSANNEKITCHEN_COOK7 = function(game, ow, npc, done)
local t = game.data.text local t = game.data.text
push(game, t._SSAnneKitchenCook7MainCourseIsText push(game, t._SSAnneKitchenCook7MainCourseIsText
@@ -27,13 +27,16 @@ return {
local dish local dish
if roll <= 2 then if roll <= 2 then
-- bit 7 of hRandomAdd set (~50%) -- 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 elseif roll == 3 then
-- bit 4 set, bit 7 clear (~25%) -- 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 else
-- neither bit set (~25%) -- 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 end
push(game, dish, done) push(game, dish, done)
end) end)
+11 -10
View File
@@ -60,22 +60,23 @@ M.VIRIDIAN_CITY = {
-- you want to know about the two kinds of caterpillar Pokemon; -- you want to know about the two kinds of caterpillar Pokemon;
-- YES -> CATERPIE/WEEDLE description, NO -> "Oh, OK then!". -- YES -> CATERPIE/WEEDLE description, NO -> "Oh, OK then!".
-- ViridianCityYoungster2OkThenText and -- ViridianCityYoungster2OkThenText and
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are -- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are defined
-- defined without a leading underscore in pokered/text/ViridianCity.asm -- without a leading underscore in pokered/text/ViridianCity.asm, but
-- and aren't present in data/generated/text.lua, so we fall back to -- tools/extract/text.py now collects them regardless -- the literal
-- the literal strings from pokered. Those fallbacks have to carry the -- strings below are only the fallback for a catalog without them.
-- extractor's markers, not plain newlines: line -> \n, cont -> \v, -- Those fallbacks have to carry the extractor's markers, not plain
-- para -> \f. Spelling cont/para as \n and \n\n put all six lines on -- newlines: line -> \n, cont -> \v, para -> \f. Spelling cont/para as
-- one page with nothing to wait on, so the whole speech scrolled past -- \n and \n\n put all six lines on one page with nothing to wait on,
-- without a button press (#250). -- so the whole speech scrolled past without a button press (#250).
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done) TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
local t = text(game) local t = text(game)
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes) or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
if yes then 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 else
push(game, "Oh, OK then!", done) push(game, t.ViridianCityYoungster2OkThenText or "Oh, OK then!", done)
end end
end) end)
end, end,
+6 -5
View File
@@ -122,9 +122,8 @@ M.CINNABAR_LAB_METRONOME_ROOM = {
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's -- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre -- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
-- text (#775). Like the SilphCo2F worker (#393) that label carries no -- text (#775). Like the SilphCo2F worker (#393) that label carries no
-- leading underscore, and on Red it sits outside the extractor's symbol -- leading underscore; tools/extract/text.py now collects it regardless,
-- set, so the literal from text/ViridianCity.asm rides along as the -- so preFallback below is just the safety net for a catalog without it.
-- fallback; Yellow resolves the ROM string instead.
M.VIRIDIAN_CITY = { M.VIRIDIAN_CITY = {
talk = { talk = {
TEXT_VIRIDIANCITY_FISHER = gift({ TEXT_VIRIDIANCITY_FISHER = gift({
@@ -146,9 +145,11 @@ M.SILPH_CO_2F = {
talk = { talk = {
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({ TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT", flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
-- the label carries no leading underscore: pokered keeps this one in -- the label carries no leading underscore (#393); collected like any
-- the script bank, not the far-text bank (#393) -- other text/*.asm label now, preFallback is just the safety net
pre = "SilphCo2FSilphWorkerFPleaseTakeThisText", 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", received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText", explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText", noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
+11 -8
View File
@@ -105,8 +105,9 @@ trixie.
This is a statement about the *compile environment*, not about where the This is a statement about the *compile environment*, not about where the
artifact runs — building on your own newer distro would silently raise that 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 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 download it. `scripts/linux-arm64/verify_appimage.sh` enforces the floor in
required glibc symbol version climbs above 2.31. 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 ### 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 exclude list still classifies known sonames correctly, that AppRun still
launches `game.love` with `--fused`, and that the host-arch guard actually launches `game.love` with `--fused`, and that the host-arch guard actually
fires. Needs no container and no arm64 machine. fires. Needs no container and no arm64 machine.
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then extracts - **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then
the artifact and asserts the layout, that every bundled object resolves `scripts/linux-arm64/verify_appimage.sh` extracts the artifact and asserts
under AppRun's `LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31. the layout, that every bundled object resolves under AppRun's
Uploads the AppImage for 7 days. `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 - **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 `game.love` from the `love-payload` job, runs the same
published like every other release asset. `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 Unlike the Switch job, none of this needs secrets or self-hosted hardware, so
it runs on fork PRs too. 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"`, `games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
`"gold"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or `"all"`. `"gold"`, `"silver"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or
`src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER` and `"all"`. `src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER`
`GameVersion.generation`, so nothing anywhere restates the game list. 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` `Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
and **derives** `manifest.gen2compat` from them, which is the one field the and **derives** `manifest.gen2compat` from them, which is the one field the
loader's gate reads. 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"`). | | `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. | | `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). | | `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"`). | | `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). | | `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. | | `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
+2
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 * **Persistent custom options** stored separately from game saves
* **Optional widescreen battle layout** * **Optional widescreen battle layout**
* **Mobile touch controls** with editable layouts, vibration, and orientation settings * **Mobile touch controls** with editable layouts, vibration, and orientation settings
* **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 * **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** * **Pokédex diploma and printer image exports**
## Gen 2 Specifics ## 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` * **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
* **Followers** for mods, plus Gen 2-only registries and hooks * **Followers** for mods, plus Gen 2-only registries and hooks
+1 -1
View File
@@ -176,7 +176,7 @@ something the filesystem encodes.
| token | means | | 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) | | `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
| `"all"` | every game this engine has | | `"all"` | every game this engine has |
+4 -2
View File
@@ -1,7 +1,8 @@
# What This Port Requires # What This Port Requires
The packaged desktop app requires one user-supplied input on first boot: a The packaged desktop app requires one user-supplied input on first boot: a
canonical 1 MiB US Pokemon Red, 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` 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 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 Assembly removes high-level names and some relationships that the Lua port
needs. The version-specific files `tools/rom_manifest.json`, 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: contain:
- the ROM symbol addresses actually read by the extractor - the ROM symbol addresses actually read by the extractor
+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: This project ships **no** game data. On first launch:
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, or 1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, Gold, or
Gold (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the Silver (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
launcher also shows the live save-dir path). All four can sit in the launcher also shows the live save-dir path). All five can sit in the
same folder. same folder.
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold). 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 Silver). Rescan matches by ROM SHA-1 for the open tab only. A Red dump
imports from the Yellow tab (and vice versa). Gold is Beta in the never imports from the Yellow tab (and vice versa). Gold and Silver are
launcher; a clean US Gold dump is enough to Play. Beta in the launcher; a clean US dump of either is enough to Play.
## 5. Import / Export a raw `.sav` ## 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/` | | Blue | `imports/saves/blue/` | `exports/blue/` |
| Yellow | `imports/saves/yellow/` | `exports/yellow/` | | Yellow | `imports/saves/yellow/` | `exports/yellow/` |
| Gold | `imports/saves/gold/` | `exports/gold/` | | Gold | `imports/saves/gold/` | `exports/gold/` |
| Silver | `imports/saves/silver/` | `exports/silver/` |
(Under the save dir `pokemon-love2d/`. The zip already creates these folders. (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 Gold and Silver cart `.sav` import/export is not supported yet -- the folders
MTP browsing matches the other games. Gold progress still saves in-engine.) 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 1. Copy a Gen 1 `.sav` (32 KB) into that game's inbox under the save dir
([switch-transfer.md](switch-transfer.md)). ([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 | | 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/`) | | 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** | | 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 `.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/` (pull after **Export save**; Gold 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` | | Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
| Lua error log | `lua-error.log` in the save dir | | 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 3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
(or copy NRO / `game.love` for loose). (or copy NRO / `game.love` for loose).
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`, 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. path the launcher prints.
5. Wait for the queue; refresh; exit MTP responder; title-override launch. 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 if love.audio and love.audio.stop then
pcall(love.audio.stop) pcall(love.audio.stop)
end end
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local currentVersion = GameVersion.get() local currentVersion = GameVersion.get()
@@ -386,11 +387,12 @@ function bootGame(version)
love.window.setTitle(Version.title( love.window.setTitle(Version.title(
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)")) GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
end end
-- Gold: Gen 1 Game:load cannot consume a Gen 2 cache -- different generated -- Gen 2: Gen 1 Game:load cannot consume a Gen 2 cache -- different generated
-- tables, save shape and screen registry -- so Gold boots its own service -- tables, save shape and screen registry -- so Gold and Silver boot their
-- owner, which mounts src/world/gen2 (walk / warps / connections) and the -- own service owner, which mounts src/world/gen2 (walk / warps /
-- Gen 2 screens instead of src/core/Game.lua's Gen 1 wiring. -- connections) and the Gen 2 screens instead of src/core/Game.lua's Gen 1
if GameVersion.isGold() then -- wiring.
if GameVersion.generation() == 2 then
Game = require("src.core.Game2").new() Game = require("src.core.Game2").new()
Game:load() Game:load()
else 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/`, `app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`,
`libs/` (the vendored FlexLove toolkit the launcher UI needs), `data/`, `libs/` (the vendored FlexLove toolkit the launcher UI needs), `data/`,
`assets/`, and the Red, Blue, and Yellow ROM manifests. The Android `assets/`, and the Red, Blue, Yellow, Gold, and Silver ROM manifests. The
packer verifies the Yellow manifest before it packages; if a partial source Android packer verifies the Yellow, Gold, and Silver manifests before it
export omitted it, it restores the file from this checkout's Git data and then packages; if a partial source export omitted one, it restores the file from
falls back to the project's GitHub copy. Generated game data, 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. scripts, tests, and mobile build sources are excluded.
## Branding (applied by the build script) ## 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_blue">#1E88E5</color>
<color name="shortcut_yellow">#FDD835</color> <color name="shortcut_yellow">#FDD835</color>
<color name="shortcut_gold">#D4AF37</color> <color name="shortcut_gold">#D4AF37</color>
<color name="shortcut_silver">#BEC6D2</color>
</resources> </resources>
@@ -398,11 +398,15 @@ public class GameActivity extends SDLActivity {
@Override @Override
protected void onDestroy() { protected void onDestroy() {
secondaryHostResumed = false;
if (vibrator != null) { if (vibrator != null) {
Log.d("GameActivity", "Cancelling vibration"); Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel(); vibrator.cancel();
} }
unregisterSecondaryDisplayListener(); unregisterSecondaryDisplayListener();
teardownSecondaryDisplay();
secondaryEnabled = false;
synchronized (secondaryFrameLock) { secondaryFrame = null; }
unregisterAudioDeviceCallback(); unregisterAudioDeviceCallback();
abandonAudioFocus(); abandonAudioFocus();
onHostDestroy(); onHostDestroy();
@@ -411,6 +415,7 @@ public class GameActivity extends SDLActivity {
@Override @Override
protected void onPause() { protected void onPause() {
secondaryHostResumed = false;
if (vibrator != null) { if (vibrator != null) {
Log.d("GameActivity", "Cancelling vibration"); Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel(); vibrator.cancel();
@@ -426,6 +431,7 @@ public class GameActivity extends SDLActivity {
@Override @Override
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
secondaryHostResumed = true;
onHostResume(); onHostResume();
requestGameAudioFocus(); requestGameAudioFocus();
registerAudioDeviceCallback(); registerAudioDeviceCallback();
@@ -1933,6 +1939,7 @@ public class GameActivity extends SDLActivity {
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY; private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
private static volatile long secondaryRetryAfter; private static volatile long secondaryRetryAfter;
private static volatile boolean secondaryEnabled = false; private static volatile boolean secondaryEnabled = false;
private static volatile boolean secondaryHostResumed = false;
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO; private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
private static volatile int dualScreenDisplayMode = -1; private static volatile int dualScreenDisplayMode = -1;
private static volatile byte[] secondaryFrame; private static volatile byte[] secondaryFrame;
@@ -1963,7 +1970,7 @@ public class GameActivity extends SDLActivity {
if (self == null) return; if (self == null) return;
self.runOnUiThread(new Runnable() { self.runOnUiThread(new Runnable() {
@Override public void run() { @Override public void run() {
if (on) { if (on && secondaryHostResumed) {
self.refreshDualScreenDisplayMode(); self.refreshDualScreenDisplayMode();
self.registerSecondaryDisplayListener(); self.registerSecondaryDisplayListener();
rebindSecondaryDisplay(); rebindSecondaryDisplay();
@@ -2035,9 +2042,11 @@ public class GameActivity extends SDLActivity {
private static void rebindSecondaryDisplay() { private static void rebindSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton; GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return; if (self == null || !secondaryHostResumed || !secondaryEnabled
|| secondaryOutputIsPreferred(self)) return;
self.runOnUiThread(() -> { self.runOnUiThread(() -> {
if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return; if (!secondaryHostResumed || !secondaryEnabled
|| secondaryOutputIsPreferred(self)) return;
teardownSecondaryDisplay(); teardownSecondaryDisplay();
setupSecondaryDisplay(); setupSecondaryDisplay();
}); });
@@ -2045,7 +2054,8 @@ public class GameActivity extends SDLActivity {
private static void setupSecondaryDisplay() { private static void setupSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton; GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryPresentation != null if (self == null || !secondaryHostResumed || !secondaryEnabled
|| secondaryPresentation != null
|| secondaryActivity != null || secondaryActivityPending || secondaryActivity != null || secondaryActivityPending
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return; || android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
try { try {
+14
View File
@@ -12,6 +12,20 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "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", "version": "0.2.9",
"date": "2026-08-19", "date": "2026-08-19",
+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: ..."] # Usage: scripts/build.sh [mac|win|linux|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
# [--notary-profile NAME] [--no-notarize] # [--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 # [--release] # ios only: release config instead of debug
# #
# Output: dist/mac/gen1recomp-macos.zip # Output: dist/mac/gen1recomp-macos.zip
@@ -36,6 +37,7 @@ NOTARY_PROFILE="notary-profile"
NOTARIZE=true NOTARIZE=true
IOS_RELEASE=false IOS_RELEASE=false
IOS_IPA=false IOS_IPA=false
GAME_LOVE_IN=""
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
@@ -48,6 +50,7 @@ while [ $# -gt 0 ]; do
--identity) IDENTITY="$2"; shift ;; --identity) IDENTITY="$2"; shift ;;
--notary-profile) NOTARY_PROFILE="$2"; shift ;; --notary-profile) NOTARY_PROFILE="$2"; shift ;;
--no-notarize) NOTARIZE=false ;; --no-notarize) NOTARIZE=false ;;
--game-love) GAME_LOVE_IN="${2:?--game-love needs a path}"; shift ;;
--release) IOS_RELEASE=true ;; --release) IOS_RELEASE=true ;;
--ipa) IOS_IPA=true ;; --ipa) IOS_IPA=true ;;
*) fail "unknown argument: $1" ;; *) 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 # 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 # `--editor` / POKEPORT_EDITOR=1 opens it standalone. It is required through
# love.filesystem's require path, so it has to live inside the archive. # love.filesystem's require path, so it has to live inside the archive.
say "packing game.love"
LOVE_FILE="$WORK/game.love" LOVE_FILE="$WORK/game.love"
rm -f "$LOVE_FILE" rm -f "$LOVE_FILE"
# The launcher UI kit lives at src/ui/kit (inside src/, packed wholesale); if [ -n "$GAME_LOVE_IN" ]; then
# the vendored libs/flexlove tree it replaced is gone. [ -f "$GAME_LOVE_IN" ] || fail "--game-love: no such file: $GAME_LOVE_IN"
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \ say "using prebuilt payload: $GAME_LOVE_IN"
main.lua conf.lua src data assets tools/save-editor \ cp "$GAME_LOVE_IN" "$LOVE_FILE"
tools/rom_manifest.json tools/rom_manifest_blue.json \ else
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ say "packing game.love"
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*') # 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 # 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 # 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 # 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 \ tools/save-editor/panels/Party.lua \
src/ui/kit/Kit.lua \ src/ui/kit/Kit.lua \
tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json; do tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json; do
grep -qxF "$required" "$LOVE_LISTING" \ grep -qxF "$required" "$LOVE_LISTING" \
|| fail "game.love is missing $required" || fail "game.love is missing $required"
done 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 # mistaken for a release. The stamp is then read back out of the archive and the
# build fails if it did not take. # build fails if it did not take.
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION into game.love" if [ -n "$GAME_LOVE_IN" ]; then
stamp_dir="$WORK/stamp" version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
rm -rf "$stamp_dir" unzip -p "$LOVE_FILE" src/core/Version.lua \
mkdir -p "$stamp_dir/src/core" | grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \ || fail "prebuilt payload does not report engine $VERSION (pack it with pack_love.sh --version $VERSION)"
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua" say "prebuilt payload already stamped: $VERSION"
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua) else
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')" say "stamping engine version $VERSION into game.love"
unzip -p "$LOVE_FILE" src/core/Version.lua \ stamp_dir="$WORK/stamp"
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \ rm -rf "$stamp_dir"
|| fail "version stamp failed: game.love does not report engine $VERSION" mkdir -p "$stamp_dir/src/core"
say "stamped engine version: $VERSION" 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 else
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)" say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
fi fi
+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}" 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_RELATIVE="tools/rom_manifest_gold.json"
GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/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="" VERSION=""
PACKAGE_ONLY=false 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" 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 # --------------------------------------------------------------- branding
# love-android 11.5+ reads app id / name / orientation from gradle.properties. # love-android 11.5+ reads app id / name / orientation from gradle.properties.
# Manifest still gets permission trims. Re-applied every build so refreshing # 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" say "packing game.love for love-android embed flavor"
ensure_yellow_manifest ensure_yellow_manifest
ensure_gold_manifest ensure_gold_manifest
ensure_silver_manifest
mkdir -p "$EMBED_ASSETS" mkdir -p "$EMBED_ASSETS"
rm -f "$LOVE_FILE" rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save # 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 \ main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.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 '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*') -x 'data/generated/*' -x 'assets/generated/*')
# List once and match against the captured text: piping unzip straight into # 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" || fail "game.love is missing the Yellow ROM import manifest"
grep -qx 'tools/rom_manifest_gold.json' <<< "$archive_entries" \ grep -qx 'tools/rom_manifest_gold.json' <<< "$archive_entries" \
|| fail "game.love is missing the Gold ROM import manifest" || 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 # 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 # 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 # 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" unzip -Z1 "$APPDIR/game.love" > "$WORK/love-listing.txt"
grep -qxF "tools/rom_manifest_gold.json" "$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" || 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 # The .desktop's Icon= resolves against the AppDir root by basename, and
# .DirIcon is what appimaged and file-manager thumbnailers read. # .DirIcon is what appimaged and file-manager thumbnailers read.
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png" 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" || fail "shared payload version was not stamped"
grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \ grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|| fail "shared payload is missing tools/rom_manifest_gold.json" || 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" 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 \ main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*') -x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if [ -f "$ROOT/PATCH_NOTES.md" ]; then if [ -f "$ROOT/PATCH_NOTES.md" ]; then
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md) (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/save-editor/panels/Party.lua \
tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
src/ui/kit/Kit.lua \ src/ui/kit/Kit.lua \
src/import/LauncherView.lua; do src/import/LauncherView.lua; do
grep -qxF "$required" "$LISTING" \ 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" ] \ if [ ! -f "$ROOT/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/blue/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" fail "generated data missing, run scripts/setup.sh first"
fi 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 your saves, imported ROMs, mods, and options. Re-extracting only
replaces the NRO(s) and these help files. replaces the NRO(s) and these help files.
3. Launch with title override (hold R on HOME, open any title → hbmenu). 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/ switch/gen1recomp/pokemon-love2d/imports/
then use Scan again in the launcher if needed. then use Scan again in the launcher if needed.
Inboxes (drop files here via MTP / SD / FTP): Inboxes (drop files here via MTP / SD / FTP):
imports/ — ROM .gb / .gbc imports/ — ROM .gb / .gbc
imports/mods/ — community mod .zip imports/mods/ — community mod .zip
imports/saves/red|blue|yellow|gold/ — raw .sav import (Gold cart .sav not yet) imports/saves/red|blue|yellow|gold|silver/ — raw .sav import (Gold/Silver cart .sav not yet)
exports/red|blue|yellow|gold/ — pull after Export save (Gold 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 Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
EOF EOF
@@ -92,7 +92,7 @@ write_readme() {
} }
write_readme "$SAVE_ROOT/imports/README.txt" \ 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" \ write_readme "$SAVE_ROOT/imports/mods/README.txt" \
"Put community mod .zip files here, then MODS → Scan again." "Put community mod .zip files here, then MODS → Scan again."
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \ 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." "Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save."
write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \ 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." "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" \ write_readme "$SAVE_ROOT/exports/red/README.txt" \
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP." "After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
write_readme "$SAVE_ROOT/exports/blue/README.txt" \ 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." "After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
write_readme "$SAVE_ROOT/exports/gold/README.txt" \ write_readme "$SAVE_ROOT/exports/gold/README.txt" \
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games." "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" rm -f "$OUT_ZIP"
( (
@@ -140,10 +144,12 @@ REQUIRED=(
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" "switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/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/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/red/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" "switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/yellow/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"
) )
for rel in "${REQUIRED[@]}"; do for rel in "${REQUIRED[@]}"; do
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel" 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/blue/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/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/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/red/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \ "switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/yellow/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 do
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}" printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
done done
+1
View File
@@ -67,6 +67,7 @@ run_tier() {
# ------- ROM-free tiers: these are what CI runs # ------- 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 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 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 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 # NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
+48 -15
View File
@@ -1718,9 +1718,14 @@ function BattleState:enter()
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs -- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
-- playerMonFainted queues on the battle screen; there is no battle -- playerMonFainted queues on the battle screen; there is no battle
-- screen to queue them on here, so they print over the map. -- 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, self.game.stack:push(require("src.render.TextBox").new(self.game,
Strings("%s is out of\nuseable POKéMON!", name) .. "\f" self:romText("_PlayerBlackedOutText2",
.. Strings("%s blacked\nout!", name), blackedOut)) "%s is out of\nuseable POKéMON!\f%s blacked\nout!", name, name),
blackedOut))
return return
end end
self.musicKind = self:computeMusicKind() self.musicKind = self:computeMusicKind()
@@ -1857,7 +1862,8 @@ function BattleState:enter()
self:slidePic("foe") self:slidePic("foe")
end) end)
-- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923) -- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
self:sayAuto(Strings("%s sent\nout %s!", foeName, self.enemy.name)) self:sayAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
foeName, self.enemy.name))
self:act(function() self:act(function()
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the -- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
-- pic grows out of the ball (AnimateSendingOutMon), then the cry -- pic grows out of the ball (AnimateSendingOutMon), then the cry
@@ -3638,12 +3644,13 @@ function BattleState:executeAction(user, target, action)
}) })
self.aiUses = self:aiUsesFor() self.aiUses = self:aiUsesFor()
markSeen(self.game, self.enemy.mon.species) markSeen(self.game, self.enemy.mon.species)
-- _AIBattleWithdrawText: "X with-/drew Y!" self:sayNext(self:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName)) self.trainer.name, oldName))
-- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText, -- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434) -- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
self.enemySendingOut = true self.enemySendingOut = true
self:sayNextAuto(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:actNext(function()
self.enemySendingOut = false self.enemySendingOut = false
self:startGrowIn(self.enemy) self:startGrowIn(self.enemy)
@@ -4142,8 +4149,12 @@ function BattleState:onFaint(battler)
-- acknowledged core.asm:797-798 bug.) -- acknowledged core.asm:797-798 bug.)
self:actNext(function() self:playVictoryMusic() end) self:actNext(function() self:playVictoryMusic() end)
end end
-- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText -- _EnemyMonFaintedText already carries its own "Enemy" wording, so this
self:sayNext(Strings("%s\nfainted!", displayName(battler))) -- 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 if battler.isPlayer then
self:act(function() self:playerMonFainted() end) self:act(function() self:playerMonFainted() end)
else else
@@ -4319,6 +4330,13 @@ function BattleState:enemyMonFainted()
-- "X is" off so "about to use" stays above the name, instead of the -- "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" / -- page ending on a bare nick (#565). Then para "Will PLAYER" /
-- "change POKéMON?" with YES/NO. -- "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:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
self:sayChoice( self:sayChoice(
Strings("Will %s\nchange POKéMON?", self.game.save.player.name), Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
@@ -4362,7 +4380,8 @@ function BattleState:enemyMonFainted()
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation -- (AnimateSendingOutMon) with the cry; no POOF -- that animation
-- belongs to the player-side SendOutMon (core.asm:1757-1762) -- belongs to the player-side SendOutMon (core.asm:1757-1762)
self.enemySendingOut = true self.enemySendingOut = true
self:sayNextAuto(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:actNext(function()
self.enemySendingOut = false self.enemySendingOut = false
self:startGrowIn(self.enemy) self:startGrowIn(self.enemy)
@@ -4878,7 +4897,8 @@ function BattleState:storeCaughtMon()
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows -- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
-- the box rather than firing when the dex bit is set -- the box rather than firing when the dex bit is set
self:sayNextWaitSfx( 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) function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end)
self:uiNext(function() self:uiNext(function()
return self:buildScreen("DexEntryMenu", species) return self:buildScreen("DexEntryMenu", species)
@@ -4899,9 +4919,12 @@ function BattleState:storeCaughtMon()
if boxNum then if boxNum then
askCaughtNickname() askCaughtNickname()
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL) local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
and "BILL's PC" or Strings("someone's PC") self:sayNext(self:romText(
self:sayNext(Strings("%s was\ntransferred to\n%s!", self.enemy.name, pc)) 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 else
self:sayNext(Strings("But every BOX\nis full!")) self:sayNext(Strings("But every BOX\nis full!"))
end end
@@ -5007,8 +5030,18 @@ function BattleState:throwBall(ball)
-- RESTLESS SOUL dodges balls even once the scope has revealed it, -- RESTLESS SOUL dodges balls even once the scope has revealed it,
-- so it is not a ghost battle any more (#444) -- so it is not a ghost battle any more (#444)
self:animNext(self:tossAnimFor(ball), true, nil, ball) self:animNext(self:tossAnimFor(ball), true, nil, ball)
self:sayNext(Strings("It dodged the\nthrown BALL!")) -- _ItemUseBallText00 is one label for both lines, \f-paged. Unlike
self:sayNext(Strings("This POKéMON\ncan't be caught!")) -- 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:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction()) self:executeAction(self.enemy, self.player, self:enemyAction())
end) end)
+38 -11
View File
@@ -326,6 +326,9 @@ function Game:logicSpeed()
if self.linkSession or (self.linkNet and not self.linkNet.closed) then if self.linkSession or (self.linkNet and not self.linkNet.closed) then
return 1 return 1
end end
if Game.isFixedSpeedInStack and Game.isFixedSpeedInStack(self.stack) then
return 1
end
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
-- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's -- 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 -- core.logic_speed hook can return anything (0, negative, nil, NaN) and
@@ -475,6 +478,15 @@ function Game.speedCategoryInStack(stack)
return "menu" return "menu"
end 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 -- Whether a state on the stack composes its own screen and so wants the
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like -- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
-- everything else here: the text box and YES/NO a battle puts up are states -- 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). -- parked the player until every direction was re-pressed (#799).
function Game:focus(f) function Game:focus(f)
Input:reset() 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() TouchControls:reset()
self:cancelPointers() self:cancelPointers()
end end
@@ -990,6 +1006,8 @@ function Game:onResume()
Input:reconcile() Input:reconcile()
TouchControls:reset() TouchControls:reset()
self:cancelPointers() 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 -- 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). -- the active screen re-cue on the next frame (hardware audio check: T19).
-- Desktop/mobile window-visible flips must not kill overworld music. -- Desktop/mobile window-visible flips must not kill overworld music.
@@ -1197,18 +1215,26 @@ end
function Game:syncEngine() function Game:syncEngine()
if self._syncOff then return nil end if self._syncOff then return nil end
if self._syncEngineRef then return self._syncEngineRef end local eng = self._syncEngineRef
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()
if not eng then if not eng then
self._syncOff = true local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
return nil 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 end
self._syncEngineRef = eng
return eng return eng
end end
@@ -1248,6 +1274,7 @@ function Game:applyOptions(opts)
-- after VideoMode: a faithful-resolution lock is an exact window size, so -- after VideoMode: a faithful-resolution lock is an exact window size, so
-- it has to be the last word on the window (it drops fullscreen to hold) -- it has to be the last word on the window (it drops fullscreen to hold)
require("src.core.FaithfulRes").applyOptions(opts) require("src.core.FaithfulRes").applyOptions(opts)
require("src.core.ScreenPosition").applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no -- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88) -- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts) 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 -- everything under src/*/gen2 reaches shared services through here. Gen 1
-- Game:load cannot consume a Gen 2 cache -- different generated tables, save -- 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 -- 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 -- Boot: copyright → GameFreak Presents → GS intro stub → title
-- (tilemap + Ho-Oh flap / clouds / trails) → Oak speech (Marill + shrink) -- (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 -- row to opt into the step-down half, so CENTERED is the whole rule
-- here. -- here.
local s = self.world:fitScale() local s = self.world:fitScale()
local ox, oy = Chrome.fitOrigin(w, h, s)
G.push() G.push()
G.translate(math.floor((w - 160 * s) / 2), math.floor((h - 144 * s) / 2)) G.translate(ox, oy)
G.scale(s, s) G.scale(s, s)
self.stack:draw() self.stack:draw()
G.pop() G.pop()
@@ -1702,7 +1703,7 @@ function Game2:hotkey(key)
self:writeSave() self:writeSave()
return true return true
elseif key == "f2" then elseif key == "f2" then
local loaded = Save.load("gold") local loaded = Save.load()
if loaded then self:continueGame(loaded) end if loaded then self:continueGame(loaded) end
return true return true
elseif key == "1" then elseif key == "1" then
@@ -1982,6 +1983,7 @@ function Game2:applyOptions()
haptics = options.haptics, haptics = options.haptics,
}) })
require("src.core.VideoMode").applyOptions(options) require("src.core.VideoMode").applyOptions(options)
require("src.core.ScreenPosition").applyOptions(options)
require("src.core.FrameCap").applyOptions(options) require("src.core.FrameCap").applyOptions(options)
require("src.world.gen2.BorderFill").applyOptions(options) require("src.world.gen2.BorderFill").applyOptions(options)
local GBCFX = require("src.render.GBCFX") 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, -- 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 -- version -- the accepted ROM hash, the import manifest, where the
-- extracted cache lives, and the save-file suffix -- so the importer, -- extracted cache lives, and the save-file suffix -- so the importer,
-- cache mount, SaveData, title screen and palette all agree. -- 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, -- 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 -- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once
-- by CacheFs.migrateLegacyRedCache. All supported versions can be imported -- 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 -- 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 -- 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", manifest = "tools/rom_manifest_gold.json",
cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated
saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp 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, generation = 2,
}, },
} }
-- Launcher column order. -- Launcher column order.
GameVersion.ORDER = { "red", "blue", "yellow", "gold" } GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver" }
GameVersion.current = "red" GameVersion.current = "red"
+1
View File
@@ -39,6 +39,7 @@ local function normalizeVersion(v)
b = "blue", blue = "blue", b = "blue", blue = "blue",
y = "yellow", yellow = "yellow", y = "yellow", yellow = "yellow",
g = "gold", gold = "gold", g = "gold", gold = "gold",
s = "silver", silver = "silver",
} }
v = alias[v] or v v = alias[v] or v
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end 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) function Music.play(data, song, loop, ctx)
if not song then return end 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 {} ctx = ctx or {}
song = selectSong(song, ctx) song = selectSong(song, ctx)
+19 -5
View File
@@ -285,6 +285,7 @@ function SaveData.defaultOptions()
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see -- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
-- src/core/FaithfulRes.lua. Ignored on mobile. -- src/core/FaithfulRes.lua. Ignored on mobile.
faithfulRes = 0, faithfulRes = 0,
screenPos = "center",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua) -- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60, fpsCap = 60,
-- graphics performance tier: auto | high | balanced | low. "auto" -- graphics performance tier: auto | high | balanced | low. "auto"
@@ -1279,13 +1280,26 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
local isFresh = save == freshPlaythrough local isFresh = save == freshPlaythrough
if isFresh then freshPlaythrough = nil end if isFresh then freshPlaythrough = nil end
local byVersion = opts.playthroughIds and opts.playthroughIds[version] 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 if type(id) ~= "string" or id == "" then
id = SaveData.newPlaythroughId() id = SaveData.newPlaythroughId()
opts.playthroughIds = opts.playthroughIds or {} -- A fresh skeleton still gets its own id (two unsaved New Games sharing a
opts.playthroughIds[version] = opts.playthroughIds[version] or {} -- slot must stay distinct), and it is still persisted when the slot has no
opts.playthroughIds[version][scope] = id -- binding yet -- that is the contract a tool relies on to resolve
SaveData.saveOptions(opts, injectedFs) -- `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 end
save.meta.playthroughId = id save.meta.playthroughId = id
return 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 ssW = dpadW * 0.30
local margin = dpadW * 0.12 local margin = dpadW * 0.12
local ok, GameVersion = pcall(require, "src.core.GameVersion") 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)) margin = math.max(margin, math.min(ww * 0.10, 72))
end end
return { return {
+26 -13
View File
@@ -103,7 +103,7 @@ Save.PLAYER_STATES = {
} }
local function saveNames(version) 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 -- Resolve the ACTIVE SLOT the same way SaveData does, and only fall back to
-- the flat save_<version>.lua when no slot is registered. -- the flat save_<version>.lua when no slot is registered.
-- --
@@ -133,16 +133,22 @@ local function fs()
return love.filesystem return love.filesystem
end 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. -- rival name, and the options the OPTION screen was left on.
function Save.newGame(opts) function Save.newGame(opts)
opts = opts or {} opts = opts or {}
local save = { local save = {
format = Save.FORMAT, format = Save.FORMAT,
version = "gold", version = GameVersion.get(),
generation = 2, generation = 2,
player = { player = {
name = opts.playerName or "GOLD", name = opts.playerName or Save.defaultPlayerName(),
-- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd -- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd
-- (engine/menus/intro_menu.asm:41-49). -- (engine/menus/intro_menu.asm:41-49).
id = opts.trainerId or rand(0, 65535), id = opts.trainerId or rand(0, 65535),
@@ -282,6 +288,7 @@ Save.DEFAULT_OPTIONS = {
musicFilter = 0, -- low-pass steps, 0 = off musicFilter = 0, -- low-pass steps, 0 = off
haptics = "light", haptics = "light",
touchControls = { enabled = true }, touchControls = { enabled = true },
screenPos = "center",
} }
function Save.defaultOptions() function Save.defaultOptions()
@@ -302,7 +309,7 @@ end
Save.OPTIONS_KEY = "gold" Save.OPTIONS_KEY = "gold"
local SHARED_KEYS = { local SHARED_KEYS = {
touchControls = true, haptics = true, touchControls = true, haptics = true, screenPos = true,
mods = true, modsByVersion = true, modsGen2 = true, mods = true, modsByVersion = true, modsGen2 = true,
modOptions = true, modProfiles = true, modProfilesSeeded = true, modOptions = true, modProfiles = true, modProfilesSeeded = true,
activeProfile = true, activeProfile = true,
@@ -374,10 +381,13 @@ end
function Save.normalize(save) function Save.normalize(save)
if type(save) ~= "table" then return nil end if type(save) ~= "table" then return nil end
save.format = save.format or Save.FORMAT 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.generation = 2
save.player = save.player or {} 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.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.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)) 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. -- copy is the witness that survives a crash mid-replace.
function Save.save(save) function Save.save(save)
if type(save) ~= "table" then return false, "no save" end 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") local ok, SaveData = pcall(require, "src.core.SaveData")
if ok and SaveData.activeSlot and not SaveData.activeSlot("gold") then if ok and SaveData.activeSlot and not SaveData.activeSlot(version) then
local id = SaveData.createSlot and SaveData.createSlot("gold") local id = SaveData.createSlot and SaveData.createSlot(version)
if id and SaveData.setActiveSlot then SaveData.setActiveSlot("gold", id) end if id and SaveData.setActiveSlot then
SaveData.setActiveSlot(version, id)
end
end end
end end
local main, backup, tmp = saveNames(save.version) local main, backup, tmp = saveNames(version)
local f = fs() local f = fs()
if not f then return false, "no filesystem" end if not f then return false, "no filesystem" end
-- saveNames may now return a saves/<version>/<slot>.lua path, and -- saveNames may now return a saves/<version>/<slot>.lua path, and
-- love.filesystem.write does not create missing parent directories. -- love.filesystem.write does not create missing parent directories.
local dir = main:match("^(.*)/[^/]+$") local dir = main:match("^(.*)/[^/]+$")
if dir and f.createDirectory then f.createDirectory(dir) end if dir and f.createDirectory then f.createDirectory(dir) end
Save.normalize(save)
save.savedAt = os.time() save.savedAt = os.time()
local encoded = SaveSerializer.encode(save) local encoded = SaveSerializer.encode(save)
if f.getInfo(main) then if f.getInfo(main) then
+4 -2
View File
@@ -249,10 +249,12 @@ function SwitchDiagnostics.probeAssets(version)
end end
-- Shallow listing so we can see if the extract tree exists at all. -- 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", "yellow/assets/generated/sprites", "blue/assets/generated/sprites",
"gold/assets/generated", "gold/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 for _, dir in ipairs(roots) do
local info = filesystem.getInfo(dir) local info = filesystem.getInfo(dir)
if info and info.type == "directory" and filesystem.getDirectoryItems then 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 CacheFs.prefix = prefix
local manifest = require("src.import.RomManifest").decode(version) 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") and require("src.import.RomExtractorGen2")
or require("src.import.RomExtractor") or require("src.import.RomExtractor")
+18 -4
View File
@@ -332,6 +332,17 @@ local function coreRows(opts, hooks)
end) end)
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") local okCap, FrameCap = pcall(require, "src.core.FrameCap")
if okCap then if okCap then
add(Strings("MAX FPS"), add(Strings("MAX FPS"),
@@ -550,9 +561,10 @@ end
-- opened on the Gold tab used to offer a dozen controls that did nothing -- opened on the Gold tab used to offer a dozen controls that did nothing
-- and hide the seven that the cart itself has. -- and hide the seven that the cart itself has.
-- --
-- The block lives in options.lua under `gold`, which is exactly where -- The block lives in options.lua under `gold` (the historical key; Gold and
-- src/core/gen2/Save.lua loadOptions reads it, so an edit here is live on the -- Silver share it the way the Gen 1 games share the flat namespace), which is
-- next boot the same way a Gen 1 edit is. Ladders mirror -- 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. -- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
local GEN2_KEY = "gold" local GEN2_KEY = "gold"
@@ -699,7 +711,9 @@ end
function LauncherSettings.open(hooks, version) function LauncherSettings.open(hooks, version)
local opts = SaveData.loadOptions() local opts = SaveData.loadOptions()
local sections 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] local block = opts[GEN2_KEY]
if type(block) ~= "table" then if type(block) ~= "table" then
block = {} block = {}
+301 -204
View File
@@ -14,9 +14,9 @@
-- height, and flex-shrink compressing text until it overlapped. -- height, and flex-shrink compressing text until it overlapped.
-- --
-- THE RULES THIS FILE FOLLOWS: -- THE RULES THIS FILE FOLLOWS:
-- * Lists paginate (Kit.pager). The installed-mod list also scrolls inside -- * Short lists paginate (Kit.pager, perPage from Kit.rowsThatFit); the
-- its viewport, so each of its pages can hold at least ten entries without -- installed-mod list is one continuous scroll instead, drawing only the
-- requiring a tall window. Pages still bound how many mod rows we visit. -- rows inside the region viewport so the window bounds the frame cost.
-- * Every click handler only QUEUES work (imp._uiActions); update() drains -- * Every click handler only QUEUES work (imp._uiActions); update() drains
-- the queue, so an action that tears the view down (Play, Edit save) -- the queue, so an action that tears the view down (Play, Edit save)
-- never runs inside the frame that dispatched it. -- never runs inside the frame that dispatched it.
@@ -45,9 +45,6 @@ local COMMUNITY_URL = "https://bois.icu"
local ACT_DEDUP = 0.35 local ACT_DEDUP = 0.35
-- Finger travel past this (px) is a drag, not a tap. -- Finger travel past this (px) is a drag, not a tap.
local TAP_SLOP2 = 16 * 16 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 MIN_SKIN_ROWS = 4
local SKIN_FORMAT_LABEL = { local SKIN_FORMAT_LABEL = {
native = "GEN1", native = "GEN1",
@@ -81,15 +78,6 @@ local function setTabScroll(imp, value)
imp._tabScroll[tabKeyOf(imp)] = clamp(value, 0, tabScrollMax(imp)) imp._tabScroll[tabKeyOf(imp)] = clamp(value, 0, tabScrollMax(imp))
end 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 -- ------------------------------------------------------------- lifecycle
local function ensureState(imp) local function ensureState(imp)
@@ -136,12 +124,48 @@ function LauncherView.detach(imp)
Kit.clearCaches() Kit.clearCaches()
end 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 -- ---------------------------------------------------------------- input
-- The kit is polled, not evented: update() samples the mouse and turns a -- The kit is polled, not evented: update() samples the mouse. A press arms
-- rising edge into a click point that the next draw consumes. Host-forwarded -- a drag that scrolls like a finger, and the click dispatches on RELEASE so
-- mousepressed stays unused, exactly as before, so Android's synthesized -- the drag can disqualify it, exactly like the touch path below; only the
-- mouse path cannot double-fire a tap (#553) -- the dedup window below is the -- cartridge (which owns its own spin-drag) keeps the press-down click.
-- other half of that guarantee. -- 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) function LauncherView.update(imp, dt)
if not imp._flex then return end if not imp._flex then return end
if imp._launchFade 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) if not touching and now >= (imp._suppressMouseUntil or 0)
and now >= (imp._suppressClickUntil or 0) then and now >= (imp._suppressClickUntil or 0) then
local mx, my = love.mouse.getPosition() 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
end end
imp._prevMouseDown = down imp._prevMouseDown = down
@@ -192,9 +248,6 @@ function LauncherView.touchpressed(imp, id, x, y)
imp._touchAt = imp._touchAt or {} imp._touchAt = imp._touchAt or {}
imp._touchAt[tostring(id)] = { imp._touchAt[tostring(id)] = {
x = x, y = y, 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), region = tabScrollMax(imp) > 0 and inRect(imp._tabRegionRect, x, y),
} }
end end
@@ -207,19 +260,9 @@ function LauncherView.touchmoved(imp, id, x, y)
if ddx * ddx + ddy * ddy > TAP_SLOP2 then if ddx * ddx + ddy * ddy > TAP_SLOP2 then
start.dragged = true start.dragged = true
end 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 if start.dragged then
local last = start.lastY or start.y local last = start.lastY or start.y
local move = -(y - last) 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 if move ~= 0 and start.region then
local at, leftover = Kit.scrollHandoff(tabScrollAt(imp), local at, leftover = Kit.scrollHandoff(tabScrollAt(imp),
tabScrollMax(imp), move) tabScrollMax(imp), move)
@@ -258,6 +301,24 @@ function LauncherView.clickAt(imp, x, y)
imp._clickPt = { x = x, y = y } imp._clickPt = { x = x, y = y }
end 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 -- 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 -- 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 -- used the arrows this session, so the long-standing "Enter plays the visible
@@ -343,7 +404,7 @@ end
local CART_COLOR = { local CART_COLOR = {
red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold, red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold,
gold = PAL.railAmber, gold = PAL.railAmber, silver = PAL.railSilver,
} }
local function cartColor(version) local function cartColor(version)
return CART_COLOR[version] or PAL.green return CART_COLOR[version] or PAL.green
@@ -418,13 +479,13 @@ end
local function cartPill(project, x, y, w, h, z, color, alpha) local function cartPill(project, x, y, w, h, z, color, alpha)
local points, radius = {}, h / 2 local points, radius = {}, h / 2
for i = 0, 10 do for i = 0, 10 do
local a = math.pi + math.pi * i / 10 local a = -math.pi / 2 + math.pi * i / 10
points[#points + 1] = { project(x + radius + math.cos(a) * radius, points[#points + 1] = { project(x + w - radius + math.cos(a) * radius,
y + radius + math.sin(a) * radius, z) } y + radius + math.sin(a) * radius, z) }
end end
for i = 0, 10 do for i = 0, 10 do
local a = math.pi * i / 10 local a = math.pi / 2 + math.pi * i / 10
points[#points + 1] = { project(x + w - radius + math.cos(a) * radius, points[#points + 1] = { project(x + radius + math.cos(a) * radius,
y + radius + math.sin(a) * radius, z) } y + radius + math.sin(a) * radius, z) }
end end
cartPolygon(points, color, alpha) cartPolygon(points, color, alpha)
@@ -504,6 +565,7 @@ end
local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
local state = cartridgeState(imp, version) local state = cartridgeState(imp, version)
markNoDrag(imp, x, y, w, h)
local focused = Kit.focusable(key, x, y, w, h) local focused = Kit.focusable(key, x, y, w, h)
local hot = Kit.hover(x, y, w, h) local hot = Kit.hover(x, y, w, h)
local active = state.active local active = state.active
@@ -605,7 +667,7 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
end end
local halfW, halfH = w / 2, h / 2 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) local project = function(px, py, pz)
return cartProject(cx + pressX, cy + pressY, yaw, pitch, return cartProject(cx + pressX, cy + pressY, yaw, pitch,
px * pressedScale, py * pressedScale, pz * pressedScale) px * pressedScale, py * pressedScale, pz * pressedScale)
@@ -648,6 +710,7 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
cartPolygon({ mainFront[2], mainFront[3], mainBack[3], mainBack[2] }, side, 1) cartPolygon({ mainFront[2], mainFront[3], mainBack[3], mainBack[2] }, side, 1)
cartPolygon({ mainFront[3], mainFront[4], mainBack[4], mainBack[3] }, 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[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[2], capFront[3], capBack[3], capBack[2] }, side, 1)
cartPolygon({ capFront[1], capFront[2], capBack[2], capBack[1] }, 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({ capFront[4], capFront[1], capBack[1], capBack[4] }, side, 1)
@@ -657,22 +720,72 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
else else
cartPolygon(mainBack, side, 1) cartPolygon(mainBack, side, 1)
cartPolygon(capBack, 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 end
if frontFacing then if frontFacing then
local faceZ = depth + 0.8 local faceZ = depth + 0.8
for i = 0, 4 do -- The shell's grip grooves: a stack beside the label recess on the left,
local ry = mainTop + 7 + i * h * 0.025 -- and one below the top-right corner notch, like the DMG cart.
cartPolygon(cartQuad(project, -halfW + 2, ry, w * 0.13, 2, faceZ), side, 0.7) local grooveW = w * 0.115
cartPolygon(cartQuad(project, halfW - w * 0.13 - 2, ry, w * 0.13, 2, faceZ), side, 0.7) 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 end
local recessX, recessY = -w * 0.32, mainTop + h * 0.023 -- The thin diagonal mold ridge cut into each long side a little below
local recessW, recessH = w * 0.64, h * 0.24 -- the grip grooves, mirrored left/right.
cartPolygon(cartQuad(project, recessX, recessY, recessW, recessH, faceZ), shell, 0.88) local function diagonal(x0, y0, x1, y1)
cartPill(project, recessX + w * 0.025, recessY + h * 0.025, local dx, dy = x1 - x0, y1 - y0
recessW - w * 0.05, h * 0.12, faceZ + 0.5, shell, 0.7) local len = math.sqrt(dx * dx + dy * dy)
cartPill(project, recessX + w * 0.045, recessY + h * 0.043, local nx, ny = -dy / len, dx / len
recessW - w * 0.09, h * 0.083, faceZ + 0.8, side, 0.42) 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 labelX, labelY = -w * 0.33, -h * 0.20
local labelW, labelH = w * 0.66, h * 0.55 local labelW, labelH = w * 0.66, h * 0.55
@@ -942,6 +1055,8 @@ local GAME_TABS = {
label = "Yellow" }, label = "Yellow" },
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber, { id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber,
label = "Gold" }, label = "Gold" },
{ id = "silver", key = "tab-silver", letter = "S", color = PAL.railSilver,
label = "Silver" },
} }
local HEADER_TABS = { local HEADER_TABS = {
@@ -1114,16 +1229,11 @@ local function buildHeader(imp, m)
local tabGap = math.floor(6 * m.s) local tabGap = math.floor(6 * m.s)
local tabRowGap = math.floor(4 * m.s) local tabRowGap = math.floor(4 * m.s)
-- the cartridge dropdown, sized to its longest label so switching games -- the cartridge dropdown: just the game's initial and the caret; the
-- never reflows the row -- popup list carries the full names
local chrome0 = headerChrome(imp) local chrome0 = headerChrome(imp)
local game = currentGame(imp) local game = currentGame(imp)
local labelW = 0 local dropW = math.min(tabRight - tabLeft, tabH + math.floor(24 * m.s))
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))
chrome0.game.color = game.color chrome0.game.color = game.color
chrome0.game.letter = game.letter chrome0.game.letter = game.letter
chrome0.game.active = imp.tab == game.id chrome0.game.active = imp.tab == game.id
@@ -1133,7 +1243,7 @@ local function buildHeader(imp, m)
-- flip with it or it vanishes into the cartridge colour -- flip with it or it vanishes into the cartridge colour
local gameInvert = chrome0.game.active or gameHot local gameInvert = chrome0.game.active or gameHot
chrome0.game.ring = gameHot and not chrome0.game.active or nil 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 do
local cw = math.floor(7 * m.s) local cw = math.floor(7 * m.s)
local ccx = tx + dropW - math.floor(14 * m.s) local ccx = tx + dropW - math.floor(14 * m.s)
@@ -1147,7 +1257,7 @@ local function buildHeader(imp, m)
end end
tx = tx + dropW + tabGap tx = tx + dropW + tabGap
for _, t in ipairs(tabs) do local function headerTab(t)
local w = tabH local w = tabH
if tx > tabLeft and tx + w > tabRight then if tx > tabLeft and tx + w > tabRight then
tx = tabLeft tx = tabLeft
@@ -1160,6 +1270,11 @@ local function buildHeader(imp, m)
btn(imp, tx, ty, w, tabH, t.key, "", o) btn(imp, tx, ty, w, tabH, t.key, "", o)
tx = tx + w + tabGap tx = tx + w + tabGap
end 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 do
local w = tabH local w = tabH
@@ -1181,6 +1296,7 @@ local function buildHeader(imp, m)
end end
tx = tx + w + tabGap tx = tx + w + tabGap
end end
if bugTab then headerTab(bugTab) end
-- `ty` has walked down with the wraps, so this stays correct at one row too. -- `ty` has walked down with the wraps, so this stays correct at one row too.
y = ty + tabH + math.floor(8 * m.s) y = ty + tabH + math.floor(8 * m.s)
@@ -1964,7 +2080,6 @@ local function buildModsPanel(imp, x, y, w, availH, m)
cy = cy + buildModScopeRow(imp, x, cy, w, m) cy = cy + buildModScopeRow(imp, x, cy, w, m)
if #mods == 0 then 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()) Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint())
return (cy - y) + math.floor(110 * m.s) return (cy - y) + math.floor(110 * m.s)
end end
@@ -2005,159 +2120,139 @@ local function buildModsPanel(imp, x, y, w, availH, m)
end end
-- A mod row is a fixed height: its details first, then a dedicated second -- 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 -- line of per-game checkboxes. Fixed row heights are what make the
-- what lets perPage come from the viewport. -- cull below plain arithmetic.
local togH = math.floor(26 * m.s) local togH = math.floor(26 * m.s)
local gamesLabel = Strings("Enable for:") local gamesLabel = Strings("Enable for:")
local textH = Kit.textHeight("button") + math.floor(4 * m.s) local textH = Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(2 * m.s) + Kit.textHeight("small") + 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 local rowH = math.floor(8 * m.s) + textH + math.floor(8 * m.s) + togH
+ math.floor(8 * m.s) + 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 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) -- One continuous list: every row is laid out, the region scroll moves
for i = first, last do -- 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 mod = mods[i]
local ry = listTop + (i - first) * (rowH + gap) - scroll local ry = listTop + (i - 1) * (rowH + gap)
local rowKey = rowKeyFor(imp, "mod-row-", mod.id) if ry + rowH >= viewTop and ry <= viewBot then
local isFullyDisabled = true local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
if mod.enabledByVersion then local isFullyDisabled = true
for _, on in pairs(mod.enabledByVersion) do if mod.enabledByVersion then
if on then isFullyDisabled = false; break end for _, on in pairs(mod.enabledByVersion) do
if on then isFullyDisabled = false; break end
end
else
isFullyDisabled = not mod.enabled
end end
else
isFullyDisabled = not mod.enabled
end
local focused = Kit.focusable(rowKey, x, ry, w, rowH) local focused = Kit.focusable(rowKey, x, ry, w, rowH)
local hot = focused or Kit.hover(x, ry, w, rowH) local hot = focused or Kit.hover(x, ry, w, rowH)
if isFullyDisabled then if isFullyDisabled then
Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted") Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted")
else else
Kit.card(x, ry, w, rowH, hot) 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
end end
tx = tx + togH + togGap local pad = math.floor(12 * m.s)
end local px, inner = x + pad, w - 2 * pad
-- The checkboxes sit inside the row's rect, so their press also passes the local ly = ry + math.floor(10 * m.s)
-- 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) local togGap = math.floor(5 * m.s) + 1
-- the games the mod is for, beside its category: the same chip the local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
-- 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 -- These answer separate games, not a single shared install flag. The
local statusText, statusCol = modStatusColor(mod.status) -- importer receives the game id so an experimental confirmation also
local line = "v" .. tostring(mod.version or "?") .. " " .. statusText -- applies only to the checkbox the player pressed.
Kit.text("small", line, px, ly, statusCol) local flipped = false
local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s) local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s)
if imp:_modInfoPending(mod.id) then Kit.text("micro", gamesLabel, px,
-- An inline spinner, because this row's release check is genuinely in gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted)
-- flight -- the list stays usable while it resolves. local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s)
Loader.dot(lx, ly, Kit.textHeight("small")) for _, game in ipairs(GameVersion.ORDER) do
Kit.text("small", Strings("Checking..."), local togKey = "mod-toggle-" .. mod.id .. "-" .. game
lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted) if modGameCheckbox(tx, gamesY, togH,
elseif info and info.status == "available" then mod.enabledByVersion and mod.enabledByVersion[game] == true,
Kit.text("small", Strings("v%s available", tostring(info.latest)), game, togKey, not safeMode) then
lx, ly, PAL.yellow) local version = game
elseif info and info.status == "current" then queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end)
Kit.text("small", Strings("up to date"), lx, ly, PAL.muted) flipped = true
elseif info and info.status == "error" then end
Kit.text("small", Strings("check failed"), lx, ly, PAL.red) tx = tx + togH + togGap
end end
ly = ly + Kit.textHeight("small") + math.floor(2 * m.s) -- 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.
-- one line of description, or the download stats when we have them if not flipped
-- (download count in green so popularity reads at a glance) and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then
if info and info.downloads then local id = mod.id
local d = info.dates queueAction(imp, rowKey, function() imp._modActions = id end)
local dl = ModUpdate.downloadsLine(info.downloads.total) end
local dates = ModUpdate.datesLine(d and d.first, d and d.latest) local textW = inner
local segs = {}
if dl then segs[#segs + 1] = { dl, PAL.green } end local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s)
if dates then -- the games the mod is for, beside its category: the same chip the
segs[#segs + 1] = { (dl and " - " or "") .. dates, PAL.detail } -- 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 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
end end
Kit.popClip()
local pagerY = listTop + listH + gap local contentH = #mods * rowH + (#mods - 1) * gap
local newPage, newPagerH = Kit.pager(x, pagerY, w, cur, #mods, perPage, "mods") return (listTop + contentH + gap) - y
if newPage ~= cur then imp.modScroll = 0 end
setPage(imp, "mods", newPage)
return pagerY + newPagerH - y
end end
-- ---------------------------------------------------------- find mods panel -- ---------------------------------------------------------- find mods panel
@@ -2889,6 +2984,8 @@ local function buildConfirmModal(imp, m)
imp:_setAllMods(true, true) imp:_setAllMods(true, true)
elseif c.kind == "importOversize" then elseif c.kind == "importOversize" then
imp:_importSave(c.version, c.source, true) imp:_importSave(c.version, c.source, true)
elseif c.kind == "largeImport" then
imp:_importRequiredSource(c.modId, c.importId, c.source, true)
else else
imp:_toggleMod(c.id, true, c.version) imp:_toggleMod(c.id, true, c.version)
end end
@@ -4457,7 +4554,7 @@ local function buildSyncHome(imp, m, eng)
local linked = eng:linked() local linked = eng:linked()
local codes = eng.codes local codes = eng.codes
local body = linked 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) or Strings(SYNC_HINT)
local innerW = w - 2 * pad local innerW = w - 2 * pad
local hintH = Kit.wrapHeight("small", body, innerW, 5) local hintH = Kit.wrapHeight("small", body, innerW, 5)
@@ -4842,17 +4939,18 @@ function LauncherView.draw(imp)
Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0) Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0)
imp._clickPt = nil imp._clickPt = nil
imp._wheelY = 0 imp._wheelY = 0
imp._noDragN = 0
Theme.field() Theme.field()
-- Everything from here to buildModals sits UNDER any open modal, so the -- Everything from here to buildModals sits UNDER any open modal, so the
-- whole stage draws shielded (no clicks, no hover, no focus ring) while -- 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. -- 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 step = Kit.scrollStep(m.s)
local nested = modListWantsWheel(imp, Kit.wheelY or 0) do
if not nested then
local rect = imp._tabRegionRect local rect = imp._tabRegionRect
if rect then if rect then
setTabScroll(imp, (Kit.scrollWheel(tabScrollAt(imp), tabScrollMax(imp), setTabScroll(imp, (Kit.scrollWheel(tabScrollAt(imp), tabScrollMax(imp),
@@ -4860,8 +4958,7 @@ function LauncherView.draw(imp)
end end
end end
local scroll = math.max(0, math.min(imp._pageScroll or 0, scrollMax)) local scroll = math.max(0, math.min(imp._pageScroll or 0, scrollMax))
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not nested if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks then
and not Kit.blockClicks then
local moved = math.max(0, math.min(scroll - Kit.wheelY * step, scrollMax)) local moved = math.max(0, math.min(scroll - Kit.wheelY * step, scrollMax))
if moved ~= scroll then if moved ~= scroll then
scroll = moved scroll = moved
@@ -4869,7 +4966,7 @@ function LauncherView.draw(imp)
end end
end end
imp._pageScroll, imp._pageScrollMax = scroll, scrollMax 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 and tabScrollMax(imp) > 0 then
local was = tabScrollAt(imp) local was = tabScrollAt(imp)
local to = Kit.scrollClamp(was - Kit.wheelY * step, tabScrollMax(imp)) local to = Kit.scrollClamp(was - Kit.wheelY * step, tabScrollMax(imp))
+198 -27
View File
@@ -4,6 +4,7 @@
-- (species order IS dex order, pics/tilesets are lz3-compressed rather than -- (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. -- pkmncompress'd, maps are grouped instead of flat). See docs/gold-phase1.md.
local bit = require("bit") local bit = require("bit")
local GameVersion = require("src.core.GameVersion")
local ImageWriter = require("src.import.ImageWriter") local ImageWriter = require("src.import.ImageWriter")
local LuaWriter = require("src.import.LuaWriter") local LuaWriter = require("src.import.LuaWriter")
local Rom = require("src.import.Rom") local Rom = require("src.import.Rom")
@@ -168,6 +169,10 @@ function RomExtractorGen2.new(romData, manifest, progress)
symbols = manifest.symbols, symbols = manifest.symbols,
progress = progress, progress = progress,
stage = 0, 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) }, RomExtractorGen2)
end end
@@ -1641,22 +1646,40 @@ function RomExtractorGen2:extractTitle()
return 3 return 3
end end
-- pret gfx/title/title_bg_gold.pal / title_fg.pal (5 BG pals, 2 OBJ pals). local silver = self.edition == "silver"
local BG_PALS = {
-- 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, 31, 31 }, { 18, 23, 31 }, { 15, 20, 31 }, { 0, 0, 0 } },
{ { 31, 21, 0 }, { 12, 14, 12 }, { 15, 20, 31 }, { 0, 0, 17 } }, { { 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 }, { 31, 0, 0 }, { 15, 20, 31 }, { 0, 0, 0 } },
{ { 31, 31, 31 }, { 29, 25, 0 }, { 15, 20, 31 }, { 17, 10, 1 } }, { { 31, 31, 31 }, { 29, 25, 0 }, { 15, 20, 31 }, { 17, 10, 1 } },
{ { 31, 31, 31 }, { 23, 26, 31 }, { 18, 23, 31 }, { 0, 0, 0 } }, { { 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); -- title_fg.pal, shared (GSTitleOBPals, engine/gfx/color.asm:1241): pal 0 =
-- pal 1 = gold trail sparks (OAM_PAL1 on GSTitleTrail). -- Ho-Oh silhouette; pal 1 = gold trail sparks (OAM_PAL1 on GSTitleTrail).
local OBJ_HOOH = { local OBJ_HOOH = {
{ 31, 31, 31 }, { 7, 6, 3 }, { 7, 6, 3 }, { 7, 6, 3 }, { 31, 31, 31 }, { 7, 6, 3 }, { 7, 6, 3 }, { 7, 6, 3 },
} }
local OBJ_TRAIL = { local OBJ_TRAIL = {
{ 31, 31, 31 }, { 31, 31, 0 }, { 26, 22, 0 }, { 0, 0, 0 }, { 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 function palColor(pal, shade)
local c = pal[shade + 1] or pal[4] 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 -- solid BLACK silhouette on a monochrome screen rather than the shaded pose
-- a straight decode gives. rOBP1 (%11111000) carries the gold trail. -- a straight decode gives. rOBP1 (%11111000) carries the gold trail.
local DMG_BGP = { 0, 2, 1, 3 } local DMG_BGP = { 0, 2, 1, 3 }
local DMG_OBP0 = { 3, 3, 3, 3 } -- engine/movie/title.asm:105-115: Silver writes %11110000 to both OBPs.
local DMG_OBP1 = { 0, 2, 3, 3 } 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. -- ImageWriter's four hardware shades, by shade number.
local DMG_SHADE = { 1, 2 / 3, 1 / 3, 0 } 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). -- Ho-Oh frames from OAMData_GSIntroHoOh1..5 (data/sprite_anims/oam.asm).
local hoohTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleScreenGFX4"), true) 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 = { local HOOH_FRAMES = {
{ -- 1 { -- 1
{ -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x04 }, { -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 }, { 3, -2, 0, 0, 0x22 }, { 3, 0, 0, 0, 0x24 },
}, },
} }
-- Frameset_GSIntroHoOhLugia (Gold): 1,2,3,4,3,5 with these durations. -- Silver's five oamsets, as {layout, vtile base} (oam.asm:103-107).
local HOOH_SEQUENCE = { 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 }, { 1, 10 }, { 2, 9 }, { 3, 10 }, { 4, 10 }, { 3, 9 }, { 5, 10 },
} }
local hoohPaths, hoohGrayPaths = {}, {} local hoohPaths, hoohGrayPaths = {}, {}
local originX, originY = 32, 24 -- Lugia1/2 span x tiles -5..4, four tiles wider than Ho-Oh's -4..3.
for fi, oam in ipairs(HOOH_FRAMES) do 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 -- 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 -- 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. -- 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 for _, spr in ipairs(oam) do
local px = originX + spr[1] * 8 + spr[3] local px = originX + spr[1] * 8 + spr[3]
local py = originY + spr[2] * 8 + spr[4] local py = originY + spr[2] * 8 + spr[4]
blitSprite(pose, hoohTiles[spr[5] + 1], px, py) blitSprite(pose, hoohTiles[base + spr[5] + 1], px, py)
blitSprite(pose, hoohTiles[spr[5] + 2], px, py + 8) blitSprite(pose, hoohTiles[base + spr[5] + 2], px, py + 8)
end end
local tinted = colorize(pose, function() return OBJ_HOOH end) local tinted = colorize(pose, function() return OBJ_HOOH end)
local rel = ("title/hooh_%d.png"):format(fi) local rel = ("title/hooh_%d.png"):format(fi)
@@ -1855,14 +1915,20 @@ function RomExtractorGen2:extractTitle()
end end
self:tick("Title screen", 3, 5) self:tick("Title screen", 3, 5)
-- Trail: TitleScreenGFX3 is raw 2bpp (8 tiles); Gold OAM uses one 8x16 -- Trail: TitleScreenGFX3 is raw 2bpp; Gold's OAM is one 8x16, Silver's two
-- on OAM_PAL1 (gold), not the Ho-Oh silhouette pal. -- side by side, and only 4 of Silver's 8 copied tiles exist (title.asm:43).
local trailSym = self:symbol("TitleScreenGFX3") 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 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[1], 0, 0)
blitSprite(trail, trailTiles[2], 0, 8) 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) local trailTint = colorize(trail, function() return OBJ_TRAIL end)
self:save(trailTint, "title/trail.png") self:save(trailTint, "title/trail.png")
self:save(throughRegister(trail, DMG_OBP1), "title/trail_gray.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", cloudsGray = "assets/generated/title/clouds_gray.png",
hoohFramesGray = hoohGrayPaths, hoohFramesGray = hoohGrayPaths,
trailGray = "assets/generated/title/trail_gray.png", 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, 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 -- `depixel 12, 11` (engine/movie/title.asm). Two traps, and the port had
-- fallen into both, which is what put Ho-Oh off-centre: -- 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 -- * 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. -- 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 -- * those are OAM coordinates, which are biased; a drawn object sits at
-- (x - 8, y - 16) on screen. -- (x - 8, y - 16) on screen.
-- The pose canvas holds its own origin at (32, 24), so the sheet's corner -- The pose canvas holds its own origin, so the sheet's corner is
-- is (88 - 8 - 32, 96 - 16 - 24) -- and the bird's 64px width then lands -- (88 - 8 - originX, 96 - 16 - originY) -- and the pose's width then lands
-- centred on the screen, 48 to 112. -- centred on the screen (Ho-Oh 48..112, Lugia 40..120).
hoohX = 48, hoohX = 80 - originX,
hoohY = 56, hoohY = 80 - originY,
trail = "assets/generated/title/trail.png", trail = "assets/generated/title/trail.png",
copyright = "assets/generated/title/copyright.png", copyright = "assets/generated/title/copyright.png",
copyrightSplash = "assets/generated/title/copyright_splash.png", copyrightSplash = "assets/generated/title/copyright_splash.png",
-- ScrollTitleScreenClouds: Gold decrements the cloud-band SCX every -- ScrollTitleScreenClouds (engine/menus/intro_menu.asm:917-928): Gold
-- 8 vblanks, so the strip slides 1px right. Silver does the same -- decrements the cloud-band SCX every 8 vblanks, so the strip slides 1px
-- decrement every frame. -- right. Silver does the same decrement every frame.
cloudScrollEvery = 8, cloudScrollEvery = silver and 1 or 8,
cloudY = 88, 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) self:write("title", data)
return data return data
@@ -5136,6 +5231,82 @@ function RomExtractorGen2:extractMenuGfx()
end end
if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch 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:write("menu_gfx", out)
self:tick("Menu graphics", 1, 1) self:tick("Menu graphics", 1, 1)
return out return out
+45 -15
View File
@@ -142,6 +142,8 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
"assets/generated/audio/programs.bin", "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): -- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup):
-- a dark neon arcade panel, one column per game. -- a dark neon arcade panel, one column per game.
@@ -335,7 +337,7 @@ function RomImporter.syncAndroidShortcuts(activeVersion)
return false return false
end end
local allVersions = { "red", "blue", "yellow", "gold" } local allVersions = GameVersion.ORDER
local ready = {} local ready = {}
local seen = {} local seen = {}
@@ -1361,11 +1363,10 @@ function RomImporter.new(onComplete, opts)
saveNotice = {}, saveNotice = {},
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array -- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
-- (refreshed lazily on first draw and after any toggle/install/delete); -- (refreshed lazily on first draw and after any toggle/install/delete);
-- modScroll is the current paged list's inner scroll offset (px, clamped -- modNotice is the last install/delete result { ok, text }.
-- in draw); modNotice is the last install/delete result { ok, text }.
-- requiredImportNotice stays inside the imported-files modal so validation -- requiredImportNotice stays inside the imported-files modal so validation
-- failures are visible beside the file picker that caused them. -- failures are visible beside the file picker that caused them.
mods = nil, modScroll = 0, modNotice = nil, issueNotice = nil, mods = nil, modNotice = nil, issueNotice = nil,
requiredImportNotice = nil, requiredImportNotice = nil,
-- Which game the MODS panel is answering for (a GameVersion id, nil = -- Which game the MODS panel is answering for (a GameVersion id, nil =
-- every game). Rows resolve their enable-state and their "runs here" -- every game). Rows resolve their enable-state and their "runs here"
@@ -1422,7 +1423,8 @@ function RomImporter.new(onComplete, opts)
self.returning[version] = self.returning[version] =
(not ready) and marker ~= nil and marker ~= markerFor(version) (not ready) and marker ~= nil and marker ~= markerFor(version)
self.romName[version] = "pokemon_" .. info.id 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 end
RomImporter.syncAndroidShortcuts() RomImporter.syncAndroidShortcuts()
self:_applyLastVersionTab() self:_applyLastVersionTab()
@@ -1535,6 +1537,9 @@ function RomImporter:focus(f)
self._modPress = nil self._modPress = nil
return return
end 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 if not (f and self.android and self.workState ~= "working") then return end
-- SAF create-document finished: GameActivity wrote export_done.flag. -- SAF create-document finished: GameActivity wrote export_done.flag.
if love.filesystem.getInfo("export_done.flag", "file") then if love.filesystem.getInfo("export_done.flag", "file") then
@@ -1687,7 +1692,7 @@ function RomImporter:startData(data, displayName)
end end
if not isAcceptedRomSize(#data) then if not isAcceptedRomSize(#data) then
self:setError(("Expected a 1 MiB Game Boy ROM (Red/Blue/Yellow) or a " 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)) :format(#data / 1024 / 1024))
return return
end end
@@ -1695,7 +1700,8 @@ function RomImporter:startData(data, displayName)
local version = GameVersion.forSha1(actualHash) local version = GameVersion.forSha1(actualHash)
if not version then if not version then
self:setError(("Unsupported ROM (SHA-1 %s). This needs a clean US Pokemon " 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)) .. "(tagged [b] or [BF]) never verify."):format(actualHash))
return return
end end
@@ -1772,7 +1778,7 @@ function RomImporter:_startExtractCoroutine(version, info, prefix, displayName)
local CacheFs = require("src.import.CacheFs") local CacheFs = require("src.import.CacheFs")
CacheFs.prefix = prefix CacheFs.prefix = prefix
local manifest = require("src.import.RomManifest").decode(version) local manifest = require("src.import.RomManifest").decode(version)
local RomExtractor = version == "gold" local RomExtractor = GameVersion.generation(version) == 2
and require("src.import.RomExtractorGen2") and require("src.import.RomExtractorGen2")
or require("src.import.RomExtractor") or require("src.import.RomExtractor")
local extractor = RomExtractor.new(self.romData, manifest, local extractor = RomExtractor.new(self.romData, manifest,
@@ -2057,7 +2063,7 @@ function RomImporter:_importRequiredData(modId, importId, data)
return nil return nil
end end
function RomImporter:_importRequiredSource(modId, importId, source) function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
local manifest = requiredManifest(self, modId) local manifest = requiredManifest(self, modId)
local spec = manifest and requiredSpec(manifest, importId) local spec = manifest and requiredSpec(manifest, importId)
if not spec then if not spec then
@@ -2065,14 +2071,31 @@ function RomImporter:_importRequiredSource(modId, importId, source)
self.modNotice = nil self.modNotice = nil
return nil return nil
end end
local RequiredImports = require("src.mods.RequiredImports")
local info = love.filesystem.getInfo(source, "file") local info = love.filesystem.getInfo(source, "file")
local size = info and info.size or externalFileSize(source) 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 if sizeErr then
requiredImportNotice(self, modId, importId, sizeErr) requiredImportNotice(self, modId, importId, sizeErr)
self.modNotice = nil self.modNotice = nil
return nil return nil
end 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) local data = love.filesystem.read(source)
if not data then data = readExternalPath(source) end if not data then data = readExternalPath(source) end
if not data then if not data then
@@ -2115,8 +2138,13 @@ function RomImporter:chooseRequiredImport(modId, importId)
if name:sub(1, 1) ~= "." then if name:sub(1, 1) ~= "." then
local path = inbox .. "/" .. name local path = inbox .. "/" .. name
local info = love.filesystem.getInfo(path, "file") local info = love.filesystem.getInfo(path, "file")
local sizeErr = info and require("src.mods.RequiredImports") local RequiredImports = require("src.mods.RequiredImports")
.sizeError(spec, info.size, false) 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 local data = not sizeErr and love.filesystem.read(path) or nil
if data and self:_importRequiredData(modId, importId, data) then return end if data and self:_importRequiredData(modId, importId, data) then return end
if sizeErr then lastError = sizeErr if sizeErr then lastError = sizeErr
@@ -2757,7 +2785,8 @@ function RomImporter:resumeAfterOverlay()
end end
function RomImporter:_cycleTab(delta) function RomImporter:_cycleTab(delta)
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins", "bug" } local order = { "red", "blue", "yellow", "gold", "silver",
"mods", "find", "skins", "bug" }
local idx = 1 local idx = 1
for i, id in ipairs(order) do for i, id in ipairs(order) do
if id == self.tab then idx = i; break end if id == self.tab then idx = i; break end
@@ -3094,8 +3123,10 @@ end
-- multi-monitor coords). Same contract as PadCursor.yieldToPointer for -- multi-monitor coords). Same contract as PadCursor.yieldToPointer for
-- the overlay hosts. Touch move/press/release must still reach -- the overlay hosts. Touch move/press/release must still reach
-- FlexLove.touch* or scroll containers never drag on phones. -- FlexLove.touch* or scroll containers never drag on phones.
function RomImporter:mousepressed() function RomImporter:mousepressed(x, y, button)
self._padCursorActive = false self._padCursorActive = false
if button ~= 1 or not self._flex then return end
require("src.import.LauncherView").mousepressed(self, x, y)
end end
function RomImporter:touchpressed(id, x, y, dx, dy, pressure) function RomImporter:touchpressed(id, x, y, dx, dy, pressure)
@@ -3123,7 +3154,6 @@ function RomImporter:_switchTab(id)
self.tab = id self.tab = id
self._findSearchFocus = false self._findSearchFocus = false
self._skinUrlFocus = false self._skinUrlFocus = false
self._modScrollMax, self._modListRect = 0, nil
self:_disarmTextInput() self:_disarmTextInput()
-- the skins list is cheap and can change behind the launcher's back -- 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 -- (an export, a hand-dropped folder), so re-read it on every visit
+2 -1
View File
@@ -262,7 +262,8 @@ function LinkBattle.new(game, net, opts)
self.opponentName = theirName self.opponentName = theirName
-- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2 -- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2
-- takes PrintBeginningBattleText's .trainerBattle arm, link included -- 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.remoteHashes = {}
self.localHashes = {} self.localHashes = {}
self.remoteParts = {} self.remoteParts = {}
+2 -2
View File
@@ -195,8 +195,8 @@ local function parseImports(value, field, required)
if value == nil then return end if value == nil then return end
assert(type(value) == "number" and value > 0 and value % 1 == 0, assert(type(value) == "number" and value > 0 and value % 1 == 0,
field .. " " .. label .. " must be a positive integer") field .. " " .. label .. " must be a positive integer")
assert(value <= 128 * 1024 * 1024, assert(value <= 2 * 1024 * 1024 * 1024,
field .. " " .. label .. " exceeds the 128 MiB hard limit") field .. " " .. label .. " exceeds the 2 GiB hard limit")
end end
validateSize(size, "size") validateSize(size, "size")
validateSize(maxSize, "max_size") validateSize(maxSize, "max_size")
+7 -1
View File
@@ -23,11 +23,17 @@ local function isRequired(spec)
end end
RequiredImports.specs = allSpecs 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) 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)) return ("%.1f MiB"):format(bytes / (1024 * 1024))
end end
RequiredImports.sizeLabel = sizeLabel
-- Check size before a caller reads an external or stored file into one large -- 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 -- 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") local GameViewport = require("src.render.GameViewport")
-- leaf module (no renderer dependency), so requiring it here cannot cycle -- leaf module (no renderer dependency), so requiring it here cannot cycle
local FaithfulRes = require("src.core.FaithfulRes") local FaithfulRes = require("src.core.FaithfulRes")
local ScreenPosition = require("src.core.ScreenPosition")
local Playfield = require("src.render.Playfield") local Playfield = require("src.render.Playfield")
local Renderer = {} local Renderer = {}
@@ -94,6 +95,11 @@ local function displayMetrics()
return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow
end 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() function Renderer:init()
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua -- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
-- (#208). Every canvas below is sized in framebuffer pixels for the same -- (#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 -- corners; flat mode returns exactly today's size (growth factor is 1 when
-- tilt is inactive). -- tilt is inactive).
function Renderer:worldViewSize() 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 -- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
-- WHOLE display, so letterbox voids become more map instead of black bars. -- 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 -- 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. -- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
if vw % 2 ~= 0 then vw = vw + 1 end if vw % 2 ~= 0 then vw = vw + 1 end
if vh % 2 ~= 0 then vh = vh + 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 if Tilt.active() then
local g = Tilt.viewGrowth() local g = Tilt.viewGrowth()
vw, vh = math.ceil(vw * g), math.ceil(vh * g) vw, vh = math.ceil(vw * g), math.ceil(vh * g)
@@ -774,8 +783,9 @@ function Renderer:frameRects()
r.uiw, r.uih = uiw, uih r.uiw, r.uih = uiw, uih
r.vpw, r.vph = uiw * r.Sx, uih * r.Sy r.vpw, r.vph = uiw * r.Sx, uih * r.Sy
-- Snap the letterbox origin to a framebuffer pixel, then convert to units. -- 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.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 -- 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 -- 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. -- 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.Up, r.Ux, r.Uy = Up, Up / dpiX, Up / dpiY
r.uvpw, r.uvph = uiw * r.Ux, uih * r.Uy r.uvpw, r.uvph = uiw * r.Ux, uih * r.Uy
r.uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX 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 return r
end end
@@ -1002,7 +1012,7 @@ function Renderer:endFrame(zones, worldZones)
local wvw = self.worldCanvas:getWidth() local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight() local wvh = self.worldCanvas:getHeight()
local wox = (vx + math.floor((pw - wvw * sp) / 2)) / dpiX 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 -- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone -- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or -- scissoring here). drawTiltedWorld returns false when tilt is off or
+4 -1
View File
@@ -228,7 +228,10 @@ SaveConvert.mergeDefaults = mergeDefaults
-- codec exists yet. Both directions answer with a plain message the -- 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 -- launcher's save card renders as-is, instead of pushing a Gen 2 save
-- table through Gen 1 offsets and surfacing a codec traceback. -- 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 -- importSav(bytes, version, gameVersion) -> saveTable, err
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data -- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
+25 -2
View File
@@ -7,6 +7,7 @@ SyncEngine.__index = SyncEngine
SyncEngine.UPLOAD_DEBOUNCE = 5 SyncEngine.UPLOAD_DEBOUNCE = 5
SyncEngine.AUTO_INTERVAL = 300 SyncEngine.AUTO_INTERVAL = 300
SyncEngine.RESUME_MIN_GAP = 60
SyncEngine.MAX_STEPS_PER_UPDATE = 8 SyncEngine.MAX_STEPS_PER_UPDATE = 8
local IDLE_STATUS = "Ready" local IDLE_STATUS = "Ready"
@@ -133,6 +134,7 @@ function SyncEngine.new(opts)
eng.modPlan = nil eng.modPlan = nil
eng.shareCode = nil eng.shareCode = nil
eng.clock = 0 eng.clock = 0
eng.autoAt = SyncEngine.AUTO_INTERVAL
eng.queue = {} eng.queue = {}
eng.pending = nil eng.pending = nil
eng.uploadAt = nil eng.uploadAt = nil
@@ -258,6 +260,11 @@ function SyncEngine:update(dt)
self.uploadAt = nil self.uploadAt = nil
if self.state.enabled and self:linked() then self:syncNow() end if self.state.enabled and self:linked() then self:syncNow() end
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 local steps = 0
while not self.pending and #self.queue > 0 while not self.pending and #self.queue > 0
and steps < SyncEngine.MAX_STEPS_PER_UPDATE do and steps < SyncEngine.MAX_STEPS_PER_UPDATE do
@@ -296,6 +303,7 @@ function SyncEngine:createAccount(label)
eng.phase = "idle" eng.phase = "idle"
eng.status = "Sync account created" eng.status = "Sync account created"
eng:_persist() eng:_persist()
eng:syncNow()
end) end)
end end
@@ -385,9 +393,24 @@ function SyncEngine:setEnabled(enabled)
return self.state.enabled return self.state.enabled
end 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() function SyncEngine:syncNow()
if not self:linked() then return false, "this device is not linked" end if not self:linked() then return false, "this device is not linked" end
if self.pending then return false, "sync is busy" end if self.pending then return false, "sync is busy" end
self.autoAt = self.clock + SyncEngine.AUTO_INTERVAL
self.queue = {} self.queue = {}
self.conflicts = {} self.conflicts = {}
self.state.pendingConflicts = {} self.state.pendingConflicts = {}
@@ -437,13 +460,13 @@ function SyncEngine:_planFrom(remoteState)
self:_addConflict(entry, key, row) self:_addConflict(entry, key, row)
elseif localChanged then elseif localChanged then
self:_queueUpload(entry, key, false) self:_queueUpload(entry, key, false)
elseif remoteChanged then elseif remoteChanged and key ~= self.protectedKey then
self:_queueDownload(key, entry.version, entry.playthroughId, "replace") self:_queueDownload(key, entry.version, entry.playthroughId, "replace")
end end
end end
end end
for key, row in pairs(remote) do 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) local version, id = SyncState.splitKey(key)
if version and id then if version and id then
self:_queueDownload(key, version, id, "replace", tonumber(row.rev)) self:_queueDownload(key, version, id, "replace", tonumber(row.rev))
+1 -1
View File
@@ -5,7 +5,7 @@ SyncMods.REV = 1
local function versions() local function versions()
local ok, GameVersion = pcall(require, "src.core.GameVersion") local ok, GameVersion = pcall(require, "src.core.GameVersion")
if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end
return { "red", "blue", "yellow", "gold" } return { "red", "blue", "yellow", "gold", "silver" }
end end
local function defaultDeps() local function defaultDeps()
+4 -2
View File
@@ -185,14 +185,16 @@ local function release(game)
return return
end end
game.stack:push(TextBox.new(game, 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, defaultNo = true, noSound = true,
choice = function(yes) choice = function(yes)
if not yes then return end if not yes then return end
table.remove(box, list.index) table.remove(box, list.index)
require("src.core.Sound").playCry(game.data, mon.species) require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game, 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() list:removeCurrent()
end, end,
})) }))
+11
View File
@@ -21,6 +21,7 @@ local GameVersion = require("src.core.GameVersion")
local VideoMode = require("src.core.VideoMode") local VideoMode = require("src.core.VideoMode")
local Orientation = require("src.core.Orientation") local Orientation = require("src.core.Orientation")
local FaithfulRes = require("src.core.FaithfulRes") local FaithfulRes = require("src.core.FaithfulRes")
local ScreenPosition = require("src.core.ScreenPosition")
local FrameCap = require("src.core.FrameCap") local FrameCap = require("src.core.FrameCap")
local Performance = require("src.core.Performance") local Performance = require("src.core.Performance")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
@@ -443,6 +444,16 @@ local function buildRows(game)
FaithfulRes.apply(o.faithfulRes) FaithfulRes.apply(o.faithfulRes)
return true return true
end }, 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 -- hard render cap (issue #88): bounds the present rate so a
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic -- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
-- is fixed-step off dt, so this touches presentation only. -- 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 Menu = require("src.ui.Menu")
local QuantityBox = require("src.ui.QuantityBox") local QuantityBox = require("src.ui.QuantityBox")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local romText = require("src.core.RomText")
local ShopMenu = {} local ShopMenu = {}
@@ -57,7 +58,8 @@ local function buy(game, stock)
end end
local cost = qty * def.price local cost = qty * def.price
-- _PokemartTellBuyPriceText + yes/no confirm -- _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) game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then if not yes then
list.footer = greet list.footer = greet
@@ -147,7 +149,8 @@ local function sell(game)
return return
end end
-- _PokemartTellSellPriceText + yes/no confirm -- _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) game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then if not yes then
list.footer = greet list.footer = greet
+3 -1
View File
@@ -46,6 +46,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local romText = require("src.core.RomText")
local SlotMachine = {} local SlotMachine = {}
SlotMachine.__index = SlotMachine SlotMachine.__index = SlotMachine
@@ -357,7 +358,8 @@ function SlotMachine:resolveWin(win)
-- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port -- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port
-- shows it in the box while the screen flashes. LinedUpText follows. -- shows it in the box while the screen flashes. LinedUpText follows.
self.yeah = (sym == "7") 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 -- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not
-- credited until the player dismisses the "lined up" text (see startPayout). -- credited until the player dismisses the "lined up" text (see startPayout).
self.stage = "flash" 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 optsTbl = SaveData.loadOptions()
local applied = optsTbl 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 {} local gold = type(optsTbl.gold) == "table" and optsTbl.gold or {}
applied = { applied = {
touchControls = gold.touchControls, touchControls = gold.touchControls,
@@ -151,7 +154,9 @@ local function persist()
skin = cfg.skin, skin = cfg.skin,
layouts = cfg.layouts, 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 = type(opts.gold) == "table" and opts.gold or {}
opts.gold.touchControls = block opts.gold.touchControls = block
else else
@@ -569,7 +574,7 @@ function Editor.new(game)
local state = { game = game, isOpaque = true } local state = { game = game, isOpaque = true }
Editor.hostPoll = true Editor.hostPoll = true
Editor.load({ Editor.load({
version = "gold", version = require("src.core.GameVersion").get(),
hostPoll = true, hostPoll = true,
onClose = function() onClose = function()
Editor.hostPoll = false 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))) scale = math.max(1, math.floor(math.min(w / 160, h / 144)))
end end
local size = 8 * scale local size = 8 * scale
local ox = math.floor((w - 160 * scale) / 2) local Chrome = require("src.ui.gen2.Chrome")
local oy = math.floor((h - 144 * scale) / 2) local ox, oy = Chrome.fitOrigin(w, h, scale)
return size, ox, oy return size, ox, oy
end end
+388 -57
View File
@@ -303,12 +303,12 @@ local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2
-- data/text/common_3.asm; none of these are in the cache's text.lua, because no -- data/text/common_3.asm; none of these are in the cache's text.lua, because no
-- script bytecode the extractor walks points at them. -- script bytecode the extractor walks points at them.
CardFlip.TEXTS = { CardFlip.TEXTS = {
playWithThree = { "Play with three", "coins?" }, playWithThree = { "Play with", "3 coins?" },
notEnough = { "Not enough coins…" }, notEnough = { "Not enough", "coins." },
chooseACard = { "Choose a card." }, chooseACard = { "Choose a", "card." },
placeYourBet = { "Place your bet." }, placeYourBet = { "Place", "your bet" },
playAgain = { "Want to play", "again?" }, playAgain = { "Play", "again?" },
shuffled = { "The cards have", "been shuffled." }, shuffled = { "The cards", "shuffled." },
yeah = { "Yeah!" }, yeah = { "Yeah!" },
darn = { "Darn…" }, darn = { "Darn…" },
} }
@@ -401,14 +401,34 @@ function CardFlip:enterBet()
self.lines = CardFlip.TEXTS.placeYourBet self.lines = CardFlip.TEXTS.placeYourBet
end end
-- .CheckTheCard: the dealt card is turned face up and marked on the discard -- .CheckTheCard: trigger hardware-accurate discrete tile flip sequence
-- pile, which is what blanks its cell on the odds board.
function CardFlip:flip() function CardFlip:flip()
local card = CardFlip.dealt(self.deck, self.played, self.which) local card = CardFlip.dealt(self.deck, self.played, self.which)
self.faceUp = card self.faceUp = card
self.discarded[card] = true self.discarded[card] = true
self:sfx(SFX_CHOOSE) local won = CardFlip.payout(self.cursorX, self.cursorY, card)
self:tabulate() self.payoutLeft = won
self.payoutTick = 0
self.phase = "flipping"
self.flipTimer = 0
self.targetCard = card
end
function CardFlip:updateFlipping()
self.flipTimer = (self.flipTimer or 0) + 1
if self.flipTimer == 4 then
self:sfx(SFX_CHOOSE)
elseif self.flipTimer >= 12 then
if (self.payoutLeft or 0) > 0 then
self.phase = "payout"
self.lines = CardFlip.TEXTS.yeah
self:sfx(SFX_WIN)
else
self.phase = "result"
self.lines = CardFlip.TEXTS.darn
self:sfx(SFX_WRONG)
end
end
end end
function CardFlip:tabulate() function CardFlip:tabulate()
@@ -472,7 +492,21 @@ function CardFlip:quit()
if self.onClose then self.onClose() end if self.onClose then self.onClose() end
end end
function CardFlip:update(_dt) function CardFlip:update(dt)
-- Support both fixed-tick 60Hz loop and variable dt accumulator
if dt and dt > 0 then
self.dtAccum = (self.dtAccum or 0) + dt
local TICK = 1 / 60
while self.dtAccum >= TICK do
self.dtAccum = self.dtAccum - TICK
self:tick()
end
else
self:tick()
end
end
function CardFlip:tick()
local input = self.game and self.game.input local input = self.game and self.game.input
if not input then return end if not input then return end
local phase = self.phase local phase = self.phase
@@ -539,6 +573,11 @@ function CardFlip:update(_dt)
return return
end end
if phase == "flipping" then
self:updateFlipping()
return
end
if phase == "payout" then if phase == "payout" then
self:updatePayout() self:updatePayout()
return return
@@ -552,79 +591,370 @@ function CardFlip:update(_dt)
end end
-- ------------------------------------------------------------------- draw -- ------------------------------------------------------------------- draw
--
-- Authentic Color Game Boy palettes and tile graphics matching pret/pokegold
local TileSheet = require("src.ui.gen2.TileSheet")
local GbcPalette = require("src.render.GbcPalette")
local CARDFLIP_PALS = {
bg = {
[0] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 0: Base / Table green
[1] = { { 255, 255, 255 }, { 239, 206, 0 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 1: Pikachu (Yellow)
[2] = { { 255, 255, 255 }, { 255, 107, 247 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 2: Jigglypuff (Pink)
[3] = { { 255, 255, 255 }, { 66, 140, 247 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 3: Poliwag (Blue)
[4] = { { 255, 255, 255 }, { 66, 255, 66 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 4: Oddish (Green)
[5] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 5: Level header
[6] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 6: Border
[7] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 7: Textbox
},
obj = {
[0] = { { 255, 255, 255 }, { 248, 56, 40 }, { 248, 56, 40 }, { 248, 56, 40 } }, -- Authentic GBC Red OAM
}
}
local TILEMAP = nil
local function getCardFlipTilemap()
if TILEMAP == nil then
local path = "assets/generated/card_flip/card_flip.tilemap"
local f = io.open(path, "rb")
if f then
local data = f:read("*a")
f:close()
TILEMAP = {}
for i = 1, #data do
TILEMAP[i] = string.byte(data, i)
end
else
TILEMAP = false
end
end
return TILEMAP or nil
end
function CardFlip:sheets()
if self.sheet1 == nil then
self.sheet1 = TileSheet.new({ path = "assets/generated/card_flip/card_flip_1.png", wide = 16, firstTile = 0 })
self.sheet2 = TileSheet.new({ path = "assets/generated/card_flip/card_flip_2.png", wide = 3, firstTile = 0 })
self.sheet3 = TileSheet.new({ path = "assets/generated/card_flip/card_flip_3.png", wide = 1, firstTile = 0 })
self.sheetOn = TileSheet.new({ path = "assets/generated/card_flip/on.png", wide = 1, firstTile = 0 })
self.sheetOff = TileSheet.new({ path = "assets/generated/card_flip/off.png", wide = 1, firstTile = 0 })
end
return self.sheet1, self.sheet2, self.sheet3, self.sheetOn, self.sheetOff
end
function CardFlip:cursorQuads()
if self.s3Image == nil then
local _, _, s3 = self:sheets()
self.s3Image = s3:image()
if self.s3Image and love and love.graphics then
local G = love.graphics
self.quadCorner = G.newQuad(0, 0, 8, 8, 8, 56) -- Tile 0: 1px corner
self.quadVEdge = G.newQuad(0, 8, 8, 8, 8, 56) -- Tile 1: 1px vertical edge
self.quadHEdge = G.newQuad(0, 16, 8, 8, 8, 56) -- Tile 2: 1px horizontal edge
end
end
return self.s3Image, self.quadCorner, self.quadVEdge, self.quadHEdge
end
local HEADER_TILE_MAP = {
[0x3E] = 0, [0x3F] = 1,
[0x40] = 3, [0x41] = 4,
[0x42] = 6, [0x43] = 7,
[0x44] = 9, [0x45] = 10,
[0x46] = 12, [0x47] = 13,
[0x48] = 15, [0x49] = 16,
[0x4A] = 18, [0x4B] = 19,
[0x4C] = 21, [0x4D] = 22,
}
-- Draw an authentic GBC red cursor bounding frame using OAM sprite tiles.
-- On real GBC hardware OAM color 0 is always transparent, so only the 1px
-- dark edges of tiles 0-2 are visible; the board shows through the middle.
-- We replicate this by drawing in "multiply" blend mode: white (255,255,255)
-- pixels multiply to the board colour unchanged, black (0,0,0) pixels tinted
-- to GBC red draw the border, and nothing fills the interior.
function CardFlip:drawOamBox(px, py, w, h)
local img, qCorner, qVEdge, qHEdge = self:cursorQuads()
local G = love.graphics
if not (img and qCorner and qVEdge and qHEdge) then
-- Fallback: plain 1px red outline
G.setColor(248 / 255, 56 / 255, 40 / 255, 1)
G.rectangle("fill", px, py, w, 1)
G.rectangle("fill", px, py + h - 1, w, 1)
G.rectangle("fill", px, py, 1, h)
G.rectangle("fill", px + w - 1, py, 1, h)
G.setColor(1, 1, 1, 1)
return
end
local prevBlend, prevAlpha = G.getBlendMode()
G.setBlendMode("multiply", "premultiplied")
-- Tint: black (0) → GBC red, white (255) → white (passthrough = transparent)
G.setColor(248 / 255, 56 / 255, 40 / 255, 1)
-- 4 Corners
G.draw(img, qCorner, px, py, 0, 1, 1)
G.draw(img, qCorner, px + w, py, 0, -1, 1)
G.draw(img, qCorner, px, py + h, 0, 1, -1)
G.draw(img, qCorner, px + w, py + h, 0, -1, -1)
-- Top & Bottom horizontal edges
if w > 16 then
for x = px + 8, px + w - 16, 8 do
G.draw(img, qHEdge, x, py, 0, 1, 1)
G.draw(img, qHEdge, x, py + h, 0, 1, -1)
end
end
-- Left & Right vertical edges
if h > 16 then
for y = py + 8, py + h - 16, 8 do
G.draw(img, qVEdge, px, y, 0, 1, 1)
G.draw(img, qVEdge, px + w, y, 0, -1, 1)
end
end
G.setBlendMode(prevBlend, prevAlpha)
G.setColor(1, 1, 1, 1)
end
function CardFlip:drawBoard() function CardFlip:drawBoard()
-- The twelve hand lights down column 9; CARDFLIP_LIGHT_ON marks the hand local s1, s2, s3, sOn, sOff = self:sheets()
-- being played and every one before it stays off. local tm = getCardFlipTilemap()
for row = 0, CardFlip.HANDS_PER_DECK - 1 do local G = love.graphics
Chrome.print(row == self.played and "o" or ".", LIGHT_X, row)
end -- Green background fill
for x = 2, 5 do G.setColor(49 / 255, 156 / 255, 66 / 255, 1)
Chrome.print(CardFlip.MON_LABELS[x - 2], MON_COL[x], MON_ROW) G.rectangle("fill", 0, 0, 160, 144)
-- The pair headers sit above the two Pokemon they cover.
if x % 2 == 0 then Chrome.print("6", MON_COL[x] + 1, MON_PAIR_ROW) end if not tm or not s2:available() then
end -- Fallback simple board
for y = 2, 7 do for row = 0, CardFlip.HANDS_PER_DECK - 1 do
local row = LEVEL_ROW[y] Chrome.print(row == self.played and "o" or ".", LIGHT_X, row)
Chrome.print(tostring(y - 1), LEVEL_COL, row) end
if y % 2 == 0 then Chrome.print("9", LEVEL_PAIR_COL, row) end
for x = 2, 5 do for x = 2, 5 do
-- A still-in-the-deck cell stands in for the card back until the art Chrome.print(CardFlip.MON_LABELS[x - 2], MON_COL[x], MON_ROW)
-- lands. It cannot be '#': that is charmap.asm $54, the text command if x % 2 == 0 then Chrome.print("6", MON_COL[x] + 1, MON_PAIR_ROW) end
-- that places "POKé", not a one-tile glyph. end
local card = CardFlip.card(y - 2, x - 2) for y = 2, 7 do
Chrome.print(self.discarded[card] and " " or "?", CARD_COL[x], row) local pair = math.floor((y - 2) / 2)
local isBottom = ((y - 2) % 2 == 1)
local row = 3 + pair * 3 + (isBottom and 1 or 0)
Chrome.print(tostring(y - 1), LEVEL_COL, row)
if y % 2 == 0 then Chrome.print("9", LEVEL_PAIR_COL, row) end
for x = 2, 5 do
local card = CardFlip.card(y - 2, x - 2)
Chrome.print(self.discarded[card] and " " or "?", MON_COL[x], row)
end
end
return
end
-- Draw the 11x12 board tilemap at (9, 0)
for ty = 0, 11 do
for tx = 0, 10 do
local idx = ty * 11 + tx + 1
local tileId = tm[idx]
local screenX = 9 + tx
local screenY = ty
-- Attribute palette
local pal = 0
if screenY >= 1 and screenY <= 2 then
if screenX == 12 or screenX == 13 then pal = 1 -- Pikachu
elseif screenX == 14 or screenX == 15 then pal = 2 -- Jigglypuff
elseif screenX == 16 or screenX == 17 then pal = 3 -- Poliwag
elseif screenX == 18 or screenX == 19 then pal = 4 -- Oddish
end
elseif screenX == 9 then
pal = 1 -- Lights
end
local colors = CARDFLIP_PALS.bg[pal]
s1.palette = colors
s2.palette = colors
s3.palette = colors
if screenX == 9 then
-- Column 9: Light buttons
if screenY == self.played then
sOn.palette = colors
sOn:draw(0, screenX, screenY)
else
sOff.palette = colors
sOff:draw(0, screenX, screenY)
end
elseif tileId >= 0x3e then
-- Board graphics from card_flip_2 (using accurate 2x2 header tile mapping)
local mappedId = HEADER_TILE_MAP[tileId] or (tileId - 0x3e)
s2:draw(mappedId, screenX, screenY)
elseif tileId < 0x3e then
-- Graphics from card_flip_1
s1:draw(tileId, screenX, screenY)
end
end
end
-- Draw discarded card blanking covers (each 2-tile wide stacked card cell is 16x12 px)
for y = 2, 7 do
local level = y - 2
local pair = math.floor(level / 2)
local isBottom = (level % 2 == 1)
local py = 24 + pair * 24 + (isBottom and 12 or 0)
for x = 2, 5 do
local mon = x - 2
local card = CardFlip.card(level, mon)
if self.discarded[card] then
-- Discarded cover over full 16x12 stacked card cell
G.setColor(49 / 255, 156 / 255, 66 / 255, 1)
G.rectangle("fill", MON_COL[x] * 8, py, 16, 12)
end
end end
end end
end end
function CardFlip:cursorCell() function CardFlip:cursorBounds()
local x, y = self.cursorX, self.cursorY local x, y = self.cursorX, self.cursorY
local col if y == 0 then
if x == 0 then col = LEVEL_PAIR_COL -- Pokemon Pair: spans 4 columns (32px), 1 row (8px)
elseif x == 1 then col = LEVEL_COL local px = (MON_COL[x] or 12) * 8
else col = MON_COL[x] end local py = MON_PAIR_ROW * 8
local row return px, py, 32, 8
if y == 0 then row = MON_PAIR_ROW elseif y == 1 then
elseif y == 1 then row = MON_ROW -- Single Pokemon: 2x2 tiles (16x16 px)
else row = LEVEL_ROW[y] end local px = (MON_COL[x] or 12) * 8
return col, row local py = MON_ROW * 8
return px, py, 16, 16
elseif x == 0 then
-- Level Pair: 1 column (8px), spans 24px across the full pair
local pair = math.floor((y - 2) / 2)
local px = LEVEL_PAIR_COL * 8
local py = 24 + pair * 24
return px, py, 8, 24
elseif x == 1 then
-- Single Level: 1 column (8px), 12px tall for each stacked card
local pair = math.floor((y - 2) / 2)
local isBottom = ((y - 2) % 2 == 1)
local px = LEVEL_COL * 8
local py = 24 + pair * 24 + (isBottom and 12 or 0)
return px, py, 8, 12
else
-- Exact Card: 2 columns (16px), 12px tall for each stacked card
local pair = math.floor((y - 2) / 2)
local isBottom = ((y - 2) % 2 == 1)
local px = (MON_COL[x] or 12) * 8
local py = 24 + pair * 24 + (isBottom and 12 or 0)
return px, py, 16, 12
end
end end
-- CardFlip_DisplayCardFaceUp: the level digit at the box origin + (3,1) and the local FACE_DOWN_TILES = {
-- 3x3 Pokepic one row further down. { 0x08, 0x09, 0x09, 0x09, 0x0a },
{ 0x0b, 0x28, 0x2b, 0x28, 0x0c },
{ 0x0b, 0x2c, 0x2d, 0x2e, 0x0c },
{ 0x0b, 0x2f, 0x30, 0x31, 0x0c },
{ 0x0b, 0x32, 0x33, 0x34, 0x0c },
{ 0x0d, 0x0e, 0x0e, 0x0e, 0x0f },
}
local FACE_UP_TILES = {
{ 0x18, 0x19, 0x19, 0x19, 0x1a },
{ 0x1b, 0x35, 0x28, 0x28, 0x1c },
{ 0x0b, 0x28, 0x28, 0x28, 0x0c },
{ 0x0b, 0x28, 0x28, 0x28, 0x0c },
{ 0x0b, 0x28, 0x28, 0x28, 0x0c },
{ 0x1d, 0x1e, 0x1e, 0x1e, 0x1f },
}
local MON_ANCHORS = {
[0] = 24, -- Pikachu (tiles 24..32 in card_flip_2)
[1] = 33, -- Jigglypuff (tiles 33..41 in card_flip_2)
[2] = 42, -- Poliwag (tiles 42..50 in card_flip_2)
[3] = 51, -- Oddish (tiles 51..59 in card_flip_2)
}
-- Draw face-down, flipping, or face-up card at (2, 0) or (2, 6)
function CardFlip:drawCards() function CardFlip:drawCards()
local s1, s2 = self:sheets()
for slot = 1, 2 do for slot = 1, 2 do
local box = CARD_BOX[slot] local box = CARD_BOX[slot]
Chrome.box(box.x, box.y, CARD_BOX_W, CARD_BOX_H) local bx, by = box.x * 8, box.y * 8
local chosen = (slot - 1) == self.which local chosen = (slot - 1) == self.which
if self.faceUp and chosen then local isFlipping = (self.phase == "flipping") and chosen
Chrome.print(tostring(CardFlip.level(self.faceUp) + 1), box.x + 3, local isFaceUp = (self.faceUp and chosen)
box.y + 1)
Chrome.print(CardFlip.MON_LABELS[CardFlip.mon(self.faceUp)], if isFaceUp or (isFlipping and (self.flipTimer or 0) >= 4) then
box.x + 1, box.y + 3) local activeCard = self.faceUp or self.targetCard or 0
elseif self.phase == "choose" and chosen then local lvl = CardFlip.level(activeCard) + 1
Chrome.cursor(box.x, box.y + 3) local mon = CardFlip.mon(activeCard)
local monPal = CARDFLIP_PALS.bg[mon + 1] or CARDFLIP_PALS.bg[1]
s1.palette = CARDFLIP_PALS.bg[0]
for cy = 1, 6 do
for cx = 1, 5 do
local tid = FACE_UP_TILES[cy][cx]
s1:draw(tid, box.x + cx - 1, box.y + cy - 1)
end
end
-- Level digit at (box.x + 3, box.y + 1)
if isFaceUp or (isFlipping and (self.flipTimer or 0) >= 8) then
Chrome.print(tostring(lvl), box.x + 3, box.y + 1)
-- Draw 3x3 Pokemon pic from card_flip_2 (s2) at (box.x + 1, box.y + 2)
s2.palette = monPal
local anchor = MON_ANCHORS[mon] or 24
for py = 0, 2 do
for px = 0, 2 do
local tid = anchor + py * 3 + px
s2:draw(tid, box.x + 1 + px, box.y + 2 + py)
end
end
end
else
s1.palette = CARDFLIP_PALS.bg[0]
for cy = 1, 6 do
for cx = 1, 5 do
local tid = FACE_DOWN_TILES[cy][cx]
s1:draw(tid, box.x + cx - 1, box.y + cy - 1)
end
end
if self.phase == "choose" and chosen then
-- Authentic OAM red selection box around 5x6 card (40x48 px)
self:drawOamBox(bx, by, 40, 48)
end
end end
end end
end end
function CardFlip:drawPanel() function CardFlip:drawPanel()
Chrome.clear()
self:drawBoard() self:drawBoard()
self:drawCards() self:drawCards()
Chrome.textbox(COIN_BOX_X, COIN_BOX_Y, COIN_BOX_W - 2, COIN_BOX_H - 2)
Chrome.print("COIN", COIN_LABEL_X, COIN_LABEL_Y) -- Dialogue / Message box at (0, 12), 10 wide, 6 tall (interior 8x4)
Chrome.print(Chrome.number(self:coins(), 4, true), COIN_VALUE_X, COIN_VALUE_Y)
if self.lines then if self.lines then
Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W - 2, TEXT_BOX_H - 2) Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, 8, 4)
for i, line in ipairs(self.lines) do for i, line in ipairs(self.lines) do
Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE) Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE)
end end
end end
-- Coin box at (9, 15), 11 wide, 3 tall (interior 9x1)
Chrome.textbox(COIN_BOX_X, COIN_BOX_Y, 9, 1)
Chrome.print("COIN", COIN_LABEL_X, COIN_LABEL_Y)
Chrome.print(Chrome.number(self:coins(), 4, true), COIN_VALUE_X, COIN_VALUE_Y)
if self.phase == "bet" then if self.phase == "bet" then
local col, row = self:cursorCell() self.betBlink = (self.betBlink or 0) + 1
Chrome.cursor(col - 1, row) if (self.betBlink % 32) < 24 then
local px, py, w, h = self:cursorBounds()
self:drawOamBox(px, py, w, h)
end
end end
if self.phase == "ask" or self.phase == "again" then if self.phase == "ask" or self.phase == "again" then
-- YesNoBox: a 6x5 box at (14,7) with YES at (16,8) and NO at (16,10). -- YesNoBox: a 6x5 box at (14,7) with YES at (16,8) and NO at (16,10).
Chrome.textbox(14, 7, 4, 3) Chrome.textbox(14, 7, 4, 3)
@@ -652,3 +982,4 @@ function CardFlip:drawWidescreen(winW, winH)
end end
return CardFlip return CardFlip
+9
View File
@@ -66,6 +66,15 @@ function Chrome.fitOrigin(winW, winH, scale)
local x, y, w, h = playfieldRect(winW, winH) local x, y, w, h = playfieldRect(winW, winH)
return x + math.floor((w - Chrome.SCREEN_W * 8 * scale) / 2), return x + math.floor((w - Chrome.SCREEN_W * 8 * scale) / 2),
y + math.floor((h - Chrome.SCREEN_H * 8 * scale) / 2) y + math.floor((h - Chrome.SCREEN_H * 8 * scale) / 2)
- Chrome.positionLift(winW, winH, scale)
end
function Chrome.positionLift(winW, winH, scale)
local ok, ScreenPosition = pcall(require, "src.core.ScreenPosition")
if not ok or ScreenPosition.skinActive(winW, winH) then return 0 end
local _, _, _, h = playfieldRect(winW, winH)
return ScreenPosition.lift(h, Chrome.SCREEN_H * 8 * (scale
or Chrome.fitScale(winW, winH)), ScreenPosition.safeTop())
end end
-- A bordered box, tile coords. Leaves the draw color black for text. -- A bordered box, tile coords. Leaves the draw color black for text.
+12 -5
View File
@@ -189,11 +189,18 @@ defineString("CREDIT_END", "END")
-- STAFF and everything after it is a heading. Anything BELOW this id is a -- STAFF and everything after it is a heading. Anything BELOW this id is a
-- person as far as ParseCredits is concerned. -- person as far as ParseCredits is concerned.
Credits.STAFF = defineString("STAFF", { -- Credits_Staff differs per edition, including the centring spaces
" #MON", -- (data/credits_strings.asm:54-60).
" GOLD VERSION", Credits.STAFF = defineString("STAFF",
" PORT STAFF", require("src.core.GameVersion").get() == "silver" and {
}) " #MON",
" SILVER VERSION",
" PORT STAFF",
} or {
" #MON",
" GOLD VERSION",
" PORT STAFF",
})
defineString("DIRECTOR", " DIRECTOR") defineString("DIRECTOR", " DIRECTOR")
defineString("PROGRAMMING", " PROGRAMMING") defineString("PROGRAMMING", " PROGRAMMING")
defineString("ENGINE_DESIGN", " ENGINE DESIGN") defineString("ENGINE_DESIGN", " ENGINE DESIGN")
+2 -2
View File
@@ -147,8 +147,8 @@ function GoldSilverIntro.new(game, opts)
-- the movie's full 2335 frames over an empty screen and reads exactly -- the movie's full 2335 frames over an empty screen and reads exactly
-- like "the intro does not work". Say so instead: the fix is a -- like "the intro does not work". Say so instead: the fix is a
-- re-import, and nothing else in the boot chain will mention it. -- re-import, and nothing else in the boot chain will mention it.
Logger.warn("gold intro: no intro.lua in the cache -- re-import Gold " Logger.warn("gen2 intro: no intro.lua in the cache -- re-import "
.. "or the movie plays blank") .. "this version or the movie plays blank")
end end
self.images = {} self.images = {}
self.sheets = {} self.sheets = {}
+1 -1
View File
@@ -56,7 +56,7 @@ function MainMenu.new(game, opts)
self.save = opts.save self.save = opts.save
if self.save == nil and opts.hasSave ~= false then if self.save == nil and opts.hasSave ~= false then
local loaded = Save.load("gold") local loaded = Save.load()
self.save = loaded self.save = loaded
end end
self.hasSave = opts.hasSave self.hasSave = opts.hasSave
+12 -4
View File
@@ -34,8 +34,14 @@ local NamePick = {}
NamePick.__index = NamePick NamePick.__index = NamePick
NamePick.isOpaque = true NamePick.isOpaque = true
-- data/player_names.asm PlayerNameArray, Gold's half of the IF. -- data/player_names.asm PlayerNameArray, one half of the IF per edition.
local PRESETS = { "GOLD", "HIRO", "TAYLOR", "KARL" } local PRESETS = { "GOLD", "HIRO", "TAYLOR", "KARL" }
local PRESETS_SILVER = { "SILVER", "KAMON", "OSCAR", "MAX" }
local function presetsFor()
local silver = require("src.core.GameVersion").get() == "silver"
return silver and PRESETS_SILVER or PRESETS
end
-- menu_coords 0, 0, 10, TEXTBOX_Y - 1 (TEXTBOX_Y = 12). -- menu_coords 0, 0, 10, TEXTBOX_Y - 1 (TEXTBOX_Y = 12).
local BOX_X1, BOX_Y1, BOX_X2, BOX_Y2 = 0, 0, 10, 11 local BOX_X1, BOX_Y1, BOX_X2, BOX_Y2 = 0, 0, 10, 11
@@ -60,7 +66,7 @@ function NamePick.new(game, opts)
self.game = game self.game = game
self.onDone = opts.onDone self.onDone = opts.onDone
self.items = { "NEW NAME" } self.items = { "NEW NAME" }
for _, name in ipairs(opts.presets or PRESETS) do for _, name in ipairs(opts.presets or presetsFor()) do
self.items[#self.items + 1] = name self.items[#self.items + 1] = name
end end
-- `db 1 ; default option`: the cursor starts on NEW NAME, not on a preset. -- `db 1 ; default option`: the cursor starts on NEW NAME, not on a preset.
@@ -106,7 +112,7 @@ function NamePick:openNaming()
if name and #name > 0 then if name and #name > 0 then
self:choose(name) self:choose(name)
else else
self:choose(self.items[2] or "GOLD") self:choose(self.items[2] or presetsFor()[1])
end end
end, end,
}) })
@@ -146,7 +152,7 @@ function NamePick:update(_dt)
if self.fontOk then if self.fontOk then
self:openNaming() self:openNaming()
else else
self:choose(self.items[2] or "GOLD") self:choose(self.items[2] or presetsFor()[1])
end end
else else
-- A preset returns through MovePlayerPicLeft, so the pic walks back -- A preset returns through MovePlayerPicLeft, so the pic walks back
@@ -227,5 +233,7 @@ function NamePick:drawWidescreen(winW, winH)
end end
NamePick.PRESETS = PRESETS NamePick.PRESETS = PRESETS
NamePick.PRESETS_SILVER = PRESETS_SILVER
NamePick.presetsFor = presetsFor
return NamePick return NamePick
+4 -4
View File
@@ -72,8 +72,8 @@ end
-- Naming presets are boot config the same way Gen 1 reads them -- Naming presets are boot config the same way Gen 1 reads them
-- (field.boot.namePresets), so a total conversion that replaces the list once -- (field.boot.namePresets), so a total conversion that replaces the list once
-- replaces it for both games; NamePick.PRESETS (data/player_names.asm -- replaces it for both games; NamePick.presetsFor (data/player_names.asm
-- PlayerNameArray) is Gold's fallback. -- PlayerNameArray) is the running edition's fallback.
local function namePresets(game, who, fallback) local function namePresets(game, who, fallback)
local boot = game and game.data and game.data.field local boot = game and game.data and game.data.field
and game.data.field.boot and game.data.field.boot
@@ -351,9 +351,9 @@ function OakSpeech:openNamePick(step)
picColors = self.playerColors, picColors = self.playerColors,
presets = step.presets presets = step.presets
or namePresets(self.game, step.presetsWho or step.who or "player", or namePresets(self.game, step.presetsWho or step.who or "player",
step.presetsFallback or NamePick.PRESETS), step.presetsFallback or NamePick.presetsFor()),
onDone = function(name) onDone = function(name)
name = name or "GOLD" name = name or NamePick.presetsFor()[1]
self.game.save.player.name = name self.game.save.player.name = name
self.game.stack:pop() -- NamePick self.game.stack:pop() -- NamePick
self.busy = false self.busy = false
+9
View File
@@ -196,6 +196,15 @@ local ROWS = {
return VideoMode.normalize(options.videoMode) == "borderless" return VideoMode.normalize(options.videoMode) == "borderless"
and "FULL" or "WINDOWED" and "FULL" or "WINDOWED"
end }, end },
{ label = "SCREEN POS", key = "screenPos", port = true,
cycle = function(options, delta)
local ScreenPosition = require("src.core.ScreenPosition")
options.screenPos = ScreenPosition.cycle(options.screenPos, delta)
ScreenPosition.setMode(options.screenPos)
end,
text = function(options)
return require("src.core.ScreenPosition").label(options.screenPos)
end },
{ id = "touchControls", label = "TOUCH PAD", port = true, { id = "touchControls", label = "TOUCH PAD", port = true,
text = function(options) text = function(options)
local tc = options.touchControls local tc = options.touchControls
+1 -1
View File
@@ -181,7 +181,7 @@ function PcMenu:beginChangeBox(index)
self.saveTimer = 0 self.saveTimer = 0
self.saved = nil self.saved = nil
local existed = self.saveExists local existed = self.saveExists
if existed == nil then existed = Save.exists("gold") end if existed == nil then existed = Save.exists() end
self.existed = existed self.existed = existed
end end
+1 -1
View File
@@ -71,7 +71,7 @@ function SaveMenu.new(game, opts)
self.onDone = opts.onDone self.onDone = opts.onDone
self.writer = opts.writer or Save.save self.writer = opts.writer or Save.save
local existed = opts.existed local existed = opts.existed
if existed == nil then existed = Save.exists("gold") end if existed == nil then existed = Save.exists() end
self.existed = existed self.existed = existed
-- confirm -> overwrite (only when a file exists) -> saving -> done -- confirm -> overwrite (only when a file exists) -> saving -> done
self.phase = "confirm" self.phase = "confirm"
+535 -80
View File
@@ -219,10 +219,16 @@ local TWO_LINES = {
function SlotMachine.matchFirstTwo(bet, r1, r2) function SlotMachine.matchFirstTwo(bet, r1, r2)
local building = SlotMachine.NO_MATCH local building = SlotMachine.NO_MATCH
local matchingSevens = false
for _, line in ipairs(TWO_LINES[(bet or 0) % 4] or {}) do for _, line in ipairs(TWO_LINES[(bet or 0) % 4] or {}) do
if r1[line[1]] == r2[line[2]] then building = r1[line[1]] end if r1[line[1]] == r2[line[2]] then
building = r1[line[1]]
if building == SlotMachine.SEVEN then
matchingSevens = true
end
end
end end
return building, building == SlotMachine.SEVEN return building, matchingSevens
end end
-- ------------------------------------------------------------------- bias -- ------------------------------------------------------------------- bias
@@ -403,13 +409,14 @@ end
-- matching SEVENs on a line this bet buys. -- matching SEVENs on a line this bet buys.
function SlotMachine.spinReel2ToSevens(position, bet, stopped1) function SlotMachine.spinReel2ToSevens(position, bet, stopped1)
local strip = SlotMachine.REELS[2] local strip = SlotMachine.REELS[2]
local pos = SlotMachine.advance(position)
for _ = 1, SEARCH_LIMIT do for _ = 1, SEARCH_LIMIT do
local window = { SlotMachine.window(strip, position) } local window = { SlotMachine.window(strip, pos) }
local building, sevens = SlotMachine.matchFirstTwo(bet, stopped1, window) local building, sevens = SlotMachine.matchFirstTwo(bet, stopped1, window)
if building ~= SlotMachine.NO_MATCH and sevens then return position end if building ~= SlotMachine.NO_MATCH and sevens then return pos end
position = SlotMachine.advance(position) pos = SlotMachine.advance(pos)
end end
return position return pos
end end
-- ------------------------------------------------------- reel 3's theatre -- ------------------------------------------------------- reel 3's theatre
@@ -580,8 +587,8 @@ end
-- ------------------------------------------------------------------ layout -- ------------------------------------------------------------------ layout
local COINS_X, COINS_Y = 5, 1 local COINS_X, COINS_Y = 5, 1
local PAYOUT_X, PAYOUT_Y = 11, 1 local PAYOUT_X, PAYOUT_Y = 11, 1
-- REEL_X_COORD / TILE_WIDTH. -- REEL_X_COORD / TILE_WIDTH (5, 9, 13) for the 3 reel apertures (columns 5-6, 9-10, 13-14)
local REEL_X = { 6, 10, 14 } local REEL_X = { 5, 9, 13 }
-- Slots_UpdateReelPositionAndOAM's y ladder, converted from OAM space (which -- Slots_UpdateReelPositionAndOAM's y ladder, converted from OAM space (which
-- sits 16px above the screen) to tile rows: bottom, middle, top, and the -- sits 16px above the screen) to tile rows: bottom, middle, top, and the
-- fourth symbol that only half shows. -- fourth symbol that only half shows.
@@ -617,7 +624,7 @@ SlotMachine.TEXTS = TEXTS
-- _SlotsLinedUpText: "lined up!" / "Won @<wStringBuffer2> coins!", with the -- _SlotsLinedUpText: "lined up!" / "Won @<wStringBuffer2> coins!", with the
-- matched symbol's 2x2 tiles printed to its left by .Text_PrintPayout. -- matched symbol's 2x2 tiles printed to its left by .Text_PrintPayout.
local function linedUpLines(payout) local function linedUpLines(payout)
return { "lined up!", ("Won %d coins!"):format(payout) } return { " lined up!", ("Won %d coins!"):format(payout) }
end end
-- Slots_PlaySFX's labels, spelled the pokegold way (Sound.GEN2_ALIASES is what -- Slots_PlaySFX's labels, spelled the pokegold way (Sound.GEN2_ALIASES is what
@@ -729,30 +736,41 @@ function SlotMachine:startSpin()
self.reel = 1 self.reel = 1
self.delay = 32 self.delay = 32
self.message = TEXTS.start self.message = TEXTS.start
self.stopped = nil
self.matched = SlotMachine.NO_MATCH
self.matchingSevens = false
self.reel3Action = nil
self.golemAnim = nil
self.chanseyAnim = nil
self.reel2Pause = nil
for i = 1, 3 do for i = 1, 3 do
self.rate[i] = 4 -- ReelAction_NormalRate self.rate[i] = 4 -- ReelAction_NormalRate
self.stops[i] = nil self.stops[i] = nil
self.distance[i] = 0
end end
self:sfx(SFX_START) self:sfx(SFX_START)
end end
-- Slots_SpinReel, per reel per frame: the action jumptable runs only on a slot -- Slots_SpinReel, per reel per frame: the action jumptable runs only on a slot
-- boundary, and the position advances when the distance's low nibble wraps. -- boundary, and the position advances whenever 16 pixels are traversed.
function SlotMachine:spinReels() function SlotMachine:spinReels()
for i = 1, 3 do for i = 1, 3 do
local rate = self.rate[i] local rate = self.rate[i]
if rate > 0 then if rate > 0 then
self.distance[i] = (self.distance[i] + rate) % 256 self.distance[i] = (self.distance[i] or 0) + rate
if self.distance[i] % 16 == 0 then while self.distance[i] >= 16 do
self.distance[i] = self.distance[i] - 16
self.positions[i] = SlotMachine.advance(self.positions[i]) self.positions[i] = SlotMachine.advance(self.positions[i])
-- A reel with a resting slot chosen stops the moment it reaches it. -- A reel with a resting slot chosen stops the moment it reaches it.
if self.stops[i] and self.positions[i] == self.stops[i] then if self.stops[i] and self.positions[i] == self.stops[i] then
self.rate[i] = 0 self.rate[i] = 0
self.distance[i] = 0
self.stopped = self.stopped or {} self.stopped = self.stopped or {}
self.stopped[i] = { SlotMachine.window(SlotMachine.REELS[i], self.stopped[i] = { SlotMachine.window(SlotMachine.REELS[i],
self.positions[i]) } self.positions[i]) }
self:sfx(SFX_STOP) self:sfx(SFX_STOP)
self:reelStopped(i) self:reelStopped(i)
break
end end
end end
end end
@@ -775,11 +793,17 @@ function SlotMachine:pressStop()
self.stops[1] = SlotMachine.stopReel1(here, self.bias) self.stops[1] = SlotMachine.stopReel1(here, self.bias)
elseif i == 2 then elseif i == 2 then
local r1 = self.stopped[1] local r1 = self.stopped[1]
if SlotMachine.reel2SkipsToSeven(self.bet, self.bias, r1, self.random) then local hereWindow = { SlotMachine.window(SlotMachine.REELS[2], here) }
local _, hereSevens = SlotMachine.matchFirstTwo(self.bet, r1, hereWindow)
local doSkip = SlotMachine.reel2SkipsToSeven(self.bet, self.bias, r1, self.random)
if hereSevens and not doSkip then
self.stops[2] = here
elseif doSkip then
-- ReelAction_SetUpReel2SkipTo7 pauses the reel for 32 frames and then -- ReelAction_SetUpReel2SkipTo7 pauses the reel for 32 frames and then
-- fast-spins it at double rate; the pause is the tell. -- fast-spins it at double rate; the pause is the tell.
self.stops[2] = SlotMachine.spinReel2ToSevens(here, self.bet, r1) self.stops[2] = SlotMachine.spinReel2ToSevens(here, self.bet, r1)
self.rate[2] = 8 self.reel2Pause = 32
self.rate[2] = 0 -- paused during the 32-frame tell
else else
self.stops[2] = SlotMachine.stopReel2(here, self.bias, self.bet, r1) self.stops[2] = SlotMachine.stopReel2(here, self.bias, self.bet, r1)
end end
@@ -789,32 +813,54 @@ function SlotMachine:pressStop()
self.matchingSevens = sevens self.matchingSevens = sevens
local action = SlotMachine.reel3Action(sevens, self.bias, self.random) local action = SlotMachine.reel3Action(sevens, self.bias, self.random)
self.reel3Action = action self.reel3Action = action
if action == SlotMachine.REEL3_STOP then if action == SlotMachine.REEL3_STOP or action == "stop" then
self.stops[3] = SlotMachine.stopReel3(here, self.bias, self.bet, r1, r2) self.stops[3] = SlotMachine.stopReel3(here, self.bias, self.bet, r1, r2)
elseif action == SlotMachine.REEL3_SLOW then elseif action == SlotMachine.REEL3_SLOW or action == "slowAdvance" then
self.stops[3] = SlotMachine.slowAdvance(here, self.bias, self.bet, r1, r2) self.stops[3] = SlotMachine.slowAdvance(here, self.bias, self.bet, r1, r2)
self.rate[3] = 1 -- ReelAction_QuarterRate self.rate[3] = 1 -- ReelAction_QuarterRate slow crawl
elseif action == SlotMachine.REEL3_GOLEM then elseif action == SlotMachine.REEL3_GOLEM or action == "golem" then
local count = SlotMachine.golemCount(here, self.bias, self.bet, r1, r2, local count = SlotMachine.golemCount(here, self.bias, self.bet, r1, r2,
self.random) self.random)
if count == 0 then count = 3 end
local target = here local target = here
for _ = 1, count do target = SlotMachine.advance(target) end for _ = 1, count do target = SlotMachine.advance(target) end
self.stops[3] = target self.stops[3] = target
self.golems = count self.golems = count
self.rate[3] = 8 self.golemAnim = {
count = count,
state = "falling",
var1 = 48,
x = 100,
y = 44 - 112,
animFrame = 0,
animTimer = 0,
}
self.rate[3] = 0 -- reel 3 stepped by each golem impact
else else
local target = SlotMachine.eggDrops(here, self.bet, r1, r2) local target = SlotMachine.eggDrops(here, self.bet, r1, r2)
self.stops[3] = target self.stops[3] = target
self.rate[3] = 16 -- ReelAction_QuadrupleRate, the egg drop self.chanseyAnim = {
state = "walking",
xcoord = 0,
x = -24,
y = 44,
animTimer = 0,
animPose = 0,
}
self.rate[3] = 0 -- paused until Chansey drops egg
end end
end end
-- A reel already sitting on its resting slot has nowhere to turn. -- A reel already sitting on its resting slot has nowhere to turn.
if self.stops[i] == self.positions[i] then -- Only trigger immediate halt if no active pause tell or special animation is running.
self.rate[i] = 0 if not self.reel2Pause and not self.golemAnim and not self.chanseyAnim then
self.stopped = self.stopped or {} if self.stops[i] == self.positions[i] then
self.stopped[i] = self:reelWindow(i) self.rate[i] = 0
self:sfx(SFX_STOP) self.distance[i] = 0
self:reelStopped(i) self.stopped = self.stopped or {}
self.stopped[i] = self:reelWindow(i)
self:sfx(SFX_STOP)
self:reelStopped(i)
end
end end
end end
@@ -823,6 +869,9 @@ function SlotMachine:reelStopped(i)
self.reel = i + 1 self.reel = i + 1
return return
end end
self.golemAnim = nil
self.chanseyAnim = nil
self.reel2Pause = nil
-- SlotsAction_FlashIfWin: a win flashes the object palette for 16 frames -- SlotsAction_FlashIfWin: a win flashes the object palette for 16 frames
-- before the payout is counted out; a loss skips straight past it. -- before the payout is counted out; a loss skips straight past it.
local r1, r2, r3 = self.stopped[1], self.stopped[2], self.stopped[3] local r1, r2, r3 = self.stopped[1], self.stopped[2], self.stopped[3]
@@ -906,13 +955,134 @@ function SlotMachine:update(_dt)
if phase == "spinning" then if phase == "spinning" then
-- SlotsAction_WaitStart clears hJoypadSum first, so a press held from the -- SlotsAction_WaitStart clears hJoypadSum first, so a press held from the
-- bet menu cannot stop reel one. -- bet menu cannot stop reel one.
if self.delay > 0 then if (self.delay or 0) > 0 then
self.delay = self.delay - 1 self.delay = self.delay - 1
self:spinReels() self:spinReels()
return return
end end
if self.reel2Pause and self.reel2Pause > 0 then
self.reel2Pause = self.reel2Pause - 1
if self.reel2Pause <= 0 then
self.reel2Pause = nil
self.rate[2] = 8 -- resume fast-spin after the 32-frame tell
end
end
if self.golemAnim then
local g = self.golemAnim
-- Cycle animation frames every 8 ticks (~7.5 fps) matching the original pacing
g.animTimer = (g.animTimer or 0) + 1
if g.animTimer >= 8 then
g.animTimer = 0
g.animFrame = ((g.animFrame or 0) + 1) % 4
end
if g.state == "falling" then
if g.var1 > 32 then
g.var1 = g.var1 - 1
local angle = (g.var1 * math.pi) / 32
local yOffset = math.floor(112 * math.sin(angle) + 0.5)
g.y = 44 + yOffset
g.x = 100
else
-- Landed on Reel 3!
g.y = 44
g.x = 100
g.state = "rolling"
g.xoffset = 0
g.animTimer = 0
g.animFrame = 0
self:sfx("Sfx_PlacePuzzlePieceDown")
-- Advance reel 3 by 1 slot per Golem impact
self.positions[3] = SlotMachine.advance(self.positions[3])
self.distance[3] = 0
end
elseif g.state == "rolling" then
g.xoffset = (g.xoffset or 0) + 1
g.x = 100 - g.xoffset
if g.xoffset >= 88 then
-- Rolled past reel 1 (100 - 88 = 12px) off the screen -> restart or end
g.count = g.count - 1
if g.count > 0 then
g.state = "falling"
g.var1 = 48
g.x = 100
g.y = 44 - 112
else
-- All Golems finished; halt reel 3 at target
self.golemAnim = nil
self.rate[3] = 0
self.distance[3] = 0
self.stopped = self.stopped or {}
self.stopped[3] = self:reelWindow(3)
self:sfx(SFX_STOP)
self:reelStopped(3)
end
end
end
end
if self.chanseyAnim then
local c = self.chanseyAnim
if c.state == "walking" then
c.xcoord = (c.xcoord or 0) + 1
c.x = c.xcoord - 24
c.y = 44
-- Cycle walking poses 0->1->2->3 (maps to Chansey 1->2->3->4) every 6 frames
c.animTimer = (c.animTimer or 0) + 1
if c.animTimer >= 6 then
c.animTimer = 0
c.animPose = ((c.animPose or 0) + 1) % 4
end
if c.xcoord % 16 == 0 then self:sfx("Sfx_JumpOverLedge") end
if c.x >= 88 then
-- Reached reel 3! Switch to tell pause (pose 4 = arm raised)
c.x = 88
c.state = "egg_pause"
c.delay = 14
c.animPose = 3
end
elseif c.state == "egg_pause" then
c.delay = (c.delay or 14) - 1
if c.delay <= 0 then
-- Switch to Chansey 5 (egg drop pose) and spawn egg
c.animPose = 4
c.state = "egg_drop"
c.eggTimer = 0
c.eggStartX = c.x + 14
c.eggStartY = c.y + 8
c.eggX = c.eggStartX
c.eggY = c.eggStartY
c.eggVisible = true
self:sfx("Sfx_Present")
end
elseif c.state == "egg_drop" then
c.eggTimer = (c.eggTimer or 0) + 1
local t = c.eggTimer / 16
if t > 1 then t = 1 end
c.eggX = c.eggStartX + t * (108 - c.eggStartX)
c.eggY = c.eggStartY + t * (56 - c.eggStartY) - math.sin(t * math.pi) * 8
if c.eggTimer >= 16 then
-- Egg landed on Reel 3!
c.eggVisible = false
c.state = "spinning"
self:sfx("Sfx_PlacePuzzlePieceDown")
self.rate[3] = 16 -- fast drop reel 3 to jackpot
end
end
end
self.message = nil self.message = nil
if input:wasPressed("a") then self:pressStop() end if input:wasPressed("a") then
if not self.stops[self.reel] and not self.reel2Pause and not self.golemAnim and not self.chanseyAnim then
self:pressStop()
end
end
self:spinReels() self:spinReels()
return return
end end
@@ -959,57 +1129,302 @@ end
-- ------------------------------------------------------------------- draw -- ------------------------------------------------------------------- draw
-- --
-- The cart's reel art is unextracted (see the header), so a symbol draws as its -- Authentic Color Game Boy palettes and tile graphics matching pret/pokegold
-- two-letter label inside a 2x2 cell. A `slots` entry in menu_gfx.lua switches local TileSheet = require("src.ui.gen2.TileSheet")
-- this to the real tiles without any other change. local GbcPalette = require("src.render.GbcPalette")
function SlotMachine:sheet()
if self.sheetCache == nil then local GBC_PALS = {
local data = self.game and self.game.data bg = {
local gfx = data and data.gen2MenuGfx and data.gen2MenuGfx.slots [0] = { { 255, 255, 255 }, { 198, 206, 231 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 0: Base Frame
if gfx and gfx.image then [1] = { { 255, 255, 255 }, { 247, 82, 49 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 1: Vileplume / Active Lights
local TileSheet = require("src.ui.gen2.TileSheet") [2] = { { 255, 255, 255 }, { 123, 255, 0 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 2: Bet 3 Indicators
self.sheetCache = TileSheet.new({ path = gfx.image, wide = gfx.wide or 16, [3] = { { 255, 255, 255 }, { 255, 123, 255 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 3: Bet 2 Indicators
firstTile = gfx.firstTile or 0 }) [4] = { { 255, 255, 255 }, { 123, 173, 255 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 4: Bet 1 Indicators
[5] = { { 255, 255, 90 }, { 255, 255, 49 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 5: Yellow Highlights
[6] = { { 255, 255, 255 }, { 132, 156, 239 }, { 206, 181, 0 }, { 0, 0, 0 } }, -- 6: Textbox frame
[7] = { { 255, 255, 255 }, { 173, 173, 173 }, { 107, 107, 107 }, { 0, 0, 0 } }, -- 7: Inactive / Gray
},
obj = {
[0] = { { 255, 255, 255 }, { 247, 82, 49 }, { 255, 0, 0 }, { 0, 0, 0 } }, -- 0: Seven (Red)
[1] = { { 255, 255, 255 }, { 99, 206, 8 }, { 41, 115, 0 }, { 0, 0, 0 } }, -- 1: Pokeball (Green/Red)
[2] = { { 255, 255, 255 }, { 99, 206, 8 }, { 247, 82, 49 }, { 0, 0, 0 } }, -- 2: Cherry
[3] = { { 255, 255, 255 }, { 255, 255, 49 }, { 165, 123, 24 }, { 0, 0, 0 } }, -- 3: Pikachu (Yellow)
[4] = { { 255, 255, 255 }, { 255, 255, 49 }, { 123, 173, 255 }, { 0, 0, 0 } }, -- 4: Squirtle (Blue/Yellow)
[5] = { { 255, 255, 255 }, { 255, 255, 49 }, { 165, 123, 24 }, { 0, 0, 0 } }, -- 5: Staryu / Golem (Rock)
[6] = { { 255, 255, 255 }, { 255, 198, 173 }, { 255, 107, 255 }, { 0, 0, 0 } }, -- 6: Chansey (Pink)
[7] = { { 255, 255, 255 }, { 255, 255, 255 }, { 0, 0, 0 }, { 0, 0, 0 } }, -- 7: Flashing
}
}
local TILEMAP = nil
local function getTilemap()
if TILEMAP == nil then
local path = "assets/generated/slots/gold_slots.tilemap"
local f = io.open(path, "rb")
if f then
local data = f:read("*a")
f:close()
TILEMAP = {}
for i = 1, #data do
TILEMAP[i] = string.byte(data, i)
end
else else
self.sheetCache = false TILEMAP = false
end end
end end
return self.sheetCache or nil return TILEMAP or nil
end end
local function cell(tx, ty, label) function SlotMachine:sheets()
local G = love.graphics if self.sheet1 == nil then
G.setColor(0, 0, 0, 1) self.sheet1 = TileSheet.new({ path = "assets/generated/slots/gold_slots_1.png", wide = 2, firstTile = 0 })
G.rectangle("line", tx * 8, ty * 8, 16, 16) self.sheet2 = TileSheet.new({ path = "assets/generated/slots/gold_slots_2.png", wide = 2, firstTile = 0 })
Chrome.print(label, tx, ty + 1) self.sheet3 = TileSheet.new({ path = "assets/generated/slots/gold_slots_3.png", wide = 3, firstTile = 0 })
end
return self.sheet1, self.sheet2, self.sheet3
end
function SlotMachine:drawBackground()
local s1, s2 = self:sheets()
local tm = getTilemap()
if not tm or not s1:available() then
-- Fallback simple background if assets unavailable
Chrome.clear()
return
end
local bet = self.bet or 0
for ty = 0, 11 do
for tx = 0, 19 do
local idx = ty * 20 + tx + 1
local tileId = tm[idx]
-- Palette attribution matching _CGB_SlotMachine
local pal = 0
if (tx <= 2 or tx >= 17) and ty >= 2 and ty <= 11 then
if ty >= 6 and ty <= 7 then pal = 4
elseif ty >= 4 and ty <= 9 then pal = 3
else pal = 2 end
elseif tx >= 4 and tx <= 15 and ty >= 2 and ty <= 3 then
pal = 1 -- Vileplume
elseif (tx == 3 or tx == 16) and ty >= 2 and ty <= 11 then
local isLit = false
if ty == 6 or ty == 7 then isLit = (bet >= 1)
elseif ty == 4 or ty == 5 or ty == 8 or ty == 9 then isLit = (bet >= 2)
elseif ty == 2 or ty == 3 or ty == 10 or ty == 11 then isLit = (bet >= 3)
end
if isLit then
pal = 1
-- Use lit lights tile
if tileId == 0x23 then tileId = 0x14
elseif tileId == 0x24 then tileId = 0x15 end
else
pal = 0
end
end
local colors = GBC_PALS.bg[pal]
s1.palette = colors
s2.palette = colors
if tileId < 0x25 then
s1:draw(tileId, tx, ty)
else
s2:draw(tileId - 0x25, tx, ty)
end
end
end
end end
function SlotMachine:drawReels() function SlotMachine:drawReels()
local _, s2 = self:sheets()
local G = love.graphics
for i = 1, 3 do for i = 1, 3 do
local strip = SlotMachine.REELS[i] local strip = SlotMachine.REELS[i]
local position = self.positions[i] local pos = self.positions[i]
-- .LoadOAM reads FOUR consecutive strip entries from REEL_POSITION and lays local a = pos
-- them bottom upward, which is what the three repeated entries at the end if a == 0 then a = 0x0f end
-- of each strip are for: position 14 reads indices 14, 15, 16 and 17 a = (a - 1) % 16
-- without wrapping. Only the lower three are on a pay line; the fourth local rx = REEL_X[i] * 8
-- half-shows at the top of the window. local dy = math.floor(self.distance[i] or 0)
for row = 1, 4 do
local symbol = strip[position + row] -- Draw 4 consecutive 2x2 symbols from bottom to top, exactly matching SlotMachine.window
cell(REEL_X[i], REEL_ROW[row], SlotMachine.LABELS[symbol] or "?") for row = 0, 3 do
local sym = strip[a + row + 1]
local py = 64 - (row * 16) + dy
local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0]
s2.palette = pal
-- 2x2 tiles in 2-wide sheet:
-- sym + 0 = top-left (col 0, row 0)
-- sym + 1 = top-right (col 1, row 0)
-- sym + 2 = bottom-left (col 0, row 1)
-- sym + 3 = bottom-right (col 1, row 1)
local t0 = s2:quad(sym + 0)
local t1 = s2:quad(sym + 1)
local t2 = s2:quad(sym + 2)
local t3 = s2:quad(sym + 3)
local img = s2:image()
if img and t0 and t1 and t2 and t3 then
local function drawSym()
G.draw(img, t0, rx, py)
G.draw(img, t1, rx + 8, py)
G.draw(img, t2, rx, py + 8)
G.draw(img, t3, rx + 8, py + 8)
end
if GbcPalette.available() then
GbcPalette.with(pal, drawSym)
else
drawSym()
end
else
-- Fallback label
cell(REEL_X[i], REEL_ROW[row + 1], SlotMachine.LABELS[sym] or "?")
end
end end
end end
end end
function SlotMachine:drawLights() function SlotMachine:actorsImage()
local lit = {} if self.actorsLoaded == nil then
-- Slots_IlluminateBetLights lights the rows for THIS bet and every smaller local Assets = require("src.render.Assets")
-- one: `dec a / jr z` falls through from three to two to one. local ok, img = pcall(Assets.image, "assets/generated/slots/gold_slots_actors.png")
for bet = 1, (self.bet or 0) do if not (ok and img) then
for _, row in ipairs(LIGHT_ROWS[bet] or {}) do lit[row] = true end ok, img = pcall(Assets.image, "assets/generated/slots/gold_slots_3.png")
end
self.actorsLoaded = (ok and img) or false
if self.actorsLoaded then
local G = love.graphics
-- 24x240 sheet:
-- 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.quadGolemStand = G.newQuad(0, 0, 24, 32, 24, 240)
self.quadGolemBall = G.newQuad(0, 32, 24, 32, 24, 240)
self.quadChansey1 = G.newQuad(0, 64, 24, 32, 24, 240)
self.quadChansey2 = G.newQuad(0, 96, 24, 32, 24, 240)
self.quadChansey3 = G.newQuad(0, 128, 24, 32, 24, 240)
self.quadChansey4 = G.newQuad(0, 160, 24, 32, 24, 240)
self.quadChanseyDrop = G.newQuad(0, 192, 24, 32, 24, 240)
self.quadEgg = G.newQuad(0, 224, 8, 16, 24, 240)
end
end end
for _, row in ipairs({ 2, 4, 6, 8, 10 }) do return self.actorsLoaded or nil
for _, col in ipairs(LIGHT_COLS) do end
Chrome.print(lit[row] and "*" or "-", col, row)
-- Redraw the top Vileplume row (rows 2..3) and bottom frame brackets (rows 10..11)
-- over the reels with solid backdrop to naturally mask any sprite overhang like the Game Boy hardware does.
function SlotMachine:drawOverlays()
local s1, s2 = self:sheets()
local tm = getTilemap()
if not tm or not s1:available() then return end
local G = love.graphics
-- Solid backdrop over header (rows 0..3) and footer (rows 10..11) between columns 4..15
G.setColor(198 / 255, 198 / 255, 74 / 255, 1)
G.rectangle("fill", 4 * 8, 2 * 8, 12 * 8, 2 * 8)
G.rectangle("fill", 4 * 8, 10 * 8, 12 * 8, 2 * 8)
G.setColor(1, 1, 1, 1)
for _, ty in ipairs({ 2, 3, 10, 11 }) do
for tx = 4, 15 do
local idx = ty * 20 + tx + 1
local tileId = tm[idx]
local pal = (ty <= 3) and 1 or 0
local colors = GBC_PALS.bg[pal]
s1.palette = colors
s2.palette = colors
if tileId < 0x25 then
s1:draw(tileId, tx, ty)
else
s2:draw(tileId - 0x25, tx, ty)
end
end
end
-- Draw Golem sprite animation
if self.golemAnim then
local actors = self:actorsImage()
local g = self.golemAnim
if actors then
local quad = self.quadGolemBall
local scaleX = 1
local scaleY = 1
if g.state == "falling" then
quad = self.quadGolemBall
elseif g.state == "rolling" then
-- Frameset_SlotsGolem: 0=Standing, 1=Ball, 2=StandingYFlip, 3=BallXFlip
local rotFrame = (g.animFrame or 0) % 4
if rotFrame == 0 then
quad = self.quadGolemStand
scaleX = 1
scaleY = 1
elseif rotFrame == 1 then
quad = self.quadGolemBall
scaleX = 1
scaleY = 1
elseif rotFrame == 2 then
quad = self.quadGolemStand
scaleX = 1
scaleY = -1
elseif rotFrame == 3 then
quad = self.quadGolemBall
scaleX = -1
scaleY = 1
end
end
G.setColor(1, 1, 1, 1)
local function drawGolem()
-- Draw rotated around center (ox=12, oy=16)
G.draw(actors, quad,
math.floor(g.x + 12),
math.floor(g.y + 16),
0, scaleX, scaleY, 12, 16)
end
if GbcPalette.available() then
GbcPalette.with(GBC_PALS.obj[5], drawGolem)
else
drawGolem()
end
end
end
-- Draw Chansey & Egg sprite animation
if self.chanseyAnim then
local actors = self:actorsImage()
local c = self.chanseyAnim
if actors then
local quad = self.quadChansey1
if c.state == "walking" then
local walkCycle = { self.quadChansey1, self.quadChansey2, self.quadChansey3, self.quadChansey4 }
quad = walkCycle[((c.animPose or 0) % 4) + 1] or self.quadChansey1
elseif c.state == "egg_pause" then
quad = self.quadChansey4
elseif c.state == "egg_drop" or c.state == "spinning" then
quad = self.quadChanseyDrop
end
G.setColor(1, 1, 1, 1)
local function drawChansey()
G.draw(actors, quad, math.floor(c.x), math.floor(c.y or 44))
if c.eggVisible and c.eggX and c.eggY then
G.draw(actors, self.quadEgg, math.floor(c.eggX), math.floor(c.eggY))
end
end
if GbcPalette.available() then
GbcPalette.with(GBC_PALS.obj[6], drawChansey)
else
drawChansey()
end
end end
end end
end end
@@ -1022,39 +1437,79 @@ function SlotMachine:drawMessage()
end end
if self.matched and self.matched ~= SlotMachine.NO_MATCH if self.matched and self.matched ~= SlotMachine.NO_MATCH
and self.phase == "payoutText" then and self.phase == "payoutText" then
cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, local _, s2 = self:sheets()
SlotMachine.LABELS[self.matched] or "?") local sym = self.matched
local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0]
s2.palette = pal
local t0 = s2:quad(sym + 0)
local t1 = s2:quad(sym + 1)
local t2 = s2:quad(sym + 2)
local t3 = s2:quad(sym + 3)
local img = s2:image()
local G = love.graphics
local px, py = PAYOUT_SYMBOL_X * 8, PAYOUT_SYMBOL_Y * 8
if img and t0 and t1 and t2 and t3 then
local function drawWin()
G.setColor(1, 1, 1, 1)
G.draw(img, t0, px, py)
G.draw(img, t1, px + 8, py)
G.draw(img, t2, px, py + 8)
G.draw(img, t3, px + 8, py + 8)
end
G.setColor(1, 1, 1, 1)
if GbcPalette.available() then
GbcPalette.with(pal, drawWin)
else
drawWin()
end
else
cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, SlotMachine.LABELS[self.matched] or "?")
end
end end
end end
function SlotMachine:drawPanel() function SlotMachine:drawPanel()
Chrome.clear() self:drawBackground()
self:drawLights() self:drawReels()
self:drawOverlays()
-- PRINTNUM_LEADINGZEROS | 2 bytes, 4 digits, for both counters. -- PRINTNUM_LEADINGZEROS | 2 bytes, 4 digits, for both counters.
Chrome.print(Chrome.number(self:coins(), 4, true), COINS_X, COINS_Y) Chrome.print(Chrome.number(self:coins(), 4, true), COINS_X, COINS_Y)
Chrome.print(Chrome.number(self.payoutLeft or 0, 4, true), PAYOUT_X, PAYOUT_Y) Chrome.print(Chrome.number(self.payoutLeft or 0, 4, true), PAYOUT_X, PAYOUT_Y)
self:drawReels()
if self.phase == "bet" then if self.phase == "bet" then
Chrome.textbox(BET_BOX_X, BET_BOX_Y, BET_BOX_W - 2, BET_BOX_H - 2) -- Left speech textbox for "Bet how many coins?"
Chrome.textbox(0, 12, 12, 4)
Chrome.print(TEXTS.betHowMany[1], 1, 14)
Chrome.print(TEXTS.betHowMany[2], 1, 16)
-- Right menu for bet choices (14, 10 to 19, 17)
Chrome.textbox(14, 10, 4, 6)
for i, label in ipairs(BET_ROWS) do for i, label in ipairs(BET_ROWS) do
local ty = BET_LABEL_Y + (i - 1) * BET_SPACING local ty = 12 + (i - 1) * 2
if i == self.betIndex then Chrome.cursor(BET_LABEL_X - 1, ty) end if i == self.betIndex then Chrome.cursor(15, ty) end
Chrome.print(label, BET_LABEL_X, ty) Chrome.print(label, 16, ty)
end end
if not self.message then
Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W - 2, TEXT_BOX_H - 2) if self.message then
for i, line in ipairs(TEXTS.betHowMany) do -- If "Not enough coins." message is shown, overlay full speech box
Chrome.textbox(0, 12, 18, 4)
for i, line in ipairs(self.message) do
Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE) Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE)
end end
end end
end elseif self.phase == "again" then
self:drawMessage() -- Speech box: "Play again?"
if self.phase == "again" then Chrome.textbox(0, 12, 18, 4)
-- PlaceYesNoBox `lb bc, 14, 12`: a 6x5 box at (14,12) with YES at (16,13). Chrome.print(TEXTS.playAgain[1], TEXT_X, TEXT_Y)
-- PlaceYesNoBox at (14, 12): 6x5 box with YES at (16,13), NO at (16,15)
Chrome.textbox(14, 12, 4, 3) Chrome.textbox(14, 12, 4, 3)
Chrome.print("YES", 16, 13) Chrome.print("YES", 16, 13)
Chrome.print("NO", 16, 15) Chrome.print("NO", 16, 15)
Chrome.cursor(15, 13 + (self.againChoice - 1) * 2) Chrome.cursor(15, 13 + (self.againChoice - 1) * 2)
else
self:drawMessage()
end end
end end
+60 -25
View File
@@ -2,6 +2,7 @@
-- wing-flap (Frameset_GSIntroHoOhLugia), spark trails, A/Start to continue. -- wing-flap (Frameset_GSIntroHoOhLugia), spark trails, A/Start to continue.
-- drawWidescreen fills the window with sky/clouds so widescreen has no -- drawWidescreen fills the window with sky/clouds so widescreen has no
-- pillarbox voids; the 160x144 art stays aspect-centered on top. -- pillarbox voids; the 160x144 art stays aspect-centered on top.
-- Every Gold/Silver difference arrives as a title.lua key, defaulted to Gold.
-- src/render/Assets.lua is the mod-override choke point: a raw -- src/render/Assets.lua is the mod-override choke point: a raw
-- love.graphics.newImage skips overrides/ and AssetTransform output. -- love.graphics.newImage skips overrides/ and AssetTransform output.
@@ -16,7 +17,7 @@ local TitleState = {}
TitleState.__index = TitleState TitleState.__index = TitleState
TitleState.isOpaque = true TitleState.isOpaque = true
-- title_bg_gold.pal mid-sky shade (sampled from composed title_screen.png). -- title_bg_gold.pal mid-sky shade, for a cache built before title.sky existed.
local SKY = { 123 / 255, 165 / 255, 255 / 255, 1 } local SKY = { 123 / 255, 165 / 255, 255 / 255, 1 }
-- ...and its grey stand-in, for when COLOR is not GBC. The title art is the -- ...and its grey stand-in, for when COLOR is not GBC. The title art is the
-- one thing in the port baked with its colours in (see the extractor), so the -- one thing in the port baked with its colours in (see the extractor), so the
@@ -61,6 +62,15 @@ function TitleState.new(game, opts)
self.hoohY = tonumber(title.hoohY) or 56 self.hoohY = tonumber(title.hoohY) or 56
self.cloudY = tonumber(title.cloudY) or 88 self.cloudY = tonumber(title.cloudY) or 88
self.cloudScrollEvery = tonumber(title.cloudScrollEvery) or 8 self.cloudScrollEvery = tonumber(title.cloudScrollEvery) or 8
-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm:820-838).
self.hoohBobAmplitude = tonumber(title.hoohBobAmplitude) or 2
self.hoohBobStep = tonumber(title.hoohBobStep) or 1
local sky = title.sky
self.sky = (type(sky) == "table" and #sky >= 3)
and { sky[1], sky[2], sky[3], 1 } or SKY
local below = title.below
self.below = (type(below) == "table" and #below >= 3)
and { below[1], below[2], below[3], 1 } or { 1, 1, 1, 1 }
self.hoohColor, self.hoohGray = {}, {} self.hoohColor, self.hoohGray = {}, {}
local paths = title.hoohFrames local paths = title.hoohFrames
@@ -80,8 +90,12 @@ function TitleState.new(game, opts)
self.sequence = title.hoohSequence or { self.sequence = title.hoohSequence or {
{ 1, 10 }, { 2, 9 }, { 3, 10 }, { 4, 10 }, { 3, 9 }, { 5, 10 }, { 1, 10 }, { 2, 9 }, { 3, 10 }, { 4, 10 }, { 3, 9 }, { 5, 10 },
} }
-- A frame shows duration + 1 ticks: GetSpriteAnimFrame stores the byte on
-- the advancing tick and only decrements on the ones after
-- (engine/sprite_anims/core.asm:400-434). Both editions' framesets total
-- 64 ticks with it, locking the wing beat to the 64-tick sine bob.
self.seqIndex = 1 self.seqIndex = 1
self.seqLeft = self.sequence[1] and self.sequence[1][2] or 10 self.seqLeft = (self.sequence[1] and self.sequence[1][2] or 10) + 1
self.frame = 1 self.frame = 1
-- AnimSeq_GSIntroHoOhLugia's SPRITEANIMSTRUCT_VAR1. -- AnimSeq_GSIntroHoOhLugia's SPRITEANIMSTRUCT_VAR1.
@@ -89,12 +103,21 @@ function TitleState.new(game, opts)
self.frameCounter = 0 self.frameCounter = 0
self.cloudScroll = 0 self.cloudScroll = 0
self.trails = {} self.trails = {}
-- UpdateTitleTrailSprite / TitleTrailCoords (intro_menu.asm), in pixels. -- UpdateTitleTrailSprite / TitleTrailCoords (intro_menu.asm:1069-1124), in
self.trailSpawns = { -- pixels.
self.trailSpawns = title.trailSpawns or {
{ 80, 88 }, { 104, 88 }, { 104, 88 }, { 120, 88 }, { 80, 88 }, { 104, 88 }, { 104, 88 }, { 120, 88 },
{ 120, 88 }, { 88, 88 }, { 120, 88 }, { 88, 88 },
} }
self.trailSpawnIndex = 1 self.trailSpawnIndex = 1
-- AnimSeq_GSTitleTrail (engine/sprite_anims/functions.asm:720-818).
self.trailMode = title.trailMode or "gold"
self.trailSpawnEvery = tonumber(title.trailSpawnEvery) or 4
self.trailStepX = tonumber(title.trailStepX) or 4
self.trailStepY = tonumber(title.trailStepY) or 1
self.trailBobAmplitude = tonumber(title.trailBobAmplitude) or 2
self.trailPhaseStep = tonumber(title.trailPhaseStep) or 3
self.trailPhase = tonumber(title.trailPhase)
-- How far past the 160px frame trails may fly (GB pixels); set each draw. -- How far past the 160px frame trails may fly (GB pixels); set each draw.
self.trailMaxX = 200 self.trailMaxX = 200
self.musicStarted = false self.musicStarted = false
@@ -119,47 +142,56 @@ function TitleState:enter()
end end
end end
-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm): VAR1 counts up -- Sprites_Sine hands back the byte the ASM leaves in a, so the down half of
-- one per frame and the struct's Y offset becomes `d * sin(VAR1 * pi/32)` with -- the wave arrives in two's complement and is a signed pixel delta here.
-- d = 2 on Gold (Silver counts DOWN with d = 8). Sprites_Sine hands back the local function signed(value)
-- byte the ASM leaves in a, so the down half of the wave arrives in two's if value >= 0x80 then return value - 0x100 end
-- complement and has to be read as a signed pixel delta here.
function TitleState:hoohBob()
local value = SpriteAnims.sine(self.hoohPhase, 2)
if value >= 0x80 then value = value - 0x100 end
return value return value
end end
-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm:820-838).
function TitleState:hoohBob()
return signed(SpriteAnims.sine(self.hoohPhase, self.hoohBobAmplitude))
end
function TitleState:advanceHooh() function TitleState:advanceHooh()
self.hoohPhase = (self.hoohPhase + 1) % 256 self.hoohPhase = (self.hoohPhase + self.hoohBobStep) % 256
self.seqLeft = self.seqLeft - 1 self.seqLeft = self.seqLeft - 1
if self.seqLeft > 0 then return end if self.seqLeft > 0 then return end
self.seqIndex = self.seqIndex + 1 self.seqIndex = self.seqIndex + 1
if self.seqIndex > #self.sequence then self.seqIndex = 1 end if self.seqIndex > #self.sequence then self.seqIndex = 1 end
local step = self.sequence[self.seqIndex] local step = self.sequence[self.seqIndex]
self.frame = step[1] self.frame = step[1]
self.seqLeft = step[2] self.seqLeft = step[2] + 1
end end
function TitleState:spawnTrail() function TitleState:spawnTrail()
if not (self.trailColor or self.trailGray) then return end if not (self.trailColor or self.trailGray) then return end
if self.frameCounter % 4 ~= 0 then return end if #self.trailSpawns == 0 then return end
if self.frameCounter % self.trailSpawnEvery ~= 0 then return end
local spawn = self.trailSpawns[self.trailSpawnIndex] local spawn = self.trailSpawns[self.trailSpawnIndex]
self.trailSpawnIndex = self.trailSpawnIndex % #self.trailSpawns + 1 self.trailSpawnIndex = self.trailSpawnIndex % #self.trailSpawns + 1
if not spawn then return end if not spawn then return end
self.trails[#self.trails + 1] = { self.trails[#self.trails + 1] = {
x = spawn[1], y = spawn[2], phase = love.math.random(0, 255), x = spawn[1], y = spawn[2],
phase = self.trailPhase or love.math.random(0, 255),
} }
end end
function TitleState:stepTrails() function TitleState:stepTrails()
local alive = {} local alive = {}
local maxX = self.trailMaxX or 200 local maxX = self.trailMaxX or 200
local silver = self.trailMode == "silver"
for _, t in ipairs(self.trails) do for _, t in ipairs(self.trails) do
t.x = t.x + 4 t.x = t.x + self.trailStepX
t.y = t.y + 1 t.y = t.y + self.trailStepY
t.phase = t.phase + 3 t.phase = t.phase + self.trailPhaseStep
t.drawY = t.y + math.floor(math.sin(t.phase / 16) * 2) if silver then
t.drawY = t.y + signed(SpriteAnims.sine(t.phase, self.trailBobAmplitude))
else
t.drawY = t.y
+ math.floor(math.sin(t.phase / 16) * self.trailBobAmplitude)
end
if t.x < maxX then alive[#alive + 1] = t end if t.x < maxX then alive[#alive + 1] = t end
end end
self.trails = alive self.trails = alive
@@ -254,14 +286,17 @@ function TitleState:drawWidescreen(winW, winH)
-- Let trails fly into the side bands. -- Let trails fly into the side bands.
self.trailMaxX = math.ceil((winW - ox) / scale) + 16 self.trailMaxX = math.ceil((winW - ox) / scale) + 16
-- Sky above the cloud line, paper white below : edge to edge. The fill has -- Sky above the cloud line, title.below under it (Gold's white cloud
-- to match whichever baked set is showing, or the surround would stay blue -- field, Silver's black sea) : edge to edge. The fill has to match
-- around a grey screen. -- whichever baked set is showing, or the surround would stay blue around a
local sky = self:gray() and SKY_GRAY or SKY -- grey screen.
local sky = self:gray() and SKY_GRAY or self.sky
G.setColor(sky[1], sky[2], sky[3], 1) G.setColor(sky[1], sky[2], sky[3], 1)
G.rectangle("fill", 0, 0, winW, math.max(0, cloudTop)) G.rectangle("fill", 0, 0, winW, math.max(0, cloudTop))
G.setColor(1, 1, 1, 1) local below = self.below
G.setColor(below[1], below[2], below[3], 1)
G.rectangle("fill", 0, cloudTop, winW, winH - cloudTop) G.rectangle("fill", 0, cloudTop, winW, winH - cloudTop)
G.setColor(1, 1, 1, 1)
-- Clouds across the full window width, aligned to the GB cloud band. -- Clouds across the full window width, aligned to the GB cloud band.
G.push() G.push()
+47 -5
View File
@@ -918,16 +918,58 @@ function Kit.rowsThatFit(h, rowH, gap, minRows, maxRows)
return math.max(minRows or 1, math.min(maxRows or 99, per)) return math.max(minRows or 1, math.min(maxRows or 99, per))
end end
Kit.dragX = nil
Kit.dragY = nil
Kit.dragAccum = 0
function Kit.dragBegin(x, y)
Kit.dragX, Kit.dragY, Kit.dragAccum = x, y, 0
end
function Kit.dragAdd(dy)
if Kit.dragX then Kit.dragAccum = Kit.dragAccum + (dy or 0) end
end
function Kit.dragEnd()
Kit.dragX, Kit.dragY, Kit.dragAccum = nil, nil, 0
end
local function dragOriginIn(x, y, w, h)
if not Kit.dragX then return false end
local x1, y1, x2, y2 = x, y, x + w, y + h
local c = Kit._clipRect
if c then
x1, y1 = math.max(x1, c.x), math.max(y1, c.y)
x2, y2 = math.min(x2, c.x + c.w), math.min(y2, c.y + c.h)
end
return Kit.dragX >= x1 and Kit.dragX <= x2
and Kit.dragY >= y1 and Kit.dragY <= y2
end
-- Mouse wheel over a paginated list turns PAGES. The wheel still has to do -- Mouse wheel over a paginated list turns PAGES. The wheel still has to do
-- something (users expect it), but it moves a bounded page index rather than -- something (users expect it), but it moves a bounded page index rather than
-- driving a pixel offset, so there is no scroll state and no interpolation. -- driving a pixel offset, so there is no scroll state and no interpolation.
function Kit.wheelPage(x, y, w, h, page, total, perPage) function Kit.wheelPage(x, y, w, h, page, total, perPage)
if Kit.blockClicks or (Kit.wheelY or 0) == 0 then return page end if Kit.blockClicks then return page end
if not Kit.hit(x, y, w, h) then return page end
local pages = math.max(1, math.ceil(total / math.max(1, perPage))) local pages = math.max(1, math.ceil(total / math.max(1, perPage)))
local moved = Theme.clamp((page or 1) + (Kit.wheelY > 0 and -1 or 1), 1, pages) local out = page
Kit.wheelY = 0 if (Kit.wheelY or 0) ~= 0 and Kit.hit(x, y, w, h) then
return math.floor(moved) out = math.floor(Theme.clamp((out or 1) + (Kit.wheelY > 0 and -1 or 1),
1, pages))
Kit.wheelY = 0
end
local acc = Kit.dragAccum or 0
if acc ~= 0 and dragOriginIn(x, y, w, h) then
local stepPx = math.max(1, math.floor((h or 0) / 2))
local flips = acc >= 0 and math.floor(acc / stepPx)
or -math.floor(-acc / stepPx)
if flips ~= 0 then
local want = (out or 1) + flips
out = math.floor(Theme.clamp(want, 1, pages))
Kit.dragAccum = out ~= want and 0 or acc - flips * stepPx
end
end
return out
end end
function Kit.scrollExtent(contentH, viewH) function Kit.scrollExtent(contentH, viewH)
+5 -2
View File
@@ -64,6 +64,7 @@ local PAL = {
railBlue = { 70, 150, 255 }, railBlue = { 70, 150, 255 },
railGold = { 255, 203, 5 }, -- Yellow cartridge (bright) railGold = { 255, 203, 5 }, -- Yellow cartridge (bright)
railAmber = { 218, 145, 32 }, -- Gold cartridge (deeper metal) railAmber = { 218, 145, 32 }, -- Gold cartridge (deeper metal)
railSilver = { 190, 198, 210 }, -- Silver cartridge (cool light metal)
} }
-- Semantic aliases kept so ported call sites read the same as before. -- Semantic aliases kept so ported call sites read the same as before.
PAL.cardBorder = PAL.line PAL.cardBorder = PAL.line
@@ -321,10 +322,12 @@ function Theme.meter(x, y, w, h, pct, c)
end end
-- The 4px version rail across the top of both windows: the only brand -- The 4px version rail across the top of both windows: the only brand
-- colour on screen (Red / Blue / Yellow / Gold cartridge colours). -- colour on screen (Red / Blue / Yellow / Gold / Silver cartridge colours).
function Theme.versionRail(x, y, w, h) function Theme.versionRail(x, y, w, h)
if not G then return end if not G then return end
local bars = { PAL.railRed, PAL.railBlue, PAL.railGold, PAL.railAmber } local bars = {
PAL.railRed, PAL.railBlue, PAL.railGold, PAL.railAmber, PAL.railSilver,
}
local seg = w / #bars local seg = w / #bars
for i, c in ipairs(bars) do for i, c in ipairs(bars) do
Theme.fill(x + (i - 1) * seg, y, seg, h, c, 1) Theme.fill(x + (i - 1) * seg, y, seg, h, c, 1)
+16 -8
View File
@@ -846,15 +846,20 @@ function OverworldState:useSoftboiledFieldMove(user, target)
if not user or not user.stats or not target or not target.stats if not user or not user.stats or not target or not target.stats
or target == user or target.hp <= 0 or target == user or target.hp <= 0
or target.hp >= target.stats.hp or user.hp <= heal then or target.hp >= target.stats.hp or user.hp <= heal then
Game.stack:push(TextBox.new(Game, Strings("It won't have\nany effect."))) Game.stack:push(TextBox.new(Game,
romText(Game.data, "_ItemUseNoEffectText", "It won't have\nany effect.")))
return false return false
end end
local before = target.hp
user.hp = user.hp - heal user.hp = user.hp - heal
target.hp = math.min(target.stats.hp, target.hp + heal) target.hp = math.min(target.stats.hp, target.hp + heal)
require("src.core.Sound").play(Game.data, "Heal_HP") require("src.core.Sound").play(Game.data, "Heal_HP")
local def = Game.data.pokemon[target.species] local def = Game.data.pokemon[target.species]
-- _PotionText's second slot is the recovered amount, same as
-- ItemEffects.lua's potion message -- the engine fallback never shows it
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
Strings("%s's HP\nwas restored!", target.nickname or def.name))) romText(Game.data, "_PotionText", "%s's HP\nwas restored!",
target.nickname or def.name, target.hp - before)))
return true return true
end end
@@ -2126,7 +2131,8 @@ function OverworldState:tryHiddenObject(fx, fy)
-- leaves the spot unfound; _CantCarryMoreText is the Toss line (#872) -- leaves the spot unfound; _CantCarryMoreText is the Toss line (#872)
local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
Strings("%s found\n%s!", save.player.name, name) .. "\f" romText(Game.data, "_FoundHiddenItemText", "%s found\n%s!",
save.player.name, name) .. "\f"
.. romText(Game.data, "_HiddenItemBagFullText", .. romText(Game.data, "_HiddenItemBagFullText",
"But, {PLAYER} has\nno more room for\vother items!"))) "But, {PLAYER} has\nno more room for\vother items!")))
return true return true
@@ -2137,7 +2143,8 @@ function OverworldState:tryHiddenObject(fx, fy)
-- text_asm tail runs it as PlaySoundWaitForCurrent + -- text_asm tail runs it as PlaySoundWaitForCurrent +
-- WaitForSoundToFinish once the box has printed (hidden_items.asm) -- WaitForSoundToFinish once the box has printed (hidden_items.asm)
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
Strings("%s found\n%s!", save.player.name, name), romText(Game.data, "_FoundHiddenItemText", "%s found\n%s!",
save.player.name, name),
nil, TextBox.soundOpts(Game, "Get_Item2"))) nil, TextBox.soundOpts(Game, "Get_Item2")))
return true return true
end end
@@ -2795,8 +2802,8 @@ function OverworldState:talkTo(npc)
"No more room for\nitems!") "No more room for\nitems!")
if GameVersion.isYellow() then if GameVersion.isYellow() then
local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item
noRoom = Strings("%s found\n%s!", Game.save.player.name, name) noRoom = romText(Game.data, "_FoundItemText", "%s found\n%s!",
.. "\f" .. noRoom Game.save.player.name, name) .. "\f" .. noRoom
end end
Game.stack:push(TextBox.new(Game, noRoom)) Game.stack:push(TextBox.new(Game, noRoom))
return return
@@ -2813,7 +2820,8 @@ function OverworldState:talkTo(npc)
local ddef = Game.data.items[d.item] local ddef = Game.data.items[d.item]
-- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm) -- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm)
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
Strings("%s found\n%s!", Game.save.player.name, name), nil, romText(Game.data, "_FoundItemText", "%s found\n%s!",
Game.save.player.name, name), nil,
TextBox.soundOpts(Game, TextBox.soundOpts(Game,
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1"))) (ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")))
return return
@@ -3745,7 +3753,7 @@ function OverworldState:applyFieldPoison()
local queue = {} local queue = {}
for _, mon in ipairs(fainted) do for _, mon in ipairs(fainted) do
local name = mon.nickname or Game.data.pokemon[mon.species].name local name = mon.nickname or Game.data.pokemon[mon.species].name
table.insert(queue, Strings("%s\nfainted!", name)) table.insert(queue, romText(Game.data, "_PokemonFaintedText", "%s\nfainted!", name))
end end
local alive = false local alive = false
for _, mon in ipairs(save.party) do for _, mon in ipairs(save.party) do
+26 -11
View File
@@ -1569,11 +1569,11 @@ function World:readVar(varId)
end end
-- Script_checkver: 0 for Gold, 1 for Silver (constants/misc_constants.asm -- Script_checkver: 0 for Gold, 1 for Silver (constants/misc_constants.asm
-- GS_VERSION). Only the Goldenrod prize counters and a handful of gift mons -- GS_VERSION).
-- read it, and the port has no Silver cache yet, so an unset version is Gold.
function World:gsVersion() function World:gsVersion()
local GameVersion = require("src.core.GameVersion")
local save = self.game and self.game.save local save = self.game and self.game.save
local version = (save and save.version) or "gold" local version = (save and save.version) or GameVersion.get()
return version == "silver" and 1 or 0 return version == "silver" and 1 or 0
end end
@@ -6633,8 +6633,9 @@ function World:nameRival(onDone)
if save then if save then
save.rival = save.rival or {} save.rival = save.rival or {}
-- NameRival ends on `ld hl, wRivalName / ld de, .DefaultName / call -- NameRival ends on `ld hl, wRivalName / ld de, .DefaultName / call
-- InitName`, and .DefaultName is "SILVER@" on Gold -- InitName`, and .DefaultName is "SILVER@" on Gold and "GOLD@" on
-- (engine/events/specials.asm:80-91). The default has to be written -- Silver (engine/events/specials.asm:80-94). The default has to be
-- written
-- HERE rather than ridden in from the seed: wRivalName starts as -- HERE rather than ridden in from the seed: wRivalName starts as
-- InitializeNPCNames' "???" (src/core/gen2/Save.lua), which is what the -- InitializeNPCNames' "???" (src/core/gen2/Save.lua), which is what the
-- pre-naming Cherrygrove battle prints. -- pre-naming Cherrygrove battle prints.
@@ -6646,7 +6647,7 @@ function World:nameRival(onDone)
if name and name:gsub(" ", "") ~= "" then if name and name:gsub(" ", "") ~= "" then
save.rival.name = name save.rival.name = name
else else
save.rival.name = "SILVER" save.rival.name = self:gsVersion() == 1 and "GOLD" or "SILVER"
end end
end end
if onDone then onDone(name) end if onDone then onDone(name) end
@@ -10026,8 +10027,11 @@ function World:draw()
G.clear(0.07, 0.05, 0.02, 1) G.clear(0.07, 0.05, 0.02, 1)
if not self.mapImage or not self.player then if not self.mapImage or not self.player then
G.setColor(0.85, 0.57, 0.13, 1) local silver = self:gsVersion() == 1
G.printf("POKEMON GOLD", 0, math.floor(h * 0.38), w, "center") if silver then G.setColor(0.74, 0.78, 0.83, 1)
else G.setColor(0.85, 0.57, 0.13, 1) end
G.printf(silver and "POKEMON SILVER" or "POKEMON GOLD",
0, math.floor(h * 0.38), w, "center")
G.setColor(0.92, 0.90, 0.82, 1) G.setColor(0.92, 0.90, 0.82, 1)
G.printf(self.status or "No map.", G.printf(self.status or "No map.",
0, math.floor(h * 0.48), w, "center") 0, math.floor(h * 0.48), w, "center")
@@ -10064,6 +10068,15 @@ function World:draw()
if self.shake then if self.shake then
self.camera.y = self.camera.y + (self.shake.phase or 0) self.camera.y = self.camera.y + (self.shake.phase or 0)
end end
local ScreenPosition = require("src.core.ScreenPosition")
local posLift = 0
if not ScreenPosition.skinActive(w, h) then
posLift = ScreenPosition.lift(h, 144 * self:fitScale(),
ScreenPosition.safeTop())
end
if posLift > 0 then
self.camera.y = self.camera.y + posLift / s
end
local override = pipelineId and self:drawPipeline(pipelineId, w, h, s) or nil local override = pipelineId and self:drawPipeline(pipelineId, w, h, s) or nil
@@ -10089,7 +10102,7 @@ function World:draw()
local pad = POKEPIC.pad[math.floor(pw / 8)] or POKEPIC.pad[7] local pad = POKEPIC.pad[math.floor(pw / 8)] or POKEPIC.pad[7]
G.push() G.push()
G.translate(math.floor((w - 160 * sPic) / 2), G.translate(math.floor((w - 160 * sPic) / 2),
math.floor((h - 144 * sPic) / 2)) math.floor((h - 144 * sPic) / 2) - posLift)
G.scale(sPic, sPic) G.scale(sPic, sPic)
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
local function body() local function body()
@@ -10140,8 +10153,10 @@ function World:draw()
-- The survey overlay is a developer aid, not part of the game: POKEPORT_DEV -- The survey overlay is a developer aid, not part of the game: POKEPORT_DEV
-- (or the F3 toggle) shows it, a normal boot does not. -- (or the F3 toggle) shows it, a normal boot does not.
if self.showDebugHud then if self.showDebugHud then
G.setColor(0.85, 0.57, 0.13, 1) local silver = self:gsVersion() == 1
G.printf("POKEMON GOLD", 0, 10, w, "center") if silver then G.setColor(0.74, 0.78, 0.83, 1)
else G.setColor(0.85, 0.57, 0.13, 1) end
G.printf(silver and "POKEMON SILVER" or "POKEMON GOLD", 0, 10, w, "center")
G.setColor(0.92, 0.90, 0.82, 1) G.setColor(0.92, 0.90, 0.82, 1)
local label = string.format("%s (%d,%d) %s · %s · zoom %s", local label = string.format("%s (%d,%d) %s · %s · zoom %s",
self.map.id, p.cellX, p.cellY, p.facing, self.map.id, p.cellX, p.cellY, p.facing,
@@ -58,6 +58,38 @@ check(position("if (secondaryEnabled) registerSecondaryDisplayListener();") <
"secondary display monitoring starts before initial discovery") "secondary display monitoring starts before initial discovery")
check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true), check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true),
"a disconnected active display is rebound without replacing a live one") "a disconnected active display is rebound without replacing a live one")
check(source:find("private static volatile boolean secondaryHostResumed = false;",
1, true), "secondary output tracks the primary activity lifecycle")
check(source:find("if (on && secondaryHostResumed)", 1, true)
and source:find("self == null || !secondaryHostResumed || !secondaryEnabled",
1, true),
"paused hosts cannot reopen secondary output from a late mod frame")
local pause = position("protected void onPause()")
local paused = assert(source:find("secondaryHostResumed = false;", pause, true))
local teardown = assert(source:find("teardownSecondaryDisplay();", pause, true))
local pauseSuper = assert(source:find("super.onPause();", pause, true))
check(pause < paused and paused < teardown and teardown < pauseSuper,
"pause blocks secondary setup before dismissing its output")
local resume = position("public void onResume()")
local resumeSuper = assert(source:find("super.onResume();", resume, true))
local resumed = assert(source:find("secondaryHostResumed = true;", resume, true))
local resumeSetup = assert(source:find("setupSecondaryDisplay();", resume, true))
check(resume < resumeSuper and resumeSuper < resumed and resumed < resumeSetup,
"resume permits secondary setup only after the primary activity resumes")
local destroy = position("protected void onDestroy()")
local destroyTeardown = assert(source:find("teardownSecondaryDisplay();", destroy, true))
local destroySuper = assert(source:find("super.onDestroy();", destroy, true))
check(destroy < destroyTeardown and destroyTeardown < destroySuper,
"destroy always dismisses secondary output before SDL destruction")
local mainFile = assert(io.open("main.lua", "rb"))
local main = mainFile:read("*a")
mainFile:close()
check(main:find('require("src.render.SecondScreen").setEnabled(false)', 1, true),
"returning from a game disables mod-owned secondary output")
check(not source:lower():find("openxr", 1, true), check(not source:lower():find("openxr", 1, true),
"generic Android activity must not require OpenXR") "generic Android activity must not require OpenXR")
@@ -41,12 +41,21 @@ end
-- Mock isReady -- Mock isReady
RomImporter.isReady = function(v) RomImporter.isReady = function(v)
return v == "red" or v == "gold" or v == "blue" or v == "yellow" return v == "red" or v == "gold" or v == "blue" or v == "yellow"
or v == "silver"
end end
local ok = RomImporter.syncAndroidShortcuts("gold") local ok = RomImporter.syncAndroidShortcuts("gold")
check(ok == true, "syncAndroidShortcuts returns true on Android") check(ok == true, "syncAndroidShortcuts returns true on Android")
check(#capturedShortcuts == 4, "syncAndroidShortcuts caps at 4 items") check(#capturedShortcuts == 4, "syncAndroidShortcuts caps at 4 items")
check(capturedShortcuts[1] == "gold", "activeVersion 'gold' is placed first") check(capturedShortcuts[1] == "gold", "activeVersion 'gold' is placed first")
check(capturedShortcuts[2] == "red" and capturedShortcuts[3] == "blue"
and capturedShortcuts[4] == "yellow",
"the rest follow GameVersion.ORDER until the cap")
capturedShortcuts = nil
RomImporter.syncAndroidShortcuts("silver")
check(#capturedShortcuts == 4, "a fifth ready game does not widen the payload")
check(capturedShortcuts[1] == "silver", "activeVersion 'silver' is placed first")
-- Test with subset of ready games (e.g. only Red and Gold) -- Test with subset of ready games (e.g. only Red and Gold)
RomImporter.isReady = function(v) RomImporter.isReady = function(v)
@@ -78,4 +87,4 @@ end
love.system.getLaunchGame = savedGetLaunchGame love.system.getLaunchGame = savedGetLaunchGame
love.system.updateShortcuts = nil love.system.updateShortcuts = nil
print("8/8 checks passed (android_shortcuts_payload_test)") T.finish("android_shortcuts_payload_test")
@@ -0,0 +1,87 @@
-- BattleState:throwBall()'s can't-be-caught path used to queue two
-- separate Strings() literals ("It dodged the\nthrown BALL!" then "This
-- POKéMON\ncan't be caught!"). The real ROM label _ItemUseBallText00
-- combines both as one \f-paged string. TextBox.new() would split \f
-- itself, but throwBall() queues through self:sayNext(), which goes
-- through the battle queue's own BattleState:startMessage() -- and that
-- one only splits on \n/\v, not \f (confirmed live: the \f landed
-- mid-line and the second sentence overflowed off the box instead of
-- starting a fresh page). The fix resolves the label once, then splits
-- it the same way TextBox.lua does and queues one sayNext per page. This
-- test fakes the label and checks the two pages reach the queue as two
-- separate messages, in order, not merged into one with a raw \f still
-- inside it.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local function mkbattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 10) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 8)
battle.ghost = true -- forces the can't-be-caught path
return battle
end
-- throwBall defers its message-queuing work into self.queue via one
-- top-level self:act(fn); run only that one function to reach the
-- say() calls the fix touches. It's the last entry throwBall itself
-- appends (after the immediate sayAuto), and it further queues its own
-- self:act(function() self:executeAction(...) end) for the actual enemy
-- turn -- deliberately NOT run here (out of scope, and running the queue
-- generically after mutation risks looping into a real turn simulation)
local function runThrowBallAct(battle)
for i = #battle.queue, 1, -1 do
if battle.queue[i].fn then
battle.queue[i].fn()
return
end
end
end
local function textEntries(battle)
local out = {}
for _, entry in ipairs(battle.queue) do
if entry.text then out[#out + 1] = entry.text end
end
return out
end
-- translated: the faked label's two \f-separated pages reach the queue
-- as two separate messages, in order, and neither one still contains a
-- raw \f (which would mean the battle queue's own renderer has to deal
-- with it, and it can't)
do
local battle = mkbattle()
Data.text._ItemUseBallText00 = "FAKE-DODGE!\fFAKE-CANTCATCH!"
battle:throwBall("FIX_BALL")
runThrowBallAct(battle)
local texts = textEntries(battle)
T.eq(texts[1], "FAKE-DODGE!", "page 1 reaches the queue on its own")
T.eq(texts[2], "FAKE-CANTCATCH!", "page 2 follows right after, still in order")
for _, t in ipairs(texts) do
T.check(not t:find("\f", 1, true), "no queued message still carries a raw \\f")
end
Data.text._ItemUseBallText00 = nil
end
-- vanilla: no catalog entry still falls back to the two English pages,
-- split the same way
do
local battle = mkbattle()
battle:throwBall("FIX_BALL")
runThrowBallAct(battle)
local texts = textEntries(battle)
T.eq(texts[1], "It dodged the\nthrown BALL!", "vanilla page 1")
T.eq(texts[2], "This POKéMON\ncan't be caught!", "vanilla page 2")
end
T.finish("battle_ball_dodge_romtext")
@@ -0,0 +1,77 @@
-- BattleState:storeCaughtMon() queues up to two plain-Lua-literal
-- messages: the new-Pokedex-data line (_ItemUseBallText06) and, when the
-- party is full, the box-transfer line (_ItemUseBallText07/08, keyed on
-- EVENT_MET_BILL -- two full, independently-translated ROM strings, not
-- one template with a substituted PC name). This test fakes all three
-- labels and checks the queued messages use them, not the English
-- literals.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local function findText(battle, needle)
for _, entry in ipairs(battle.queue) do
if entry.text and entry.text:find(needle, 1, true) then return entry.text end
end
return nil
end
local function mkbattle(partySize, metBill)
local save = SaveData.newGame()
save.party = {}
for i = 1, partySize do
save.party[i] = Pokemon.new(Data, "FIXMON_A", 5)
end
save.flags = save.flags or {}
save.flags.EVENT_MET_BILL = metBill
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
return BattleState.newWild(game, "FIXMON_C", 8)
end
-- empty party: Party.add succeeds, only the new-Pokedex-data message fires
do
local battle = mkbattle(0, false)
Data.text._ItemUseBallText06 = "FAKE-DEX {RAM:wEnemyMonNick} FAKE!"
battle:storeCaughtMon()
T.eq(findText(battle, "FAKE-DEX"), "FAKE-DEX " .. battle.enemy.name .. " FAKE!",
"a translated _ItemUseBallText06 reaches the new-Pokedex-data message")
Data.text._ItemUseBallText06 = nil
end
-- full party, EVENT_MET_BILL true: box transfer via _ItemUseBallText07
do
local battle = mkbattle(6, true)
Data.text._ItemUseBallText07 = "FAKE-BILL {RAM:wBoxMonNicks} FAKE!"
battle:storeCaughtMon()
T.eq(findText(battle, "FAKE-BILL"), "FAKE-BILL " .. battle.enemy.name .. " FAKE!",
"EVENT_MET_BILL true routes through the translated _ItemUseBallText07")
Data.text._ItemUseBallText07 = nil
end
-- full party, EVENT_MET_BILL false: box transfer via _ItemUseBallText08
do
local battle = mkbattle(6, false)
Data.text._ItemUseBallText08 = "FAKE-SOMEONE {RAM:wBoxMonNicks} FAKE!"
battle:storeCaughtMon()
T.eq(findText(battle, "FAKE-SOMEONE"), "FAKE-SOMEONE " .. battle.enemy.name .. " FAKE!",
"EVENT_MET_BILL false routes through the translated _ItemUseBallText08")
Data.text._ItemUseBallText08 = nil
end
-- vanilla, full party, EVENT_MET_BILL true: English literal, BILL's PC
do
local battle = mkbattle(6, true)
battle:storeCaughtMon()
T.eq(findText(battle, "transferred"),
battle.enemy.name .. " was\ntransferred to\nBILL's PC!",
"no catalog entry falls back to the English BILL's-PC literal")
end
T.finish("battle_catch_messages_romtext")
@@ -0,0 +1,64 @@
-- BattleState:onFaint's "%s\nfainted!" collapsed two distinct ROM
-- strings (_EnemyMonFaintedText already carries its own "Enemy" wording;
-- _PlayerMonFaintedText does not) into one literal, substituted with
-- displayName(battler) -- which itself runs the enemy name through a
-- SEPARATE Strings("Enemy %s", ...) call. The fix passes the raw
-- battler.name and lets each label supply its own wording. This test
-- fakes both labels and checks the raw name reaches the right one, with
-- no "Enemy" ever duplicated.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local function mkbattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 10) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
return BattleState.newWild(game, "FIXMON_C", 8)
end
-- finds the queued say-message text (onFaint queues several entries: a
-- wait, then the say)
local function findText(battle)
for _, entry in ipairs(battle.queue) do
if entry.text then return entry.text end
end
return nil
end
-- enemy: translated _EnemyMonFaintedText reaches onFaint, raw name only
do
local battle = mkbattle()
Data.text._EnemyMonFaintedText = "FAKE-ENEMY {RAM:wEnemyMonNick} FAKE!"
battle:onFaint(battle.enemy)
T.eq(findText(battle), "FAKE-ENEMY " .. battle.enemy.name .. " FAKE!",
"a translated _EnemyMonFaintedText reaches the enemy faint message")
Data.text._EnemyMonFaintedText = nil
end
-- enemy vanilla: the English literal already carries "Enemy " itself
do
local battle = mkbattle()
battle:onFaint(battle.enemy)
T.eq(findText(battle), "Enemy " .. battle.enemy.name .. "\nfainted!",
"no catalog entry falls back to the English literal, Enemy included")
end
-- player: translated _PlayerMonFaintedText reaches onFaint
do
local battle = mkbattle()
Data.text._PlayerMonFaintedText = "FAKE-PLAYER {RAM:wBattleMonNick} FAKE!"
battle:onFaint(battle.player)
T.eq(findText(battle), "FAKE-PLAYER " .. battle.player.name .. " FAKE!",
"a translated _PlayerMonFaintedText reaches the player faint message")
Data.text._PlayerMonFaintedText = nil
end
T.finish("battle_fainted_message_romtext")
@@ -0,0 +1,129 @@
-- BoxMenu's RELEASE confirmation ("Once released,\n%s is\ngone forever.
-- OK?") used to be a bare Lua literal. tests/engine/pc_release.lua only
-- ever runs with an empty Data.text, so it can't tell a properly-wired
-- t._OnceReleasedText or "..." fallback apart from a literal that never
-- looked at t at all -- every assertion there passes either way. This
-- test drives the same interactive release flow with a faked
-- Data.text._OnceReleasedText and checks the pushed TextBox's raw text
-- (captured via a TextBox.new spy, so real pagination/choice behavior is
-- untouched) uses the translated value, not the English literal.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local ids = T.fixtures.ids
require("src.render.Font").load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local Boxes = require("src.pokemon.Boxes")
local TextBox = require("src.render.TextBox")
local BoxMenu = require("src.ui.BoxMenu")
local ListMenu = require("src.ui.ListMenu")
local ChoiceBox = require("src.ui.ChoiceBox")
local SaveData = require("src.core.SaveData")
local Sound = require("src.core.Sound")
local realCry, realPlay = Sound.playCry, Sound.play
Sound.playCry = function() end
Sound.play = function() end
-- spy: records every raw text TextBox.new receives, without disturbing
-- pagination/choice re-push, so the interactive flow behaves exactly like
-- pc_release.lua's
local captured
local realNew = TextBox.new
TextBox.new = function(game, text, onDone, opts)
captured[#captured + 1] = text
return realNew(game, text, onDone, opts)
end
local stack = { states = {} }
function stack:push(s) self.states[#self.states + 1] = s end
function stack:pop()
local t = self.states[#self.states]
self.states[#self.states] = nil
return t
end
function stack:top() return self.states[#self.states] end
function stack:update(dt)
local t = self:top()
if t and t.update then t:update(dt) end
end
local pressed = {}
local function press(btn)
pressed = { [btn] = true }
stack:update(1 / 60)
pressed = {}
end
local function topMt() return getmetatable(stack:top()) end
local function mash(btn, cond, n)
for _ = 1, (n or 400) do
if cond() then return true end
press(btn)
end
return false
end
local function mkGame()
stack.states = {}
captured = {}
local game = {
data = Data,
save = SaveData.newGame(),
stack = stack,
input = {
wasPressed = function(_, key) return pressed[key] or false end,
isDown = function() return false end,
},
}
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
local box = Boxes.active(game.save)
box[1] = Pokemon.new(Data, ids.species[1], 5)
return game, box
end
local function releaseFirstMon(game)
stack:push(BoxMenu.new(game))
press("down"); press("down"); press("a") -- open RELEASE list
T.check(topMt() == ListMenu, "RELEASE opens the box list")
press("a") -- choose the first (only) mon
T.check(mash("a", function() return topMt() == ChoiceBox end),
"confirm choice opens")
-- the confirmation TextBox is captured[1] the moment it was pushed,
-- before this mash even ran
return captured[1]
end
-- monName (BoxMenu.lua): mon.nickname or def.name
local function monName(box)
local mon = box[1]
local def = Data.pokemon[mon.species]
return mon.nickname or def.name
end
-- translated: the fake value must reach the pushed TextBox
do
local game, box = mkGame()
local name = monName(box)
Data.text._OnceReleasedText = "FAKE {RAM:wStringBuffer} released!"
local text = releaseFirstMon(game)
T.eq(text, "FAKE " .. name .. " released!",
"a translated _OnceReleasedText reaches the release confirmation")
Data.text._OnceReleasedText = nil
end
-- vanilla: with no catalog entry, the English literal still substitutes
do
local game, box = mkGame()
local name = monName(box)
local text = releaseFirstMon(game)
T.eq(text, "Once released,\n" .. name .. " is\ngone forever. OK?",
"no catalog entry still falls back to the English literal")
end
TextBox.new = realNew
Sound.playCry, Sound.play = realCry, realPlay
T.finish("box_release_confirmation_romtext")
+1
View File
@@ -23,6 +23,7 @@ T.eq(GameVersion.generation("red"), 1, "Red is Gen 1")
T.eq(GameVersion.generation("blue"), 1, "Blue is Gen 1") T.eq(GameVersion.generation("blue"), 1, "Blue is Gen 1")
T.eq(GameVersion.generation("yellow"), 1, "Yellow is Gen 1") T.eq(GameVersion.generation("yellow"), 1, "Yellow is Gen 1")
T.eq(GameVersion.generation("gold"), 2, "Gold is Gen 2") T.eq(GameVersion.generation("gold"), 2, "Gold is Gen 2")
T.eq(GameVersion.generation("silver"), 2, "Silver is Gen 2")
-- ------- 2. manifest: gen2compat is opt-in and defaults off -- ------- 2. manifest: gen2compat is opt-in and defaults off
+45 -34
View File
@@ -39,45 +39,54 @@ end
local edited = 0 local edited = 0
local hooks = { editTouchControls = function() edited = edited + 1 end } local hooks = { editTouchControls = function() edited = edited + 1 end }
local gold = LauncherSettings.open(hooks, "gold") for _, version in ipairs({ "gold", "silver" }) do
check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD") local model = LauncherSettings.open(hooks, version)
check(has(gold, "VIBRATION"), "and VIBRATION") check(has(model, "TOUCH PAD"), version .. " gear offers TOUCH PAD")
check(has(gold, "TOUCH CONTROLS"), "and the layout editor") check(has(model, "VIBRATION"), version .. " and VIBRATION")
check(has(gold, "VOID FILL"), "and VOID FILL") check(has(model, "TOUCH CONTROLS"), version .. " and the layout editor")
check(has(model, "VOID FILL"), version .. " and VOID FILL")
local voidFill = findRow(gold, "VOID FILL") local voidFill = findRow(model, "VOID FILL")
eq(voidFill.value(), "FADE ", "VOID FILL defaults to FADE") eq(voidFill.value(), "FADE ", version .. " VOID FILL defaults to FADE")
voidFill.step(1) voidFill.step(1)
eq(gold.opts.gold.voidFill, "water", "right stores water in the gold block") eq(model.opts.gold.voidFill, "water",
eq(voidFill.value(), "WATER", "and the row reads WATER") version .. " right stores water in the gold block")
voidFill.step(-1) eq(voidFill.value(), "WATER", version .. " and the row reads WATER")
eq(gold.opts.gold.voidFill, "fade", "left restores fade") voidFill.step(-1)
eq(model.opts.gold.voidFill, "fade", version .. " left restores fade")
-- Every write has to land in the gold block: the flat keys beside it are -- Every write has to land in the gen2 block: the flat keys beside it are
-- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299). -- Red's, and no Gen 2 boot reads them (src/core/gen2/Save.lua:299).
-- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gold -- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gen 2
-- rows leave them exactly as they found them. -- rows leave them exactly as they found them.
local flatPad = gold.opts.touchControls local flatPad = model.opts.touchControls
local flatBuzz = gold.opts.haptics local flatBuzz = model.opts.haptics
local pad = findRow(gold, "TOUCH PAD") local pad = findRow(model, "TOUCH PAD")
local before = pad.value() local before = pad.value()
pad.step(1) pad.step(1)
check(pad.value() ~= before, "stepping TOUCH PAD flips it") check(pad.value() ~= before, version .. " stepping TOUCH PAD flips it")
check(type(gold.opts.gold) == "table", "into the gold block") check(type(model.opts.gold) == "table", version .. " into the gold block")
eq(gold.opts.gold.touchControls.enabled, false, "which now carries enabled") eq(model.opts.gold.touchControls.enabled, false,
eq(gold.opts.touchControls, flatPad, "leaving the flat Gen 1 key alone") version .. " which now carries enabled")
eq(model.opts.touchControls, flatPad,
version .. " leaving the flat Gen 1 key alone")
eq(model.opts.silver, nil,
version .. " and inventing no second Gen 2 block beside it")
local buzz = findRow(gold, "VIBRATION") local buzz = findRow(model, "VIBRATION")
local buzzBefore = buzz.value() local buzzBefore = buzz.value()
buzz.step(1) buzz.step(1)
check(buzz.value() ~= buzzBefore, "stepping VIBRATION moves the level") check(buzz.value() ~= buzzBefore, version .. " stepping VIBRATION moves the level")
eq(gold.opts.gold.haptics, TouchControls.normalizeHaptics(gold.opts.gold.haptics), eq(model.opts.gold.haptics,
"VIBRATION stores a level the shared module knows") TouchControls.normalizeHaptics(model.opts.gold.haptics),
eq(gold.opts.haptics, flatBuzz, "also without touching Red's") version .. " VIBRATION stores a level the shared module knows")
eq(model.opts.haptics, flatBuzz, version .. " also without touching Red's")
findRow(gold, "TOUCH CONTROLS").action() edited = 0
eq(edited, 1, "the editor row reaches the host hook") findRow(model, "TOUCH CONTROLS").action()
eq(edited, 1, version .. " the editor row reaches the host hook")
end
-- The Gen 1 gear is untouched by the extraction: same three rows, still on -- The Gen 1 gear is untouched by the extraction: same three rows, still on
-- the flat table. -- the flat table.
@@ -94,6 +103,8 @@ eq(findRow(red, "TOUCH PAD").value() ~= nil, true, "and reading it back")
-- rather than dead, on both sides. -- rather than dead, on both sides.
eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false, eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false,
"no hook, no editor row on Gold") "no hook, no editor row on Gold")
eq(has(LauncherSettings.open(nil, "silver"), "TOUCH CONTROLS"), false,
"nor on Silver")
eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false, eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false,
"nor on Red") "nor on Red")
-- The Edit row hands the screen to the host, and the host has to know WHICH -- The Edit row hands the screen to the host, and the host has to know WHICH
+123 -38
View File
@@ -155,28 +155,34 @@ modImp.mods = mods
modImp._ensureMods = function() return mods end modImp._ensureMods = function() return mods end
LauncherView.draw(modImp) LauncherView.draw(modImp)
LauncherView.draw(modImp) LauncherView.draw(modImp)
check((modImp._modScrollMax or 0) > 0, local reg = modImp._tabRegionRect
"60 mods overflow the list viewport inside the panel") check((modImp._tabScrollMax.mods or 0) > reg.h,
local list = modImp._modListRect "60 installed mods are ONE continuous list: the region's travel spans "
check(list.x + list.w .. "the whole list, not one page of it")
<= modImp._tabRegionRect.x + modImp._tabRegionRect.w - Kit.scrollBarW(), eq(modImp._pages.mods, nil, "and no page state is ever minted for it")
"the rows stop short of the region's scrollbar gutter") pointer(reg.x + 10, reg.y + 40)
pointer(list.x + 10, list.y + 10)
modImp._wheelY = -1 modImp._wheelY = -1
LauncherView.draw(modImp) LauncherView.draw(modImp)
check((modImp.modScroll or 0) > 0, "a notch over the mod list scrolls the list") check((modImp._tabScroll.mods or 0) > 0,
eq(modImp._tabScroll.mods or 0, 0, "not the panel region around it") "a notch over the region scrolls the list like any other tab")
eq(modImp._pageScroll, 0, "and not the page behind that") eq(modImp._pageScroll, 0, "without reaching the page behind it")
modImp._tabScroll.mods = modImp._tabScrollMax.mods
LauncherView.draw(modImp)
pointer(reg.x + 10, reg.y + reg.h * 0.5)
modImp._wheelY = -1
LauncherView.draw(modImp)
eq(modImp._pages.mods, nil,
"bottoming the list out never auto-advances any pager")
eq(modImp._tabScroll.mods, modImp._tabScrollMax.mods,
"the list just rests at its end")
modImp._modActions = "mod1" modImp._modActions = "mod1"
local shielded = modImp.modScroll local shieldedAt = modImp._tabScroll.mods
local shieldedPage = modImp._pageScroll
pointer(list.x + 10, list.y + 10)
modImp._wheelY = -1 modImp._wheelY = -1
LauncherView.draw(modImp) LauncherView.draw(modImp)
eq(modImp.modScroll, shielded, "a shielded mod list ignores the notch") eq(modImp._tabScroll.mods, shieldedAt,
eq(modImp._tabScroll.mods or 0, 0, "and so does the region under the scrim") "a shielded mod list ignores the notch under the scrim")
eq(modImp._pageScroll, shieldedPage, "and the page behind that")
modImp._modActions = nil modImp._modActions = nil
modImp._wheelY = 0 modImp._wheelY = 0
LauncherView.draw(modImp) LauncherView.draw(modImp)
@@ -225,32 +231,27 @@ dragMods.mods = mods
dragMods._ensureMods = function() return mods end dragMods._ensureMods = function() return mods end
LauncherView.draw(dragMods) LauncherView.draw(dragMods)
LauncherView.draw(dragMods) LauncherView.draw(dragMods)
local dlist = dragMods._modListRect local dreg = dragMods._tabRegionRect
local dListMax = dragMods._modScrollMax
local dRegionMax = dragMods._tabScrollMax.mods local dRegionMax = dragMods._tabScrollMax.mods
check(dListMax > 0 and dRegionMax > 0, check(dRegionMax > 0, "the mods region scrolls its overscan like any tab")
"the mods tab has both an inner list and a region to scroll") LauncherView.touchpressed(dragMods, 7, dreg.x + 20, dreg.y + 30)
LauncherView.touchpressed(dragMods, 7, dlist.x + 20, dlist.y + 30) LauncherView.touchmoved(dragMods, 7, dreg.x + 20, dreg.y + 30 - 60)
LauncherView.touchmoved(dragMods, 7, dlist.x + 20, dlist.y + 30 - 60) eq(dragMods._tabScroll.mods, math.min(60, dRegionMax),
eq(dragMods.modScroll, math.min(60, dListMax), "a drag moves the region by the finger's travel")
"the first pixels of the drag move the list") LauncherView.touchmoved(dragMods, 7, dreg.x + 20,
eq(dragMods._tabScroll.mods or 0, 0, "and nothing else") dreg.y + 30 - 60 - dRegionMax * 2)
LauncherView.touchmoved(dragMods, 7, dlist.x + 20, eq(dragMods._tabScroll.mods, dRegionMax, "carrying on saturates the region")
dlist.y + 30 - 60 - dListMax - dRegionMax * 2)
eq(dragMods.modScroll, dListMax, "carrying on saturates the list")
eq(dragMods._tabScroll.mods, dRegionMax,
"then the same gesture walks the region to its bottom")
check((dragMods._pageScroll or 0) > 0, "and only then reaches the page") check((dragMods._pageScroll or 0) > 0, "and only then reaches the page")
LauncherView.touchreleased(dragMods, 7, dlist.x + 20, dlist.y - 900) LauncherView.touchreleased(dragMods, 7, dreg.x + 20, dreg.y - 900)
dragMods._skins = { { id = "s1", source = "user", controls = 8, pages = 1 } } dragMods._skins = { { id = "s1", source = "user", controls = 8, pages = 1 } }
for i = 2, 12 do for i = 2, 12 do
dragMods._skins[i] = { id = "s" .. i, source = "user", controls = 8, pages = 1 } dragMods._skins[i] = { id = "s" .. i, source = "user", controls = 8, pages = 1 }
end end
dragMods._ensureSkins = function() return dragMods._skins end dragMods._ensureSkins = function() return dragMods._skins end
dragMods.modScroll = 0 local heldModsPage = dragMods._pages.mods or 1
local heldModScroll = dragMods.modScroll local heldModsAt = dragMods._tabScroll.mods
local overList = dlist.y + 30 local overList = dreg.y + 60
dragMods:_switchTab("skins") dragMods:_switchTab("skins")
LauncherView.draw(dragMods) LauncherView.draw(dragMods)
LauncherView.draw(dragMods) LauncherView.draw(dragMods)
@@ -260,8 +261,10 @@ LauncherView.touchpressed(dragMods, 9, sreg.x + 20, overList)
LauncherView.touchmoved(dragMods, 9, sreg.x + 20, overList - 200) LauncherView.touchmoved(dragMods, 9, sreg.x + 20, overList - 200)
check((dragMods._tabScroll.skins or 0) > 0, check((dragMods._tabScroll.skins or 0) > 0,
"a drag on the skins tab scrolls the skins tab") "a drag on the skins tab scrolls the skins tab")
eq(dragMods.modScroll, heldModScroll, eq(dragMods._pages.mods or 1, heldModsPage,
"and leaves the mod list where the player parked it") "and leaves the mod list on the page the player parked it")
eq(dragMods._tabScroll.mods, heldModsAt,
"with its region offset held for the return trip")
LauncherView.touchreleased(dragMods, 9, sreg.x + 20, sreg.y - 400) LauncherView.touchreleased(dragMods, 9, sreg.x + 20, sreg.y - 400)
love.graphics.polygon = love.graphics.polygon or function() end love.graphics.polygon = love.graphics.polygon or function() end
@@ -297,6 +300,88 @@ LauncherView.draw(edgeImp)
check((edgeImp._tabScroll.skins or 0) > 0, check((edgeImp._tabScroll.skins or 0) > 0,
"which reaches the tab region even though the cursor is below it") "which reaches the tab region even though the cursor is below it")
Kit.blockClicks = false
Kit._clipRect = nil
Kit.wheelY = 0
Kit.mouseX, Kit.mouseY = 400, 400
Kit.dragBegin(50, 50)
Kit.dragAdd(120)
local pg = Kit.wheelPage(0, 0, 100, 100, 1, 100, 10)
eq(pg, 3, "a drag that crossed two half-heights turns two pages")
eq(Kit.dragAccum, 20, "and keeps the remainder for the next flip")
Kit.dragAdd(-140)
pg = Kit.wheelPage(0, 0, 100, 100, pg, 100, 10)
eq(pg, 1, "dragging back down returns those pages")
eq(Kit.dragAccum, -20, "with the remainder's sign preserved")
Kit.dragAdd(-80)
pg = Kit.wheelPage(0, 0, 100, 100, pg, 100, 10)
eq(pg, 1, "a drag past the first page clamps to it")
eq(Kit.dragAccum, 0, "and drops the pile-up so reversing is instant")
Kit.dragAdd(200)
pg = Kit.wheelPage(200, 200, 100, 100, pg, 100, 10)
eq(pg, 1, "a drag that began outside the list is not the list's")
eq(Kit.dragAccum, 200, "and its travel stays queued")
Kit.dragEnd()
eq(Kit.dragAccum, 0, "releasing the button retires the gesture")
window(360, 780)
local mouseImp = skinLauncher(12)
LauncherView.draw(mouseImp)
LauncherView.draw(mouseImp)
local mreg = mouseImp._tabRegionRect
local mmax = mouseImp._tabScrollMax.skins
check(mmax > 0, "the mouse-dragged panel has travel")
local mdown = false
love.mouse.isDown = function() return mdown end
pointer(mreg.x + 20, mreg.y + 40)
mdown = true
LauncherView.update(mouseImp, 0.016)
check(mouseImp._clickPt == nil,
"a press over a scrollable region mints no click")
check(mouseImp._mouseAt ~= nil, "it arms a drag instead")
pointer(mreg.x + 20, mreg.y + 40 - 200)
LauncherView.update(mouseImp, 0.016)
eq(mouseImp._tabScroll.skins, math.min(200, mmax),
"dragging the held mouse scrolls the panel by its travel")
eq(mouseImp._pageScroll or 0, 0,
"while the panel still has travel, the page waits")
pointer(mreg.x + 20, mreg.y + 40 - 200 - mmax * 2)
LauncherView.update(mouseImp, 0.016)
eq(mouseImp._tabScroll.skins, mmax,
"a longer mouse drag reaches the panel's bottom")
check((mouseImp._pageScroll or 0) > 0, "and spills into the page from there")
mdown = false
LauncherView.update(mouseImp, 0.016)
check(mouseImp._clickPt == nil, "a released drag is not a click")
check(mouseImp._mouseAt == nil, "and the gesture is retired")
pointer(mreg.x + 20, mreg.y + 40)
mdown = true
LauncherView.update(mouseImp, 0.016)
check(mouseImp._clickPt == nil, "a fresh press still holds its click back")
mdown = false
LauncherView.update(mouseImp, 0.016)
check(mouseImp._clickPt ~= nil,
"press and release without travel is still a tap, on release")
mouseImp._clickPt = nil
LauncherView.draw(gameImp)
LauncherView.draw(gameImp)
check((gameImp._noDragN or 0) > 0, "the game tab publishes its cartridge rect")
local cart = gameImp._noDragRects[1]
pointer(cart.x + cart.w / 2, cart.y + cart.h / 2)
mdown = true
LauncherView.update(gameImp, 0.016)
check(gameImp._clickPt ~= nil,
"a press on the cartridge clicks at once so its own spin-drag still owns "
.. "the gesture")
check(gameImp._mouseAt == nil, "and never arms the scroll drag")
gameImp._clickPt = nil
mdown = false
LauncherView.update(gameImp, 0.016)
love.mouse.isDown = nil
local function read(path) local function read(path)
local f = assert(io.open(path, "r")) local f = assert(io.open(path, "r"))
local src = f:read("*a") local src = f:read("*a")
@@ -308,8 +393,8 @@ local view = read("src/import/LauncherView.lua")
check(view:find("Kit.scrollBegin(", 1, true) ~= nil, check(view:find("Kit.scrollBegin(", 1, true) ~= nil,
"the panel dispatch opens a scroll region") "the panel dispatch opens a scroll region")
check(view:find("Kit.scrollEnd(", 1, true) ~= nil, "and closes it") check(view:find("Kit.scrollEnd(", 1, true) ~= nil, "and closes it")
check(view:find("modListWantsWheel", 1, true) ~= nil, check(view:find("modListWantsWheel", 1, true) == nil,
"the nested mod list is asked before the region takes a notch") "no nested list steals the wheel from the region any more")
check(view:find("start.region", 1, true) ~= nil, check(view:find("start.region", 1, true) ~= nil,
"a touch drag that began in the region scrolls the region") "a touch drag that began in the region scrolls the region")
check(view:find("Kit.scrollGutter(", 1, true) ~= nil, check(view:find("Kit.scrollGutter(", 1, true) ~= nil,
+12 -8
View File
@@ -32,11 +32,13 @@ do
"a version id names exactly that game") "a version id names exactly that game")
eq(table.concat(ModTargets.expand("GEN1"), ","), "red,blue,yellow", eq(table.concat(ModTargets.expand("GEN1"), ","), "red,blue,yellow",
"gen1 is every Gen 1 game, case-insensitive") "gen1 is every Gen 1 game, case-insensitive")
eq(table.concat(ModTargets.expand("gen2"), ","), "gold", eq(table.concat(ModTargets.expand("gen2"), ","), "gold,silver",
"gen2 is every Gen 2 game") "gen2 is every Gen 2 game")
eq(table.concat(ModTargets.expand("silver"), ","), "silver",
"and each of them names itself")
eq(table.concat(ModTargets.expand("all"), ","), eq(table.concat(ModTargets.expand("all"), ","),
table.concat(GameVersion.ORDER, ","), "all is the launcher order itself") table.concat(GameVersion.ORDER, ","), "all is the launcher order itself")
eq(ModTargets.expand("silver"), nil, "a game this engine has no cache for") eq(ModTargets.expand("crystal"), nil, "a game this engine has no cache for")
eq(ModTargets.expand("gen9"), nil, "a generation with no games is unknown") eq(ModTargets.expand("gen9"), nil, "a generation with no games is unknown")
eq(ModTargets.expand(7), nil, "a non-string token is not a game") eq(ModTargets.expand(7), nil, "a non-string token is not a game")
end end
@@ -56,7 +58,7 @@ end
do do
eq(list(mf({})), "red,blue,yellow", eq(list(mf({})), "red,blue,yellow",
"a manifest with no games key is Gen 1, which is what it was tested as") "a manifest with no games key is Gen 1, which is what it was tested as")
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold", eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold,silver",
"gen2compat keeps Gen 1 and adds Gen 2") "gen2compat keeps Gen 1 and adds Gen 2")
eq(mf({}).gen2compat, false, "and the derived flag agrees") eq(mf({}).gen2compat, false, "and the derived flag agrees")
eq(mf({ gen2compat = true }).gen2compat, true, "both ways") eq(mf({ gen2compat = true }).gen2compat, true, "both ways")
@@ -67,14 +69,14 @@ end
do do
local gen2 = mf({ games = { "gen2" } }) local gen2 = mf({ games = { "gen2" } })
eq(list(gen2), "gold", "games can name Gen 2 alone") eq(list(gen2), "gold,silver", "games can name Gen 2 alone")
eq(gen2.gen2compat, true, "which IS the gen2compat claim the gate reads") eq(gen2.gen2compat, true, "which IS the gen2compat claim the gate reads")
local both = mf({ games = { "gen1", "gen2" } }) local both = mf({ games = { "gen1", "gen2" } })
eq(list(both), "red,blue,yellow,gold", "or both generations") eq(list(both), "red,blue,yellow,gold,silver", "or both generations")
local one = mf({ games = { "blue" } }) local one = mf({ games = { "blue" } })
eq(list(one), "blue", "or one single game") eq(list(one), "blue", "or one single game")
eq(one.gen2compat, false, "a Gen 1 game is not a Gen 2 claim") eq(one.gen2compat, false, "a Gen 1 game is not a Gen 2 claim")
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold", eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold,silver",
"an old gen2compat beside a new games list still adds its game") "an old gen2compat beside a new games list still adds its game")
end end
@@ -224,10 +226,12 @@ do
local bad = ModProfile.decode(require("src.core.SaveSerializer").encode({ local bad = ModProfile.decode(require("src.core.SaveSerializer").encode({
format = "g1rmodlist", formatVersion = 1, format = "g1rmodlist", formatVersion = 1,
profile = { name = "P", enabledByVersion = { profile = { name = "P", enabledByVersion = {
gold = { a = true }, silver = { a = true }, red = "nope" } }, gold = { a = true }, silver = { a = true }, crystal = { a = true },
red = "nope" } },
})) }))
eq(bad.enabledByVersion.gold.a, true, "a shared file's known game is kept") eq(bad.enabledByVersion.gold.a, true, "a shared file's known game is kept")
eq(bad.enabledByVersion.silver, nil, "an unknown game is dropped on read") eq(bad.enabledByVersion.silver.a, true, "every one of them, not just the first")
eq(bad.enabledByVersion.crystal, nil, "an unknown game is dropped on read")
eq(bad.enabledByVersion.red, nil, "and so is a bucket that is not a table") eq(bad.enabledByVersion.red, nil, "and so is a bucket that is not a table")
end end
@@ -0,0 +1,139 @@
-- Two OverworldController.lua messages used to be bare Lua literals:
-- applyFieldPoison()'s "%s\nfainted!" (the third of three collapsed
-- fainted-message ROM strings, _PokemonFaintedText) and
-- useSoftboiledFieldMove()'s "It won't have\nany effect."/"%s's HP\nwas
-- restored!" (the same _ItemUseNoEffectText/_PotionText labels
-- ItemEffects.lua's real potion message already uses -- _PotionText's
-- second slot is the actual amount healed, which the old literal never
-- showed at all).
--
-- Uses the debug.setupvalue technique already established in
-- oaks_pc_flow.lua to fake the module-level Game/TextBox upvalues
-- ROM-free, without going through the heavy OverworldState:enter().
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed = {}
local textBoxStub = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
}
local realSound = package.loaded["src.core.Sound"]
package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end }
local function mkGame()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
pushed = {}
return {
data = Data, save = save,
stack = { push = function(_, item) pushed[#pushed + 1] = item end },
}
end
for _, name in ipairs({ "applyFieldPoison", "useSoftboiledFieldMove" }) do
T.check(setUpvalue(OW[name], "Game", mkGame()), ("Game upvalue on %s"):format(name))
T.check(setUpvalue(OW[name], "TextBox", textBoxStub), ("TextBox upvalue on %s"):format(name))
end
local fakeSelf = setmetatable({}, { __index = OW })
-- ---- applyFieldPoison: _PokemonFaintedText ----
do
local game = mkGame()
setUpvalue(OW.applyFieldPoison, "Game", game)
local mon = game.save.party[1]
mon.status = "PSN"
mon.hp = 1 -- one poison tick (1 dmg by default) faints it
game.save.poisonSteps = 3 -- (3+1) % 4 == 0: this step ticks poison
Data.text._PokemonFaintedText = "FAKE {RAM:wNameBuffer} FAKE!"
fakeSelf:applyFieldPoison()
T.eq(pushed[1] and pushed[1].text, "FAKE " .. (mon.nickname or "FIXMON A") .. " FAKE!",
"a translated _PokemonFaintedText reaches the field-poison faint message")
Data.text._PokemonFaintedText = nil
end
-- vanilla: no catalog entry, English literal
do
local game = mkGame()
setUpvalue(OW.applyFieldPoison, "Game", game)
local mon = game.save.party[1]
mon.status = "PSN"
mon.hp = 1
game.save.poisonSteps = 3
fakeSelf:applyFieldPoison()
T.eq(pushed[1] and pushed[1].text,
(mon.nickname or "FIXMON A") .. "\nfainted!",
"no catalog entry falls back to the English fainted literal")
end
-- ---- useSoftboiledFieldMove: _ItemUseNoEffectText / _PotionText ----
do
local game = mkGame()
setUpvalue(OW.useSoftboiledFieldMove, "Game", game)
local user = Pokemon.new(Data, "FIXMON_A", 20)
local target = Pokemon.new(Data, "FIXMON_B", 20)
target.hp = target.stats.hp -- already full: no effect
Data.text._ItemUseNoEffectText = "FAKE-NOEFFECT!"
local ok = fakeSelf:useSoftboiledFieldMove(user, target)
T.check(ok == false, "a full-HP target reports no effect")
T.eq(pushed[1] and pushed[1].text, "FAKE-NOEFFECT!",
"a translated _ItemUseNoEffectText reaches the no-effect message")
Data.text._ItemUseNoEffectText = nil
end
do
local game = mkGame()
setUpvalue(OW.useSoftboiledFieldMove, "Game", game)
local user = Pokemon.new(Data, "FIXMON_A", 20)
local target = Pokemon.new(Data, "FIXMON_B", 20)
target.hp = target.stats.hp - 10 -- missing exactly 10 HP
Data.text._PotionText = "FAKE {RAM:wNameBuffer} healed {NUM:wHPBarHPDifference, 2, 3}!"
local ok = fakeSelf:useSoftboiledFieldMove(user, target)
T.check(ok == true, "a damaged target heals successfully")
T.eq(pushed[1] and pushed[1].text,
"FAKE " .. (target.nickname or "FIXMON B") .. " healed 10!",
"a translated _PotionText reaches the heal message, amount included")
Data.text._PotionText = nil
end
-- vanilla: no catalog entry, the fallback's single %s slot still fills
-- correctly (the amount is silently dropped by design, same as
-- ItemEffects.lua's own _PotionText fallback -- not a regression, this
-- matches the pre-fix literal's behavior exactly)
do
local game = mkGame()
setUpvalue(OW.useSoftboiledFieldMove, "Game", game)
local user = Pokemon.new(Data, "FIXMON_A", 20)
local target = Pokemon.new(Data, "FIXMON_B", 20)
target.hp = target.stats.hp - 10
local ok = fakeSelf:useSoftboiledFieldMove(user, target)
T.check(ok == true, "a damaged target heals successfully (vanilla)")
T.eq(pushed[1] and pushed[1].text,
(target.nickname or "FIXMON B") .. "'s HP\nwas restored!",
"no catalog entry falls back to the English literal (no amount shown)")
end
package.loaded["src.core.Sound"] = realSound
T.finish("overworld_field_faint_heal_romtext")
@@ -0,0 +1,85 @@
-- OverworldState:tryHiddenObject()'s "%s found\n%s!" message used to
-- substitute both the player name and the item name into one bare Lua
-- literal. The real _FoundHiddenItemText label leads with a {PLAYER}
-- named token, which romText auto-fills from a 2-arg call in the same
-- order the literal already used -- this test checks both slots land
-- correctly (an accidental argument swap is the easy mistake this shape
-- invites).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed = {}
local textBoxStub = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
soundOpts = function() return {} end,
}
local MAP_ID = "FIX_TOWN"
Data.field.hiddenItems[MAP_ID] = { { x = 3, y = 3, item = "FIX_BALL" } }
local function mkGame()
local save = SaveData.newGame()
save.player.name = "FAKEPLAYER"
pushed = {}
return {
data = Data, save = save,
stack = { push = function(_, item) pushed[#pushed + 1] = item end },
}
end
T.check(setUpvalue(OW.tryHiddenObject, "Game", mkGame()), "Game upvalue on tryHiddenObject")
T.check(setUpvalue(OW.tryHiddenObject, "TextBox", textBoxStub), "TextBox upvalue on tryHiddenObject")
local fakeSelf = setmetatable({ map = { id = MAP_ID } }, { __index = OW })
-- translated: player name and item name both land in the right slots
do
local game = mkGame()
setUpvalue(OW.tryHiddenObject, "Game", game)
Data.text._FoundHiddenItemText = "FAKE {PLAYER} found FAKE {RAM:wNameBuffer} FAKE!"
local found = fakeSelf:tryHiddenObject(3, 3)
T.check(found == true, "the hidden item at (3,3) is found")
T.eq(pushed[1] and pushed[1].text,
"FAKE FAKEPLAYER found FAKE FIX BALL FAKE!",
"a translated _FoundHiddenItemText fills both {PLAYER} and the item name")
Data.text._FoundHiddenItemText = nil
end
-- vanilla: no catalog entry, so romText falls back to plain
-- Strings(fallback, ...) -- the fallback literal is "%s found\n%s!" (both
-- slots plain %s, matching the pre-fix literal's own shape), not the
-- {PLAYER} token the real label uses, since Strings() never does
-- {TOKEN} substitution on its own. (A {PLAYER}-token fallback would
-- still render correctly too, since the real TextBox.new always runs
-- TextBox.substitute over whatever text it's given -- but this test
-- stubs TextBox without that call, and the fallback shouldn't lean on a
-- substitution pass happening downstream regardless.)
do
local game = mkGame()
setUpvalue(OW.tryHiddenObject, "Game", game)
game.save.hiddenTaken = {} -- fresh spot
local found = fakeSelf:tryHiddenObject(3, 3)
T.check(found == true, "the hidden item is found again in a fresh game")
T.eq(pushed[1] and pushed[1].text, "FAKEPLAYER found\nFIX BALL!",
"with no catalog entry, the fallback still fills both the player "
.. "and item name via plain %s substitution")
end
T.finish("overworld_hidden_item_romtext")
@@ -59,7 +59,7 @@ package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter") RomImporter = require("src.import.RomImporter")
local function clearSavesInbox() local function clearSavesInbox()
for _, ver in ipairs({ "red", "blue", "yellow", "gold" }) do for _, ver in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
local dir = "imports/saves/" .. ver local dir = "imports/saves/" .. ver
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
love.filesystem.remove(dir .. "/" .. name) love.filesystem.remove(dir .. "/" .. name)
@@ -137,6 +137,8 @@ check(createdDirs["imports/saves/yellow"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/yellow/") "RES-01: ensureSavesInboxDir creates imports/saves/yellow/")
check(createdDirs["imports/saves/gold"] == true, check(createdDirs["imports/saves/gold"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/gold/") "RES-01: ensureSavesInboxDir creates imports/saves/gold/")
check(createdDirs["imports/saves/silver"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/silver/")
-- NXSAV-02: notice/hint includes save dir + per-game imports/saves/<version>/ MTP path -- NXSAV-02: notice/hint includes save dir + per-game imports/saves/<version>/ MTP path
ri = freshImporter() ri = freshImporter()
+113
View File
@@ -0,0 +1,113 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local ScreenPosition = require("src.core.ScreenPosition")
local TouchSkin = require("src.core.TouchSkin")
local Renderer = require("src.render.Renderer")
local Chrome = require("src.ui.gen2.Chrome")
local Zoom = require("src.render.Zoom")
eq(ScreenPosition.normalize(nil), "center", "no setting is CENTER")
eq(ScreenPosition.normalize("junk"), "center", "garbage degrades to CENTER")
eq(ScreenPosition.normalize("top"), "top", "top passes through")
eq(ScreenPosition.label("upper"), "UPPER", "upper reads UPPER")
local seen, v = {}, "center"
for _ = 1, 3 do
seen[#seen + 1] = ScreenPosition.label(v)
v = ScreenPosition.cycle(v, 1)
end
eq(table.concat(seen, ","), "CENTER,UPPER,TOP", "the row cycles CENTER,UPPER,TOP")
eq(ScreenPosition.cycle("top", 1), "center", "and wraps back to CENTER")
eq(ScreenPosition.cycle("center", -1), "top", "stepping back lands on TOP")
ScreenPosition.setMode("center")
eq(ScreenPosition.lift(640, 288), 0, "CENTER never lifts")
ScreenPosition.setMode("top")
eq(ScreenPosition.lift(640, 288), 176, "TOP lifts the centered origin to 0")
eq(ScreenPosition.lift(288, 288), 0, "no slack, no lift")
eq(ScreenPosition.lift(144, 288), 0, "negative slack, no lift")
eq(ScreenPosition.lift(640, 288, 40), 136, "TOP stops at the safe-area inset")
eq(ScreenPosition.lift(640, 288, 999), 0,
"a safe inset past center degrades to centered")
ScreenPosition.setMode("upper")
eq(ScreenPosition.lift(640, 288), 88, "UPPER lands halfway between")
eq(ScreenPosition.lift(640, 288, 40), 88, "a small inset leaves UPPER alone")
eq(ScreenPosition.lift(640, 288, 120), 56, "a large inset pushes UPPER down")
ScreenPosition.applyOptions({ screenPos = "top" })
eq(ScreenPosition.mode, "top", "applyOptions takes the stored key")
ScreenPosition.applyOptions(nil)
eq(ScreenPosition.mode, "center", "applyOptions without options is CENTER")
local function setWindow(w, h)
love.graphics.getDimensions = function() return w, h end
love.graphics.getPixelDimensions = function() return w, h end
end
setWindow(360, 640)
TouchSkin.setActive(nil)
Renderer:init()
Zoom.offset = 0
ScreenPosition.setMode("center")
local r = Renderer:frameRects()
eq(r.Sp, 2, "360x640 fits two whole GB pixels")
eq(r.lift, 0, "CENTER: no lift")
eq(r.oy, 176, "CENTER: the letterbox is centered")
local _, vhCenter = Renderer:worldViewSize()
ScreenPosition.setMode("top")
r = Renderer:frameRects()
eq(r.lift, 176, "TOP: the full centered slack lifts away")
eq(r.oy, 0, "TOP: the letterbox sits at the top edge")
eq(r.uoy, 0, "TOP: the UI letterbox follows")
eq(r.ox, math.floor((360 - 320) / 2), "TOP: horizontal centering is untouched")
local _, vhTop = Renderer:worldViewSize()
eq(vhTop, vhCenter + 2 * math.ceil(176 / 2),
"TOP: the world canvas grows to keep the bottom covered")
ScreenPosition.setMode("upper")
r = Renderer:frameRects()
eq(r.oy, 88, "UPPER: the letterbox centers in the upper half")
ScreenPosition.setMode("top")
local ox, oy = Chrome.fitOrigin(360, 640)
eq(oy, 0, "TOP: Gold's letterbox sits at the top edge")
eq(ox, math.floor((360 - 320) / 2), "TOP: Gold's horizontal centering is untouched")
ScreenPosition.setMode("center")
local _, cy = Chrome.fitOrigin(360, 640)
eq(cy, 176, "CENTER: Gold's letterbox is centered")
ScreenPosition.setMode("top")
local skin = assert(TouchSkin.parse([[
overlays = 1
overlay0_name = "bezel"
overlay0_full_screen = true
overlay0_normalized = true
overlay0_viewport = "0.1,0.1,0.8,0.4"
overlay0_descs = 1
overlay0_desc0 = "nul,0.5,0.5,rect,0.02,0.02"
]]))
TouchSkin.setActive(skin)
TouchSkin.setOverlayLive(false)
r = Renderer:frameRects()
eq(r.cut, true, "the skin viewport cuts the playfield")
eq(r.lift, 0, "a skin viewport disables the lift")
eq(select(1, ScreenPosition.skinActive(360, 640)), true,
"skinActive sees the viewport")
local _, sy = Chrome.fitOrigin(360, 640)
local _, cy2 = (function()
ScreenPosition.setMode("center")
return Chrome.fitOrigin(360, 640)
end)()
eq(sy, cy2, "with a skin the Gold origin ignores the mode")
TouchSkin.setActive(nil)
ScreenPosition.setMode("center")
T.finish("screen_position")
@@ -0,0 +1,39 @@
-- SlotMachine:resolveWin's "%s lined up!\nScored %d coins!" message used
-- to interpolate the symbol id AND the payout into one bare Lua literal.
-- The real _LinedUpText label has no slot for the symbol at all (the
-- original ROM drew it separately) -- only for the coin count. This test
-- fakes _LinedUpText and checks the symbol is concatenated in front of the
-- translated suffix, with the payout correctly substituted into it.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local SlotMachine = require("src.ui.SlotMachine")
local function mkSelf()
local game = { data = Data, save = { coins = 0 } }
return setmetatable({ game = game, allowMatchesCounter = 0 }, SlotMachine)
end
-- translated: the fake suffix reaches self.message, with the symbol
-- concatenated in front and the payout substituted into the fake text
do
local self = mkSelf()
Data.text._LinedUpText = " FAKE-SUFFIX {RAM:wStringBuffer}!"
self:resolveWin({ symbol = "CHERRY", payout = 8 })
T.eq(self.message, "CHERRY FAKE-SUFFIX 8!",
"a translated _LinedUpText reaches the lined-up message")
Data.text._LinedUpText = nil
end
-- vanilla: with no catalog entry, the English literal still substitutes,
-- with its own leading space (the original had no slot for the symbol)
do
local self = mkSelf()
self:resolveWin({ symbol = "CHERRY", payout = 8 })
T.eq(self.message, "CHERRY lined up!\nScored 8 coins!",
"no catalog entry still falls back to the English literal")
end
T.finish("slot_machine_lined_up_romtext")

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