Compare commits

...

59 Commits

Author SHA1 Message Date
bryanthaboi d54f9a02e0 Merge pull request #1680 from colsonrice/feat/gen2-rom-text 2026-08-22 11:08:18 -04:00
Colson Rice e094b73536 Give Gen 2 the ROM text table Gen 1 has had all along
Gold and Silver had no label-keyed string table at all.  The manifests
carried no text section, RomExtractorGen2 had no extractText, and
game.data.text was never assigned, so every call through
src/core/RomText.lua fell back to the literal written beside it.  The only
Gen 2 text the cache held was the script text in data/generated/text.lua,
keyed by bank:address for the overworld VM, which nothing can look a battle
line up in.

make_gold_manifest.py now walks data/text/'s five dialogue files for their
labels, the way make_rom_manifest.text_metadata walks pokered's, and embeds
each one's symbol.  889 labels, all of them resolving in both editions.
make_silver_manifest.py inherits the list unchanged and re-resolves the
addresses from pokesilver.sym.

RomExtractorGen2:extractText decodes them into data/generated/rom_text.lua.
The mechanism is the one extractOakSpeech already used for _OakText1-7:
resolve the label, decode from the cart, key by name.  What is new is that
the list comes from the manifest rather than being written out in Lua, so
all of data/text/ arrives instead of seven strings.

decodeGen2Text also emits the three runtime name slots it used to drop.
PlaceMoveUsersName, PlaceMoveTargetsName and PlaceEnemysName (home/text.asm)
substitute a battler's name as the line prints, so <USER>, <TARGET> and
<ENEMY> are markers, not glyphs.  Skipped as control glyphs, SubTookDamageText
decoded as "The SUBSTITUTE / took damage for" with nothing after it.

Game2:load assigns the table to self.data.text, which is what makes the
existing shared RomText helper work on Gold and Silver at all.

The new cache file is listed in the Gold override rather than bumping
CACHE_FORMAT, so caches built before this stage re-import themselves and Red,
Blue and Yellow are left alone.
2026-08-21 23:08:26 -04:00
bryanthaboi 478e3bf8eb Merge pull request #1660 from jramiresbrito/fix/gen2-exp-award-announce 2026-08-21 22:35:43 -04:00
bryanthaboi f009fc856c Merge pull request #1678 from colsonrice/fix/choose-rom-imports-the-chosen-version 2026-08-21 22:31:35 -04:00
bryanthaboi 3abc6ceb15 Merge pull request #1673 from colsonrice/fix/gen2-battle-text-matches-cart 2026-08-21 22:28:42 -04:00
bryanthaboi b93d070abc Merge pull request #1627 from AverageConsumer/codex/android-full-apk-updates 2026-08-21 22:24:44 -04:00
Colson Rice 921f565f1d Import the cart Choose ROM asked for
findPendingRom answered with the first dump in the save directory whose SHA-1
mapped to any not-yet-ready version.  On a device with no file picker that
scan IS the import, so with four dumps in the folder, choosing Red imported
and decoded Blue (#1274).

It now takes an optional version and narrows to it.  The two Choose paths
pass self.chooseVersion, since a Choose names the cart it is for.  The two
Android USB-drop scans pass nothing and still take the first pending cart of
any version, which is what they are for.

A chosen version with no dump present now imports nothing rather than the
wrong cart, and falls through to the notice that says where to put the file.
2026-08-21 22:21:41 -04:00
bryanthaboi 6e624724ca Fix the Gold SFX drivers' staging and wait budgets 2026-08-21 22:21:39 -04:00
bryanthaboi 51bc4c7437 Fix the Gold bump driver's staging so it actually measures the bump 2026-08-21 22:00:23 -04:00
bryanthaboi e13271fc85 a little more 2026-08-21 21:33:42 -04:00
bryanthaboi c23f82efdd Fix transition state pop, Gen 2 Bide/Sleep Talk/exp bugs, pp repair, TM pocket capacity and Bill's PC arrows
CLOSES #1663, CLOSES #1664, CLOSES #1665, CLOSES #1666, CLOSES #1667, CLOSES #1668, CLOSES #1670, CLOSES #1672
2026-08-21 21:21:59 -04:00
bryanthaboi 15fc04e067 Collision-check the remaining single-tile player refusal shoves
CLOSES #1548
2026-08-21 19:46:15 -04:00
Colson Rice 61f42cea2b Cover the Gen 2 battle lines against pokegold's own labels
Drives a real turn per case and compares the emitted line to the label in
data/text/battle.asm.  Six of the eleven checks fail against the text as it
stood.

The tail case wraps each changed line through Chrome.wrap at the box's own
width and holds it to the two rows printMessage draws.  That is the check
that caught SpikesText needing a third row, which is why Spikes is not in
this change.
2026-08-21 19:37:41 -04:00
Colson Rice 2c1e411e3c Print the Gen 2 battle lines the cart actually writes
Five messages in the Gen 2 battle code are written by hand rather than taken
from data/text/battle.asm, and each has drifted from what the game prints:

  SuperEffectiveText                     lost its hyphen and its line break
  NotVeryEffectiveText                   ended on three periods, not the
                                         ellipsis glyph the charmap carries
  BattleText_TheresNoPPLeftForThisMove   dropped "There's"
  PlayerHitTimesText/EnemyHitTimesText   printed "Hit 3 time(s)!", showing
                                         the parenthetical on screen; Gen 1
                                         already prints "Hit 3 times!" via
                                         _HitXTimesText
  StartPerishText                        printed a sentence no cart prints

The break is \n, which is what RomExtractorGen2 decodes the cart's own $4e
into, so these read as an extracted line would.  Each goes through Strings
now, which is what the rest of this file already does with its messages.

SpikesText is left alone and the reason is written down beside it: its third
row is a `cont`, and engine messages set self.message directly rather than
going through showPages, so printMessage would cut the row carrying <TARGET>.
2026-08-21 19:37:41 -04:00
bryanthaboi c0f4315cd8 Fix Gen 1/2 battle, menu, PC and audio parity gaps; wire luacheck into CI
CLOSES #1484, CLOSES #1486, CLOSES #1513, CLOSES #1517, CLOSES #1556, CLOSES #1564, CLOSES #1567
2026-08-21 19:22:34 -04:00
AverageConsumer 8c65e76145 fix(android): bootstrap legacy APK updater 2026-08-21 23:37:12 +02:00
jramiresbrito 5ec9ce5f88 feat(gen2): read applyShare's announce argument in battle.exp_award
battle.exp_award hands a mod ctx.applyShare(mon, split, announce) on both
generations, and on Gen 1 the third argument decides whether the mon's
GainedText box is printed -- which is how a mod paying the whole party
prints ONE summary line instead of a box per recipient.  Gold accepted
the argument and ignored it, so the same mod source printed one line on
Red and one per party member on Gold.

The Exp Share mod is the live case: it declares games gen1+gen2 and its
description promises "a single shared-exp line instead of one message per
Pokemon", passing true for the fighters and nil for the bench exactly as
the Gen 1 seam asks.  On Gold every nil call announced anyway, so a
five-mon party turned every KO into six boxes.  There was no mod-side
fix: the emit sits behind no hook, and the argument meaning "quietly" was
discarded.

Gold now reads it, and ONLY when it is actually passed -- by argument
count, not by value.  select("#", ...) counts an explicit nil, so
applyShare(mon, split) is distinguishable from applyShare(mon, split,
nil); the first is a Gen 2-era call written against a seam that always
announced and keeps announcing, the second is a deliberate "pay this one
quietly" and is now silent on both games.  No mod that exists today
changes behaviour, and a mod that passes the argument gets parity.

Only the { kind = "experience" } event is affected.  A silent award is
still a whole award: exp, stat exp, battle.exp_gained, "grew to level",
learned moves and the forget prompt are untouched, in the same order.
giveExperiencePass takes a sixth `silent` parameter that defaults to
announcing, so both of the cart's own passes are unchanged.

RFC: docs/rfcs/0012-gen2-exp-award-announce.md
Docs: docs/mod-api-gen2-compat.md gains the reading and the residual
      omitted-argument difference beside the existing payload note.
Tests: tests/gen2_exp_share_test.lua grows the two Route B tests -- the
       no-mod parity case and the seam driven through hooks:wrap -- and
       its 23 existing checks are unchanged.
2026-08-21 18:07:36 -03:00
bryanthaboi 087a275189 skins 2026-08-21 16:36:55 -04:00
bryanthaboi 06e06e305b skin studio fix 2026-08-21 14:38:54 -04:00
bryanthaboi 0e407dca7a Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-21 14:03:27 -04:00
bryanthaboi b0ff1552f2 skins fixerino 2026-08-21 14:03:25 -04:00
bryanthaboi d191aaa34d Merge pull request #1616 from HighDrexler/fix/large-required-imports-v2
Fix/large required imports v2
2026-08-21 09:59:17 -04:00
bryanthaboi a1a70540b8 Merge pull request #1645 from MaxTomahawk/adaptive-trainers/charge-required-hook
feat(mod-api): expose charge decision hook
2026-08-21 09:19:17 -04:00
bryanthaboi 5c1837b1eb maybe smoother mobile scroll on installed mods 2026-08-21 09:17:06 -04:00
MaxTomahawk bb0f156497 test(mod-api): cover Gold charge continuations 2026-08-21 14:52:11 +02:00
MaxTomahawk 4b0496bad1 feat(mod-api): expose charge decision hook 2026-08-21 14:40:51 +02:00
bryanthaboi a56add6d17 screen position fixes for gen 2 2026-08-21 08:30:44 -04:00
bryanthaboi 48b39c8519 CLOSES #1570 2026-08-21 08:23:21 -04:00
HighDrexler decaa006b2 test: lock large imports to streaming path after confirmation 2026-08-21 08:01:00 -04:00
HighDrexler c0bd4a35be test(mods): fix streamed import rewind assertion 2026-08-21 07:33:24 -04:00
bryanthaboi 8c0d0ace4d CLOSES #1367, CLOSES #1585, CLOSES #1611, CLOSES #1612, CLOSES #1636, CLOSES #1638, CLOSES #1641 2026-08-21 07:14:47 -04:00
bryanthaboi c11c762f15 Merge pull request #1634 from 1Jamie/game-corner-extraction-fix 2026-08-21 05:20:28 -04:00
HighDrexler c465f58006 fix(mods): harden streamed import cleanup 2026-08-20 23:46:02 -04:00
1jamie 780246c4f6 feat: add deinterleave function and enhance graphics extraction for Slots and Card Flip
Implemented a new deinterleave function in ImageWriter to restore row-major order for PNGs. Updated RomExtractorGen2 to utilize this function for extracting Slot Machine graphics, and added new functions for padding and writing graphics sheets. Updated the ROM manifests to include necessary symbols for Slots and Card Flip assets.
2026-08-20 20:40:37 -05:00
1jamie 5714555847 feat: enhance Goldenrod Game Corner graphics extraction
Added functions to write raw graphics assets for the Slot Machine and Card Flip games, ensuring that the necessary files are generated when the corresponding symbols are present in the ROM. Updated the manifest to include required symbols for these assets, preventing fallback to labelled cells when graphics are missing.
2026-08-20 20:23:39 -05:00
1jamie f5b8b6c85f feat: implement Diploma screen visual assets and add version-specific blink logic to TownMap
Fix for #1595 #1597 #1589 #1613 and maybe 1520.
2026-08-20 20:37:51 -04:00
bryanthaboi db25c14dfb save sync update 2026-08-20 20:36:23 -04:00
bryanthaboi 90163a3ff2 CLOSES #1619 2026-08-20 20:07:10 -04:00
bryanthaboi 4bdb9435a4 fix android because goia broke it 2026-08-20 18:36:29 -04:00
bryanthaboi ada0d8abe1 updater stuff 2026-08-20 17:30:48 -04:00
bryanthaboi dbecc345e3 CLOSES #1478, CLOSES #1479, CLOSES #1488, CLOSES #1510, CLOSES #1511, CLOSES #1514, CLOSES #1521, CLOSES #1522, CLOSES #1545, CLOSES #1557, CLOSES #1563, CLOSES #1565, CLOSES #1566, CLOSES #1577, CLOSES #1594, CLOSES #1596, CLOSES #1606, CLOSES #1608 2026-08-20 17:30:18 -04:00
bryanthaboi 0f8f6d0e4f CLOSES #1474, CLOSES #1475, CLOSES #1477, CLOSES #1482, CLOSES #1512, CLOSES #1516, CLOSES #1558, CLOSES #1569, CLOSES #1578, CLOSES #1579 2026-08-20 17:29:51 -04:00
HighDrexler 911e11a372 test(mods): document and cover streamed import access 2026-08-20 17:14:32 -04:00
github-actions[bot] 2d28d18bf6 fix(mods): support large required imports end-to-end 2026-08-20 21:10:01 +00:00
HighDrexler 0b70d6c535 chore: stage prepared large-import PR patch 2026-08-20 17:09:40 -04:00
bryanthaboi 83463a5a59 Merge pull request #1605 from AverageConsumer/codex/android-secondary-lifecycle
Fix secondary display lifecycle cleanup
2026-08-20 13:04:55 -04:00
bryanthaboi d66a72ac95 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-20 12:43:05 -04:00
bryanthaboi 1b659dab01 CLOSES #1582, CLOSES #1583 2026-08-20 12:43:04 -04:00
AverageConsumer 9e01fe2c2c Fix secondary display lifecycle cleanup 2026-08-20 18:23:20 +02:00
bryanthaboi 5fa5005786 Merge pull request #1559 from thibautbus/fix/route-more-messages-through-romtext
Route more battle/overworld/menu messages through their real ROM text
2026-08-20 11:54:40 -04:00
bryanthaboi 1ac5b867bb for that one guy who has a mouse but for some reason it doesnt have a scroll wheel 2026-08-20 11:54:08 -04:00
github-actions 69ef1bfc77 chore(ios): update app-repo.json [skip ci] 2026-08-20 11:00:28 -04:00
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
241 changed files with 22786 additions and 1325 deletions
+21
View File
@@ -468,3 +468,24 @@ jobs:
if [ "$found" = "0" ]; then if [ "$found" = "0" ]; then
echo "no committed mods to lint" echo "no committed mods to lint"
fi fi
luacheck:
name: engine lint (luacheck)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: install luacheck
run: |
set -e
sudo apt-get update
sudo apt-get install -y lua5.4 liblua5.4-dev luarocks
sudo luarocks install luacheck || sudo apt-get install -y lua-check
luacheck --version
- name: luacheck gate (undefined globals, unreachable code)
run: ./scripts/lint.sh --gate
- name: luacheck full report (advisory)
continue-on-error: true
run: ./scripts/lint.sh
+25 -4
View File
@@ -373,10 +373,31 @@ jobs:
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; }
- name: Build Android - name: Materialize Android release signing key
env:
KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }}
run: | run: |
set -euo pipefail set -euo pipefail
scripts/build_android.sh --version "${{ needs.version.outputs.version }}" [ -n "$KEYSTORE_B64" ] || {
echo "::error::ANDROID_RELEASE_KEYSTORE_B64 is required for a publishable Android update"
exit 1
}
python3 - <<'PY'
import base64, os, pathlib
encoded = os.environ["KEYSTORE_B64"]
path = pathlib.Path(os.environ["RUNNER_TEMP"]) / "gen1recomp-android-release.keystore"
path.write_bytes(base64.b64decode(encoded, validate=True))
PY
- name: Build Android
env:
GEN1RECOMP_ANDROID_KEYSTORE: ${{ runner.temp }}/gen1recomp-android-release.keystore
GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }}
GEN1RECOMP_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
GEN1RECOMP_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
run: |
set -euo pipefail
scripts/build_android.sh --release --version "${{ needs.version.outputs.version }}"
- name: Install xcbeautify - name: Install xcbeautify
run: | run: |
@@ -498,8 +519,8 @@ jobs:
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; } [ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage" cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage" chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
apk="$(find dist/android/debug -name '*.apk' | head -1)" apk="$(find dist/android/release -name '*.apk' | head -1)"
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; } [ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk" cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
ipa="dist/ios/gen1recomp++.ipa" ipa="dist/ios/gen1recomp++.ipa"
+5
View File
@@ -23,6 +23,10 @@ read_globals = {
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks -- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
-- it; without this, every `table.unpack` reads as an undefined field. -- it; without this, every `table.unpack` reads as an undefined field.
table = { fields = { "unpack" } }, table = { fields = { "unpack" } },
"POKEPORT_DISPLAY_COMPANION",
"POKEPORT_EDITOR_MODE",
"rawlen",
package = { fields = { "searchers" } },
} }
-- Vendored/native trees and the test suites have their own conventions. -- Vendored/native trees and the test suites have their own conventions.
@@ -30,6 +34,7 @@ exclude_files = {
"mobile/", "mobile/",
"tests/", "tests/",
"tools/save-editor/", "tools/save-editor/",
"tools/save_convert/vendor/",
} }
ignore = { ignore = {
+1 -1
View File
@@ -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
and Silver 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 44 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
+20 -2
View File
@@ -38,6 +38,22 @@ local function retryTmGive(game, ow, victoryKey, done)
return true return true
end end
-- The badge line + its jingle, armed for the battle screen the way
-- SaveEndBattleTextPointers does (PewterGym.asm:117-119) (#1606)
local function badgeEndBattleText(game, victoryKey)
local reward = victoryKey and require("data.scripts.victories")[victoryKey]
if not (reward and reward.dialogue) then return nil end
local text = game.data.text or {}
local pages = {}
for _, label in ipairs(reward.dialogue) do
if text[label] and text[label] ~= "" then
pages[#pages + 1] = text[label]
end
end
if #pages == 0 then return nil end
return table.concat(pages, "\f"), reward.badgeSound
end
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent -- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints -- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
-- _PewterGymBrockPreBattleText and engages the leader battle -- _PewterGymBrockPreBattleText and engages the leader battle
@@ -58,7 +74,8 @@ M.PEWTER_GYM.talk = {
game.data.text._PewterGymBrockPostBattleAdviceText game.data.text._PewterGymBrockPostBattleAdviceText
or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done)) or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done))
else else
ow:engageTrainer(npc, done) local text, sound = badgeEndBattleText(game, "OPP_BROCK#1")
ow:engageTrainer(npc, done, text, nil, sound)
end end
end, end,
} }
@@ -91,7 +108,8 @@ local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryK
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
game.data.text[adviceLabel] or fallback, finish)) game.data.text[adviceLabel] or fallback, finish))
else else
ow:engageTrainer(npc, done) local text, sound = badgeEndBattleText(game, victoryKey)
ow:engageTrainer(npc, done, text, nil, sound)
end end
end end
end end
+1 -1
View File
@@ -111,7 +111,7 @@ local function joinPrompt(game, ow, done)
local t = game.data.text local t = game.data.text
local back = function(text) local back = function(text)
game.stack:push(TextBox.new(game, text, function() game.stack:push(TextBox.new(game, text, function()
ow:scriptMove(ow.player, "down", 1, done) ow:scriptMove(ow.player, "down", 1, done, { collide = true })
end)) end))
end end
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
+8 -7
View File
@@ -127,7 +127,7 @@ M.VIRIDIAN_CITY = {
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
game.data.text._ViridianCityOldManSleepyPrivatePropertyText game.data.text._ViridianCityOldManSleepyPrivatePropertyText
or "You can't go\nthrough here!\fThis is private\nproperty!", or "You can't go\nthrough here!\fThis is private\nproperty!",
function() ow:scriptMove(ow.player, "down", 1) end)) function() ow:scriptMove(ow.player, "down", 1, nil, { collide = true }) end))
return true return true
end, end,
} }
@@ -356,7 +356,7 @@ M.VERMILION_CITY = {
if shipLeft then if shipLeft then
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.", t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
function() ow:scriptMove(ow.player, "up", 1) end)) function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
return true return true
end end
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so -- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
@@ -377,7 +377,7 @@ M.VERMILION_CITY = {
ask .. "\f" ask .. "\f"
.. (t._VermilionCitySailor1YouNeedATicketText .. (t._VermilionCitySailor1YouNeedATicketText
or "You need a ticket\nto get aboard."), or "You need a ticket\nto get aboard."),
function() ow:scriptMove(ow.player, "up", 1) end)) function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
return true return true
end, end,
talk = { talk = {
@@ -837,13 +837,14 @@ M.SILPH_CO_11F = {
-- every Silph rocket leaves off-screen (the street rockets are -- every Silph rocket leaves off-screen (the street rockets are
-- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not -- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
-- run here: the battle's own callbacks are still unwinding, so -- run here: the battle's own callbacks are still unwinding, so
-- queueScript starts it on the first idle overworld frame -- -- queueScript starts it on the first idle overworld frame (#722).
-- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2
-- pushes (#722).
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
ow:queueScript(silphAftermathRows()) ow:queueScript(silphAftermathRows())
end end
end, nil, true) end,
-- "Arrgh!!" is armed for the battle screen, not the map
-- (scripts/SilphCo11F.asm:264-266 SaveEndBattleTextPointers) #1606
game.data.text._SilphCo10FGiovanniILostAgainText, true)
end) end)
end)) end))
return true return true
+2 -2
View File
@@ -414,7 +414,7 @@ local function saffronGate(guardText, triggers, horizontal)
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.", t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
function() function()
ow:scriptMove(ow.player, back, 1) ow:scriptMove(ow.player, back, 1, nil, { collide = true })
end)) end))
return true return true
end, end,
@@ -769,7 +769,7 @@ M.MUSEUM_1F = {
if y == 4 and (x == 9 or x == 10) if y == 4 and (x == 9 or x == 10)
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
museumClerk(game, ow, nil, function() museumClerk(game, ow, nil, function()
ow:scriptMove(ow.player, "down", 1) ow:scriptMove(ow.player, "down", 1, nil, { collide = true })
end) end)
return true return true
end end
+1 -1
View File
@@ -176,7 +176,7 @@ M.POKEMON_TOWER_6F = {
-- .did_not_defeat: one simulated step right, off the trigger, -- .did_not_defeat: one simulated step right, off the trigger,
-- so fleeing does not leave you standing on a cell that -- so fleeing does not leave you standing on a cell that
-- immediately re-fires. -- immediately re-fires.
ow:scriptMove(ow.player, "right", 1) ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
end end
ow:afterBattle(result, battle) ow:afterBattle(result, battle)
end end
+5 -2
View File
@@ -216,7 +216,10 @@ local function dojoMasterGate(game, ow, x, y)
if not master or ow:trainerDefeated(master) then return false end if not master or ow:trainerDefeated(master) then return false end
ow.player.facing = "right" ow.player.facing = "right"
master:facePlayer(ow.player) master:facePlayer(ow.player)
ow:engageTrainer(master) -- scripts/FightingDojo.asm:117-119 SaveEndBattleTextPointers (#1606)
ow:engageTrainer(master, nil,
((game.data or {}).text or {})._FightingDojoKarateMasterDefeatedText,
nil, nil, false)
return true return true
end end
@@ -731,7 +734,7 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
game.data.text[dontRunText] or "Don't run away!", function() game.data.text[dontRunText] or "Don't run away!", function()
ow:scriptMove(ow.player, "up", 1) ow:scriptMove(ow.player, "up", 1, nil, { collide = true })
end)) end))
return true return true
end, end,
+22 -19
View File
@@ -240,7 +240,7 @@ local function stepGate(opts)
push(game, text(game)[opts.text] or opts.fallback, function() push(game, text(game)[opts.text] or opts.fallback, function()
ow.player.facing = opts.push ow.player.facing = opts.push
if not ow:checkLedgeHop(opts.push) then if not ow:checkLedgeHop(opts.push) then
ow:scriptMove(ow.player, opts.push, 1) ow:scriptMove(ow.player, opts.push, 1, nil, { collide = true })
end end
end) end)
return true return true
@@ -647,27 +647,29 @@ end
local rocketRows = { local rocketRows = {
{ "face_player" }, -- 1 { "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_TM28" }, -- 2 { "check_flag", "EVENT_GOT_TM28" }, -- 2
{ "jump_if_true", 15 }, -- 3 → CeruleanHideRocket { "jump_if_true", 16 }, -- 3 → CeruleanHideRocket
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4 { "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
{ "jump_if_true", 9 }, -- 5 { "jump_if_true", 10 }, -- 5
{ "show_text", "_CeruleanCityRocketText" }, -- 6 { "show_text", "_CeruleanCityRocketText" }, -- 6
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7 -- scripts/CeruleanCity.asm:297 SaveEndBattleTextPointers
{ "jump_if_false", "end" }, -- 8 { "save_end_battle_text", "_CeruleanCityRocketIGiveUpText" }, -- 7
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9 { "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 8
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10 { "jump_if_false", "end" }, -- 9
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints) { "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 10
{ "set_flag", "EVENT_GOT_TM28" }, -- 12 { "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 11
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13 { "give_item", "TM_DIG", 1, false }, -- 12 (row 14 prints)
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14 { "set_flag", "EVENT_GOT_TM28" }, -- 13
{ "fade", "out" }, -- 15 GBFadeOutToBlack { "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 14
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 15
{ "fade", "out" }, -- 16 GBFadeOutToBlack
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2 -- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south -- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
-- door neighbour -- the swap reconnects the city (Bill's ticket does -- door neighbour -- the swap reconnects the city (Bill's ticket does
-- the same in story.lua; either route is enough). -- the same in story.lua; either route is enough).
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16 { "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 17
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17 { "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 18
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18 { "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 19
{ "fade", "in" }, -- 19 GBFadeInFromBlack { "fade", "in" }, -- 20 GBFadeInFromBlack
} }
M.CERULEAN_CITY = { M.CERULEAN_CITY = {
@@ -818,7 +820,8 @@ M.PEWTER_POKECENTER = {
-- on the west-side cells and walks you back -- on the west-side cells and walks you back
local function bikeGateGuard(coords, stopText, explainText) local function bikeGateGuard(coords, stopText, explainText)
return function(game, ow, x, y) return function(game, ow, x, y)
if game.save.inventory.BICYCLE then return false end local bike = game.save.inventory.BICYCLE
if bike and bike ~= 0 then return false end
if not inCoords(coords, x, y) then return false end if not inCoords(coords, x, y) then return false end
-- walk the player up to the tile beside the counter, no further: -- walk the player up to the tile beside the counter, no further:
-- (matchedY - closestY) tiles, 0 when already next to it -- (matchedY - closestY) tiles, 0 when already next to it
@@ -840,10 +843,10 @@ local function bikeGateGuard(coords, stopText, explainText)
-- (PlayerMovingRightScript). Without it the player was left -- (PlayerMovingRightScript). Without it the player was left
-- parked beside the guard's counter with no way past. #518 -- parked beside the guard's counter with no way past. #518
local function shoveRight() local function shoveRight()
ow:scriptMove(ow.player, "right", 1) ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
end end
if dist > 0 then if dist > 0 then
ow:scriptMove(ow.player, "up", dist, shoveRight) ow:scriptMove(ow.player, "up", dist, shoveRight, { collide = true })
else else
shoveRight() shoveRight()
end end
+14 -2
View File
@@ -24,7 +24,7 @@ The short version, for an author deciding what to write:
merged.** The write is taken, dropped, and named once per mod in the same merged.** The write is taken, dropped, and named once per mod in the same
error feed the mod manager shows -- in both directions, so a Red boot writing error feed the mod manager shows -- in both directions, so a Red boot writing
to `decorations` is told exactly as a Gold boot writing to `map_scripts` is. to `decorations` is told exactly as a Gold boot writing to `map_scripts` is.
- **40 event names and 43 hook names have a call site in both generations**, so - **40 event names and 44 hook names have a call site in both generations**, so
one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua` one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua`
reads those names back out of the source and fails if a site is renamed or reads those names back out of the source and fails if a site is renamed or
deleted on either side, and fails again if a new shared site appears without deleted on either side, and fails again if a new shared site appears without
@@ -539,7 +539,8 @@ gains a field instead of the name gaining a prefix.
`battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`, `battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`,
`battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`, `battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`,
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`, `pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
`battle.crit`, `battle.accuracy`, `battle.turn_order`, `battle.crit`, `battle.accuracy`, `battle.charge_required`,
`battle.turn_order`,
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`, `battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`, `catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
`battle.catch_exp`, `battle.bottom_ui_visible`, `battle.catch_exp`, `battle.bottom_ui_visible`,
@@ -549,6 +550,17 @@ gains a field instead of the name gaining a prefix.
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
`ctx.battle.data` instead gets nil on Gold. `ctx.battle.data` instead gets nil on Gold.
`battle.exp_award`'s `ctx.applyShare(mon, split, announce)` reads its third
argument on both generations: truthy prints the mon's GainedText, falsy pays
it silently, so one mod source can print a single summary line for a
party-wide award instead of a box per recipient. Gold honours it **only when
it is passed**, by argument count -- `applyShare(mon, split)` was written
against a seam that always announced on Gold and keeps announcing there,
while `applyShare(mon, split, nil)` is silent on both. Pass the argument
explicitly and the two generations agree; omit it and Gen 1 stays silent
where Gold speaks. Only the line is affected: the exp, the stat exp,
`battle.exp_gained`, the level-up line, learned moves and the forget prompt
happen either way.
- *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook - *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits `evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
`pokemon.caught` once the mon is in the party or the box, and `pokemon.caught` once the mon is in the party or the box, and
+57 -5
View File
@@ -122,21 +122,40 @@ Each object requires a stable `id`, a display `name`, a destination `file`
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
`description` gives players dump or region guidance in the import panel. `description` gives players dump or region guidance in the import panel.
`size` declares the exact canonical byte length; `max_size` declares a smaller `size` declares the exact canonical byte length; `max_size` declares a smaller
per-import ceiling when an exact size is not appropriate. Every import also per-import ceiling when an exact size is not appropriate. The engine hard limit
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its is 2 GiB. Imports above 128 MiB receive an explicit free-space confirmation and
filesystem reports an invalid size. use the launcher's streaming large-file path rather than being materialized as
one Lua string.
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders, For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
strips a recognized 512-byte copier header, converts the bytes to canonical strips a recognized 512-byte copier header, converts the bytes to canonical
big-endian `.z64` order, and then checks MD5. The canonical bytes are written big-endian `.z64` order, and then checks MD5. The canonical bytes are written
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
mod: the launcher never scans or copies another mod's imported files merely mod: the launcher never scans or copies another mod's imported files merely
because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for because its manifest names the same digest. Small sources can still be read
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem with the existing scoped `mod:read` API, for example
`mod:read("baseroms/stadium2.z64")`. For large sources, prefer the bounded
`mod.imports` facade described below; no host path or new general filesystem
permission is exposed. Missing `required_imports` block the mod before its permission is exposed. Missing `required_imports` block the mod before its
entry chunk runs; missing `optional_imports` remain visible in the same entry chunk runs; missing `optional_imports` remain visible in the same
launcher panel but do not block loading. launcher panel but do not block loading.
#### Bounded access to validated imports
A loaded mod can address only ids declared by its own `required_imports` or
`optional_imports` arrays:
```lua
local info, err = mod.imports:info("stadium2")
local header, err = mod.imports:read("stadium2", 0, 4096)
```
`read` uses zero-based offsets and is capped at 8 MiB per call. The engine
rechecks the stored import before exposing it, seeks into the engine-owned
copy, and never gives the mod a host path or file handle. This is intended for
large source formats whose table/index can be parsed with small reads before
selectively reading the payloads a transform actually needs.
MD5 here identifies a known dump because ROM databases commonly publish it; MD5 here identifies a known dump because ROM databases commonly publish it;
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
@@ -441,6 +460,28 @@ default** (1x front, 2x back).
ball-to-pic grow multiplies your scale through each stage, so a rescaled ball-to-pic grow multiplies your scale through each stage, so a rescaled
mon still grows into place from the ball, grounded the whole way. mon still grows into place from the ball, grounded the whole way.
## Installation-scoped generated cache
Generated data derived from a validated user source often belongs to the mod
installation rather than to one Pokémon save. `mod.cache` is that namespace:
```lua
local ok, err = mod.cache:write("extract/v1/arena.bin", encodedArena)
local bytes, err = mod.cache:read("extract/v1/arena.bin")
local info = mod.cache:info("extract/v1/arena.bin")
mod.cache:delete("extract/v1/arena.bin")
```
The physical root is engine-owned (`mod_cache/<mod-id>/`) and never exposed to
the mod. Keys are safe relative paths and a single write is capped at 64 MiB.
The cache does not rewind with checkpoints and is not scoped to game version,
slot, or playthrough. The mod owns its generated format, fingerprints, rebuild
policy, and completion marker; the engine treats the bytes as opaque data.
Use `mod.storage` instead when the data belongs to one playthrough. Use
`mod.cache` when it is a reproducible installation artifact that can be rebuilt
from a declared user source.
## Durable tool storage and runtime checkpoints ## Durable tool storage and runtime checkpoints
`mod.save` remains the right place for state that should travel with the next `mod.save` remains the right place for state that should travel with the next
@@ -633,6 +674,17 @@ the selected indices. Mods remain responsible for selection policy and should
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
contract and compatibility guarantees. contract and compatibility guarantees.
Both battle engines expose the guarded `battle.charge_required` hook when a
charge-capable move is selected for its initial turn and the active ruleset
would otherwise charge it. The wrapper receives `(next, ctx)`, where `ctx` is
`{ battle, user, target, move, charge = true, isCalled }`. Return `false` to
skip only that initial charge and continue through the ordinary move pipeline;
call `next(ctx)` to keep it. The hook does not run for the release turn or when
the active ruleset already skips charging (for example, Gold Solarbeam in
sun). PP use, accuracy, damage, animation, and secondary effects remain owned
by the engine. With no subscriber, the vanilla decision runs without building
the hook context.
## Developer console ## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload Boot with developer mode on to unlock the in-game console and hot-reload
+1
View File
@@ -14,6 +14,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Screen position setting** (center, upper, top) shared across all games, for clamp-on controllers that cover the lower screen * **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**
* **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed
## Gen 2 Specifics ## Gen 2 Specifics
@@ -0,0 +1,141 @@
# RFC 0008 — Streamed mod imports and installation-scoped generated cache
## Motivation
`required_imports`/`optional_imports` can now describe files up to 2 GiB, but
the existing launcher and public mod API still assume imported bytes are small:
* the Windows desktop picker stages a selected required import through a fixed
`%TEMP%/pokeport_required_import.bin` path before validation;
* the fallback import path materializes the selected file as one Lua string;
* after validation a mod can only use `mod:read("baseroms/...")`, which also
materializes the whole file;
* `mod.storage` is intentionally scoped to one Pokémon playthrough, so it is
not an appropriate home for a one-time generated asset cache shared by every
save using the same installed mod.
This makes optical-disc-sized user sources impractical even though the manifest
schema already accepts them. A failed temporary staging copy can also turn a
valid large source into a smaller temporary file and produce a misleading
"wrong file size" rejection.
A mod should be able to consume its own already-validated source incrementally
and compile derived runtime data once, without receiving a host path or general
filesystem access.
## Decision being extended
This extends the same legal/sandbox direction as **D11 asset transforms**
(`src/mods/AssetTransform.lua`): mods distribute recipes and derive bytes from
user-owned sources rather than shipping ROM-derived data. It also follows the
**D14 parity-gate** contract referenced by `tests/harness.lua` and
`tests/engine/gate_meta_coverage.lua` (the `21-testing-and-ci` plan): additive
extension points ship public-API coverage, no-mod parity coverage, and docs in
the same change.
The historical D11 plan document is referenced by source comments but is not
present in the current repository tree; this RFC is the checked-in design
record for the new surface.
## Exact API delta
No manifest field changes. Existing `required_imports` and `optional_imports`
remain the declaration/validation authority.
Two additive facades are added to the `mod` object.
### `mod.imports`
```lua
local info, err = mod.imports:info("source_id")
local bytes, err = mod.imports:read("source_id", offset, length)
```
* `source_id` must name an import declared by the calling mod.
* the import is rechecked through `RequiredImports.validateStored` before it is
exposed, so missing, replaced, or invalid optional imports are not readable;
* `offset` and `length` are zero-based byte coordinates;
* one read is capped at 8 MiB;
* no host path or file handle is returned;
* production reads seek into the engine-owned stored copy instead of reading
the whole source.
`info()` returns declaration metadata plus stored size. It does not expose a
host path.
### `mod.cache`
```lua
mod.cache:write("extract/v1/model.bin", bytes)
local bytes = mod.cache:read("extract/v1/model.bin")
local info = mod.cache:info("extract/v1/model.bin")
mod.cache:delete("extract/v1/model.bin")
```
The cache is rooted at `mod_cache/<mod-id>/`, follows the engine persistence
backend, and is independent of game version, launcher slot, and playthrough.
Paths are checked with `SafePath`; `..`, absolute paths, drive paths, and other
escapes remain unavailable. A single cache write is capped at 64 MiB so large
generated datasets are naturally split into independently replaceable files.
The engine does not interpret cache bytes. Mods own generated-format versioning,
fingerprints, transactional completion markers, and rebuild policy.
## Launcher/import transport delta
For large raw required imports:
1. desktop pickers return the original selected path instead of staging it
through a fixed temporary file;
2. the engine opens that source itself;
3. bytes are copied directly to the existing engine-owned
`mods/<id>/baseroms/<file>` destination in 4 MiB chunks;
4. MD5 is updated incrementally during the copy;
5. the normal size/MD5 validation receipt is written only after the complete
destination passes validation;
6. partial destinations are removed on short reads, write failure, size
mismatch, or digest mismatch.
N64 imports stay on the existing canonicalization path because byte-order and
copier-header normalization require transformation rather than a raw copy.
If a validation receipt for an already-stored large raw import is missing, the
engine rebuilds it with streaming MD5 rather than a whole-file read.
## Backward compatibility / migration
**Existing mods do nothing.** This is additive.
* manifest v1/v2 fields are unchanged;
* `mod:read`, `mod.storage`, registries, events, hooks, and legacy compatibility
retain their existing behavior;
* small required imports retain the existing in-memory validation path;
* N64 imports retain canonicalization and existing accepted byte orders;
* a mod that never touches `mod.imports` or `mod.cache` creates no new cache
files and observes no new behavior.
The mod API integer is not bumped because no existing member changes meaning or
shape.
## Security and legal posture
The launcher remains the authority that validates user-supplied bytes. The new
facade narrows access rather than widening it: a mod can read only ids declared
in its own manifest, only after validation, and only in bounded ranges. It does
not receive host paths, `io`, or a raw filesystem handle.
`mod.cache` is writable only beneath the calling mod's generated-cache root.
Nothing in this RFC permits packaged ROM-derived bytes; `modkit lint/pack`
continue to enforce the existing legal posture.
## Parity guarantee
The change ships with:
* a no-mod/API-v1 parity test proving an empty load and an existing v1-style
`mod:read` load do not create cache data or change the old surface;
* a public mod-API test that reaches `mod.imports` and `mod.cache` through a
real `Loader` load, including bounded reads, undeclared/missing imports,
cache isolation, and traversal rejection;
* incremental MD5 vectors and a large-import streaming regression test;
* the existing engine suite, required-import suite, and mod lint gates.
+92
View File
@@ -0,0 +1,92 @@
# RFC 0011: Charge-required battle hook
## Status
Proposed.
## Motivation
A battle-mechanics mod can change damage through `battle.damage` and register
move effects, but it cannot conditionally skip the first turn of an existing
charge move. In Gen 1, the engine decides and stores the charge continuation
before any public effect callback can run. Reaching into `user.charging`,
`user.chargeReady`, or generation-specific volatile state is private,
checkpoint-fragile, and would require a mod to duplicate move-pipeline policy.
Weather is the immediate example: a portable sun rule needs Solarbeam to
resolve on selection while leaving Fly, Dig, PP use, hit resolution, animation,
and secondary effects to the engine. The capability is generic and useful to
other ruleset and move-mechanics mods.
## Decision and plan extended
This implements **D-AT-002: charge-stage policy remains mod authority through a
generic guarded engine decision seam**. The consuming design is tracked in the
Adaptive Trainers implementation plan,
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
Task 8. The delta follows the additive, guarded hook convention documented by
Route B in `CONTRIBUTING-mods.md`; it contains no weather, move-id, trainer, or
Adaptive Trainers policy.
## Exact API delta
Both the Gen 1 and Gen 2 battle engines add this guarded hook:
```lua
mod.hooks:wrap("battle.charge_required", function(next, ctx)
-- ctx = {
-- battle = live battle controller,
-- user = attacking battler,
-- target = defending battler,
-- move = merged move record,
-- charge = true,
-- isCalled = false,
-- }
if should_resolve_now(ctx) then return false end
return next(ctx)
end)
```
The call site is the initial-use charge decision, after announcement and PP
handling but before charge state, invulnerability, charge animation, or charge
text is created. It runs only when the active engine rules would otherwise
require a charge. It does not run on the release turn. Returning exactly
`false` skips that initial charge and continues through the engine-owned move
pipeline. Any other downstream return preserves the charge. `isCalled` is true
when Metronome or Mirror Move selected the move.
Gold keeps its native sun decision first, so Solarbeam in native sun already
requires no charge and does not invoke the hook. Gen 1 link battles use the
shared Gen 1 move pipeline and therefore receive the same seam; normal link
mod-compatibility rules continue to govern deterministic peers.
The hot path first calls `Runtime.wantsHook("battle.charge_required")`. With no
subscriber, no hook payload table is allocated and the existing branch runs
unchanged.
## Migration and compatibility
Existing mods change nothing. The hook name and payload are additive. With no
wrapper installed, Red, Blue, Yellow, Gold, and Silver retain their previous
charge state, PP use, text, animation, accuracy, damage, and native weather
behavior. Existing charge-move data and effect records require no migration.
A mod adopting the seam should call `next(ctx)` unless it deliberately wants to
skip this charge. It should not mutate private charge fields or re-run the move.
## Verification
- `tests/engine/battle_charge_required.lua` exercises the real Gen 1 and Gen 2
engines through a sandboxed public mod, including false-to-skip, next-to-keep,
release-turn behavior, called-move PP semantics, shared payload shape, and
native Gold sun behavior.
- The same test proves no-mod charge/release parity and replaces
`Runtime.call` with a sentinel behind a false `Runtime.wantsHook` guard.
- `tests/engine/gate_hooks.lua` discovers the new catalog name and proves empty
chains preserve vanilla values and allocation behavior.
- `tests/engine/gate_gen2_mod_api.lua` requires a guarded site in both
generations and keeps the compatibility reference list complete.
## Deprecation etiquette
Nothing is removed, renamed, superseded, or deprecated.
+140
View File
@@ -0,0 +1,140 @@
# RFC 0012: `applyShare`'s announce argument on Gen 2
## Status
Proposed.
## Motivation
`battle.exp_award` hands a mod `ctx.applyShare(mon, split, announce)` on both
generations. On Gen 1 the third argument decides whether the mon's GainedText
box is printed (`src/battle/BattleState.lua`, `if announce then`), which is how
a mod that pays the whole party prints **one** summary line instead of a box
per recipient.
Gold accepts the argument and ignores it, as its own comment above the hook
call says. So the same mod source, running the same code, prints one line on
Red and six on Gold — one for the participant plus one for every bench mon it
paid.
This is not hypothetical. The [Exp Share](https://github.com/ShaneMcGovernIE/exp_share)
mod declares `"games": ["gen1", "gen2"]` and its description promises "a single
shared-exp line instead of one message per Pokemon". It passes `true` for the
fighters and `nil` for the bench, exactly as the Gen 1 seam asks. On Gold every
one of those `nil` calls announces anyway, so a five-mon party turns every KO
into six boxes to click through.
There is no mod-side fix. The announcement is emitted inside
`Battle:giveExperiencePass`, behind no hook, and a mod cannot ask for silence
because the argument that means "quietly" is discarded. The only workaround is
to intercept the battle's event queue afterwards and delete the boxes, which is
what a mod written for this had to do — a mod reaching into engine internals to
undo something the public seam should never have done.
## Decision and plan extended
This does not add a seam. It finishes one: `battle.exp_award` is documented as
"the same hook `BattleState:awardExp` calls on Gen 1 and with the same ctx",
and `docs/mod-api-gen2-compat.md` lists it among the hooks shared with Gen 1.
The third `applyShare` argument is the one part of that ctx whose meaning did
not survive the crossing, so the promise the catalog already makes is what this
change delivers.
The delta follows Route B's additive, guarded convention in
`CONTRIBUTING-mods.md`: nothing is renamed, nothing is removed, and no mod that
exists today changes behaviour.
## Exact API delta
`ctx.applyShare(mon, split, announce)` on Gen 2 now reads `announce`:
| Call | Gen 1 | Gen 2 before | Gen 2 after |
|---|---|---|---|
| `applyShare(mon, split)` | silent | announces | **announces** (unchanged) |
| `applyShare(mon, split, nil)` | silent | announces | **silent** |
| `applyShare(mon, split, false)` | silent | announces | **silent** |
| `applyShare(mon, split, true)` | announces | announces | announces |
| `applyShare(mon, split, "expAll")` | announces | announces | announces |
The argument is honoured **only when it is actually passed**, decided by
argument count rather than by value:
```lua
local function applyShare(mon, split, ...)
local announce = ...
local silent = select("#", ...) > 0 and not announce
...
end
```
`select("#", ...)` counts an explicit `nil`, so `applyShare(mon, split)` and
`applyShare(mon, split, nil)` are distinguishable — and they have to be, because
the first is a Gen 2-era call written against a seam that always announced, and
the second is a deliberate "pay this one quietly".
Only the `{ kind = "experience" }` event is affected. A silent award is still a
whole award: the exp, the stat exp, the `battle.exp_gained` event, the
`grew to level` line, learned moves and the interactive forget-a-move prompt all
happen exactly as before, in the same order.
Internally `Battle:giveExperiencePass` takes a sixth parameter, `silent`. It
defaults to announcing, so both of the cart's own passes are untouched.
## Migration and compatibility
**Existing mods change nothing.** A Gen 2 mod calling `applyShare(mon, split)`
gets the behaviour it was written against. A Gen 1 mod is untouched: no Gen 1
file is modified. The v1 surface — `content.X:register/override/get`,
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and
`pokemon.before_give` — is not involved; `mods/example_mew_starter` neither
calls this seam nor loads differently.
A mod that wants parity passes the argument explicitly, which is what the Gen 1
seam has always documented. Exp Share already does, and needs no edit to get
its own README's behaviour on Gold.
One residual difference is deliberate and now documented rather than silent: an
**omitted** third argument still means "silent" on Gen 1 and "announce" on
Gold. Closing that would change what an existing Gen 2 mod prints, which Route
B rejects. Passing the argument makes the two generations agree, so the rule an
author needs is one sentence: *say what you mean and both games do the same
thing.*
## Verification
- `tests/gen2_exp_share_test.lua` grows two sections and 14 checks, and the 23
checks it already had are unchanged — which is itself the vanilla-parity
evidence for this file.
- **The no-mod test.** With nothing subscribed to `battle.exp_award`, a solo
participant still prints one line and the EXP.SHARE double pass still
prints both. The hot path is unchanged: `Runtime.wantsHook` still guards
the ctx allocation, and `vanillaAward` never passes `silent`.
- **The mod-API test.** The seam is driven through `hooks:wrap` on a real
`Runtime.install`ed bus, not by calling internals: the omitted argument
announces, an explicit `nil` and an explicit `false` are silent, a truthy
value (including Gen 1's `"expAll"`) announces, the exp and stat exp paid
are identical either way, and no `experience` event leaks into the queue on
a silent pass.
- `tests/engine/gate_gen2_mod_api.lua` (943 checks),
`tests/engine/gate_hooks.lua` (493) and `tests/engine/gate_events.lua` (529)
pass unchanged; `battle.exp_award` was already in the shared catalog, so no
gate list moves.
- `tests/gen2_battle_test.lua` (690), `gen2_battle_end_test.lua` (32),
`gen2_battle_items_test.lua` (99), `gen2_badge_boosts_test.lua` (34) and
`gen2_battle_loss_test.lua` (15) pass unchanged.
## Docs with the change
`docs/mod-api-gen2-compat.md` gains the `applyShare` reading beside the
existing `battle.low_health_alarm` payload note, in the same section that lists
`battle.exp_award` as shared — including the argument-count rule and the
residual difference above.
No registry or schema field changes, so `src/mods/Schemas.lua` is untouched and
`tools/gen_registry_docs.lua` has nothing new to emit.
## Deprecation etiquette
Nothing is removed, renamed, superseded or deprecated. The two-argument call is
not deprecated either — it keeps its current Gen 2 meaning permanently, and the
docs name the explicit form as the one that behaves the same on both games.
+64 -25
View File
@@ -1,11 +1,11 @@
# Touch skins and the Skin Studio # Touch skins and the Skin Studio
A **skin** replaces the on-screen controls wholesale: a bezel image, a A **skin** replaces the on-screen controls wholesale: a bezel image, a
control layout, and the rectangle the Game Boy screen is drawn into. Engine: control layout, and a screen-placement anchor. Engine:
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua` `src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
(draw and input), `src/render/Renderer.lua` (the screen viewport), (draw and input), `src/render/Renderer.lua` (screen placement),
`src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export), `src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export),
`src/ui/SkinStudio.lua` (the desktop editor). Tests: `src/ui/SkinStudio.lua` (the responsive skin editor). Tests:
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`, `tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
`tests/engine/skin_studio_ux.lua`, `tests/engine/skin_studio_ux.lua`,
`tests/engine/skin_studio_image_import.lua`, `tests/engine/skin_studio_image_import.lua`,
@@ -13,8 +13,10 @@ control layout, and the rectangle the Game Boy screen is drawn into. Engine:
`tests/engine/launcher_skins_tab.lua`, `tests/engine/launcher_skins_tab.lua`,
`tests/engine/launcher_skins_ux.lua`. `tests/engine/launcher_skins_ux.lua`.
Skins are picked in the launcher's **Skins** tab, which also imports them and The launcher's **Skins** tab imports skins, shows the enabled skin, exports it,
opens the studio. `options.touchControls.skin` holds the folder name. and is the one place that turns skin use off. **My Skins** holds the visual
grid, pagination, edit, delete and per-skin export actions.
`options.touchControls.skin` holds the folder name.
## Formats ## Formats
@@ -30,7 +32,7 @@ as-is. Supported keys:
| `overlays` | page count | | `overlays` | page count |
| `overlayN_name` | page name, the target of `next_target` | | `overlayN_name` | page name, the target of `next_target` |
| `overlayN_overlay` | bezel image | | `overlayN_overlay` | bezel image |
| `overlayN_full_screen` | stretch the page to the window | | `overlayN_full_screen` | cover the window with the page without deforming its artwork |
| `overlayN_rect` | page placement, default `0,0,1,1` | | `overlayN_rect` | page placement, default `0,0,1,1` |
| `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen | | `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen |
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults | | `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
@@ -98,8 +100,8 @@ corners fire two directions. `screens[1].outputFrame` (or the legacy
`gameScreenFrame`) becomes the screen cutout. A portrait page with neither `gameScreenFrame`) becomes the screen cutout. A portrait page with neither
keeps `mappingSize` as the overlay aspect, sits at the bottom of the keeps `mappingSize` as the overlay aspect, sits at the bottom of the
window, and puts the Game Boy picture in the leftover space above -- the window, and puts the Game Boy picture in the leftover space above -- the
usual GBA4iOS controller-deck layout. Pages that name a screen rect still usual GBA4iOS controller-deck layout. Pages that name a screen rect fit the
stretch to the window the way Delta does. Host functions map to game into it. Host functions map to
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`; `hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration. `quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
@@ -135,7 +137,7 @@ to decoration and never captures a touch.
As an extension to the format, `key:<name>` presses any keyboard key, which is As an extension to the format, `key:<name>` presses any keyboard key, which is
how a skin button reaches a mod hotkey. how a skin button reaches a mod hotkey.
## The screen viewport ## Screen placement
`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy `overlayN_viewport` is the cutout the picture is fitted into. The Game Boy
screen keeps its whole-pixel scale and letterboxes inside that rect rather than screen keeps its whole-pixel scale and letterboxes inside that rect rather than
@@ -146,6 +148,17 @@ that lets a widescreen bezel take the filling survey-zoom world view instead.
A viewport also implies the faithful-ratio lock. Without it the world pass A viewport also implies the faithful-ratio lock. Without it the world pass
expands to fill the cutout and you get more map instead of a Game Boy screen. expands to fill the cutout and you get more map instead of a Game Boy screen.
Zoom still steps around that hole: OUT shows more map inside it, IN enlarges
the world, and the start menu stays at the hole's fit scale instead of
shrinking with the map.
An image-backed portrait overlay that has no explicit vertical anchor is treated
as a controller deck: it is contained without deformation and pinned to the
bottom on taller screens. The spare space belongs to the game above it.
When a skin is active, **SCREEN POS** reads **SKIN**: placement comes from the
skin rather than the normal Center / Upper / Top setting.
Border art often ships with a transparent hole and no `viewport` key. **Detect Border art often ships with a transparent hole and no `viewport` key. **Detect
screen from bezel** in the studio measures the hole out of the art's alpha screen from bezel** in the studio measures the hole out of the art's alpha
channel and writes the rect. channel and writes the rect.
@@ -153,10 +166,8 @@ channel and writes the rect.
## Bezels versus pads ## Bezels versus pads
A skin whose active page binds nothing is a frame rather than a pad: a TV A skin whose active page binds nothing is a frame rather than a pad: a TV
surround, a handheld shell, a Super Game Boy border. Those draw on **desktop** surround, a handheld shell, a Super Game Boy border. Selected skins draw on
as well, where the touch overlay itself does not, and a gamepad does not hide **desktop** as well as mobile; a gamepad does not hide them.
them. Anything that binds a button still follows the usual mobile /
`POKEPORT_TOUCH` rule.
## Installing ## Installing
@@ -190,9 +201,32 @@ shipping branding.
## The studio ## The studio
Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open Launcher, Skins tab, **My Skins** opens the Studio library on desktop and
that skin. Desktop only: the launcher does not offer it on Android or iOS, mobile. **My Skins** is the only visual grid: real bezel previews plus create
because it wants a mouse, typed coordinates and room for an inspector. and import actions, with each card owning Edit, Export and (for installed
skins) Delete. Choosing Edit opens a separate, canvas-first editor; the old
New/Load workspace controls are deliberately not duplicated inside that editor.
The editor keeps its canvas unobstructed and puts the contextual actions in a
compact lower tray: add/control binding, button and bezel artwork, pages,
screen placement, freeform/10:9 screen shape and deletion. **Screen** opens
cutout, bezel-hole detect, **Detect this screen**, and the canvas presets.
**Detect this screen** (also on the tray) sets the mock device to the live
window size so a phone skin is authored at that phone's form factor rather
than a generic 1080x1920 16:9. **Zoom ** shrinks the mock device inside the
workspace so the screen hole can be dragged larger than the bezel while the
handles stay grabable; **Fit** restores contain. The mouse wheel over the
canvas, and `-` / `=` / `0` on a keyboard, do the same. Touches select, drag and resize the
same controls that a mouse edits on desktop.
My Skins and the editor chrome sit inside the platform safe area (notch,
status bar, home indicator), the same inset the launcher uses. The mock
device still represents the full window, because a skin covers the whole
screen at play time.
The launchers **Turn skins off** button clears the selected skin and disables
skin use. With no skin enabled, mobile falls back to the built-in pad; that pad
is not itself a skin card.
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at **Canvas.** A mock device at a chosen preset, so a phone skin is authored at
phone proportions on a desktop monitor. phone proportions on a desktop monitor.
@@ -200,6 +234,7 @@ phone proportions on a desktop monitor.
| Preset | Size | | Preset | Size |
| --- | --- | | --- | --- |
| Phone portrait / landscape | 1080x1920, 1920x1080 | | Phone portrait / landscape | 1080x1920, 1920x1080 |
| This screen | the live window, so a phone is authored at its own height |
| Tablet portrait / landscape | 1536x2048, 2048x1536 | | Tablet portrait / landscape | 1536x2048, 2048x1536 |
| Steam Deck | 1280x800 | | Steam Deck | 1280x800 |
| Desktop 1080p | 1920x1080 | | Desktop 1080p | 1920x1080 |
@@ -216,13 +251,16 @@ and of the page itself when it comes within a few pixels, and the guide it
snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be
typed to the coordinate its art was drawn at. **Back** and **Front** move the typed to the coordinate its art was drawn at. **Back** and **Front** move the
selection through the draw order. Bind, hitbox shape, hit reach and idle and selection through the draw order. Bind, hitbox shape, hit reach and idle and
pressed images are per control; the bezel, the pages and the screen cutout are pressed images are per control; the bezel, the pages and the screen anchor are
per page. The cutout is itself a draggable element with a 10:9 lock. per page. The SCREEN anchor is itself draggable and resizable; its default
shape is freeform, with an optional 10:9 lock.
**Bind** opens a grid of every bind the engine understands: the eight Game Boy **Bind** opens a grid of every bind the engine understands: the eight Game Boy
buttons, the diagonal pairs, every hotkey, a few `key:` entries, and buttons, the diagonal pairs, every hotkey, desktop hotkeys and decoration.
decoration. The COMBINE chips at the top toggle one part at a time, which is The desktop section exposes `-` / `=`, `1` through `5`, `F1`, `F2` and `F10`
how a pipe bind like `left|down` is built without typing it. as `key:` controls, so a mobile button invokes the exact same game path as
its desktop shortcut. The COMBINE chips at the top toggle one part at a time,
which is how a pipe bind like `left|down` is built without typing it.
**Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or **Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or
`u` / shift+`u` without a keyboard modifier). The stack holds the last 50 `u` / shift+`u` without a keyboard modifier). The stack holds the last 50
@@ -247,12 +285,13 @@ the **Import** button there and beside each row opens the host file picker (`src
zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in
the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the
window does the same for whichever slot was last touched. A new bezel does not window does the same for whichever slot was last touched. A new bezel does not
move the screen cutout: press **Detect screen from bezel** to measure it out of move the screen anchor: press **Detect screen from bezel** to measure it out of
the art's alpha. the art's alpha.
**Testing.** **Test** makes the canvas live: clicking presses real Game Boy **Testing.** **Test** renders a game-composition preview behind the live overlay:
buttons and the footer reports what is held. **Play** saves the skin, selects the 160x144 picture letterboxes inside the screen cutout, matching gameplay.
it, and boots the game with it. Clicking presses real Game Boy buttons and the footer reports what is held.
**Play** saves the skin, selects it, and boots the game with it.
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the **Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
skin names, so the folder stands alone. **Export** offers three formats, and skin names, so the folder stands alone. **Export** offers three formats, and
+26 -4
View File
@@ -64,7 +64,8 @@ mounted or deleted as stale; the launcher directs the player to a full package.
Each tagged release `vX.Y.Z` carries the existing per-platform archives Each tagged release `vX.Y.Z` carries the existing per-platform archives
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`, (`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
`-android.apk`) plus two assets the updater itself consumes: `-linux-arm64.AppImage`, `-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and
PortMaster archives) plus two assets the updater itself consumes:
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern - `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and `gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and
@@ -75,8 +76,9 @@ Each tagged release `vX.Y.Z` carries the existing per-platform archives
filename otherwise to match the asset name exactly. filename otherwise to match the asset name exactly.
A release missing either asset is treated as "no in-place update available": A release missing either asset is treated as "no in-place update available":
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()` `Check` reports `needs_full`. It also selects the exact current platform asset
(`https://github.com/bryanthaboi/gen1recomp/releases/latest`). from the same release and persists the requirement, so it is visible again on
every launch, including offline launches.
## Save-directory layout ## Save-directory layout
@@ -85,6 +87,7 @@ Under the save directory (identity `pokemon-love2d`):
``` ```
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s) updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
updates/pending.txt crash-guard marker updates/pending.txt crash-guard marker
updates/full-update.json persistent native-package requirement
``` ```
`pending.txt` holds the filename of the payload currently being chainloaded. `pending.txt` holds the filename of the payload currently being chainloaded.
@@ -106,7 +109,8 @@ bundled game, in that case.
against the GitHub releases API; safe to call every frame, it is a no-op against the GitHub releases API; safe to call every frame, it is a no-op
once a check is in flight or has reached a terminal state. `Check.state()` once a check is in flight or has reached a terminal state. `Check.state()`
reports `idle | checking | uptodate | available | downloading | ready | reports `idle | checking | uptodate | available | downloading | ready |
needs_full | error` plus the latest version and download progress. needs_full | full_downloading | full_ready | error` plus the latest version,
download progress, and (when applicable) the selected full-package asset.
3. **Download + verify**: on `available`, `Check.download()` tells the 3. **Download + verify**: on `available`, `Check.download()` tells the
worker to fetch the payload, polling the growing `.part` file for worker to fetch the payload, polling the growing `.part` file for
progress. On completion the worker re-fetches `sha256sums.txt`, verifies progress. On completion the worker re-fetches `sha256sums.txt`, verifies
@@ -117,6 +121,17 @@ bundled game, in that case.
4. **Restart to apply**: a `ready` payload just sits in `updates/` until the 4. **Restart to apply**: a `ready` payload just sits in `updates/` until the
player relaunches; the next launch's Boot step (1) is what actually player relaunches; the next launch's Boot step (1) is what actually
mounts and runs it. There is no in-session hot-swap. mounts and runs it. There is no in-session hot-swap.
5. **Native-package requirement**: when `minShell` or `payloadHost` is
incompatible, the worker writes `full-update.json` and surfaces a
persistent launcher control. Android downloads the release APK, verifies
its SHA-256 entry from `sha256sums.txt`, then invokes Android's Package
Installer. The installer asks the user for consent and enforces package,
version-code, and signing-certificate compatibility. A legacy APK without
the installer bridge links its full package for one manual bootstrap
update, including when its downloaded payload already reports the latest
engine version. iOS links the sideload repository for a re-sideload; Xbox,
desktop, and PortMaster builds link their correctly named full package.
Switch keeps its native OTA flow.
## Known limitations ## Known limitations
@@ -140,6 +155,13 @@ bundled game, in that case.
still need a full reinstall (`minShell` / `payloadHost` gate → still need a full reinstall (`minShell` / `payloadHost` gate →
`needs_full`). Applying a downloaded payload on Android relaunches via `needs_full`). Applying a downloaded payload on Android relaunches via
`love.system.restartApp`; iOS still uses in-process `quit("restart")`. `love.system.restartApp`; iOS still uses in-process `quit("restart")`.
- **Android full updates are user-confirmed and certificate-bound.** The app
uses a private `FileProvider` cache path plus
`Intent.ACTION_INSTALL_PACKAGE`, checks Android 8+'s per-app
"install unknown apps" setting, and never requests a silent install. The
release job must use the original long-lived Android signing key; a new key
causes Android to reject an in-place update and requires a one-time manual
reinstall. See [mobile/ANDROID.md](../mobile/ANDROID.md).
- **Dev/source runs never self-update.** `Boot.run` returns immediately when - **Dev/source runs never self-update.** `Boot.run` returns immediately when
`love.filesystem.isFused()` is false, and a working tree's `engine` is the `love.filesystem.isFused()` is false, and a working tree's `engine` is the
`"0.0.0-dev"` placeholder that always reports up to date, so a source `"0.0.0-dev"` placeholder that always reports up to date, so a source
+25 -9
View File
@@ -24,10 +24,10 @@ local GameViewport = require("src.render.GameViewport")
-- Lua errors: persist a redacted trace in the save dir and surface a hint. -- Lua errors: persist a redacted trace in the save dir and surface a hint.
do do
local defaultErrorHandler = love.errorhandler local defaultErrorHandler = love.errorhandler or love.errhand
function love.errorhandler(msg) function love.errorhandler(msg)
local hint = SwitchDiagnostics.logLuaError(msg) local ok, hint = pcall(SwitchDiagnostics.logLuaError, msg)
if hint and type(msg) == "string" then if ok and hint and type(msg) == "string" then
msg = msg .. "\n\n" .. hint msg = msg .. "\n\n" .. hint
end end
if defaultErrorHandler then if defaultErrorHandler then
@@ -302,6 +302,8 @@ local function makeLauncher()
forceImport = forceImport, forceImport = forceImport,
onEditSave = openEditor, onEditSave = openEditor,
onEditTouchControls = openTouchControlsEditor, onEditTouchControls = openTouchControlsEditor,
-- Skin Studio owns a touch-first layout as well as the desktop workspace.
-- Keep the compatibility predicate so external hosts using it still work.
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop() onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
and openSkinStudio or nil, and openSkinStudio or nil,
}) })
@@ -327,6 +329,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()
@@ -876,6 +879,8 @@ love.handlers = love.handlers or {}
function love.handlers.audiosuspend() function love.handlers.audiosuspend()
local ChipAudio = package.loaded["src.core.ChipAudio"] local ChipAudio = package.loaded["src.core.ChipAudio"]
if ChipAudio then pcall(ChipAudio.setSuspended, true) end if ChipAudio then pcall(ChipAudio.setSuspended, true) end
local Sound = package.loaded["src.core.Sound"]
if Sound then pcall(Sound.onDeviceReset) end
end end
function love.handlers.audioreset() function love.handlers.audioreset()
@@ -928,7 +933,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
if love.system.getOS() == "iOS" then return end if love.system.getOS() == "iOS" then return end
return TouchEditor.touchpressed(id, x, y) return TouchEditor.touchpressed(id, x, y)
end end
if Studio then return end if Studio then return Studio.touchpressed(id, x, y) end
if Importer then if Importer then
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are -- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
-- polled inside the view; the istouch filter on mousepressed still drops -- polled inside the view; the istouch filter on mousepressed still drops
@@ -945,7 +950,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
if love.system.getOS() == "iOS" then return end if love.system.getOS() == "iOS" then return end
return TouchEditor.touchmoved(id, x, y) return TouchEditor.touchmoved(id, x, y)
end end
if Studio then return end if Studio then return Studio.touchmoved(id, x, y) end
if Importer then if Importer then
return Importer:touchmoved(id, x, y, dx, dy, pressure) return Importer:touchmoved(id, x, y, dx, dy, pressure)
end end
@@ -959,7 +964,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
if love.system.getOS() == "iOS" then return end if love.system.getOS() == "iOS" then return end
return TouchEditor.touchreleased(id, x, y) return TouchEditor.touchreleased(id, x, y)
end end
if Studio then return end if Studio then return Studio.touchreleased(id, x, y) end
if Importer then if Importer then
return Importer:touchreleased(id, x, y, dx, dy, pressure) return Importer:touchreleased(id, x, y, dx, dy, pressure)
end end
@@ -1012,7 +1017,12 @@ function love.mousepressed(x, y, button, istouch)
if love.system.getOS() == "Android" then return end if love.system.getOS() == "Android" then return end
return TouchEditor.mousepressed(x, y, button) return TouchEditor.mousepressed(x, y, button)
end end
if Studio then return Studio.mousepressed(x, y, button) end if Studio then
-- Mobile LÖVE sends both a touch event and an `istouch` mouse twin.
-- Studio consumes the real finger stream above, so discard the twin.
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
return Studio.mousepressed(x, y, button)
end
if Importer then if Importer then
-- love.touchpressed already forwards the primary touch into FlexLove for -- love.touchpressed already forwards the primary touch into FlexLove for
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both -- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
@@ -1047,7 +1057,10 @@ function love.mousereleased(x, y, button, istouch)
if love.system.getOS() == "Android" then return end if love.system.getOS() == "Android" then return end
return TouchEditor.mousereleased(x, y, button) return TouchEditor.mousereleased(x, y, button)
end end
if Studio then return Studio.mousereleased(x, y, button) end if Studio then
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
return Studio.mousereleased(x, y, button)
end
if Importer then return end if Importer then return end
if editorMode and EditorApp.mousereleased then if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button) return EditorApp.mousereleased(x, y, button)
@@ -1065,7 +1078,10 @@ function love.mousemoved(x, y, dx, dy, istouch)
if love.system.getOS() == "Android" then return end if love.system.getOS() == "Android" then return end
return TouchEditor.mousemoved(x, y) return TouchEditor.mousemoved(x, y)
end end
if Studio then return Studio.mousemoved(x, y) end if Studio then
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
return Studio.mousemoved(x, y)
end
if editorMode or Importer then return end if editorMode or Importer then return end
if mouseTouch then if mouseTouch then
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
+15 -5
View File
@@ -93,8 +93,9 @@ transport, exactly as a missing curl does.
love-android 11.5a expects: love-android 11.5a expects:
- **JDK 17** - **JDK 17**
- Android SDK with **API 34** - Android SDK with **API 36** (Android 16; latest 36.x Build-Tools)
- NDK **25.2.9519653** (Apple Silicon host supported) - NDK **25.2.9519653** (Apple Silicon host supported)
- **minSdk 19** (Android 4.4), **targetSdk 36** (Android 16)
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
`local.properties` when it finds `~/Library/Android/sdk`. `local.properties` when it finds `~/Library/Android/sdk`.
@@ -122,15 +123,24 @@ scripts, tests, and mobile build sources are excluded.
| `app.application_id` | `com.theboisclub.pokemonred` | | `app.application_id` | `com.theboisclub.pokemonred` |
| `app.name` | Pokemon Red | | `app.name` | Pokemon Red |
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. | | `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted | | `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*1,000,000 + minor*1,000 + patch); left as-is if `--version` is omitted |
| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept | | Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept; REQUEST_INSTALL_PACKAGES is limited to the user-confirmed full-update installer |
## Releases ## Releases
`.github/workflows/release.yml` builds the APK with `--version` set to the `.github/workflows/release.yml` builds the APK with `--version` set to the
release version and publishes it alongside the macOS/Windows/Linux builds as release version and publishes it alongside the macOS/Windows/Linux builds as
`PokemonRed-<version>-android.apk`. `gen1recomp-<version>-android.apk`.
## Signing ## Signing
Signed with the default Android keystore (no setup required). Production APKs are built with `scripts/build_android.sh --release`. They must
be signed with the same long-lived certificate as the currently installed app:
Android's Package Installer rejects an update with a different signing
certificate. Store that keystore and its passwords only in CI secrets, expose
them as `GEN1RECOMP_ANDROID_KEYSTORE`,
`GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD`, `GEN1RECOMP_ANDROID_KEY_ALIAS`, and
`GEN1RECOMP_ANDROID_KEY_PASSWORD`, and never commit the keystore. A newly
created certificate cannot update users who have an APK signed by a different
legacy key; those users need one final manual reinstall before in-app updates
can take over.
+1 -1
View File
@@ -41,7 +41,7 @@ Quick Start:
Before you start, install JDK 17 (not later not earlier). If you intend to build from Android Studio, skip this step as Before you start, install JDK 17 (not later not earlier). If you intend to build from Android Studio, skip this step as
Android Studio bundles its own JDK 17. Android Studio bundles its own JDK 17.
Install Android SDK with SDK API 34 (34.x.y) and Android NDK 25.2.9519653, set the environment variable Install Android SDK with SDK API 36 (latest 36.x Build-Tools) and Android NDK 25.2.9519653, set the environment variable
`ANDROID_SDK_ROOT` to your Android SDK location and run: `ANDROID_SDK_ROOT` to your Android SDK location and run:
``` ```
+27 -3
View File
@@ -10,9 +10,12 @@ android {
applicationId project.properties["app.application_id"] applicationId project.properties["app.application_id"]
versionCode project.properties["app.version_code"].toInteger() versionCode project.properties["app.version_code"].toInteger()
versionName project.properties["app.version_name"] versionName project.properties["app.version_name"]
minSdk 16 // NDK r25 no longer supports API 16; API 19 is Android 4.4 and keeps
compileSdk 34 // the native toolchain and package-installer bridge on a supported ABI.
targetSdk 34 minSdk 19
// Android 16 / API 36: current Android distribution target.
compileSdk 36
targetSdk 36
def getAppName = { def getAppName = {
def nameArray = project.properties["app.name_byte_array"] def nameArray = project.properties["app.name_byte_array"]
@@ -38,10 +41,31 @@ android {
ORIENTATION:project.properties["app.orientation"], ORIENTATION:project.properties["app.orientation"],
] ]
} }
// Release signing lives outside the repository. The release build script
// requires all five values below, while debug builds intentionally remain
// usable without them.
def releaseStore = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE")
def releaseStorePassword = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD")
def releaseKeyAlias = System.getenv("GEN1RECOMP_ANDROID_KEY_ALIAS")
def releaseKeyPassword = System.getenv("GEN1RECOMP_ANDROID_KEY_PASSWORD")
def hasReleaseSigning = releaseStore && releaseStorePassword && releaseKeyAlias && releaseKeyPassword
if (hasReleaseSigning) {
signingConfigs {
release {
storeFile file(releaseStore)
storePassword releaseStorePassword
keyAlias releaseKeyAlias
keyPassword releaseKeyPassword
}
}
}
buildTypes { buildTypes {
release { release {
minifyEnabled true minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
if (hasReleaseSigning) signingConfig signingConfigs.release
} }
} }
flavorDimensions = ['mode', 'recording'] flavorDimensions = ['mode', 'recording']
@@ -8,6 +8,10 @@
the link screen shows as "(Operation not permitted)" (issue #287). the link screen shows as "(Operation not permitted)" (issue #287).
scripts/build_android.sh must not strip this again. --> scripts/build_android.sh must not strip this again. -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- Required only to hand a checksum-verified, user-selected GitHub release
APK to Android's own Package Installer. Android still shows the install
confirmation and enforces package/signing-key/version compatibility. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step <!-- Step bridge: love.system.syncHealthSteps reads the hardware step
counter, which Android 10+ gates behind this runtime permission. counter, which Android 10+ gates behind this runtime permission.
Requested only on the first sync call (the Pokéwalker mod's SYNC Requested only on the first sync call (the Pokéwalker mod's SYNC
@@ -35,10 +39,22 @@
<meta-data <meta-data
android:name="android.allow_multiple_resumed_activities" android:name="android.allow_multiple_resumed_activities"
android:value="true" /> android:value="true" />
<!-- The full-update APK is copied into this small cache subdirectory
before it is handed to Package Installer. Keep the provider private
and expose only that directory, never a storage root. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.full_update_provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/full_update_paths" />
</provider>
<activity <activity
android:name="org.love2d.android.GameActivity" android:name="org.love2d.android.GameActivity"
android:exported="true" android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection|colorMode"
android:label="${NAME}" android:label="${NAME}"
android:launchMode="singleTask" android:launchMode="singleTask"
android:screenOrientation="${ORIENTATION}" android:screenOrientation="${ORIENTATION}"
@@ -55,7 +71,7 @@
</activity> </activity>
<activity <activity
android:name="org.love2d.android.GameActivity$SecondaryActivity" android:name="org.love2d.android.GameActivity$SecondaryActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection|colorMode"
android:excludeFromRecents="true" android:excludeFromRecents="true"
android:exported="false" android:exported="false"
android:launchMode="singleTask" android:launchMode="singleTask"
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Deliberately narrow: FileProvider may grant only the staged APK, never
arbitrary app, external, or shared storage. -->
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="full_update" path="full-update/" />
</paths>
+2 -1
View File
@@ -18,7 +18,8 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.1.1' // Android 16 / API 36 requires Android Gradle Plugin 8.9+.
classpath 'com.android.tools.build:gradle:8.9.2'
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files
-1
View File
@@ -15,7 +15,6 @@ app.version_name=11.5a
# No need to modify anything past this line! # No need to modify anything past this line!
android.enableJetifier=false android.enableJetifier=false
android.useAndroidX=true android.useAndroidX=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
android.nonFinalResIds=true android.nonFinalResIds=true
app.name=gen1recomp app.name=gen1recomp
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000 networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
+4 -5
View File
@@ -10,9 +10,9 @@ android {
ndkVersion '25.2.9519653' ndkVersion '25.2.9519653'
defaultConfig { defaultConfig {
minSdk 16 minSdk 19
compileSdk 34 compileSdk 36
targetSdk 34 targetSdk 36
externalNativeBuild { externalNativeBuild {
ndkBuild { ndkBuild {
arguments "-j" + Runtime.runtime.availableProcessors() arguments "-j" + Runtime.runtime.availableProcessors()
@@ -63,8 +63,7 @@ android {
buildTypes { buildTypes {
release { release {
minifyEnabled true minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
} }
debug { debug {
ndk { ndk {
@@ -283,6 +283,40 @@ bool restartApp()
return result; return result;
} }
bool installApk(const char *path)
{
if (path == nullptr || path[0] == '\0')
return false;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
// This may be called from Lua's main thread, but use the activity object
// class just like httpDownload so a future worker caller does not depend on
// the system JNI class loader finding the app class.
void *rawActivity = SDL_AndroidGetActivity();
if (rawActivity == nullptr)
return false;
jobject activityObj = (jobject) rawActivity;
jclass activity = env->GetObjectClass(activityObj);
env->DeleteLocalRef(activityObj);
jmethodID method = env->GetStaticMethodID(activity, "installApk",
"(Ljava/lang/String;Ljava/lang/String;)Z");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
jstring jpath = env->NewStringUTF(path);
jstring jroot = env->NewStringUTF(bridgeSaveDirectory());
jboolean result = env->CallStaticBooleanMethod(activity, method, jpath, jroot);
env->DeleteLocalRef(jroot);
env->DeleteLocalRef(jpath);
env->DeleteLocalRef(activity);
return result;
}
bool updateAppShortcuts(const std::vector<std::string> &versions) bool updateAppShortcuts(const std::vector<std::string> &versions)
{ {
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
@@ -90,6 +90,12 @@ bool syncHealthSteps();
**/ **/
bool restartApp(); bool restartApp();
/**
* Stages a checksum-verified APK from the current save directory and starts
* Android's user-confirmed Package Installer flow. Android-only.
**/
bool installApk(const char *path);
/** /**
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions. * Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
**/ **/
@@ -245,6 +245,16 @@ bool System::restartApp() const
#endif #endif
} }
bool System::installApk(const char *path) const
{
#ifdef LOVE_ANDROID
return love::android::installApk(path);
#else
LOVE_UNUSED(path);
return false;
#endif
}
bool System::updateShortcuts(const std::vector<std::string> &versions) const bool System::updateShortcuts(const std::vector<std::string> &versions) const
{ {
#ifdef LOVE_ANDROID #ifdef LOVE_ANDROID
@@ -143,6 +143,9 @@ public:
**/ **/
virtual bool restartApp() const; virtual bool restartApp() const;
/** Starts Android's user-confirmed install flow for a verified APK. */
virtual bool installApk(const char *path) const;
virtual bool updateShortcuts(const std::vector<std::string> &versions) const; virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
virtual std::string getLaunchGame() const; virtual std::string getLaunchGame() const;
@@ -132,6 +132,13 @@ int w_restartApp(lua_State *L)
return 1; return 1;
} }
int w_installApk(lua_State *L)
{
const char *path = luaL_checkstring(L, 1);
luax_pushboolean(L, instance()->installApk(path));
return 1;
}
int w_httpDownload(lua_State *L) int w_httpDownload(lua_State *L)
{ {
const char *url = luaL_checkstring(L, 1); const char *url = luaL_checkstring(L, 1);
@@ -325,6 +332,7 @@ static const luaL_Reg functions[] =
{ "createFile", w_createFile }, { "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps }, { "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp }, { "restartApp", w_restartApp },
{ "installApk", w_installApk },
{ "updateShortcuts", w_updateShortcuts }, { "updateShortcuts", w_updateShortcuts },
{ "getLaunchGame", w_getLaunchGame }, { "getLaunchGame", w_getLaunchGame },
{ "httpDownload", w_httpDownload }, { "httpDownload", w_httpDownload },
@@ -45,6 +45,7 @@ import android.app.AlarmManager;
import android.app.AlertDialog; import android.app.AlertDialog;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.content.Context; import android.content.Context;
import android.content.ClipData;
import android.content.DialogInterface; import android.content.DialogInterface;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
@@ -77,6 +78,7 @@ import android.view.*;
import androidx.annotation.Keep; import androidx.annotation.Keep;
import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityCompat;
import androidx.core.content.FileProvider;
public class GameActivity extends SDLActivity { public class GameActivity extends SDLActivity {
private static DisplayMetrics metrics = null; private static DisplayMetrics metrics = null;
@@ -398,11 +400,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 +417,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 +433,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();
@@ -690,6 +698,103 @@ public class GameActivity extends SDLActivity {
return true; // unreachable, but keeps the JNI signature honest return true; // unreachable, but keeps the JNI signature honest
} }
/**
* Stages a verified release APK in cache and asks Android's Package
* Installer to update this package. This never silently installs an APK:
* the platform owns both the unknown-sources consent and final install
* confirmation. `updateRoot` comes from the native save directory and is
* checked before any file is read, so a Lua caller cannot turn this into a
* general-purpose local-file sharing bridge.
*/
@Keep
public static boolean installApk(final String sourcePath, final String updateRoot) {
final GameActivity self = (GameActivity) mSingleton;
if (self == null || sourcePath == null || updateRoot == null) return false;
final File source;
try {
source = new File(sourcePath).getCanonicalFile();
File root = new File(updateRoot, "updates").getCanonicalFile();
String rootPath = root.getPath() + File.separator;
if (!source.getPath().startsWith(rootPath)
|| !source.isFile() || source.length() == 0
|| !source.getName().matches("gen1recomp-[0-9]+\\.[0-9]+\\.[0-9]+-android\\.apk")) {
return false;
}
} catch (IOException e) {
Log.d("GameActivity", "invalid update APK path: " + e.getMessage());
return false;
}
// Android 8+ lets the user decide whether this app is trusted to
// request package installs. Send them to the per-app setting first;
// they deliberately tap Install again after granting it.
if (android.os.Build.VERSION.SDK_INT >= 26
&& !self.getPackageManager().canRequestPackageInstalls()) {
try {
Intent settings = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:" + self.getPackageName()));
self.startActivity(settings);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open install-source settings: " + e.getMessage());
return false;
}
}
// Copying an APK can be large; keep both I/O and checksum-verified
// source access off the UI thread. The FileProvider exposes this cache
// child only after it has been fully written and renamed.
new Thread(new Runnable() {
@Override public void run() {
File stagedDir = new File(self.getCacheDir(), "full-update");
File partial = new File(stagedDir, "update.apk.part");
File staged = new File(stagedDir, "update.apk");
try {
if (!stagedDir.exists() && !stagedDir.mkdirs()) return;
copyFile(source, partial);
if (staged.exists() && !staged.delete()) return;
if (!partial.renameTo(staged)) return;
self.runOnUiThread(new Runnable() {
@Override public void run() { launchPackageInstaller(self, staged); }
});
} catch (Exception e) {
Log.d("GameActivity", "could not stage update APK: " + e.getMessage());
} finally {
if (partial.exists()) partial.delete();
}
}
}, "gen1recomp-apk-stage").start();
return true;
}
private static void copyFile(File source, File destination) throws IOException {
InputStream in = new BufferedInputStream(new FileInputStream(source));
OutputStream out = new BufferedOutputStream(new FileOutputStream(destination));
try {
byte[] buffer = new byte[32768];
int count;
while ((count = in.read(buffer)) != -1) out.write(buffer, 0, count);
} finally {
try { out.close(); } catch (IOException ignored) {}
try { in.close(); } catch (IOException ignored) {}
}
}
private static void launchPackageInstaller(GameActivity activity, File apk) {
try {
Context context = activity.getApplicationContext();
Uri uri = FileProvider.getUriForFile(context,
context.getPackageName() + ".full_update_provider", apk);
Intent install = new Intent(Intent.ACTION_INSTALL_PACKAGE);
install.setData(uri);
install.setClipData(ClipData.newRawUri("apk", uri));
install.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
activity.startActivity(install);
} catch (Exception e) {
Log.d("GameActivity", "could not open package installer: " + e.getMessage());
}
}
@Keep @Keep
public static String getLaunchGame() { public static String getLaunchGame() {
return initialGame != null ? initialGame : ""; return initialGame != null ? initialGame : "";
@@ -1933,6 +2038,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 +2069,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 +2141,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 +2153,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 {
+7
View File
@@ -12,6 +12,13 @@
"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", "version": "0.2.10",
"date": "2026-08-19", "date": "2026-08-19",
+39 -10
View File
@@ -1,19 +1,20 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a. # Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
# #
# Usage: scripts/build_android.sh [--version X.Y.Z] [--package-only] # Usage: scripts/build_android.sh [--version X.Y.Z] [--release] [--package-only]
# #
# --version X.Y.Z set app.version_name / app.version_code (else left as-is) # --version X.Y.Z set app.version_name / app.version_code (else left as-is)
# --release build the production-signed release APK (requires the
# GEN1RECOMP_ANDROID_* signing environment variables)
# --package-only zip game.love + apply branding; skip gradle # --package-only zip game.love + apply branding; skip gradle
# #
# Prerequisites: # Prerequisites:
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md) # - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
# - Android SDK + NDK (SDK API 34, NDK 25.2.9519653) # - Android SDK + NDK (SDK API 36, NDK 25.2.9519653)
# - JDK 17 # - JDK 17
# #
# Output (after gradle): # Output (after gradle):
# dist/android/debug/*.apk (convenience copy) # dist/android/debug/*.apk (normal local build) or dist/android/release/*.apk
# mobile/android/app/build/outputs/apk/embedNoRecord/debug/*.apk
set -euo pipefail set -euo pipefail
@@ -26,6 +27,7 @@ APP_NAME="gen1recomp"
APPLICATION_ID="com.theboisclub.pokemonred" APPLICATION_ID="com.theboisclub.pokemonred"
LOVE_ANDROID_VERSION="11.5a" LOVE_ANDROID_VERSION="11.5a"
NDK_VERSION="25.2.9519653" NDK_VERSION="25.2.9519653"
ANDROID_API="36"
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json" 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"
@@ -35,6 +37,7 @@ SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/br
VERSION="" VERSION=""
PACKAGE_ONLY=false PACKAGE_ONLY=false
RELEASE=false
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; }
@@ -44,11 +47,12 @@ while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--version) VERSION="$2"; shift ;; --version) VERSION="$2"; shift ;;
--package-only) PACKAGE_ONLY=true ;; --package-only) PACKAGE_ONLY=true ;;
--release) RELEASE=true ;;
-h|--help) -h|--help)
sed -n '2,20p' "$0" sed -n '2,20p' "$0"
exit 0 exit 0
;; ;;
*) fail "unknown argument: $1 (try --version X.Y.Z or --package-only)" ;; *) fail "unknown argument: $1 (try --version X.Y.Z, --release, or --package-only)" ;;
esac esac
shift shift
done done
@@ -62,7 +66,22 @@ if [ -n "$VERSION" ]; then
rest="${VERSION#*.}" rest="${VERSION#*.}"
minor="${rest%%.*}" minor="${rest%%.*}"
patch="${rest##*.}" patch="${rest##*.}"
VERSION_CODE=$((major * 10000 + minor * 100 + patch)) # Reserve three digits for each lower component. This stays monotonic across
# 1.0.100 -> 1.1.0, unlike the old two-digit encoding, and remains inside
# Android's signed 32-bit versionCode range for normal release versions.
if [ "$minor" -gt 999 ] || [ "$patch" -gt 999 ] || [ "$major" -gt 2099 ]; then
fail "--version components exceed Android versionCode limits"
fi
VERSION_CODE=$((major * 1000000 + minor * 1000 + patch))
fi
if $RELEASE; then
for var in GEN1RECOMP_ANDROID_KEYSTORE GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD \
GEN1RECOMP_ANDROID_KEY_ALIAS GEN1RECOMP_ANDROID_KEY_PASSWORD; do
[ -n "${!var:-}" ] || fail "--release requires $var"
done
[ -f "$GEN1RECOMP_ANDROID_KEYSTORE" ] \
|| fail "Android signing keystore does not exist: $GEN1RECOMP_ANDROID_KEYSTORE"
fi fi
# --------------------------------------------------------------- preconditions # --------------------------------------------------------------- preconditions
@@ -389,13 +408,18 @@ require_android_sdk() {
export ANDROID_SDK_ROOT=\$HOME/Library/Android/sdk export ANDROID_SDK_ROOT=\$HOME/Library/Android/sdk
or create mobile/android/local.properties with: or create mobile/android/local.properties with:
sdk.dir=/path/to/Android/sdk sdk.dir=/path/to/Android/sdk
love-android $LOVE_ANDROID_VERSION expects SDK API 34 and NDK $NDK_VERSION love-android $LOVE_ANDROID_VERSION expects SDK API $ANDROID_API and NDK $NDK_VERSION
(see mobile/ANDROID.md)." (see mobile/ANDROID.md)."
fi fi
export ANDROID_SDK_ROOT="$sdk" export ANDROID_SDK_ROOT="$sdk"
export ANDROID_HOME="$sdk" export ANDROID_HOME="$sdk"
if [ ! -d "$sdk/platforms/android-$ANDROID_API" ]; then
fail "Android SDK platform android-$ANDROID_API is not installed.
Install Android $ANDROID_API (and the latest 36.x Build-Tools) in SDK Manager."
fi
local props="$ANDROID_DIR/local.properties" local props="$ANDROID_DIR/local.properties"
# Always rewrite so a leftover Docker sdk.dir=/opt/android-sdk cannot stick. # Always rewrite so a leftover Docker sdk.dir=/opt/android-sdk cannot stick.
printf 'sdk.dir=%s\n' "$sdk" > "$props" printf 'sdk.dir=%s\n' "$sdk" > "$props"
@@ -412,7 +436,12 @@ require_android_sdk() {
# --------------------------------------------------------------- gradle # --------------------------------------------------------------- gradle
run_gradle() { run_gradle() {
local task="assembleEmbedNoRecordDebug" local variant="debug"
$RELEASE && variant="release"
# Keep this compatible with macOS's bundled Bash 3.2 (no ${var^}).
local variant_title="Debug"
$RELEASE && variant_title="Release"
local task="assembleEmbedNoRecord$variant_title"
local build_dir="$ANDROID_DIR" local build_dir="$ANDROID_DIR"
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in # ndk-build is GNU make underneath and cannot cope with spaces anywhere in
@@ -447,12 +476,12 @@ run_gradle() {
You can still iterate on the .love payload with: scripts/build_android.sh --package-only" You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
fi fi
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug" local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/$variant"
if [ -d "$out_dir" ]; then if [ -d "$out_dir" ]; then
say "APK output:" say "APK output:"
find "$out_dir" -name '*.apk' -exec ls -lh {} \; find "$out_dir" -name '*.apk' -exec ls -lh {} \;
local dist_dir="$DIST/debug" local dist_dir="$DIST/$variant"
rm -rf "$dist_dir" rm -rf "$dist_dir"
mkdir -p "$dist_dir" mkdir -p "$dist_dir"
find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \; find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \;
+21 -2
View File
@@ -7,7 +7,8 @@
# the nil global), unused values, unreachable code. The .luacheckrc mutes the # the nil global), unused values, unreachable code. The .luacheckrc mutes the
# cosmetic categories the codebase lives with, so what prints is worth a look. # cosmetic categories the codebase lives with, so what prints is worth a look.
# #
# scripts/lint.sh lint src/ # scripts/lint.sh full advisory report over every shipped tree
# scripts/lint.sh --gate only the codes CI blocks on (0xx, 1xx, 511)
# scripts/lint.sh src tools lint specific paths # scripts/lint.sh src tools lint specific paths
# #
# Install once with: luarocks install luacheck # Install once with: luarocks install luacheck
@@ -15,9 +16,27 @@
set -uo pipefail set -uo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
DEFAULT_PATHS=(main.lua conf.lua src data/scripts mods tools)
GATE=0
if [ "${1:-}" = "--gate" ]; then
GATE=1
shift
fi
if ! command -v luacheck >/dev/null 2>&1; then if ! command -v luacheck >/dev/null 2>&1; then
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2 echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
exit 2 exit 2
fi fi
luacheck "${@:-src}" if [ "$#" -gt 0 ]; then
PATHS=("$@")
else
PATHS=("${DEFAULT_PATHS[@]}")
fi
if [ "$GATE" = "1" ]; then
exec luacheck "${PATHS[@]}" -q --codes --only 0 1 511
fi
luacheck "${PATHS[@]}"
+9
View File
@@ -66,6 +66,15 @@ run_tier() {
# ------- ROM-free tiers: these are what CI runs # ------- ROM-free tiers: these are what CI runs
if command -v luacheck >/dev/null 2>&1; then
run_tier "T0 luacheck gate (undefined globals, unreachable code)" \
./scripts/lint.sh --gate
else
echo ""
echo "-- T0 luacheck gate: skipped (no luacheck on PATH --"
echo " luarocks install luacheck; CI installs and gates on it regardless)"
fi
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 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
+1
View File
@@ -8,6 +8,7 @@ local BattleSafety = {}
local BATTLE_BUSY_FIELDS = { local BATTLE_BUSY_FIELDS = {
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI", "current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn", "waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
"shrinkOut",
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result", "introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
} }
+178 -39
View File
@@ -538,6 +538,7 @@ end
local function makeBattler(data, mon, isPlayer, save) local function makeBattler(data, mon, isPlayer, save)
local def = data.pokemon[mon.species] local def = data.pokemon[mon.species]
require("src.pokemon.Stats").ensure(def, mon)
local badgeBoosts = data.constants and data.constants.badgeBoosts local badgeBoosts = data.constants and data.constants.badgeBoosts
local badges = nil local badges = nil
if isPlayer and save then if isPlayer and save then
@@ -1364,7 +1365,7 @@ function BattleState:updateQueue()
-- subanimation (or just the coarse fx when animations are off). -- subanimation (or just the coarse fx when animations are off).
-- item.hit carries the target's blink + damage sound, applied when -- item.hit carries the target's blink + damage sound, applied when
-- the animation ends (hitRow rows carry a hit with no animation -- -- the animation ends (hitRow rows carry a hit with no animation --
-- thrash/rage continuation turns that skip the announcement). -- Mimic, whose animation waits on a successful copy).
if item.anim or item.hitRow then if item.anim or item.hitRow then
-- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then -- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
-- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass -- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass
@@ -1718,9 +1719,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 +1863,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
@@ -2489,9 +2496,12 @@ function BattleState:openOldManBag()
-- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by -- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by
-- the Viridian tutorial and Oak's catch, has one. -- the Viridian tutorial and Oak's catch, has one.
local qty = require("src.core.GameVersion").isYellow() and "x1" or "x50" local qty = require("src.core.GameVersion").isYellow() and "x1" or "x50"
-- the tutorial bag rides DisplayBagMenu's LIST_MENU_BOX over the battle
-- screen (engine/battle/core.asm:2210)
list = ListMenu.new(game, "ITEMS", { list = ListMenu.new(game, "ITEMS", {
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = qty }, { value = "POKE_BALL", label = Strings("POKé BALL"), right = qty },
}, { }, {
itemBox = true,
script = function(l) script = function(l)
l.scriptTimer = (l.scriptTimer or 0) + 1 l.scriptTimer = (l.scriptTimer or 0) + 1
if l.scriptTimer == 81 then if l.scriptTimer == 81 then
@@ -2702,9 +2712,11 @@ function BattleState:resolveSwitch(newMon)
self.afterQueue = "menu" self.afterQueue = "menu"
self:act(function() self:act(function()
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the -- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
-- outgoing pic and holds 50 frames before the mon is recalled -- outgoing pic and holds 50 frames, then AnimateRetreatingPlayerMon
-- runs before the mon is recalled
self:sayNextAuto(self:withdrawText(self.player.name), self:sayNextAuto(self:withdrawText(self.player.name),
Timing.SWITCH_PLAYER_MON) Timing.SWITCH_PLAYER_MON)
self:queueRetreatAnim()
self:actNext(function() self:actNext(function()
self:restoreMimicked(self.player) -- the battle copy leaves with it self:restoreMimicked(self.player) -- the battle copy leaves with it
local previous = self.player local previous = self.player
@@ -3316,6 +3328,26 @@ function BattleState:queueSendOutAnim(append)
if append then self:act(fn) else self:actNext(fn) end if append then self:act(fn) else self:actNext(fn) end
end end
-- AnimateRetreatingPlayerMon (core.asm:1769-1796); the Yellow starter Pikachu
-- slides off instead (pokeyellow core.asm:1862-1866, animations.asm:1259)
function BattleState:queueRetreatAnim()
if self:starterPikachuSendOut() then
self:actNext(function() self:slidePic("playerMon", 0, -64, 8, 3) end)
self:waitNext(24)
self:actNext(function()
-- .clearScreenArea keeps the 7x7 area blank until the swap
-- (pokeyellow core.asm:1867-1871) (#1545)
self.sendingOut = true
self:slidePic("playerMon")
end)
else
self:actNext(function()
self.shrinkOut = { battler = self.player, frame = 0 }
end)
self:waitNext(7)
end
end
-- Should the low-health alarm sound this frame? pokered keys it off -- Should the low-health alarm sound this frame? pokered keys it off
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets -- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is -- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
@@ -3532,6 +3564,12 @@ function BattleState:updateFx()
self.growIn.frame = self.growIn.frame + 1 self.growIn.frame = self.growIn.frame + 1
if self.growIn.frame >= 12 then self.growIn = nil end if self.growIn.frame >= 12 then self.growIn = nil end
end end
-- the retreat shrink (AnimateRetreatingPlayerMon): 4+3 frames, then the
-- 7x7 area holds cleared (scale 0) until the swap replaces the battler
if self.shrinkOut then
self.shrinkOut.frame = self.shrinkOut.frame + 1
if self.shrinkOut.battler ~= self.player then self.shrinkOut = nil end
end
-- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren -- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren
-- loops while the player's bar is red; see lowHealthAlarmActive -- loops while the player's bar is red; see lowHealthAlarmActive
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
@@ -3638,12 +3676,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)
@@ -3795,6 +3834,8 @@ function BattleState:statusInterrupt(user, target, selectedId)
{ rng = self.rng, forceCrit = false, typeless = true, { rng = self.rng, forceCrit = false, typeless = true,
screens = target }) screens = target })
self:sayNext(self:romText("_HurtItselfText", "It hurt itself in\nits confusion!")) self:sayNext(self:romText("_HurtItselfText", "It hurt itself in\nits confusion!"))
-- HandleSelfConfusionDamage (core.asm:3706-3714, enemy side :5807-5811)
self:animNext("POUND", not user.isPlayer)
self:clearVolatiles(user, true) self:clearVolatiles(user, true)
self:applyDamage(user, dmg) self:applyDamage(user, dmg)
if user.mon.hp <= 0 then self:onFaint(user) end if user.mon.hp <= 0 then self:onFaint(user) end
@@ -3887,25 +3928,38 @@ function BattleState:performMove(user, target, moveInst, isCalled)
end end
self.moveAnimRow = nil self.moveAnimRow = nil
if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then local thrashing = user.thrashTurns and moveInst == user.thrashMove
and user.thrashAnnounced or false
if thrashing then
-- .ThrashingAboutCheck (core.asm:3531-3552)
self:sayNextAuto(self:romText("_ThrashingAboutText", "%s's\nthrashing about!",
displayName(user)))
user.thrashTurns = user.thrashTurns - 1
if user.thrashTurns <= 0 then
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
if not user.confusedTurns then user.confusedTurns = self.rng(2, 5) end
end
else
self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name)) self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
-- the move's animation plays right after the announcement; the end
-- damage path attaches the target's hit blink to this row so the -- PlayCurrentMoveAnimation follows the announcement; Mimic (announceAnim
-- blink follows the animation (pokered's order). Mimic is the -- = false) queues it from applyMimic after a successful copy
-- exception (announceAnim = false): PlayCurrentMoveAnimation runs
-- only after a successful copy, never on a miss -- applyMimic queues it
if not (record and record.announceAnim == false) then if not (record and record.announceAnim == false) then
self.nextInsert = (self.nextInsert or 0) + 1 self.nextInsert = (self.nextInsert or 0) + 1
self.moveAnimRow = { anim = move.id, attackerIsPlayer = user.isPlayer } -- ld a, THRASH / ld [wPlayerMoveNum] (core.asm:3534-3535, :5909-5910) #1577
self.moveAnimRow = { anim = thrashing and "THRASH" or move.id,
attackerIsPlayer = user.isPlayer }
table.insert(self.queue, self.nextInsert, self.moveAnimRow) table.insert(self.queue, self.nextInsert, self.moveAnimRow)
end end
end
Runtime.emit("battle.move_used", { Runtime.emit("battle.move_used", {
battle = self, user = user, target = target, move = move, battle = self, user = user, target = target, move = move,
isCalled = isCalled or false, isCalled = isCalled or false,
}) })
local ctx = EffectRegistry.makeCtx(self, user, target, move, moveInst, isCalled) local ctx = EffectRegistry.makeCtx(self, user, target, move, moveInst, isCalled)
-- .ThrashingAboutCheck jumps past JumpMoveEffect into PlayerCalcMoveDamage
-- (core.asm:3540), so SpecialEffectsCont never re-runs on a locked turn
ctx.thrashing = thrashing
-- Metronome / Mirror Move re-entry; a nil pick means the record -- Metronome / Mirror Move re-entry; a nil pick means the record
-- already said its failure text -- already said its failure text
@@ -3927,7 +3981,17 @@ function BattleState:performMove(user, target, moveInst, isCalled)
-- record (chargeText) and the invulnerability from semiInvulnerable, -- record (chargeText) and the invulnerability from semiInvulnerable,
-- falling back to the id tables (Fly AND Dig go semi-invulnerable: -- falling back to the id tables (Fly AND Dig go semi-invulnerable:
-- ChargeEffect sets INVULNERABLE for both) -- ChargeEffect sets INVULNERABLE for both)
if record and record.charge and not releasing then local chargeRequired = record and record.charge ~= nil and not releasing
if chargeRequired and Runtime.wantsHook("battle.charge_required") then
local required = Runtime.call("battle.charge_required", function(c)
return c.charge
end, {
battle = self, user = user, target = target, move = move,
charge = true, isCalled = isCalled or false,
})
chargeRequired = required ~= false
end
if chargeRequired then
self:cancelMoveAnim() self:cancelMoveAnim()
user.charging = moveInst user.charging = moveInst
user.chargeReady = true user.chargeReady = true
@@ -4142,8 +4206,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
@@ -4174,8 +4242,11 @@ function BattleState:awardExp()
end end
local function applyShare(mon, split, announce) local function applyShare(mon, split, announce)
local playerId = self.game.save.player and self.game.save.player.id local playerId = self.game.save.player and self.game.save.player.id
local traded = mon.traded == true -- GainExperience (engine/battle/experience.asm:69-88) compares the
or (mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId) -- stored MON_OTID against wPlayerID every award; no persistent flag
-- mon.traded covers otId-less mons (repairTradedOtIds, old link peers) #1488
local traded = playerId ~= nil and ((mon.otId ~= nil and mon.otId ~= playerId)
or (mon.otId == nil and mon.traded == true))
local levels, gained = Experience.apply(self.data, mon, self.enemy.def, local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
self.enemy.mon.level, self.kind == "trainer", self.enemy.mon.level, self.kind == "trainer",
split, traded) split, traded)
@@ -4319,22 +4390,44 @@ 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))
-- EnemySendOutFirstMon .next9/.next8 (core.asm:1390-1409) and
-- HasMonFainted's NoWillText (core.asm:1473-1488)
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),
function(yes) function(yes)
if not yes then return end if not yes then return end
local game = self.game local game = self.game
Screens.push(game, "PartyMenu", { local shiftOpts, reopenShift
reopenShift = function(text)
table.insert(self.queue, 1, { ui = function()
return self:buildScreen("PartyMenu", shiftOpts)
end })
table.insert(self.queue, 1, { text = text })
end
shiftOpts = {
battle = self, battle = self,
party = self:playerPartyView(), party = self:playerPartyView(),
forceSwitch = true, forceSwitch = true,
onSwitch = function(mon) onSwitch = function(mon)
if mon ~= self.player.mon and mon.hp > 0 then if mon == self.player.mon then
reopenShift(self:romText("_AlreadyOutText",
"%s is\nalready out!", self.player.name))
elseif mon.hp <= 0 then
reopenShift(self:romText("_NoWillText", "There's no will\nto fight!"))
else
shiftSwitchMon = mon shiftSwitchMon = mon
end end
end, end,
}) }
Screens.push(game, "PartyMenu", shiftOpts)
end, { box = Theme.trainerSwitchBox }) end, { box = Theme.trainerSwitchBox })
end end
self:act(function() self:act(function()
@@ -4362,7 +4455,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)
@@ -4376,10 +4470,11 @@ function BattleState:enemyMonFainted()
local mon = shiftSwitchMon local mon = shiftSwitchMon
if not mon then return end if not mon then return end
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame -- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
-- hold, then the recall and the send-out -- hold, AnimateRetreatingPlayerMon, then the recall and the send-out
self.nextInsert = 0 self.nextInsert = 0
self:sayNextAuto(self:withdrawText(self.player.name), self:sayNextAuto(self:withdrawText(self.player.name),
Timing.SWITCH_PLAYER_MON) Timing.SWITCH_PLAYER_MON)
self:queueRetreatAnim()
self:actNext(function() self:actNext(function()
local previous = self.player local previous = self.player
self.player = makeBattler(self.data, mon, true, self.game.save) self.player = makeBattler(self.data, mon, true, self.game.save)
@@ -4451,9 +4546,22 @@ function BattleState:enemyMonFainted()
-- TrainerNamePointers aims those entries at wTrainerName). The tag -- TrainerNamePointers aims those entries at wTrainerName). The tag
-- prints once, so a `para` page carries no second copy (#566). -- prints once, so a `para` page carries no second copy (#566).
local tag = self.trainer and self.trainer.name local tag = self.trainer and self.trainer.name
-- the badge jingle (sound_get_item_1 and friends) rides the armed
-- line's first page, as the script's text command would (#1606)
local sfx = self.endBattleSound
local data = self.data
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
if page ~= "" then if page ~= "" then
self:sayNext(tag and (tag .. ": " .. page) or page) local line = tag and (tag .. ": " .. page) or page
if sfx then
local id = sfx
self:sayNextWaitSfx(line, function()
return require("src.core.Sound").play(data, id)
end)
sfx = nil
else
self:sayNext(line)
end
tag = nil tag = nil
end end
end end
@@ -4878,7 +4986,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 +5008,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 +5119,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)
@@ -5055,10 +5177,14 @@ function BattleState:openParty()
battle = self, battle = self,
party = self:playerPartyView(), party = self:playerPartyView(),
onSwitch = function(mon) onSwitch = function(mon)
-- PartyMenuOrRockOrRun's SWITCH .partyMonDeselected (core.asm:2396-2408)
if mon == self.player.mon then if mon == self.player.mon then
self:say(Strings("%s is\nalready out!", self.player.name)) self:say(self:romText("_AlreadyOutText",
"%s is\nalready out!", self.player.name))
self:act(function() self:openParty() end)
elseif mon.hp <= 0 then elseif mon.hp <= 0 then
self:say(self:romText("_NoWillText", "There's no will\nto fight!")) self:say(self:romText("_NoWillText", "There's no will\nto fight!"))
self:act(function() self:openParty() end)
else else
self:resolveSwitch(mon) self:resolveSwitch(mon)
end end
@@ -5202,6 +5328,16 @@ function BattleState:growInScale(battler)
return f < 3 and 0 or f < 7 and 3 / 7 or 5 / 7 return f < 3 and 0 or f < 7 and 3 / 7 or 5 / 7
end end
-- AnimateRetreatingPlayerMon's CopyDownscaledMonTiles stages
-- (core.asm:1769-1796)
function BattleState:shrinkOutScale(battler)
local shrink = self.shrinkOut
if not shrink or shrink.battler ~= battler then return nil end
-- scale 0 past Delay3: the area stays cleared until the swap
-- (core.asm:1790-1796) (#1563)
return shrink.frame < 4 and 5 / 7 or shrink.frame < 7 and 3 / 7 or 0
end
-- battler hidden this frame? (damage blink) -- battler hidden this frame? (damage blink)
-- --
-- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits -- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits
@@ -5838,15 +5974,18 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
local s = BattleState.resolveBattleScale(self.data, "back", local s = BattleState.resolveBattleScale(self.data, "back",
imagePathOf(self.player.sprite), imagePathOf(self.player.sprite),
self.player.mon and self.player.mon.species) self.player.mon and self.player.mon.species)
local gs = self:growInScale(self.player) local gs = self:growInScale(self.player) or self:shrinkOutScale(self.player)
if gs then if gs then
-- the player-side AnimateSendingOutMon grow (after the poof, -- the player-side AnimateSendingOutMon grow (core.asm:1757-1762) and
-- core.asm:1757-1762): feet pinned at y=96, horizontal centre -- the AnimateRetreatingPlayerMon shrink (core.asm:1769-1796)
-- pinned, mod scale composed with the grow stage
local eff = s * gs local eff = s * gs
if eff > 0 then if eff > 0 then
-- the retreat stages sit one tile right of the grow-in's
-- (hlcoord 3,7 / 4,9 vs 2,7 / 3,9, core.asm:1770-1788) (#1563)
local shrinkX = self.shrinkOut
and self.shrinkOut.battler == self.player and 8 or 0
love.graphics.draw(img, love.graphics.draw(img,
8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx, 8 + shrinkX - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff) 96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
end end
else else
+6 -4
View File
@@ -108,13 +108,17 @@ end
-- The damaging pipeline, extracted from the performMove monolith: every -- The damaging pipeline, extracted from the performMove monolith: every
-- stage keeps the original's exact check order and rng consumption -- stage keeps the original's exact check order and rng consumption
-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy -> -- (pre-accuracy -> invulnerability -> gate -> hit count -> accuracy ->
-- damage choice -> hits -> messages -> after-damage -> secondary run). -- damage choice -> hits -> messages -> after-damage -> secondary run).
function EffectRegistry.runDamaging(battle, ctx, record) function EffectRegistry.runDamaging(battle, ctx, record)
local user, target = ctx.user, ctx.target local user, target = ctx.user, ctx.target
local move, moveInst = ctx.move, ctx.moveInst local move, moveInst = ctx.move, ctx.moveInst
local neverMiss = record and record.neverMiss local neverMiss = record and record.neverMiss
-- SpecialEffectsCont's JumpMoveEffect (core.asm:3129-3133) runs before
-- MoveHitTest's INVULNERABLE test (:3150), mid-Fly/Dig included (#1565)
if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end
-- Swift ignores semi-invulnerability (MoveHitTest returns hit for -- Swift ignores semi-invulnerability (MoveHitTest returns hit for
-- SWIFT_EFFECT before the INVULNERABLE check) -- SWIFT_EFFECT before the INVULNERABLE check)
if target.invulnerable and not neverMiss then if target.invulnerable and not neverMiss then
@@ -143,8 +147,6 @@ function EffectRegistry.runDamaging(battle, ctx, record)
local hits = hitCount(ctx, record) local hits = hitCount(ctx, record)
if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end
if not neverMiss then if not neverMiss then
if not battle:accuracyRoll(move, user, target) then if not battle:accuracyRoll(move, user, target) then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
@@ -221,7 +223,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType -- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the -- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
-- announcement-time moveAnimRow, later hits queue fresh anim rows. -- announcement-time moveAnimRow, later hits queue fresh anim rows.
-- Thrash/rage continuations have no announcement anim -- a bare -- Mimic queues no announcement anim (announceAnim = false) -- a bare
-- hitRow carries the blink instead. -- hitRow carries the blink instead.
-- PlayApplyingAttackSound (engine/battle/animations.asm, the routine after -- PlayApplyingAttackSound (engine/battle/animations.asm, the routine after
-- PlayApplyingAttackAnimation) picks the sound off wDamageMultipliers -- 10 -- PlayApplyingAttackAnimation) picks the sound off wDamageMultipliers -- 10
+7 -20
View File
@@ -582,30 +582,15 @@ MoveEffects.full = {
}, },
THRASH_PETAL_DANCE_EFFECT = { THRASH_PETAL_DANCE_EFFECT = {
-- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage -- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage
-- (data/battle/special_effects.asm:22) and animates the setup turn -- (data/battle/special_effects.asm:22, core.asm:3531-3552)
beforeAccuracy = function(ctx) beforeAccuracy = function(ctx)
local user = ctx.user local user = ctx.user
if not user.thrashTurns then if ctx.thrashing or user.thrashTurns then return end
ctx.battle:animBeforeMove(
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
end
end,
afterDamage = function(ctx)
local user = ctx.user
if not user.thrashTurns then
user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion
user.thrashMove = ctx.moveInst user.thrashMove = ctx.moveInst
user.thrashAnnounced = true user.thrashAnnounced = true
else ctx.battle:animBeforeMove(
user.thrashTurns = user.thrashTurns - 1 user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
if user.thrashTurns <= 0 then
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
if not user.confusedTurns then
user.confusedTurns = ctx.rng(2, 5)
ctx.say(romText(ctx.battle.data, "_BecameConfusedText", "%s\nbecame confused!", displayName(user)))
end
end
end
end, end,
}, },
JUMP_KICK_EFFECT = { JUMP_KICK_EFFECT = {
@@ -660,7 +645,9 @@ MoveEffects.full = {
user.bideTurns = ctx.rng(2, 3) user.bideTurns = ctx.rng(2, 3)
user.bideDamage = 0 user.bideDamage = 0
ctx.battle:cancelMoveAnim() ctx.battle:cancelMoveAnim()
ctx.anim(user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM") ctx.battle:animBeforeMove(
user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM",
user.isPlayer)
ctx.say(Strings("%s\nis storing energy!", displayName(user))) ctx.say(Strings("%s\nis storing energy!", displayName(user)))
end, end,
}, },
+265 -50
View File
@@ -166,6 +166,9 @@ Battle.SUBSTATUS_ITEMS = {
-- be run from or Roared away. -- be run from or Roared away.
Battle.BATTLETYPE_FORCESHINY = 7 Battle.BATTLETYPE_FORCESHINY = 7
Battle.BATTLETYPE_TRAP = 9 Battle.BATTLETYPE_TRAP = 9
-- LostBattle's .canlose arm (engine/battle/core.asm:2766): the only battle
-- type whose loss still prints the trainer's own line instead of a whiteout.
Battle.BATTLETYPE_CANLOSE = 1
-- BadgeStatBoosts (engine/battle/core.asm:6534): each of these Johto badges -- BadgeStatBoosts (engine/battle/core.asm:6534): each of these Johto badges
-- raises the PLAYER's in-battle stat by 1/8. The routine walks every other -- raises the PLAYER's in-battle stat by 1/8. The routine walks every other
@@ -889,16 +892,27 @@ Battle.PRIORITY = {
EFFECT_ENDURE = 3, EFFECT_ENDURE = 3,
EFFECT_COUNTER = -1, EFFECT_COUNTER = -1,
EFFECT_MIRROR_COAT = -1, EFFECT_MIRROR_COAT = -1,
EFFECT_VITAL_THROW = -1, EFFECT_FORCE_SWITCH = -1, -- Whirlwind, Roar: priority 0, below BASE
} }
function Battle:movePriority(moveId) function Battle:movePriority(moveId)
-- GetMovePriority `cp VITAL_THROW / ld a, 0 / ret z`
-- (engine/battle/core.asm:787-789).
if moveId == "VITAL_THROW" then return -1 end
local def = self:moveDef(moveId) local def = self:moveDef(moveId)
return (def and Battle.PRIORITY[def.effect]) or 0 return (def and Battle.PRIORITY[def.effect]) or 0
end end
-- engine/battle/effect_commands.asm:192-197 (enemy twin :383-390)
Battle.SLEEP_BYPASS_MOVES = { SNORE = true, SLEEP_TALK = true }
-- Can this mon act? Returns true, or false plus the message the cart prints. -- Can this mon act? Returns true, or false plus the message the cart prints.
function Battle:canAct(mon) -- `moveId` is wCurPlayerMove / wCurEnemyMove (effect_commands.asm:193).
local function clearBide(state)
state.bideTurns, state.bideStored, state.bideMove = nil, nil, nil
end
local function checkTurn(self, mon, moveId)
local name = self:monName(mon) local name = self:monName(mon)
-- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads -- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads
-- it first, clears it, prints MustRechargeText and jumps to EndTurn, so a mon -- it first, clears it, prints MustRechargeText and jumps to EndTurn, so a mon
@@ -919,7 +933,11 @@ function Battle:canAct(mon)
local beforeMove = record and record.beforeMove local beforeMove = record and record.beforeMove
if beforeMove if beforeMove
and (record.beforeMovePriority or 0) > Battle.VOLATILE_PRIORITY then and (record.beforeMovePriority or 0) > Battle.VOLATILE_PRIORITY then
return beforeMove(self, mon, name) and true or false -- engine/battle/effect_commands.asm:188-200
local bypass = mon.status == "sleep" and Battle.SLEEP_BYPASS_MOVES[moveId]
local acted = beforeMove(self, mon, name) and true or false
if acted or not bypass then return acted end
beforeMove = nil
end end
-- SUBSTATUS_FLINCHED, read and cleared right after the freeze check -- SUBSTATUS_FLINCHED, read and cleared right after the freeze check
-- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the -- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the
@@ -953,6 +971,14 @@ function Battle:canAct(mon)
return true return true
end end
-- CantMove (engine/battle/effect_commands.asm:344-353) clears BIDE on every
-- arm of CheckPlayerTurn / CheckEnemyTurn that spends the turn.
function Battle:canAct(mon, moveId)
local acted = checkTurn(self, mon, moveId)
if not acted then clearBide(self:volatile(mon)) end
return acted
end
-- STRUGGLE, the move a mon with nothing left to spend falls back to -- STRUGGLE, the move a mon with nothing left to spend falls back to
-- (engine/battle/core.asm `.CheckPlayerHasUsableMoves` for the player and -- (engine/battle/core.asm `.CheckPlayerHasUsableMoves` for the player and
-- `.struggle` for the enemy). It lives in the move table like any other move -- `.struggle` for the enemy). It lives in the move table like any other move
@@ -1228,10 +1254,15 @@ function Battle:dealDamage(attacker, defender, damage, opts)
if opts.critical then if opts.critical then
self:emit({ kind = "message", text = "A critical hit!" }) self:emit({ kind = "message", text = "A critical hit!" })
end end
-- SuperEffectiveText / NotVeryEffectiveText (data/text/battle.asm:603,608).
-- The cart breaks both across the box's two lines and hyphenates "super-"
-- to do it, and the not-very line ends on the single ellipsis glyph Gold's
-- charmap carries at $75, not three periods.
if opts.effectiveness and opts.effectiveness > 10 then if opts.effectiveness and opts.effectiveness > 10 then
self:emit({ kind = "message", text = "It's super effective!" }) self:emit({ kind = "message", text = Strings("It's super-\neffective!") })
elseif opts.effectiveness and opts.effectiveness < 10 then elseif opts.effectiveness and opts.effectiveness < 10 then
self:emit({ kind = "message", text = "It's not very effective..." }) self:emit({ kind = "message",
text = Strings("It's not very\neffective…") })
end end
if endured then if endured then
self:emit({ kind = "message", self:emit({ kind = "message",
@@ -1383,9 +1414,18 @@ function Battle:useMove(attacker, defender, moveId)
-- free of PP and obedience in exactly the same way. -- free of PP and obedience in exactly the same way.
local rolling = state.rolloutLock == moveId local rolling = state.rolloutLock == moveId
if not (charging or rampaging or rolling) then -- engine/battle/effect_commands.asm:977-979, data/moves/effects.asm:795-800,
-- engine/battle/move_effects/bide.asm:62-68
local biding = def.effect == "EFFECT_BIDE" and state.bideTurns ~= nil
-- engine/battle/effect_commands.asm:6222-6234, :949-951
local called = (self.copyDepth or 0) > 0
if not (charging or rampaging or rolling or biding or called) then
if move and (move.pp or 0) <= 0 then if move and (move.pp or 0) <= 0 then
self:emit({ kind = "message", text = "No PP left for this move!" }) -- BattleText_TheresNoPPLeftForThisMove (data/text/battle.asm:315).
self:emit({ kind = "message",
text = Strings("There's no PP left\nfor this move!") })
return return
end end
if move then move.pp = (move.pp or 1) - 1 end if move then move.pp = (move.pp or 1) - 1 end
@@ -1465,9 +1505,38 @@ function Battle:useMove(attacker, defender, moveId)
return return
end end
-- engine/battle/move_effects/sleep_talk.asm:2, :16-19, :61
if def.effect == "EFFECT_SLEEP_TALK" then
local picked
if attacker.status == "sleep" and (self.copyDepth or 0) == 0 then
-- engine/battle/move_effects/sleep_talk.asm:40-44, :117-141
local pool = {}
for _, own in ipairs(attacker.moves or {}) do
local ownDef = self:moveDef(own.id)
local effect = ownDef and ownDef.effect
if own.id ~= moveId and not self:moveDisabled(attacker, own.id)
and not Effects.CHARGE[effect] and effect ~= "EFFECT_BIDE" then
pool[#pool + 1] = own.id
end
end
if #pool > 0 then picked = pool[rand(self.random, #pool) + 1] end
end
if not picked then
self:markMissed()
self:emit({ kind = "message", text = "But it failed!" })
return
end
state.lastMove = nil
self.copyDepth = (self.copyDepth or 0) + 1
self:useMove(attacker, defender, picked)
self.copyDepth = self.copyDepth - 1
return
end
-- Everything past here counts as "the user's last move" for Mirror Move, -- Everything past here counts as "the user's last move" for Mirror Move,
-- Encore and Disable. -- Encore and Disable. A called move skips the write
state.lastMove = moveId -- (engine/battle/used_move_text.asm:30-36).
if (self.copyDepth or 0) == 0 then state.lastMove = moveId end
state.turnsTaken = (state.turnsTaken or 0) + 1 state.turnsTaken = (state.turnsTaken or 0) + 1
state.usedMoves = state.usedMoves or {} state.usedMoves = state.usedMoves or {}
local seen = false local seen = false
@@ -1488,6 +1557,15 @@ function Battle:useMove(attacker, defender, moveId)
if def.effect == "EFFECT_SOLARBEAM" and self.weather == "sun" then if def.effect == "EFFECT_SOLARBEAM" and self.weather == "sun" then
charge = nil charge = nil
end end
if charge and not charging and Runtime.wantsHook("battle.charge_required") then
local required = Runtime.call("battle.charge_required", function(c)
return c.charge
end, {
battle = self, user = attacker, target = defender, move = def,
charge = true, isCalled = (self.copyDepth or 0) > 0,
})
if required == false then charge = nil end
end
if charge and not charging then if charge and not charging then
state.chargeMove = moveId state.chargeMove = moveId
state.vanished = charge.vanish or nil state.vanished = charge.vanish or nil
@@ -1501,6 +1579,13 @@ function Battle:useMove(attacker, defender, moveId)
return return
end end
-- BattleCommand_Snore (engine/battle/move_effects/snore.asm:1-9)
if def.effect == "EFFECT_SNORE" and attacker.status ~= "sleep" then
self:markMissed()
self:emit({ kind = "message", text = "But it failed!" })
return
end
-- Counter and Mirror Coat answer what the user took this turn, at double, -- Counter and Mirror Coat answer what the user took this turn, at double,
-- and fail outright when nothing of the right kind landed. -- and fail outright when nothing of the right kind landed.
local counterKind = Effects.COUNTER[def.effect] local counterKind = Effects.COUNTER[def.effect]
@@ -1732,8 +1817,12 @@ function Battle:useMove(attacker, defender, moveId)
landed = landed + 1 landed = landed + 1
end end
if landed > 1 then if landed > 1 then
self:emit({ kind = "message", -- PlayerHitTimesText / EnemyHitTimesText (data/text/battle.asm:749,755)
text = ("Hit %d time(s)!"):format(landed) }) -- are "Hit @ times!". Gen 2 has no singular form of this line, so the
-- plural stands even at one hit rather than the "(s)" this printed.
-- Gen 1 already says it this way (src/battle/EffectRegistry.lua,
-- _HitXTimesText).
self:emit({ kind = "message", text = Strings("Hit %d times!", landed) })
end end
-- move_effects/pay_day.asm:13 -- move_effects/pay_day.asm:13
@@ -1953,8 +2042,11 @@ Battle.MOVE_EFFECTS.EFFECT_PERISH_SONG = function(self)
if mine.perish and theirs.perish then return fail(self) end if mine.perish and theirs.perish then return fail(self) end
if not mine.perish then mine.perish = Effects.PERISH_TURNS end if not mine.perish then mine.perish = Effects.PERISH_TURNS end
if not theirs.perish then theirs.perish = Effects.PERISH_TURNS end if not theirs.perish then theirs.perish = Effects.PERISH_TURNS end
-- StartPerishText (data/text/battle.asm:986). What shipped here was a
-- sentence no cart prints; the Gen 2 line names both sides and counts in
-- digits.
self:emit({ kind = "message", self:emit({ kind = "message",
text = "All POKéMON hearing the song will faint in three turns!" }) text = Strings("Both POKéMON will\nfaint in 3 turns!") })
end end
-- BattleCommand_Encore: 3-6 turns locked into the move the target last used. -- BattleCommand_Encore: 3-6 turns locked into the move the target last used.
@@ -2084,6 +2176,11 @@ Battle.MOVE_EFFECTS.EFFECT_SPIKES = function(self, attacker, defender)
local side = self:sideOf(defender) local side = self:sideOf(defender)
if self.spikes[side] then return fail(self) end if self.spikes[side] then return fail(self) end
self.spikes[side] = true self.spikes[side] = true
-- SpikesText (data/text/battle.asm:974) is three rows, the third scrolled
-- (`cont`) and carrying <TARGET>. The battle message path has no `cont`:
-- src/ui/gen2/BattleState.lua sets self.message straight from the event and
-- printMessage cuts past two rows, so the cart's line cannot be told here
-- yet without the name being dropped on screen. Left as it stands.
self:emit({ kind = "message", text = "Spikes were scattered all around!" }) self:emit({ kind = "message", text = "Spikes were scattered all around!" })
end end
@@ -2118,6 +2215,8 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
if not state.bideTurns then if not state.bideTurns then
state.bideTurns = Effects.bideTurns(self.random) state.bideTurns = Effects.bideTurns(self.random)
state.bideStored = 0 state.bideStored = 0
-- engine/battle/core.asm:574-576
state.bideMove = moveId
self:emit({ kind = "message", self:emit({ kind = "message",
text = self:monName(attacker) .. " is storing energy!" }) text = self:monName(attacker) .. " is storing energy!" })
return return
@@ -2129,7 +2228,7 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
return return
end end
local damage = Effects.bideDamage(state.bideStored) local damage = Effects.bideDamage(state.bideStored)
state.bideTurns, state.bideStored = nil, nil state.bideTurns, state.bideStored, state.bideMove = nil, nil, nil
self:emit({ kind = "message", self:emit({ kind = "message",
text = self:monName(attacker) .. " unleashed energy!" }) text = self:monName(attacker) .. " unleashed energy!" })
if damage <= 0 then return fail(self) end if damage <= 0 then return fail(self) end
@@ -2319,7 +2418,9 @@ Battle.MOVE_EFFECTS.EFFECT_BEAT_UP = function(self, attacker, defender, def)
{ move = def, moveId = def and def.id }) { move = def, moveId = def and def.id })
landed = landed + 1 landed = landed + 1
end end
self:emit({ kind = "message", text = ("Hit %d time(s)!"):format(landed) }) -- BattleCommand_EndLoop prints the same line for Beat Up, and Beat Up can
-- land exactly once, which is the case the cart still prints as "times".
self:emit({ kind = "message", text = Strings("Hit %d times!", landed) })
end end
-- BattleCommand_Heal (effect_commands.asm:5986): Recover and Rest are both -- BattleCommand_Heal (effect_commands.asm:5986): Recover and Rest are both
@@ -2402,11 +2503,14 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker)
self.enemyIndex = target self.enemyIndex = target
self.enemy = party[target] self.enemy = party[target]
self.enemy.volatile = carried self.enemy.volatile = carried
-- engine/battle/move_effects/baton_pass.asm:59
self:resetParticipants()
end end
self:emit({ kind = "send", side = side, local sent = side == "player" and self.player or self.enemy
mon = side == "player" and self.player or self.enemy, self:emit({ kind = "send", side = side, mon = sent,
text = "Go! " .. self:monName(side == "player" and self.player hp = sent.hp or 0, status = sent.status or false,
or self.enemy) .. "!" }) level = sent.level, experience = sent.experience,
text = "Go! " .. self:monName(sent) .. "!" })
end end
-- BattleCommand_TrapTarget's .Traps table, one line per move: target first, -- BattleCommand_TrapTarget's .Traps table, one line per move: target first,
@@ -2663,8 +2767,12 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
self.enemyIndex = pick self.enemyIndex = pick
self.enemy = incoming self.enemy = incoming
self.stages.enemy = Battle.newStages() self.stages.enemy = Battle.newStages()
-- ForceEnemySwitch (engine/battle/core.asm:2937)
self:resetParticipants()
end end
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming, self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
hp = incoming.hp or 0, status = incoming.status or false,
level = incoming.level, experience = incoming.experience,
text = self:monName(incoming) .. " was dragged out!" }) text = self:monName(incoming) .. " was dragged out!" })
self:breakTrapsOnSend(incoming) self:breakTrapsOnSend(incoming)
self:spikesDamage(incoming) self:spikesDamage(incoming)
@@ -3070,6 +3178,12 @@ end
-- Faint bookkeeping and experience. Returns true when the battle ended. -- Faint bookkeeping and experience. Returns true when the battle ended.
function Battle:resolveFaints() function Battle:resolveFaints()
-- engine/battle/core.asm:2551-2556, :7116-7130, :3033-3037
if (self.player.hp or 0) <= 0 and self.participantsCleared ~= self.player then
self.participantsCleared = self.player
if self.playerIndex then self.participants[self.playerIndex] = nil end
end
if (self.enemy.hp or 0) <= 0 then if (self.enemy.hp or 0) <= 0 then
self:emit({ kind = "faint", side = "enemy", self:emit({ kind = "faint", side = "enemy",
text = (self.wild and "Wild " or "") .. self:monName(self.enemy) text = (self.wild and "Wild " or "") .. self:monName(self.enemy)
@@ -3084,6 +3198,7 @@ function Battle:resolveFaints()
if self.trainer then if self.trainer then
self:emit({ kind = "message", self:emit({ kind = "message",
text = (self.trainer.name or "TRAINER") .. " was defeated!" }) text = (self.trainer.name or "TRAINER") .. " was defeated!" })
self:printWinLossText("win")
self:awardPrizeMoney() self:awardPrizeMoney()
end end
-- CheckPayDay, on the win arm only (engine/battle/core.asm:7971-7976, -- CheckPayDay, on the win arm only (engine/battle/core.asm:7971-7976,
@@ -3110,6 +3225,8 @@ function Battle:resolveFaints()
-- can offer a shift on (engine/battle/core.asm:2241-2278). -- can offer a shift on (engine/battle/core.asm:2241-2278).
self:emit({ kind = "send", side = "enemy", mon = self.enemy, self:emit({ kind = "send", side = "enemy", mon = self.enemy,
replacement = true, replacement = true,
hp = self.enemy.hp or 0, status = self.enemy.status or false,
level = self.enemy.level, experience = self.enemy.experience,
text = (self.trainer and self.trainer.name or "Foe") .. " sent out " text = (self.trainer and self.trainer.name or "Foe") .. " sent out "
.. self:monName(self.enemy) .. "!" }) .. self:monName(self.enemy) .. "!" })
Runtime.emit("battle.battler_switched", { Runtime.emit("battle.battler_switched", {
@@ -3152,6 +3269,11 @@ function Battle:resolveFaints()
local nextIndex = Battle.firstHealthy(self.party) local nextIndex = Battle.firstHealthy(self.party)
if not nextIndex then if not nextIndex then
self:emit({ kind = "message", text = "You have no more POKéMON!" }) self:emit({ kind = "message", text = "You have no more POKéMON!" })
-- LostBattle (engine/battle/core.asm:2763-2782): only BATTLETYPE_CANLOSE
-- reaches PrintWinLossText on a loss; every other loss whites out.
if self.battleType == Battle.BATTLETYPE_CANLOSE then
self:printWinLossText("lose")
end
self:endBattle("lose") self:endBattle("lose")
return true return true
end end
@@ -3177,14 +3299,23 @@ function Battle:resolveFaints()
return false return false
end end
-- WinTrainerBattle's money arm, which runs after BattleText_EnemyWasDefeated -- WinTrainerBattle (engine/battle/core.asm:2310-2323), LostBattle's .canlose
-- and the frontpic slide: the four quarters are dealt between the wallet and -- arm (:2769-2782), PrintWinLossText (home/trainers.asm:230)
-- Mom's savings and then one StdBattleTextbox names the figure. function Battle:printWinLossText(result)
-- local trainer = self.trainer
-- The `ld a, [wDebugFlags] / bit DEBUG_BATTLE_F` skip in front of if not trainer then return end
-- PrintWinLossText is the trainer's own after-battle line, which this port -- The DEBUG_BATTLE_F skip sits in front of PrintWinLossText alone, behind
-- runs from the script on the way out of the battle rather than from here. -- the slide (engine/battle/core.asm:2310, :2320-2323).
-- The payout is not gated on it either way. -- The CANLOSE loss arm runs ClearBox first (:2770-2773).
self:emit({ kind = "trainer-return", cleared = result == "lose" or nil })
local text = (result == "lose") and trainer.lossText or trainer.winText
if type(text) ~= "string" or text == "" then return end
-- FarPrintText prints the pointer alone: no trainer-name tag in front of
-- it, unlike Gen 1's TrainerEndBattleText (pokered home/trainers.asm:355).
self:emit({ kind = "win-text", text = text })
end
-- WinTrainerBattle's money arm (engine/battle/core.asm:2310-2323)
function Battle:awardPrizeMoney() function Battle:awardPrizeMoney()
local save = self.save local save = self.save
if not (save and save.player) then return nil end if not (save and save.player) then return nil end
@@ -3237,7 +3368,16 @@ end
-- `count` is the pass's own divisor -- the participant count for the first -- `count` is the pass's own divisor -- the participant count for the first
-- pass, the holder count for the EXP.SHARE pass -- and `halved` is whether -- pass, the holder count for the EXP.SHARE pass -- and `halved` is whether
-- any Share holder taxed the whole pool. -- any Share holder taxed the whole pool.
function Battle:giveExperiencePass(loser, def, recipients, count, halved) --
-- `silent` suppresses only the GainedText line. It exists for the
-- battle.exp_award seam below, where a mod paying the bench wants one summary
-- line rather than a box per mon; the cart's own two passes never pass it, so
-- vanilla prints exactly what it always did. Everything else about the pass
-- -- the exp, the stat exp, battle.exp_gained, "grew to level", learned moves
-- and the forget prompt -- is unaffected, because a silent award is still an
-- award.
function Battle:giveExperiencePass(loser, def, recipients, count, halved,
silent)
for _, index in ipairs(recipients) do for _, index in ipairs(recipients) do
local mon = self.party[index] local mon = self.party[index]
if mon and (mon.hp or 0) > 0 and not mon.isEgg then if mon and (mon.hp or 0) > 0 and not mon.isEgg then
@@ -3288,10 +3428,12 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
index = index, index = index,
}) })
end end
if not silent then
self:emit({ kind = "experience", index = index, amount = amount, self:emit({ kind = "experience", index = index, amount = amount,
-- BoostedExpPointsText, keyed on the traded arm alone. -- BoostedExpPointsText, keyed on the traded arm alone.
text = self:monName(mon) .. " gained " text = self:monName(mon) .. " gained "
.. (traded and "a boosted " or "") .. amount .. " EXP. Points!" }) .. (traded and "a boosted " or "") .. amount .. " EXP. Points!" })
end
if result.levels > 0 then if result.levels > 0 then
-- "level up happiness mod", the cart's own comment, sitting right -- "level up happiness mod", the cart's own comment, sitting right
-- after the stat recalc and before the "grew to level" text. It fires -- after the stat recalc and before the "grew to level" text. It fires
@@ -3306,7 +3448,9 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
local moveDef = self:moveDef(moveId) local moveDef = self:moveDef(moveId)
local moveName = (moveDef and moveDef.name) or moveId local moveName = (moveDef and moveDef.name) or moveId
if ok then if ok then
-- data/text/common_3.asm:119
self:emit({ kind = "message", self:emit({ kind = "message",
sfx = "Sfx_DexFanfare5079", waitSfx = true,
text = self:monName(mon) .. " learned " .. moveName .. "!" }) text = self:monName(mon) .. " learned " .. moveName .. "!" })
elseif reason == "full" then elseif reason == "full" then
-- LearnMove's full-moveset arm calls ForgetMove, which asks with -- LearnMove's full-moveset arm calls ForgetMove, which asks with
@@ -3385,22 +3529,38 @@ function Battle:awardExperience(loser)
-- battle.exp_award, the same hook BattleState:awardExp calls on Gen 1 and -- battle.exp_award, the same hook BattleState:awardExp calls on Gen 1 and
-- with the same ctx: the participant COUNT, the live participants, and an -- with the same ctx: the participant COUNT, the live participants, and an
-- applyShare(mon, split) a mod can call to pay one mon its own share. The -- applyShare(mon, split, announce) a mod can call to pay one mon its own
-- third applyShare argument is Gen 1's EXP.ALL announcement variant; Gen 2 -- share. `recipients`, `holders` and `halved` are the Gen 2 additions.
-- has no EXP.ALL (the EXP.SHARE pass below is its replacement), so it is --
-- accepted and ignored rather than changing what is printed. `recipients`, -- `announce` is Gen 1's third argument (src/battle/BattleState.lua
-- `holders` and `halved` are the Gen 2 additions. -- applyShare) and means the same thing here: truthy prints the mon's
-- GainedText, falsy pays it silently. That is what lets one mod source
-- print ONE summary line for a party-wide award on both generations instead
-- of a box per mon -- which is what the Exp Share mod documents and could
-- not do on Gold, because this argument used to be accepted and ignored.
--
-- It is honoured only when it is actually PASSED, by argument count rather
-- than by value. A Gen 2-era mod calling applyShare(mon, split) was written
-- against a seam that always announced and keeps announcing; a caller that
-- passes the argument -- including an explicit nil, which is what a "pay
-- this one quietly" call looks like -- gets Gen 1's reading. So no existing
-- mod changes behaviour, and a mod that opts in gets parity.
if Runtime.wantsHook("battle.exp_award") then if Runtime.wantsHook("battle.exp_award") then
local alive = {} local alive = {}
for _, index in ipairs(participants) do for _, index in ipairs(participants) do
local mon = self.party[index] local mon = self.party[index]
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
end end
local function applyShare(mon, split) local function applyShare(mon, split, ...)
local announce = ...
-- select("#") counts an explicit nil; `announce == nil` alone could not
-- tell applyShare(mon, split) from applyShare(mon, split, nil), and
-- those two have to mean different things here.
local silent = select("#", ...) > 0 and not announce
for index, candidate in ipairs(self.party) do for index, candidate in ipairs(self.party) do
if candidate == mon then if candidate == mon then
return self:giveExperiencePass(loser, def, { index }, return self:giveExperiencePass(loser, def, { index },
math.max(1, split or 1), halved) math.max(1, split or 1), halved, silent)
end end
end end
end end
@@ -3415,8 +3575,7 @@ function Battle:awardExperience(loser)
-- GiveExperiencePoints .done falls through ResetBattleParticipants into -- GiveExperiencePoints .done falls through ResetBattleParticipants into
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033). -- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
self.participants = {} self:resetParticipants()
if self.playerIndex then self.participants[self.playerIndex] = true end
end end
-- The answer to a `choose-forget`: drop the move in `slot` and put the -- The answer to a `choose-forget`: drop the move in `slot` and put the
@@ -3437,7 +3596,9 @@ function Battle:resolveForget(index, slot, entry, moveName)
end end
self:emit({ kind = "message", self:emit({ kind = "message",
text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" }) text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" })
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
self:emit({ kind = "message", self:emit({ kind = "message",
sfx = "Sfx_DexFanfare5079", waitSfx = true,
text = self:monName(mon) .. " learned " text = self:monName(mon) .. " learned "
.. (moveName or (entry and entry.id) or "?") .. "!" }) .. (moveName or (entry and entry.id) or "?") .. "!" })
-- The forget path writes the slot itself rather than going through -- The forget path writes the slot itself rather than going through
@@ -3476,6 +3637,13 @@ function Battle:switchLocked()
return self:volatile(self.enemy).trapsTarget == true return self:volatile(self.enemy).trapsTarget == true
end end
-- ResetBattleParticipants falls through into AddBattleParticipant
-- (engine/battle/core.asm:3033 and :3037).
function Battle:resetParticipants()
self.participants = {}
if self.playerIndex then self.participants[self.playerIndex] = true end
end
-- EnemySwitch's shift arm zeroes both participant bitfields before PlayerSwitch -- EnemySwitch's shift arm zeroes both participant bitfields before PlayerSwitch
-- (engine/battle/core.asm:2959-2961). -- (engine/battle/core.asm:2959-2961).
function Battle:shiftSwitch(index) function Battle:shiftSwitch(index)
@@ -3498,6 +3666,7 @@ function Battle:switch(index)
-- A mon that comes back (a REVIVE, or a second battle) has to be able to -- A mon that comes back (a REVIVE, or a second battle) has to be able to
-- announce its own faint again; see resolveFaints. -- announce its own faint again; see resolveFaints.
self.faintAnnounced = nil self.faintAnnounced = nil
self.participantsCleared = nil
-- ForcePlayerMonChoice has been answered, so the next faint may ask again. -- ForcePlayerMonChoice has been answered, so the next faint may ask again.
self.pendingSwitch = nil self.pendingSwitch = nil
self.player = mon self.player = mon
@@ -3505,6 +3674,8 @@ function Battle:switch(index)
self.participants[index] = true self.participants[index] = true
self.stages.player = Battle.newStages() self.stages.player = Battle.newStages()
self:emit({ kind = "send", side = "player", mon = mon, self:emit({ kind = "send", side = "player", mon = mon,
hp = mon.hp or 0, status = mon.status or false,
level = mon.level, experience = mon.experience,
text = "Go! " .. self:monName(mon) .. "!" }) text = "Go! " .. self:monName(mon) .. "!" })
-- battle.battler_switched, the payload BattleState:resolveSwitch emits on -- battle.battler_switched, the payload BattleState:resolveSwitch emits on
-- Gen 1: the side record, whoever walked in, and whoever walked out. -- Gen 1: the side record, whoever walked in, and whoever walked out.
@@ -3733,19 +3904,41 @@ function Battle:lockedInMove(mon)
return nil return nil
end end
-- ParsePlayerAction's bide arm (engine/battle/core.asm:569-576), enemy twin
-- at :5650
function Battle:fightLockedMove(mon)
local state = self:volatile(mon)
if state.bideTurns then return state.bideMove end
return nil
end
-- engine/battle/core.asm:627-629
function Battle:cancelBide(mon)
clearBide(self:volatile(mon))
end
-- Encore forces the move; Disable forbids one. Both are read by the screen -- Encore forces the move; Disable forbids one. Both are read by the screen
-- (to grey out the move list) and by the enemy's own choice below. -- (to grey out the move list) and by the enemy's own choice below.
-- engine/battle/core.asm:561-566
local function encoredMove(state, mon)
if not state.encore then return nil end
for _, move in ipairs(mon.moves or {}) do
if move.id == state.encore and (move.pp or 0) > 0 then
return state.encore
end
end
state.encore, state.encoreTurns = nil, nil
return nil
end
function Battle:forcedMove(mon) function Battle:forcedMove(mon)
local locked = self:lockedInMove(mon) local locked = self:lockedInMove(mon)
if locked then return locked end if locked then return locked end
local state = self:volatile(mon) -- ParsePlayerAction reads SUBSTATUS_ENCORED ahead of the bide arm
if not state.encore then return nil end -- (engine/battle/core.asm:561-566).
for _, move in ipairs(mon.moves or {}) do local encored = encoredMove(self:volatile(mon), mon)
if move.id == state.encore and (move.pp or 0) > 0 then return state.encore end if encored then return encored end
end return self:fightLockedMove(mon)
-- Encore ends early when the move runs out of PP.
state.encore, state.encoreTurns = nil, nil
return nil
end end
function Battle:moveDisabled(mon, moveId) function Battle:moveDisabled(mon, moveId)
@@ -3759,8 +3952,9 @@ function Battle:usableMoves(mon)
-- .CheckPlayerHasUsableMoves (core.asm:533-556), so a Rollout or a rampage -- .CheckPlayerHasUsableMoves (core.asm:533-556), so a Rollout or a rampage
-- that spent its last PP on the opening turn keeps running: no later turn -- that spent its last PP on the opening turn keeps running: no later turn
-- of the lock spends any. Encore is not in this exemption -- forcedMove -- of the lock spends any. Encore is not in this exemption -- forcedMove
-- ends it the moment the encored move runs dry. -- ends it the moment the encored move runs dry. Bide is exempt too:
local locked = self:lockedInMove(mon) -- .CheckPlayerHasUsableMoves lives inside MoveSelectionScreen (core.asm:5058).
local locked = self:lockedInMove(mon) or self:fightLockedMove(mon)
local out = {} local out = {}
for _, move in ipairs(mon.moves or {}) do for _, move in ipairs(mon.moves or {}) do
local ok = (move.pp or 0) > 0 and not self:moveDisabled(mon, move.id) local ok = (move.pp or 0) > 0 and not self:moveDisabled(mon, move.id)
@@ -3962,6 +4156,8 @@ function Battle:enemyTrySwitchOrItem()
.. self:monName(outgoing) .. "!" }) .. self:monName(outgoing) .. "!" })
self.enemyIndex = target self.enemyIndex = target
self.enemy = self.enemyParty[target] self.enemy = self.enemyParty[target]
-- AI_Switch (engine/battle/ai/items.asm:697)
self:resetParticipants()
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove -- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
-- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so -- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so
-- the mon coming IN starts from an empty area -- the same pair of clears -- the mon coming IN starts from an empty area -- the same pair of clears
@@ -3969,6 +4165,8 @@ function Battle:enemyTrySwitchOrItem()
self:clearVolatile(self.enemy) self:clearVolatile(self.enemy)
self.stages.enemy = Battle.newStages() self.stages.enemy = Battle.newStages()
self:emit({ kind = "send", side = "enemy", mon = self.enemy, self:emit({ kind = "send", side = "enemy", mon = self.enemy,
hp = self.enemy.hp or 0, status = self.enemy.status or false,
level = self.enemy.level, experience = self.enemy.experience,
text = (self.trainer.name or "TRAINER") .. " sent out " text = (self.trainer.name or "TRAINER") .. " sent out "
.. self:monName(self.enemy) .. "!" }) .. self:monName(self.enemy) .. "!" })
Runtime.emit("battle.battler_switched", { Runtime.emit("battle.battler_switched", {
@@ -4050,9 +4248,16 @@ function Battle:vanillaEnemyMove()
-- move answers "TYPHLOSION's attack missed!", so Hitmonlee cannot be damaged -- move answers "TYPHLOSION's attack missed!", so Hitmonlee cannot be damaged
-- by anything, at any level. Fifteen straight attempts at the Elite Four -- by anything, at any level. Fifteen straight attempts at the Elite Four
-- died there, and no amount of grinding could ever have got past it. -- died there, and no amount of grinding could ever have got past it.
local charged = self:volatile(self.enemy).chargeMove local enemyState = self:volatile(self.enemy)
local charged = enemyState.chargeMove
if charged then return charged end if charged then return charged end
-- engine/battle/core.asm:5524-5533: the encore arm runs ahead of
-- CheckEnemyLockedIn (:5650).
local encored = encoredMove(enemyState, self.enemy)
if encored then return encored end
if enemyState.bideTurns then return enemyState.bideMove end
-- Encore and Disable narrow the pool before the AI ever scores it. -- Encore and Disable narrow the pool before the AI ever scores it.
local moves = self:usableMoves(self.enemy) local moves = self:usableMoves(self.enemy)
if #moves == 0 then if #moves == 0 then
@@ -4146,6 +4351,7 @@ local function runTurn(self, action)
-- (engine/battle/core.asm:5035-5038), which reopens the 2x2 menu with the -- (engine/battle/core.asm:5035-5038), which reopens the 2x2 menu with the
-- turn unspent -- so a refused RUN never bought the enemy a free attack. -- turn unspent -- so a refused RUN never bought the enemy a free attack.
if self.runRefused then return self:takeEvents() end if self.runRefused then return self:takeEvents() end
self:cancelBide(self.player)
action = { kind = "skip" } action = { kind = "skip" }
end end
@@ -4161,6 +4367,9 @@ local function runTurn(self, action)
if action.kind == "item" and Battle.X_ITEMS[action.item] then if action.kind == "item" and Battle.X_ITEMS[action.item] then
Happiness.change(self.player, "USEDXITEM") Happiness.change(self.player, "USEDXITEM")
end end
-- engine/battle/core.asm:572-573 into :627-629; a switch takes :570-571
-- instead and keeps the store.
if action.kind == "item" then self:cancelBide(self.player) end
-- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides -- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides
-- to rotate or drink a potion spends its whole turn on it. -- to rotate or drink a potion spends its whole turn on it.
@@ -4209,7 +4418,6 @@ local function runTurn(self, action)
local function playerAttack() local function playerAttack()
if action.kind ~= "move" then return end if action.kind ~= "move" then return end
if not self:canAct(self.player) then return end
local move = action.move local move = action.move
-- An encored mon has no choice, whatever the menu said. -- An encored mon has no choice, whatever the menu said.
local forced = self:forcedMove(self.player) local forced = self:forcedMove(self.player)
@@ -4229,13 +4437,20 @@ local function runTurn(self, action)
-- player's own mon spends the rest of the battle underground. -- player's own mon spends the rest of the battle underground.
local stored = self:volatile(self.player).chargeMove local stored = self:volatile(self.player).chargeMove
if stored then move = stored end if stored then move = stored end
-- engine/battle/core.asm:558-598 settles wCurPlayerMove before
-- engine/battle/effect_commands.asm:193 reads it.
if not self:canAct(self.player, move) then return end
-- CheckPlayerLockedIn quits before .CheckPlayerHasUsableMoves and before -- CheckPlayerLockedIn quits before .CheckPlayerHasUsableMoves and before
-- checkobedience, so a locked Rollout or Thrash is exempt from the -- checkobedience, so a locked Rollout or Thrash is exempt from the
-- Struggle substitution and the obedience roll the same way the second -- Struggle substitution and the obedience roll the same way the second
-- half of a charge move is. -- half of a charge move is.
local charging = self:volatile(self.player).chargeMove == move local charging = self:volatile(self.player).chargeMove == move
or self:lockedInMove(self.player) == move or self:lockedInMove(self.player) == move
if not charging and not self:hasUsableMoves(self.player) then -- engine/battle/core.asm:5058, and data/moves/effects.asm:796 keeps
-- `checkobedience`.
local bideLocked = self:fightLockedMove(self.player) == move
if not charging and not bideLocked
and not self:hasUsableMoves(self.player) then
self:emit({ kind = "message", self:emit({ kind = "message",
text = self:monName(self.player) .. " has no moves left!" }) text = self:monName(self.player) .. " has no moves left!" })
move = Battle.STRUGGLE move = Battle.STRUGGLE
@@ -4273,7 +4488,7 @@ local function runTurn(self, action)
-- against a trainer, could not be escaped either. -- against a trainer, could not be escaped either.
enemyMoveId = Battle.STRUGGLE enemyMoveId = Battle.STRUGGLE
end end
if not self:canAct(self.enemy) then return end if not self:canAct(self.enemy, enemyMoveId) then return end
-- CheckEnemyTurn's disabled arm (engine/battle/effect_commands.asm:562-574): -- CheckEnemyTurn's disabled arm (engine/battle/effect_commands.asm:562-574):
-- the AI chose before the player's Disable landed, so the turn is spent here. -- the AI chose before the player's Disable landed, so the turn is spent here.
if self:moveDisabled(self.enemy, enemyMoveId) then if self:moveDisabled(self.enemy, enemyMoveId) then
+22 -16
View File
@@ -609,9 +609,10 @@ function Game:draw()
-- ...and for the same reason the UI's own scale has to know the world is -- ...and for the same reason the UI's own scale has to know the world is
-- still the backdrop while an opaque menu covers it. Renderer:uiScale -- still the backdrop while an opaque menu covers it. Renderer:uiScale
-- steps the UI down with the survey zoom only while a world is behind it, -- steps the UI down with the survey zoom only while a world is behind it,
-- gated on this frame's world pass -- which the party menu and the bag end -- gated on this frame's world pass -- which the party menu ends by being
-- by being opaque. Without this hold they lose the step-down and blit at -- opaque (the bag's item box shows the map around it, #1521). Without
-- full fit scale over a battle drawn at the zoomed-out one. -- this hold it loses the step-down and blits at full fit scale over a
-- battle drawn at the zoomed-out one.
Renderer.uiWorldHold = Renderer.battleDim ~= nil Renderer.uiWorldHold = Renderer.battleDim ~= nil
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen -- ...and a battle keeps its dialogue box and YES/NO inside its own screen
-- instead of letting them dock to the window edge. -- instead of letting them dock to the window edge.
@@ -918,16 +919,7 @@ function Game:gamepadaxis(joystick, axis, value)
Input:gamepadaxis(joystick, axis, value) Input:gamepadaxis(joystick, axis, value)
end end
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the local isAccelerometer = GamepadMap.isAccelerometer
-- generic joystick path anyway: any sensor-style device that still reaches us
-- has gravity pinning an axis past the deadzone, which would hide the touch
-- overlay every instant and steer the player by tilt through the axis-1/2
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
-- never as "* Accelerometer".
local function isAccelerometer(joystick)
local name = joystick and joystick.getName and joystick:getName()
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
end
-- BindingsMenu's raw-stick capture rides the same top-state routing as the -- BindingsMenu's raw-stick capture rides the same top-state routing as the
-- keyboard and gamepad paths (#632). Only a stick SDL does not recognize -- keyboard and gamepad paths (#632). Only a stick SDL does not recognize
@@ -982,7 +974,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
@@ -1002,6 +998,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.
@@ -1209,18 +1207,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
if not eng then
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine") local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
if not ok or type(SyncEngine) ~= "table" then if not ok or type(SyncEngine) ~= "table" then
self._syncOff = true self._syncOff = true
return nil return nil
end end
local eng = SyncEngine.shared() eng = SyncEngine.shared()
if not eng then if not eng then
self._syncOff = true self._syncOff = true
return nil return nil
end end
self._syncEngineRef = eng 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
return eng return eng
end end
+121 -30
View File
@@ -18,6 +18,7 @@ local Chrome = require("src.ui.gen2.Chrome")
local Clock = require("src.core.gen2.Clock") local Clock = require("src.core.gen2.Clock")
local FixedStep = require("src.core.FixedStep") local FixedStep = require("src.core.FixedStep")
local Font = require("src.render.Font") local Font = require("src.render.Font")
local GamepadMap = require("src.core.GamepadMap")
local Input = require("src.core.Input") local Input = require("src.core.Input")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local Save = require("src.core.gen2.Save") local Save = require("src.core.gen2.Save")
@@ -57,20 +58,6 @@ Game2.__index = Game2
local function noop() end local function noop() end
for _, name in ipairs({
"joystickpressed", "joystickreleased", "joystickaxis", "joystickhat",
"joystickadded",
}) do
Game2[name] = noop
end
-- Not a noop, because the overlay has to come back on its own: a player who
-- unplugs the only controller would otherwise have to tap a blind screen to
-- get the pad back (src/core/Game.lua:869 does the same).
function Game2:joystickremoved()
TouchControls:joystickremoved()
end
-- THE FRAME AND INPUT SEAMS. -- THE FRAME AND INPUT SEAMS.
-- --
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad -- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
@@ -496,8 +483,10 @@ function Game2:learnMoveOn(mon, moveId, onDone)
if onDone then onDone(learned) end if onDone then onDone(learned) end
end end
if ok then if ok then
-- data/text/common_3.asm:119
return self:say(("%s learned\n%s!"):format(name, moveName), return self:say(("%s learned\n%s!"):format(name, moveName),
function() finish(true) end) function() finish(true) end,
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
end end
if reason ~= "full" then return finish(false) end if reason ~= "full" then return finish(false) end
local askForget, pickMove, askStop local askForget, pickMove, askStop
@@ -552,9 +541,11 @@ function Game2:learnMoveOn(mon, moveId, onDone)
-- The slot is written here rather than through Mon.learnMove, so -- The slot is written here rather than through Mon.learnMove, so
-- pokemon.move_learned is raised here too. -- pokemon.move_learned is raised here too.
ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
self:say(("1, 2 and… Poof!\f%s forgot\n%s.\fAnd…\f%s learned\n%s!") self:say(("1, 2 and… Poof!\f%s forgot\n%s.\fAnd…\f%s learned\n%s!")
:format(name, oldName, name, moveName), :format(name, oldName, name, moveName),
function() finish(true) end) function() finish(true) end,
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
end, end,
}) })
end end
@@ -719,7 +710,9 @@ function Game2:usePartyItem(itemId)
}) })
elseif action == "candy" then elseif action == "candy" then
self:consumeItem(itemId) self:consumeItem(itemId)
self:say(result.text, function() self:afterRareCandy(mon, result) end) -- data/text/common_1.asm:86
self:say(result.text, function() self:afterRareCandy(mon, result) end,
result.sfx and TextBox.soundOpts(self, result.sfx) or nil)
else else
self:consumeItem(itemId) self:consumeItem(itemId)
self:say(result.text) self:say(result.text)
@@ -778,8 +771,10 @@ function Game2:useSelectItem()
local name = (items[itemId] and items[itemId].name) or itemId local name = (items[itemId] and items[itemId].name) or itemId
self:say(Strings("{PLAYER} used the\n%s.", name)) self:say(Strings("{PLAYER} used the\n%s.", name))
elseif outcome == "trophy_sent" then elseif outcome == "trophy_sent" then
-- data/text/common_3.asm:372
self:say(Strings( self:say(Strings(
"There was a trophy\ninside!\fThe trophy was\nsent home.")) "There was a trophy\ninside!\fThe trophy was\nsent home."),
nil, TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
end end
-- Anything else (a fishing bite, the ITEMFINDER's queued script) already -- Anything else (a fishing bite, the ITEMFINDER's queued script) already
-- drives its own presentation off World:step -- nothing left to print here. -- drives its own presentation off World:step -- nothing left to print here.
@@ -791,9 +786,9 @@ end
-- onDone that popped again ate the state UNDER the box: dismissing a message -- onDone that popped again ate the state UNDER the box: dismissing a message
-- over the PACK closed the PACK with it, and over an empty overworld stack it -- over the PACK closed the PACK with it, and over an empty overworld stack it
-- was a silent extra pop. -- was a silent extra pop.
function Game2:say(text, onDone) function Game2:say(text, onDone, opts)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
self.stack:push(TextBox.new(self, text, onDone)) self.stack:push(TextBox.new(self, text, onDone, opts))
end end
-- The landmark the player is standing in, for the Pokegear map's marker. -- The landmark the player is standing in, for the Pokegear map's marker.
@@ -948,6 +943,12 @@ function Game2:load()
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua") self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua") self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
self.data.gen2Text = loadGenerated("data/generated/text.lua") self.data.gen2Text = loadGenerated("data/generated/text.lua")
-- The engine's own strings, keyed by the disassembly's label. gen2Text
-- above is the script text and is keyed by bank:address for the overworld
-- VM, so the two are different tables and both are loaded. This one is
-- what src/core/RomText.lua reads, which is why it lands on `text`: that
-- helper is shared with Gen 1 and looks up data.text[label].
self.data.text = loadGenerated("data/generated/rom_text.lua") or {}
-- data/generated/events.lua: the side tables a script command NAMES rather -- data/generated/events.lua: the side tables a script command NAMES rather
-- than carries -- the phone book, the in-game trades, the elevator's floor -- than carries -- the phone book, the in-game trades, the elevator's floor
-- labels, the decoration descriptions. Keyed for World's own `eventTables` -- labels, the decoration descriptions. Keyed for World's own `eventTables`
@@ -1968,7 +1969,11 @@ function Game2:applyOptions()
local options = self.options or {} local options = self.options or {}
Music.applyOptions(options) Music.applyOptions(options)
require("src.core.Sound").applyOptions(options) require("src.core.Sound").applyOptions(options)
require("src.render.Zoom").applyOptions(options) local Zoom = require("src.render.Zoom")
Zoom.applyOptions(options)
local caps = require("src.core.Performance").applyOptions(options)
Zoom.allowSurvey = caps.survey
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
require("src.render.Tilt").applyOptions(options) require("src.render.Tilt").applyOptions(options)
require("src.render.GbcPalette").applyOptions(options) require("src.render.GbcPalette").applyOptions(options)
-- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame. -- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame.
@@ -1993,6 +1998,13 @@ function Game2:applyOptions()
end end
end end
function Game2:_cycleSpeed(dir)
local GameSpeed = require("src.core.GameSpeed")
self.options.speed = GameSpeed.cycle(self.options.speed, dir)
if self.save then self.save.options = self.options end
self:persistOptions()
end
-- `back` -- SDL's name for the small left-hand menu button: Xbox VIEW, the PS -- `back` -- SDL's name for the small left-hand menu button: Xbox VIEW, the PS
-- CREATE/SHARE beside the touchpad, the Switch MINUS -- is SELECT, and has been -- CREATE/SHARE beside the touchpad, the Switch MINUS -- is SELECT, and has been
-- since src/core/GamepadMap.lua's DEFAULT_GAMEPAD_BINDINGS was written -- since src/core/GamepadMap.lua's DEFAULT_GAMEPAD_BINDINGS was written
@@ -2003,27 +2015,49 @@ end
-- the PACK's move-item, the party menu's reorder and half the soft-reset chord -- the PACK's move-item, the party menu's reorder and half the soft-reset chord
-- (A+B+SELECT+START) were all unreachable from a pad, and pressing the button -- (A+B+SELECT+START) were all unreachable from a pad, and pressing the button
-- to find out killed the process. It reaches Input like every other button now. -- to find out killed the process. It reaches Input like every other button now.
function Game2:gamepadpressed(_joystick, button) function Game2:gamepadpressed(joystick, button)
-- a controller is being used: the touch overlay steps aside until the next -- a controller is being used: the touch overlay steps aside until the next
-- screen touch (mobile only; a no-op elsewhere) -- screen touch (mobile only; a no-op elsewhere)
TouchControls:noteGamepad() TouchControls:noteGamepad()
-- The shoulders cycle GAME SPEED, as they do in the Gen 1 path. local selectHeld = Input:isDown("select")
if button == "rightshoulder" or button == "leftshoulder" then if not selectHeld and joystick and joystick.isGamepadDown then
local GameSpeed = require("src.core.GameSpeed") local ok, down = pcall(function()
local dir = button == "rightshoulder" and 1 or -1 return joystick:isGamepadDown("back")
self.options.speed = GameSpeed.cycle(self.options.speed, dir) end)
if self.save then self.save.options = self.options end selectHeld = ok and down == true
self:persistOptions() end
-- shoulders and triggers cycle GAME SPEED, as in src/core/Game.lua:881
if not selectHeld then
if button == "rightshoulder" or button == "righttrigger" then
self:_cycleSpeed(1)
return return
elseif button == "leftshoulder" or button == "lefttrigger" then
self:_cycleSpeed(-1)
return
end
end
local top = self.stack and self.stack:top()
if top and top.onGamepadPressed then
top:onGamepadPressed(button)
return
end
if selectHeld then
local digit = GamepadMap.displayChordDigit(button)
if digit then
self:keypressed(digit)
return
end
end end
-- START opens the start menu in the overworld; it used to quit, from before -- START opens the start menu in the overworld; it used to quit, from before
-- there was a menu to open. -- there was a menu to open.
Input:gamepadpressed(_joystick, button) Input:gamepadpressed(joystick, button)
end end
function Game2:gamepadreleased(joystick, button) function Game2:gamepadreleased(joystick, button)
Input:gamepadreleased(joystick, button) Input:gamepadreleased(joystick, button)
local top = self.stack and self.stack:top()
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
end end
function Game2:gamepadaxis(joystick, axis, value) function Game2:gamepadaxis(joystick, axis, value)
@@ -2032,4 +2066,61 @@ function Game2:gamepadaxis(joystick, axis, value)
Input:gamepadaxis(joystick, axis, value) Input:gamepadaxis(joystick, axis, value)
end end
-- The raw joystick road, same bodies as src/core/Game.lua:935 (#620, #632, #1570).
local function isRawStick(joystick)
return not (joystick and joystick.isGamepad and joystick:isGamepad())
end
function Game2:joystickpressed(joystick, button)
if GamepadMap.isAccelerometer(joystick) then return end
TouchControls:noteGamepad()
local top = self.stack and self.stack:top()
if isRawStick(joystick) and top and top.onJoystickPressed then
top:onJoystickPressed(button)
return
end
Input:joystickpressed(joystick, button)
end
function Game2:joystickreleased(joystick, button)
if GamepadMap.isAccelerometer(joystick) then return end
Input:joystickreleased(joystick, button)
local top = self.stack and self.stack:top()
if isRawStick(joystick) and top and top.onJoystickReleased then
top:onJoystickReleased(button)
end
end
function Game2:joystickaxis(joystick, axis, value)
if GamepadMap.isAccelerometer(joystick) then return end
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
Input:joystickaxis(joystick, axis, value)
end
function Game2:joystickhat(joystick, hat, direction)
if GamepadMap.isAccelerometer(joystick) then return end
if direction ~= "c" then TouchControls:noteGamepad() end
Input:joystickhat(joystick, hat, direction)
end
-- src/core/Game.lua:1015 (#799)
function Game2:recoverInput()
Input:reset()
Input:reconcile()
TouchControls:reset()
if self.mods and self.mods.releaseModInput then self.mods:releaseModInput() end
self:cancelPointers()
end
function Game2:joystickadded()
self:recoverInput()
end
-- The overlay comes back on its own when the last pad is unplugged
-- (src/core/Game.lua:1044).
function Game2:joystickremoved()
self:recoverInput()
TouchControls:joystickremoved()
end
return Game2 return Game2
+11
View File
@@ -106,6 +106,17 @@ function GamepadMap.ignoreRawForJoystick(joystick)
return ok and isPad == true return ok and isPad == true
end end
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
-- generic joystick path anyway: any sensor-style device that still reaches us
-- has gravity pinning an axis past the deadzone, which would hide the touch
-- overlay every instant and steer the player by tilt through the axis-1/2
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
-- never as "* Accelerometer".
function GamepadMap.isAccelerometer(joystick)
local name = joystick and joystick.getName and joystick:getName()
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
end
function GamepadMap.mapRawButton(index) function GamepadMap.mapRawButton(index)
if nxActive() then if nxActive() then
local nx = GamepadMap.NX_RAW_BUTTON_BINDINGS[index] local nx = GamepadMap.NX_RAW_BUTTON_BINDINGS[index]
+6 -1
View File
@@ -281,6 +281,7 @@ end
function Input:joystickpressed(joystick, button) function Input:joystickpressed(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
noteCapture(self, "joy", "pressed", button) noteCapture(self, "joy", "pressed", button)
local btn = self.joyBindings[button] local btn = self.joyBindings[button]
if btn then press(self, btn, "joy:" .. button) end if btn then press(self, btn, "joy:" .. button) end
@@ -288,6 +289,7 @@ end
function Input:joystickreleased(joystick, button) function Input:joystickreleased(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
noteCapture(self, "joy", "released", button) noteCapture(self, "joy", "released", button)
local btn = self.joyBindings[button] local btn = self.joyBindings[button]
if btn then release(self, btn, "joy:" .. button) end if btn then release(self, btn, "joy:" .. button) end
@@ -330,6 +332,7 @@ end
function Input:joystickaxis(joystick, axis, value) function Input:joystickaxis(joystick, axis, value)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
if axis == 1 then if axis == 1 then
self:gamepadaxis(joystick, "leftx", value) self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then elseif axis == 2 then
@@ -343,6 +346,7 @@ end
-- directions on top of a direction rebind. -- directions on top of a direction rebind.
function Input:joystickhat(joystick, hat, direction) function Input:joystickhat(joystick, hat, direction)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
local source = "hat:" .. hat local source = "hat:" .. hat
for _, btn in ipairs(self.hatDirs[hat] or {}) do for _, btn in ipairs(self.hatDirs[hat] or {}) do
release(self, btn, source) release(self, btn, source)
@@ -380,7 +384,8 @@ function Input:reconcile()
local ok, joysticks = pcall(js.getJoysticks) local ok, joysticks = pcall(js.getJoysticks)
if not ok or type(joysticks) ~= "table" then return end if not ok or type(joysticks) ~= "table" then return end
for _, j in ipairs(joysticks) do for _, j in ipairs(joysticks) do
if GamepadMap.ignoreRawForJoystick(j) then if GamepadMap.isAccelerometer(j) then
elseif GamepadMap.ignoreRawForJoystick(j) then
-- SDL-recognized pad: buttons + left stick, the gamepad surfaces -- SDL-recognized pad: buttons + left stick, the gamepad surfaces
if j.isGamepadDown then if j.isGamepadDown then
for button, btn in pairs(self.padBindings) do for button, btn in pairs(self.padBindings) do
+79 -23
View File
@@ -1,4 +1,4 @@
-- Screen orientation lock, Android only (#592, #716). -- Screen orientation lock, Android and iOS (#592, #716, #1638).
-- --
-- Persisted as options.orientation: "auto" | "portrait" | "landscape" | -- Persisted as options.orientation: "auto" | "portrait" | "landscape" |
-- "reverseLandscape". The lock travels through SDL_HINT_ORIENTATIONS: -- "reverseLandscape". The lock travels through SDL_HINT_ORIENTATIONS:
@@ -10,16 +10,12 @@
-- rotation lock"; LANDSCAPE allows both landscapes (SENSOR_LANDSCAPE -> -- rotation lock"; LANDSCAPE allows both landscapes (SENSOR_LANDSCAPE ->
-- USER_LANDSCAPE); REVERSE LANDSCAPE is SDL's LandscapeRight alone. -- USER_LANDSCAPE); REVERSE LANDSCAPE is SDL's LandscapeRight alone.
-- --
-- SDL only re-reads the hint when the window is created or its resizable -- Android only re-reads the hint at window creation or on a resizable-flag
-- flag changes (SDL_androidwindow.c: Android_CreateWindow / -- change (SDL_androidwindow.c), and SDL_SetWindowResizable early-returns on
-- Android_SetWindowResizable both call Android_JNI_SetOrientation). LOVE -- a fullscreen window (SDL_video.c:2237) -- which LOVE's Android window
-- 11.5 exposes neither hints nor a resizable setter, so apply() goes through -- always is -- so the hint never reached a running activity (#1638).
-- the FFI to SDL's C API: set the hint, then pulse the window's resizable -- apply() sets the hint for a later window, then goes over JNI for the live
-- flag off and back on -- each edge makes the Android backend recompute the -- one. iOS needs only the hint. Desktop and headless stubs no-op.
-- requested orientation, so a change from the launcher or the OPTION menu
-- takes hold immediately, and the flag ends where it started (conf.lua sets
-- resizable on mobile). Everything is pcall-guarded: desktop, iOS (the
-- Info.plist governs there) and headless stubs make this a no-op.
local Orientation = {} local Orientation = {}
@@ -58,6 +54,11 @@ function Orientation.isAndroid()
return love.system.getOS() == "Android" return love.system.getOS() == "Android"
end end
function Orientation.isIOS()
if not love or not love.system or not love.system.getOS then return false end
return love.system.getOS() == "iOS"
end
function Orientation.cycle(mode, dir) function Orientation.cycle(mode, dir)
local cur, idx = Orientation.normalize(mode), 1 local cur, idx = Orientation.normalize(mode), 1
for i, m in ipairs(Orientation.MODES) do for i, m in ipairs(Orientation.MODES) do
@@ -67,6 +68,15 @@ function Orientation.cycle(mode, dir)
return Orientation.MODES[(idx - 1 + (dir or 1)) % n + 1] return Orientation.MODES[(idx - 1 + (dir or 1)) % n + 1]
end end
-- ActivityInfo constants, what setOrientationBis lands on per hint after
-- GameActivity's *_SENSOR -> *_USER remap (#716).
local REQUESTED = {
auto = 13,
portrait = 1,
landscape = 11,
reverseLandscape = 8,
}
-- The SDL2 C API this module needs. cdef errors on redefinition, so run it -- The SDL2 C API this module needs. cdef errors on redefinition, so run it
-- once and remember whether it took; ffi itself may be absent (plain Lua -- once and remember whether it took; ffi itself may be absent (plain Lua
-- test interpreters), hence the pcall'd require. -- test interpreters), hence the pcall'd require.
@@ -77,33 +87,79 @@ local function sdlFfi()
if cdefOk == nil then if cdefOk == nil then
cdefOk = pcall(ffi.cdef, [[ cdefOk = pcall(ffi.cdef, [[
typedef struct SDL_Window SDL_Window; typedef struct SDL_Window SDL_Window;
typedef union { int32_t i; int64_t pad; } love_jvalue;
int SDL_SetHint(const char *name, const char *value); int SDL_SetHint(const char *name, const char *value);
SDL_Window *SDL_GL_GetCurrentWindow(void); SDL_Window *SDL_GL_GetCurrentWindow(void);
void SDL_SetWindowResizable(SDL_Window *window, int resizable); void SDL_SetWindowResizable(SDL_Window *window, int resizable);
void *SDL_AndroidGetJNIEnv(void);
void *SDL_AndroidGetActivity(void);
]]) ]])
end end
if not cdefOk then return nil end if not cdefOk then return nil end
return ffi return ffi
end end
-- Push the mode into the live activity. Returns true when the hint reached -- Slot numbers in JNINativeInterface (jni.h).
-- SDL (the symbols resolved), false on any non-Android / stubbed platform. local JNI_EXCEPTION_CLEAR = 17
local JNI_DELETE_LOCAL_REF = 23
local JNI_GET_OBJECT_CLASS = 31
local JNI_GET_METHOD_ID = 33
local JNI_CALL_VOID_METHOD_A = 63
-- What Android_JNI_SetOrientation reaches, called directly: the hint path
-- cannot re-run on a live fullscreen window (SDL_video.c:2237).
local function setRequestedOrientation(ffi, requested)
local env = ffi.C.SDL_AndroidGetJNIEnv()
if env == nil then return false end
local activity = ffi.C.SDL_AndroidGetActivity()
if activity == nil then return false end
local fns = ffi.cast("void***", env)[0]
local getObjectClass = ffi.cast("void *(*)(void *, void *)", fns[JNI_GET_OBJECT_CLASS])
local getMethodID = ffi.cast(
"void *(*)(void *, void *, const char *, const char *)", fns[JNI_GET_METHOD_ID])
local callVoidMethodA = ffi.cast(
"void (*)(void *, void *, void *, love_jvalue *)", fns[JNI_CALL_VOID_METHOD_A])
local deleteLocalRef = ffi.cast("void (*)(void *, void *)", fns[JNI_DELETE_LOCAL_REF])
local exceptionClear = ffi.cast("void (*)(void *)", fns[JNI_EXCEPTION_CLEAR])
local ok = false
local cls = getObjectClass(env, activity)
if cls ~= nil then
local mid = getMethodID(env, cls, "setRequestedOrientation", "(I)V")
if mid ~= nil then
local args = ffi.new("love_jvalue[1]")
args[0].pad = 0
args[0].i = requested
callVoidMethodA(env, activity, mid, args)
ok = true
end
exceptionClear(env)
deleteLocalRef(env, cls)
end
deleteLocalRef(env, activity)
return ok
end
-- Returns true only when the request actually landed, never unconditionally
-- as it once did (#1638).
function Orientation.apply(mode) function Orientation.apply(mode)
if not Orientation.isAndroid() then return false end local android = Orientation.isAndroid()
if not (android or Orientation.isIOS()) then return false end
local ffi = sdlFfi() local ffi = sdlFfi()
if not ffi then return false end if not ffi then return false end
mode = Orientation.normalize(mode) mode = Orientation.normalize(mode)
local ok = pcall(function() local ok, reached = pcall(function()
-- "SDL_IOS_ORIENTATIONS" is SDL_HINT_ORIENTATIONS's name (SDL_hints.h); -- SDL_HINT_ORIENTATIONS is "SDL_IOS_ORIENTATIONS" in the SDL2 Android
-- despite the IOS in the string, the Android backend reads it too. -- ships and "SDL_ORIENTATIONS" in the SDL3 the iOS app links; each
-- engine ignores the other's key.
ffi.C.SDL_SetHint("SDL_IOS_ORIENTATIONS", HINTS[mode]) ffi.C.SDL_SetHint("SDL_IOS_ORIENTATIONS", HINTS[mode])
local win = ffi.C.SDL_GL_GetCurrentWindow() ffi.C.SDL_SetHint("SDL_ORIENTATIONS", HINTS[mode])
if win ~= nil then -- On iOS the hint is the lock: UIKit re-asks on every rotation
ffi.C.SDL_SetWindowResizable(win, 0) -- (SDL_uikitviewcontroller.m supportedInterfaceOrientations).
ffi.C.SDL_SetWindowResizable(win, 1) if not android then return true end
end return setRequestedOrientation(ffi, REQUESTED[mode])
end) end)
return ok return ok and reached == true
end end
function Orientation.applyOptions(opts) function Orientation.applyOptions(opts)
+5 -2
View File
@@ -86,8 +86,11 @@ function Performance.detect()
local cores = processorCount() local cores = processorCount()
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project -- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
-- already ships a build for): the weakest target here. -- already ships a build for): the weakest target here. Desktop ARM
if isArm and os ~= "Android" and os ~= "iOS" then -- (Apple Silicon "OS X", Windows-on-ARM) is not a handheld — those
-- used to resolve AUTO → LOW, which stripped survey zoom-out from
-- OPTIONS so the ZOOM row only offered IN.
if isArm and os == "Linux" then
return "low" return "low"
end end
-- Phones and tablets: GBC FX is already force-disabled here (issue #136); -- Phones and tablets: GBC FX is already force-disabled here (issue #136);
+21 -1
View File
@@ -1700,6 +1700,14 @@ local function clamp(n, lo, hi, fallback)
return n return n
end end
-- PP and the PP Up count are unsigned bit fields of one byte
-- (constants/pokemon_data_constants.asm:101-102)
local function ppInt(v, fallback)
local n = tonumber(v)
if n == nil or n ~= n or n == math.huge or n == -math.huge then return fallback end
return math.max(0, math.floor(n))
end
local function ensureOrphaned(save) local function ensureOrphaned(save)
if not save.orphaned then if not save.orphaned then
save.orphaned = { mons = {}, items = {} } save.orphaned = { mons = {}, items = {} }
@@ -1763,7 +1771,7 @@ local function scrubKnownMon(mon, data)
-- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means -- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means
-- every later reader (menus, battle, items, SGB bar zones, the link -- every later reader (menus, battle, items, SGB bar zones, the link
-- fingerprint) sees a party-shaped mon. Runs after the level clamp above -- fingerprint) sees a party-shaped mon. Runs after the level clamp above
-- so the derived stats use a sane level. A save that already has stats is -- so the derived stats use a sane level. A complete stat block is
-- untouched. -- untouched.
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon) Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
local moves = mon.moves local moves = mon.moves
@@ -1783,6 +1791,18 @@ local function scrubKnownMon(mon, data)
moves[1] = { id = fallback, pp = def.pp } moves[1] = { id = fallback, pp = def.pp }
end end
end end
-- the replacement PP mirrors AddBonusPP (engine/items/item_effects.asm:2418);
-- Mimic rewrites the move id and not the PP (engine/battle/effects.asm:1266)
for j = 1, #moves do
local slot = moves[j]
if type(slot) == "table" then
local mdef = data.moves and data.moves[slot.id]
local base = ppInt(mdef and mdef.pp, 0)
local ppUps = math.min(3, ppInt(slot.ppUps, 0))
if slot.ppUps ~= nil then slot.ppUps = ppUps end
slot.pp = ppInt(slot.pp, base + ppUps * math.floor(base / 5))
end
end
end end
local function scrubMonList(list, where, save, data, report) local function scrubMonList(list, where, save, data, report)
+5
View File
@@ -12,10 +12,12 @@ function ScreenPosition.normalize(v)
end end
function ScreenPosition.label(v) function ScreenPosition.label(v)
if ScreenPosition.skinActive() then return "SKIN" end
return LABELS[ScreenPosition.normalize(v)] return LABELS[ScreenPosition.normalize(v)]
end end
function ScreenPosition.cycle(v, dir) function ScreenPosition.cycle(v, dir)
if ScreenPosition.skinActive() then return ScreenPosition.normalize(v) end
v = ScreenPosition.normalize(v) v = ScreenPosition.normalize(v)
local modes = ScreenPosition.MODES local modes = ScreenPosition.MODES
local cur = 1 local cur = 1
@@ -44,6 +46,9 @@ end
function ScreenPosition.skinActive(w, h) function ScreenPosition.skinActive(w, h)
local ok, TouchSkin = pcall(require, "src.core.TouchSkin") local ok, TouchSkin = pcall(require, "src.core.TouchSkin")
if not ok or type(TouchSkin.viewport) ~= "function" then return false end if not ok or type(TouchSkin.viewport) ~= "function" then return false end
if (not w or not h) and love and love.graphics and love.graphics.getDimensions then
w, h = love.graphics.getDimensions()
end
local okv, x = pcall(TouchSkin.viewport, w, h) local okv, x = pcall(TouchSkin.viewport, w, h)
return okv and x ~= nil return okv and x ~= nil
end end
+8
View File
@@ -154,8 +154,14 @@ local function newSfxSource(data, key, def, pitch, tempo, plain)
return newFileSource(def) return newFileSource(def)
end end
local function deviceSuspended()
local ChipAudio = package.loaded["src.core.ChipAudio"]
return ChipAudio ~= nil and ChipAudio.isSuspended()
end
local function playPath(data, key, def, pitch, tempo, plain) local function playPath(data, key, def, pitch, tempo, plain)
if not love.audio or not def then return nil end if not love.audio or not def then return nil end
if deviceSuspended() then return nil end
local src = cache[key] local src = cache[key]
if src == false then return nil end -- known bad, already logged if src == false then return nil end -- known bad, already logged
if not src then if not src then
@@ -602,6 +608,7 @@ end
-- cache carries no clips (Red/Blue) or headless. -- cache carries no clips (Red/Blue) or headless.
function Sound.playPikaCry(data, n) function Sound.playPikaCry(data, n)
if not love.audio then return nil end if not love.audio then return nil end
if deviceSuspended() then return nil end
local count = data.audio and data.audio.pikaCries local count = data.audio and data.audio.pikaCries
if not count then return nil end if not count then return nil end
n = math.max(1, math.min(count, n or 1)) n = math.max(1, math.min(count, n or 1))
@@ -633,6 +640,7 @@ end
-- like the original's PlayCry -> WaitForSoundToFinish can poll it -- like the original's PlayCry -> WaitForSoundToFinish can poll it
function Sound.playCry(data, species, pikaClip) function Sound.playCry(data, species, pikaClip)
if not love.audio then return nil end if not love.audio then return nil end
if deviceSuspended() then return nil end
-- Yellow voices every Pikachu cry with the PCM clips (the chip cry is -- Yellow voices every Pikachu cry with the PCM clips (the chip cry is
-- never used for the species there). Which clip is a property of the -- never used for the species there). Which clip is a property of the
-- call site in the original -- every caller of PlayPikachuSoundClip sets -- call site in the original -- every caller of PlayPikachuSoundClip sets
+18 -6
View File
@@ -293,7 +293,9 @@ function TouchControls:applyOptions(opts)
-- launcher editor round-trips through config() (#806) -- launcher editor round-trips through config() (#806)
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics) self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
TouchSkin.setOverlayLive(self.active) TouchSkin.setOverlayLive(self.active)
self:selectSkin(cfg.skin) -- Off means off everywhere: do not leave a hidden selected skin behind to
-- influence renderer placement on desktop or with a controller attached.
self:selectSkin(cfg.enabled and cfg.skin or nil)
self.layouts = cfg.layouts self.layouts = cfg.layouts
self.layoutW, self.layoutH = nil, nil self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil self.layoutOx, self.layoutOy = nil, nil
@@ -335,7 +337,10 @@ function TouchControls:visible()
local art = TouchSkin.active ~= nil or self.img ~= nil local art = TouchSkin.active ~= nil or self.img ~= nil
if self.preview then return art end if self.preview then return art end
if self.enabled == false or not art then return false end if self.enabled == false or not art then return false end
if TouchSkin.active and TouchSkin.decorativeOnly() then return true end -- A selected skin is also a desktop/TV bezel. Input remains gated in
-- touchpressed, but the artwork must not disappear when a controller is
-- connected or the platform is not touch-first.
if TouchSkin.active then return true end
return self.active and not self.controllerHidden return self.active and not self.controllerHidden
end end
@@ -778,12 +783,19 @@ local function drawIcon(img, zone, pressed, alphaMul)
zone.cy - img:getHeight() * scale / 2, 0, scale, scale) zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
end end
local function drawStretched(img, x, y, w, h, alpha) local function drawCovered(img, x, y, w, h, alpha)
if not img or alpha <= 0 then return end if not img or alpha <= 0 then return end
local iw, ih = img:getWidth(), img:getHeight() local iw, ih = img:getWidth(), img:getHeight()
if iw <= 0 or ih <= 0 then return end if iw <= 0 or ih <= 0 then return end
-- Cover the assigned box with one uniform scale and crop the excess. The
-- old independent X/Y scale made portrait art visibly squash on wide
-- displays (and vice versa).
local s = math.max(w / iw, h / ih)
local dw, dh = iw * s, ih * s
love.graphics.setColor(1, 1, 1, math.min(1, alpha)) love.graphics.setColor(1, 1, 1, math.min(1, alpha))
love.graphics.draw(img, x, y, 0, w / iw, h / ih) love.graphics.setScissor(x, y, w, h)
love.graphics.draw(img, x + (w - dw) * 0.5, y + (h - dh) * 0.5, 0, s, s)
love.graphics.setScissor()
end end
function TouchControls:drawSkin(alphaMul) function TouchControls:drawSkin(alphaMul)
@@ -795,7 +807,7 @@ function TouchControls:drawSkin(alphaMul)
love.graphics.push("all") love.graphics.push("all")
love.graphics.origin() love.graphics.origin()
drawStretched(page.image, bx, by, bw, bh, opacity) drawCovered(page.image, bx, by, bw, bh, opacity)
local pressed = {} local pressed = {}
for _, touch in pairs(self.touches or {}) do for _, touch in pairs(self.touches or {}) do
@@ -810,7 +822,7 @@ function TouchControls:drawSkin(alphaMul)
TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy) TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy)
local alpha = opacity local alpha = opacity
if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end
drawStretched(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha) drawCovered(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
end end
end end
+69 -9
View File
@@ -294,7 +294,6 @@ function TouchSkin.parse(text)
page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0), page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0),
w = num(vp[3], 1), h = num(vp[4], 1) } w = num(vp[3], 1), h = num(vp[4], 1) }
page.viewportFill = toBool(kv[p .. "_viewport_fill"]) page.viewportFill = toBool(kv[p .. "_viewport_fill"])
page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
end end
page.pixelCoords = not page.normalized page.pixelCoords = not page.normalized
@@ -447,7 +446,6 @@ function TouchSkin.parseNative(text)
page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0), page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0),
w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) } w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) }
page.viewportFill = raw.viewport.fill == true page.viewportFill = raw.viewport.fill == true
page.viewportExpand = raw.viewport.expand == true
end end
for _, c in ipairs(raw.controls or {}) do for _, c in ipairs(raw.controls or {}) do
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul") local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
@@ -526,7 +524,6 @@ function TouchSkin.toNative(skin)
x = page.viewport.x, y = page.viewport.y, x = page.viewport.x, y = page.viewport.y,
w = page.viewport.w, h = page.viewport.h, w = page.viewport.w, h = page.viewport.h,
fill = page.viewportFill or nil, fill = page.viewportFill or nil,
expand = page.viewportExpand or nil,
} }
end end
for _, ctl in ipairs(page.controls or {}) do for _, ctl in ipairs(page.controls or {}) do
@@ -636,6 +633,19 @@ local function applyPixelScale(page)
return true return true
end end
-- An overlay image is its own design canvas. Older RetroArch cfg files often
-- omit `aspect_ratio`; reading the dimensions here keeps that legacy art and
-- all of its normalized controls on the same uniform scale.
function TouchSkin.applyImageAspect(page)
if not page or page.aspectFromCfg or not page.image
or not page.image.getDimensions then return false end
local iw, ih = page.image:getDimensions()
if not iw or not ih or iw <= 0 or ih <= 0 then return false end
page.aspect = iw / ih
page.aspectFromImage = true
return true
end
function TouchSkin.load(root, id) function TouchSkin.load(root, id)
local cfgPath, format, prefix = findConfig(root) local cfgPath, format, prefix = findConfig(root)
if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end
@@ -665,6 +675,7 @@ function TouchSkin.load(root, id)
elseif page.pdfPath then elseif page.pdfPath then
rasterizePdfPage(page, root) rasterizePdfPage(page, root)
end end
TouchSkin.applyImageAspect(page)
if not applyPixelScale(page) then if not applyPixelScale(page) then
return nil, "could not read " .. tostring(page.imagePath) return nil, "could not read " .. tostring(page.imagePath)
.. ", which " .. page.name .. " measures its coordinates against" .. ", which " .. page.name .. " measures its coordinates against"
@@ -771,6 +782,28 @@ function TouchSkin.find(id)
return nil return nil
end end
-- Remove only a user-installed skin. Bundled skins are shipped with the
-- game and intentionally have no delete affordance.
function TouchSkin.remove(id)
local entry = TouchSkin.find(id)
if not entry then return nil, "no skin " .. tostring(id) end
if entry.source ~= "user" then return nil, "bundled skins cannot be deleted" end
if not (love and love.filesystem and love.filesystem.remove) then
return nil, "no writable filesystem"
end
local function removeTree(path)
if isDir(path) and love.filesystem.getDirectoryItems then
for _, name in ipairs(love.filesystem.getDirectoryItems(path)) do
local ok, err = removeTree(path .. "/" .. name)
if not ok then return nil, err end
end
end
local ok, err = love.filesystem.remove(path)
return ok and true or nil, err
end
return removeTree(entry.archive or (TouchSkin.USER_ROOT .. "/" .. entry.id))
end
function TouchSkin.assetPaths(skin) function TouchSkin.assetPaths(skin)
local out, seen = {}, {} local out, seen = {}, {}
local function add(rel) local function add(rel)
@@ -886,7 +919,6 @@ function TouchSkin.toRetroArchConfig(skin)
if page.viewport then if page.viewport then
out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport) out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport)
if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end
if page.viewportExpand then out[#out + 1] = p .. "_viewport_expand = true" end
end end
local controls = {} local controls = {}
for _, ctl in ipairs(page.controls or {}) do for _, ctl in ipairs(page.controls or {}) do
@@ -1284,7 +1316,7 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
-- full_screen means "relative to the window, not the game viewport". -- full_screen means "relative to the window, not the game viewport".
-- When the cfg also names an aspect_ratio, that window is then fitted -- When the cfg also names an aspect_ratio, that window is then fitted
-- to the overlay's design aspect so buttons do not stretch. #1503 -- to the overlay's design aspect so buttons do not stretch. #1503
local fit = ((not page.fullScreen) or page.aspectFromCfg) local fit = ((not page.fullScreen) or page.aspectFromCfg or page.aspectFromImage)
and page.aspect and page.aspect > 0 and h > 0 and page.aspect and page.aspect > 0 and h > 0
if fit then if fit then
local displayAspect = w / h local displayAspect = w / h
@@ -1306,6 +1338,11 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
by = oy + extra by = oy + extra
elseif anchor == "top" then elseif anchor == "top" then
by = oy by = oy
elseif page.aspect < 1 then
-- A portrait bezel with controls is a controller deck. On an
-- unusually tall display, pin the deck to the lower edge and leave
-- the additional room for the game above it.
by = oy + extra
else else
by = oy + extra * 0.5 by = oy + extra * 0.5
end end
@@ -1359,8 +1396,10 @@ function TouchSkin.decorativeOnly()
end end
function TouchSkin.drawable() function TouchSkin.drawable()
if not TouchSkin.active then return false end -- A selected skin is a presentation choice, not a mobile-only input mode.
return TouchSkin.overlayLive or TouchSkin.decorativeOnly() -- Its artwork and screen placement therefore belong on every platform;
-- `overlayLive` still controls whether touch input is available.
return TouchSkin.active ~= nil
end end
function TouchSkin.hasViewport() function TouchSkin.hasViewport()
@@ -1391,6 +1430,16 @@ local function remainderBox(ox, oy, w, h, bx, by, bw, bh)
return best[1], best[2], best[3], best[4] return best[1], best[2], best[3], best[4]
end end
-- The deck box pinned to the lower edge (see pageBox) leaves room above it
-- that belongs to the game, so a screen rect flush with the top of the deck
-- grows into it instead of showing a black band.
local function deckHeadroom(y, vh, by, bh, oy, h)
if by <= oy + 0.5 then return y, vh end
if by + bh < oy + h - 0.5 then return y, vh end
if y - by > math.max(2, bh * 0.01) then return y, vh end
return oy, vh + (y - oy)
end
function TouchSkin.pageViewport(page, w, h, ox, oy) function TouchSkin.pageViewport(page, w, h, ox, oy)
if not page then return nil end if not page then return nil end
ox, oy = ox or 0, oy or 0 ox, oy = ox or 0, oy or 0
@@ -1400,16 +1449,27 @@ function TouchSkin.pageViewport(page, w, h, ox, oy)
local x, y = bx + v.x * bw, by + v.y * bh local x, y = bx + v.x * bw, by + v.y * bh
local vw, vh = v.w * bw, v.h * bh local vw, vh = v.w * bw, v.h * bh
if vw <= 0 or vh <= 0 then return nil end if vw <= 0 or vh <= 0 then return nil end
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true y, vh = deckHeadroom(y, vh, by, bh, oy, h)
return x, y, vw, vh, page.viewportFill == true
end end
if page.screenFit == "remainder" then if page.screenFit == "remainder" then
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh) local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
if not x then return nil end if not x then return nil end
return x, y, vw, vh, false, false return x, y, vw, vh, false
end end
return nil return nil
end end
-- Centre of the page's screen cutout. The renderer fits the 160x144
-- picture into that rect; this helper is for the studio preview.
function TouchSkin.screenCenter(w, h, ox, oy, page)
page = page or TouchSkin.page()
if not page then return nil end
local x, y, vw, vh = TouchSkin.pageViewport(page, w, h, ox, oy)
if not x then return nil end
return x + vw * 0.5, y + vh * 0.5
end
function TouchSkin.viewport(w, h, ox, oy) function TouchSkin.viewport(w, h, ox, oy)
local page = TouchSkin.page() local page = TouchSkin.page()
if not page or not TouchSkin.drawable() then return nil end if not page or not TouchSkin.drawable() then return nil end
+2
View File
@@ -240,6 +240,8 @@ local function rareCandy(mon, data)
used = true, used = true,
level = newLevel, level = newLevel,
learned = learned, learned = learned,
-- data/text/common_1.asm:86
sfx = "Sfx_DexFanfare5079",
text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel), text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel),
} }
end end
+9
View File
@@ -220,6 +220,9 @@ function Save.newGame(opts)
phoneContacts = {}, phoneContacts = {},
tradeFlags = {}, tradeFlags = {},
pokedex = { seen = {}, caught = {} }, pokedex = { seen = {}, caught = {} },
-- wLastDexMode (engine/pokedex/pokedex.asm:59-61): the sort mode the
-- #DEX reopens in. NEW_MODE is the cart's zero byte.
lastDexMode = "NEW",
-- wUnownDex: the distinct Unown FORMS caught, in catching order. A second -- wUnownDex: the distinct Unown FORMS caught, in catching order. A second
-- record beside the #DEX because the #DEX knows only the species -- record beside the #DEX because the #DEX knows only the species
-- (src/core/gen2/Unown.lua). -- (src/core/gen2/Unown.lua).
@@ -685,6 +688,12 @@ function Save.validate(save)
scrubEvents(save, report) scrubEvents(save, report)
scrubMapScenes(save, report) scrubMapScenes(save, report)
scrubPlayerState(save, report) scrubPlayerState(save, report)
-- wLastDexMode: only the three modes the #DEX has (PokedexMenu MODES);
-- a hand-edited value falls back to NEW_MODE, the cart's zero byte
if save.lastDexMode ~= "NEW" and save.lastDexMode ~= "OLD"
and save.lastDexMode ~= "A-Z" then
save.lastDexMode = "NEW"
end
-- The `mailmsg` structs get the same treatment for the same reason: their -- The `mailmsg` structs get the same treatment for the same reason: their
-- `type` byte is an item id nothing else in the save vouches for, and a -- `type` byte is an item id nothing else in the save vouches for, and a
-- party key outside 1..6 or a MAILBOX past MAILBOX_CAPACITY is a region the -- party key outside 1..6 or a MAILBOX past MAILBOX_CAPACITY is a region the
+37
View File
@@ -287,6 +287,43 @@ function CacheFs.write(rel, data)
return love.filesystem.write(rel, data) return love.filesystem.write(rel, data)
end end
-- Open a cache-relative file for streaming replacement. The returned handle
-- has write(bytes) and close() methods and follows the same portable/save-dir
-- routing as CacheFs.write without forcing the caller to hold the whole file
-- in one Lua string.
function CacheFs.openWrite(rel)
rel = withPrefix(rel)
local root = CacheFs.root()
if root then
ensureParents(root, rel)
local f, err = io.open(realPath(root, rel), "wb")
if not f then return nil, err end
return {
write = function(_, data)
local ok, writeErr = f:write(data)
if not ok then return nil, writeErr end
return true
end,
close = function() f:close() end,
}
end
if not (love and love.filesystem and love.filesystem.newFile) then
return nil, "streaming cache writes are unavailable"
end
local parent = rel:match("^(.*)/[^/]+$")
if parent and not love.filesystem.createDirectory(parent) then
local info = love.filesystem.getInfo(parent)
local reason = info and ("a " .. info.type .. " already exists there")
or "unknown reason"
return nil, "could not create " .. parent .. ": " .. reason
end
local file, makeErr = love.filesystem.newFile(rel)
if not file then return nil, makeErr or "could not create cache file" end
local ok, openErr = file:open("w")
if not ok then return nil, openErr or "could not open cache file" end
return file
end
-- read cache-relative `rel`; returns the bytes or nil -- read cache-relative `rel`; returns the bytes or nil
function CacheFs.read(rel) function CacheFs.read(rel)
rel = withPrefix(rel) rel = withPrefix(rel)
+20
View File
@@ -125,6 +125,26 @@ function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile)
return out return out
end end
-- Inverse of pret tools/gfx --interleave (pokecrystal tools/gfx.c).
-- Build-time interleave stores each vertical 8x16 pair as consecutive 8x8
-- tiles for OBJ mode; this restores row-major sheet order for PNGs.
function ImageWriter.deinterleave(raw, width, bytesPerTile)
bytesPerTile = bytesPerTile or 16
local widthTiles = width / 8
local numTiles = #raw / bytesPerTile
local out = {}
for i = 0, numTiles - 1 do
local row = math.floor(i / widthTiles)
local src = i * 2 - (row % 2 == 1
and widthTiles * (row + 1) - 1
or widthTiles * row)
for offset = 1, bytesPerTile do
out[i * bytesPerTile + offset] = raw[src * bytesPerTile + offset]
end
end
return out
end
function ImageWriter.save(image, path) function ImageWriter.save(image, path)
local ok, fileData = pcall(image.encode, image, "png") local ok, fileData = pcall(image.encode, image, "png")
if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end
+26 -9
View File
@@ -6,8 +6,8 @@
-- options.lua table (src/core/SaveData.loadOptions/saveOptions) and lets the -- options.lua table (src/core/SaveData.loadOptions/saveOptions) and lets the
-- next boot's applyOptions pick the values up. Every ladder mirrors -- next boot's applyOptions pick the values up. Every ladder mirrors
-- OptionsMenu's semantics and stored values; when editing one, keep the two -- OptionsMenu's semantics and stored values; when editing one, keep the two
-- in sync. ZOOM is deliberately absent: its range depends on the live -- in sync. ZOOM uses the live window's integer fit (same 160×144 rule as
-- renderer's fit scale (Renderer:fitScale), which does not exist here. -- Renderer:fitScale) so the row can offer OUT/FIT/IN without a running game.
-- --
-- Rows are the same descriptor idiom OptionRows draws in game: -- Rows are the same descriptor idiom OptionRows draws in game:
-- { label, value = fn() -> string, step = fn(dir) -> changed, -- { label, value = fn() -> string, step = fn(dir) -> changed,
@@ -278,6 +278,16 @@ local function coreRows(opts, hooks)
end) end)
end end
local okZ, Zoom = pcall(require, "src.render.Zoom")
if okZ then
add(Strings("ZOOM"),
function() return Zoom.offsetLabel(opts.zoom or 0) end,
function(dir)
Zoom.nudgeOptions(opts, dir, Zoom.windowFitScale())
return true
end)
end
local okTile, TileRenderer = pcall(require, "src.render.TileRenderer") local okTile, TileRenderer = pcall(require, "src.render.TileRenderer")
if okTile and TileRenderer.VOID_FILLS then if okTile and TileRenderer.VOID_FILLS then
add(Strings("VOID FILL"), add(Strings("VOID FILL"),
@@ -303,15 +313,12 @@ local function coreRows(opts, hooks)
end) end)
end end
-- ORIENTATION (#592): Android only -- the lock rides SDL's orientation -- ORIENTATION (#592, #1638): mobile only. Unlike the other launcher rows
-- hint, which iOS reads only at startup (the Info.plist governs there) and -- this one live-applies: the window exists here too, and rotating under
-- desktop ignores. Unlike the other launcher rows this one live-applies: -- the player's finger is the only feedback that reads.
-- the window exists here too, and rotating under the player's finger is
-- the only feedback that reads.
do do
local osName = love.system and love.system.getOS and love.system.getOS()
local okOr, Orientation = pcall(require, "src.core.Orientation") local okOr, Orientation = pcall(require, "src.core.Orientation")
if okOr and osName == "Android" then if okOr and (Orientation.isAndroid() or Orientation.isIOS()) then
add(Strings("ORIENTATION"), add(Strings("ORIENTATION"),
function() return Strings(Orientation.modeLabel(opts.orientation)) end, function() return Strings(Orientation.modeLabel(opts.orientation)) end,
function(dir) function(dir)
@@ -646,6 +653,16 @@ local function gen2Rows(opts, hooks)
end) end)
end end
local okZ, Zoom = pcall(require, "src.render.Zoom")
if okZ then
add(Strings("ZOOM"),
function() return Zoom.offsetLabel(opts.zoom or 0) end,
function(dir)
Zoom.nudgeOptions(opts, dir, Zoom.windowFitScale())
return true
end)
end
local okFill, BorderFill = pcall(require, "src.world.gen2.BorderFill") local okFill, BorderFill = pcall(require, "src.world.gen2.BorderFill")
if okFill and BorderFill.VOID_FILLS then if okFill and BorderFill.VOID_FILLS then
add(Strings("VOID FILL"), add(Strings("VOID FILL"),
+477 -135
View File
@@ -124,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
@@ -154,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
@@ -233,10 +301,12 @@ function LauncherView.clickAt(imp, x, y)
imp._clickPt = { x = x, y = y } imp._clickPt = { x = x, y = y }
end end
-- Event-driven click: a macOS trackpad tap delivers press+release inside one -- Event-driven press: a macOS trackpad tap delivers press+release inside one
-- frame, so update()'s love.mouse.isDown poll never sees it. Mint the click -- 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, and mark the -- from the press event under the poll's own suppression rules -- the poll's
-- press seen so the poll cannot mint a second one when isDown does catch it. -- 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) function LauncherView.mousepressed(imp, x, y)
if not imp._flex then return end if not imp._flex then return end
local now = love.timer.getTime() local now = love.timer.getTime()
@@ -245,7 +315,7 @@ function LauncherView.mousepressed(imp, x, y)
or now < (imp._suppressClickUntil or 0) then or now < (imp._suppressClickUntil or 0) then
return return
end end
imp._clickPt = { x = x, y = y } if not imp._mouseAt then armMouse(imp, x, y) end
imp._prevMouseDown = true imp._prevMouseDown = true
end end
@@ -495,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
@@ -682,12 +753,15 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
-- and one below the top-right corner notch, like the DMG cart. -- and one below the top-right corner notch, like the DMG cart.
local grooveW = w * 0.115 local grooveW = w * 0.115
local grooveH = math.max(1, h * 0.009) local grooveH = math.max(1, h * 0.009)
local grooveScale = { 1.22, 1.10, 1.00, 1.00, 1.10, 1.22 }
local grooveInset = w * 0.02
for i = 0, 5 do for i = 0, 5 do
local ry = mainTop + h * 0.014 + i * h * 0.021 local ry = mainTop + h * 0.014 + i * h * 0.021
cartPolygon(cartQuad(project, -halfW + w * 0.02, ry, local gw = grooveW * grooveScale[i + 1]
grooveW, grooveH, faceZ), side, 0.7) cartPolygon(cartQuad(project, -halfW + grooveInset, ry,
cartPolygon(cartQuad(project, halfW - grooveW - w * 0.02, ry, gw, grooveH, faceZ), side, 0.7)
grooveW, grooveH, faceZ), side, 0.7) cartPolygon(cartQuad(project, halfW - gw - grooveInset, ry,
gw, grooveH, faceZ), side, 0.7)
end end
-- The thin diagonal mold ridge cut into each long side a little below -- The thin diagonal mold ridge cut into each long side a little below
-- the grip grooves, mirrored left/right. -- the grip grooves, mirrored left/right.
@@ -703,12 +777,14 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
{ project(x1 + nx * t, y1 + ny * t, faceZ) }, { project(x1 + nx * t, y1 + ny * t, faceZ) },
}, side, 0.7) }, side, 0.7)
end end
local dgY = mainTop + h * 0.20 local dgY = mainTop + h * 0.25
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)
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. -- The pill recess: one stadium pill sunk into the shell.
local pillX, pillW = -halfW + w * 0.17, w * 0.62 local pillX, pillW = -halfW + w * 0.19, w * 0.62
local pillY, pillH = mainTop + h * 0.024, h * 0.115 local pillY, pillH = mainTop + h * 0.015, h * 0.115
-- log out pillH
cartPill(project, pillX, pillY, pillW, pillH, faceZ + 0.5, side, 0.55) cartPill(project, pillX, pillY, pillW, pillH, faceZ + 0.5, side, 0.55)
local inX, inY = w * 0.008, h * 0.008 local inX, inY = w * 0.008, h * 0.008
cartPill(project, pillX + inX, pillY + inY, cartPill(project, pillX + inX, pillY + inY,
@@ -991,9 +1067,22 @@ local GAME_TABS = {
local HEADER_TABS = { local HEADER_TABS = {
{ id = "mods", key = "tab-mods" }, { id = "mods", key = "tab-mods" },
{ id = "find", key = "tab-find" }, { id = "find", key = "tab-find" },
{ id = "skins", key = "tab-skins", glyph = true }, { id = "skins", key = "tab-skins", glyph = true, beta = true },
{ id = "bug", key = "tab-bug" }, { id = "bug", key = "tab-bug" },
} }
local BETA_TAG_OPTS = { fill = true, bold = true, ink = PAL.inverse }
local function drawBetaTag(x, y, w, h)
Kit.tag(x, y, w, h, "BETA", PAL.yellow, BETA_TAG_OPTS)
end
local function overlayBeta(tx, ty, w, tabH, m)
local bh = math.floor(11 * m.s)
local bw = math.min(w, Kit.textWidth("micro", "BETA") + math.floor(10 * m.s))
drawBetaTag(tx + (w - bw) / 2, ty + tabH - bh - math.floor(2 * m.s), bw, bh)
end
for _, t in ipairs(HEADER_TABS) do for _, t in ipairs(HEADER_TABS) do
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter } t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
if t.glyph then if t.glyph then
@@ -1197,6 +1286,7 @@ local function buildHeader(imp, m)
o.image = t.icon o.image = t.icon
o.action = chrome.tab[t.id] o.action = chrome.tab[t.id]
btn(imp, tx, ty, w, tabH, t.key, "", o) btn(imp, tx, ty, w, tabH, t.key, "", o)
if t.beta then overlayBeta(tx, ty, w, tabH, m) end
tx = tx + w + tabGap tx = tx + w + tabGap
end end
-- The bug-report chip sits LAST, past the sync chip. -- The bug-report chip sits LAST, past the sync chip.
@@ -1214,10 +1304,7 @@ local function buildHeader(imp, m)
local o = chrome.sync local o = chrome.sync
o.active = imp._syncModal ~= nil o.active = imp._syncModal ~= nil
btn(imp, tx, ty, w, tabH, "tab-sync", "", o) btn(imp, tx, ty, w, tabH, "tab-sync", "", o)
local bh = math.floor(11 * m.s) overlayBeta(tx, ty, w, tabH, m)
local bw = math.min(w, Kit.textWidth("micro", "BETA") + math.floor(10 * m.s))
Kit.tag(tx + (w - bw) / 2, ty + tabH - bh - math.floor(2 * m.s), bw, bh,
"BETA", o.active and PAL.inverse or PAL.yellow)
local eng = imp._sync local eng = imp._sync
if eng and eng.busy and eng:busy() then if eng and eng.busy and eng:busy() then
Kit.spinner(tx + w - math.floor(8 * m.s), ty + math.floor(8 * m.s), Kit.spinner(tx + w - math.floor(8 * m.s), ty + math.floor(8 * m.s),
@@ -1233,7 +1320,7 @@ local function buildHeader(imp, m)
return y + math.floor(10 * m.s) return y + math.floor(10 * m.s)
end end
-- The state of the self-updater, as a top-right control. -- The state of the self-updater, shown in the launcher footer.
-- Returns status, label, action, glow. -- Returns status, label, action, glow.
function LauncherView._updateControl(imp) function LauncherView._updateControl(imp)
if not imp.Check then return nil end if not imp.Check then return nil end
@@ -1245,15 +1332,27 @@ function LauncherView._updateControl(imp)
elseif status == "downloading" then elseif status == "downloading" then
local pct = st.progress and math.floor(st.progress * 100) or 0 local pct = st.progress and math.floor(st.progress * 100) or 0
return status, Strings("Updating %d%%", pct), nil, false return status, Strings("Updating %d%%", pct), nil, false
elseif status == "full_downloading" then
local pct = st.progress and math.floor(st.progress * 100) or 0
return status, Strings("Downloading app %d%%", pct), nil, false
elseif status == "available" then elseif status == "available" then
return status, st.latest and (Strings("Update v") .. st.latest) return status, st.latest and (Strings("Update v") .. st.latest)
or Strings("Update"), function() pcall(imp.Check.download) end, true or Strings("Update"), function() pcall(imp.Check.download) end, true
elseif status == "ready" then elseif status == "ready" then
return status, Strings("Restart to update"), return status, Strings("Restart to update"),
function() require("src.core.HostShell").restart() end, true function() require("src.core.HostShell").restart() end, true
elseif status == "needs_full" then elseif status == "needs_full" or status == "full_ready" then
return status, Strings("Open releases"), local action = imp.Check.fullUpdateAction and imp.Check.fullUpdateAction()
function() love.system.openURL(imp.Check.releaseUrl()) end, true local label = action and action.label or "Open releases"
local url = action and action.url or imp.Check.releaseUrl()
return status, Strings(label),
function()
if action and action.kind and imp.Check.performFullUpdate then
pcall(imp.Check.performFullUpdate)
else
love.system.openURL(url)
end
end, true
end end
-- idle / uptodate / error: offer a manual check, with no glow. -- idle / uptodate / error: offer a manual check, with no glow.
return status, Strings("Check for updates"), return status, Strings("Check for updates"),
@@ -2059,16 +2158,20 @@ local function buildModsPanel(imp, x, y, w, availH, m)
+ math.floor(8 * m.s) + math.floor(8 * m.s)
local listTop = cy local listTop = cy
-- One continuous list: every row is laid out, the region scroll moves -- One continuous list: derive the rows that can touch the viewport before
-- through all of it, and only rows inside the region's viewport draw -- -- entering the loop. Drawing was already culled, but scanning every
-- so the per-frame cost stays bounded by the window, not the list. -- installed row to discover that defeats the point on a large mod library.
local view = imp._tabRegionRect local view = imp._tabRegionRect
local viewTop = view and view.y or listTop local viewTop = view and view.y or listTop
local viewBot = view and (view.y + view.h) or (listTop + availH) local viewBot = view and (view.y + view.h) or (listTop + availH)
for i = 1, #mods do local stride = rowH + gap
local first = math.max(1,
math.ceil((viewTop - rowH - listTop) / stride) + 1)
local last = math.min(#mods,
math.floor((viewBot - listTop) / stride) + 1)
for i = first, last do
local mod = mods[i] local mod = mods[i]
local ry = listTop + (i - 1) * (rowH + gap) local ry = listTop + (i - 1) * (rowH + gap)
if ry + rowH >= viewTop and ry <= viewBot then
local rowKey = rowKeyFor(imp, "mod-row-", mod.id) local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
local isFullyDisabled = true local isFullyDisabled = true
if mod.enabledByVersion then if mod.enabledByVersion then
@@ -2178,7 +2281,6 @@ local function buildModsPanel(imp, x, y, w, availH, m)
px, ly, PAL.detail) px, ly, PAL.detail)
end end
end end
end
local contentH = #mods * rowH + (#mods - 1) * gap local contentH = #mods * rowH + (#mods - 1) * gap
return (listTop + contentH + gap) - y return (listTop + contentH + gap) - y
@@ -2186,8 +2288,8 @@ end
-- ---------------------------------------------------------- find mods panel -- ---------------------------------------------------------- find mods panel
-- SKINS tab: pick the on-screen skin, import one, or open the desktop studio. -- SKINS tab: pick the on-screen skin, import one, or open Skin Studio.
local function buildSkinsPanel(imp, x, y, w, availH, m) local function buildSkinsPanelLegacy(imp, x, y, w, availH, m)
local skins = imp:_ensureSkins() local skins = imp:_ensureSkins()
local active = imp:_activeSkin() local active = imp:_activeSkin()
local gap = m.gap local gap = m.gap
@@ -2241,7 +2343,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
end end
cy = cy + urlH + math.floor(8 * m.s) cy = cy + urlH + math.floor(8 * m.s)
-- Studio button. Desktop only: the host supplies the hook nowhere else. -- The Studio reflows to a touch-first canvas plus inspector on phones.
if imp.onOpenSkinStudio then if imp.onOpenSkinStudio then
local label = Strings("Open Skin Studio") local label = Strings("Open Skin Studio")
local bw = math.min(w, Kit.textWidth("small", label) + math.floor(40 * m.s)) local bw = math.min(w, Kit.textWidth("small", label) + math.floor(40 * m.s))
@@ -2261,6 +2363,64 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
Kit.caption(x, cy, Strings("INSTALLED")) Kit.caption(x, cy, Strings("INSTALLED"))
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s) cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
-- Keep the actionable list below for detailed metadata and exports, but
-- lead with a visual picker: skins are much easier to recognize by their
-- bezel than by a folder name. Cards use the same loaded art that the
-- runtime draws, so they cannot drift from the selected skin.
local previewGap = math.floor(8 * m.s)
local previewCols = w >= math.floor(420 * m.s) and 2 or 1
local previewW = (w - previewGap * (previewCols - 1)) / previewCols
local previewH = math.max(108 * m.s, Kit.tapMin() * 2)
local previewCount = #skins
for i, entry in ipairs(skins) do
local n = i - 1
local px = x + (n % previewCols) * (previewW + previewGap)
local py = cy + math.floor(n / previewCols) * (previewH + previewGap)
local key = "skin-preview-" .. entry.id
local selected = active == entry.id
local focused = Kit.focusable(key, px, py, previewW, previewH)
Kit.card(px, py, previewW, previewH, selected and "selected"
or (focused or Kit.hover(px, py, previewW, previewH)))
local pad = math.floor(8 * m.s)
local artH = math.floor(previewH * 0.60)
Theme.fillRounded(px + pad, py + pad, previewW - pad * 2, artH,
PAL.bg, 1, Theme.cardRadius() * 0.6)
local art = entry.preview
if art and art.getDimensions then
local iw, ih = art:getDimensions()
if iw > 0 and ih > 0 then
local scale = math.min((previewW - pad * 4) / iw, (artH - pad * 2) / ih)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(art, px + (previewW - iw * scale) * 0.5,
py + pad + (artH - ih * scale) * 0.5, 0, scale, scale)
end
else
Theme.strokeRounded(px + previewW * 0.23, py + pad + artH * 0.15,
previewW * 0.54, artH * 0.42, PAL.line, Theme.A.hairline, 1, 2)
Theme.fillRounded(px + previewW * 0.20, py + pad + artH * 0.64,
previewW * 0.22, artH * 0.17, PAL.steel, 0.75, 3)
Theme.fillRounded(px + previewW * 0.60, py + pad + artH * 0.61,
previewW * 0.12, artH * 0.22, PAL.steel, 0.75, artH * 0.11)
Theme.fillRounded(px + previewW * 0.75, py + pad + artH * 0.56,
previewW * 0.12, artH * 0.22, PAL.steel, 0.75, artH * 0.11)
end
Kit.text("mono", Kit.ellipsize("mono", entry.id, previewW - pad * 2),
px + pad, py + pad + artH + math.floor(5 * m.s),
selected and PAL.green or PAL.heading)
if selected then
Kit.text("micro", Strings("IN USE"), px + pad,
py + previewH - pad - Kit.textHeight("micro"), PAL.green)
end
if Kit.press(px, py, previewW, previewH) or Kit._activateId == key then
queueAction(imp, key, function() imp:_useSkin(entry.id) end)
end
end
if previewCount > 0 then
cy = cy + math.ceil(previewCount / previewCols) * previewH
+ math.max(0, math.ceil(previewCount / previewCols) - 1) * previewGap
+ gap
end
local rowH = math.max(Kit.tapMin(), math.floor(44 * m.s)) local rowH = math.max(Kit.tapMin(), math.floor(44 * m.s))
imp._skinGear = imp._skinGear imp._skinGear = imp._skinGear
or love.graphics.newImage("assets/launcher/gear.png") or love.graphics.newImage("assets/launcher/gear.png")
@@ -2374,6 +2534,69 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
return cy + hintH - y return cy + hintH - y
end end
-- The launcher is the short path: bring a skin in, see what is enabled, or
-- turn skin use off. Browsing, pagination and per-skin editing/export live
-- together in My Skins, where they are useful instead of competing here.
local function buildSkinsPanel(imp, x, y, w, availH, m)
local cy, gap, bh = y, m.gap, m.btnH
local active = imp:_activeSkin()
Kit.text("button", Strings("Skins"), x, cy, PAL.heading)
local importW = math.min(w * 0.46,
Kit.textWidth("small", imp:_skinsImportButtonLabel()) + math.floor(24 * m.s))
btn(imp, x + w - importW, cy, importW, bh, "skins-import",
imp:_skinsImportButtonLabel(), { kind = "accent", font = "small",
action = function() imp:chooseSkin() end })
cy = cy + bh + gap
if imp._skinNotice then
cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w,
imp._skinNotice.ok and PAL.green or PAL.red, 2) + gap
end
local addW = Kit.textWidth("small", Strings("Add")) + math.floor(24 * m.s)
if imp._skinFetch then
Loader.inline(x, cy, w, bh, Strings("Downloading %s...",
tostring(imp._skinFetch.name or "")))
else
btn(imp, x + w - addW, cy, addW, bh, "skins-url-add", Strings("Add"), {
kind = "accent", font = "small", action = function() imp:_addSkinFromUrl() end })
textField(imp, x, cy, w - addW - gap, bh, "skins-url", imp.skinUrl or "",
Strings("Paste a skin link (.zip, .cfg, .deltaskin)"),
imp._skinUrlFocus == true, function() imp:_toggleSkinUrlFocus() end)
end
cy = cy + bh + gap
local currentH = active and (bh * 2 + gap * 2) or (bh + gap * 2)
Kit.card(x, cy, w, currentH)
Kit.caption(x + gap, cy + gap, "CURRENT SKIN")
local current = active and tostring(active) or Strings("No skin enabled")
Kit.text("mono", Kit.ellipsize("mono", current, w - gap * 2), x + gap,
cy + gap + Kit.textHeight("small") + math.floor(4 * m.s),
active and PAL.green or PAL.muted)
local buttonY = cy + bh + gap
local half = (w - gap * 3) * 0.5
if active then
btn(imp, x + gap, buttonY, half, bh, "skins-export-current",
Strings("Export current"), { font = "small",
action = function() imp:_exportSkin(active, "native") end })
btn(imp, x + gap * 2 + half, buttonY, half, bh, "skins-off",
Strings("Turn skins off"), { kind = "danger", font = "small",
action = function() imp:_disableSkins() end })
end
cy = cy + currentH + gap
if imp.onOpenSkinStudio then
btn(imp, x, cy, w, bh, "skins-my-skins", Strings("My Skins"), {
kind = "accent", font = "small",
action = function() imp.onOpenSkinStudio(imp.modScope or "red", active) end })
cy = cy + bh + gap
end
Kit.textWrapped("small", Strings("Import from a file or link, then manage, edit and export individual skins in My Skins."),
x, cy, w, PAL.muted, 3)
return cy + Kit.wrapHeight("small", Strings("Import from a file or link, then manage, edit and export individual skins in My Skins."), w, 3) - y
end
local function buildBugPanel(imp, x, y, w, availH, m) local function buildBugPanel(imp, x, y, w, availH, m)
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local gap = m.gap local gap = m.gap
@@ -2934,9 +3157,13 @@ local function buildTextModal(imp, m, key, title, body, closeFn)
local h = math.floor(math.min(m.H - 2 * m.pad, 460 * m.s)) local h = math.floor(math.min(m.H - 2 * m.pad, 460 * m.s))
local px, py, pw, ph = modalPanel(m, w, h) local px, py, pw, ph = modalPanel(m, w, h)
local cy = py + pad local cy = py + pad
Kit.text("button", Kit.ellipsize("button", title, pw - 2 * pad), local xW = math.max(Kit.tapMin(), math.floor(30 * m.s))
Kit.text("button", Kit.ellipsize("button", title,
pw - 2 * pad - xW - math.floor(8 * m.s)),
px + pad, cy, PAL.heading) px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(10 * m.s) btn(imp, px + pw - pad - xW, cy, xW, xW, key .. "-x", "X",
{ font = "small", action = closeFn })
cy = cy + math.max(Kit.textHeight("button"), xW) + math.floor(10 * m.s)
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local bodyH = (py + ph - pad) - cy - m.btnH - math.floor(10 * m.s) local bodyH = (py + ph - pad) - cy - m.btnH - math.floor(10 * m.s)
@@ -4274,26 +4501,75 @@ local function syncTitle(imp, m, px, py, pw, pad)
Kit.text("button", label, px + pad, py, PAL.heading) Kit.text("button", label, px + pad, py, PAL.heading)
local bh = math.floor(15 * m.s) local bh = math.floor(15 * m.s)
local bw = Kit.textWidth("micro", "BETA") + math.floor(14 * m.s) local bw = Kit.textWidth("micro", "BETA") + math.floor(14 * m.s)
Kit.tag(px + pad + Kit.textWidth("button", label) + math.floor(8 * m.s), drawBetaTag(px + pad + Kit.textWidth("button", label) + math.floor(8 * m.s),
py + (Kit.textHeight("button") - bh) / 2, bw, bh, "BETA", PAL.yellow) py + (Kit.textHeight("button") - bh) / 2, bw, bh)
return py + Kit.textHeight("button") + math.floor(12 * m.s) return py + Kit.textHeight("button") + math.floor(12 * m.s)
end end
local function syncStatus(imp, m, x, y, w, eng) local function syncWidth(m, want)
return math.floor(math.min(want, m.W - 2 * m.pad))
end
local function syncFit(m, fixed, rows, gaps, texts)
local fit = { btnH = m.btnH, gap = math.floor(8 * m.s), lines = {} }
local avail = m.H - 2 * m.pad
texts = texts or {}
for i, blk in ipairs(texts) do fit.lines[i] = blk.max end
local function total()
local t = fixed + rows * fit.btnH + gaps * fit.gap
for i, blk in ipairs(texts) do
t = t + Kit.wrapHeight(blk.font, blk.str, blk.w, fit.lines[i])
end
return t
end
while total() > avail do
local worst, worstH = nil, 0
for i, blk in ipairs(texts) do
if fit.lines[i] > 1 then
local hgt = Kit.wrapHeight(blk.font, blk.str, blk.w, fit.lines[i])
if hgt > worstH then worst, worstH = i, hgt end
end
end
if not worst then break end
fit.lines[worst] = fit.lines[worst] - 1
end
if total() > avail and gaps > 0 then
fit.gap = math.max(math.max(2, math.floor(3 * m.s)),
fit.gap - math.ceil((total() - avail) / gaps))
end
if total() > avail and rows > 0 then
fit.btnH = math.max(Kit.tapMin(),
fit.btnH - math.ceil((total() - avail) / rows))
end
fit.over = total() - avail
fit.h = math.min(total(), avail)
return fit
end
local function syncStatus(imp, m, x, y, w, eng, fit)
local bh = (fit and fit.btnH) or m.btnH
local gap = (fit and fit.gap) or math.floor(8 * m.s)
if eng:busy() then if eng:busy() then
Loader.inline(x, y, w, m.btnH, eng.status) Loader.inline(x, y, w, bh, eng.status)
return m.btnH + math.floor(8 * m.s) return bh + gap
end end
Kit.text("small", Kit.ellipsize("small", eng.status or "", w), x, y, Kit.text("small", Kit.ellipsize("small", eng.status or "", w), x, y,
eng.phase == "error" and PAL.red or PAL.muted) eng.phase == "error" and PAL.red or PAL.muted)
return Kit.textHeight("small") + gap + math.floor(2 * m.s)
end
local function syncReserve(m, eng)
if eng:busy() then return m.btnH + math.floor(8 * m.s) end
return Kit.textHeight("small") + math.floor(10 * m.s) return Kit.textHeight("small") + math.floor(10 * m.s)
end end
local function syncRow(imp, m, x, y, w, key, label, opts) local function syncRow(imp, m, x, y, w, key, label, opts, fit)
opts = opts or {} opts = opts or {}
opts.font = "small" opts.font = "small"
btn(imp, x, y, w, m.btnH, key, label, opts) local bh = (fit and fit.btnH) or m.btnH
return y + m.btnH + math.floor(8 * m.s) local gap = (fit and fit.gap) or math.floor(8 * m.s)
btn(imp, x, y, w, bh, key, label, opts)
return y + bh + gap
end end
function LauncherView.syncSideText(meta) function LauncherView.syncSideText(meta)
@@ -4325,91 +4601,99 @@ end
local function buildSyncConflict(imp, m, eng) local function buildSyncConflict(imp, m, eng)
local row = eng.conflicts[1] local row = eng.conflicts[1]
local pad = math.floor(18 * m.s) local pad = math.floor(18 * m.s)
local w = math.floor(520 * m.s) local w = syncWidth(m, math.floor(520 * m.s))
local innerW = w - 2 * pad local innerW = w - 2 * pad
local lead = row.overlap local lead = row.overlap
and Strings("These saves were played at the same time.") and Strings("These saves were played at the same time.")
or Strings("This save also changed on another device.") or Strings("This save also changed on another device.")
local leadH = Kit.wrapHeight("small", lead, innerW, 2) local mine = LauncherView.syncSideText(row.localMeta)
local sideH = Kit.textHeight("small") + math.floor(2 * m.s) local theirs = LauncherView.syncSideText(row.remoteMeta)
+ Kit.wrapHeight("micro", "x", innerW, 2) local fit = syncFit(m,
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + leadH 2 * pad + Kit.textHeight("button") + math.floor(22 * m.s)
+ math.floor(10 * m.s) + 2 * (sideH + math.floor(10 * m.s)) + 2 * (Kit.textHeight("small") + math.floor(12 * m.s)),
+ 4 * (m.btnH + math.floor(8 * m.s)) + pad 4, 4, {
local px, py, pw = modalPanel(m, w, h) { font = "small", str = lead, w = innerW, max = 2 },
{ font = "micro", str = mine, w = innerW, max = 2 },
{ font = "micro", str = theirs, w = innerW, max = 2 },
})
local px, py, pw = modalPanel(m, w, fit.h)
local cy = syncTitle(imp, m, px, py + pad, pw, pad) local cy = syncTitle(imp, m, px, py + pad, pw, pad)
cy = cy + Kit.textWrapped("small", lead, px + pad, cy, pw - 2 * pad, cy = cy + Kit.textWrapped("small", lead, px + pad, cy, innerW,
PAL.detail, 2) + math.floor(10 * m.s) PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
local function side(title, meta) local function side(title, text, lines)
Kit.text("small", title, px + pad, cy, PAL.heading) Kit.text("small", title, px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("small") + math.floor(2 * m.s) cy = cy + Kit.textHeight("small") + math.floor(2 * m.s)
cy = cy + Kit.textWrapped("micro", LauncherView.syncSideText(meta), cy = cy + Kit.textWrapped("micro", text, px + pad, cy, innerW,
px + pad, cy, pw - 2 * pad, PAL.muted, 2) + math.floor(10 * m.s) PAL.muted, lines) + math.floor(10 * m.s)
end end
side(Strings("This device") .. " \194\183 " .. tostring(row.version or "?"), side(Strings("This device") .. " \194\183 " .. tostring(row.version or "?"),
row.localMeta) mine, fit.lines[2])
side(Strings("Other device"), row.remoteMeta) side(Strings("Other device"), theirs, fit.lines[3])
local key = row.key local key = row.key
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-this", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-this",
Strings("Keep this device"), { kind = "primary", Strings("Keep this device"), { kind = "primary",
action = function() imp:_syncResolve(key, "local") end }) action = function() imp:_syncResolve(key, "local") end }, fit)
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-other", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-other",
Strings("Keep the other device"), { kind = "accent", Strings("Keep the other device"), { kind = "accent",
action = function() imp:_syncResolve(key, "remote") end }) action = function() imp:_syncResolve(key, "remote") end }, fit)
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-both", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-both",
Strings("Keep both"), { Strings("Keep both"), {
action = function() imp:_syncResolve(key, "both") end }) action = function() imp:_syncResolve(key, "both") end }, fit)
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-conflict-close", syncRow(imp, m, px + pad, cy, innerW, "sync-conflict-close",
Strings("Close"), { action = function() imp:_closeSync() end }) Strings("Close"), { action = function() imp:_closeSync() end }, fit)
end end
local function buildSyncLink(imp, m, eng) local function buildSyncLink(imp, m, eng)
local mo = imp._syncModal local mo = imp._syncModal
local pad = math.floor(18 * m.s) local pad = math.floor(18 * m.s)
local w = math.floor(460 * m.s) local w = syncWidth(m, math.floor(460 * m.s))
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s)) local innerW = w - 2 * pad
local hint = Strings("Enter the two codes the other device is showing.") local hint = Strings("Enter the two codes the other device is showing.")
local hintH = Kit.wrapHeight("small", hint, w - 2 * pad, 2) local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH local fit = syncFit(m,
+ math.floor(10 * m.s) + 2 * (fieldH + math.floor(8 * m.s)) 2 * pad + Kit.textHeight("button") + math.floor(22 * m.s)
+ Kit.textHeight("small") + math.floor(10 * m.s) + 2 * (fieldH + math.floor(8 * m.s)) + syncReserve(m, eng),
+ 2 * (m.btnH + math.floor(8 * m.s)) + pad 2, 2, { { font = "small", str = hint, w = innerW, max = 2 } })
local px, py, pw = modalPanel(m, w, h) local px, py, pw = modalPanel(m, w, fit.h)
local cy = syncTitle(imp, m, px, py + pad, pw, pad) local cy = syncTitle(imp, m, px, py + pad, pw, pad)
cy = cy + Kit.textWrapped("small", hint, px + pad, cy, pw - 2 * pad, cy = cy + Kit.textWrapped("small", hint, px + pad, cy, innerW,
PAL.detail, 2) + math.floor(10 * m.s) PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code1", textField(imp, px + pad, cy, innerW, fieldH, "sync-code1",
mo.code1 or "", Strings("First code"), imp._syncFocus == "code1", mo.code1 or "", Strings("First code"), imp._syncFocus == "code1",
function() imp:_syncFocusField("code1") end) function() imp:_syncFocusField("code1") end)
cy = cy + fieldH + math.floor(8 * m.s) cy = cy + fieldH + fit.gap
textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code2", textField(imp, px + pad, cy, innerW, fieldH, "sync-code2",
mo.code2 or "", Strings("Second code"), imp._syncFocus == "code2", mo.code2 or "", Strings("Second code"), imp._syncFocus == "code2",
function() imp:_syncFocusField("code2") end) function() imp:_syncFocusField("code2") end)
cy = cy + fieldH + math.floor(8 * m.s) cy = cy + fieldH + fit.gap
cy = cy + syncStatus(imp, m, px + pad, cy, pw - 2 * pad, eng) cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit)
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-go", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link-go",
Strings("Link this device"), { kind = "primary", enabled = not eng:busy(), Strings("Link this device"), { kind = "primary", enabled = not eng:busy(),
action = function() imp:_syncLink() end }) action = function() imp:_syncLink() end }, fit)
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-back", syncRow(imp, m, px + pad, cy, innerW, "sync-link-back",
Strings("Back"), { action = function() imp:_syncView("home") end }) Strings("Back"), { action = function() imp:_syncView("home") end }, fit)
end end
local function buildSyncMods(imp, m, eng) local function buildSyncMods(imp, m, eng)
local mo = imp._syncModal local mo = imp._syncModal
local pad = math.floor(18 * m.s) local pad = math.floor(18 * m.s)
local w = math.floor(500 * m.s) local w = syncWidth(m, math.floor(500 * m.s))
local innerW = w - 2 * pad
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s)) local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
local plan = eng.modPlan local plan = eng.modPlan
local rows = 4 + (plan and 1 or 0) local rows = 4 + (plan and 1 or 0)
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) local codeH = eng.shareCode and (Kit.textHeight("small")
+ 3 * (Kit.textHeight("small") + math.floor(8 * m.s)) + Kit.textHeight("stat") + Kit.textHeight("micro")
+ fieldH + math.floor(8 * m.s) + math.floor(18 * m.s)) or 0
+ rows * (m.btnH + math.floor(8 * m.s)) + pad local planH = plan and (Kit.textHeight("small") + math.floor(8 * m.s)) or 0
local px, py, pw = modalPanel(m, w, h) local fit = syncFit(m,
2 * pad + Kit.textHeight("button") + math.floor(12 * m.s) + codeH + planH
+ fieldH + math.floor(8 * m.s) + syncReserve(m, eng),
rows, rows, {})
local px, py, pw = modalPanel(m, w, fit.h)
local cy = syncTitle(imp, m, px, py + pad, pw, pad) local cy = syncTitle(imp, m, px, py + pad, pw, pad)
local innerW = pw - 2 * pad
if eng.shareCode then if eng.shareCode then
Kit.text("small", Strings("Share this code:"), px + pad, cy, PAL.muted) Kit.text("small", Strings("Share this code:"), px + pad, cy, PAL.muted)
@@ -4421,17 +4705,23 @@ local function buildSyncMods(imp, m, eng)
px + pad, cy, PAL.muted) px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("micro") + math.floor(10 * m.s) cy = cy + Kit.textHeight("micro") + math.floor(10 * m.s)
end end
local withOptions = mo.withOptions ~= false
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-share-options",
Strings("Include my mod options") .. " \194\183 "
.. (withOptions and Strings("ON") or Strings("OFF")),
{ kind = withOptions and "accent" or nil, enabled = not eng:busy(),
action = function() imp:_syncToggleShareOptions() end }, fit)
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-share-mods", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-share-mods",
Strings("Share mod list"), { kind = "accent", enabled = not eng:busy(), Strings("Share mod list"), { kind = "accent", enabled = not eng:busy(),
action = function() imp:_syncShareMods() end }) action = function() imp:_syncShareMods() end }, fit)
textField(imp, px + pad, cy, innerW, fieldH, "sync-share-code", textField(imp, px + pad, cy, innerW, fieldH, "sync-share-code",
mo.share or "", Strings("Paste a 6-character mod code"), mo.share or "", Strings("Paste a 6-character mod code"),
imp._syncFocus == "share", function() imp:_syncFocusField("share") end) imp._syncFocus == "share", function() imp:_syncFocusField("share") end)
cy = cy + fieldH + math.floor(8 * m.s) cy = cy + fieldH + fit.gap
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-get-mods", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-get-mods",
Strings("Get mod list"), { kind = "accent", enabled = not eng:busy(), Strings("Get mod list"), { kind = "accent", enabled = not eng:busy(),
action = function() imp:_syncGetShare() end }) action = function() imp:_syncGetShare() end }, fit)
if plan then if plan then
local line = Strings("%d mods, %d indexes to add", local line = Strings("%d mods, %d indexes to add",
@@ -4440,24 +4730,29 @@ local function buildSyncMods(imp, m, eng)
line = line .. " \194\183 " .. Strings("%d not in your indexes", line = line .. " \194\183 " .. Strings("%d not in your indexes",
#plan.missing) #plan.missing)
end end
if #(plan.options or {}) > 0 then
line = line .. " \194\183 " .. (plan.applyOptions
and Strings("options for %d mods", #plan.options)
or Strings("their options skipped"))
end
Kit.text("small", Kit.ellipsize("small", line, innerW), px + pad, cy, Kit.text("small", Kit.ellipsize("small", line, innerW), px + pad, cy,
PAL.detail) PAL.detail)
cy = cy + Kit.textHeight("small") + math.floor(8 * m.s) cy = cy + Kit.textHeight("small") + math.floor(8 * m.s)
local prog = mo.progress local prog = mo.progress
if prog then if prog then
Loader.inline(px + pad, cy, innerW, m.btnH, Loader.inline(px + pad, cy, innerW, fit.btnH,
Strings("%d of %d", prog.done or 0, prog.total or 0)) Strings("%d of %d", prog.done or 0, prog.total or 0))
cy = cy + m.btnH + math.floor(8 * m.s) cy = cy + fit.btnH + fit.gap
else else
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-apply-mods", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-apply-mods",
Strings("Apply these mods"), { kind = "primary", Strings("Apply these mods"), { kind = "primary",
enabled = not eng:busy(), enabled = not eng:busy(),
action = function() imp:_syncApplyMods() end }) action = function() imp:_syncApplyMods() end }, fit)
end end
end end
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng) cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit)
syncRow(imp, m, px + pad, cy, innerW, "sync-mods-back", Strings("Back"), syncRow(imp, m, px + pad, cy, innerW, "sync-mods-back", Strings("Back"),
{ action = function() imp:_syncView("home") end }) { action = function() imp:_syncView("home") end }, fit)
end end
function LauncherView.syncDeviceRows(eng, limit) function LauncherView.syncDeviceRows(eng, limit)
@@ -4479,29 +4774,35 @@ end
local function buildSyncHome(imp, m, eng) local function buildSyncHome(imp, m, eng)
local pad = math.floor(18 * m.s) local pad = math.floor(18 * m.s)
local w = math.floor(460 * m.s) local w = syncWidth(m, math.floor(460 * m.s))
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 codesH = codes local codesH = codes
and (Kit.textHeight("small") + math.floor(6 * m.s) and (Kit.textHeight("small") + math.floor(6 * m.s)
+ 2 * (Kit.textHeight("title") + math.floor(4 * m.s)) + 2 * (Kit.textHeight("title") + math.floor(4 * m.s))
+ math.floor(8 * m.s)) or 0 + math.floor(8 * m.s)) or 0
local devices = linked and LauncherView.syncDeviceRows(eng) or {} local devices = linked and LauncherView.syncDeviceRows(eng) or {}
local hidden, fit = 0, nil
repeat
local devicesH = #devices > 0 local devicesH = #devices > 0
and (Kit.textHeight("small") + math.floor(6 * m.s)) or 0 and (Kit.textHeight("small") + math.floor(6 * m.s)) or 0
local rows = (linked and 5 or 3) + #devices fit = syncFit(m,
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH 2 * pad + Kit.textHeight("button") + math.floor(22 * m.s) + codesH
+ math.floor(10 * m.s) + codesH + devicesH + m.btnH + math.floor(10 * m.s) + devicesH + syncReserve(m, eng),
+ rows * (m.btnH + math.floor(8 * m.s)) + pad (linked and 4 or 3) + #devices, (linked and 4 or 3) + #devices,
local px, py, pw = modalPanel(m, w, h) { { font = "small", str = body, w = innerW, max = 5 } })
if fit.over <= 0 or #devices == 0 then break end
table.remove(devices)
hidden = hidden + 1
until false
local px, py, pw = modalPanel(m, w, fit.h)
local cy = syncTitle(imp, m, px, py + pad, pw, pad) local cy = syncTitle(imp, m, px, py + pad, pw, pad)
cy = cy + Kit.textWrapped("small", body, px + pad, cy, innerW, PAL.detail, 5) cy = cy + Kit.textWrapped("small", body, px + pad, cy, innerW, PAL.detail,
+ math.floor(10 * m.s) fit.lines[1]) + math.floor(10 * m.s)
if codes then if codes then
Kit.text("small", Strings("Enter these on your other device:"), px + pad, Kit.text("small", Strings("Enter these on your other device:"), px + pad,
@@ -4512,23 +4813,24 @@ local function buildSyncHome(imp, m, eng)
Kit.text("title", codes.code2, px + pad, cy, PAL.heading) Kit.text("title", codes.code2, px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("title") + math.floor(8 * m.s) cy = cy + Kit.textHeight("title") + math.floor(8 * m.s)
end end
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng) cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit)
if #devices > 0 then if #devices > 0 then
Kit.text("small", Strings("Devices on this account:"), px + pad, cy, Kit.text("small", hidden > 0
PAL.muted) and Strings("Devices on this account (%d more)", hidden)
or Strings("Devices on this account:"), px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s) cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
for i, device in ipairs(devices) do for i, device in ipairs(devices) do
local id = device.id local id = device.id
if device.current then if device.current then
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i, cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i,
device.label .. " \194\183 " .. Strings("this device"), device.label .. " \194\183 " .. Strings("this device"),
{ enabled = false }) { enabled = false }, fit)
else else
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i, cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i,
Strings("Unlink %s", device.label), { kind = "danger", Strings("Unlink %s", device.label), { kind = "danger",
enabled = not eng:busy(), enabled = not eng:busy(),
action = function() imp:_syncUnlinkDevice(id) end }) action = function() imp:_syncUnlinkDevice(id) end }, fit)
end end
end end
end end
@@ -4536,38 +4838,69 @@ local function buildSyncHome(imp, m, eng)
if linked then if linked then
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-now", Strings("Sync now"), cy = syncRow(imp, m, px + pad, cy, innerW, "sync-now", Strings("Sync now"),
{ kind = "primary", enabled = not eng:busy(), { kind = "primary", enabled = not eng:busy(),
action = function() imp:_syncNow() end }) action = function() imp:_syncNow() end }, fit)
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-mods", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-mods",
Strings("Share or get a mod list"), { kind = "accent", Strings("Share or get a mod list"), { kind = "accent",
action = function() imp:_syncView("mods") end }) action = function() imp:_syncView("mods") end }, fit)
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-unlink", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-unlink",
Strings("Unlink this device"), { kind = "danger", Strings("Unlink this device"), { kind = "danger",
action = function() imp:_syncUnlink() end }) action = function() imp:_syncUnlink() end }, fit)
else else
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-create", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-create",
Strings("Create sync account"), { kind = "primary", Strings("Create sync account"), { kind = "primary",
enabled = not eng:busy(), enabled = not eng:busy(),
action = function() imp:_syncCreate() end }) action = function() imp:_syncCreate() end }, fit)
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link",
Strings("Link this device"), { kind = "accent", Strings("Link this device"), { kind = "accent",
action = function() imp:_syncView("link") end }) action = function() imp:_syncView("link") end }, fit)
end end
syncRow(imp, m, px + pad, cy, innerW, "sync-close", Strings("Close"), syncRow(imp, m, px + pad, cy, innerW, "sync-close", Strings("Close"),
{ action = function() imp:_closeSync() end }) { action = function() imp:_closeSync() end }, fit)
end
local function buildSyncModOptions(imp, m, eng)
local plan = eng.modPlan
local ids = {}
for _, row in ipairs(plan.options or {}) do ids[#ids + 1] = row.id end
local pad = math.floor(18 * m.s)
local w = syncWidth(m, math.floor(480 * m.s))
local innerW = w - 2 * pad
local lead = Strings(
"This mod list also carries the options its owner set for %d mods. Import their options, or keep the ones you have?",
#ids)
local names = table.concat(ids, ", ")
local fit = syncFit(m,
2 * pad + Kit.textHeight("button") + math.floor(32 * m.s), 2, 2, {
{ font = "small", str = lead, w = innerW, max = 4 },
{ font = "micro", str = names, w = innerW, max = 3 },
})
local px, py, pw = modalPanel(m, w, fit.h)
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
cy = cy + Kit.textWrapped("small", lead, px + pad, cy, innerW, PAL.detail,
fit.lines[1]) + math.floor(8 * m.s)
cy = cy + Kit.textWrapped("micro", names, px + pad, cy, innerW, PAL.muted,
fit.lines[2]) + math.floor(12 * m.s)
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-options-import",
Strings("Import their options"), { kind = "primary",
action = function() imp:_syncAnswerModOptions(true) end }, fit)
syncRow(imp, m, px + pad, cy, innerW, "sync-options-skip",
Strings("Keep my options"), {
action = function() imp:_syncAnswerModOptions(false) end }, fit)
end end
local function buildSyncUnavailable(imp, m, msg) local function buildSyncUnavailable(imp, m, msg)
local pad = math.floor(18 * m.s) local pad = math.floor(18 * m.s)
local w = math.floor(420 * m.s) local w = syncWidth(m, math.floor(420 * m.s))
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) local innerW = w - 2 * pad
+ Kit.wrapHeight("small", msg, w - 2 * pad, 4) + math.floor(10 * m.s) local fit = syncFit(m,
+ m.btnH + pad 2 * pad + Kit.textHeight("button") + math.floor(22 * m.s), 1, 0,
local px, py, pw = modalPanel(m, w, h) { { font = "small", str = msg, w = innerW, max = 4 } })
local px, py, pw = modalPanel(m, w, fit.h)
local cy = syncTitle(imp, m, px, py + pad, pw, pad) local cy = syncTitle(imp, m, px, py + pad, pw, pad)
cy = cy + Kit.textWrapped("small", msg, px + pad, cy, pw - 2 * pad, cy = cy + Kit.textWrapped("small", msg, px + pad, cy, innerW,
PAL.detail, 4) + math.floor(10 * m.s) PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-close", syncRow(imp, m, px + pad, cy, innerW, "sync-close",
Strings("Close"), { action = function() imp:_closeSync() end }) Strings("Close"), { action = function() imp:_closeSync() end }, fit)
end end
local function buildSyncModal(imp, m) local function buildSyncModal(imp, m)
@@ -4586,6 +4919,12 @@ local function buildSyncModal(imp, m)
buildSyncConflict(imp, m, eng) buildSyncConflict(imp, m, eng)
return return
end end
local plan = eng.modPlan
if type(plan) == "table" and #(plan.options or {}) > 0
and plan.applyOptions == nil then
buildSyncModOptions(imp, m, eng)
return
end
local view = imp._syncModal and imp._syncModal.view or "home" local view = imp._syncModal and imp._syncModal.view or "home"
if view == "link" then if view == "link" then
buildSyncLink(imp, m, eng) buildSyncLink(imp, m, eng)
@@ -4868,13 +5207,16 @@ 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)
if imp._modalUpNow then imp:_blurPanelFields() end
Kit.blockClicks = imp._modalUpNow
local step = Kit.scrollStep(m.s) local step = Kit.scrollStep(m.s)
do do
+263 -38
View File
@@ -27,7 +27,7 @@ RomExtractorGen2.__index = RomExtractorGen2
-- scripts, pokemon, moves, items, marts, encounters, trainers, pokedex, -- scripts, pokemon, moves, items, marts, encounters, trainers, pokedex,
-- landmarks, intro movie, menu gfx, title, credits, diploma, trade animation, -- landmarks, intro movie, menu gfx, title, credits, diploma, trade animation,
-- audio, stubs -- audio, stubs
local STAGE_COUNT = 26 local STAGE_COUNT = 27
local Opcodes = require("src.script.gen2.Opcodes") local Opcodes = require("src.script.gen2.Opcodes")
-- BG palette slots inside one loaded 8-palette set (constants/tileset_constants.asm -- BG palette slots inside one loaded 8-palette set (constants/tileset_constants.asm
@@ -136,6 +136,18 @@ local TEXT_NO_GLYPH = {
[0x13] = true, [0x15] = true, [0x13] = true, [0x15] = true,
} }
-- The three runtime name slots. PlaceMoveUsersName, PlaceMoveTargetsName and
-- PlaceEnemysName (home/text.asm:302, :307, :327) swap these for a battler's
-- own name as the line prints, so they are markers rather than glyphs. They
-- decode to the same shape Gen 1 uses, which src/core/RomText.lua already
-- fills in argument order. Dropped, SubTookDamageText read "took damage
-- for" with nothing after it.
local NAME_SLOT = {
["<USER>"] = "{USER}",
["<TARGET>"] = "{TARGET}",
["<ENEMY>"] = "{ENEMY}",
}
local ROOF_TILES = 9 local ROOF_TILES = 9
local SPRITEDATA_LENGTH = 6 local SPRITEDATA_LENGTH = 6
@@ -2596,6 +2608,8 @@ function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers)
out[#out + 1] = ch out[#out + 1] = ch
elseif ch == "<……>" or b == 0x56 then elseif ch == "<……>" or b == 0x56 then
out[#out + 1] = "……" out[#out + 1] = "……"
elseif NAME_SLOT[ch] then
out[#out + 1] = NAME_SLOT[ch]
elseif not ch then elseif not ch then
out[#out + 1] = ("{BYTE:%02X}"):format(b) out[#out + 1] = ("{BYTE:%02X}"):format(b)
end end
@@ -3439,6 +3453,18 @@ function RomExtractorGen2:extractScriptsAndText(maps, stdScripts)
elseif info.name == "givepoke" then elseif info.name == "givepoke" then
cmd.species, cmd.level, cmd.item, cmd.trainer = cmd.species, cmd.level, cmd.item, cmd.trainer =
args[1], args[2], args[3], args[4] args[1], args[2], args[3], args[4]
-- Script_givepoke (engine/overworld/scripting.asm:1806)
if size == 8 then
local function readAt(lo, hi)
local addr = (args[lo] or 0) + (args[hi] or 0) * 0x100
if not romAddrOk(bank, addr) then return nil end
local okStr, str = pcall(self.rom.readString, self.rom,
bank, addr, charmap, 0x50, 16)
return okStr and str or nil
end
cmd.name = readAt(5, 6)
cmd.otName = readAt(7, 8)
end
elseif info.name == "pokepic" or info.name == "disappear" then elseif info.name == "pokepic" or info.name == "disappear" then
cmd.species = args[1] -- pokepic cmd.species = args[1] -- pokepic
cmd.object = args[1] -- disappear (same byte) cmd.object = args[1] -- disappear (same byte)
@@ -3686,6 +3712,37 @@ function RomExtractorGen2:splashGfx()
} }
end end
-- The engine's own strings, keyed by the label the disassembly gives them.
--
-- This is what extractOakSpeech has always done for _OakText1-7: resolve the
-- label, decode from the cart, key by name. What is new is that the list of
-- labels comes from the manifest instead of being written out here, so all of
-- data/text/ arrives rather than seven strings. Gen 1 has had the same table
-- since RomExtractor:extractText; this is the Gen 2 side of it, and it is
-- what lets src/core/RomText.lua work on Gold and Silver at all.
--
-- Written as `rom_text` rather than `text`: data/generated/text.lua is
-- already the script text, keyed by bank:address for the overworld VM, and
-- these are a different table with different keys.
function RomExtractorGen2:extractText()
self:beginStage("Dialogue")
local charmap = self.manifest.charmap or {}
local labels = (self.manifest.text or {}).labels or {}
local texts = {}
for index, label in ipairs(labels) do
local location = self.symbols[label]
-- A label the manifest names but the symbol table does not carry would
-- be a generator bug, not a cart difference: make_gold_manifest.py
-- resolves every one of these before it writes the list.
if location then
texts[label] = self:decodeGen2Text(location[1], location[2], charmap)
end
self:tick("Dialogue", index, #labels)
end
self:write("rom_text", texts)
return texts
end
-- OakSpeech (engine/menus/intro_menu.asm): named _OakText* strings plus the -- OakSpeech (engine/menus/intro_menu.asm): named _OakText* strings plus the
-- POKEMON_PROF / CAL trainer pics shown before NamePlayer. Also pulls -- POKEMON_PROF / CAL trainer pics shown before NamePlayer. Also pulls
-- Shrink1/2 pics and the GameFreak splash sheets for the boot cinema. -- Shrink1/2 pics and the GameFreak splash sheets for the boot cinema.
@@ -5139,6 +5196,7 @@ function RomExtractorGen2:extractMenuGfx()
} }
out.pack = pack out.pack = pack
out.pokedex = self:pokedexGfx() out.pokedex = self:pokedexGfx()
out.billsPc = self:billsPcGfx()
out.pokegear = self:pokegearGfx() out.pokegear = self:pokegearGfx()
out.trainerCard = self:trainerCardGfx() out.trainerCard = self:trainerCardGfx()
out.unownPuzzle = self:unownPuzzleGfx() out.unownPuzzle = self:unownPuzzleGfx()
@@ -5231,81 +5289,228 @@ 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
-- StatsScreenPageTilesGFX (gfx/font.asm:23), the 17 tiles
-- LoadStatsScreenPageTilesGFX lands at vTiles2 $31 (engine/gfx/load_font.asm:90).
local hpBarBorder = self.symbols["EnemyHPBarBorderGFX"]
if hpBarBorder then
local address = hpBarBorder[2] - 17 * 16
self:write2bpp(self.rom:bytes(hpBarBorder[1], address, 17 * 16),
17 * 8, 8, "menu/stats_tiles.png")
out.stats = {
sheet = "assets/generated/menu/stats_tiles.png",
tiles = 17,
firstTile = 0x31,
}
end
-- Goldenrod Game Corner: Slot Machine graphics assets -- Goldenrod Game Corner: Slot Machine graphics assets
local CacheFs = require("src.import.CacheFs")
local function packBytes(bytes)
local chars = {}
for i = 1, #bytes do chars[i] = string.char(bytes[i]) end
return table.concat(chars)
end
local function writeRaw(relative, bytes)
local ok, writeError = CacheFs.write(
"assets/generated/" .. relative, packBytes(bytes))
if not ok then
error("could not write " .. relative .. ": " .. tostring(writeError))
end
end
-- Canonical sheet sizes match the cart art the UI indexes (and pret's
-- gfx/slots + gfx/card_flip PNGs). ROM LZ streams are those sheets after
-- Makefile gfx transforms; reverse what the decompressed bytes still carry.
local SLOTS1_W, SLOTS1_H = 16, 152
local SLOTS2_W, SLOTS2_H = 16, 256
local SLOTS3_W, SLOTS3_H = 24, 240
local CARD1_W, CARD1_H = 128, 32
local CARD2_W, CARD2_H = 24, 160
local CARD3_W, CARD3_H = 8, 56
local function pad2bpp(raw, width, height)
local need = width * height / 4
while #raw < need do raw[#raw + 1] = 0 end
while #raw > need do table.remove(raw) end
return raw
end
local function writeSheet(raw, width, height, relative, transparent)
self:write2bpp(pad2bpp(raw, width, height), width, height, relative,
transparent)
end
-- Slots3LZ is unique 8x16 OBJ columns (interleave + remove-duplicates +
-- remove-xflip). Rebuild the 24x240 actor sheet the UI quads expect from
-- OAMData_SlotsGolem / Chansey* / Egg (data/sprite_anims/oam.asm), same
-- pattern as title-screen Ho-Oh frame composition above.
local function composeSlotsActors(raw)
local tileCount = math.floor(#raw / 16)
local tiles = {}
for index = 0, tileCount - 1 do
local one = {}
for b = 1, 16 do one[b] = raw[index * 16 + b] or 0 end
tiles[index] = ImageWriter.decode2bpp(one, 8, 8, true)
end
local sheet = ImageWriter.blank(SLOTS3_W, SLOTS3_H, 1, 1, 1, 0)
local function blit8x16(tileId, dx, dy, flipX)
local top, bot = tiles[tileId], tiles[tileId + 1]
if not (top and bot) then return end
ImageWriter.blit(sheet, top, dx, dy, 0, 0, 8, 8, flipX)
ImageWriter.blit(sheet, bot, dx, dy + 8, 0, 0, 8, 8, flipX)
end
local function blitPose(poseY, base, entries)
for _, e in ipairs(entries) do
blit8x16(base + e.t, (e.x + 2) * 8, poseY + (e.y + 2) * 8, e.xf)
end
end
local golem = {
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
{ x = 0, y = -2, t = 0x00, xf = true },
{ x = -2, y = 0, t = 0x04 }, { x = -1, y = 0, t = 0x06 },
{ x = 0, y = 0, t = 0x04, xf = true },
}
local chansey = {
{
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
{ x = 0, y = -2, t = 0x04 },
{ x = -2, y = 0, t = 0x06 }, { x = -1, y = 0, t = 0x08 },
{ x = 0, y = 0, t = 0x0a },
},
{
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
{ x = 0, y = -2, t = 0x04 },
{ x = -2, y = 0, t = 0x0c }, { x = -1, y = 0, t = 0x0e },
{ x = 0, y = 0, t = 0x10 },
},
{
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
{ x = 0, y = -2, t = 0x04 },
{ x = -2, y = 0, t = 0x12 }, { x = -1, y = 0, t = 0x14 },
{ x = 0, y = 0, t = 0x16 },
},
{
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
{ x = 0, y = -2, t = 0x04 },
{ x = -2, y = 0, t = 0x18 }, { x = -1, y = 0, t = 0x1a },
{ x = 0, y = 0, t = 0x1c },
},
{
{ x = -2, y = -2, t = 0x1e }, { x = -1, y = -2, t = 0x20 },
{ x = 0, y = -2, t = 0x22 },
{ x = -2, y = 0, t = 0x24 }, { x = -1, y = 0, t = 0x26 },
{ x = 0, y = 0, t = 0x28 },
},
}
blitPose(0, 0x00, golem)
blitPose(32, 0x08, golem)
for index, frame in ipairs(chansey) do
blitPose(32 + index * 32, 0x10, frame)
end
blit8x16(0x3a, 0, 224, false)
return sheet
end
-- card_flip_2.2bpp uses --remove-whitespace: blank tiles in column 2 of the
-- 3-wide header strip (indices 2,5,...,23) are dropped from the ROM stream.
-- Re-insert them so HEADER_TILE_MAP / MON_ANCHORS (pret sheet indices) work.
local function expandCardFlip2(compact)
local need = CARD2_W * CARD2_H / 4
local out = {}
for i = 1, need do out[i] = 0 end
local whitespace = {
[2] = true, [5] = true, [8] = true, [11] = true,
[14] = true, [17] = true, [20] = true, [23] = true,
}
local src = 0
for tile = 0, 59 do
if not whitespace[tile] then
for b = 1, 16 do
out[tile * 16 + b] = compact[src * 16 + b] or 0
end
src = src + 1
end
end
return out
end
local slots = nil
if self.symbols["Slots1LZ"] then if self.symbols["Slots1LZ"] then
-- --trim-whitespace drops the final empty tile (37 of 38).
local raw1 = self:decompressLz3Symbol("Slots1LZ") local raw1 = self:decompressLz3Symbol("Slots1LZ")
self:write2bpp(raw1, 16, #raw1 / 4, "slots/gold_slots_1.png") writeSheet(raw1, SLOTS1_W, SLOTS1_H, "slots/gold_slots_1.png")
slots = slots or {}
slots.sheet1 = "assets/generated/slots/gold_slots_1.png"
end end
if self.symbols["Slots2LZ"] then if self.symbols["Slots2LZ"] then
local raw2 = self:decompressLz3Symbol("Slots2LZ") local raw2 = ImageWriter.deinterleave(
-- In Pokemon Gold ROM, Seven symbol (first 4 tiles = 64 bytes) has inverted bit polarity self:decompressLz3Symbol("Slots2LZ"), SLOTS2_W)
-- Commercial Gold stores the Seven symbol with inverted bit polarity.
for i = 1, math.min(64, #raw2) do for i = 1, math.min(64, #raw2) do
raw2[i] = bit.band(bit.bnot(raw2[i]), 0xFF) raw2[i] = bit.band(bit.bnot(raw2[i]), 0xFF)
end end
self:write2bpp(raw2, 16, #raw2 / 4, "slots/gold_slots_2.png") writeSheet(raw2, SLOTS2_W, SLOTS2_H, "slots/gold_slots_2.png")
slots = slots or {}
slots.sheet2 = "assets/generated/slots/gold_slots_2.png"
end end
if self.symbols["Slots3LZ"] then if self.symbols["Slots3LZ"] then
local raw3 = self:decompressLz3Symbol("Slots3LZ") local raw3 = self:decompressLz3Symbol("Slots3LZ")
self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_3.png", true) local actors = composeSlotsActors(raw3)
-- Slots3LZ is a 24px-wide (3 tiles), 240px-tall (30 tiles) sprite sheet containing: self:save(actors, "slots/gold_slots_3.png")
-- Y=0: Golem 1 (Standing, 24x32) self:save(actors, "slots/gold_slots_actors.png")
-- Y=32: Golem 2 (Ball, 24x32) slots = slots or {}
-- Y=64: Chansey 1 (Standing / Step 1, 24x32) slots.sheet3 = "assets/generated/slots/gold_slots_3.png"
-- 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 end
if self.symbols["SlotsTilemap"] then if self.symbols["SlotsTilemap"] then
local symbol = self:symbol("SlotsTilemap") local symbol = self:symbol("SlotsTilemap")
local tm = self.rom:bytes(symbol.bank, symbol.address, 20 * 12) local tm = self.rom:bytes(symbol.bank, symbol.address, 20 * 12)
self:save(tm, "slots/gold_slots.tilemap") writeRaw("slots/gold_slots.tilemap", tm)
slots = slots or {}
slots.tilemap = "assets/generated/slots/gold_slots.tilemap"
end end
if slots then out.slots = slots end
-- Goldenrod Game Corner: Card Flip graphics assets -- Goldenrod Game Corner: Card Flip graphics assets
local cardFlip = nil
if self.symbols["CardFlipLZ01"] then if self.symbols["CardFlipLZ01"] then
-- --trim-whitespace: 62 of 64 tiles in the ROM stream.
local raw1 = self:decompressLz3Symbol("CardFlipLZ01") local raw1 = self:decompressLz3Symbol("CardFlipLZ01")
self:write2bpp(raw1, 128, #raw1 / 32, "card_flip/card_flip_1.png") writeSheet(raw1, CARD1_W, CARD1_H, "card_flip/card_flip_1.png")
cardFlip = cardFlip or {}
cardFlip.sheet1 = "assets/generated/card_flip/card_flip_1.png"
end end
if self.symbols["CardFlipLZ02"] then if self.symbols["CardFlipLZ02"] then
local raw2 = self:decompressLz3Symbol("CardFlipLZ02") local raw2 = expandCardFlip2(self:decompressLz3Symbol("CardFlipLZ02"))
self:write2bpp(raw2, 24, #raw2 / 6, "card_flip/card_flip_2.png") writeSheet(raw2, CARD2_W, CARD2_H, "card_flip/card_flip_2.png")
cardFlip = cardFlip or {}
cardFlip.sheet2 = "assets/generated/card_flip/card_flip_2.png"
end end
if self.symbols["CardFlipLZ03"] then if self.symbols["CardFlipLZ03"] then
local raw3 = self:decompressLz3Symbol("CardFlipLZ03") local raw3 = self:decompressLz3Symbol("CardFlipLZ03")
self:write2bpp(raw3, 8, #raw3 / 2, "card_flip/card_flip_3.png") writeSheet(raw3, CARD3_W, CARD3_H, "card_flip/card_flip_3.png")
cardFlip = cardFlip or {}
cardFlip.sheet3 = "assets/generated/card_flip/card_flip_3.png"
end end
if self.symbols["CardFlipOnButtonGFX"] then if self.symbols["CardFlipOnButtonGFX"] then
local symbol = self:symbol("CardFlipOnButtonGFX") local symbol = self:symbol("CardFlipOnButtonGFX")
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/on.png") self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/on.png")
cardFlip = cardFlip or {}
cardFlip.on = "assets/generated/card_flip/on.png"
end end
if self.symbols["CardFlipOffButtonGFX"] then if self.symbols["CardFlipOffButtonGFX"] then
local symbol = self:symbol("CardFlipOffButtonGFX") local symbol = self:symbol("CardFlipOffButtonGFX")
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/off.png") self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/off.png")
cardFlip = cardFlip or {}
cardFlip.off = "assets/generated/card_flip/off.png"
end end
if self.symbols["CardFlipTilemap"] then if self.symbols["CardFlipTilemap"] then
local symbol = self:symbol("CardFlipTilemap") local symbol = self:symbol("CardFlipTilemap")
local tm = self.rom:bytes(symbol.bank, symbol.address, 11 * 12) local tm = self.rom:bytes(symbol.bank, symbol.address, 11 * 12)
self:save(tm, "card_flip/card_flip.tilemap") writeRaw("card_flip/card_flip.tilemap", tm)
cardFlip = cardFlip or {}
cardFlip.tilemap = "assets/generated/card_flip/card_flip.tilemap"
end end
if cardFlip then out.cardFlip = cardFlip 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)
@@ -5538,6 +5743,25 @@ function RomExtractorGen2:pokedexGfx()
return dex return dex
end end
-- BillsPC_InitGFX's four tiles at vTiles2 $5c
-- (engine/pokemon/bills_pc.asm:2170-2173)
function RomExtractorGen2:billsPcGfx()
if not self.symbols["PCMailGFX"] then return nil end
local pc = {}
local gfx = self:symbol("PCMailGFX")
self:write2bpp(self.rom:bytes(gfx.bank, gfx.address, 4 * 16),
32, 8, "pc/mail_item.png")
pc.icons = "assets/generated/pc/mail_item.png"
pc.firstTile = 0x5c
pc.palette = self:predefPal(PREDEFPAL_POKEDEX)
-- gfx/pc/orange.pal
if self.symbols["BillsPCOrangePalette"] then
local orange = self:symbol("BillsPCOrangePalette")
pc.orangePalette = self:colors(orange.bank, orange.address, 4)
end
return pc
end
-- Reads a `tile, count` RLE tilemap (Pokegear_LoadTilemapRLE). The routine's -- Reads a `tile, count` RLE tilemap (Pokegear_LoadTilemapRLE). The routine's
-- own comment says "repeat count, tile ID" and has it backwards: it loads b -- own comment says "repeat count, tile ID" and has it backwards: it loads b
-- from the first byte, c from the second, and writes `b` c times. $ff ends -- from the first byte, c from the second, and writes `b` c times. $ff ends
@@ -6104,6 +6328,7 @@ function RomExtractorGen2:run()
results.sprites = self:extractSprites() results.sprites = self:extractSprites()
results.stdScripts = self:extractStdScripts() results.stdScripts = self:extractStdScripts()
results.scripts = self:extractScriptsAndText(results.maps, results.stdScripts) results.scripts = self:extractScriptsAndText(results.maps, results.stdScripts)
results.text = self:extractText()
results.pokemon = self:extractPokemon() results.pokemon = self:extractPokemon()
results.moves = self:extractMoves() results.moves = self:extractMoves()
results.items = self:extractItems() results.items = self:extractItems()
+252 -25
View File
@@ -104,6 +104,12 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
"data/generated/sprites.lua", -- OW sheets (Chris + NPCs) "data/generated/sprites.lua", -- OW sheets (Chris + NPCs)
"data/generated/scripts.lua", -- disassembled map scripts "data/generated/scripts.lua", -- disassembled map scripts
"data/generated/text.lua", -- decoded Gen 2 dialogue strings "data/generated/text.lua", -- decoded Gen 2 dialogue strings
-- The engine's own strings, keyed by label rather than by address. A
-- cache built before RomExtractorGen2:extractText has none, and every
-- line that reads through src/core/RomText.lua would silently keep
-- printing its Lua fallback, so this re-imports those caches rather than
-- bumping CACHE_FORMAT and dragging Red, Blue and Yellow through it too.
"data/generated/rom_text.lua",
"data/generated/pokemon.lua", "data/generated/pokemon.lua",
"data/generated/tilesets.lua", "data/generated/tilesets.lua",
"data/generated/audio.lua", "data/generated/audio.lua",
@@ -140,6 +146,14 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
-- before the four ball tiles were extracted (#1502). -- before the four ball tiles were extracted (#1502).
"assets/generated/battle/hud/balls.png", "assets/generated/battle/hud/balls.png",
"assets/generated/audio/programs.bin", "assets/generated/audio/programs.bin",
-- Goldenrod Game Corner reel + board art (#1581). menu_gfx.lua used to
-- advertise these paths even when Slots*LZ / CardFlip* were absent from
-- the manifest, so a cache that never wrote the PNGs still looked
-- complete and SlotMachine crashed on its labelled-cell fallback.
"assets/generated/slots/gold_slots_1.png",
"assets/generated/card_flip/card_flip_1.png",
-- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173)
"assets/generated/pc/mail_item.png",
}, },
} }
-- Same Gen 2 extract, so a Silver cache is complete when the same files exist. -- Same Gen 2 extract, so a Silver cache is complete when the same files exist.
@@ -382,6 +396,142 @@ local function externalFileSize(path)
return size return size
end end
local function openImportSource(path)
-- Desktop picker paths live outside LÖVE's virtual filesystem. Prefer the
-- native file handle so a 1.46 GiB disc is never copied to a temp file or
-- read into one Lua string before validation.
local native = io.open(path, "rb")
if native then
local size, sizeErr = native:seek("end")
if size == nil or size == false then
native:close()
return nil, sizeErr or "could not determine source file size"
end
local reset, resetErr = native:seek("set", 0)
if reset == nil or reset == false then
native:close()
return nil, resetErr or "could not rewind source file"
end
return {
size = size,
read = function(_, n) return native:read(n) end,
close = function() native:close() end,
}
end
if love and love.filesystem and love.filesystem.newFile then
local file, makeErr = love.filesystem.newFile(path)
if not file then return nil, makeErr or "could not open source file" end
local ok, openErr = file:open("r")
if not ok then return nil, openErr or "could not open source file" end
local size = file.getSize and file:getSize() or nil
return {
size = size,
read = function(_, n) return file:read(n) end,
close = function() file:close() end,
}
end
return nil, "streaming source access is unavailable"
end
local function streamRequiredImport(manifest, importId, source)
local RequiredImports = require("src.mods.RequiredImports")
local spec = RequiredImports.spec(manifest, importId)
if not spec then return nil, "Import declaration was not found." end
if spec.format == "n64" then
return nil, "streaming canonicalization is unavailable for N64 imports"
end
local input, openErr = openImportSource(source)
if not input then return nil, openErr end
local sizeErr = RequiredImports.sizeError(spec, input.size, false)
if sizeErr then input:close(); return nil, sizeErr end
local CacheFs = require("src.import.CacheFs")
local destination = RequiredImports.path(manifest, spec)
local savedPrefix = CacheFs.prefix
local output
local inputClosed, outputClosed = false, false
local function closeInput()
if inputClosed then return end
inputClosed = true
pcall(function() input:close() end)
end
local function closeOutput()
if outputClosed or not output then return end
outputClosed = true
pcall(function() output:close() end)
end
local resultOk, resultDetail
CacheFs.prefix = ""
local ran, thrown = xpcall(function()
CacheFs.remove(RequiredImports.receiptPath(manifest, spec))
CacheFs.remove(destination)
local makeErr
output, makeErr = CacheFs.openWrite(destination)
if not output then
resultDetail = makeErr or "could not create imported file"
return
end
local MD5 = require("src.mods.StreamMD5")
local md5 = MD5.new()
local total, chunkBytes = 0, 4 * 1024 * 1024
while true do
local chunk = input:read(chunkBytes)
if not chunk or #chunk == 0 then break end
md5:update(chunk)
local wrote, writeErr = output:write(chunk)
if wrote == false or wrote == nil then
resultDetail = "could not copy import: "
.. tostring(writeErr or "write failed")
return
end
total = total + #chunk
if #chunk < chunkBytes then break end
end
closeInput()
closeOutput()
if input.size and total ~= input.size then
resultDetail = ("source read ended early (expected %d bytes, copied %d)")
:format(input.size, total)
return
end
local storedSizeErr = RequiredImports.sizeError(spec, total, true)
if storedSizeErr then resultDetail = storedSizeErr; return end
local digest = md5:final()
-- acceptStoredDigest uses the normal engine path/receipt rules, so
-- restore the caller's prefix before handing control to it.
CacheFs.prefix = savedPrefix
local accepted, detail = RequiredImports.acceptStoredDigest(
manifest, importId, digest, love.filesystem)
if not accepted then resultDetail = detail; return end
resultOk, resultDetail = true, detail
end, function(err)
if debug and debug.traceback then
return debug.traceback(tostring(err), 2)
end
return tostring(err)
end)
-- finally: resource handles and the process-global CacheFs prefix must
-- be restored even when a read/hash/write helper raises a Lua error.
closeInput()
closeOutput()
CacheFs.prefix = ""
if not ran or not resultOk then
pcall(function() CacheFs.remove(destination) end)
end
CacheFs.prefix = savedPrefix
if not ran then
return nil, "could not copy import: " .. tostring(thrown)
end
if not resultOk then return nil, resultDetail end
return true, resultDetail
end
local function readDroppedFile(file) local function readDroppedFile(file)
local ok, openError = file:open("r") local ok, openError = file:open("r")
if not ok then return nil, openError end if not ok then return nil, openError end
@@ -942,13 +1092,19 @@ end
-- naive "first ROM wins" scan would re-import Red when the player tries to -- naive "first ROM wins" scan would re-import Red when the player tries to
-- add Blue (issue #167). Yellow and Gold carts are typically .gbc (Gold is -- add Blue (issue #167). Yellow and Gold carts are typically .gbc (Gold is
-- 2 MiB). -- 2 MiB).
local function findPendingRom(ready) -- `wanted` narrows the scan to one version. A Choose names the cart it is
-- for, so without it several dumps sitting in the save directory answered in
-- listing order and the selection was dropped: picking Red imported Blue
-- (#1274). The callers that are not a Choose, the Android USB-drop scans,
-- pass nothing and still take the first pending cart of any version.
local function findPendingRom(ready, wanted)
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.gbc?$") and love.filesystem.getInfo(name, "file") then if name:lower():match("%.gbc?$") and love.filesystem.getInfo(name, "file") then
local data = love.filesystem.read(name) local data = love.filesystem.read(name)
if type(data) == "string" and isAcceptedRomSize(#data) then if type(data) == "string" and isAcceptedRomSize(#data) then
local version = GameVersion.forSha1(sha1(data)) local version = GameVersion.forSha1(sha1(data))
if version and not ready[version] then if version and not ready[version]
and (wanted == nil or version == wanted) then
return name, data return name, data
end end
end end
@@ -1228,11 +1384,13 @@ local function chooseRequiredFile()
"$d=New-Object System.Windows.Forms.OpenFileDialog;", "$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='" .. prompt .. "';", "$d.Title='" .. prompt .. "';",
"$d.Filter='All files (*.*)|*.*';", "$d.Filter='All files (*.*)|*.*';",
-- Required imports can be multi-gigabyte optical-disc images. Do NOT
-- stage them through %TEMP%: that doubles free-space requirements and a
-- failed Copy-Item can leave a plausible-looking truncated temp file.
-- Stream the selected source directly into the mod-owned destination.
"if($d.ShowDialog() -eq 'OK'){", "if($d.ShowDialog() -eq 'OK'){",
"$t=Join-Path $env:TEMP 'pokeport_required_import.bin';",
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;", "[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
"[Console]::Write($t)}", "[Console]::Write($d.FileName)}",
}) })
return commandOutput( return commandOutput(
'powershell -NoProfile -STA -Command "' .. script .. '"') 'powershell -NoProfile -STA -Command "' .. script .. '"')
@@ -1537,6 +1695,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
@@ -2069,8 +2230,13 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
return nil return nil
end end
local RequiredImports = require("src.mods.RequiredImports") local RequiredImports = require("src.mods.RequiredImports")
-- A desktop picker returns a host path. Ask the host file handle first;
-- love.filesystem.getInfo is only authoritative for virtual/save paths.
local size = externalFileSize(source)
if not size then
local info = love.filesystem.getInfo(source, "file") local info = love.filesystem.getInfo(source, "file")
local size = info and info.size or externalFileSize(source) size = info and info.size or nil
end
local sizeErr = 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)
@@ -2093,6 +2259,20 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
} }
return nil return nil
end end
if type(size) == "number" and size > RequiredImports.LARGE_WARN_BYTES
and spec.format ~= "n64" then
local ok, result = streamRequiredImport(manifest, importId, source)
if ok then
self.requiredImportNotice = nil
self.modNotice = { ok = true, text = "Imported " .. tostring(importId)
.. " for " .. tostring(manifest.name or manifest.id) .. "." }
self:_refreshMods()
return true
end
requiredImportNotice(self, modId, importId, result)
self.modNotice = nil
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
@@ -2407,8 +2587,9 @@ function RomImporter:choose(version)
if self.android then if self.android then
-- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or -- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or
-- a fresh SAF pick). Never reuse an already-imported cart's file -- that -- a fresh SAF pick). Never reuse an already-imported cart's file -- that
-- was the #167 failure mode (second Choose just re-extracted Red). -- was the #167 failure mode (second Choose just re-extracted Red). This
local name, data = findPendingRom(self.ready) -- is a Choose, so it takes the cart that was asked for and nothing else.
local name, data = findPendingRom(self.ready, self.chooseVersion)
if name then if name then
self:startData(data, name) self:startData(data, name)
elseif consumePickedRomError(self) then elseif consumePickedRomError(self) then
@@ -2436,8 +2617,10 @@ function RomImporter:choose(version)
-- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or -- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or
-- kdialog. Fall back to the same "drop a .gb/.gbc next to the game" scan -- kdialog. Fall back to the same "drop a .gb/.gbc next to the game" scan
-- used on Android, which works when the game is launched as an unpacked -- used on Android, which works when the game is launched as an unpacked
-- directory (see build-rg34xxsp.sh). -- directory (see build-rg34xxsp.sh). Narrowed to the chosen version: with
local name, data = findPendingRom(self.ready) -- four dumps in the folder the unnarrowed scan answered in listing order,
-- so Choose Red imported and decoded Blue (#1274).
local name, data = findPendingRom(self.ready, self.chooseVersion)
if name then if name then
self:startData(data, name) self:startData(data, name)
return return
@@ -2845,6 +3028,7 @@ function RomImporter:_updatePadCursor(dt)
local overY = 0 local overY = 0
if ny > oy + h then overY = ny - (oy + h) if ny > oy + h then overY = ny - (oy + h)
elseif ny < oy then overY = ny - oy end elseif ny < oy then overY = ny - oy end
if math.abs(ay) > PAD_DEAD and not self._padStickCentered then overY = 0 end
if overY ~= 0 and self._flex then if overY ~= 0 and self._flex then
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48) require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
end end
@@ -2904,7 +3088,11 @@ end
function RomImporter:gamepadaxis(_, axis, value) function RomImporter:gamepadaxis(_, axis, value)
if axis == "leftx" or axis == "lefty" or axis == "righty" then if axis == "leftx" or axis == "lefty" or axis == "righty" then
self._padAxis[axis] = value self._padAxis[axis] = value
if math.abs(value) > PAD_DEAD then self:_activatePadCursor() end if math.abs(value) > PAD_DEAD then
self:_activatePadCursor()
elseif axis == "lefty" then
self._padStickCentered = true
end
end end
end end
@@ -2913,18 +3101,21 @@ end
-- fire the virtual cursor's click twice off one A press (#620). -- fire the virtual cursor's click twice off one A press (#620).
function RomImporter:joystickpressed(joystick, button) function RomImporter:joystickpressed(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
local padButton = GamepadMap.mapRawToGamepadButton(button) local padButton = GamepadMap.mapRawToGamepadButton(button)
if padButton then self:gamepadpressed(joystick, padButton) end if padButton then self:gamepadpressed(joystick, padButton) end
end end
function RomImporter:joystickreleased(joystick, button) function RomImporter:joystickreleased(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
local padButton = GamepadMap.mapRawToGamepadButton(button) local padButton = GamepadMap.mapRawToGamepadButton(button)
if padButton then self:gamepadreleased(joystick, padButton) end if padButton then self:gamepadreleased(joystick, padButton) end
end end
function RomImporter:joystickaxis(joystick, axis, value) function RomImporter:joystickaxis(joystick, axis, value)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
if axis == 1 then if axis == 1 then
self:gamepadaxis(joystick, "leftx", value) self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then elseif axis == 2 then
@@ -2934,6 +3125,7 @@ end
function RomImporter:joystickhat(joystick, hat, direction) function RomImporter:joystickhat(joystick, hat, direction)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
for _, dir in ipairs(self._rawHatDirs[hat] or {}) do for _, dir in ipairs(self._rawHatDirs[hat] or {}) do
self._padDir[dir] = nil self._padDir[dir] = nil
end end
@@ -3179,6 +3371,10 @@ function RomImporter:_ensureSkins(force)
format = skin and skin.format or nil, format = skin and skin.format or nil,
pages = skin and #skin.pages or 0, pages = skin and #skin.pages or 0,
controls = controls, controls = controls,
-- The launcher owns the visual skin picker, so retain the already
-- loaded first-page bezel for its preview card instead of decoding it
-- again every frame.
preview = page and page.image or nil,
screen = page ~= nil screen = page ~= nil
and (page.viewport ~= nil or page.screenFit == "remainder"), and (page.viewport ~= nil or page.screenFit == "remainder"),
ok = skin ~= nil, ok = skin ~= nil,
@@ -3191,7 +3387,7 @@ end
function RomImporter:_activeSkin() function RomImporter:_activeSkin()
local opts = require("src.core.SaveData").loadOptions() local opts = require("src.core.SaveData").loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {} local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
return tc.skin return tc.enabled == false and nil or tc.skin
end end
function RomImporter:_useSkin(id) function RomImporter:_useSkin(id)
@@ -3208,6 +3404,16 @@ function RomImporter:_useSkin(id)
} }
end end
function RomImporter:_disableSkins()
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled, tc.skin = false, nil
opts.touchControls = tc
SaveData.saveOptions(opts)
self._skinNotice = { ok = true, text = "Skins are off. Mobile will use the built-in pad when needed." }
end
function RomImporter:_installSkinZip(source) function RomImporter:_installSkinZip(source)
if self.workState == "working" then return end if self.workState == "working" then return end
self.tab = "skins" self.tab = "skins"
@@ -3475,7 +3681,7 @@ end
function RomImporter:_openSync() function RomImporter:_openSync()
self:_syncEngine() self:_syncEngine()
self._syncModal = self._syncModal self._syncModal = self._syncModal
or { view = "home", code1 = "", code2 = "", share = "" } or { view = "home", code1 = "", code2 = "", share = "", withOptions = true }
self._syncFocus = nil self._syncFocus = nil
self:_disarmTextInput() self:_disarmTextInput()
end end
@@ -3560,9 +3766,22 @@ function RomImporter:_syncUnlinkDevice(deviceId)
end end
function RomImporter:_syncShareMods() function RomImporter:_syncShareMods()
local eng = self:_syncEngine() local eng, mo = self:_syncEngine(), self._syncModal
if not eng then return false end if not eng then return false end
return eng:shareMods() return eng:shareMods(mo and mo.withOptions ~= false)
end
function RomImporter:_syncToggleShareOptions()
local mo = self._syncModal
if not mo then return false end
mo.withOptions = not (mo.withOptions ~= false)
return mo.withOptions
end
function RomImporter:_syncAnswerModOptions(importThem)
local eng = self:_syncEngine()
if not eng or type(eng.answerModOptions) ~= "function" then return false end
return eng:answerModOptions(importThem)
end end
function RomImporter:_syncGetShare() function RomImporter:_syncGetShare()
@@ -3981,6 +4200,18 @@ function RomImporter:_disarmTextInput()
end end
end end
function RomImporter:_blurPanelFields()
if not (self._findSearchFocus or self._skinUrlFocus) then return end
if self._indexPrompt or self._rename or self._settingsText
or self._profileSavePrompt or self._profileRenamePrompt
or (self._syncModal and self._syncFocus) then
return
end
self._findSearchFocus = false
self._skinUrlFocus = false
self:_disarmTextInput()
end
function RomImporter:_beginRename(version, id) function RomImporter:_beginRename(version, id)
local label local label
for _, slot in ipairs(self.slots[version] or {}) do for _, slot in ipairs(self.slots[version] or {}) do
@@ -4111,7 +4342,6 @@ function RomImporter:_refreshMods()
end end
self.mods = kept self.mods = kept
end end
self:_syncModUpdateInfo(false)
end end
-- Point the MODS panel at one game (or nil for all of them) and relist, so -- Point the MODS panel at one game (or nil for all of them) and relist, so
@@ -4125,15 +4355,12 @@ function RomImporter:_ensureMods()
if not self.mods then self:_refreshMods() end if not self.mods then self:_refreshMods() end
end end
-- Resolve cached (or freshly fetched) GitHub status for every mod that -- Resolve GitHub status for every installed mod that declares a github field.
-- declares a github field. force=true bypasses the 6h cache on every repo. -- This is deliberately opt-in: only the explicit "Check for updates" action
-- Results live on self.modUpdateInfo[id] = { status, latest, best, releases }. -- calls it. Opening, scrolling, toggling, or relisting the MODS tab must not
-- ASYNC (was synchronous). This runs on every _refreshMods -- boot, and any -- create a burst of release work behind the list. force=true bypasses the 6h
-- toggle or install -- and used to make one blocking curl call per mod with a -- cache on every repo. Results live on self.modUpdateInfo[id] = {
-- github field, in a loop, on the render thread. A handful of mods was a -- status, latest, best, releases } and resolve asynchronously across frames.
-- multi-second freeze of the whole launcher. Now each mod gets a handle and
-- they resolve together across later frames; a mod whose cache is still fresh
-- resolves on the first pump with no network at all.
function RomImporter:_syncModUpdateInfo(force) function RomImporter:_syncModUpdateInfo(force)
local ModUpdate = require("src.mods.ModUpdate") local ModUpdate = require("src.mods.ModUpdate")
self.modUpdateInfo = self.modUpdateInfo or {} self.modUpdateInfo = self.modUpdateInfo or {}
+3 -2
View File
@@ -10,13 +10,14 @@
local Bag = {} local Bag = {}
-- MAX_ITEMS / MAX_BALLS / MAX_KEY_ITEMS, and the TM/HM pocket holds one of -- MAX_ITEMS / MAX_BALLS / MAX_KEY_ITEMS, and the TM/HM pocket holds one of
-- every TM plus the seven HMs (NUM_TMS + NUM_HMS). A `mods` bagSize override -- every TM plus the seven HMs (NUM_TMS + NUM_HMS -- ram/wram.asm:2421 is
-- `wTMsHMs:: ds NUM_TMS + NUM_HMS`, 57 bytes). A `mods` bagSize override
-- replaces the ITEM pocket only, the way the Gen 1 single-bag config did. -- replaces the ITEM pocket only, the way the Gen 1 single-bag config did.
local POCKET_CAPACITY = { local POCKET_CAPACITY = {
ITEM = 20, ITEM = 20,
BALL = 12, BALL = 12,
KEY_ITEM = 25, KEY_ITEM = 25,
TM_HM = 64, TM_HM = 57,
} }
local DEFAULT_CAPACITY = 20 local DEFAULT_CAPACITY = 20
+6
View File
@@ -93,6 +93,12 @@ function ItemEffects.healsHP(id)
or id == "REVIVE" or id == "MAX_REVIVE" or id == "REVIVE" or id == "MAX_REVIVE"
end end
-- .useRareCandy prints over the still-drawn party menu
-- (engine/items/item_effects.asm:1392-1418)
function ItemEffects.keepsPartyMenuOpen(id)
return ItemEffects.healsHP(id) or id == "RARE_CANDY"
end
function ItemEffects.isBattleMedicine(id) function ItemEffects.isBattleMedicine(id)
return HEAL_AMOUNT[id] ~= nil or STATUS_HEAL[id] ~= nil return HEAL_AMOUNT[id] ~= nil or STATUS_HEAL[id] ~= nil
or id == "MAX_POTION" or id == "FULL_RESTORE" or id == "MAX_POTION" or id == "FULL_RESTORE"
+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 = {}
+46 -5
View File
@@ -808,6 +808,33 @@ local function buildOverworld()
local Movement = rawRequire("src.script.gen2.Movement") local Movement = rawRequire("src.script.gen2.Movement")
local HiddenItems = rawRequire("src.world.gen2.HiddenItems") local HiddenItems = rawRequire("src.world.gen2.HiddenItems")
local Bike = rawRequire("src.world.gen2.Bike") local Bike = rawRequire("src.world.gen2.Bike")
local FieldMoves = rawRequire("src.world.gen2.FieldMoves")
-- home/map.asm:1869
local function stepAllowed(world, entity, dir, cx, cy)
local d = Map2.DELTA[dir]
if not (world.map and d and entity) then return true end
cx, cy = cx or entity.cellX, cy or entity.cellY
if not (cx and cy) then return true end
if not Permissions.stepPermitted(
function(px, py) return world:cellCollisionAcross(world.map, px, py) end,
cx, cy, dir) then
return false
end
local map = world.map
if entity == world.player and FieldMoves.isSurfing(world.playerState) then
map = world:surfMap(world.map)
end
local tx, ty = cx + d[1], cy + d[2]
if not map:inBounds(tx, ty) or not map:isWalkable(tx, ty) then return false end
for _, e in ipairs(world.entities or {}) do
if e ~= entity and not e.passable then
if e.cellX == tx and e.cellY == ty then return false end
if e.moving and e.targetX == tx and e.targetY == ty then return false end
end
end
return true
end
local api = nil local api = nil
-- one WorldAPI instance, so queueScript reuses the five-verb allow list -- one WorldAPI instance, so queueScript reuses the five-verb allow list
@@ -1011,20 +1038,33 @@ local function buildOverworld()
-- Gold has ONE movement slot; a second concurrent call is refused with a -- Gold has ONE movement slot; a second concurrent call is refused with a
-- reason rather than dropped (src/world/gen2/WorldAPI.lua:171's recipe). -- reason rather than dropped (src/world/gen2/WorldAPI.lua:171's recipe).
function ow.scriptMove(entity, dir, tiles, onDone) function ow.scriptMove(entity, dir, tiles, onDone, opts)
local world = w("scriptMove") local world = w("scriptMove")
if not world then return nil, "no overworld" end if not world then return nil, "no overworld" end
if world.moveState then return nil, "a movement is already running" end if world.moveState then return nil, "a movement is already running" end
local step = Movement.stepByte(dir) local step = Movement.stepByte(dir)
if not step then return nil, "unknown direction: " .. tostring(dir) end if not step then return nil, "unknown direction: " .. tostring(dir) end
local bytes = {}
for _ = 1, math.max(0, tiles or 1) do bytes[#bytes + 1] = step end
bytes[#bytes + 1] = Movement.STEP_END
local objectId = objectIdOf(world, entity) local objectId = objectIdOf(world, entity)
if not objectId then if not objectId then
return nil, "no Gen 2 objectId for that entity: only the player and a " return nil, "no Gen 2 objectId for that entity: only the player and a "
.. "mapped object (def.index) can be moved" .. "mapped object (def.index) can be moved"
end end
local n = math.max(0, tiles or 1)
if opts and opts.collide and n > 0 then
local d = Map2.DELTA[dir]
local cx, cy = entity.cellX, entity.cellY
local allowed = 0
for _ = 1, n do
if not stepAllowed(world, entity, dir, cx, cy) then break end
allowed = allowed + 1
if d and cx and cy then cx, cy = cx + d[1], cy + d[2] end
end
if allowed < n then entity.facing = dir end
n = allowed
end
local bytes = {}
for _ = 1, n do bytes[#bytes + 1] = step end
bytes[#bytes + 1] = Movement.STEP_END
world:beginMovement(objectId, bytes, onDone) world:beginMovement(objectId, bytes, onDone)
return true return true
end end
@@ -1401,7 +1441,8 @@ COVERAGE[OW] = {
.. "objectId 1 is wLastTalked, not object zero", .. "objectId 1 is wLastTalked, not object zero",
scriptMove = "the player maps to objectId 0 and a mapped object to " scriptMove = "the player maps to objectId 0 and a mapped object to "
.. "def.index + 1; an entity with neither (a mod's own guest) is " .. "def.index + 1; an entity with neither (a mod's own guest) is "
.. "REFUSED with a reason rather than moving the last-talked NPC", .. "REFUSED with a reason rather than moving the last-talked NPC; "
.. "opts.collide truncates the walk at the first blocked step",
connectionLanding = "Gen 1's five values (destDef, tilesetDef, x, y, " connectionLanding = "Gen 1's five values (destDef, tilesetDef, x, y, "
.. "conn); `conn` is Gold's connection record, keyed map/mapId + offset", .. "conn); `conn` is Gold's connection record, keyed map/mapId + offset",
timeOfDay = "recomputed from the clock every call and never cached, so a " timeOfDay = "recomputed from the clock every call and never cached, so a "
+174
View File
@@ -0,0 +1,174 @@
-- Scoped access to a mod's launcher-validated required/optional imports and
-- to installation-wide generated cache data.
--
-- This intentionally does not expose host paths or raw filesystem handles.
-- Import reads are bounded and can only address ids declared by the calling
-- mod's manifest. Cache paths are confined to mod_cache/<mod-id>/ and are not
-- tied to a Pokémon playthrough.
local RequiredImports = require("src.mods.RequiredImports")
local SafePath = require("src.mods.SafePath")
local SaveData = require("src.core.SaveData")
local ImportAccess = {}
ImportAccess.MAX_READ_BYTES = 8 * 1024 * 1024
ImportAccess.MAX_CACHE_WRITE_BYTES = 64 * 1024 * 1024
local function specMap(manifest)
local out = {}
for _, spec in ipairs(RequiredImports.specs(manifest)) do out[spec.id] = spec end
return out
end
local function parentOf(path)
return path:match("^(.*)/[^/]+$")
end
local function copyInfo(info)
if not info then return nil end
return { type = info.type, size = info.size, modtime = info.modtime }
end
local function fsReadRange(fs, path, offset, length)
if fs and type(fs.readRange) == "function" then
return fs.readRange(path, offset, length)
end
local newFile = fs and fs.newFile
if newFile then
local file, makeErr = newFile(path)
if not file then return nil, makeErr or "could not open import" end
local ok, openErr = file:open("r")
if not ok then return nil, openErr or "could not open import" end
local seekOk, seekErr = file:seek(offset)
if seekOk == nil or seekOk == false then
file:close()
return nil, seekErr or "could not seek import"
end
local data, readErr = file:read(length)
file:close()
return data, readErr
end
-- Injectable headless filesystems may expose only read(). Production
-- love.filesystem has newFile(), so large imports are never materialized
-- into one Lua string by this fallback.
if fs and fs.read then
local data = fs.read(path)
if type(data) ~= "string" then return nil, "could not read import" end
return data:sub(offset + 1, offset + length)
end
return nil, "random-access import reads are unavailable"
end
local function validatedInfo(manifest, spec, fs)
local ok, detail = RequiredImports.validateStored(manifest, spec, fs)
if not ok then return nil, detail or "import is not validated" end
local path = RequiredImports.path(manifest, spec)
local info = fs.getInfo and fs.getInfo(path, "file") or nil
if not info then return nil, "import is missing" end
return info, detail
end
local function makeCache(modId, fs)
local root = "mod_cache/" .. modId
local function pathFor(rel, what)
rel = SafePath.require(rel, what or "mod.cache path")
return root .. "/" .. rel
end
local cache = {}
function cache:write(rel, bytes)
if type(bytes) ~= "string" then
return nil, "mod.cache:write expects a byte string"
end
if #bytes > ImportAccess.MAX_CACHE_WRITE_BYTES then
return nil, "mod.cache:write payload exceeds 64 MiB; split generated data into smaller files"
end
local path = pathFor(rel, "mod.cache:write")
local parent = parentOf(path)
if parent and fs.createDirectory then
local ok = fs.createDirectory(parent)
if ok == false then return nil, "could not create cache directory" end
end
if not fs.write then return nil, "cache writes are unavailable" end
return fs.write(path, bytes)
end
function cache:read(rel)
local path = pathFor(rel, "mod.cache:read")
if not fs.read then return nil, "cache reads are unavailable" end
return fs.read(path)
end
function cache:info(rel)
local path = pathFor(rel, "mod.cache:info")
if not fs.getInfo then return nil end
return copyInfo(fs.getInfo(path))
end
function cache:exists(rel)
local info = self:info(rel)
return info ~= nil and info.type == "file"
end
function cache:delete(rel)
local path = pathFor(rel, "mod.cache:delete")
if not fs.remove then return nil, "cache deletion is unavailable" end
return fs.remove(path)
end
return cache
end
function ImportAccess.new(manifest, fs)
local specs = specMap(manifest)
local cacheFs = SaveData.persistenceFs(fs) or fs
local imports = {}
function imports:info(id)
local spec = specs[id]
if not spec then return nil, "undeclared import: " .. tostring(id) end
local info, digestOrErr = validatedInfo(manifest, spec, fs)
if not info then return nil, digestOrErr end
return {
id = spec.id,
name = spec.name,
file = spec.file,
size = info.size,
md5 = digestOrErr,
required = spec.required ~= false,
}
end
function imports:read(id, offset, length)
local spec = specs[id]
if not spec then return nil, "undeclared import: " .. tostring(id) end
offset, length = tonumber(offset), tonumber(length)
if not offset or offset < 0 or offset % 1 ~= 0 then
return nil, "offset must be a non-negative integer"
end
if not length or length < 0 or length % 1 ~= 0 then
return nil, "length must be a non-negative integer"
end
if length > ImportAccess.MAX_READ_BYTES then
return nil, "single import read exceeds 8 MiB"
end
local info, err = validatedInfo(manifest, spec, fs)
if not info then return nil, err end
local size = tonumber(info.size) or tonumber(spec.size)
if size and offset + length > size then return nil, "import read is out of bounds" end
if length == 0 then return "" end
local path = RequiredImports.path(manifest, spec)
local data, readErr = fsReadRange(fs, path, offset, length)
if not data then return nil, readErr end
if #data ~= length then return nil, "short import read" end
return data
end
return imports, makeCache(manifest.id, cacheFs)
end
return ImportAccess
+27
View File
@@ -603,6 +603,33 @@ function LauncherMods.setEnabled(id, enabled, version)
return true return true
end end
function LauncherMods.modOptions()
local ok, options = pcall(SaveData.loadOptions)
if not ok or type(options) ~= "table" then return {} end
return options.modOptions or {}
end
function LauncherMods.setModOptions(id, values)
if type(id) ~= "string" or id == "" or type(values) ~= "table" then
return false
end
local options = SaveData.loadOptions()
if SaveData.isSafeMode(options) then return false end
options.modOptions = options.modOptions or {}
local bucket = options.modOptions[id] or {}
for key, value in pairs(values) do
local t = type(value)
if type(key) == "string" and key ~= ""
and (t == "string" or t == "number" or t == "boolean") then
bucket[key] = value
end
end
options.modOptions[id] = bucket
SaveData.saveOptions(options)
LauncherMods.syncActiveProfile(options)
return true
end
-- setAllEnabled(ids, enabled [, version]): the launcher's Enable all / Disable -- setAllEnabled(ids, enabled [, version]): the launcher's Enable all / Disable
-- all buttons (#647). Writes what setEnabled writes, but loads and -- all buttons (#647). Writes what setEnabled writes, but loads and
-- saves once for the whole list: saveOptions rewrites the whole options file per -- saves once for the whole list: saveOptions rewrites the whole options file per
+20
View File
@@ -119,6 +119,17 @@ local GEN1_ONLY_MODULES = {
["src.ui.OptionsMenu"] = true, ["src.ui.OptionsMenu"] = true,
} }
local function crossGenerationDenial(name, generation)
if type(name) ~= "string" or generation ~= 1 then return nil end
if not (name:find("^src%.[%w_]+%.gen2%.") or name == "src.core.Game2") then
return nil
end
return ("%s is a Gen 2 engine module and this is a Gen 1 game; the structs "
.. "it reads and writes are not this game's, so anything it stores lands "
.. "on the save in the wrong shape. Take the game from mod.game and the "
.. "world from mod.world, which resolve per generation"):format(name)
end
-- the src.* modules the mod surface points authors at: another mod's -- the src.* modules the mod surface points authors at: another mod's
-- exports carry a version string that wants range-checking before use, and -- exports carry a version string that wants range-checking before use, and
-- ChipAsm is the authoring path for chip music and sfx -- ChipAsm is the authoring path for chip music and sfx
@@ -214,6 +225,7 @@ function Loader:_installDevShim()
if owner or callerIsMod(3) then if owner or callerIsMod(3) then
local id = type(owner) == "string" and owner or nil local id = type(owner) == "string" and owner or nil
local denial = Sandbox.moduleDenial(name, devShim.permissions[id]) local denial = Sandbox.moduleDenial(name, devShim.permissions[id])
or (id and crossGenerationDenial(name, devShim.generation))
if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end
end end
if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end
@@ -962,6 +974,8 @@ function Loader:_api(mod)
local Storage = engineRequire("src.mods.Storage") local Storage = engineRequire("src.mods.Storage")
local storage = Storage and Storage.new(modId, loader.fs) local storage = Storage and Storage.new(modId, loader.fs)
local Checkpoint = engineRequire("src.core.Checkpoint") local Checkpoint = engineRequire("src.core.Checkpoint")
local ImportAccess = engineRequire("src.mods.ImportAccess")
local importApi, installCache = ImportAccess.new(mod.manifest, loader.fs)
local api = { local api = {
id = modId, id = modId,
version = mod.manifest.version, version = mod.manifest.version,
@@ -1161,6 +1175,12 @@ function Loader:_api(mod)
-- checkpoint. The -- checkpoint. The
-- engine binds version/playthrough/mod scope and portable persistence; -- engine binds version/playthrough/mod scope and portable persistence;
-- callers never receive paths or a raw filesystem handle. -- callers never receive paths or a raw filesystem handle.
-- Read-only bounded access to this mod's manifest-declared, launcher-validated
-- imports. No host path is exposed; large sources are read in bounded ranges.
imports = importApi,
-- Installation-scoped generated data, independent from Pokémon save slots.
-- This is where ROM-derived caches belong; mod.storage remains playthrough-scoped.
cache = installCache,
storage = { storage = {
context = function(_, game) return storage:context(game) end, context = function(_, game) return storage:context(game) end,
selected = function(_, game) return storage:selected(game) end, selected = function(_, game) return storage:selected(game) end,
+87
View File
@@ -120,6 +120,36 @@ local function accepts(spec, digest)
return false return false
end end
local function specById(manifest, importId)
for _, candidate in ipairs(allSpecs(manifest)) do
if candidate.id == importId then return candidate end
end
return nil
end
RequiredImports.spec = specById
local function streamDigest(fs, path, chunkBytes)
if not (fs and fs.newFile) then return nil, "streaming file access is unavailable" end
local file, makeErr = fs.newFile(path)
if not file then return nil, makeErr or "could not open stored import" end
local ok, openErr = file:open("r")
if not ok then return nil, openErr or "could not open stored import" end
local MD5 = require("src.mods.StreamMD5")
local ctx = MD5.new()
chunkBytes = chunkBytes or (4 * 1024 * 1024)
while true do
local data, readErr = file:read(chunkBytes)
if data and #data > 0 then ctx:update(data) end
if not data or #data < chunkBytes then
if readErr then file:close(); return nil, readErr end
break
end
end
file:close()
return ctx:final()
end
function RequiredImports.path(manifest, spec) function RequiredImports.path(manifest, spec)
return manifest.path .. "/baseroms/" .. spec.file return manifest.path .. "/baseroms/" .. spec.file
end end
@@ -180,6 +210,47 @@ local function removeReceipt(manifest, spec, fs)
end end
end end
-- Finalize a caller-streamed import after the destination bytes have already
-- been copied into the engine-owned baseroms path. This keeps large imports
-- out of a single Lua string while preserving the same size/MD5 receipt rules.
function RequiredImports.acceptStoredDigest(manifest, importId, digest, fs)
fs = fs or (love and love.filesystem)
local spec = specById(manifest, importId)
if not spec then return nil, "unknown required import: " .. tostring(importId) end
digest = tostring(digest or ""):lower()
if not accepts(spec, digest) then
return nil, ("MD5 mismatch (got %s)"):format(digest ~= "" and digest or "unavailable")
end
local path = RequiredImports.path(manifest, spec)
local info = fs and fs.getInfo and fs.getInfo(path, "file") or nil
if not info then return nil, "copied import is missing" end
local sizeErr = RequiredImports.sizeError(spec, info.size, true)
if sizeErr then return nil, sizeErr end
if love and fs == love.filesystem then
local savedPrefix = CacheFs.prefix
local ok, prefixErr = xpcall(function()
CacheFs.prefix = ""
CacheFs.remove(removedMarker(manifest, spec))
CacheFs.prefix = savedPrefix
-- writeReceipt has its own temporary CacheFs prefix switch. Keep it
-- inside this guard too so a write error cannot leak global state.
writeReceipt(manifest, spec, digest, info, fs)
end, function(err)
return tostring(err)
end)
CacheFs.prefix = savedPrefix
if not ok then
return nil, "could not finalize import receipt: " .. tostring(prefixErr)
end
return true, digest
elseif fs and fs.remove then
fs.remove(removedMarker(manifest, spec))
end
writeReceipt(manifest, spec, digest, info, fs)
return true, digest
end
-- Validate bytes against a declaration. The returned data is canonicalized -- Validate bytes against a declaration. The returned data is canonicalized
-- (notably for N64 byte order/header variants) and is what must be stored. -- (notably for N64 byte order/header variants) and is what must be stored.
function RequiredImports.validateData(spec, data, hashFn) function RequiredImports.validateData(spec, data, hashFn)
@@ -217,6 +288,22 @@ function RequiredImports.validateStored(manifest, spec, fs, hashFn)
local cached = cachedDigest(manifest, spec, fs, info) local cached = cachedDigest(manifest, spec, fs, info)
if cached then return true, cached, true end if cached then return true, cached, true end
removeReceipt(manifest, spec, fs) removeReceipt(manifest, spec, fs)
-- Large raw imports (GameCube discs, future optical images, etc.) must never
-- be materialized into one Lua string merely because their validation
-- receipt was lost. Stream the MD5 directly from the installed file. N64
-- sources stay on the canonicalization path because byte-order/header
-- normalization is part of their validation contract.
if info.size and info.size > RequiredImports.LARGE_WARN_BYTES
and spec.format ~= "n64" and fs.newFile then
local digest, hashErr = streamDigest(fs, path)
if not digest then return nil, hashErr end
if not accepts(spec, digest) then
return nil, ("MD5 mismatch (got %s)"):format(digest)
end
info = fs.getInfo(path, "file") or info
writeReceipt(manifest, spec, digest, info, fs)
return true, digest, false
end
if not fs.read then return nil, "file could not be read" end if not fs.read then return nil, "file could not be read" end
local data = fs.read(path) local data = fs.read(path)
local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn) local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn)
+101
View File
@@ -0,0 +1,101 @@
local bitlib = rawget(_G, "bit") or rawget(_G, "bit32")
if not bitlib then error("StreamMD5 requires bit or bit32") end
local band, bor, bxor, bnot = bitlib.band, bitlib.bor, bitlib.bxor, bitlib.bnot
local lshift, rshift = bitlib.lshift, bitlib.rshift
local rol = bitlib.rol or bitlib.lrotate
local K = {
0xd76aa478,0xe8c7b756,0x242070db,0xc1bdceee,0xf57c0faf,0x4787c62a,0xa8304613,0xfd469501,
0x698098d8,0x8b44f7af,0xffff5bb1,0x895cd7be,0x6b901122,0xfd987193,0xa679438e,0x49b40821,
0xf61e2562,0xc040b340,0x265e5a51,0xe9b6c7aa,0xd62f105d,0x02441453,0xd8a1e681,0xe7d3fbc8,
0x21e1cde6,0xc33707d6,0xf4d50d87,0x455a14ed,0xa9e3e905,0xfcefa3f8,0x676f02d9,0x8d2a4c8a,
0xfffa3942,0x8771f681,0x6d9d6122,0xfde5380c,0xa4beea44,0x4bdecfa9,0xf6bb4b60,0xbebfbc70,
0x289b7ec6,0xeaa127fa,0xd4ef3085,0x04881d05,0xd9d4d039,0xe6db99e5,0x1fa27cf8,0xc4ac5665,
0xf4292244,0x432aff97,0xab9423a7,0xfc93a039,0x655b59c3,0x8f0ccc92,0xffeff47d,0x85845dd1,
0x6fa87e4f,0xfe2ce6e0,0xa3014314,0x4e0811a1,0xf7537e82,0xbd3af235,0x2ad7d2bb,0xeb86d391,
}
local S = {
7,12,17,22, 7,12,17,22, 7,12,17,22, 7,12,17,22,
5,9,14,20, 5,9,14,20, 5,9,14,20, 5,9,14,20,
4,11,16,23, 4,11,16,23, 4,11,16,23, 4,11,16,23,
6,10,15,21, 6,10,15,21, 6,10,15,21, 6,10,15,21,
}
local function add32(a,b,c,d)
local n = (a or 0) + (b or 0) + (c or 0) + (d or 0)
return band(n, 0xffffffff)
end
local function le32_from(s, i)
local b1,b2,b3,b4 = s:byte(i, i+3)
return bor(b1, lshift(b2,8), lshift(b3,16), lshift(b4,24))
end
local function le32_bytes(x)
return string.char(
band(x,0xff), band(rshift(x,8),0xff),
band(rshift(x,16),0xff), band(rshift(x,24),0xff))
end
local M = {}
local StreamMD5 = {}
StreamMD5.__index = StreamMD5
function StreamMD5.new()
return setmetatable({
a=0x67452301, b=0xefcdab89, c=0x98badcfe, d=0x10325476,
bytes=0, buffer="", done=false,
}, StreamMD5)
end
function StreamMD5:_block(block)
for j=1,16 do M[j] = le32_from(block, (j-1)*4+1) end
local a,b,c,d = self.a,self.b,self.c,self.d
for i=0,63 do
local f,g
if i < 16 then
f = bor(band(b,c), band(bnot(b),d)); g=i
elseif i < 32 then
f = bor(band(d,b), band(bnot(d),c)); g=(5*i+1)%16
elseif i < 48 then
f = bxor(b,c,d); g=(3*i+5)%16
else
f = bxor(c, bor(b,bnot(d))); g=(7*i)%16
end
local tmp=d
d=c
c=b
b=add32(b, rol(add32(a,f,K[i+1],M[g+1]), S[i+1]))
a=tmp
end
self.a=add32(self.a,a); self.b=add32(self.b,b)
self.c=add32(self.c,c); self.d=add32(self.d,d)
end
function StreamMD5:update(data)
assert(not self.done, "StreamMD5 context already finalized")
assert(type(data)=="string", "StreamMD5:update expects a string")
self.bytes = self.bytes + #data
local s = self.buffer .. data
local full = #s - (#s % 64)
for i=1,full,64 do self:_block(s:sub(i,i+63)) end
self.buffer = s:sub(full+1)
return self
end
function StreamMD5:final()
assert(not self.done, "StreamMD5 context already finalized")
local originalBytes = self.bytes
local padLen = (56 - ((originalBytes + 1) % 64)) % 64
local bits = originalBytes * 8
local lo = bits % 4294967296
local hi = math.floor(bits / 4294967296) % 4294967296
self:update("\128" .. string.rep("\0", padLen) .. le32_bytes(lo) .. le32_bytes(hi))
assert(#self.buffer == 0, "MD5 finalization left a partial block")
self.done=true
local raw = le32_bytes(self.a)..le32_bytes(self.b)..le32_bytes(self.c)..le32_bytes(self.d)
return (raw:gsub(".", function(ch) return string.format("%02x", ch:byte()) end))
end
return StreamMD5
+37 -11
View File
@@ -19,6 +19,9 @@ local romText = require("src.core.RomText")
local Evolution = {} local Evolution = {}
-- engine/pokemon/evos_moves.asm:122-123 (ld c, 50 / call DelayFrames)
local EVOLVING_TEXT_FRAMES = 50
Evolution.METHODS = { Evolution.METHODS = {
LEVEL = { LEVEL = {
check = function(game, mon, evo, trigger) check = function(game, mon, evo, trigger)
@@ -157,27 +160,50 @@ end
-- Play the evolution movie (flashing forms), then apply + text. -- Play the evolution movie (flashing forms), then apply + text.
-- Headless (no real graphics) falls back to the plain text flow. -- Headless (no real graphics) falls back to the plain text flow.
function Evolution.evolve(game, mon, newSpecies, onDone, via) function Evolution.evolve(game, mon, newSpecies, onDone, via)
local oldName = mon.nickname or game.data.pokemon[mon.species].name
-- IsEvolvingText, DelayFrames 50; ClearScreenArea then wipes rows 0-11
-- ONLY, so the box rides through EvolveMon (evos_moves.asm:120-134)
local isEvolving = romText(game.data, "_IsEvolvingText",
"What?\n%s is\nevolving!", oldName)
if love.image and love.image.newImageData then if love.image and love.image.newImageData then
-- forward `via` so EvolutionState can keep trade evolutions local intro
-- non-cancelable (LINK_STATE_TRADING) while others accept B (#213) intro = TextBox.new(game, isEvolving, nil, { stay = {
Screens.push(game, "EvolutionState", mon, newSpecies, onDone, via) onShown = function()
-- DelayFrames 50 with the box and the old screen still up
-- (evos_moves.asm:122-123)
local hold = { t = 0 }
hold.update = function()
hold.t = hold.t + 1
if hold.t < EVOLVING_TEXT_FRAMES then return end
game.stack:pop() -- this hold
-- forward `via` so trade evolutions stay non-cancelable while
-- others accept B (evos_moves.asm:72-75) (#213)
Screens.push(game, "EvolutionState", mon, newSpecies, function()
-- the result/cancel box owns the intro box's pop (#1596)
if game.stack:top() == intro then game.stack:pop() end
if onDone then onDone() end
end, via)
end
hold.draw = function() end
game.stack:push(hold)
end,
} })
game.stack:push(intro)
return return
end end
Music.play(game.data, Music.special(game.data, "evolution")) Music.play(game.data, Music.special(game.data, "evolution"))
local oldName = mon.nickname or game.data.pokemon[mon.species].name
Evolution.apply(game, mon, newSpecies, via) Evolution.apply(game, mon, newSpecies, via)
-- the congrats page keeps the engine wording: _EvolvedText extracts -- EvolvedText then IntoText in the same box (evos_moves.asm:136-150)
-- truncated (it stops at a dynamic marker the decoder does not follow) local msg = isEvolving .. "\f"
local msg = romText(game.data, "_IsEvolvingText", .. romText(game.data, "_EvolvedText", "%s evolved", oldName)
"What?\n%s is\nevolving!", oldName) .. romText(game.data, "_IntoText", "\ninto %s!",
.. "\f" .. Strings("Congratulations!\nYour %s\nevolved into\n%s!", game.data.pokemon[newSpecies].name)
oldName, game.data.pokemon[newSpecies].name)
game.stack:push(TextBox.new(game, msg, function() game.stack:push(TextBox.new(game, msg, function()
Music.restoreMap(game.data) Music.restoreMap(game.data)
-- re-run the evolved species' level-up learn check before onDone -- re-run the evolved species' level-up learn check before onDone
-- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12) -- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12)
Evolution.learnEvolutionMoves(game, mon, onDone) Evolution.learnEvolutionMoves(game, mon, onDone)
end)) end, TextBox.soundOpts(game, "Get_Item2")))
end end
-- Entry point for mods whose methods fire outside the vanilla moments -- Entry point for mods whose methods fire outside the vanilla moments
+13 -4
View File
@@ -51,11 +51,20 @@ end
-- (engine/pokemon/status_screen.asm:66-76, "mon is in a box or daycare" -> -- (engine/pokemon/status_screen.asm:66-76, "mon is in a box or daycare" ->
-- CalcStats) and when one is moved back into the party -- CalcStats) and when one is moved back into the party
-- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is -- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is
-- kept (box_struct does hold it) but clamped to the recalculated maximum so -- kept (box_struct does hold it) but clamped to the recalculated maximum.
-- a tampered save cannot overfill the bar. A mon that already has stats is -- A complete stat block is returned untouched; an incomplete one is rebuilt,
-- returned untouched, so a vanilla save round-trips. #233, #304 -- because CalcStats writes all NUM_STATS stats or none
-- (home/move_mon.asm:33-48). #233, #304, #1517
local function statsComplete(stats)
for _, key in ipairs(ORDER) do
if type(stats[key]) ~= "number" then return false end
end
return true
end
function Stats.ensure(speciesDef, mon) function Stats.ensure(speciesDef, mon)
if type(mon) ~= "table" or type(mon.stats) == "table" then return mon end if type(mon) ~= "table" then return mon end
if type(mon.stats) == "table" and statsComplete(mon.stats) then return mon end
if type(speciesDef) ~= "table" or type(speciesDef.baseStats) ~= "table" then if type(speciesDef) ~= "table" or type(speciesDef.baseStats) ~= "table" then
return mon return mon
end end
+1 -1
View File
@@ -454,7 +454,7 @@ function PaletteFX.pal(data, name)
if fromCgb then return fromCgb end if fromCgb then return fromCgb end
end end
local p = PaletteFX.pack(data) local p = PaletteFX.pack(data)
local c = p and p.palettes[name] local c = p and p.palettes and p.palettes[name]
if c then return c end if c then return c end
if GameVersion.isYellow() then if GameVersion.isYellow() then
local y = PaletteFX.yellowPack() local y = PaletteFX.yellowPack()
+2 -9
View File
@@ -3,8 +3,6 @@ local TouchSkin = require("src.core.TouchSkin")
local Playfield = {} local Playfield = {}
Playfield.WIDTH, Playfield.HEIGHT = 160, 144
Playfield.entered = false Playfield.entered = false
Playfield.box = nil Playfield.box = nil
@@ -35,14 +33,9 @@ function Playfield.cutout(sw, sh)
end end
function Playfield.rect(sw, sh) function Playfield.rect(sw, sh)
local x, y, w, h, _, expand = Playfield.cutout(sw, sh) local x, y, w, h = Playfield.cutout(sw, sh)
if not x then return 0, 0, sw or 0, sh or 0, false end if not x then return 0, 0, sw or 0, sh or 0, false end
if expand then return x, y, w, h, true end return x, y, w, h, true
local s = math.max(1, math.floor(math.min(w / Playfield.WIDTH,
h / Playfield.HEIGHT)))
local pw = math.min(w, Playfield.WIDTH * s)
local ph = math.min(h, Playfield.HEIGHT * s)
return x + math.floor((w - pw) / 2), y + math.floor((h - ph) / 2), pw, ph, true
end end
function Playfield.enter(x, y, w, h) function Playfield.enter(x, y, w, h)
+5 -6
View File
@@ -87,12 +87,12 @@ local function displayMetrics()
if dpiX < 1e-6 then dpiX = 1 end if dpiX < 1e-6 then dpiX = 1 end
if dpiY < 1e-6 then dpiY = 1 end if dpiY < 1e-6 then dpiY = 1 end
local vx, vy = 0, 0 local vx, vy = 0, 0
local cut, grow = false, false local cut = false
local sx, sy, sw, sh, _, expand = Playfield.cutout(pw, ph) local sx, sy, sw, sh = Playfield.cutout(pw, ph)
if sx then if sx then
vx, vy, pw, ph, cut, grow = sx, sy, sw, sh, true, expand vx, vy, pw, ph, cut = sx, sy, sw, sh, true
end end
return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut
end end
local function positionLift(ph, contentPx, dpiY, cut) local function positionLift(ph, contentPx, dpiY, cut)
@@ -275,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, _, dpiY, _, _, cut, grow = displayMetrics() local _, _, pw, ph, _, dpiY, _, _, cut = 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
@@ -287,7 +287,6 @@ function Renderer:worldViewSize()
-- this is the same sum with the viewport standing in for the window, so -- this is the same sum with the viewport standing in for the window, so
-- both platforms show the same map area at the same zoom. -- both platforms show the same map area at the same zoom.
local cap = FaithfulRes.scaleCap() local cap = FaithfulRes.scaleCap()
if not cap and cut and not grow then cap = self:fitScale() end
if cap then if cap then
local uiw, uih = self:uiSize() local uiw, uih = self:uiSize()
pw = cut and math.min(pw, uiw * cap) or uiw * cap pw = cut and math.min(pw, uiw * cap) or uiw * cap
+13 -2
View File
@@ -44,7 +44,8 @@ local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
-- waits for nothing, shows no blinking arrow, and never pops itself -- -- waits for nothing, shows no blinking arrow, and never pops itself --
-- whoever pushed it owns the pop. stay.onShown fires once, on the frame -- whoever pushed it owns the pop. stay.onShown fires once, on the frame
-- the last page finishes typing, which is where the caller pushes whatever -- the last page finishes typing, which is where the caller pushes whatever
-- goes on top of it (#591). -- goes on top of it (#591). stay.prompt waits out one arrowed A/B press
-- first (TextCommand_PROMPT_BUTTON, home/text.asm:434-444) (#1511).
function TextBox.new(game, text, onDone, opts) function TextBox.new(game, text, onDone, opts)
local self = setmetatable({}, TextBox) local self = setmetatable({}, TextBox)
self.game = game self.game = game
@@ -296,6 +297,15 @@ function TextBox:update(dt)
-- exactly once (#591) -- exactly once (#591)
if self.stay then if self.stay then
if not self.stayShown then if not self.stayShown then
-- stay.prompt: arrowed A/B wait, then the box stays up
-- (TextCommand_PROMPT_BUTTON, home/text.asm:434-444)
if self.stay.prompt
and not (input:wasPressed("a") or input:wasPressed("b")) then
return
end
if self.stay.prompt then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
self.stayShown = true self.stayShown = true
if self.stay.onShown then self.stay.onShown() end if self.stay.onShown then self.stay.onShown() end
end end
@@ -494,7 +504,8 @@ function TextBox:draw()
Font.draw(money, 152 - Font.width(money), 8) Font.draw(money, 152 - Font.width(money), 8)
end end
if (self.waiting or (self.done and not self.choice and not self.auto if (self.waiting or (self.done and not self.choice and not self.auto
and not self.stay)) and (not self.stay
or (self.stay.prompt and not self.stayShown))))
and self.blink < 30 then and self.blink < 30 then
-- page-advance cursor: glyph $EE by default, the blinking down arrow -- page-advance cursor: glyph $EE by default, the blinking down arrow
-- the original prints via `ld a, "▼"` (home/text.asm) -- the original prints via `ld a, "▼"` (home/text.asm)
+11 -3
View File
@@ -85,7 +85,9 @@ function Transition.new(game, onMidpoint, onDone, warp)
end end
function Transition:finish() function Transition:finish()
self.game.stack:pop() if self.done then return end
self.done = true
if not self.offStack then self.game.stack:pop() end
if self.onDone then self.onDone() end if self.onDone then self.onDone() end
end end
@@ -96,10 +98,16 @@ function Transition:update(dt)
self.t = 0 self.t = 0
if self.phase == "out" then if self.phase == "out" then
self.phase = "in" self.phase = "in"
if self.onMidpoint then self.onMidpoint() end
-- LoadGBPal restores the palettes in one write, so with no fade in the -- LoadGBPal restores the palettes in one write, so with no fade in the
-- map is simply there on the next frame -- map is simply there on the next frame
if (self.framesIn or 0) <= 0 then self:finish() end if (self.framesIn or 0) <= 0 then
self.offStack = true
self.game.stack:pop()
if self.onMidpoint then self.onMidpoint() end
self:finish()
elseif self.onMidpoint then
self.onMidpoint()
end
else else
self:finish() self:finish()
end end
+33 -1
View File
@@ -19,6 +19,8 @@ Zoom.allowSurvey = true
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world -- legal offset range for a given fit scale (vanilla: survey at 1 px/world
-- through 2× fit). zoom.range may widen or shrink the window. -- through 2× fit). zoom.range may widen or shrink the window.
-- When the window only fits 1×, 1-S is 0 and there would be no OUT
-- levels; keep three survey steps so OPTIONS always has zoom-out.
function Zoom.offsetRange(S) function Zoom.offsetRange(S)
S = math.max(1, math.floor(tonumber(S) or 1)) S = math.max(1, math.floor(tonumber(S) or 1))
local lo, hi = 1 - S, S local lo, hi = 1 - S, S
@@ -30,7 +32,11 @@ function Zoom.offsetRange(S)
end end
-- LOW performance tier: no survey (negative offsets), even if a mod's -- LOW performance tier: no survey (negative offsets), even if a mod's
-- zoom.range widened it. == false so nil/true stays permissive. -- zoom.range widened it. == false so nil/true stays permissive.
if Zoom.allowSurvey == false and lo < 0 then lo = 0 end if Zoom.allowSurvey == false then
if lo < 0 then lo = 0 end
elseif lo > -3 then
lo = -3
end
return lo, hi return lo, hi
end end
@@ -44,6 +50,8 @@ function Zoom.scale(S)
local maxScale = math.max(minScale, S + hi) local maxScale = math.max(minScale, S + hi)
if s < minScale then s = minScale end if s < minScale then s = minScale end
if s > maxScale then s = maxScale end if s > maxScale then s = maxScale end
-- Integer offset below 1px/world (OPTIONS OUT on a 1× window): 1/2, 1/4, …
if s < 1 then s = 0.5 ^ (1 - s) end
if s < 0.25 then s = 0.25 end if s < 0.25 then s = 0.25 end
return s return s
end end
@@ -79,6 +87,30 @@ function Zoom.applyOptions(opts)
Zoom.offset = math.floor(tonumber(opts and opts.zoom) or 0) Zoom.offset = math.floor(tonumber(opts and opts.zoom) or 0)
end end
-- Integer fit used when OPTIONS has no live renderer (launcher, title).
function Zoom.windowFitScale()
if love and love.graphics and love.graphics.getDimensions then
local ww, wh = love.graphics.getDimensions()
ww, wh = tonumber(ww) or 0, tonumber(wh) or 0
if ww >= 160 and wh >= 144 then
return math.max(1, math.floor(math.min(ww / 160, wh / 144)))
end
end
return 1
end
-- One step of the OPTIONS ZOOM row (dir +1 in, -1 out). Shared by Red
-- and Gold so both ladders offer OUT / FIT / IN.
function Zoom.nudgeOptions(options, dir, S)
S = math.max(1, math.floor(tonumber(S) or Zoom.windowFitScale()))
local lo, hi = Zoom.offsetRange(S)
local off = math.floor(tonumber(options and options.zoom) or 0) + (dir or 1)
if off > hi then off = lo elseif off < lo then off = hi end
if options then options.zoom = off end
Zoom.offset = off
return off
end
-- FIT / OUT1 / OUT2 / … / IN1 / IN2 / … -- FIT / OUT1 / OUT2 / … / IN1 / IN2 / …
function Zoom.offsetLabel(offset) function Zoom.offsetLabel(offset)
offset = math.floor(tonumber(offset) or 0) offset = math.floor(tonumber(offset) or 0)
+2 -1
View File
@@ -1694,7 +1694,8 @@ H.RandomPhoneWildMon = function(vm)
local entry = contact and contact.map and grass and grass[contact.map] local entry = contact and contact.map and grass and grass[contact.map]
local slots = entry and entry.slots local slots = entry and entry.slots
if not slots then return end if not slots then return end
local daytime = (w and w.daytime) or "DAY" -- wTimeOfDay, not the palette pin (wildmons.asm:861)
local daytime = (w and (w.tod or w.daytime)) or "DAY"
if daytime == "DARK" then daytime = "NITE" end if daytime == "DARK" then daytime = "NITE" end
local slot = (slots[daytime] or slots.DAY or {})[Specials.random(4)] local slot = (slots[daytime] or slots.DAY or {})[Specials.random(4)]
if slot and slot.species then nameSpecies(vm, slot.species) end if slot and slot.species then nameSpecies(vm, slot.species) end
+15 -6
View File
@@ -543,10 +543,14 @@ local function runCmd(self, cmd, op)
local species = cmd.species or arg1(cmd) local species = cmd.species or arg1(cmd)
local level = cmd.level or (cmd.args and cmd.args[2]) or 5 local level = cmd.level or (cmd.args and cmd.args[2]) or 5
local item = cmd.item or (cmd.args and cmd.args[3]) or 0 local item = cmd.item or (cmd.args and cmd.args[3]) or 0
if self.givePokeFn then
local mon = self.givePokeFn(species, level, item)
-- engine/pokemon/move_mon.asm:1753-1757
local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0 local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0
if self.givePokeFn then
-- engine/pokemon/move_mon.asm:1695-1736: the trainer arm copies the
-- script's own nickname and OT name in instead of asking for one.
local named = trainer ~= 0
and { nickname = cmd.name, otName = cmd.otName } or nil
local mon = self.givePokeFn(species, level, item, named)
-- engine/pokemon/move_mon.asm:1753-1757
if mon and trainer == 0 then if mon and trainer == 0 then
Specials.askNickname(self, mon) Specials.askNickname(self, mon)
end end
@@ -1114,7 +1118,9 @@ local function runCmd(self, cmd, op)
-- really does run here. -- really does run here.
if self.reloadMapFn then self.reloadMapFn(true) end if self.reloadMapFn then self.reloadMapFn(true) end
elseif op == "winlosstext" then elseif op == "winlosstext" then
-- Overrides the struct's win/loss text for this battle only. -- Overrides the struct's win/loss text for this battle only; a 0
-- argument zeroes that pointer (engine/overworld/scripting.asm:651)
self.winLossArmed = true
self.winTextOverride = cmd.winText self.winTextOverride = cmd.winText
self.lossTextOverride = cmd.lossText self.lossTextOverride = cmd.lossText
elseif op == "trainertext" then elseif op == "trainertext" then
@@ -1122,9 +1128,11 @@ local function runCmd(self, cmd, op)
local obj = self.trainerObject or {} local obj = self.trainerObject or {}
local key local key
if which == 1 then if which == 1 then
key = self.winTextOverride or obj.winText key = self.winLossArmed and self.winTextOverride
or (not self.winLossArmed and obj.winText or nil)
elseif which == 2 then elseif which == 2 then
key = self.lossTextOverride or obj.lossText key = self.winLossArmed and self.lossTextOverride
or (not self.winLossArmed and obj.lossText or nil)
else else
key = obj.seenText key = obj.seenText
end end
@@ -2425,6 +2433,7 @@ function Vm:start(scriptKey)
self.battleOutcome = nil self.battleOutcome = nil
self.winTextOverride = nil self.winTextOverride = nil
self.lossTextOverride = nil self.lossTextOverride = nil
self.winLossArmed = nil
-- The whiteout abort is per-run too: a script that ended because the player -- The whiteout abort is per-run too: a script that ended because the player
-- was wiped must not stop the next one before it starts. -- was wiped must not stop the next one before it starts.
self.aborted = false self.aborted = false
+66 -16
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))
@@ -573,12 +596,13 @@ function SyncEngine:resolveConflict(key, choice)
return true return true
end end
function SyncEngine:uploadMods() function SyncEngine:uploadMods(includeOptions)
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:busy() then return false, "sync is busy" end if self:busy() then return false, "sync is busy" end
local manifest = SyncMods.build(self.modDeps) local manifest = SyncMods.build(self.modDeps, includeOptions)
self.phase = "uploading" self.phase = "uploading"
self.status = "Uploading the mod list..." self.status = includeOptions and "Uploading the mod list and options..."
or "Uploading the mod list..."
local handle, err = self.client:putMods(manifest) local handle, err = self.client:putMods(manifest)
return self:_request(handle, err, function(eng) return self:_request(handle, err, function(eng)
eng.phase = "idle" eng.phase = "idle"
@@ -595,19 +619,17 @@ function SyncEngine:fetchModPlan()
return self:_request(handle, err, function(eng, res) return self:_request(handle, err, function(eng, res)
local data = res.data or {} local data = res.data or {}
local manifest = type(data.manifest) == "table" and data.manifest or data local manifest = type(data.manifest) == "table" and data.manifest or data
eng.modPlan = SyncMods.plan(manifest, eng.modDeps) eng:_takeModPlan(SyncMods.plan(manifest, eng.modDeps))
eng.phase = "idle"
eng.status = SyncMods.planEmpty(eng.modPlan)
and "Mods already match" or "Mod changes ready to apply"
end) end)
end end
function SyncEngine:shareMods() function SyncEngine:shareMods(includeOptions)
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:busy() then return false, "sync is busy" end if self:busy() then return false, "sync is busy" end
local manifest = SyncMods.build(self.modDeps) local manifest = SyncMods.build(self.modDeps, includeOptions)
self.phase = "uploading" self.phase = "uploading"
self.status = "Sharing the mod list..." self.status = includeOptions and "Sharing the mod list and options..."
or "Sharing the mod list..."
local handle, err = self.client:shareMods(manifest) local handle, err = self.client:shareMods(manifest)
return self:_request(handle, err, function(eng, res) return self:_request(handle, err, function(eng, res)
local data = res.data or {} local data = res.data or {}
@@ -626,16 +648,44 @@ function SyncEngine:fetchShare(code)
return self:_request(handle, err, function(eng, res) return self:_request(handle, err, function(eng, res)
local data = res.data or {} local data = res.data or {}
local manifest = type(data.manifest) == "table" and data.manifest or data local manifest = type(data.manifest) == "table" and data.manifest or data
eng.modPlan = SyncMods.plan(manifest, eng.modDeps) eng:_takeModPlan(SyncMods.plan(manifest, eng.modDeps))
eng.phase = "idle"
eng.status = SyncMods.planEmpty(eng.modPlan)
and "Mods already match" or "Mod changes ready to apply"
end) end)
end end
function SyncEngine:_takeModPlan(plan)
self.modPlan = plan
self.phase = "idle"
if SyncMods.planHasOptions(plan) then
self.status = ("This list carries options for %d mods.")
:format(#plan.options)
elseif SyncMods.planEmpty(plan) then
self.status = "Mods already match"
else
self.status = "Mod changes ready to apply"
end
end
function SyncEngine:modOptionsAsk()
local plan = self.modPlan
if not SyncMods.planHasOptions(plan) then return nil end
if plan.applyOptions ~= nil then return nil end
return SyncMods.optionModIds(plan)
end
function SyncEngine:answerModOptions(importThem)
local plan = self.modPlan
if not SyncMods.planHasOptions(plan) then return false end
SyncMods.answerOptions(plan, importThem)
self.status = plan.applyOptions
and "Their mod options will be imported too"
or "Their mod options will be skipped"
return plan.applyOptions
end
function SyncEngine:applyModPlan(progress) function SyncEngine:applyModPlan(progress)
if not self.modPlan then return false, "no mod plan" end if not self.modPlan then return false, "no mod plan" end
if self.modApply then return false, "the mods are already being applied" end if self.modApply then return false, "the mods are already being applied" end
self.modPlan.applyOptions = self.modPlan.applyOptions == true
local steps = SyncMods.steps(self.modPlan, self.modDeps) local steps = SyncMods.steps(self.modPlan, self.modDeps)
if #steps == 0 then if #steps == 0 then
self.modPlan = nil self.modPlan = nil
+101 -3
View File
@@ -1,6 +1,8 @@
local SyncMods = {} local SyncMods = {}
SyncMods.REV = 1 SyncMods.REV = 2
SyncMods.MAX_OPTION_KEYS = 64
SyncMods.MAX_OPTION_TEXT = 256
local function versions() local function versions()
local ok, GameVersion = pcall(require, "src.core.GameVersion") local ok, GameVersion = pcall(require, "src.core.GameVersion")
@@ -35,6 +37,12 @@ local function defaultDeps()
setEnabled = function(id, enabled, version) setEnabled = function(id, enabled, version)
return require("src.mods.LauncherMods").setEnabled(id, enabled, version) return require("src.mods.LauncherMods").setEnabled(id, enabled, version)
end, end,
modOptions = function()
return require("src.mods.LauncherMods").modOptions()
end,
setOptions = function(id, values)
return require("src.mods.LauncherMods").setModOptions(id, values)
end,
} }
end end
@@ -46,6 +54,41 @@ local function deps(given)
return out return out
end end
local function sanitizeOptions(bucket)
if type(bucket) ~= "table" then return nil end
local keys = {}
for k, v in pairs(bucket) do
local t = type(v)
if type(k) == "string" and k ~= ""
and (t == "string" or t == "number" or t == "boolean") then
keys[#keys + 1] = k
end
end
if #keys == 0 then return nil end
table.sort(keys)
local out, n = {}, 0
for _, k in ipairs(keys) do
if n >= SyncMods.MAX_OPTION_KEYS then break end
local v = bucket[k]
if type(v) == "string" then v = v:sub(1, SyncMods.MAX_OPTION_TEXT) end
local finite = type(v) ~= "number"
or (v == v and v ~= math.huge and v ~= -math.huge)
if finite then
out[k] = v
n = n + 1
end
end
if n == 0 then return nil end
return out
end
local function sameOptions(a, b)
for k, v in pairs(a) do
if (b or {})[k] ~= v then return false end
end
return true
end
local function sourceOf(row) local function sourceOf(row)
local github = row.github local github = row.github
or (type(row.manifest) == "table" and row.manifest.github) or (type(row.manifest) == "table" and row.manifest.github)
@@ -55,9 +98,14 @@ local function sourceOf(row)
return "local" return "local"
end end
function SyncMods.build(given) function SyncMods.build(given, includeOptions)
local d = deps(given) local d = deps(given)
local manifest = { rev = SyncMods.REV, indexes = {}, mods = {} } local manifest = { rev = SyncMods.REV, indexes = {}, mods = {} }
local stored = {}
if includeOptions then
local ok, live = pcall(d.modOptions)
if ok and type(live) == "table" then stored = live end
end
for _, row in ipairs(d.indexes() or {}) do for _, row in ipairs(d.indexes() or {}) do
local url = row.url or row.feed local url = row.url or row.feed
if type(url) == "string" and url ~= "" then if type(url) == "string" and url ~= "" then
@@ -72,11 +120,14 @@ function SyncMods.build(given)
for _, version in ipairs(versions()) do for _, version in ipairs(versions()) do
if answers[version] then enabledFor[#enabledFor + 1] = version end if answers[version] then enabledFor[#enabledFor + 1] = version end
end end
local options = includeOptions and sanitizeOptions(stored[row.id]) or nil
if options then manifest.hasOptions = true end
manifest.mods[#manifest.mods + 1] = { manifest.mods[#manifest.mods + 1] = {
id = row.id, id = row.id,
version = row.version, version = row.version,
source = sourceOf(row), source = sourceOf(row),
enabledFor = enabledFor, enabledFor = enabledFor,
options = options,
} }
end end
end end
@@ -86,9 +137,16 @@ end
function SyncMods.plan(manifest, given) function SyncMods.plan(manifest, given)
local d = deps(given) local d = deps(given)
local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {} } local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {},
options = {}, applyOptions = nil }
if type(manifest) ~= "table" then return plan end if type(manifest) ~= "table" then return plan end
local liveOptions = {}
do
local ok, live = pcall(d.modOptions)
if ok and type(live) == "table" then liveOptions = live end
end
local haveIndex = {} local haveIndex = {}
for _, row in ipairs(d.indexes() or {}) do for _, row in ipairs(d.indexes() or {}) do
if type(row.url) == "string" then haveIndex[row.url] = true end if type(row.url) == "string" then haveIndex[row.url] = true end
@@ -130,6 +188,10 @@ function SyncMods.plan(manifest, given)
plan.toEnable[#plan.toEnable + 1] = { id = mod.id, version = version } plan.toEnable[#plan.toEnable + 1] = { id = mod.id, version = version }
end end
end end
local wanted = sanitizeOptions(mod.options)
if wanted and not sameOptions(wanted, liveOptions[mod.id]) then
plan.options[#plan.options + 1] = { id = mod.id, values = wanted }
end
end end
end end
end end
@@ -138,10 +200,33 @@ end
function SyncMods.planEmpty(plan) function SyncMods.planEmpty(plan)
if type(plan) ~= "table" then return true end if type(plan) ~= "table" then return true end
if plan.applyOptions and #(plan.options or {}) > 0 then return false end
return #(plan.indexes or {}) == 0 and #(plan.toInstall or {}) == 0 return #(plan.indexes or {}) == 0 and #(plan.toInstall or {}) == 0
and #(plan.toEnable or {}) == 0 and #(plan.toEnable or {}) == 0
end end
function SyncMods.planHasOptions(plan)
return type(plan) == "table" and #(plan.options or {}) > 0
end
function SyncMods.optionsAnswered(plan)
return not SyncMods.planHasOptions(plan) or plan.applyOptions ~= nil
end
function SyncMods.answerOptions(plan, importThem)
if type(plan) ~= "table" then return false end
plan.applyOptions = importThem and true or false
return plan.applyOptions
end
function SyncMods.optionModIds(plan)
local out = {}
for _, row in ipairs((type(plan) == "table" and plan.options) or {}) do
out[#out + 1] = row.id
end
return out
end
function SyncMods.steps(plan, given) function SyncMods.steps(plan, given)
local d = deps(given) local d = deps(given)
local out = {} local out = {}
@@ -175,6 +260,19 @@ function SyncMods.steps(plan, given)
return true return true
end } end }
end end
if plan.applyOptions then
for _, want in ipairs(plan.options or {}) do
out[#out + 1] = { label = want.id, run = function()
if broken[want.id] then return true end
local ok, err = d.setOptions(want.id, want.values)
if ok == false then
return nil, want.id .. ": "
.. tostring(err or "could not set the mod options")
end
return true
end }
end
end
return out return out
end end
+16 -7
View File
@@ -17,10 +17,13 @@ local function buildItems(game)
local items = {} local items = {}
for _, id in ipairs(Bag.order(game.save)) do for _, id in ipairs(Bag.order(game.save)) do
local def = game.data.items[id] local def = game.data.items[id]
-- PrintListMenuEntries skips the quantity for anything IsKeyItem_ owns:
-- the KeyItemFlags bitfield plus the HMs (item_effects.asm:2616-2641)
local unsellable = (def and def.keyItem) or id:find("^HM_") ~= nil
table.insert(items, { table.insert(items, {
value = id, value = id,
label = def and def.name or id, label = def and def.name or id,
right = "x" .. game.save.inventory[id], right = (not unsellable) and ("x" .. game.save.inventory[id]) or nil,
}) })
end end
return items return items
@@ -334,8 +337,13 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
local Evolution = require("src.pokemon.Evolution") local Evolution = require("src.pokemon.Evolution")
local evoTo, evo = Evolution.pendingFor(game, target, local evoTo, evo = Evolution.pendingFor(game, target,
{ kind = "levelup" }) { kind = "levelup" })
-- the party menu stays up through TryEvolvingMon and only
-- comes down at RemoveUsedItem (item_effects.asm:1392-1418)
if evoTo then if evoTo then
Evolution.evolve(game, target, evoTo, nil, evo and evo.method) Evolution.evolve(game, target, evoTo, closePicker,
evo and evo.method)
else
closePicker()
end end
return return
end end
@@ -399,10 +407,9 @@ local function pickTargetAndUse(game, battle, id, list)
local opts = { local opts = {
pickOnly = true, pickOnly = true,
battle = battle, battle = battle,
-- HP medicine animates its bar with the picker still up (#252). Only -- HP medicine animates with the picker up (#252), RARE CANDY prints over
-- out of battle: the in-battle tail closes the bag list underneath -- the party menu (item_effects.asm:1392-1418)
-- first, which needs the picker already gone. keepOpen = (not battle) and ItemEffects.keepsPartyMenuOpen(id),
keepOpen = (not battle) and ItemEffects.healsHP(id),
onSwitch = function(mon, picker) onSwitch = function(mon, picker)
if not wantsMove then if not wantsMove then
useOn(game, battle, id, mon, list, nil, picker) useOn(game, battle, id, mon, list, nil, picker)
@@ -470,7 +477,9 @@ function BagMenu.new(game, opts)
local list local list
list = ListMenu.new(game, "ITEMS", buildItems(game), { list = ListMenu.new(game, "ITEMS", buildItems(game), {
kind = "bag", kind = "bag",
footer = ("¥%d"):format(game.save.money), -- StartMenu_Item zeroes wPrintItemPrices and draws no money box: the
-- LIST_MENU_BOX floats over the map (engine/menus/start_sub_menus.asm)
itemBox = true,
-- B returns to the start menu when the bag was opened from it -- B returns to the start menu when the bag was opened from it
onCancel = opts.onCancel, onCancel = opts.onCancel,
-- SELECT reorders items like the original bag (swap_items.asm) -- SELECT reorders items like the original bag (swap_items.asm)
+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,
})) }))
+109 -18
View File
@@ -1,18 +1,77 @@
-- The dex-completion diploma (engine/events/diploma.asm DisplayDiploma / -- The dex-completion diploma (engine/events/diploma.asm DisplayDiploma /
-- diploma2.asm DisplayDiplomaTop): a bordered certificate page with the -- diploma2.asm DisplayDiplomaTop): a bordered certificate page with the
-- player's name, shown by the Celadon Mansion 3F game designer once 150 -- player's name and character sprite, shown by the Celadon Mansion 3F game designer
-- species are owned. Diploma.render also backs the Yellow-only printed -- once 150 species are owned. Diploma.render also backs the printed copy
-- copy (engine/printer/printer.asm PrintDiploma -> src/core/Printer.lua). -- (engine/printer/printer.asm PrintDiploma -> src/core/Printer.lua).
local Assets = require("src.render.Assets")
local Font = require("src.render.Font") local Font = require("src.render.Font")
local PaletteFX = require("src.render.PaletteFX")
local Sprites = require("src.pokemon.Sprites")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local Diploma = {} local Diploma = {}
Diploma.__index = Diploma Diploma.__index = Diploma
Diploma.isOpaque = true Diploma.isOpaque = true
-- SGB: PalPacket_Generic (MEWMON), whole screen (engine/events/diploma.asm:67)
function Diploma:sgbPalettes(game)
return PaletteFX.wholeNamed(game.data, "MEWMON")
end
local function tryImage(path)
if not path then return nil end
local ok, img = pcall(Assets.image, path)
if ok and img then return img end
local ok2, img2 = pcall(love.graphics.newImage, path)
return ok2 and img2 or nil
end
local function loadFrame()
local frame = tryImage("assets/generated/trainer_card/trainer_info.png")
if not frame then return nil end
local quads = {}
for i = 0, 8 do
quads[i] = love.graphics.newQuad((i % 3) * 8,
math.floor(i / 3) * 8,
8, 8, frame:getDimensions())
end
return { img = frame, quads = quads }
end
local function drawFrameBox(frame, tx, ty, tw, th)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
if not frame then
Font.drawBox(tx, ty, tw, th)
return
end
local img = frame.img
local q = frame.quads
love.graphics.setColor(1, 1, 1, 1)
-- corners
love.graphics.draw(img, q[0], tx * 8, ty * 8)
love.graphics.draw(img, q[2], (tx + tw - 1) * 8, ty * 8)
love.graphics.draw(img, q[6], tx * 8, (ty + th - 1) * 8)
love.graphics.draw(img, q[8], (tx + tw - 1) * 8, (ty + th - 1) * 8)
-- horizontal edges
for x = 1, tw - 2 do
love.graphics.draw(img, q[1], (tx + x) * 8, ty * 8)
love.graphics.draw(img, q[7], (tx + x) * 8, (ty + th - 1) * 8)
end
-- vertical edges
for y = 1, th - 2 do
love.graphics.draw(img, q[3], tx * 8, (ty + y) * 8)
love.graphics.draw(img, q[5], (tx + tw - 1) * 8, (ty + y) * 8)
end
end
function Diploma.new(game, onDone) function Diploma.new(game, onDone)
return setmetatable({ game = game, onDone = onDone }, Diploma) local self = setmetatable({
game = game,
onDone = onDone,
}, Diploma)
return self
end end
function Diploma:update() function Diploma:update()
@@ -23,23 +82,55 @@ function Diploma:update()
end end
end end
-- the DisplayDiplomaTop layout, hlcoord tiles kept as x*8 / y*8 pixels -- the DisplayDiploma / DisplayDiplomaTop layout (hlcoord tiles -> x*8, y*8)
function Diploma.render(game) function Diploma.render(game)
local frame = loadFrame()
local circle = tryImage("assets/generated/trainer_card/circle_tile.png")
-- 1. Outer ornate frame border: hlcoord 0, 0 / bc 16, 18 -> (0, 0, 20, 18)
drawFrameBox(frame, 0, 0, 20, 18)
-- 2. Draw Player character sprite: farcall DrawPlayerCharacter
-- Shifted +33 px right from title screen base (82 + 33 = 115, y = 80)
local picPath, picTrueColor = Sprites.playerPath(
game.data, "front", { kind = "diploma" })
local pic = tryImage(picPath)
if pic then
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.draw(pic, 115, 80)
love.graphics.setColor(0, 0, 0, 1) if picTrueColor then
love.graphics.rectangle("line", 2.5, 2.5, 155, 139) PaletteFX.markTrueColor(115, 80, pic:getDimensions())
Font.draw(Strings("<Diploma>"), 40, 16) -- hlcoord 5,2
Font.draw(Strings("Player"), 24, 32) -- hlcoord 3,4
Font.draw(game.save.player.name or "RED", 80, 32) -- hlcoord 10,4
local congrats = { -- hlcoord 2,6
"Congrats! This", "diploma certifies", "that you have",
"completed your", "POKéDEX.",
}
for i, line in ipairs(congrats) do
Font.draw(Strings(line), 16, 48 + (i - 1) * 10)
end end
Font.draw(Strings("GAME FREAK"), 72, 128) -- hlcoord 9,16 end
-- 3. Header: hlcoord 5, 2 with flanking circle tiles ($70)
love.graphics.setColor(1, 1, 1, 1)
if circle then
love.graphics.draw(circle, 40, 16) -- hlcoord 5, 2
love.graphics.draw(circle, 104, 16) -- hlcoord 13, 2
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("Diploma"), 48, 16) -- hlcoord 6, 2
-- 4. Player info: hlcoord 3, 4 ("PLAYER" / "Player") and hlcoord 10, 4 (name)
Font.draw(Strings("Player"), 24, 32)
local playerName = (game.save.player and game.save.player.name) or "RED"
Font.draw(playerName, 80, 32)
-- 5. Congratulations text: hlcoord 2, 6 double-spaced lines (rows 6, 8, 10, 12, 14)
local congrats = {
{ text = "Congrats! This", y = 48 }, -- hlcoord 2, 6
{ text = "diploma certifies", y = 64 }, -- hlcoord 2, 8
{ text = "that you have", y = 80 }, -- hlcoord 2, 10
{ text = "completed your", y = 96 }, -- hlcoord 2, 12
{ text = "POKéDEX.", y = 112 }, -- hlcoord 2, 14
}
for _, line in ipairs(congrats) do
Font.draw(Strings(line.text), 16, line.y)
end
-- 6. Developer signature: hlcoord 9, 16
Font.draw(Strings("GAME FREAK"), 72, 128)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+13 -19
View File
@@ -1,6 +1,7 @@
-- The evolution movie (engine/movie/evolution.asm): the mon's pic -- The evolution movie (engine/movie/evolution.asm): the mon's pic
-- flashes back and forth with the evolved form, speeding up, then the -- flashes back and forth with the evolved form, speeding up, then the
-- new form appears with its cry and the congratulations text. -- new form appears with its cry and the "evolved into" text
-- (engine/pokemon/evos_moves.asm:120-128).
-- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the -- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the
-- joypad during the flash: a fresh B press aborts the evolution -- the mon -- joypad during the flash: a fresh B press aborts the evolution -- the mon
-- keeps its species and _StoppedEvolvingText ("Huh? MON stopped evolving!") -- keeps its species and _StoppedEvolvingText ("Huh? MON stopped evolving!")
@@ -9,14 +10,13 @@
-- and stone evolutions, where the B press is read but thrown away because -- and stone evolutions, where the B press is read but thrown away because
-- ItemUseEvoStone left wForceEvolution set (#290). -- ItemUseEvoStone left wForceEvolution set (#290).
local Font = require("src.render.Font")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local Strings = require("src.core.Strings")
local romText = require("src.core.RomText") local romText = require("src.core.RomText")
-- Not opaque: ClearScreenArea wipes rows 0-11 only (evos_moves.asm:126-128),
-- so the "is evolving!" box beneath stays visible through the flash (#1596).
local EvolutionState = {} local EvolutionState = {}
EvolutionState.__index = EvolutionState EvolutionState.__index = EvolutionState
EvolutionState.isOpaque = true
-- SGB: SetPal_PokemonWholeScreen for the mon on display -- SGB: SetPal_PokemonWholeScreen for the mon on display
function EvolutionState:sgbPalettes(game) function EvolutionState:sgbPalettes(game)
@@ -127,11 +127,11 @@ function EvolutionState:update(dt)
require("src.core.Sound").playCry(game.data, self.newSpecies) require("src.core.Sound").playCry(game.data, self.newSpecies)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local newName = game.data.pokemon[self.newSpecies].name local newName = game.data.pokemon[self.newSpecies].name
-- _EvolvedText extracts truncated (it stops at a dynamic marker the -- EvolvedText then IntoText in the same box (PrintText_NoCreatingTextBox),
-- decoder does not follow), so the engine's wording stands here -- then SFX_GET_ITEM_2 (engine/pokemon/evos_moves.asm:136-153)
game.stack:push(TextBox.new(game, local msg = romText(game.data, "_EvolvedText", "%s evolved", self.oldName)
Strings("Congratulations!\nYour %s\nevolved into\n%s!", .. romText(game.data, "_IntoText", "\ninto %s!", newName)
self.oldName, newName), game.stack:push(TextBox.new(game, msg,
function() function()
Music.restoreMap(game.data) Music.restoreMap(game.data)
game.stack:pop() -- the evolution screen itself game.stack:pop() -- the evolution screen itself
@@ -141,13 +141,15 @@ function EvolutionState:update(dt)
-- first so the "learned MOVE!" text / forget prompt push onto the -- first so the "learned MOVE!" text / forget prompt push onto the
-- overworld / battle-return, not this state. -- overworld / battle-return, not this state.
Evolution.learnEvolutionMoves(game, self.mon, self.onDone) Evolution.learnEvolutionMoves(game, self.mon, self.onDone)
end)) end,
TextBox.soundOpts(game, "Get_Item2")))
end end
end end
function EvolutionState:draw() function EvolutionState:draw()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144) -- rows 0-11 only (hlcoord 0,0 / lb bc, 12, 20, evos_moves.asm:126-128)
love.graphics.rectangle("fill", 0, 0, 160, 96)
-- accelerating flash between the two forms -- accelerating flash between the two forms
local sprite, spriteTrueColor local sprite, spriteTrueColor
@@ -172,14 +174,6 @@ function EvolutionState:draw()
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions()) require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
end end
end end
love.graphics.setColor(0, 0, 0, 1)
if not self.done then
Font.draw(Strings("What?"), 8, 104)
Font.draw(self.oldName .. " is", 8, 114)
Font.draw(Strings("evolving!"), 8, 124)
end
love.graphics.setColor(1, 1, 1, 1)
end end
return EvolutionState return EvolutionState
+66 -3
View File
@@ -17,6 +17,14 @@ function ListMenu:sgbPalettes(game)
end end
local ROWS = 7 local ROWS = 7
-- LIST_MENU_BOX 4,2 - 19,12 (data/text_boxes.asm:13); 4 names from
-- hlcoord 6,4 two rows apart (home/list_menu.asm:51-52, 364-365, 471-479)
local ITEM_BOX = { tx = 4, ty = 2, tw = 16, th = 11 }
local ITEM_ROWS = 4
local ITEM_NAME_X, ITEM_TOP_Y = 48, 32
local ITEM_CURSOR_X = 40
local ITEM_QTY_X, ITEM_QTY_END = 112, 136
local ITEM_MORE_X, ITEM_MORE_Y = 144, 88
-- frames to wait before key-repeat kicks in, then between repeats -- frames to wait before key-repeat kicks in, then between repeats
local REPEAT_DELAY = 16 local REPEAT_DELAY = 16
local REPEAT_RATE = 4 local REPEAT_RATE = 4
@@ -83,7 +91,20 @@ function ListMenu.new(game, title, items, opts)
-- for their whole run (engine/menus/pc.asm, engine/menus/players_pc.asm), -- for their whole run (engine/menus/pc.asm, engine/menus/players_pc.asm),
-- so their lists opt out of the A/B beep the same way Menu's noSound does -- so their lists opt out of the A/B beep the same way Menu's noSound does
self.noSound = opts.noSound or false self.noSound = opts.noSound or false
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS) -- the bag's item list: a partial box the map stays visible around, not a
-- screen of its own (home/list_menu.asm:29-31)
self.itemBox = opts.itemBox or false
if self.itemBox then
self.isOpaque = false
-- keep RunDefaultPaletteCommand's last palette: ItemMenuLoop never sets
-- its own (engine/menus/start_sub_menus.asm:300)
self.sgbPalettes = false
-- wMaxMenuItem is 2 for item lists; the fourth printed row is a
-- look-ahead the cursor cannot reach (home/list_menu.asm:46-48)
self.cursorRows = 3
end
self.rows = opts.rows or (self.itemBox and ITEM_ROWS)
or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
return self return self
end end
@@ -100,8 +121,9 @@ local function moveIndex(self, delta)
end end
local function syncScroll(self) local function syncScroll(self)
if self.index - self.scroll > self.rows then local maxRow = self.cursorRows or self.rows
self.scroll = self.index - self.rows if self.index - self.scroll > maxRow then
self.scroll = self.index - maxRow
end end
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
end end
@@ -206,7 +228,48 @@ function ListMenu:close()
if top == self then self.game.stack:pop() end if top == self then self.game.stack:pop() end
end end
-- PrintListMenuEntries, minus the price column StartMenu_Item never asks for
-- (wPrintItemPrices = 0, engine/menus/start_sub_menus.asm)
function ListMenu:drawItemBox()
love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(ITEM_BOX.tx, ITEM_BOX.ty, ITEM_BOX.tw, ITEM_BOX.th)
love.graphics.setColor(0, 0, 0, 1)
if #self.items == 0 then
Font.draw(Strings("Nothing here."), ITEM_NAME_X, ITEM_TOP_Y)
end
local shown = 0
for row = 1, self.rows do
local i = self.scroll + row
local item = self.items[i]
if not item then break end
shown = shown + 1
local y = ITEM_TOP_Y + (row - 1) * 16
Font.draw(item.label, ITEM_NAME_X, y)
if item.right then
-- '×' at column 14, PrintNumber's two right-aligned digits after it
-- (home/list_menu.asm:479-490)
local count = item.right:sub(2)
Font.draw(item.right:sub(1, 1), ITEM_QTY_X, y + 8)
Font.draw(count, ITEM_QTY_END - Font.width(count), y + 8)
end
if i == self.index then
Font.drawCode(self.hollowIndex == i
and Theme.cursorHollow or Theme.cursor, ITEM_CURSOR_X, y)
end
if self.swapIndex == i and i ~= self.index then
Font.drawCode(Theme.cursorHollow, ITEM_CURSOR_X, y)
end
end
-- the terminator prints CANCEL and returns before the '▼'
-- (home/list_menu.asm:372, 518-524)
if shown == self.rows then
Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y)
end
love.graphics.setColor(1, 1, 1, 1)
end
function ListMenu:draw() function ListMenu:draw()
if self.itemBox then return self:drawItemBox() end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
+21 -5
View File
@@ -34,6 +34,9 @@ function Menu.new(game, items, opts)
if self.tx + self.tw > 20 then self.tx = math.max(0, 20 - self.tw) end if self.tx + self.tw > 20 then self.tx = math.max(0, 20 - self.tw) end
end end
self.rowStep = opts.rowStep or 2 self.rowStep = opts.rowStep or 2
-- engine/movie/oak_speech/oak_speech2.asm:162 (DisplayIntroNameTextBox)
self.title = opts.title
self.itemY = opts.itemY
-- maxVisible: cap the box to this many rows and scroll the rest instead -- maxVisible: cap the box to this many rows and scroll the rest instead
-- of growing past it (e.g. the start menu, whose row count varies with -- of growing past it (e.g. the start menu, whose row count varies with
-- save state and mod hooks); nil/unset keeps every caller's old -- save state and mod hooks); nil/unset keeps every caller's old
@@ -116,7 +119,17 @@ function Menu:draw()
self.tw * 8, self.th * 8, self.anchor) self.tw * 8, self.th * 8, self.anchor)
end end
Font.drawBox(self.tx, self.ty, self.tw, self.th) Font.drawBox(self.tx, self.ty, self.tw, self.th)
-- PlaceString at hlcoord 3,0 writes over the border row it was just
-- drawn on (oak_speech2.asm:162-170)
if self.title then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", (self.tx + 3) * 8, self.ty * 8,
#Font.split(self.title) * 8, 8)
end
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
if self.title then
Font.draw(self.title, (self.tx + 3) * 8, self.ty * 8)
end
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items)) local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
or #self.items or #self.items
-- Row Y: pokered's boxed menus anchor the choices to the BOTTOM interior -- Row Y: pokered's boxed menus anchor the choices to the BOTTOM interior
@@ -130,15 +143,18 @@ function Menu:draw()
-- USE/TOSS is th = 5 for two choices (#284, matching text_boxes.asm's -- USE/TOSS is th = 5 for two choices (#284, matching text_boxes.asm's
-- USE_TOSS_MENU_TEMPLATE rows 10..14), and a top anchor pushed TOSS onto -- USE_TOSS_MENU_TEMPLATE rows 10..14), and a top anchor pushed TOSS onto
-- the bottom border (#564, #572). -- the bottom border (#564, #572).
local function rowY(row)
if self.itemY then
return (self.ty + self.itemY + (row - 1) * self.rowStep) * 8
end
return (self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8
end
for row = 1, visible do for row = 1, visible do
local item = self.items[self.scroll + row] local item = self.items[self.scroll + row]
if not item then break end if not item then break end
Font.draw(item.label, (self.tx + 2) * 8, Font.draw(item.label, (self.tx + 2) * 8, rowY(row))
(self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8)
end end
local cursorRow = self.index - self.scroll Font.drawCode(Theme.cursor, (self.tx + 1) * 8, rowY(self.index - self.scroll))
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
(self.ty + self.th - 2 - (visible - cursorRow) * self.rowStep) * 8)
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState -- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
-- use, sat on the bottom border like TextBox's page-advance cursor. It -- use, sat on the bottom border like TextBox's page-advance cursor. It
-- has to be the border row, not ty + th - 2: that is the last interior -- has to be the border row, not ty + th - 2: that is the last interior
+14 -2
View File
@@ -67,6 +67,7 @@ function NamingScreen.new(game, opts)
self.game = game self.game = game
self.title = opts.title or Strings("YOUR NAME?") self.title = opts.title or Strings("YOUR NAME?")
self.presets = opts.presets self.presets = opts.presets
self.introBox = opts.introBox
self.maxLen = opts.maxLen or 7 self.maxLen = opts.maxLen or 7
self.default = opts.default self.default = opts.default
self.onDone = opts.onDone self.onDone = opts.onDone
@@ -95,14 +96,25 @@ function NamingScreen:enter()
onSelect = function() onSelect = function()
-- the menu already popped itself; pop the naming screen too -- the menu already popped itself; pop the naming screen too
self.game.stack:pop() self.game.stack:pop()
if self.onDone then self.onDone(preset) end if self.onDone then self.onDone(preset, false) end
end, end,
}) })
end end
if self.introBox then
-- DisplayIntroNameTextBox (oak_speech2.asm:162): TextBoxBorder at
-- hlcoord 0,0 with b=$a c=$9, "NAME" at hlcoord 3,0, list at hlcoord 2,2
-- TextBoxBorder's b = $a is a fixed 12-row box, whatever the preset
-- list's length (oak_speech2.asm:163-166)
self.game.stack:push(Menu.new(self.game, items, {
tx = 0, ty = 0, tw = 11, th = 12,
itemY = 2, title = Strings("NAME"), cancelable = false,
}))
else
self.game.stack:push(Menu.new(self.game, items, { self.game.stack:push(Menu.new(self.game, items, {
tx = 4, ty = 0, tw = 12, th = #items * 2 + 2, cancelable = false, tx = 4, ty = 0, tw = 12, th = #items * 2 + 2, cancelable = false,
})) }))
end end
end
end end
function NamingScreen:confirm() function NamingScreen:confirm()
@@ -125,7 +137,7 @@ function NamingScreen:confirm()
end end
Sound.play(self.game.data, "Press_AB") Sound.play(self.game.data, "Press_AB")
self.game.stack:pop() self.game.stack:pop()
if self.onDone then self.onDone(name) end if self.onDone then self.onDone(name, true) end
end end
function NamingScreen:grid() function NamingScreen:grid()
+89 -2
View File
@@ -35,6 +35,29 @@ OakSpeech.letterboxWhite = true
local FADE_FRAMES = 24 local FADE_FRAMES = 24
local WIPE_FRAMES = 32 local WIPE_FRAMES = 32
-- OakSpeechSlidePicRight / OakSpeechSlidePicLeft (oak_speech2.asm:67-89)
local SLIDE_TILES = 6
local SLIDE_FRAMES = 3
local PicSlide = {}
PicSlide.__index = PicSlide
function PicSlide:update(dt)
-- OakSpeechSlidePicLeft: ClearScreenArea, ld c, 10 / DelayFrames, Delay3
-- before the first slide step (oak_speech2.asm:69-78)
if (self.delay or 0) > 0 then
self.delay = self.delay - 1
return
end
self.t = self.t + 1
local tiles = math.min(SLIDE_TILES, math.floor(self.t / SLIDE_FRAMES))
self.speech.picSlide = (self.dir > 0 and tiles or (SLIDE_TILES - tiles)) * 8
if tiles >= SLIDE_TILES then
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
-- naming presets are boot config (field.boot.namePresets), which a total -- naming presets are boot config (field.boot.namePresets), which a total
-- conversion replaces; the Red/Blue lists remain the fallback -- conversion replaces; the Red/Blue lists remain the fallback
local function namePresets(game, who, fallback) local function namePresets(game, who, fallback)
@@ -155,6 +178,10 @@ function OakSpeech.defaultSteps(speech)
kind = "say", kind = "say",
textKey = "_IntroducePlayerText", textKey = "_IntroducePlayerText",
pic = "player", pic = "player",
-- oak_speech.asm:89-92: MovePicLeft, then IntroducePlayerText's
-- `prompt` (text_2.asm:1730) waits for A and leaves the box up
reveal = "wipe",
stay = true,
}, },
{ {
id = "name_player", id = "name_player",
@@ -171,12 +198,19 @@ function OakSpeech.defaultSteps(speech)
id = "confirm_player_name", id = "confirm_player_name",
kind = "say", kind = "say",
textKey = "_YourNameIsText", textKey = "_YourNameIsText",
-- _YourNameIsText's `prompt` (text_2.asm:1766), then GBFadeOutToWhite
-- / ClearScreen with the box still up (oak_speech.asm:93-94)
fadeOut = true,
}, },
{ {
id = "ask_rival_name", id = "ask_rival_name",
kind = "say", kind = "say",
textKey = "_IntroduceRivalText", textKey = "_IntroduceRivalText",
pic = "rival", pic = "rival",
-- oak_speech.asm:98-101: FadeInIntroPic, then IntroduceRivalText's
-- `prompt` (text_2.asm:1740) leaves the box up for ChooseRivalName
reveal = "fade",
stay = true,
}, },
{ {
id = "name_rival", id = "name_rival",
@@ -191,6 +225,9 @@ function OakSpeech.defaultSteps(speech)
id = "confirm_rival_name", id = "confirm_rival_name",
kind = "say", kind = "say",
textKey = "_HisNameIsText", textKey = "_HisNameIsText",
-- _HisNameIsText's `prompt` (text_2.asm:1772) then the .skipSpeech
-- fade with the box up (oak_speech.asm:103-104)
fadeOut = true,
}, },
{ {
id = "legend", id = "legend",
@@ -378,7 +415,25 @@ function OakSpeech:runStep(step)
self:applyPic(step) self:applyPic(step)
self:afterReveal(step, function() self:afterReveal(step, function()
self:runCry(step) self:runCry(step)
if step.stay or step.fadeOut then
local box = TextBox.new(self.game, self:stepText(step), nil,
{ stay = { prompt = true, onShown = function()
if step.fadeOut then
-- GBFadeOutToWhite / ClearScreen (oak_speech.asm:93-94)
self.game.stack:push(require("src.render.Transition")
.whiteFlash(self.game, nil, function()
self:closeHoldBox()
self:advance()
end))
else
self:advance()
end
end } })
self.holdBox = box
self.game.stack:push(box)
else
self:sayText(self:stepText(step), function() self:advance() end) self:sayText(self:stepText(step), function() self:advance() end)
end
end) end)
elseif kind == "demo" then elseif kind == "demo" then
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A -- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
@@ -394,20 +449,36 @@ function OakSpeech:runStep(step)
local presets = step.presets local presets = step.presets
or namePresets(self.game, step.presetsWho or who, or namePresets(self.game, step.presetsWho or who,
step.presetsFallback or { "RED" }) step.presetsFallback or { "RED" })
local function openNaming()
require("src.ui.Screens").push(self.game, "NamingScreen", { require("src.ui.Screens").push(self.game, "NamingScreen", {
title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")), title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")),
presets = presets, presets = presets,
introBox = true,
maxLen = step.maxLen or self.nameLen, maxLen = step.maxLen or self.nameLen,
onDone = function(name) onDone = function(name, custom)
if who == "rival" then if who == "rival" then
self.game.save.player.rival = name self.game.save.player.rival = name
else else
self.game.save.player.name = name self.game.save.player.name = name
end end
self:recordAnswer(step, 1, name, name) self:recordAnswer(step, 1, name, name)
-- YourNameIsText / HisNameIsText print into the box this one
-- held (oak_speech2.asm:26-28, :59-61)
self:closeHoldBox()
if custom then
-- .customName: ClearScreen / Delay3 / pic recentered, no
-- slide-back (oak_speech2.asm:21-25)
self.picSlide = 0
self:advance() self:advance()
else
-- OakSpeechSlidePicLeft's 13-frame pre-slide beat
-- (oak_speech2.asm:69-78)
self:slidePic(-1, function() self:advance() end, 13)
end
end, end,
}) })
end
self:slidePic(1, openNaming)
elseif kind == "choice" then elseif kind == "choice" then
self:applyPic(step) self:applyPic(step)
self:afterReveal(step, function() self:afterReveal(step, function()
@@ -518,6 +589,22 @@ function OakSpeech:revealPic(kind, next)
} }
end end
-- ..(engine/movie/oak_speech/oak_speech2.asm ln 67)
function OakSpeech:slidePic(dir, onDone, delay)
self.picSlide = (dir > 0 and 0 or SLIDE_TILES * 8)
self.game.stack:push(setmetatable({
game = self.game, speech = self, dir = dir, t = 0, onDone = onDone,
delay = delay,
}, PicSlide))
end
-- IntroducePlayerText's text_end box (oak_speech.asm:90) is ours to close
function OakSpeech:closeHoldBox()
local box = self.holdBox
self.holdBox = nil
if box and self.game.stack:top() == box then self.game.stack:pop() end
end
function OakSpeech:advance() function OakSpeech:advance()
self.step = self.step + 1 self.step = self.step + 1
-- picFlip belongs to the pic, not to the step: OakSpeechText2 prints 2A -- picFlip belongs to the pic, not to the step: OakSpeechText2 prints 2A
@@ -615,7 +702,7 @@ function OakSpeech:draw()
-- it like the sprite buffer does ((8 - w) >> 1) tiles across, -- it like the sprite buffer does ((8 - w) >> 1) tiles across,
-- bottom-aligned -- bottom-aligned
local w, h = self.pic:getDimensions() local w, h = self.pic:getDimensions()
local x = 48 + math.floor((8 - w / 8) / 2) * 8 local x = 48 + math.floor((8 - w / 8) / 2) * 8 + (self.picSlide or 0)
local y = 32 + (7 - h / 8) * 8 local y = 32 + (7 - h / 8) * 8
local reveal = self.picReveal local reveal = self.picReveal
local off = 0 local off = 0
+3 -10
View File
@@ -381,14 +381,7 @@ local function buildRows(game)
return Zoom.offsetLabel(g.save.options.zoom or 0) return Zoom.offsetLabel(g.save.options.zoom or 0)
end, end,
step = function(g, dir) step = function(g, dir)
local o = g.save.options Zoom.nudgeOptions(g.save.options, dir, Renderer:fitScale())
local S = Renderer:fitScale()
local lo, hi = Zoom.offsetRange(S)
local off = (o.zoom or 0) + dir
if off > hi then off = lo
elseif off < lo then off = hi end
o.zoom = off
Zoom.offset = off
return true return true
end }, end },
{ id = "voidFill", label = Strings("VOID FILL"), { id = "voidFill", label = Strings("VOID FILL"),
@@ -580,8 +573,8 @@ local function buildRows(game)
end end
rows = filtered rows = filtered
end end
-- ORIENTATION only on Android, the one platform Orientation.apply reaches. -- ORIENTATION only on the platforms Orientation.apply reaches (#1638).
if not Orientation.isAndroid() then if not (Orientation.isAndroid() or Orientation.isIOS()) then
local filtered = {} local filtered = {}
for _, row in ipairs(rows) do for _, row in ipairs(rows) do
if row.id ~= "orientation" then filtered[#filtered + 1] = row end if row.id ~= "orientation" then filtered[#filtered + 1] = row end
+4
View File
@@ -151,6 +151,7 @@ end
function PadCursor.joystickpressed(joystick, button) function PadCursor.joystickpressed(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return nil end if GamepadMap.ignoreRawForJoystick(joystick) then return nil end
if GamepadMap.isAccelerometer(joystick) then return nil end
local padButton = GamepadMap.mapRawToGamepadButton(button) local padButton = GamepadMap.mapRawToGamepadButton(button)
if padButton then return PadCursor.gamepadpressed(joystick, padButton) end if padButton then return PadCursor.gamepadpressed(joystick, padButton) end
return nil return nil
@@ -158,12 +159,14 @@ end
function PadCursor.joystickreleased(joystick, button) function PadCursor.joystickreleased(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
local padButton = GamepadMap.mapRawToGamepadButton(button) local padButton = GamepadMap.mapRawToGamepadButton(button)
if padButton then PadCursor.gamepadreleased(joystick, padButton) end if padButton then PadCursor.gamepadreleased(joystick, padButton) end
end end
function PadCursor.joystickaxis(joystick, axisIndex, value) function PadCursor.joystickaxis(joystick, axisIndex, value)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
if axisIndex == 1 then if axisIndex == 1 then
PadCursor.gamepadaxis(joystick, "leftx", value) PadCursor.gamepadaxis(joystick, "leftx", value)
elseif axisIndex == 2 then elseif axisIndex == 2 then
@@ -173,6 +176,7 @@ end
function PadCursor.joystickhat(joystick, hat, direction) function PadCursor.joystickhat(joystick, hat, direction)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
for _, d in ipairs(rawHatDirs[hat] or {}) do for _, d in ipairs(rawHatDirs[hat] or {}) do
dir[d] = nil dir[d] = nil
end end
+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
+838 -50
View File
File diff suppressed because it is too large Load Diff
+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"
+53 -9
View File
@@ -11,6 +11,7 @@ local Renderer = require("src.render.Renderer")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local Theme = require("src.ui.Theme")
local StartMenu = {} local StartMenu = {}
@@ -19,6 +20,7 @@ local function sameItems(_, items) return items end
function StartMenu.new(game) function StartMenu.new(game)
local flags = game.save.flags or {} local flags = game.save.flags or {}
local items = {} local items = {}
local menu
-- vanilla start submenus return here on B (RedisplayStartMenu): the -- vanilla start submenus return here on B (RedisplayStartMenu): the
-- generic Menu pops the start menu when a row is selected, so each -- generic Menu pops the start menu when a row is selected, so each
@@ -51,22 +53,62 @@ function StartMenu.new(game)
end }) end })
-- SAVE shows the player/badges/dex/time panel then asks to confirm -- SAVE shows the player/badges/dex/time panel then asks to confirm
-- (PrintSaveScreenText) -- (PrintSaveScreenText); StartMenu_SaveReset never clears the START menu
table.insert(items, { label = Strings("SAVE"), onSelect = function() -- box, so it stays on screen beside the panel (start_sub_menus.asm:641-647)
table.insert(items, { label = Strings("SAVE"), keepOpen = true,
onSelect = function()
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local badges = require("src.inventory.Badges").count(game.data, game.save) local badges = require("src.inventory.Badges").count(game.data, game.save)
local owned = 0 local owned = 0
for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do
owned = owned + 1 owned = owned + 1
end end
-- the panel is a static snapshot; the cart prints it once
-- (main_menu.asm:390-401)
local t = math.floor(game.save.playTime or 0) local t = math.floor(game.save.playTime or 0)
local panel = Strings("PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d", -- PrintSaveScreenText draws its own border at hlcoord 4,0 (b=8, c=$e) and
game.save.player.name or "RED", badges, owned, -- leaves it up under the prompt -- engine/menus/main_menu.asm:381-405
math.floor(t / 3600), math.floor(t / 60) % 60) local panel
panel = {
-- the panel overlaps the kept-open START menu box (start_sub_menus.asm:
-- 641-647), so neither can be docked to a screen edge on its own
holdsUIAnchors = true,
delay = 0,
update = function()
-- ld c, 30 / jp DelayFrames: the bare panel holds before the
-- prompt (main_menu.asm:404-405)
panel.delay = panel.delay + 1
if panel.delay == 30 then panel.openPrompt() end
end,
draw = function()
Font.drawBox(4, 0, 16, 10)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("PLAYER"), 5 * 8, 2 * 8)
Font.draw(game.save.player.name or "RED", 12 * 8, 2 * 8)
Font.draw(Strings("BADGES"), 5 * 8, 4 * 8)
Font.draw(("%2d"):format(badges), 17 * 8, 4 * 8)
Font.draw(Strings("POKéDEX"), 5 * 8, 6 * 8)
Font.draw(("%3d"):format(owned), 16 * 8, 6 * 8)
Font.draw(Strings("TIME"), 5 * 8, 8 * 8)
Font.draw(("%3d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60), 13 * 8, 8 * 8)
love.graphics.setColor(1, 1, 1, 1)
end,
}
local function closePanel()
if game.stack:top() == panel then game.stack:pop() end
-- SaveMenu returns into HoldTextDisplayOpen, not RedisplayStartMenu
-- (start_sub_menus.asm:645-647): the kept-open START menu goes too
if menu and game.stack:top() == menu then game.stack:pop() end
end
panel.openPrompt = function()
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
panel .. Strings("\fWould you like to\nSAVE the game?"), nil, { Strings("Would you like to\nSAVE the game?"), nil, {
-- SaveTheGame_YesOrNo pins its TWO_OPTION_MENU at hlcoord 0, 7 rather
-- than the shared right-hand one -- engine/menus/save.asm:186-192
choiceBox = Theme.saveBox,
choice = function(yes) choice = function(yes)
if not yes then return end if not yes then closePanel() return end
-- SaveMenu .save (engine/menus/save.asm:164-181): "Now saving..." -- SaveMenu .save (engine/menus/save.asm:164-181): "Now saving..."
-- is a bare PlaceString held by DelayFrames 120, then GameSavedText, -- is a bare PlaceString held by DelayFrames 120, then GameSavedText,
-- which ends in `done` and so never reaches TX_PROMPT_BUTTON. -- which ends in `done` and so never reaches TX_PROMPT_BUTTON.
@@ -78,7 +120,7 @@ function StartMenu.new(game)
game:writeSave() game:writeSave()
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
Strings("%s saved\nthe game!", game.save.player.name or "RED"), Strings("%s saved\nthe game!", game.save.player.name or "RED"),
nil, { auto = { closePanel, { auto = {
sound = function() sound = function()
return require("src.core.Sound").play(game.data, "Save") return require("src.core.Sound").play(game.data, "Save")
end, end,
@@ -87,6 +129,8 @@ function StartMenu.new(game)
end, { auto = { delay = 120 } })) end, { auto = { delay = 120 } }))
end, end,
})) }))
end
game.stack:push(panel)
end }) end })
table.insert(items, { label = Strings("OPTION"), onSelect = function() table.insert(items, { label = Strings("OPTION"), onSelect = function()
@@ -142,7 +186,7 @@ function StartMenu.new(game)
-- with Menu's moreArrow showing while there's more below. -- with Menu's moreArrow showing while there's more below.
local rowStep = 2 local rowStep = 2
local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep) local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep)
local menu = Menu.new(game, items, menu = Menu.new(game, items,
-- the START menu hugs the top-right corner of the SCREEN, not of a -- the START menu hugs the top-right corner of the SCREEN, not of a
-- centred letterbox: at 9,0 x 11 it is already flush with the top and -- centred letterbox: at 9,0 x 11 it is already flush with the top and
-- right of the 20x18 grid, so the anchor keeps it flush when the view -- right of the 20x18 grid, so the anchor keeps it flush when the view
+3
View File
@@ -23,6 +23,9 @@ local Theme = {
-- EnemySendOutFirstMon inlines its own TWO_OPTION_MENU at hlcoord 0, 7 -- EnemySendOutFirstMon inlines its own TWO_OPTION_MENU at hlcoord 0, 7
-- instead of the shared right-hand one -- engine/battle/core.asm:1378-1384 -- instead of the shared right-hand one -- engine/battle/core.asm:1378-1384
trainerSwitchBox = { tx = 0, ty = 7, tw = 6, th = 5 }, trainerSwitchBox = { tx = 0, ty = 7, tw = 6, th = 5 },
-- SaveTheGame_YesOrNo pins its TWO_OPTION_MENU at hlcoord 0, 7 too --
-- engine/menus/save.asm:186-192
saveBox = { tx = 0, ty = 7, tw = 6, th = 5 },
} }
function Theme.load(data) function Theme.load(data)
+107 -18
View File
@@ -43,6 +43,28 @@ local function withWhiteOf(pal, ref)
return { ref[1], pal[2], pal[3], pal[4] } return { ref[1], pal[2], pal[3], pal[4] }
end end
-- Every drawn box, not just the topmost state's: DisplayContinueGameInfo
-- leaves the menu box up behind the info window (main_menu.asm:36-39), so both
-- are on screen and both need the overlay below.
local function titleUiBoxes(game)
local stack = game and game.stack
local states = stack and stack.states
if not states then
local top = stack and stack.top and stack:top()
local box = top and top.titleUiBox
return box and { box } or {}
end
local boxes = {}
for i = (stack.visibleBase and stack:visibleBase() or 1), #states do
local state = states[i]
local shown = not stack.renderVisible or stack:renderVisible(state)
if shown and state and state.titleUiBox then
boxes[#boxes + 1] = state.titleUiBox
end
end
return boxes
end
function TitleState:sgbPalettes(game) function TitleState:sgbPalettes(game)
local P = require("src.render.PaletteFX") local P = require("src.render.PaletteFX")
local z local z
@@ -65,9 +87,6 @@ function TitleState:sgbPalettes(game)
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17), P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
} }
end end
local top = game.stack and game.stack:top()
local box = top and top.titleUiBox
if box then
-- A DMG-grays zone, not the trueColor opt-out: through the shade-remap -- A DMG-grays zone, not the trueColor opt-out: through the shade-remap
-- shader GRAYS is the identity for the box's four shades, so SGB / -- shader GRAYS is the identity for the box's four shades, so SGB /
-- ADVANCED / OG modes keep #133's white paper and black ink exactly, -- ADVANCED / OG modes keep #133's white paper and black ink exactly,
@@ -75,6 +94,7 @@ function TitleState:sgbPalettes(game)
-- modes -- a trueColor rect skipped the shader entirely, leaving the -- modes -- a trueColor rect skipped the shader entirely, leaving the
-- main menu and CONTINUE info box a raw white hole over a CLASSIC -- main menu and CONTINUE info box a raw white hole over a CLASSIC
-- pea-green title instead of matching it like the START menu does (#870). -- pea-green title instead of matching it like the START menu does (#870).
for _, box in ipairs(titleUiBoxes(game)) do
z[#z + 1] = P.zone(P.GRAYS, box[1], box[2], box[3], box[4]) z[#z + 1] = P.zone(P.GRAYS, box[1], box[2], box[3], box[4])
end end
return z[3] and z or nil return z[3] and z or nil
@@ -175,20 +195,21 @@ end
local function replayObjSprite(game, image, quad, x, y) local function replayObjSprite(game, image, quad, x, y)
local P = require("src.render.PaletteFX") local P = require("src.render.PaletteFX")
if not P.usesSpriteObp() then return end if not P.usesSpriteObp() then return end
local top = game.stack and game.stack:top() local boxes = titleUiBoxes(game)
local box = top and top.titleUiBox if boxes[1] then
if box then
local w, h local w, h
if quad then if quad then
w, h = select(3, quad:getViewport()) w, h = select(3, quad:getViewport())
else else
w, h = image:getDimensions() w, h = image:getDimensions()
end end
for _, box in ipairs(boxes) do
if x < (box[3] + 1) * 8 and x + w > box[1] * 8 if x < (box[3] + 1) * 8 and x + w > box[1] * 8
and y < (box[4] + 1) * 8 and y + h > box[2] * 8 then and y < (box[4] + 1) * 8 and y + h > box[2] * 8 then
return return
end end
end end
end
P.markUiSpriteRedraw(image, quad, x, y) P.markUiSpriteRedraw(image, quad, x, y)
end end
@@ -392,6 +413,17 @@ function TitleState:updateSequence()
self.phase = "loop" self.phase = "loop"
self.blinkTimer = 0 self.blinkTimer = 0
end end
elseif self.phase == "exitCry" then
-- .finishedWaiting: PlayCry then WaitForSoundToFinish before the
-- white-out (engine/movie/title.asm:241-243)
self.timer = self.timer + 1
local playing = self.exitCrySrc and self.exitCrySrc.isPlaying
and self.exitCrySrc:isPlaying()
if self.timer >= 3 and (not playing or self.timer > 180) then
self.exitCrySrc = nil
self.phase = "loop"
self:toMenu()
end
end end
end end
@@ -460,8 +492,8 @@ function ContinueInfo:update(dt)
self.game.stack:pop() self.game.stack:pop()
if self.title.onContinue then self.title.onContinue() end if self.title.onContinue then self.title.onContinue() end
elseif input:wasPressed("b") then elseif input:wasPressed("b") then
-- the CONTINUE / NEW GAME menu is still open underneath (main_menu.asm:91-92)
self.game.stack:pop() self.game.stack:pop()
self.title:openMenu()
end end
end end
@@ -497,7 +529,10 @@ function TitleState:openMenu()
local game = self.game local game = self.game
local items = {} local items = {}
if hasSave() then if hasSave() then
table.insert(items, { label = Strings("CONTINUE"), onSelect = function() -- DisplayContinueGameInfo leaves the menu box up behind the info window
-- (engine/menus/main_menu.asm:36-39, :91-92)
table.insert(items, { label = Strings("CONTINUE"), keepOpen = true,
onSelect = function()
-- peek at the save for the info window; fall through if the -- peek at the save for the info window; fall through if the
-- file can't be read -- file can't be read
local ok, loaded = pcall(require("src.core.SaveData").load) local ok, loaded = pcall(require("src.core.SaveData").load)
@@ -511,7 +546,13 @@ function TitleState:openMenu()
table.insert(items, { label = Strings("NEW GAME"), onSelect = function() table.insert(items, { label = Strings("NEW GAME"), onSelect = function()
if self.onNewGame then self.onNewGame() end if self.onNewGame then self.onNewGame() end
end }) end })
table.insert(items, { label = Strings("OPTION"), onSelect = function() -- DisplayOptionMenu returns to .mainMenuLoop, which redraws the box
-- (engine/menus/main_menu.asm ln 87-90)
local menu
table.insert(items, { label = Strings("OPTION"), keepOpen = true,
onSelect = function()
-- .mainMenuLoop re-zeroes wCurrentMenuItem on re-entry (main_menu.asm:56-57)
if menu then menu.index = 1 end
require("src.ui.Screens").push(game, "OptionsMenu") require("src.ui.Screens").push(game, "OptionsMenu")
end }) end })
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function() table.insert(items, { label = Strings("EXIT GAME"), onSelect = function()
@@ -529,7 +570,14 @@ function TitleState:openMenu()
type(hooked)) type(hooked))
end end
local th = #items * 2 + 2 local th = #items * 2 + 2
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th }) menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
-- .mainMenuLoop's B branch jumps back to DisplayTitleScreen, which opens
-- with GBPalWhiteOut and reruns the whole boot cinematic
-- (engine/menus/main_menu.asm:69-70, title.asm:29)
menu.onCancel = function()
game.stack:push(require("src.render.Transition").whiteFlash(game, nil,
function() self:restartSequence() end))
end
-- full-width title LOGO zones would recolor this box; see sgbPalettes. -- full-width title LOGO zones would recolor this box; see sgbPalettes.
-- Menu.new may have grown tw for longer (e.g. localized) labels, so the -- Menu.new may have grown tw for longer (e.g. localized) labels, so the
-- recolor zone follows the box's real width instead of the vanilla 13. -- recolor zone follows the box's real width instead of the vanilla 13.
@@ -537,6 +585,38 @@ function TitleState:openMenu()
game.stack:push(menu) game.stack:push(menu)
end end
-- .mainMenuLoop's B branch: DisplayTitleScreen from the top
-- (main_menu.asm:70, title.asm:39-222)
function TitleState:restartSequence()
self.menuOpen = false
pcall(Music.stop)
self.scy = 0x40
self.phase = "drop"
self.dropStep, self.dropLeft = 1, nil
self.showBubble = not self.yellowLayout
self.timer = 0
self.blinkTimer = 0
self.blinkAt = nil
self.cycleIndex = 1
self.scrollPhase = "hold"
self.scrollFrame = 1
self.monOffset = 0
self.ballY = BALL_REST
self.ribbonOffset = nil
self.whooshSrc, self.crySrc, self.exitCrySrc = nil, nil, nil
end
-- .finishedWaiting: GBPalWhiteOutWithDelay3 then ClearScreen before MainMenu,
-- which clears again itself (engine/movie/title.asm ln 243, main_menu.asm ln 26)
function TitleState:toMenu()
local game = self.game
game.stack:push(require("src.render.Transition").whiteFlash(game, nil,
function()
self.menuOpen = true
self:openMenu()
end))
end
-- ..(engine/movie/title.asm ln 271) -- ..(engine/movie/title.asm ln 271)
function TitleState:pickNewMon() function TitleState:pickNewMon()
if #self.cycleSpecies < 2 then return end if #self.cycleSpecies < 2 then return end
@@ -589,6 +669,11 @@ function TitleState:updateCycle()
end end
function TitleState:update(dt) function TitleState:update(dt)
-- an onSelect that handed control back without a new state (a failed
-- CONTINUE load, a mod row) re-runs DisplayTitleScreen (main_menu.asm:70)
if self.menuOpen and self.game.stack:top() == self then
self:restartSequence()
end
if self.phase ~= "loop" then if self.phase ~= "loop" then
self:updateSequence() self:updateSequence()
return return
@@ -599,10 +684,10 @@ function TitleState:update(dt)
if input:wasPressed("start") or input:wasPressed("a") then if input:wasPressed("start") or input:wasPressed("a") then
-- .go_to_main_menu voices PikachuCry11 on the way out -- .go_to_main_menu voices PikachuCry11 on the way out
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
if not Sound.playPikaCry(self.game.data, 11) then self.exitCrySrc = Sound.playPikaCry(self.game.data, 11)
Sound.playCry(self.game.data, "PIKACHU") or Sound.playCry(self.game.data, "PIKACHU")
end self.phase = "exitCry"
self:openMenu() self.timer = 0
end end
return return
end end
@@ -613,23 +698,27 @@ function TitleState:update(dt)
if input:wasPressed("start") or input:wasPressed("a") then if input:wasPressed("start") or input:wasPressed("a") then
-- the title mon cries when you leave the title (.finishedWaiting); -- the title mon cries when you leave the title (.finishedWaiting);
-- Yellow's fixed Pikachu title always cries Pikachu. -- Yellow's fixed Pikachu title always cries Pikachu.
require("src.core.Sound").playCry(self.game.data, self.exitCrySrc = require("src.core.Sound").playCry(self.game.data,
self.yellowLayout and "PIKACHU" self.yellowLayout and "PIKACHU"
or self.cycleSpecies[self.cycleIndex]) or self.cycleSpecies[self.cycleIndex])
self:openMenu() self.phase = "exitCry"
self.timer = 0
end end
end end
-- ..(engine/movie/title.asm ln 28) -- ..(engine/movie/title.asm ln 28)
function TitleState:draw() function TitleState:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
-- MainMenu's own ClearScreen wipes the logo, mon and sprites before the
-- CONTINUE / NEW GAME border is drawn (engine/menus/main_menu.asm ln 26)
if self.menuOpen then return end
local PaletteFX = require("src.render.PaletteFX") local PaletteFX = require("src.render.PaletteFX")
local playerImage = self.player local playerImage = self.player
if playerImage and PaletteFX.usesSpriteObp() then if playerImage and PaletteFX.usesSpriteObp() then
playerImage = require("src.render.SpriteRenderer").obpImage( playerImage = require("src.render.SpriteRenderer").obpImage(
self.playerPath, PaletteFX.ogObj()) self.playerPath, PaletteFX.ogObj())
end end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local scrollY = -(self.scy or 0) local scrollY = -(self.scy or 0)
-- ..(engine/movie/title.asm ln 28) -- ..(engine/movie/title.asm ln 28)
local preRibbon = not self.yellowLayout local preRibbon = not self.yellowLayout

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