Compare commits

...

116 Commits

Author SHA1 Message Date
bryanthaboi ac01135ca8 Merge pull request #615 from bryanthaboi/dev 2026-08-01 16:53:54 -04:00
bryanthaboi 5e89e35e02 Merge pull request #614 from castdrian/metal 2026-08-01 16:52:41 -04:00
bryanthaboi 7e7afeaf82 Mask the self-hosted runner's home path in release logs 2026-08-01 16:45:37 -04:00
Adrian Castro c6fa6d294b fix(ios): support LÖVE 12 file bridge 2026-08-01 22:22:49 +02:00
bryanthaboi fc2d17fc78 Merge pull request #612 from bryanthaboi/dev 2026-08-01 16:03:28 -04:00
bryanthaboi 9f072285a6 Merge pull request #611 from ShaneMcGovernIE/fix/oak-last-ball-601 2026-08-01 16:00:52 -04:00
bryanthaboi c58ebe4b9b Merge pull request #609 from castdrian/metal 2026-08-01 15:59:33 -04:00
Shane McGovern 0dd187fe30 Fix wrong dialogue on Prof. Oak's leftover ball (#601)
After the player picks a starter and the rival takes his, every ball on
the lab table fell through to 'Those are POKé BALLs' instead of the
leftover-mon line.  Port the pokered OaksLabSelectedPokeBallScript ->
OaksLabLastMonScript beat: with EVENT_GOT_STARTER set, Oak turns to face
the player and reads 'That's PROF.OAK's last Pokémon!'.  The ROM's
'#MON' ligature is spelled out as Pokémon.

The renumbering also drops the table's nine out-of-range 'jump 21'
rows (run-time 'end' idioms) for explicit "end" targets, so the
script now validates cleanly.

Tests: T2 suite drives the ball talk table through a ScriptRunner-
compatible executor (leftover text, pre-escort text, pre-pick offer)
plus a T3 driver that talks to the leftover ball in a real game.
2026-08-01 20:55:31 +01:00
Adrian Castro ea78792c03 fix(ios): build releases with LÖVE 12 2026-08-01 21:45:23 +02:00
bryanthaboi 9954225701 Merge pull request #603 from bryanthaboi/dev 2026-08-01 14:32:23 -04:00
bryanthaboi 6bb2e078c0 Scrub mod manifest strings to valid UTF-8
A manifest whose name, version, description, or category carries invalid
UTF-8 (a BOM, Latin-1 bytes) crashed the launcher's MODS panel, since
love.graphics.printf raises on invalid UTF-8. Manifest.validate now drops
invalid bytes and a leading BOM from those strings, in place so the
badge's raw.category read agrees.
2026-08-01 14:22:30 -04:00
bryanthaboi 323c59e54f Merge pull request #594 from bryanthaboi/dev 2026-08-01 13:03:22 -04:00
bryanthaboi d26d63ed38 Merge pull request #588 from techmore/fix/prof-oaks-pc-flow 2026-08-01 12:58:03 -04:00
bryanthaboi 2009df3dd1 CLOSES #575, CLOSES #578, CLOSES #584, CLOSES #589 2026-08-01 12:56:13 -04:00
techmore f0f3e9634b fix: restore PROF. OAK's PC intro, jingle timing and closing link
CLOSES #576

The launcher skipped oaks_pc.asm's whole session -- the access text and
the 'Want to get your #DEX rated?' YES/NO -- and played the Pokedex_Rating
jingle the moment the entry was picked, before any text printed.  Now the
access text types out, the YES/NO pops, and only once the completion line
and the rating tier have printed does the jingle sound (DisplayDexRating
-> PlayPokedexRatingSfx, auto.wait hands the box to the A/B path), then
the 'Closed link to PROF.OAK's PC.' tail closes the session.
2026-08-01 11:01:19 -04:00
bryanthaboi ca1beaefe7 Merge pull request #582 from castdrian/metal 2026-08-01 10:41:40 -04:00
bryanthaboi 24cf1b3317 Merge pull request #583 from BartInTheField/fix-encounter-slide-577 2026-08-01 10:41:25 -04:00
Bart in 't Veld 88e2ec2042 Fix encounter silhouette slide speed and blackout (#577)
The battle intro slide ran at 4px/frame over 40 frames -- twice the
original speed -- and drew the pics in their normal palette instead of
as black silhouettes.

SlidePlayerAndEnemySilhouettesOnScreen scrolls SCX from $90 to 0 at
2px/frame (~72 frames) under the %11100100 silhouette palette, only
running SET_PAL_BATTLE once the pics land. Match that: introSlide starts
at 80 with a 2px/frame offset (80 frames over the full 160px width), and
picImage bakes both pics through PAL_BLACK while introSlide > 0, exactly
like the evolution movie (#279).

parity_battle_intro_chrome waited a hardcoded 45 frames for the slide to
land; bumped to 85 to cover the slower slide.
2026-08-01 15:25:54 +02:00
Adrian Castro be60c7c1d5 fix(ios): present voxel pipeline canvases 2026-08-01 14:37:18 +02:00
bryanthaboi 9be21380c7 Merge pull request #581 from bryanthaboi/dev
bug smashing on a saturday
2026-08-01 08:14:38 -04:00
bryanthaboi 22fd196045 Merge pull request #579 from castdrian/metal
feat: metal support and make ios usable
2026-08-01 08:13:34 -04:00
bryanthaboi d5293dffb8 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-01 08:10:22 -04:00
bryanthaboi 9326b07583 CLOSES #415, CLOSES #484, CLOSES #488, CLOSES #492, CLOSES #497, CLOSES #541, CLOSES #559, CLOSES #562, CLOSES #563, CLOSES #564, CLOSES #565, CLOSES #566, CLOSES #567, CLOSES #568, CLOSES #569, CLOSES #570, CLOSES #571, CLOSES #572 2026-08-01 08:10:20 -04:00
Adrian Castro 90437a5d8d fix(ios): export saves from Love directory 2026-08-01 13:07:36 +02:00
Adrian Castro c4ec5dc1c5 test(ios): cover save export picker 2026-08-01 12:58:42 +02:00
bryanthaboi 3b19826f1e Merge pull request #543 from BartInTheField/render-second-screen-seam 2026-08-01 06:56:38 -04:00
Adrian Castro 95e4715bc3 fix(ios): declare Love 12 compatibility 2026-08-01 12:54:05 +02:00
Adrian Castro 81edc97d86 fix(ios): package updater boot module 2026-08-01 12:44:26 +02:00
Adrian Castro 92c8bac714 fix(ios): support direct picker results 2026-08-01 12:36:18 +02:00
Adrian Castro 1df04aa18d fix(ios): route native picker results 2026-08-01 12:32:22 +02:00
Bart in 't Veld 218b9249a2 render: add render.compose seam + second-screen bridge
Expose a generic seam so a mod can drive a second screen without the
engine owning any dual-screen layout policy:

- render.compose hook in Renderer:endFrame hands a mod the finished
  world + UI canvases, their SGB zones, the frame metrics,
  Renderer:blitCanvas (lifted from the internal blit closure) and the
  SecondScreen bridge. Return true to take over the window; no wrap (or
  calling next) runs the normal single-window composite byte-for-byte.
- SecondScreen.lua + the Android Presentation bridge (love_android_
  secondary_* in common/android.cpp, GameActivity secondary display)
  as the optional physical-second-display transport.

No battle-render changes: a mod lays out the two screens (including any
battle split) itself. Ships with a unit test, no-mod parity via
gate_hooks, and docs/modding.md (D14).
2026-08-01 09:35:24 +02:00
bryanthaboi 50947eec2a Merge pull request #489 from mresnick67/android-step-bridge 2026-08-01 00:21:40 -04:00
bryanthaboi 2624bb0210 Merge pull request #537 from sirj0k3r/hook/exp 2026-08-01 00:21:20 -04:00
bryanthaboi 6e9685f302 Merge pull request #538 from sirj0k3r/hook/low-health 2026-08-01 00:21:10 -04:00
bryanthaboi 898bf0c71e Merge pull request #560 from bryanthaboi/dev 2026-07-31 23:55:26 -04:00
bryanthaboi 5a7add8eaa Merge pull request #554 from andrewqsantos/feature/mobile-safe-area 2026-07-31 23:54:11 -04:00
bryanthaboi 66334dfacd Merge pull request #558 from erereck/fix/mod-configurable-bag-capacity 2026-07-31 23:53:54 -04:00
bryanthaboi a33f3b1ceb mobile fixes 2026-07-31 23:47:28 -04:00
erereck 6e724cedf7 Honor modded bag capacity 2026-08-01 00:32:00 -03:00
bryanthaboi e949c78639 Merge pull request #556 from bryanthaboi/dev 2026-07-31 23:03:09 -04:00
bryanthaboi 9bcfdb1f0d mobile fixes 2026-07-31 23:01:25 -04:00
Andrew Quenehen fccb122c59 Respect iOS/Android safe areas in launcher and touch chrome.
Layout interactive UI against love.window.getSafeArea so notch, Dynamic Island, and home-indicator insets no longer clip controls, while keeping the game framebuffer edge-to-edge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 23:53:26 -03:00
bryanthaboi e2b2a5fb9f Merge pull request #555 from bryanthaboi/dev 2026-07-31 22:41:53 -04:00
bryanthaboi f2a3e5f05b mobile updates CLOSES #482, CLOSES #553 2026-07-31 22:39:01 -04:00
bryanthaboi 5f855568c8 Merge pull request #539 from hernan0078/ios-support 2026-07-31 22:16:24 -04:00
bryanthaboi 24696e3be2 Merge pull request #524 from kaosregulator/claude/multi-game-low-end-support-o16gz5
Claude/multi game low end support  and some fixes o16gz5
2026-07-31 20:26:09 -04:00
bryanthaboi b17dce232e Merge pull request #547 from bryanthaboi/dev
bug squashing and what not
2026-07-31 20:21:37 -04:00
bryanthaboi e1b20723fd Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-07-31 20:20:38 -04:00
bryanthaboi fdab15a6dc CLOSES #503, CLOSES #511, CLOSES #515, CLOSES #518, CLOSES #522, CLOSES #523, CLOSES #525, CLOSES #528, CLOSES #529, CLOSES #533, CLOSES #535, CLOSES #536 2026-07-31 20:20:37 -04:00
sirj0k3r 33899c237e + Implemented tests for battle.low_health_alarm hook 2026-07-31 22:17:19 +01:00
sirj0k3r 0fad222569 + Implemented tests for battle.catch_exp and battle.exp_award hooks 2026-07-31 22:10:26 +01:00
hernan0078 9c5f33e187 Fix #482: guard love.system.pickFile so Import ROM cannot crash
Pressing Import ROM on iOS takes the whole app down:

  src/import/RomImporter.lua: attempt to call field 'pickFile' (a nil value)

love.system.pickFile is a NATIVE BRIDGE, not part of LOVE. It exists only on
builds that compiled one -- Android, and iOS builds patched by
mobile/ios/patch_love_src.py -- so on a build without it the field is simply
nil. RomImporter:546 routes iOS down the same path as Android
(`mobileOS == "Android" or mobileOS == "iOS"`), and all three mobile pick
sites called the field unguarded.

That is why the reports say "any version": nothing about it is version
specific. Red, Blue and Yellow all reach the same call.

Every one of those call sites already handles a device with no document
picker -- Choose falls back to "No picker available, copy your ROM into:"
plus the save directory, and the mod / save rows have their own notices --
and love.system.createFile at its single call site was already guarded this
way. These three were not, so the fallback that was written for exactly this
case could never be reached.

Route them through one small helper that answers false when the bridge is
absent. A build without a picker now degrades to the copy-into-the-save-folder
flow, which on iOS is a working path: the Files app exposes the app's
Documents folder and GRBootstrap sweeps what lands there into the save dir.

tests/rom_importer_no_picker_test.lua covers Import ROM, Import mod and
Import save with the bridge missing, and asserts the picker is still used
when it is present. Reverting the fix reproduces the reported error exactly.

Reported in #482 (confirmed by three people) and #512.
2026-07-31 16:38:15 -04:00
sirj0k3r ddcce5b95a + Implemented Exp hook 2026-07-31 21:14:23 +01:00
sirj0k3r 6fd9741fab + Implemented Low Health Alert hook for modding 2026-07-31 21:12:21 +01:00
bryanthaboi 4916fc8f4b Merge pull request #532 from TitaniteScale/symlink-mod-support
Recognize symlinked mod dirs on Linux (mods/ dev workflow)
2026-07-31 15:22:48 -04:00
Jake Eaker fc19e58678 added symlink support for mods 2026-07-31 13:47:53 -04:00
bryanthaboi 9610bcfb72 Merge pull request #527 from bryanthaboi/dev
bug fixes and new features
2026-07-31 12:04:18 -04:00
bryanthaboi 1bc252741a rom finder 2026-07-31 11:58:21 -04:00
Myles Resnick caa65182dc Android: hardware step-counter bridge for step-sync mods
love.system.syncHealthSteps() now exists on Android, matching the iOS
Health bridge merged in #452 and using the same JNI route as the SAF
picker (wrap_System.cpp -> System.cpp -> common/android.cpp ->
GameActivity over JNI):

- GameActivity.syncHealthSteps: one-shot read of the hardware
  TYPE_STEP_COUNTER sensor (cumulative since boot, counted by the OS
  whether or not any app runs). The reading is anchored in
  SharedPreferences so a walk is never credited twice; a reading below
  the anchor means the phone rebooted, which re-anchors without
  crediting. Deltas (50k clamp) merge into steps_pending.json in the
  save identity dir - the same contract as the iOS GRHealthBridge, so
  the Pokewalker mod works unchanged on both platforms.
- ACTIVITY_RECOGNITION declared in the app manifest (Android 10+
  runtime prompt on first sync; granted -> the sensor read runs
  immediately via onRequestPermissionsResult). The build script's
  permission trim leaves it alone.
- Nothing in the base game calls the new seam; without a consumer mod
  the only cost is one dormant manifest permission.
- build_android.sh: shadow-build from a space-free temp dir when the
  checkout path contains spaces - ndk-build is GNU make underneath and
  cannot cope with paths like "xCode Projects".
- mobile/ANDROID.md: step-bridge dev notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 11:11:54 -04:00
bryanthaboi 97e3fb296c Update tool_mod_hooks.lua 2026-07-31 10:45:12 -04:00
Claude 74eeb411ba Add luacheck config + lint script
Introduce a tuned .luacheckrc and scripts/lint.sh so the engine has a
standing static-analysis baseline -- the tool that would have caught both
bugs in the previous commit before they shipped.

The config is high-signal by design: it keeps the categories that catch
real defects (undefined globals/locals, unused values, unreachable code)
and mutes the cosmetic ones the codebase deliberately lives with (a self/dt
an interface requires but a method ignores, documented empty fall-through
branches, long lines). It marks `love` mutable (games assign callbacks onto
it) and teaches it LuaJIT's table.unpack.

.luacheckrc is tracked via a .gitignore exception, matching how .github and
.gitignore opt out of the blanket dotfile ignore.

`luacheck src` now reports 7 benign warnings and 0 errors, down from 185.
Also drop one dead `require` (ItemEffects loaded src.pokemon.Pokemon and
never used it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
2026-07-31 14:35:40 +00:00
Claude 040ca3f332 Fix two latent bugs surfaced by static analysis
Both are code paths that never run in a green test today but crash or
misbehave the moment a mod or a link failure exercises them.

1. Music.lua: applyVolume built its `music.volume` hook context from the
   private `state` table, but was defined *above* `local state = {...}`, so
   those reads bound to the nil global `state`. Any mod registering the
   music.volume hook crashed with "attempt to index a nil value (global
   'state')" the first time a volume was applied. Forward-declare `state`
   above applyVolume. Regression test drives a file-backed song through the
   hook and asserts the context resolves.

2. Tournament.lua: `local battle, why = isHost and newHost() or newGuest()`
   had two defects. The and/or idiom truncates a call to its first result,
   so `why` (the specific failure reason) was always dropped and every link
   failure showed the generic "Link battle can't start" instead of e.g.
   "same mods on both games". Worse, when a host's newHost() returned nil,
   the `or` fell through and wrongly called newGuest() as the host. Split
   into an explicit if/else so the reason is preserved and each role calls
   its own constructor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
2026-07-31 14:34:58 +00:00
bryanthaboi 5ec440792e Merge pull request #347 from kevindjacobson/agent/tool-mod-hooks
Add lifecycle and HUD hooks for tool mods
2026-07-31 10:29:04 -04:00
bryanthaboi 9d3ee6a597 mod manager now supports auto updates 2026-07-31 10:19:51 -04:00
bryanthaboi 6c4c9d14e2 Merge pull request #520 from johnjohto/fix-true-color-evolution-trade-494
Fix true-color evolution and trade sprites
2026-07-31 10:18:44 -04:00
bryanthaboi d165189032 Merge pull request #521 from johnjohto/fix-fighting-dojo-master-gate-495
Fix Karate Master gate
2026-07-31 10:08:02 -04:00
johnjohto 84bf25fcd5 Fix Karate Master gate 2026-07-31 09:47:53 -04:00
bryanthaboi 3945b9d078 on screen controller editing CLOSES #327 2026-07-31 09:23:38 -04:00
bryanthaboi 6796804593 CLOSES #505, CLOSES #510, CLOSES #513 2026-07-31 09:08:56 -04:00
johnjohto 858c5bc9b8 Fix true-color evolution and trade sprites 2026-07-31 09:05:19 -04:00
bryanthaboi 71bdbb7e48 CLOSES #519 2026-07-31 08:27:29 -04:00
bryanthaboi c346c78697 Merge pull request #499 from johnjohto/fix-battle-menu-cursor
Fix battle menu cursor edges
2026-07-31 08:14:55 -04:00
bryanthaboi ba2bac5bed Merge branch 'dev' into fix-battle-menu-cursor 2026-07-31 08:14:08 -04:00
bryanthaboi bcfc94d448 Merge pull request #366 from pmarcus93/android-respect-rotation-lock
Respect the device rotation lock on Android
2026-07-31 08:08:19 -04:00
bryanthaboi bebfc87cd4 yellow palette / pikachu yellow 2026-07-31 08:07:23 -04:00
Claude c8e035d332 Add graphics performance tier for low-end devices
Introduce an OPTIONS -> PERFORMANCE setting that scales the port's
optional presentation extras down for weaker hardware, so older/lower-end
devices can run the game smoothly.

The tier governs the three heaviest non-faithful extras -- the 3D TILT,
the GBC FX post-process shader, and survey ZOOM (which renders connected
neighbor maps) -- plus a hard FPS ceiling. It never touches game logic,
which is fixed-step off dt, so every tier plays identically.

- src/core/Performance.lua: tiers (auto/high/balanced/low), a conservative
  device auto-detect (ARM handhelds -> low, phones -> balanced, normal
  desktops -> high), per-tier caps, and the option-row cycle. Zero
  requires, like GameVersion.
- Game:applyOptions clamps the *live* presentation state against the tier
  without rewriting stored options, so a lower tier hides the player's
  TILT/GBC FX/ZOOM/FPS choices and a higher tier restores them exactly.
- Zoom.offsetRange floors the range at FIT when survey is disallowed, so
  the option row, hotkey, and mouse wheel all stop at close-up on LOW.
- New save.options.performance default "auto"; OPTIONS row heads the
  display group and re-applies live.
- Tests: tests/engine/performance_tiers.lua (ROM-free); mod_ui_tests row
  golden updated for the spliced row.

AUTO resolves to HIGH on a normal desktop and on every options.lua that
predates the option, so the common case is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
2026-07-31 01:44:17 +00:00
bryanthaboi e58929428c Merge pull request #498 from johnjohto/fix-cerulean-badge-house-exit 2026-07-30 18:24:26 -04:00
johnjohto a389416b7b Fix battle menu cursor edges
Battle commands toggled directly between rows and columns. An outward press therefore moved the cursor to the opposite command instead of leaving it at the edge.
2026-07-30 17:53:53 -04:00
johnjohto 0a8bca1a6d Fix Cerulean badge menu exit
The selected badge list stayed on the stack while its description opened. Cancelling the next list only closed that newer menu, leaving the NPC conversation open.
2026-07-30 17:49:02 -04:00
bryanthaboi efad8b325b Merge pull request #490 from bryanthaboi/dev
ios and some bugs
2026-07-30 16:12:24 -04:00
bryanthaboi 4c5f776128 Update README.md 2026-07-30 16:04:34 -04:00
bryanthaboi c1495e0d5e ios 2026-07-30 16:03:35 -04:00
bryanthaboi a97168a4f0 Update ios-install.md 2026-07-30 15:53:49 -04:00
bryanthaboi a50ad30176 Merge pull request #464 from mresnick67/pokewalker-0.2.0-followup
iOS: stop fusing mods; Pokewalker moves to its own repo
2026-07-30 15:51:55 -04:00
bryanthaboi c1f65106de Merge pull request #457 from johnjohto/fix-trainer-evolution-order
Delay evolution until trainer text ends
2026-07-30 14:56:24 -04:00
johnjohto 4250e3aa32 Delay evolution until trainer text ends 2026-07-30 14:03:36 -04:00
bryanthaboi cecb24bb0e Merge pull request #480 from bryanthaboi/dev
johnny boys fixes
2026-07-30 13:55:19 -04:00
bryanthaboi b99b497893 Merge pull request #479 from johnjohto/fix-save-editor-items
Fix save editor item crash on Android
2026-07-30 13:53:24 -04:00
johnjohto e16a45d6ba Avoid invalid save editor clips
On compact Android viewports the item picker can end up with no list
space. Passing its negative height to setScissor crashes LÖVE.
Use an empty clip region instead.
2026-07-30 13:28:18 -04:00
bryanthaboi 0e29046c31 Merge pull request #458 from johnjohto/fix-yellow-bills-pikachu
Add Bill's House Pikachu scenes
2026-07-30 13:02:50 -04:00
johnjohto 5cbada493a Add Bill's House Pikachu scenes 2026-07-30 12:52:17 -04:00
bryanthaboi 4c79db3d5d Merge pull request #472 from bryanthaboi/dev
stupid ass android
2026-07-30 12:28:57 -04:00
bryanthaboi c7e46b6563 stupid ass android 2026-07-30 12:26:27 -04:00
Myles Resnick fe1714d50e iOS: stop fusing mods; Pokewalker moves to its own repo
Follow-up to #452, from dogfooding the merged build on a real iPhone.

A mod fused into game.love sits in the read-only app bundle: the mod
manager's Delete can't remove it and it reappears every launch, and an
imported newer version can never fully replace it. iOS now packs no
mods, matching every other platform - mods install as .zips at runtime
with a real install/upgrade/delete lifecycle.

With nothing fusing it, mods/pokewalker moves out of this tree to its
canonical home, https://github.com/mresnick67/Gen1ReComp-Pokewalker
(already at 0.2.0 there with move learning and a paged credit dialog;
in-tree 0.1.0 was stale). docs/ios-install.md points at it. The
event:save.loaded coverage-debt entry returns to gate_meta_coverage:
its coverage came from the mod's test suite and leaves with it.

Local reproduction of the ci workflow on this branch: all tiers, mod
lint, fingerprint + meta-coverage gates green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 11:44:16 -04:00
bryanthaboi 2765e3d145 Merge pull request #463 from bryanthaboi/dev
big bug squash party
2026-07-30 11:40:02 -04:00
bryanthaboi c62f1334bc merge main into dev 2026-07-30 11:35:57 -04:00
bryanthaboi eea0dec4a6 Merge pull request #459 from johnjohto/fix-generic-joystick-input
Handle generic joystick input
2026-07-30 11:27:41 -04:00
bryanthaboi 0b55ab083a Merge pull request #452 from mresnick67/ios-native-bridge
iOS: native document-picker + Apple Health bridges, working device builds
2026-07-30 11:26:16 -04:00
bryanthaboi 6b9d4f2470 Merge pull request #450 from RetroRulez/feat/i18n-enabling
i18n: make more UI reachable for translation mods (Strings wraps, menu box auto-size, optional metric dex)
2026-07-30 11:24:59 -04:00
bryanthaboi 8a8340ffc8 Merge pull request #461 from bryanthaboi/bug-squashing-jul-30
Bug squashing jul 30
2026-07-30 11:23:08 -04:00
bryanthaboi 0af69e2df6 pikachu volume 2026-07-30 11:20:21 -04:00
bryanthaboi 4b9a326f6b CLOSES #339, CLOSES #354, CLOSES #360, CLOSES #372, CLOSES #373, CLOSES #374, CLOSES #375, CLOSES #378, CLOSES #379, CLOSES #383, CLOSES #384, CLOSES #385, CLOSES #391, CLOSES #392, CLOSES #393, CLOSES #394, CLOSES #395, CLOSES #396, CLOSES #397, CLOSES #398, CLOSES #413, CLOSES #420, CLOSES #423, CLOSES #424, CLOSES #425, CLOSES #426, CLOSES #427, CLOSES #429, CLOSES #430, CLOSES #431, CLOSES #433, CLOSES #435, CLOSES #436, CLOSES #438, CLOSES #439, CLOSES #441, CLOSES #442, CLOSES #444 2026-07-30 11:14:51 -04:00
Myles Resnick a92761dc13 Make the Pokewalker suite CI-runnable (fixture dataset) + pay save.loaded debt
T4 auto-runs shipped-mod suites ROM-free: the test now uses
T.fixtures.fresh() (FIXMON_A) with the level assertion derived from the
growth curve, and the now-covered event:save.loaded DEBT entry is
removed as gate_meta_coverage instructs. All four CI jobs reproduced
green on a ROM-free checkout.
2026-07-30 10:46:34 -04:00
johnjohto 8a649e8d95 Handle generic joystick input
Unmapped handheld controllers emit joystick events, leaving the launcher
cursor and in-game input inactive.
2026-07-30 10:38:24 -04:00
Cyberboy 5c5c970e81 i18n: draw naming-grid cells through Strings()
The name-entry keyboard drew each cell literally, so the case-switch labels
("lower case" / "UPPER CASE") and the "ED" confirm cell could not be
localized. Wrapping the draw in Strings() makes them translatable; the grid
data keeps the original strings, so the case-switch detection is unaffected.
Behavior-neutral for the base game (Strings identity without a catalog).
2026-07-30 16:35:03 +02:00
Myles Resnick 902f0d73d4 iOS: native document-picker + Apple Health bridges, working device builds
Makes the iOS build a first-class citizen: ROM/mod/save import through
the system document picker (the README's missing "UIDocumentPicker
handoff"), Files-app drop-in support, and an opt-in Apple Health
step-sync seam consumed by a new gallery mod (Pokewalker).

Native layer (mobile/ios/native/, wired by mobile/ios/patch_love_src.py
on every build, so the fetched love-src tree stays pristine + re-patchable):
- GRPickerBridge.swift: love.system.pickFile("rom"|"mod"|"sav") and
  love.system.createFile on iOS with the same contract as love-android's
  SAF picker (picked_rom.gb / picked_mod.zip / picked_save.sav /
  export_done.flag in the save dir). Reached from wrap_System.cpp via the
  ObjC runtime, so liblove needs no Swift interop.
- GRBootstrap.m: sweeps .gb/.gbc/.zip/.sav dropped in Documents (Files
  app / Finder sharing) into the save dir on every activation;
  UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace in the plist
  overlay. Drop a ROM, open the app, it imports with zero taps.
- GRHealthBridge.swift: love.system.syncHealthSteps() -> read-only
  HealthKit step query anchored to the last sync, delivered as
  steps_pending.json (merge-not-overwrite). HealthKit entitlement +
  usage description included.

Lua:
- RomImporter: iOS rides the Android mobile flows; a 0.5s poll consumes
  picker deliveries (iOS pickers are in-process modals, so the Android
  refocus rescan never fires); failed pick copies surface as an
  on-screen notice via pick_error.txt.
- main.lua: on iOS, stop forwarding touchpressed to the Importer - LOVE
  already synthesizes a mousepressed for the primary touch, and the
  resulting same-frame double-present made the document picker
  auto-dismiss with zero documents (silent import failure).
- mods/pokewalker: opt-in Pokewalker mod (manifest v2, MECHANIC,
  permissions declared, mod.card, CHANGELOG, headless test suite 9/9,
  modkit validate --base imported + lint clean). Fused into iOS
  game.love only; loads dormant anywhere without the bridge.

Build (scripts/build_ios.sh):
- Fix Xcode 26: the global PRODUCT_NAME override also renamed liblove.a
  and broke the app link; the app bundle is renamed after the build
  instead.
- Fix nondeterministic pack failures: grep -q + pipefail races SIGPIPE
  on the game.love content checks.
- Simulator builds sign ad-hoc so entitlements embed (HealthKit works in
  the simulator).
- Device builds: signing team auto-detected from the keychain,
  CODE_SIGN_STYLE=Automatic + -allowProvisioningUpdates for CLI-only
  provisioning, per-team derived bundle ID (explicit App IDs are
  globally unique, so third parties can't sign the project default),
  gitignored mobile/ios/bundle_id.local pin, and --install to push to a
  connected iPhone.
- docs/ios-install.md: a zero-knowledge walkthrough from bare Mac to
  playing on an iPhone.

Backward compatibility: no behavior change on desktop or Android. The
new love.system functions exist only under LOVE_IOS; RomImporter's
mobile flag simply includes iOS alongside Android; the main.lua change
is iOS-gated; the Pokewalker mod is packed only by the iOS build script
and its option defaults off.

Verified on an iPhone 17 Pro simulator and an iPhone 16 Pro device:
scripted ROM import to title screen, Files-drop zero-tap import,
picker-driven mod install and save import/export, HealthKit permission
sheet + step credit (4000 steps -> +200 EXP at the default rate through
the engine growth curve).
2026-07-30 09:46:48 -04:00
Cyberboy f6ae967796 i18n: route SummaryMenu stat labels through Strings()
The ATTACK/DEFENSE/SPEED/SPECIAL labels on the status screen were drawn
literally; wrap them like the other UI so a language mod can translate them.
Behavior-neutral for the base game (Strings identity without a catalog).
2026-07-30 15:37:10 +02:00
bryanthaboi 29afbdaec7 Update README with additional media badges
Added new badges for Digital Foundry, Android Authority, and XDA Developers to the README.
2026-07-30 09:08:58 -04:00
Cyberboy 350402b179 i18n: route hardcoded UI strings through Strings(), auto-size menu boxes, optional metric dex fields
Makes several UI spots reachable for translation mods without forking the
engine. All behavior-neutral for the base game: Strings(x) is the identity
function when no catalog is loaded, and the metric dex fields are opt-in.

- ListMenu: draw the title via Strings() (covers bag/PC/box/fly/move lists)
- Menu / (see follow-up ChoiceBox): grow the box to the widest label so longer
  localized labels don't overflow the frame
- OptionsMenu: wrap toggle values (ON/OFF, SET/SHIFT, WIDE/OG, text speed) and
  the ruleset display name in Strings()
- Schemas: add optional dexEntry.heightM / weightKg
- DexEntryMenu: render metric height/weight when those fields are present,
  otherwise unchanged (ft/in + lb)
2026-07-30 15:03:35 +02:00
bryanthaboi 0f581e2f69 so many bugs i cannot even breathe 2026-07-30 07:41:41 -04:00
bryanthaboi e63fa70700 Update README.md 2026-07-29 21:22:25 -04:00
bryanthaboi 27fb6eedc6 Change update video link and thumbnail
Updated the YouTube video link and thumbnail in the README.
2026-07-29 16:48:54 -04:00
Marcus Pereira cd33858020 Respect the device rotation lock on Android
The manifest asks for android:screenOrientation="fullUser", but SDL
overrides it at window creation: SDLActivity.setOrientationBis, given a
resizable window and no SDL_HINT_ORIENTATIONS -- which is what conf.lua
produces on Android -- requests SCREEN_ORIENTATION_FULL_SENSOR. The
*_SENSOR constants follow the accelerometer even when the player has
turned auto-rotate off, so the game rotated anyway on a locked device.

Override setOrientationBis in GameActivity to remap SDL's request onto
the matching *_USER constant after super has run. The same orientations
stay allowed and SDL keeps deciding which ones those are; only the
tie-break changes, from the sensor to the system rotation setting.
2026-07-28 23:22:35 -03:00
Kevin Jacobson fb58fa788b Expose window margins to tool HUDs 2026-07-28 14:09:46 -07:00
Kevin Jacobson d09cf80da5 Add persistent HUD hook for tool mods 2026-07-28 13:58:42 -07:00
Kevin Jacobson aab980b05a Add lifecycle hooks for tool mods 2026-07-28 13:33:53 -07:00
292 changed files with 32531 additions and 1537 deletions
+1
View File
@@ -40,6 +40,7 @@ body:
- Windows
- Linux
- Android
- iOS
- Multiple platforms
validations:
required: true
+28 -3
View File
@@ -1,8 +1,8 @@
name: Release
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the
# Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted
# Mac runner, and publishes them as a GitHub Release.
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
# IPA, and the Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on
# the self-hosted Mac runner, and publishes them as a GitHub Release.
#
# Versioning:
# - First ever release is 0.1.0.
@@ -45,6 +45,12 @@ jobs:
runs-on: [self-hosted, macOS]
steps:
# The self-hosted runner lives under the machine owner's home
# directory; mask it first so absolute paths in every later step's
# output show up as *** in the public workflow logs.
- name: Mask runner paths
run: echo "::add-mask::$HOME"
- name: Checkout
uses: actions/checkout@v4
with:
@@ -170,6 +176,20 @@ jobs:
set -euo pipefail
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
- name: Install xcbeautify
run: |
set -euo pipefail
brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
- name: Build iOS
run: |
set -euo pipefail
# Device Release IPA; signs with the Apple Development identity on
# the runner (auto team detection). Users on other Apple IDs still
# re-sign or build via docs/ios-install.md.
scripts/build_ios.sh --fetch --device --release \
--version "${{ steps.ver.outputs.version }}"
- name: Build Anbernic RG34XXSP port
run: |
set -euo pipefail
@@ -226,6 +246,10 @@ jobs:
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
ipa="dist/ios/gen1recomp.ipa"
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa"
# Anbernic handheld port (suffix names the CFW it targets, so a
# future RG35XX/other-CFW pack can ship alongside it).
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
@@ -344,6 +368,7 @@ jobs:
"dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk" \
"dist/release/gen1recomp-${v}-ios.ipa" \
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
"dist/release/gen1recomp-${v}.love" \
"dist/release/sha256sums.txt"
+4
View File
@@ -16,6 +16,7 @@ __pycache__/
.*
!.github/
!.gitignore
!.luacheckrc
# Android build outputs / local SDK path / packaged payload (keep love-android sources)
mobile/android/app/build/
@@ -39,3 +40,6 @@ mobile/dist/
# Tiled map-editing workspace (tools/tiled_export.py). Derived from the imported
# ROM cache exactly like data/generated/, so it is never committable.
/build/tiled/
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
mobile/ios/bundle_id.local
+43
View File
@@ -0,0 +1,43 @@
-- Static-analysis config for `luacheck` (https://luacheck.readthedocs.io).
--
-- Run it over the engine with: luacheck src (or scripts/lint.sh)
--
-- The point is a high-signal baseline: the categories left on are the ones
-- that catch real defects -- undefined globals/locals (the class that hid a
-- `music.volume` crash: applyVolume read a `state` that was still the nil
-- global), unused values, unreachable code, redefinitions. The cosmetic
-- categories the codebase deliberately lives with (a `self`/`dt` an
-- interface requires but a given method ignores, documented empty
-- fall-through branches, the odd long line) are muted so they don't drown
-- the signal.
std = "luajit"
-- LÖVE exposes `love` as a mutable table: games assign their callbacks onto
-- it (love.wheelmoved, love.run, ...), so it is a regular global, not
-- read-only -- otherwise every callback registration reads as a violation.
globals = { "love" }
read_globals = {
"jit",
-- 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.
table = { fields = { "unpack" } },
}
-- Vendored/native trees and the test suites have their own conventions.
exclude_files = {
"mobile/",
"tests/",
"tools/save-editor/",
}
ignore = {
"212", -- unused argument -- self/dt kept for a shared method signature
"213", -- unused loop variable -- `for _, v in` where only v is wanted
"421", -- shadowing a local -- deliberate re-use in a few tight scopes
"431", -- shadowing an upvalue
"432", -- shadowing an argument
"542", -- empty if branch -- documented fall-throughs, not gaps
"631", -- line is too long
}
+64 -14
View File
@@ -8,36 +8,68 @@ supplied by the player.
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
<p align="center"> <a href="https://www.polygon.com/pokemon-red-blue-3d-voxel-mod-battle-pixels-gameplay-footage-remake/"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-POLYGON-ea2e49?style=for-the-badge" alt="As seen on Polygon"> </a> </p>
<p align="center"> <a href="https://kotaku.com/pokemon-red-blue-recompilation-project-voxel-3d-mod-2000720281"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-KOTAKU-ea2e49?style=for-the-badge" alt="As seen on KOTAKU"> </a> </p>
<p align="center">
<a href="https://www.youtube.com/@bryanthaboi">
<img src="https://img.shields.io/badge/YouTube-FF0000?style=for-the-badge&logo=youtube&logoColor=white" alt="YouTube">
</a>
<a href="https://www.tiktok.com/@bryanthaboi">
<img src="https://img.shields.io/badge/TikTok-000000?style=for-the-badge&logo=tiktok&logoColor=white" alt="TikTok">
</a>
<a href="https://x.com/bryanthaboi">
<img src="https://img.shields.io/badge/X-000000?style=for-the-badge&logo=x&logoColor=white" alt="X">
</a>
<a href="https://bsky.app/profile/bryanthaboi.live">
<img src="https://img.shields.io/badge/Bluesky-0285FF?style=for-the-badge&logo=bluesky&logoColor=white" alt="Bluesky">
</a>
<a href="https://www.instagram.com/bryanthaboi">
<img src="https://img.shields.io/badge/Instagram-E4405F?style=for-the-badge&logo=instagram&logoColor=white" alt="Instagram">
</a>
</p>
<p align="center"> <a href="https://www.polygon.com/pokemon-red-blue-3d-voxel-mod-battle-pixels-gameplay-footage-remake/"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-POLYGON-ea2e49?style=for-the-badge" alt="As seen on Polygon"> </a>
<a href="https://kotaku.com/pokemon-red-blue-recompilation-project-voxel-3d-mod-2000720281"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-KOTAKU-ea2e49?style=for-the-badge" alt="As seen on KOTAKU"> </a>
<a href="https://www.digitalfoundry.net/news/2026/07/pokemon-yellow-voxel-mod-turns-the-original-gameboy-code-into-a-stunning-world">
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-DIGITAL%20FOUNDRY-ea2e49?style=for-the-badge" alt="As seen on Digital Foundry">
</a>
<a href="https://www.androidauthority.com/unofficial-android-port-pokemon-red-blue-yellow-3692724/">
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-ANDROID%20AUTHORITY-ea2e49?style=for-the-badge" alt="As seen on Android Authority">
</a>
<a href="https://www.xda-developers.com/this-amazing-pokemon-red-and-blue-voxel-mod-adds-a-3d-perspective-without-an-emulator/">
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-XDA%20DEVELOPERS-ea2e49?style=for-the-badge" alt="As seen on XDA Developers">
</a>
</p>
### Watch the latest update video
[![Watch the latest update video](https://img.youtube.com/vi/TbHdJIrKJJU/maxresdefault.jpg)](https://www.youtube.com/watch?v=TbHdJIrKJJU)
[![Watch the latest update video](https://img.youtube.com/vi/8IOgqbe4YvA/maxresdefault.jpg)](https://www.youtube.com/watch?v=8IOgqbe4YvA)
This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Poke Red or Blue ROM is the only
game content input.
or download a disassembly. A canonical US Poke Red, Blue, or Yellow ROM is the
only game content input.
The ROM is verified, used during import, and then released from memory. It is
not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again. Red and Blue can both be imported and played
side by side.
do not ask for the ROM again. Red, Blue, and Yellow can all be imported and
played side by side.
## Quick Start
Open the desktop app. On first boot, choose your legally obtained `.gb` file
or drop it onto the window. Import takes a few seconds and the game starts
automatically.
Open the desktop app. On first boot, choose your legally obtained `.gb` /
`.gbc` file or drop it onto the window. Import takes a few seconds and the
game starts automatically.
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
verifies SHA-1 before creating any game data:
Only the canonical 1 MiB US Red, Blue, and Yellow ROMs are accepted. The
importer verifies SHA-1 before creating any game data:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact
@@ -76,6 +108,17 @@ supported out of the box.
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
and persist in `options.lua`.
### Low-end devices
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX),
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
normal desktops → HIGH, unchanged). It only scales presentation; the
fixed-step game logic is identical on every tier, and a lower tier hides
your tilt/zoom/GBC-FX preferences without forgetting them. Details in
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
### Rulesets
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
@@ -110,11 +153,11 @@ famous bugs included:
## Running From Source
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
Requires LÖVE 11.x. Place a Red, Blue, or Yellow ROM in the project folder and
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
```sh
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Poke Blue.gb
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Blue.gb / Yellow.gbc
scripts/run.sh
```
@@ -145,6 +188,13 @@ even on a different computer, as long as the same folder comes along.
already written to either location is touched automatically, so copy files
over yourself if you want to carry existing progress across the switch.
## iOS
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
(Windows or Mac) — see [docs/ios-sideload.md](docs/ios-sideload.md). To
build and install from source on a Mac instead, see
[docs/ios-install.md](docs/ios-install.md).
## Handhelds
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
+18 -2
View File
@@ -1,4 +1,9 @@
function love.conf(t)
-- PhysFS ignores symlinks unless told otherwise, so a mod dev-linked into
-- mods/ (ln -s, matching the mklink /J workflow on Windows) is invisible
-- to love.filesystem.getDirectoryItems without this.
love.filesystem.setSymlinksEnabled(true)
local editor = os.getenv("POKEPORT_EDITOR") == "1"
local developer = os.getenv("POKEPORT_DEV") == "1"
if arg then
@@ -44,7 +49,7 @@ function love.conf(t)
t.window.minwidth = 480
t.window.minheight = 360
end
t.version = "11.5"
t.version = love._os == "iOS" and "12.0" or "11.5"
t.window.vsync = 1
t.modules.joystick = true
t.modules.physics = false
@@ -62,7 +67,10 @@ function love.conf(t)
-- the game follow the device. The renderer letterboxes the 160x144
-- viewport into whatever size results, and the on-screen touch controls
-- re-lay themselves out from the new window size, so both orientations
-- just work. iOS follows the Info.plist orientations
-- just work. FULL_SENSOR ignores the device's rotation lock, so
-- GameActivity.setOrientationBis remaps it to FULL_USER after SDL has
-- run: same orientations allowed, but auto-rotate being off now wins.
-- iOS follows the Info.plist orientations
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true
-- Starting size is a tall portrait hint; the OS resizes to the real
@@ -78,6 +86,14 @@ function love.conf(t)
-- player to copy their ROM there instead of needing a native file
-- picker (LOVE 11.5 on Android has none -- see src/import/RomImporter.lua).
t.externalstorage = osName == "Android"
-- LOVE 11.x exposes the phone's accelerometer as a 3-axis joystick by
-- default. Gravity keeps one axis pinned near +/-1.0 whenever the phone
-- is held upright, so it streams past-deadzone joystickaxis events that
-- both hid the touch overlay every instant (it reads as "a controller is
-- in use") and, via the generic-joystick axis mapping (#459), walked the
-- player around by tilt. Nothing in the game reads the accelerometer,
-- so drop the device entirely (#468).
t.accelerometerjoystick = false
else
t.window.resizable = true
end
+686
View File
@@ -0,0 +1,686 @@
-- Generated by tools/build_data.py. DO NOT EDIT.
-- Pokemon Yellow palettes (COLORS=OG YELLOW): SuperPalettes +
-- CGBBasePalettes (authentic GBC look) as 4 8-bit RGB colors
-- per palette (color 0 first), plus MonsterPalettes.
return {
cgbBase = {
["0F"] = {
{ 255, 255, 255 },
{ 107, 8, 255 },
{ 0, 74, 255 },
{ 8, 8, 8 },
},
BADGE = {
{ 255, 255, 255 },
{ 189, 66, 0 },
{ 140, 115, 90 },
{ 25, 25, 25 },
},
BLACK = {
{ 255, 255, 255 },
{ 25, 25, 25 },
{ 25, 25, 25 },
{ 25, 25, 25 },
},
BLUEMON = {
{ 255, 255, 255 },
{ 132, 148, 255 },
{ 0, 8, 206 },
{ 25, 25, 25 },
},
BROWNMON = {
{ 255, 255, 255 },
{ 239, 148, 82 },
{ 140, 74, 41 },
{ 25, 25, 25 },
},
CAVE = {
{ 255, 255, 255 },
{ 189, 66, 0 },
{ 140, 115, 90 },
{ 25, 25, 25 },
},
CELADON = {
{ 255, 255, 255 },
{ 41, 255, 41 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
CERULEAN = {
{ 255, 255, 255 },
{ 41, 66, 255 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
CINNABAR = {
{ 255, 255, 255 },
{ 255, 66, 66 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
CYANMON = {
{ 255, 255, 255 },
{ 132, 214, 255 },
{ 0, 140, 255 },
{ 25, 25, 25 },
},
FUCHSIA = {
{ 255, 255, 255 },
{ 255, 123, 123 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
GAMEFREAK = {
{ 255, 255, 255 },
{ 255, 156, 0 },
{ 156, 156, 0 },
{ 25, 25, 25 },
},
GRAYMON = {
{ 255, 255, 255 },
{ 165, 189, 82 },
{ 90, 90, 41 },
{ 25, 25, 25 },
},
GREENBAR = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 0, 255, 0 },
{ 25, 25, 25 },
},
GREENMON = {
{ 255, 255, 255 },
{ 140, 255, 90 },
{ 8, 181, 49 },
{ 25, 25, 25 },
},
INDIGO = {
{ 255, 255, 255 },
{ 90, 66, 255 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
LAVENDER = {
{ 255, 255, 255 },
{ 206, 33, 255 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
LOGO1 = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 255, 0, 0 },
{ 255, 0, 0 },
},
LOGO2 = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 58, 58, 206 },
{ 0, 0, 140 },
},
MEWMON = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 255, 8, 8 },
{ 25, 25, 25 },
},
PALLET = {
{ 255, 255, 255 },
{ 189, 140, 255 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
PEWTER = {
{ 255, 255, 255 },
{ 148, 148, 123 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
PIKACHUS_BEACH = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
PIKACHUS_BEACH_TITLE = {
{ 255, 255, 255 },
{ 74, 74, 74 },
{ 255, 173, 0 },
{ 25, 25, 25 },
},
PIKACHU_PORTRAIT = {
{ 255, 255, 255 },
{ 255, 148, 0 },
{ 156, 58, 8 },
{ 25, 25, 25 },
},
PINKMON = {
{ 255, 255, 255 },
{ 255, 123, 148 },
{ 255, 0, 49 },
{ 25, 25, 25 },
},
PURPLEMON = {
{ 255, 255, 255 },
{ 206, 123, 255 },
{ 156, 0, 181 },
{ 25, 25, 25 },
},
REDBAR = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 255, 0, 0 },
{ 25, 25, 25 },
},
REDMON = {
{ 255, 255, 255 },
{ 255, 140, 0 },
{ 255, 0, 0 },
{ 25, 25, 25 },
},
ROUTE = {
{ 255, 255, 255 },
{ 132, 255, 33 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
SAFFRON = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
SLOTS1 = {
{ 255, 255, 255 },
{ 206, 8, 255 },
{ 255, 0, 0 },
{ 25, 25, 25 },
},
SLOTS2 = {
{ 255, 255, 255 },
{ 255, 33, 156 },
{ 255, 255, 0 },
{ 25, 25, 25 },
},
SLOTS3 = {
{ 255, 255, 255 },
{ 66, 255, 0 },
{ 255, 255, 0 },
{ 25, 25, 25 },
},
SLOTS4 = {
{ 255, 255, 255 },
{ 0, 255, 255 },
{ 255, 255, 0 },
{ 25, 25, 25 },
},
TOWNMAP = {
{ 255, 255, 255 },
{ 0, 173, 255 },
{ 82, 230, 0 },
{ 8, 8, 8 },
},
VERMILION = {
{ 255, 255, 255 },
{ 255, 156, 0 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
VIRIDIAN = {
{ 255, 255, 255 },
{ 156, 255, 0 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
YELLOWBAR = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 255, 148, 0 },
{ 25, 25, 25 },
},
YELLOWMON = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 230, 115, 0 },
{ 25, 25, 25 },
},
},
order = {
"ROUTE",
"PALLET",
"VIRIDIAN",
"PEWTER",
"CERULEAN",
"LAVENDER",
"VERMILION",
"CELADON",
"FUCHSIA",
"CINNABAR",
"INDIGO",
"SAFFRON",
"TOWNMAP",
"LOGO1",
"LOGO2",
"0F",
"MEWMON",
"BLUEMON",
"REDMON",
"CYANMON",
"PURPLEMON",
"BROWNMON",
"GREENMON",
"PINKMON",
"YELLOWMON",
"GRAYMON",
"SLOTS1",
"SLOTS2",
"SLOTS3",
"SLOTS4",
"BLACK",
"GREENBAR",
"YELLOWBAR",
"REDBAR",
"BADGE",
"CAVE",
"GAMEFREAK",
"PIKACHUS_BEACH",
"PIKACHU_PORTRAIT",
"PIKACHUS_BEACH_TITLE",
},
palettes = {
["0F"] = {
{ 255, 255, 247 },
{ 197, 165, 247 },
{ 90, 165, 247 },
{ 25, 16, 16 },
},
BADGE = {
{ 255, 255, 247 },
{ 165, 123, 90 },
{ 181, 173, 165 },
{ 49, 49, 49 },
},
BLACK = {
{ 255, 255, 247 },
{ 49, 49, 49 },
{ 49, 49, 49 },
{ 49, 49, 49 },
},
BLUEMON = {
{ 255, 255, 247 },
{ 173, 181, 255 },
{ 74, 82, 165 },
{ 49, 49, 49 },
},
BROWNMON = {
{ 255, 255, 247 },
{ 214, 189, 148 },
{ 148, 115, 82 },
{ 49, 49, 49 },
},
CAVE = {
{ 255, 255, 247 },
{ 165, 123, 90 },
{ 181, 173, 165 },
{ 49, 49, 49 },
},
CELADON = {
{ 255, 255, 247 },
{ 181, 255, 181 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
CERULEAN = {
{ 255, 255, 247 },
{ 181, 189, 255 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
CINNABAR = {
{ 255, 255, 247 },
{ 255, 123, 115 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
CYANMON = {
{ 255, 255, 247 },
{ 214, 230, 255 },
{ 58, 197, 230 },
{ 49, 49, 49 },
},
FUCHSIA = {
{ 255, 255, 247 },
{ 255, 214, 214 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
GAMEFREAK = {
{ 255, 255, 247 },
{ 230, 197, 115 },
{ 165, 165, 90 },
{ 49, 49, 49 },
},
GRAYMON = {
{ 255, 255, 247 },
{ 206, 206, 148 },
{ 132, 132, 115 },
{ 49, 49, 49 },
},
GREENBAR = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 0, 173, 0 },
{ 49, 49, 49 },
},
GREENMON = {
{ 255, 255, 247 },
{ 197, 230, 148 },
{ 107, 173, 123 },
{ 49, 49, 49 },
},
INDIGO = {
{ 255, 255, 247 },
{ 140, 140, 206 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
LAVENDER = {
{ 255, 255, 247 },
{ 222, 189, 239 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
LOGO1 = {
{ 255, 255, 247 },
{ 247, 247, 140 },
{ 173, 0, 33 },
{ 173, 0, 33 },
},
LOGO2 = {
{ 255, 255, 247 },
{ 247, 247, 140 },
{ 148, 148, 197 },
{ 58, 58, 132 },
},
MEWMON = {
{ 255, 255, 247 },
{ 255, 247, 181 },
{ 222, 132, 132 },
{ 49, 49, 49 },
},
PALLET = {
{ 255, 255, 247 },
{ 230, 222, 255 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
PEWTER = {
{ 255, 255, 247 },
{ 189, 189, 181 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
PIKACHUS_BEACH = {
{ 255, 255, 247 },
{ 255, 247, 181 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
PIKACHUS_BEACH_TITLE = {
{ 255, 255, 247 },
{ 132, 132, 132 },
{ 255, 206, 74 },
{ 49, 49, 49 },
},
PIKACHU_PORTRAIT = {
{ 255, 255, 247 },
{ 230, 189, 74 },
{ 148, 115, 82 },
{ 49, 49, 49 },
},
PINKMON = {
{ 255, 255, 247 },
{ 255, 197, 214 },
{ 255, 148, 173 },
{ 49, 49, 49 },
},
PURPLEMON = {
{ 255, 255, 247 },
{ 222, 181, 247 },
{ 181, 123, 189 },
{ 49, 49, 49 },
},
REDBAR = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 214, 74, 49 },
{ 49, 49, 49 },
},
REDMON = {
{ 255, 255, 247 },
{ 255, 197, 90 },
{ 214, 74, 49 },
{ 49, 49, 49 },
},
ROUTE = {
{ 255, 255, 247 },
{ 189, 214, 156 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
SAFFRON = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
SLOTS1 = {
{ 255, 255, 247 },
{ 222, 181, 247 },
{ 214, 74, 49 },
{ 49, 49, 49 },
},
SLOTS2 = {
{ 255, 255, 247 },
{ 255, 189, 214 },
{ 239, 239, 66 },
{ 49, 49, 49 },
},
SLOTS3 = {
{ 255, 255, 247 },
{ 189, 255, 165 },
{ 239, 239, 66 },
{ 49, 49, 49 },
},
SLOTS4 = {
{ 255, 255, 247 },
{ 189, 239, 255 },
{ 239, 239, 66 },
{ 49, 49, 49 },
},
TOWNMAP = {
{ 255, 255, 247 },
{ 165, 214, 255 },
{ 140, 189, 82 },
{ 25, 16, 16 },
},
VERMILION = {
{ 255, 255, 247 },
{ 255, 206, 132 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
VIRIDIAN = {
{ 255, 255, 247 },
{ 214, 255, 173 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
YELLOWBAR = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 230, 189, 74 },
{ 49, 49, 49 },
},
YELLOWMON = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 230, 189, 74 },
{ 49, 49, 49 },
},
},
pokemon = {
ABRA = "YELLOWMON",
AERODACTYL = "GRAYMON",
ALAKAZAM = "YELLOWMON",
ARBOK = "PURPLEMON",
ARCANINE = "REDMON",
ARTICUNO = "BLUEMON",
BEEDRILL = "YELLOWMON",
BELLSPROUT = "GREENMON",
BLASTOISE = "CYANMON",
BULBASAUR = "GREENMON",
BUTTERFREE = "CYANMON",
CATERPIE = "GREENMON",
CHANSEY = "PINKMON",
CHARIZARD = "REDMON",
CHARMANDER = "REDMON",
CHARMELEON = "REDMON",
CLEFABLE = "PINKMON",
CLEFAIRY = "PINKMON",
CLOYSTER = "GRAYMON",
CUBONE = "GRAYMON",
DEWGONG = "BLUEMON",
DIGLETT = "BROWNMON",
DITTO = "GRAYMON",
DODRIO = "BROWNMON",
DODUO = "BROWNMON",
DRAGONAIR = "BLUEMON",
DRAGONITE = "BROWNMON",
DRATINI = "GRAYMON",
DROWZEE = "YELLOWMON",
DUGTRIO = "BROWNMON",
EEVEE = "GRAYMON",
EKANS = "PURPLEMON",
ELECTABUZZ = "YELLOWMON",
ELECTRODE = "YELLOWMON",
EXEGGCUTE = "PINKMON",
EXEGGUTOR = "GREENMON",
FARFETCHD = "BROWNMON",
FEAROW = "BROWNMON",
FLAREON = "REDMON",
GASTLY = "PURPLEMON",
GENGAR = "PURPLEMON",
GEODUDE = "GRAYMON",
GLOOM = "REDMON",
GOLBAT = "BLUEMON",
GOLDEEN = "REDMON",
GOLDUCK = "CYANMON",
GOLEM = "GRAYMON",
GRAVELER = "GRAYMON",
GRIMER = "PURPLEMON",
GROWLITHE = "BROWNMON",
GYARADOS = "BLUEMON",
HAUNTER = "PURPLEMON",
HITMONCHAN = "BROWNMON",
HITMONLEE = "BROWNMON",
HORSEA = "CYANMON",
HYPNO = "YELLOWMON",
IVYSAUR = "GREENMON",
JIGGLYPUFF = "PINKMON",
JOLTEON = "YELLOWMON",
JYNX = "MEWMON",
KABUTO = "BROWNMON",
KABUTOPS = "BROWNMON",
KADABRA = "YELLOWMON",
KAKUNA = "YELLOWMON",
KANGASKHAN = "BROWNMON",
KINGLER = "REDMON",
KOFFING = "PURPLEMON",
KRABBY = "REDMON",
LAPRAS = "CYANMON",
LICKITUNG = "PINKMON",
MACHAMP = "GRAYMON",
MACHOKE = "GRAYMON",
MACHOP = "GRAYMON",
MAGIKARP = "REDMON",
MAGMAR = "REDMON",
MAGNEMITE = "GRAYMON",
MAGNETON = "GRAYMON",
MANKEY = "BROWNMON",
MAROWAK = "GRAYMON",
MEOWTH = "YELLOWMON",
METAPOD = "GREENMON",
MEW = "MEWMON",
MEWTWO = "MEWMON",
MOLTRES = "REDMON",
MR_MIME = "PINKMON",
MUK = "PURPLEMON",
NIDOKING = "PURPLEMON",
NIDOQUEEN = "BLUEMON",
NIDORAN_F = "BLUEMON",
NIDORAN_M = "PURPLEMON",
NIDORINA = "BLUEMON",
NIDORINO = "PURPLEMON",
NINETALES = "YELLOWMON",
ODDISH = "GREENMON",
OMANYTE = "BLUEMON",
OMASTAR = "BLUEMON",
ONIX = "GRAYMON",
PARAS = "REDMON",
PARASECT = "REDMON",
PERSIAN = "YELLOWMON",
PIDGEOT = "BROWNMON",
PIDGEOTTO = "BROWNMON",
PIDGEY = "BROWNMON",
PIKACHU = "YELLOWMON",
PINSIR = "BROWNMON",
POLIWAG = "BLUEMON",
POLIWHIRL = "BLUEMON",
POLIWRATH = "BLUEMON",
PONYTA = "REDMON",
PORYGON = "GRAYMON",
PRIMEAPE = "BROWNMON",
PSYDUCK = "YELLOWMON",
RAICHU = "YELLOWMON",
RAPIDASH = "REDMON",
RATICATE = "GRAYMON",
RATTATA = "GRAYMON",
RHYDON = "GRAYMON",
RHYHORN = "GRAYMON",
SANDSHREW = "BROWNMON",
SANDSLASH = "BROWNMON",
SCYTHER = "GREENMON",
SEADRA = "CYANMON",
SEAKING = "REDMON",
SEEL = "BLUEMON",
SHELLDER = "GRAYMON",
SLOWBRO = "PINKMON",
SLOWPOKE = "PINKMON",
SNORLAX = "PINKMON",
SPEAROW = "BROWNMON",
SQUIRTLE = "CYANMON",
STARMIE = "GRAYMON",
STARYU = "REDMON",
TANGELA = "BLUEMON",
TAUROS = "GRAYMON",
TENTACOOL = "CYANMON",
TENTACRUEL = "CYANMON",
VAPOREON = "CYANMON",
VENOMOTH = "PURPLEMON",
VENONAT = "PURPLEMON",
VENUSAUR = "GREENMON",
VICTREEBEL = "GREENMON",
VILEPLUME = "REDMON",
VOLTORB = "YELLOWMON",
VULPIX = "REDMON",
WARTORTLE = "CYANMON",
WEEDLE = "YELLOWMON",
WEEPINBELL = "GREENMON",
WEEZING = "PURPLEMON",
WIGGLYTUFF = "PINKMON",
ZAPDOS = "YELLOWMON",
ZUBAT = "BLUEMON",
},
source = "pokeyellow data/sgb/sgb_palettes.asm + data/pokemon/palettes.asm",
}
+71 -4
View File
@@ -11,8 +11,70 @@
-- equivalent, kept for save compatibility: rows 1-4 also self-heal
-- older saves (flag set before the port hid the ball) by hiding the
-- leftover ball on the next interaction.
--
-- The room's two readables (data/events/hidden_events.asm,
-- hidden_events_for CELADON_MANSION_ROOF_HOUSE):
-- hidden_text_predef 3, 0 / 4, 0 PrintBlackboardLinkCableText, LinkCableHelp
-- hidden_text_predef 3, 4 PrintNotebookText, TMNotebook
-- tools/extract/field.py only parses `hidden_event` rows, so no
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
-- were dead A presses (#391). Same hook shape as the bedroom SNES in
-- data/scripts/flavor/reds_house_2f.lua (#135). hidden_text_predef puts
-- the predef id in the facing byte, so neither tile gates on facing.
local Menu = require("src.ui.Menu")
local TextBox = require("src.render.TextBox")
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, so the
-- extractor never collects it and the pamphlet's text is inlined.
local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f"
.. "There are 50 TMs\nin all.\f"
.. "There are also 5\nHMs that can be\vused repeatedly.\f"
.. "SILPH CO."
-- LinkCableHelp (engine/events/hidden_events/school_blackboard.asm):
-- HowToLinkText's four headings in the 15x10 box at the top left; picking
-- a heading prints its LinkCableInfoText and returns to the menu, B or
-- STOP READING closes.
local LINK_HEADINGS = { "HOW TO LINK", "COLOSSEUM", "TRADE CENTER" }
local function linkCableHelp(game)
local text = game.data.text or {}
local items, showMenu, askHeading
function showMenu()
game.stack:push(Menu.new(game, items,
{ tx = 0, ty = 0, tw = 15, th = 10, rowStep = 1 }))
end
function askHeading()
game.stack:push(TextBox.new(game,
text._LinkCableHelpText2 or "Which heading do\nyou want to read?",
showMenu))
end
items = {}
for i, label in ipairs(LINK_HEADINGS) do
items[i] = { label = label, onSelect = function()
game.stack:push(TextBox.new(game,
text["_LinkCableInfoText" .. i] or label, askHeading))
end }
end
items[#items + 1] = { label = "STOP READING" }
game.stack:push(TextBox.new(game,
text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable",
askHeading))
end
return {
onInteract = function(game, ow, fx, fy)
if fy == 0 and (fx == 3 or fx == 4) then
linkCableHelp(game)
return true
end
if fx == 3 and fy == 4 then
game.stack:push(TextBox.new(game, TM_NOTEBOOK_TEXT))
return true
end
return false
end,
talk = {
TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL = {
{ "check_flag", "EVENT_GOT_EEVEE" }, -- 1
@@ -22,11 +84,16 @@ return {
{ "jump", 13 }, -- 4
{ "give_pokemon", "EEVEE", 25 }, -- 5
{ "jump_if_false", 12 }, -- 6 (party+box full)
{ "play_sound", "Get_Item1" }, -- 7 (GotMonText jingle)
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 8
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 9
-- flag + HideObject before the jingle and GotMonText: the nickname
-- prompt inside give_pokemon and the text row both yield, and a
-- script that dies there would leave the EEVEE taken with the ball
-- still on the table and the gift claimable again (#426). The row
-- count is unchanged, so the numeric jump targets still hold.
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 10
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
{ "play_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
{ "jump", 13 }, -- 11
{ "show_text", "_BoxIsFullText" }, -- 12
},
+9 -7
View File
@@ -1,10 +1,9 @@
-- BikeShop (BIKE_SHOP) flavor dialogue
-- Source: pokered/scripts/BikeShop.asm, pokered/text/BikeShop.asm
--
-- TEXT_BIKESHOP_CLERK is skipped: it drives the actual voucher-for-bicycle
-- exchange (YesNoChoice purchase menu, GiveItem, RemoveItemByID, SetEvent
-- EVENT_GOT_BICYCLE). That is a significant standalone feature outside the
-- scope of these two flavor NPCs and is left unported here.
-- TEXT_BIKESHOP_CLERK lives in data/scripts/story2.lua (M.BIKE_SHOP): the
-- voucher exchange and the BICYCLE/CANCEL price window need more than
-- command rows (#568).
return {
BIKE_SHOP = {
@@ -20,12 +19,15 @@ return {
-- CheckEvent EVENT_GOT_BICYCLE ; jr nz, .gotBike
-- before the player owns a bike -> TheseBikesAreExpensiveText
-- after the player owns a bike -> CoolBikeText
-- The check reads the bag, not the event: the port hands out the
-- BICYCLE itself (a key item, so it cannot be tossed), and that also
-- reads right on saves written before the clerk set the event (#567).
TEXT_BIKESHOP_YOUNGSTER = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_BICYCLE" }, -- 2
{ "jump_if_true", 5 }, -- 3
{ "check_item", "BICYCLE" }, -- 2
{ "jump_if_true", 6 }, -- 3
{ "show_text", "_BikeShopYoungsterTheseBikesAreExpensiveText" }, -- 4
{ "jump", 6 }, -- 5
{ "jump", "end" }, -- 5
{ "show_text", "_BikeShopYoungsterCoolBikeText" }, -- 6
},
},
@@ -35,13 +35,25 @@ local function middleAgedMan(game, ow, npc, done)
-- .loop: print WhichBadgeText, then show the badge list menu again
push(game, t._CeruleanBadgeHouseMiddleAgedManWhichBadgeText, function()
local ListMenu = require("src.ui.ListMenu")
local Strings = require("src.core.Strings")
local items = {}
for _, id in ipairs(BADGE_ORDER) do
local idef = game.data.items[id]
items[#items + 1] = { label = idef and idef.name or id, value = id }
end
-- PrintListMenuEntries prints ListMenuCancelText once it hits the
-- list's $FF terminator (home/list_menu.asm), so every
-- DisplayListMenuID list ends in an on-screen CANCEL row; picking it
-- takes DisplayListMenuIDLoop's ExitListMenu path, the same .done
-- exit as B (#569)
items[#items + 1] = { label = Strings("CANCEL") }
local menu = ListMenu.new(game, "", items, {
onChoose = function(item)
game.stack:pop()
if not item.value then
push(game, t._CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText, done)
return
end
push(game, t[BADGE_TEXT[item.value]], loop)
end,
onCancel = function()
+33 -18
View File
@@ -3,6 +3,10 @@
-- Both NPCs use text_asm with an hRandomAdd roll to pick one of several
-- flavor lines (no flags, no branching outcome) -- ported as a weighted
-- math.random pick each time the NPC is talked to.
--
-- Yellow renames Slowbro -> Electrode in the text labels (see
-- tools/yellow_symbol_aliases.py); look up both so Red/Blue and Yellow
-- share this script.
local M = {}
@@ -12,6 +16,29 @@ local function push(game, ow, npc, done, text)
game.stack:push(TextBox.new(game, text, done))
end
-- CeruleanCitySlowbroText / CeruleanCityElectrodeText
-- cp 180 -> 76/256 chance of 1st; cp 120 -> 60/256 chance of 2nd;
-- cp 60 -> 60/256 chance of 3rd; else 60/256 chance of 4th.
local function talkMon(game, ow, npc, done)
local t = game.data.text
local roll = math.random(0, 255)
local text
if roll >= 180 then
text = t._CeruleanCitySlowbroTookASnoozeText
or t._CeruleanCityElectrodeTookASnoozeText
elseif roll >= 120 then
text = t._CeruleanCitySlowbroIsLoafingAroundText
or t._CeruleanCityElectrodeIsLoafingAroundText
elseif roll >= 60 then
text = t._CeruleanCitySlowbroTurnedAwayText
or t._CeruleanCityElectrodeTurnedAwayText
else
text = t._CeruleanCitySlowbroIgnoredOrdersText
or t._CeruleanCityElectrodeIgnoredOrdersText
end
push(game, ow, npc, done, text)
end
M.CERULEAN_CITY = {
talk = {
-- CeruleanCityCooltrainerF1Text (scripts/CeruleanCity.asm:362-393)
@@ -23,32 +50,20 @@ M.CERULEAN_CITY = {
local text
if roll >= 180 then
text = t._CeruleanCityCooltrainerF1SlowbroUseSonicboomText
or t._CeruleanCityCooltrainerF1ElectrodeUseSonicboomText
elseif roll >= 100 then
text = t._CeruleanCityCooltrainerF1SlowbroPunchText
or t._CeruleanCityCooltrainerF1ElectrodePunchText
else
text = t._CeruleanCityCooltrainerF1SlowbroWithdrawText
or t._CeruleanCityCooltrainerF1ElectrodeWithdrawText
end
push(game, ow, npc, done, text)
end,
-- CeruleanCitySlowbroText (scripts/CeruleanCity.asm:395-436)
-- cp 180 -> 76/256 chance of 1st; cp 120 -> 60/256 chance of 2nd;
-- cp 60 -> 60/256 chance of 3rd; else 60/256 chance of 4th.
TEXT_CERULEANCITY_SLOWBRO = function(game, ow, npc, done)
local t = game.data.text
local roll = math.random(0, 255)
local text
if roll >= 180 then
text = t._CeruleanCitySlowbroTookASnoozeText
elseif roll >= 120 then
text = t._CeruleanCitySlowbroIsLoafingAroundText
elseif roll >= 60 then
text = t._CeruleanCitySlowbroTurnedAwayText
else
text = t._CeruleanCitySlowbroIgnoredOrdersText
end
push(game, ow, npc, done, text)
end,
TEXT_CERULEANCITY_SLOWBRO = talkMon,
-- Yellow map object uses TEXT_CERULEANCITY_ELECTRODE
TEXT_CERULEANCITY_ELECTRODE = talkMon,
},
}
+41
View File
@@ -85,6 +85,47 @@ return {
alreadyGotLabel = "_GameCornerGentlemanCloselyWatchTheReelsText",
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
}),
-- Yellow keeps all three giveaways with the same events and amounts
-- but renames the objects and their text labels: FISHING_GURU ->
-- FISHING_GURU1, CLERK2 -> MIDDLE_AGED_MAN2, GENTLEMAN ->
-- FISHING_GURU2 (pokeyellow/scripts/GameCorner.asm). The Red/Blue
-- keys above never match there, so Yellow needs its own three (#552).
TEXT_GAMECORNER_FISHING_GURU1 = coinGiver({
event = "EVENT_GOT_10_COINS",
amount = 10,
askLabel = "_GameCornerFishingGuru1WantToPlayText",
askFallback = "Kid, do you want\nto play?",
receivedLabel = "_GameCornerFishingGuru1Received10CoinsText",
coinCaseFullLabel = "_GameCornerFishingGuru1DontNeedMyCoinsText",
coinCaseFullFallback = "You don't need my\ncoins!",
alreadyGotLabel = "_GameCornerFishingGuru1WinsComeAndGoText",
alreadyGotFallback = "Wins seem to come\nand go.",
}),
TEXT_GAMECORNER_MIDDLE_AGED_MAN2 = coinGiver({
event = "EVENT_GOT_20_COINS_2",
amount = 20,
askLabel = "_GameCornerMiddleAgedMan2WantSomeCoinsText",
askFallback = "What's up? Want\nsome coins?",
receivedLabel = "_GameCornerMiddleAgedMan2Received20CoinsText",
coinCaseFullLabel = "_GameCornerMiddleAgedMan2YouHaveLotsOfCoinsText",
coinCaseFullFallback = "You have lots of\ncoins!",
alreadyGotLabel = "_GameCornerMiddleAgedMan2INeedMoreCoinsText",
alreadyGotFallback = "Darn! I need more\ncoins for the\vPOKéMON I want!",
}),
TEXT_GAMECORNER_FISHING_GURU2 = coinGiver({
event = "EVENT_GOT_20_COINS",
amount = 20,
askLabel = "_GameCornerFishingGuru2ThrowingMeOffText",
askFallback = "Hey, what? You're\nthrowing me off!\vHere are some\vcoins, shoo!",
receivedLabel = "_GameCornerFishingGuru2Received20CoinsText",
coinCaseFullLabel = "_GameCornerFishingGuru2YouGotYourOwnCoinsText",
coinCaseFullFallback = "You've got your\nown coins!",
alreadyGotLabel = "_GameCornerFishingGuru2CloselyWatchTheReelsText",
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
}),
},
},
}
@@ -0,0 +1,95 @@
-- Viridian School House's two readables (data/events/hidden_events.asm,
-- hidden_events_for VIRIDIAN_SCHOOL_HOUSE):
-- hidden_text_predef 3, 0 PrintBlackboardLinkCableText, ViridianSchoolBlackboard
-- hidden_text_predef 3, 4 PrintNotebookText, ViridianSchoolNotebook
-- tools/extract/field.py only parses `hidden_event` rows, so neither
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
-- were dead A presses (#503). Same hook shape, and the same sibling asm
-- file, as the Celadon roof house in data/scripts/celadon_eevee.lua (#391);
-- hidden_text_predef spends the facing byte on the tx_pre id, so neither
-- tile gates on facing.
local Menu = require("src.ui.Menu")
local TextBox = require("src.render.TextBox")
-- ViridianSchoolBlackboard (engine/events/hidden_events/school_blackboard.asm):
-- StatusAilmentText1/2 are the two columns of the 12x8 box at the top left
-- (hlcoord 0, 0 + `lb bc, 6, 10`); picking a status prints its
-- ViridianBlackboardStatusPointers entry and jumps back to .blackboardLoop,
-- QUIT or B falls through to .exitBlackboard.
local STATUS_LABELS = {
{ " SLP", "_ViridianBlackboardSleepText" },
{ " PSN", "_ViridianBlackboardPoisonText" },
{ " PAR", "_ViridianBlackboardPrlzText" },
{ " BRN", "_ViridianBlackboardBurnText" },
{ " FRZ", "_ViridianBlackboardFrozenText" },
}
local function blackboard(game)
local text = game.data.text or {}
local items, showMenu, askHeading
function showMenu()
game.stack:push(Menu.new(game, items,
{ tx = 0, ty = 0, tw = 12, th = 8, rowStep = 1 }))
end
-- ViridianSchoolBlackboardText2 is reprinted on every .blackboardLoop
-- pass, immediately before HandleMenuInput
function askHeading()
game.stack:push(TextBox.new(game,
text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?",
showMenu))
end
items = {}
for i, row in ipairs(STATUS_LABELS) do
local label, key = row[1], row[2]
items[i] = { label = label, onSelect = function()
game.stack:push(TextBox.new(game, text[key] or label, askHeading))
end }
end
-- no onSelect: Menu's own pop closes the box, matching .exitBlackboard
items[#items + 1] = { label = " QUIT" }
game.stack:push(TextBox.new(game,
text._ViridianSchoolBlackboardText1
or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.",
askHeading))
end
-- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm):
-- pages 1-3 each end in TurnPageSchoolNotebook (TurnPageText + YesNoChoice)
-- and NO stops the read; page 4 turns without asking and runs straight into
-- page 5, the girl catching you at it.
local function notebook(game)
local text = game.data.text or {}
local function page(n, after)
return TextBox.new(game, text["_ViridianSchoolNotebookText" .. n] or "", after)
end
local function turnPage(nextPage)
return function()
game.stack:push(TextBox.new(game, text._TurnPageText or "Turn the page?",
nil, { choice = function(yes)
if yes then game.stack:push(nextPage()) end
end }))
end
end
local function page5() return page(5) end
local function page4() return page(4, function() game.stack:push(page5()) end) end
local function page3() return page(3, turnPage(page4)) end
local function page2() return page(2, turnPage(page3)) end
game.stack:push(page(1, turnPage(page2)))
end
return {
VIRIDIAN_SCHOOL_HOUSE = {
onInteract = function(game, ow, fx, fy)
if fx == 3 and fy == 0 then
blackboard(game)
return true
end
if fx == 3 and fy == 4 then
notebook(game)
return true
end
return false
end,
},
}
+1
View File
@@ -60,6 +60,7 @@ local files = {
"data.scripts.flavor.victory_road_2f",
"data.scripts.flavor.viridian_city",
"data.scripts.flavor.viridian_nickname_house",
"data.scripts.flavor.viridian_school_house", -- #503
"data.scripts.flavor.wardens_house",
}
+14 -5
View File
@@ -6,6 +6,8 @@
-- takes it ("I'll take this one, then!") and both balls disappear.
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
-- OaksLabRivalTakePokeBallScript.
-- * Leftover ball (after the pick): Oak turns and reads the last-mon
-- line instead of re-offering the starter (OaksLabLastMonScript, #601).
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak
-- has walked you in, else "gramps isn't around" (#218); with
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
@@ -23,7 +25,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "jump_if_true", 20 }, -- 2
-- no picking until Oak has walked you in (OaksLabScript gating)
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 20 }, -- 4
{ "jump_if_false", 22 }, -- 4
-- the Pokédex "new species" entry shows before the ask (predef
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
-- sets the owned bits so ShowPokedexData prints height/weight/text;
@@ -31,7 +33,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "push_screen", "DexEntryMenu",
{ species = species, forceOwned = true } }, -- 5
{ "ask", askText }, -- 6
{ "jump_if_false", 21 }, -- 7
{ "jump_if_false", "end" }, -- 7
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
-- inside give_pokemon). Show the received text first so the
-- nickname prompt follows "you got X", matching Gen1.
@@ -52,9 +54,16 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
or "BULBASAUR" } }, -- 17
{ "jump", 21 }, -- 18
{ "jump", 21 }, -- 19 (spacer)
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
{ "jump", "end" }, -- 18
{ "jump", "end" }, -- 19 (spacer)
-- a leftover ball after the player's pick: Oak turns to face the
-- player and reads the last-mon line instead of re-offering the
-- starter (scripts/OaksLab.asm OaksLabSelectedPokeBallScript ->
-- OaksLabLastMonScript; #601). The ROM's "#MON" ligature is spelled
-- out as Pokémon here.
{ "face_object", 5, "down" }, -- 20
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 21
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 22
}
end
+16 -8
View File
@@ -143,17 +143,25 @@ return {
-- OaksLabRivalExclamationScript: "!" over the rival
{ "emote", RIVAL, "shock" },
}
-- .RivalPushesPlayerAwayFromEeveeBall + the PAD_RIGHT x2 shove:
-- the rival cuts across to the ball WHILE the player standing
-- below it is bumped two tiles right (both movements run in the
-- same beat, so the walk overlaps the shove like the original)
-- .RivalPushesPlayerAwayFromEeveeBall is DOWN then RIGHT x3 (the $07
-- bytes are Yellow's own step-right encoding, decoded by
-- engine/overworld/movement.asm Func_5288 -> Func_532b), and the
-- PAD_RIGHT x2 shove is NOT queued alongside it:
-- OaksLabRivalTakesPokeballScript .asm_1c564 polls every frame and
-- only simulates the pair once wNPCNumScriptedSteps reads 1 -- i.e.
-- as the rival begins the LAST byte, the step onto the tile the
-- player is standing on. Starting both on one row had Red stroll
-- off the Eevee while the rival was still at the top of the table
-- (#559).
if py == 4 then
rows[#rows + 1] = { "walk_npc", RIVAL,
{ "down", "right", "right", "right" }, { wait = false } }
rows[#rows + 1] = { "walk_npc", RIVAL, { "down", "right", "right" } }
-- this one runs concurrently with the shove below
rows[#rows + 1] = { "walk_npc", RIVAL, { "right" }, { wait = false } }
rows[#rows + 1] = { "face_player_dir", "left" }
rows[#rows + 1] = { "move_player", "right", 2 }
-- let the rival finish the last stretch to (7,4)
rows[#rows + 1] = { "wait", 40 }
-- move_player blocks for both tiles, so the rival has already
-- landed on (7,4); this is just the beat before he turns up
rows[#rows + 1] = { "wait", 20 }
else
rows[#rows + 1] = { "move_npc_to", RIVAL, 7, 4 }
end
+92 -19
View File
@@ -183,6 +183,7 @@ M.BILLS_HOUSE = {
overworld = ow },
"BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
require("src.world.PikachuFollower").onBillEnteredMachine(game, ow)
done()
end
if ow.player.facing == "down" then
@@ -273,6 +274,9 @@ M.BILLS_HOUSE = {
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
end
if not flags.EVENT_MET_BILL_2 then
require("src.world.PikachuFollower").onBillsHouseEnter(game, ow)
end
end,
}
@@ -643,7 +647,12 @@ M.WARDENS_HOUSE = {
TEXT_WARDENSHOUSE_WARDEN = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_HM04" }, -- 2
{ "jump_if_true", 13 }, -- 3
-- #535: previously jumped to the same silent-end target as the
-- give-then-thank fallthrough (row 13), so the warden said nothing
-- on every visit after the trade. pokered's .got_item branch
-- (scripts/WardensHouse.asm) instead prints HM04ExplanationText,
-- so route here to the new row 17 that does the same.
{ "jump_if_true", 17 }, -- 3
{ "check_item", "GOLD_TEETH" }, -- 4
{ "jump_if_false", 15 }, -- 5
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
@@ -654,9 +663,15 @@ M.WARDENS_HOUSE = {
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
{ "jump", 16 }, -- 13 (already got it)
{ "jump", 16 }, -- 14 (unused)
{ "jump", 18 }, -- 13 (already got it this convo; jp .done)
{ "jump", 18 }, -- 14 (unused)
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
{ "jump", 18 }, -- 16 (#535: skip the new explanation row below)
-- #535: pokered .got_item branch (scripts/WardensHouse.asm) --
-- printed on every subsequent talk once EVENT_GOT_HM04 is set.
-- Text is _WardensHouseWardenHM04ExplanationText (text/WardensHouse.asm):
-- HM04 teaches Strength, and hints at the Safari Zone secret house.
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 17
},
},
}
@@ -666,6 +681,48 @@ M.WARDENS_HOUSE = {
-- generic OPP_GIOVANNI#2 battle)
-- -------------------------------------------------------------------
-- SilphCo11FTeamRocketLeavesScript (scripts/SilphCo11F.asm) hides every
-- ROCKET and rocket-aligned SCIENTIST toggle on 2F-11F the moment Giovanni
-- falls; the item balls, the 2F/10F rescued workers and the 7F rival keep
-- theirs. Names and order are data/maps/toggleable_objects.asm's
-- TOGGLE_SILPH_CO_<floor>_n entries. Saffron City's half of the same
-- script lives in M.SAFFRON_CITY (story4.lua) (#392).
local SILPH_ROCKET_OBJECTS = {
{ "SILPH_CO_2F", { "SILPHCO2F_SCIENTIST1", "SILPHCO2F_SCIENTIST2",
"SILPHCO2F_ROCKET1", "SILPHCO2F_ROCKET2" } },
{ "SILPH_CO_3F", { "SILPHCO3F_ROCKET", "SILPHCO3F_SCIENTIST" } },
{ "SILPH_CO_4F", { "SILPHCO4F_ROCKET1", "SILPHCO4F_SCIENTIST",
"SILPHCO4F_ROCKET2" } },
{ "SILPH_CO_5F", { "SILPHCO5F_ROCKET1", "SILPHCO5F_SCIENTIST",
"SILPHCO5F_ROCKER", "SILPHCO5F_ROCKET2" } },
{ "SILPH_CO_6F", { "SILPHCO6F_ROCKET1", "SILPHCO6F_SCIENTIST",
"SILPHCO6F_ROCKET2" } },
{ "SILPH_CO_7F", { "SILPHCO7F_ROCKET1", "SILPHCO7F_SCIENTIST",
"SILPHCO7F_ROCKET2", "SILPHCO7F_ROCKET3" } },
{ "SILPH_CO_8F", { "SILPHCO8F_ROCKET1", "SILPHCO8F_SCIENTIST",
"SILPHCO8F_ROCKET2" } },
{ "SILPH_CO_9F", { "SILPHCO9F_ROCKET1", "SILPHCO9F_SCIENTIST",
"SILPHCO9F_ROCKET2" } },
{ "SILPH_CO_10F", { "SILPHCO10F_ROCKET", "SILPHCO10F_SCIENTIST" } },
{ "SILPH_CO_11F", { "SILPHCO11F_GIOVANNI", "SILPHCO11F_ROCKET1",
"SILPHCO11F_ROCKET2" } },
}
-- hide_object writes save.objectToggles, so the single pass at the win
-- covers floors the player is not standing on; onlyMap is the per-floor
-- repair path below.
local function silphRocketsLeave(game, ow, onlyMap)
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
if not onlyMap or onlyMap == floor[1] then
for _, name in ipairs(floor[2]) do
Commands.hide_object(ctx, floor[1], name)
end
end
end
end
M.SILPH_CO_11F = {
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
@@ -692,13 +749,11 @@ M.SILPH_CO_11F = {
ow:scriptMove(gio, "down", 3, function()
gio:facePlayer(ow.player)
ow:engageTrainer(gio, function()
-- SilphCo11FTeamRocketLeavesScript: Giovanni leaves the floor
-- SilphCo11FTeamRocketLeavesScript: every Silph rocket leaves
-- after the loss (the street rockets are handled by
-- M.SAFFRON_CITY.onEnter in story4.lua).
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
silphRocketsLeave(game, ow)
end
end)
end)
@@ -706,28 +761,46 @@ M.SILPH_CO_11F = {
end,
onEnter = function(game, ow)
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
silphRocketsLeave(game, ow)
end
end,
talk = {
-- SilphCo11FSilphPresidentText branches on EVENT_GOT_MASTER_BALL only:
-- the teleport pads reach him without passing Giovanni's trigger, and
-- afterwards he describes the ball forever. The old rows fell off the
-- end of the script on both branches, so a second talk was silence
-- (#392).
TEXT_SILPHCO11F_SILPH_PRESIDENT = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
{ "jump_if_false", 10 }, -- 3
{ "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 4
{ "jump_if_true", 10 }, -- 5
{ "show_text", "_SilphCo11FSilphPresidentText" }, -- 6
{ "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 2
{ "jump_if_true", 9 }, -- 3
{ "show_text", "_SilphCo11FSilphPresidentText" }, -- 4
-- give-then-print like scripts/SilphCo11F.asm
{ "give_item", "MASTER_BALL", 1, false }, -- 7
{ "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 8
{ "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 9
{ "jump", 11 }, -- 10
{ "give_item", "MASTER_BALL", 1, false }, -- 5
{ "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 6
{ "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 7
{ "jump", "end" }, -- 8
{ "show_text",
"_SilphCo11FSilphPresidentMasterBallDescriptionText" }, -- 9
},
},
}
-- Floors 2F-10F carry no other onEnter, so this repairs saves that beat
-- Giovanni before the hide list existed (same shape as
-- M.POKEMON_TOWER_7F.onEnter); 11F hides its own inside the table above.
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
local mapId = floor[1]
if mapId ~= "SILPH_CO_11F" then
M[mapId] = M[mapId] or {}
M[mapId].onEnter = function(game, ow)
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
silphRocketsLeave(game, ow, mapId)
end
end
end
end
-- -------------------------------------------------------------------
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
-- a boulder resting on a switch removes a barrier block; the 3F hole
+130 -11
View File
@@ -413,22 +413,141 @@ M.POKEMON_FAN_CLUB = {
},
}
-- BikeShopClerkText (scripts/BikeShop.asm) runs three ways: the BICYCLE is
-- already yours, you are carrying the BIKE VOUCHER, or you get the sales
-- pitch. The pitch draws its own window (TextBoxBorder hlcoord 0,0, b=4
-- c=15) holding BikeShopMenuText and BikeShopMenuPrice, and leaves it up
-- while the clerk keeps talking in the bottom box: the original never
-- erases it before TextScriptEnd (#568).
local BikeShopWindow = {}
BikeShopWindow.__index = BikeShopWindow
function BikeShopWindow.new(game, footer, onChoose)
local self = setmetatable({}, BikeShopWindow)
self.game = game
self.onChoose = onChoose
self.index = 1
self.active = true
-- BikeShopClerkDoYouLikeItText stays on screen under the window for as
-- long as the menu is up. The text box pushed on top types it out and
-- pops itself; this copy of its last page takes over from there, so the
-- bottom box never blanks between the pitch and the answer. Paginated
-- with the themed column budget so the copy breaks where the box did.
local TextBox = require("src.render.TextBox")
local box = require("src.ui.Theme").textBox or {}
local pages = TextBox.paginate(TextBox.substitute(game, footer), box.maxCols)
self.footer = pages[#pages]
return self
end
function BikeShopWindow:update()
-- one answer only: the boxes pushed by onChoose sit on top of this
-- state, but a second A on the same frame must not fire it twice
if not self.active then return end
local input = self.game.input
-- HandleMenuInput with wMenuWrappingEnabled clear: the two rows clamp
if input:wasPressed("up") then
self.index = 1
elseif input:wasPressed("down") then
self.index = 2
elseif input:wasPressed("a") or input:wasPressed("b") then
local cancelled = input:wasPressed("b") -- bit B_PAD_B -> .cancel
require("src.core.Sound").play(self.game.data, "Press_AB")
self.active = false
self.onChoose(not cancelled and self.index == 1)
end
end
function BikeShopWindow:draw()
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Theme = require("src.ui.Theme")
Font.drawBox(0, 0, 17, 6)
love.graphics.setColor(0, 0, 0, 1)
local bike = self.game.data.items.BICYCLE
Font.draw(bike and bike.name or "BICYCLE", 16, 16) -- hlcoord 2, 2
Font.draw("¥1000000", 64, 24) -- hlcoord 8, 3
Font.draw(Strings("CANCEL"), 16, 32) -- `next` skips a row
-- wTopMenuItemX 1, wTopMenuItemY 2, rows two apart
Font.drawCode(Theme.cursor, 8, self.index == 1 and 16 or 32)
if self.footer then
-- same geometry TextBox resolves against, so a themed box matches
local box = Theme.textBox or {}
local tx, ty = box.tx or 0, box.ty or 12
love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(tx, ty, box.tw or 20, box.th or 6)
love.graphics.setColor(0, 0, 0, 1)
for i, line in ipairs(self.footer) do
Font.draw(line, (tx + 1) * 8, (ty + 2 * i) * 8)
end
end
love.graphics.setColor(1, 1, 1, 1)
end
M.BIKE_SHOP = {
talk = {
TEXT_BIKESHOP_CLERK = function(game, ow, npc, done)
local Bag = require("src.inventory.Bag")
local Flags = require("src.script.Flags")
local TextBox = require("src.render.TextBox")
if (game.save.inventory.BICYCLE or 0) > 0 then
game.stack:push(TextBox.new(game, "How's the\nBICYCLE treating\nyou?", done))
elseif (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
game.save.inventory.BIKE_VOUCHER = nil
game.save.inventory.BICYCLE = 1
game.stack:push(TextBox.new(game,
("Oh, that's a\nBIKE VOUCHER!\f%s exchanged\nit for a BICYCLE!")
:format(game.save.player.name), done))
else
game.stack:push(TextBox.new(game,
"A BICYCLE costs\n¥1000000. Sorry,\nno instalments!", done))
local t = game.data.text
local function say(text, after, opts)
game.stack:push(TextBox.new(game, text, after, opts))
end
-- CheckEvent EVENT_GOT_BICYCLE. Saves made before the clerk started
-- setting the event still have the bike in the bag, so either counts.
if (game.save.inventory.BICYCLE or 0) > 0
or Flags.get(game.save, "EVENT_GOT_BICYCLE") then
say(t._BikeShopClerkHowDoYouLikeYourBicycleText, done)
return
end
-- .dontHaveBike: IsItemInBag BIKE_VOUCHER
if (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
say(t._BikeShopClerkOhThatsAVoucherText, function()
-- GiveItem's `jr nc, .BagFull`: the voucher is only spent once
-- the BICYCLE is actually in the bag
if not Bag.add(game.save, "BICYCLE", 1) then
say(t._BikeShopBagFullText, done)
return
end
Bag.remove(game.save, "BIKE_VOUCHER", 1)
Flags.set(game.save, "EVENT_GOT_BICYCLE")
-- BikeShopExchangedVoucherText carries sound_get_key_item; the
-- map runs EnableAutoTextBoxDrawing, so the box still waits for
-- a button once the jingle has played (auto.wait, #247)
say(t._BikeShopExchangedVoucherText, done, {
auto = { wait = true, sound = function()
return require("src.core.Sound").play(game.data, "Get_Key_Item")
end },
})
end)
return
end
-- .dontHaveVoucher: welcome, then the BICYCLE/CANCEL window
say(t._BikeShopClerkWelcomeText, function()
local pitch = t._BikeShopClerkDoYouLikeItText
game.stack:push(BikeShopWindow.new(game, pitch, function(bought)
local function comeAgain()
say(t._BikeShopComeAgainText, function()
game.stack:pop() -- the window, still up under the text
done()
end)
end
if bought then
-- a million is out of anyone's reach: BikeShopCantAffordText
say(t._BikeShopCantAffordText, comeAgain)
else
comeAgain()
end
end))
-- PrintText BikeShopClerkDoYouLikeItText, then straight into
-- HandleMenuInput: the box types out and hands over without
-- waiting, leaving the window's copy of the line on screen
say(pitch, nil, { auto = { delay = 0 } })
end)
end,
},
}
+180 -127
View File
@@ -111,8 +111,9 @@ M.POKEMON_TOWER_5F = {
--
-- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO
-- Silph Scope check at the trigger -- the scope only decides whether the
-- battle is disguised (IsGhostBattle -> makeGhost: "too scared to move",
-- balls dodged). An earlier version of this port turned the player back
-- disguise sticks (IsGhostBattle -> makeGhost: "too scared to move", balls
-- dodged) or comes off in the unveil (makeUnveiledGhost, #492). An earlier
-- version of this port turned the player back
-- without the scope and never opened the battle, which made 6F
-- impassable on any route that skips Rocket Hideout; vanilla lets the
-- battle open and a POKE_DOLL end it (see wBattleResult below).
@@ -128,7 +129,18 @@ M.POKEMON_TOWER_6F = {
t._PokemonTower6FBeGoneText or "Be gone...\nIntruders...", function()
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(game, "MAROWAK", 30)
if not game.save.inventory.SILPH_SCOPE then
-- ItemUseBall .notOldManBattle (item_effects.asm:166-175): a ball
-- thrown on POKEMON_TOWER_6F at the RESTLESS SOUL is dodged whether
-- or not the scope revealed it, so the "can't be caught" state rides
-- the battle instead of IsGhostBattle alone (#444)
battle.noCatch = true
-- InitWildBattle enters disguised for the RESTLESS SOUL either way
-- (core.asm:6698-6700). The scope does not skip the disguise, it
-- buys the unveil PrintBeginningBattleText .isMarowak plays over it
-- before the battle proceeds as an ordinary wild one (#492).
if game.save.inventory.SILPH_SCOPE then
battle:makeUnveiledGhost()
else
battle:makeGhost()
end
battle.onFinish = function(result)
@@ -182,17 +194,16 @@ M.POKEMON_TOWER_6F = {
-- preFrames: the shake's lead-in delays -- ShakeElevator's own Delay3s
-- come to 9 frames; Silph/Rocket's ...ShakeScript prefixes another
-- Delay3 (12) while CeladonMartElevatorShakeScript farjps straight in
-- The panel is a bg_event, not a map-entry script: data/maps/objects/
-- CeladonMartElevator.asm has `bg_event 3, 0, TEXT_CELADONMARTELEVATOR`
-- and CeladonMartElevatorText is what runs DisplayElevatorFloorMenu, so
-- the menu waits for the player to face the panel and press A (#395).
-- Map entry only stores the car's exit warps (CeladonMartElevator
-- StoreWarpEntriesScript), which is elevatorSeedExit below.
-- After the ride the original does NOT jump-cut to the floor: choosing a
-- floor in engine/events/elevator.asm DisplayElevatorFloorMenu rewrites
-- the elevator car's own warp entries (wWarpEntries, via .UpdateWarp) to
-- the chosen floor's exit warp, then the player walks out of the car onto
-- that warp themselves (scripts/SilphCoElevator.asm etc.). Reproduce
-- that here: rewrite the car's exit warps, then drive a short scripted
-- walk-out onto an exit tile and take the (now rewritten) warp, reusing
-- ow:scriptMove / ow:takeWarp (the Oak-escort primitives).
local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
local WALK_ORDER = { "up", "down", "left", "right" }
-- floor rewrites the elevator car's own warp entries (wWarpEntries, via
-- .UpdateWarp) to the chosen floor's exit warp and returns control, and
-- the player walks out of the car onto that warp themselves.
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
-- door (warp id, map id). Shared generated map data, but the car's
@@ -205,46 +216,6 @@ local function elevatorSetExit(ow, floor)
end
end
local function elevatorWalkOut(ow, floor)
local m, p = ow.map, ow.player
elevatorSetExit(ow, floor)
-- leave by the exit tile under the player (they warped in onto one),
-- else the nearest
local door
for _, w in ipairs(m.def.warps) do
if w.x == p.cellX and w.y == p.cellY then door = w break end
end
if not door then
local best
for _, w in ipairs(m.def.warps) do
local d = math.abs(w.x - p.cellX) + math.abs(w.y - p.cellY)
if not best or d < best then best, door = d, w end
end
end
-- the car interior sits on the door's walkable side; "out" is the
-- doorway direction (the map edge for Silph/Celadon, the top doorway
-- for the Rocket car). Fixed direction order keeps the step
-- deterministic when a door tile has several walkable neighbours
-- (Silph/Celadon step up into the car, the Rocket car steps down).
local into
for _, dir in ipairs(WALK_ORDER) do
local v = WALK_DIRVEC[dir]
local nx, ny = door.x + v[1], door.y + v[2]
if m:inBounds(nx, ny) and m:isWalkableCell(nx, ny) then into = dir break end
end
into = into or "up"
local out = WALK_OPP[into]
local function leave()
ow:takeWarp(door) -- door SFX + warp to the rewritten floor target
end
-- step one tile into the car, then walk back through the doorway onto
-- the exit tile and take the warp: a visible walk-out on valid tiles
-- for either door orientation, instead of a jump cut
ow:scriptMove(p, into, 1, function()
ow:scriptMove(p, out, 1, leave)
end)
end
local function elevatorFloors(elevatorMapId, game)
local floors = {}
for mapId, def in pairs(game.data.maps) do
@@ -272,9 +243,9 @@ local function elevatorFloors(elevatorMapId, game)
end
local function elevatorSeedExit(ow, floors, fromMapId)
-- Seed a walk-out destination before the menu (or key-gate text):
-- entry floor when known, else the first listed floor (1F). Choosing
-- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave
-- Seed a walk-out destination on entry (before the panel is ever
-- read): entry floor when known, else the first listed floor (1F).
-- Choosing a floor rewrites it again; B-cancel / no-key leave
-- keeps this seed so walking out of the car cannot hit a missing ROM
-- placeholder (#123) or the car's static default floor (#90: Rocket
-- Hideout defaults to B1F even when entered from B2F/B4F).
@@ -288,68 +259,68 @@ local function elevatorSeedExit(ow, floors, fromMapId)
return exitFloor
end
local function elevator(elevatorMapId, keyGate, preFrames)
local function elevator(elevatorMapId, panelText, keyGate, preFrames)
-- the panel bg_event: DisplayElevatorFloorMenu runs from this text
-- script, never from map entry (#395)
local function panel(game, ow, npc, done)
done = done or function() end
local floors = elevatorFloors(elevatorMapId, game)
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
-- a-key line and shows no floor menu
-- (scripts/RocketHideoutElevator.asm).
if keyGate and not game.save.inventory[keyGate.item] then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text[keyGate.text] or "It appears to\nneed a key.", done))
return
end
local items = {}
for _, f in ipairs(floors) do
table.insert(items, { label = f.token, value = f })
end
local ListMenu = require("src.ui.ListMenu")
game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, {
onChoose = function(item, list)
list:close()
-- the whole ShakeElevator ride runs in place -- music stop, 100
-- collision-thud scroll bounces, the PA chime -- and only then
-- does .UpdateWarp's rewrite land, with the player still stood
-- at the panel: they walk out to the car door themselves
local ElevatorShake = require("src.world.ElevatorShake")
game.stack:push(ElevatorShake.new(game, ow, {
preFrames = preFrames,
onDone = function()
elevatorSetExit(ow, item.value)
done()
end,
}))
end,
onCancel = function()
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
-- happens, the player just stays in the car (exit warps were
-- already seeded on entry)
done()
end,
}))
end
return {
-- fromMapId: the floor the player just left (setMap passes it), so a
-- B-cancel can still walk out onto a real map. Silph's ROM car warps
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
-- asserted and hard-crashed (#123).
onEnter = function(game, ow, fromMapId)
local floors = elevatorFloors(elevatorMapId, game)
elevatorSeedExit(ow, floors, fromMapId)
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
-- still seeded above so walking out returns to the entry floor
-- instead of the car's ROM default (B1F) -- #90 / #105.
if keyGate and not game.save.inventory[keyGate.item] then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text[keyGate.text] or "It appears to\nneed a key."))
return
end
local items = {}
for _, f in ipairs(floors) do
table.insert(items, { label = f.token, value = f })
end
local ListMenu = require("src.ui.ListMenu")
game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, {
onChoose = function(item, list)
list:close()
-- the map-entry Transition (startWarpTo) calls onEnter from
-- its OWN midpoint callback, so this menu was pushed on top
-- of that still-active Transition; only the top state
-- updates, so it froze mid-fade instead of finishing. Left
-- alone it would resurface after the ride and play a stray
-- fade at the wrong time -- pop it now, before it can happen.
local Transition = require("src.render.Transition")
if getmetatable(game.stack:top()) == Transition then
game.stack:pop()
end
-- the whole ShakeElevator ride runs in place -- music stop,
-- 100 collision-thud scroll bounces, the PA chime -- and only
-- then does the player walk out of the car onto the chosen
-- floor (elevatorWalkOut rewrites the car's exit warps first)
local ElevatorShake = require("src.world.ElevatorShake")
game.stack:push(ElevatorShake.new(game, ow, {
preFrames = preFrames,
onDone = function()
elevatorWalkOut(ow, item.value)
end,
}))
end,
onCancel = function()
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
-- happens, the player just stays in the car (exit warps were
-- already seeded to the entry floor above)
end,
}))
elevatorSeedExit(ow, elevatorFloors(elevatorMapId, game), fromMapId)
end,
talk = { [panelText] = panel },
}
end
M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR")
M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR",
"TEXT_SILPHCOELEVATOR_ELEVATOR")
M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR",
"TEXT_CELADONMARTELEVATOR", nil, 9)
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
"TEXT_ROCKETHIDEOUTELEVATOR",
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
-- -------------------------------------------------------------------
@@ -389,6 +360,38 @@ M.ROCKET_HIDEOUT_B4F = {
end))
end,
-- Yellow swaps Rocket1/Rocket2 for Jessie & James and keeps a single
-- grunt, spelled ROCKETHIDEOUTB4F_ROCKET / TEXT_ROCKETHIDEOUTB4F_ROCKET
-- (pokeyellow/scripts/RocketHideoutB4F.asm), so the Red/Blue key above
-- never matched there and the LIFT KEY never dropped (#552, a
-- regression of #105). Yellow also moves the drop off the second
-- talk: RocketHideoutB4FRocketEndBattleText is text_promptbutton +
-- text_asm, so its SetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5 run the instant the battle ends.
TEXT_ROCKETHIDEOUTB4F_ROCKET = function(game, ow, npc, done)
if not ow:trainerDefeated(npc) then
ow:engageTrainer(npc, function()
-- engageTrainer records the win before it calls back, so this
-- is the end-battle text's SetEvent + ShowObject
if ow:trainerDefeated(npc)
and not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
local Commands = require("src.script.Commands")
Commands.show_object(
{ game = game, save = game.save, overworld = ow },
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
end
done()
end)
return
end
-- RocketHideoutB4FRocketAfterBattleText: later talks only reprint
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._RocketHideoutB4FRocketAfterBattleText
or "Oh no! I dropped\nthe LIFT KEY!", done))
end,
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
-- owns both the engage and the BeatGiovanniScript aftermath.
@@ -538,45 +541,56 @@ M.GAME_CORNER = {
end))
end)
end,
-- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a
-- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the
-- object const and from every one of his text labels
-- (GameCornerClerkText, pokeyellow/scripts/GameCorner.asm) with an
-- identical body, so each line resolves under both spellings and the
-- handler is bound to both text ids just below (#552).
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local t = game.data.text
local function line(suffix, fallback)
return t["_GameCornerClerk1" .. suffix]
or t["_GameCornerClerk" .. suffix]
or fallback
end
game.stack:push(TextBox.new(game,
(t._GameCornerClerk1DoYouNeedSomeGameCoinsText
or "Do you need some\ngame coins?\f¥1000 for 50."), function()
line("DoYouNeedSomeGameCoinsText",
"Do you need some\ngame coins?\f¥1000 for 50."), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1PleaseComePlaySometimeText
or "No? Please come\nplay sometime!", done))
line("PleaseComePlaySometimeText",
"No? Please come\nplay sometime!"), done))
return
end
-- scripts/GameCorner.asm GameCornerClerk1Text: coins need
-- the COIN CASE and room for at least 9 coins (Has9990Coins)
if not game.save.inventory.COIN_CASE then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1DontHaveCoinCaseText
or "You don't have a\nCOIN CASE!", done))
line("DontHaveCoinCaseText",
"You don't have a\nCOIN CASE!"), done))
return
end
if (game.save.coins or 0) >= 9990 then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1CoinCaseIsFullText
or "Oops! Your COIN\nCASE is full.", done))
line("CoinCaseIsFullText",
"Oops! Your COIN\nCASE is full."), done))
return
end
if game.save.money < 1000 then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1CantAffordTheCoinsText
or "You can't afford\nthe coins!", done))
line("CantAffordTheCoinsText",
"You can't afford\nthe coins!"), done))
return
end
game.save.money = game.save.money - 1000
game.save.coins = math.min(9999, (game.save.coins or 0) + 50)
game.stack:push(TextBox.new(game,
(t._GameCornerClerk1ThanksHereAre50CoinsText
or "Thanks! Here are\nyour 50 coins!")
line("ThanksHereAre50CoinsText",
"Thanks! Here are\nyour 50 coins!")
.. ("\fCOINS: %d"):format(game.save.coins), done))
end))
end))
@@ -584,6 +598,13 @@ M.GAME_CORNER = {
},
}
-- Yellow's coin clerk is GAMECORNER_CLERK / TEXT_GAMECORNER_CLERK where Red
-- and Blue spell him CLERK1, and the two text_asm bodies are identical, so
-- one handler answers both ids. Without the alias the Yellow clerk fell
-- through to his extracted offer line with no yes/no box behind it (#552).
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK =
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK1
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
-- The six mon prizes differ between Red and Blue; the three TM prizes are
-- identical, so they are shared and appended to each version's mon list.
@@ -695,6 +716,16 @@ local DOCK_SHIP_BLOCKS = {
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
}
-- her four hull columns bow-to-stern (upper-half / lower-half block ids)
-- and the open-water ids of the rows she sits in
local DOCK_SHIP_COLUMNS = {
{ bx = 5, top = 4, bottom = 8 },
{ bx = 6, top = 5, bottom = 9 },
{ bx = 7, top = 6, bottom = 10 },
{ bx = 8, top = 7, bottom = 11 },
}
local DOCK_WATER_TOP, DOCK_WATER_BOTTOM = 1, 13
M.VERMILION_DOCK = {
onEnter = function(game, ow)
local Flags = require("src.script.Flags")
@@ -729,17 +760,39 @@ M.VERMILION_DOCK = {
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
end
puff(3, 15)
-- VermilionDock_EraseSSAnne deliberately leaves the blocks under the
-- player alone ("south of the player and won't be redrawn"), so skip
-- his own block: he must not spend the walk-out standing on water
local pbx = math.floor(ow.player.cellX / 2)
local pby = math.floor(ow.player.cellY / 2)
local rows = {}
rows[#rows + 1] = { "wait", 100 }
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
local function setBlock(bx, by, block)
if bx < 1 or bx > 8 then return end
if bx == pbx and by == pby then return end
rows[#rows + 1] = { "replace_block", bx, by, block }
end
rows[#rows + 1] = { "wait", 30 }
rows[#rows + 1] = { "wait", 120 }
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
-- .shift_columns_up slides her tile columns west behind a mid-frame
-- rSCX split; with no split scroll here she sails one block per beat
-- and the water closes in astern (#360)
for step = 1, 8 do
for _, col in ipairs(DOCK_SHIP_COLUMNS) do
setBlock(col.bx - step, 1, col.top)
setBlock(col.bx - step, 2, col.bottom)
end
setBlock(9 - step, 1, DOCK_WATER_TOP)
setBlock(9 - step, 2, DOCK_WATER_BOTTOM)
rows[#rows + 1] = { "wait", 20 }
end
-- the second horn as she clears the dock, then EraseSSAnne's 120
-- frames before the walk out
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
rows[#rows + 1] = { "wait", 120 }
rows[#rows + 1] = { "move_player", "up", 2 }
-- keep Music_Surfing across the city warp (pokered stays on it
-- through the exit-ship walk)
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
-- cutscene, and VERMILION_CITY's own theme has to take over as the
-- player crosses in (EnterMap's PlayDefaultMusic)
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
rows[#rows + 1] = { "move_player", "up", 2 }
ow:queueScript(rows)
+23
View File
@@ -171,6 +171,28 @@ local function dojoBall(species, ownBall, otherBall, askKey)
end
end
-- FightingDojoDefaultScript does not use the Master's facing direction for
-- this battle. It checks exactly the cell left of him, turns both sprites
-- toward one another, and then displays his trainer text. Keeping this out
-- of trainer sight also lets him keep his original downward-facing pose.
local function dojoMasterGate(game, ow, x, y)
if x ~= 4 or y ~= 3 or game.save.flags.EVENT_BEAT_KARATE_MASTER then
return false
end
local master
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "FIGHTINGDOJO_KARATE_MASTER" then
master = npc
break
end
end
if not master or ow:trainerDefeated(master) then return false end
ow.player.facing = "right"
master:facePlayer(ow.player)
ow:engageTrainer(master)
return true
end
M.FIGHTING_DOJO = {
talk = {
TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL =
@@ -197,6 +219,7 @@ M.FIGHTING_DOJO = {
end
return false
end,
onStep = dojoMasterGate,
}
-- -------------------------------------------------------------------
+32 -4
View File
@@ -135,6 +135,9 @@ M.SILPH_CO_2F = {
talk = {
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
-- the label carries no leading underscore: pokered keeps this one in
-- the script bank, not the far-text bank (#393)
pre = "SilphCo2FSilphWorkerFPleaseTakeThisText",
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
@@ -787,8 +790,19 @@ local function bikeGateGuard(coords, stopText, explainText)
local t = text(game)
push(game, t[stopText] or "Hey! Wait up!", function()
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
-- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript
-- (scripts/Route16Gate1F.asm, Route18Gate1F.asm) simulate one
-- PAD_RIGHT step after the refusal text, and only clear
-- wJoyIgnore / hand control back once that step finishes
-- (PlayerMovingRightScript). Without it the player was left
-- parked beside the guard's counter with no way past. #518
local function shoveRight()
ow:scriptMove(ow.player, "right", 1)
end
if dist > 0 then
ow:scriptMove(ow.player, "up", dist)
ow:scriptMove(ow.player, "up", dist, shoveRight)
else
shoveRight()
end
end)
end)
@@ -836,6 +850,19 @@ M.SILPH_CO_7F = {
end,
}
-- SSAnne2FRivalAfterBattleScript's exit walk is keyed on the player's X,
-- not on where the rival stopped: from (37,8) the rival stands below him on
-- (36,8) and takes .RivalDownFourMovement straight down out of the room;
-- from (36,8) he stands above him on (36,7) and takes
-- .RivalWalkAroundPlayerMovement, which steps RIGHT and then falls through
-- into the same four DOWNs, so five downs in all (#360).
local function ssAnne2FRivalExitDirs(onLeft)
if onLeft then
return { "right", "down", "down", "down", "down", "down" }
end
return { "down", "down", "down", "down" }
end
-- S.S. Anne 2F rival ambush (scripts/SSAnne2F.asm; coords 36/37,8)
M.SS_ANNE_2F = {
onStep = function(game, ow, x, y)
@@ -848,11 +875,12 @@ M.SS_ANNE_2F = {
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
{ "show_text", "_SSAnne2FRivalText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
{ "jump_if_false", 10 }, -- 6
{ "jump_if_false", 11 }, -- 6
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
{ "move_npc_to", 2, 36, 4 }, -- 9
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 10
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
}, onLeft and "up" or "left")
end,
}
+15 -4
View File
@@ -23,10 +23,15 @@ M.CERULEAN_MELANIES_HOUSE = {
rows[#rows + 1] = { "jump_if_false", "declined" }
rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10 }
rows[#rows + 1] = { "jump_if_false", "end" } -- party + box full
rows[#rows + 1] = { "show_text", "MelanieText3" }
-- Event and HideObject before MelanieText3: give_pokemon's nickname
-- prompt and the received text both yield, and a script that dies
-- in that window used to leave the BULBASAUR given with the event
-- clear, so Melanie offered a second one (#426). The flag is only
-- read at script entry, so the order is invisible in play.
rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" }
rows[#rows + 1] = { "hide_object", "CERULEAN_MELANIES_HOUSE",
"CERULEANMELANIESHOUSE_BULBASAUR" }
rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" }
rows[#rows + 1] = { "show_text", "MelanieText3" }
rows[#rows + 1] = { "jump", "end" }
rows[#rows + 1] = { "label", "declined" }
rows[#rows + 1] = { "show_text", "MelanieText5" }
@@ -60,8 +65,13 @@ M.ROUTE_24 = {
{ "jump_if_false", "declined" },
{ "give_pokemon", "CHARMANDER", 10 },
{ "jump_if_false", "end" },
{ "show_text", "_Route24DamianText2" },
-- SetEvent hoisted ahead of the received text (Route24.asm writes it
-- after Route24Text_515e3, where nothing in between can fail): the
-- nickname prompt and the text both yield, and a script death in that
-- window left the CHARMANDER given with EVENT_54F clear, so Damian
-- handed out a second one on the next talk (#426).
{ "set_flag", "EVENT_54F" },
{ "show_text", "_Route24DamianText2" },
{ "jump", "end" },
{ "label", "declined" },
{ "show_text", "_Route24DamianText3" },
@@ -87,9 +97,10 @@ M.VERMILION_CITY = {
rows[#rows + 1] = { "jump_if_false", "declined" }
rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10 }
rows[#rows + 1] = { "jump_if_false", "end" }
rows[#rows + 1] = { "show_text", "_OfficerJennyText3" }
-- event before the received text, as above (#426)
rows[#rows + 1] = { "set_flag",
"EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" }
rows[#rows + 1] = { "show_text", "_OfficerJennyText3" }
rows[#rows + 1] = { "jump", "end" }
rows[#rows + 1] = { "label", "declined" }
rows[#rows + 1] = { "show_text", "_OfficerJennyText4" }
+15 -1
View File
@@ -48,7 +48,19 @@ M.MT_MOON_B2F = {
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
{ "show_text", "_MtMoonJessieJamesText1" },
{ "face_player_dir", "right" },
{ "face_player_dir", "up" },
{ "emote", "player", "shock", 30 },
-- MtMoonB2FScript_49e15 simulates PAD_UP for one player step, then
-- Script6/Script9 walk Jessie (object 2, MovementData_f9e65: six
-- $06) and James (object 6, f9e66: five $06); both objects' movement
-- byte 2 is LEFT, so .determineDirection makes those LEFT steps --
-- Jessie (9,3)->(3,3) above the player at (3,4), James (9,4)->(4,4)
-- beside him. #423
{ "walk_npc", "player", { "up" } },
{ "walk_npc", 2, { "left", "left", "left", "left", "left", "left" } },
{ "face_object", 2, "down" },
{ "walk_npc", 6, { "left", "left", "left", "left", "left" } },
{ "face_object", 6, "left" },
{ "show_text", "_MtMoonJessieJamesText2" },
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
{ "check_battle_result", "win" },
@@ -57,8 +69,10 @@ M.MT_MOON_B2F = {
{ "show_text", "_MtMoonJessieJamesText4" },
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
{ "fade", "out" },
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
{ "fade", "in" },
{ "play_default_music" },
{ "set_flag", "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" },
}, {})
+7
View File
@@ -65,6 +65,13 @@ display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
put the row back.
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
to AUTO, which reads this device as an ARM Linux handheld and resolves to
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
so the overworld runs smoothly on the H700 out of the box. Bump it to
BALANCED or HIGH from OPTIONS if you want the extras and your device keeps
up; see [Performance tier](new-features.md#performance-tier-low-end-devices).
The pack bundles the LÖVE 11.5 aarch64 runtime from
[PortMaster](https://portmaster.games/), so the device does not need a
separate `love_11.5` runtime download on first launch. The launcher resolves
+5 -1
View File
@@ -12,7 +12,7 @@ this port.
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here |
|---|---|---|---|---|
| 1 | Bag capacity | 20 item slots | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `src/inventory/Bag.lua:8` (`Bag.CAPACITY = 20`) |
| 1 | Bag capacity | 20 item slots by default | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `Data.constants.bagSize`, read by `src/inventory/Bag.lua` (`Bag.capacity`) |
| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) |
| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` |
| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` |
@@ -38,6 +38,10 @@ this port.
## Notes
- Mods may patch `constants.bagSize` through the public content registry. The
native `save.lua` format keeps every existing item when the configured
limit changes; exporting to a cartridge `.sav` still writes only the first
20 bag slots because the original SRAM layout has no room for more.
- PC Box **overflow handling** was deliberately changed even though the
20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards
or blocks the deposit," this port spills into the next box with room.
+116
View File
@@ -0,0 +1,116 @@
# Build & install on your iPhone — step by step
Don't want to install Xcode? Download the release IPA and sideload it with
AltStore instead — see [ios-sideload.md](ios-sideload.md).
This guide assumes **zero** programming experience. Follow it top to
bottom and you'll have the game running on your own iPhone in roughly an
hour (most of it is waiting for downloads).
## What you need
- A **Mac** (any Apple-silicon or recent Intel Mac on macOS 14 or newer)
- An **iPhone** and its **charging cable**
- A free **Apple ID** (the same account you use for the App Store)
- Your **own, legally obtained** Pokémon Red or Blue ROM file (a 1 MB
`.gb` file). This project ships no game data — the app rebuilds
everything from *your* cartridge dump and verifies it before use.
- About **15 GB free disk space** (Xcode is enormous; the game itself is
tiny)
> **Free vs. paid Apple account:** a free Apple ID works. The only catch:
> apps signed with a free account stop launching after **7 days** — just
> re-run the install command (step 5) to re-sign; your save data is kept.
> A paid Apple Developer account ($99/yr) extends that to a year.
---
## Step 1 — Install Xcode (once)
1. On the Mac, open the **App Store**, search **Xcode**, click **Get**.
It's a ~10 GB download — go make tea.
2. Open Xcode once. Accept the license. If it offers to install extra
components or the **iOS platform**, say yes and let it finish.
## Step 2 — Sign Xcode into your Apple ID (once)
1. In Xcode's menu bar: **Xcode → Settings → Accounts**.
2. Click the **+** in the bottom-left → **Apple Account** → sign in.
3. Close the window. (This quietly creates the "signing certificate" the
build uses — you never have to touch it again.)
## Step 3 — Get this project onto the Mac
If you received it as a folder, put it somewhere easy like your home
folder. If it's on GitHub, click the green **Code** button → **Download
ZIP**, then double-click the zip to unpack it.
## Step 4 — Build it (one command)
1. Open the **Terminal** app (press ⌘-space, type `terminal`, press
return).
2. Type `cd ` (c, d, space — don't press return yet), then **drag the
project folder** from Finder onto the Terminal window — it fills in
the path — and press **return**.
3. Paste this and press return:
```sh
scripts/build_ios.sh --fetch
```
The first run downloads the LÖVE engine and compiles everything
(515 minutes). Lines of build output scrolling by is normal. You're
done when you see **`==> done`**.
## Step 5 — Put it on your iPhone
1. Plug the iPhone into the Mac with the cable. **Unlock it** and keep it
unlocked. If it asks **"Trust This Computer?" → Trust**.
2. Paste this and press return:
```sh
scripts/build_ios.sh --device --install
```
The script finds your signing identity and your phone by itself. If it
complains, it tells you exactly what to fix (usually: the phone was
locked — unlock and re-run).
3. First time only, the iPhone will want two approvals:
- **Developer Mode**: Settings → **Privacy & Security** → scroll to
**Developer Mode** → turn on → restart the phone → confirm.
- **Trust the developer**: Settings → **General** → **VPN & Device
Management** → tap the entry under *Developer App***Trust**.
Then re-run the command in step 5.2 if the install had failed.
## Step 6 — Give it your ROM and play
1. Get your `.gb` file onto the phone — AirDrop it to yourself, or save
it in iCloud Drive / Files.
2. Open the app. On the **RED** tab, tap **Import ROM** and pick your
`.gb` file in the file browser that appears. (Alternative: in the
**Files** app, drop the `.gb` into **On My iPhone → the game's
folder** and just reopen the app — it imports automatically.)
3. Import takes ~10 seconds, the button turns into **Play Red** — tap it.
## Optional goodies
- **Mods**: launcher → **MODS** tab → **Import mod .zip**.
- **Save import/export**: buttons on each game's tab, using the normal
iOS file picker.
## When things go wrong
| Symptom | Fix |
|---|---|
| `xcodebuild not found` | Xcode isn't installed or wasn't opened once — do Step 1 |
| `no Apple signing identity found` | Do Step 2, then re-run |
| `no iPhone/iPad found` | Cable in? Phone unlocked? Tapped "Trust"? |
| Install fails with "locked" | Unlock the phone, keep it unlocked, re-run |
| App icon appears then won't open | Do the two approvals in Step 5.3 |
| App stops launching after a week | Free-account 7-day limit — re-run Step 5.2 |
| "That ROM could not be imported" | The file isn't a canonical 1 MB US Red/Blue dump — the importer checks its fingerprint |
Every build/install command is safe to re-run; your saves live on the
phone and survive reinstalls.
+48
View File
@@ -0,0 +1,48 @@
# Sideload the iOS build with AltStore
Every GitHub Release ships an IPA (`gen1recomp-*-ios.ipa`). Install it on
your iPhone or iPad with [AltStore Classic](https://altstore.io/) — AltStore
re-signs the app with **your** free Apple ID so you do not need a Mac or
Xcode.
## 1. Install AltStore
Follow the official guide for your computer:
- [How to Install (Windows)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-windows)
- [How to Install (macOS)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-macos)
You will install **AltServer** on the computer, then use it to put AltStore
on the phone. What AltServer is and why it needs to stay running:
- [AltServer](https://faq.altstore.io/altstore-classic/altserver)
Stuck? Start here:
- [Troubleshooting Guide](https://faq.altstore.io/altstore-classic/troubleshooting-guide)
## 2. Install the game
1. Download `gen1recomp-*-ios.ipa` from
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
2. Open **AltStore** on the phone (AltServer must be running on the same
WiFi, or keep the phone plugged into the computer).
3. Tap **My Apps → +** (or share the IPA into AltStore) and pick the file.
4. Sign in with your Apple ID when prompted. Wait for the install to finish.
5. On first launch: Settings → **Privacy & Security → Developer Mode** (iOS
16+), and Settings → **General → VPN & Device Management** → Trust your
Apple ID if asked.
Then open the app, import your own legal `.gb` ROM on the Red/Blue tab, and
play.
## Refresh / 7-day limit
With a free Apple ID, sideloaded apps stop launching after **7 days**. Keep
AltServer running so AltStore can refresh them, or open AltStore and refresh
manually before they expire. Saves on the phone are kept across refreshes.
## Prefer building it yourself?
Building from source on a Mac (no AltStore) is covered in
[ios-install.md](ios-install.md).
+33
View File
@@ -167,5 +167,38 @@ The console understands these verbs (anything else is evaluated as Lua):
- `trace PAT | trace off` — trace events/hooks matching a glob pattern.
- `help` — list the verbs.
## Tool input and title-menu hooks
Tool mods that need to act once per game logic tick can wrap `input.step`.
It runs immediately before queued button edges are promoted, so input added by
the wrapper is visible during that same fixed step. The callback receives
`(next, game, dt)` and must call `next(game, dt)`.
`ui.title_menu.items` receives `(next, game, items)` and follows the same
decorate-after-`next` convention as `ui.start_menu.items`. It is the safe place
for a tool to offer a fresh-session action before gameplay begins.
Ephemeral tools can wrap `save.write(next, game)` and return `false` to veto a
progress write before world state is captured or any bytes reach disk.
`render.hud` receives `(next, game, viewport)` after the finished game frame is
composited and before touch controls draw. The window-space viewport contains
`width`, `height`, `gameX`, `gameY`, `gameWidth`, `gameHeight`, `scale`, `dpiX`,
and `dpiY`, so a tool can use the letterbox margins without drawing over the
playfield or pushing an updating game state.
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
the mod full control of the window, while calling `next` runs the engine's
normal single-window composite so the mod can decorate around it. `ctx` carries
the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
palette-correct blit of either canvas into an arbitrary screen rect, and the
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`)
for driving a second physical display. This is what lets a mod lay the two
passes out as two stacked Game Boy screens, or push one onto a second screen,
without the engine knowing the layout.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
+105 -7
View File
@@ -86,14 +86,20 @@ Game Boy equivalent:
## Colors mode
The `2` key (and the Options menu COLORS row) cycles the display mode
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**.
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**
(on Blue the first slot labels **OG BLUE**; on Yellow, **OG YELLOW**).
The first three are the real colorizations; the rest are DMG-shade novelties:
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
red BG palette + one green OBJ palette, every map, no per-map variation
(Pokemon Red has no CGB code, so on a GBC the boot ROM colors it globally).
The player/NPCs stay green over the red terrain via the OBP bake +
post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`).
- **OG RED** / **OG BLUE**: the Game Boy Color boot-ROM look for that cart --
one global BG palette + one OBJ palette, every map, no per-map variation
(Red/Blue ship no CGB code, so on a GBC the boot ROM colors them globally).
The player/NPCs keep the boot-ROM OBJ color over the terrain via the OBP
bake + post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`, or Blue's blue/pink
pair).
- **OG YELLOW** (Yellow playthrough, same `ogred` save id): Pokemon Yellow's
authentic GBC look from `CGBBasePalettes` (`data/palettes_yellow.lua`,
sourced from pret/pokeyellow). Per-map / per-species colors, not a single
boot-ROM ramp -- Yellow was CGB-enhanced.
- **SGB** (default): the per-map Super Game Boy region palettes
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
real SGB. (This is the mode formerly mislabeled "GBC".)
@@ -137,6 +143,41 @@ effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
its launcher because the device reports `"Linux"` while its GPU is in the
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
## Performance tier (low-end devices)
The Options **PERFORMANCE** row scales the port's optional presentation
extras down for weaker hardware. The extras it governs are the three
heaviest things the port adds on top of the original -- the whole-screen 3D
**TILT** (transforms the entire map as a ground plane), the **GBC FX**
post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out
renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard
FPS ceiling. None of this touches game logic, which is fixed-step off `dt`
(`src/core/FixedStep.lua`), so every tier plays identically; they differ
only in how much eye-candy the renderer is allowed to do.
| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling |
| ------------ | ---- | ------ | ----------- | ----------------- |
| **HIGH** | on | on | on | none |
| **BALANCED** | off | off | on | none |
| **LOW** | off | off | off | 60 |
| **AUTO** | picks a default from the device (below) |||
- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds
(e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core
desktops to **BALANCED**, and everything else -- a normal desktop, and
every existing `options.lua` that predates this option -- to **HIGH**,
so the common case is unchanged. See `src/core/Performance.detect`.
- AUTO only chooses the *default*; all four tiers are selectable, so a
wrong guess is one row away from being overridden.
- The clamps are applied **live** against your stored options and never
rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC
FX / ZOOM without forgetting them -- raising the tier restores exactly
what you had. (This is why the TILT / GBC FX / ZOOM rows still show your
saved choice on a clamped tier: it's your preference, waiting for a tier
that can afford it.)
- Persisted as `save.options.performance` (`auto` | `high` | `balanced` |
`low`); unit-tested in `tests/engine/performance_tiers.lua`.
## Peer-to-peer link play (lua-enet)
Trades and link battles connect two copies of the game directly over
@@ -151,6 +192,13 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
also exercises real UDP pairing.
Red, Blue, and Yellow copies link with each other, as the real cable
does. The compatibility fingerprint hashes only data a link mode can
actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the
only R/B/Y link-surface difference) no longer read as different games
(issue #511). Moving the fingerprint is a link parity change: builds
from before this fix will refuse to pair with builds after it.
## Fair play in link and online matches
A link session is decided by the battle and nothing else, so for its
@@ -193,6 +241,11 @@ flushes the live options. Old saves that still embed an `options` table are
migrated once into `options.lua` on load.
- Music / SFX volume
- PIKACHU VOL (0-7, Yellow only): trims Pikachu's PCM voice clips under the
SFX level, so the follower's constant chatter, the title-screen cry and
every in-battle "Pika!" can be pulled down (or muted at 0) without
quieting the rest of the sound effects. The row is hidden on Red/Blue,
which have no voice clips.
- Music Filter
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
@@ -260,6 +313,14 @@ the last controller restores it immediately. Layout re-derives from the
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
the overlay on and lets the mouse act as a finger (`=0` forces it off).
The launcher's **Touch Controls** button opens a drag editor: move each
button freely, **Disable** to hide the overlay permanently (for
controllers / emulation handhelds -- distinct from the temporary
gamepad auto-hide), **Reset** for defaults, **Done** to save into
`options.lua` as normalized window fractions so rotation keeps the
relative placement. In-game, Options → **TOUCH PAD** toggles the same
on/off flag without leaving a play session.
## Translation support
Every string the player can read is now reachable from a mod, so a
@@ -319,7 +380,8 @@ semantics - so the two windows read as one app. Six tabs:
party dock, so deposit and withdraw live in one place. Empty slots are
clickable and create a mon there.
- **Items**: money, a searchable item picker (replacing the arrows that
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
cycled one id at a time through ~250 items), the configurable bag (20 slots
by default), PC storage
with no slot cap, and the eight badges as toggle chips.
- **Events**: flags, defeated trainers, taken items and per-map object
toggles, with a real filter field and a two-column paged grid.
@@ -362,3 +424,39 @@ kind, number, height/weight, dex text) to a PNG at 4x scale under
`prints/` in the save directory, then reports the filename in a dialog.
No printer hardware or link cable emulation involved; the file is the
printout.
## Find Mods (community mod indexes)
A FIND MODS tab sits beside MODS in the launcher and browses a published
mod index: a metadata-only feed listing mods that live in their authors'
own repositories. No index ships with the launcher and none is ever added
automatically, so the tab opens on an "Add an index" prompt until you name
one; paste an index URL or its `owner/repo` and it is remembered in
`options.lua`. More than one index can be added, and the listings merge.
## Soft reset (all versions)
Holding A, B, START and SELECT together restarts the game the way flicking
a Game Boy's power switch did, dropping straight back to the title screen.
It works from anywhere, including mid-battle, which the QUIT entry on the
start menu cannot do: the original combo is how stationary and gift
Pokemon get their stats rerolled without sitting through a full relaunch.
Unsaved progress is discarded, exactly as on hardware.
As on the original, the four buttons have to stay held for 16 straight
polls (better than a quarter of a second) and any direction in the mix
cancels it, so it is hard to hit by accident -- including on the on-screen
touch controls, where it would take four fingers held on four separate
controls.
## Controls rebinding (CONTROLS screen)
OPTIONS -> CONTROLS lists every Game Boy button with its current keyboard
key and controller button side by side (Z/A). Press A on a row, then press
and release the key or pad button you want; the rebind commits on the
release. If that input already belongs to another row, the two rows swap,
so no button is ever stranded without an input and no input ever serves
two buttons. Holding a second key or pad button while the first is still
down backs out of the capture without touching a keyboard; Escape still
cancels too. SELECT clears one row back to its default, and START resets
every binding after a confirmation.
+139 -12
View File
@@ -10,7 +10,7 @@
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
local Game, EditorApp, Importer
local Game, EditorApp, Importer, TouchEditor
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
@@ -134,6 +134,26 @@ function closeEditor()
end
end
-- ------------------------------------------------------------ touch controls editor
-- Suspends the launcher while the player drags on-screen buttons / toggles
-- the overlay off (#327). No ROM cache needed -- options.lua only.
local touchEditorHost
local closeTouchControlsEditor -- forward declaration
local function openTouchControlsEditor()
touchEditorHost = Importer
Importer = nil
TouchEditor = require("src.ui.TouchControlsEditor")
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end })
end
function closeTouchControlsEditor()
if TouchEditor and TouchEditor.unload then TouchEditor.unload() end
TouchEditor = nil
Importer = touchEditorHost
touchEditorHost = nil
end
local function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue / Yellow); scripted and
-- headless runs fall back to POKEPORT_VERSION, then Red. Set the active
@@ -239,11 +259,17 @@ function love.load(args)
Importer = RomImporter.new(function(version)
Importer = nil
bootGame(version)
end, { launcher = true, forceImport = forceImport, onEditSave = openEditor })
end, {
launcher = true,
forceImport = forceImport,
onEditSave = openEditor,
onEditTouchControls = openTouchControlsEditor,
})
end
function love.update(dt)
if editorMode then return EditorApp.update(dt) end
if TouchEditor then return TouchEditor.update(dt) end
if Importer then return Importer:update(dt) end
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
@@ -283,6 +309,7 @@ end
function love.draw()
if editorMode then return EditorApp.draw() end
if TouchEditor then return TouchEditor.draw() end
if Importer then return Importer:draw() end
Game:draw()
@@ -303,36 +330,61 @@ end
function love.keypressed(key, scancode, isrepeat)
if editorMode then return EditorApp.keypressed(key) end
if TouchEditor then return TouchEditor.keypressed(key) end
if Importer then return Importer:keypressed(key) end
Game:keypressed(key)
end
function love.keyreleased(key)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return end
Game:keyreleased(key)
end
function love.gamepadpressed(joystick, button)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return Importer:gamepadpressed(joystick, button) end
Game:gamepadpressed(joystick, button)
end
function love.gamepadreleased(joystick, button)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return Importer:gamepadreleased(joystick, button) end
Game:gamepadreleased(joystick, button)
end
function love.gamepadaxis(joystick, axis, value)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
Game:gamepadaxis(joystick, axis, value)
end
function love.joystickpressed(joystick, button)
if editorMode or TouchEditor then return end
if Importer then return Importer:joystickpressed(joystick, button) end
Game:joystickpressed(joystick, button)
end
function love.joystickreleased(joystick, button)
if editorMode or TouchEditor then return end
if Importer then return Importer:joystickreleased(joystick, button) end
Game:joystickreleased(joystick, button)
end
function love.joystickaxis(joystick, axis, value)
if editorMode or TouchEditor then return end
if Importer then return Importer:joystickaxis(joystick, axis, value) end
Game:joystickaxis(joystick, axis, value)
end
function love.joystickhat(joystick, hat, direction)
if editorMode or TouchEditor then return end
if Importer then return Importer:joystickhat(joystick, hat, direction) end
Game:joystickhat(joystick, hat, direction)
end
function love.joystickremoved(joystick)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return end
Game:joystickremoved(joystick)
end
@@ -341,7 +393,7 @@ end
-- direction's key-up can be delivered to the OS instead of the game while
-- unfocused, so reset input on either transition rather than trust it.
function love.focus(f)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then
if Importer.focus then Importer:focus(f) end
return
@@ -351,25 +403,50 @@ end
-- v is true when the window becomes visible again, false on minimize.
function love.visible(v)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return end
Game:visible(v)
end
function love.touchpressed(id, x, y, dx, dy, pressure)
if editorMode then return end
if Importer then return Importer:mousepressed(x, y, 1) end
if TouchEditor then
-- iOS synthesizes mousepressed for the primary touch (same as the
-- launcher); Android drives the editor through love.touch directly.
if love.system.getOS() == "iOS" then return end
return TouchEditor.touchpressed(id, x, y)
end
if Importer then
-- iOS: LÖVE already synthesizes a mousepressed for the primary touch,
-- and love.mousepressed below forwards that to the Importer, so
-- forwarding here too fires every launcher button twice per tap. The
-- resulting double-present was fatal for the document picker: the
-- second sheet stole the first one's weakly-held delegate, so picking
-- a file silently did nothing. Android keeps the forward for upstream
-- parity (its SAF picker is a separate activity and tolerates the
-- re-launch).
if love.system.getOS() == "iOS" then return end
return Importer:mousepressed(x, y, 1)
end
Game:touchpressed(id, x, y)
end
function love.touchmoved(id, x, y, dx, dy, pressure)
if editorMode then return end
if TouchEditor then
if love.system.getOS() == "iOS" then return end
return TouchEditor.touchmoved(id, x, y)
end
if Importer then return end
Game:touchmoved(id, x, y)
end
function love.touchreleased(id, x, y, dx, dy, pressure)
if editorMode then return end
if TouchEditor then
if love.system.getOS() == "iOS" then return end
return TouchEditor.touchreleased(id, x, y)
end
if Importer then return end
Game:touchreleased(id, x, y)
end
@@ -379,12 +456,36 @@ function love.wheelmoved(x, y)
if EditorApp.wheelmoved then return EditorApp.wheelmoved(x, y) end
return
end
if TouchEditor then return end
if Importer then return end
Game:wheelmoved(x, y)
end
function love.mousepressed(x, y, button)
if Importer then return Importer:mousepressed(x, y, button) end
function love.mousepressed(x, y, button, istouch)
if TouchEditor then
-- Android primary touch already arrived via love.touchpressed; a second
-- mouse path would double-fire Done / begin a second drag.
if love.system.getOS() == "Android" then return end
return TouchEditor.mousepressed(x, y, button)
end
if Importer then
-- The same double-fire TouchEditor guards against, which the launcher was
-- missing: love.touchpressed above forwards the primary touch to the
-- Importer on Android, and LÖVE ALSO synthesizes a mouse press for that
-- same touch, so one tap ran every launcher button twice. On Import that
-- meant two choose() calls and two stacked SAF picker activities: the
-- player picked their ROM, the top picker closed, and the second was still
-- underneath asking for it again, which is the "import the file twice"
-- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a
-- USB mouse) working, which an Android-wide return would have broken.
--
-- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above
-- returns early on iOS and never forwards, so there the synthesized mouse
-- press is the ONLY event the launcher gets. Filtering istouch on both
-- killed every tap on iOS outright.
if istouch and love.system.getOS() == "Android" then return end
return Importer:mousepressed(x, y, button)
end
if editorMode and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, button)
end
@@ -394,6 +495,10 @@ function love.mousepressed(x, y, button)
end
function love.mousereleased(x, y, button)
if TouchEditor then
if love.system.getOS() == "Android" then return end
return TouchEditor.mousereleased(x, y, button)
end
if Importer then return end
if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button)
@@ -404,6 +509,10 @@ function love.mousereleased(x, y, button)
end
function love.mousemoved(x, y)
if TouchEditor then
if love.system.getOS() == "Android" then return end
return TouchEditor.mousemoved(x, y)
end
if editorMode or Importer then return end
if mouseTouch and Game and love.mouse.isDown(1) then
Game:touchmoved("mouse", x, y)
@@ -411,6 +520,7 @@ function love.mousemoved(x, y)
end
function love.textinput(text)
if TouchEditor then return end
if Importer then return Importer:textinput(text) end
if editorMode and EditorApp.textinput then
return EditorApp.textinput(text)
@@ -424,6 +534,16 @@ function love.quit()
pcall(function()
require("src.core.DiscordPresence").shutdown()
end)
-- LOVE waits for every live love.thread before the process exits, and both
-- background workers idle in a loop that only a "quit" command breaks, so
-- without this the process outlived the window and the next launch re-entered
-- the dead one instead of starting fresh (#339)
if package.loaded["src.core.ChipAudio"] then
pcall(package.loaded["src.core.ChipAudio"].shutdown)
end
if package.loaded["src.update.Check"] then
pcall(package.loaded["src.update.Check"].shutdown)
end
end
function love.filedropped(file)
@@ -461,6 +581,13 @@ function love.run()
for name, a, b, c, d, e, f in love.event.poll() do
if name == "quit" then
if not love.quit or not love.quit() then
-- Android keeps the process and its task alive after LOVE's own
-- teardown, so the relaunched task re-enters an activity whose
-- native main already returned; end the process outright once the
-- love.quit hook has run (#339)
if love.system and love.system.getOS() == "Android" then
os.exit(a or 0)
end
return a or 0
end
end
+24 -1
View File
@@ -50,6 +50,29 @@ chosen file under the app save directory as `picked_rom.gb`,
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
itself remains data-free (no embedded ROM or generated cache).
### Step bridge (Pokéwalker mod)
`love.system.syncHealthSteps()``GameActivity.syncHealthSteps` (same
JNI route as the picker: `common/android.cpp`
`modules/system/System.cpp``wrap_System.cpp`). The Java side does a
one-shot read of the hardware `TYPE_STEP_COUNTER` sensor (cumulative
since boot, counted by the OS whether or not any app runs), anchors the
reading in `SharedPreferences` so a walk is never credited twice
(a reading below the anchor means the phone rebooted → re-anchor without
crediting), and stages the delta as `steps_pending.json` in the save
identity dir — the same contract as the iOS `GRHealthBridge`. Nothing in
the base game calls it; the consumer is the
[Pokéwalker mod](https://github.com/mresnick67/Gen1ReComp-Pokewalker),
installed as a mod `.zip` at runtime (its SYNC STEPS option defaults
off).
Android 10+ gates the sensor behind the `ACTIVITY_RECOGNITION` runtime
permission (declared in `app/src/main/AndroidManifest.xml`; keep it out
of the build script's permission trim). The first
`syncHealthSteps()` call shows the system prompt; on grant the sensor
read runs immediately (`onRequestPermissionsResult`,
`STEP_PERMISSION_REQUEST_CODE`).
### SDK / NDK
love-android 11.5a expects:
@@ -82,7 +105,7 @@ scripts, tests, and mobile build sources are excluded.
| --- | --- |
| `app.application_id` | `com.theboisclub.pokemonred` |
| `app.name` | Pokemon Red |
| `app.orientation` | `portrait` |
| `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 |
| Permissions | INTERNET / RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH kept |
@@ -8,6 +8,11 @@
the link screen shows as "(Operation not permitted)" (issue #287).
scripts/build_android.sh must not strip this again. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
counter, which Android 10+ gates behind this runtime permission.
Requested only on the first sync call (the Pokéwalker mod's SYNC
STEPS option); scripts/build_android.sh must not strip it. -->
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<!-- OpenGL ES 2.0 -->
<uses-feature android:glEsVersion="0x00020000" />
<!-- Touchscreen support -->
@@ -219,6 +219,40 @@ bool showCreateDocument(const char *suggestedName)
return result;
}
bool syncHealthSteps()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "syncHealthSteps", "()Z");
jboolean result = env->CallStaticBooleanMethod(activity, method);
env->DeleteLocalRef(activity);
return result;
}
bool restartApp()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
// Old APK / new liblove skew: fail soft so HostShell.restart can fall
// back to a clean quit instead of aborting on a missing method (#575).
jmethodID method = env->GetStaticMethodID(activity, "restartApp", "()Z");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
// Does not return on success: the Java side exits the process.
jboolean result = env->CallStaticBooleanMethod(activity, method);
env->DeleteLocalRef(activity);
return result;
}
/*
* Helper functions for the filesystem module
*/
@@ -851,4 +885,51 @@ const char *getArg0()
} // android
} // love
extern "C" __attribute__((visibility("default")))
int love_android_secondary_ready()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID m = env->GetStaticMethodID(activity, "hasSecondaryDisplay", "()Z");
jboolean ready = JNI_FALSE;
if (m)
ready = env->CallStaticBooleanMethod(activity, m);
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
return ready ? 1 : 0;
}
extern "C" __attribute__((visibility("default")))
void love_android_push_secondary(const void *rgba, int w, int h)
{
if (!rgba || w <= 0 || h <= 0)
return;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID m = env->GetStaticMethodID(activity, "updateSecondaryFrame", "(Ljava/nio/ByteBuffer;II)V");
if (m)
{
jobject buf = env->NewDirectByteBuffer((void*) rgba, (jlong) w * (jlong) h * 4);
env->CallStaticVoidMethod(activity, m, buf, w, h);
env->DeleteLocalRef(buf);
}
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
}
extern "C" __attribute__((visibility("default")))
void love_android_secondary_enable(int on)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID m = env->GetStaticMethodID(activity, "setSecondaryEnabled", "(Z)V");
if (m)
env->CallStaticVoidMethod(activity, m, on ? JNI_TRUE : JNI_FALSE);
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
}
#endif // LOVE_ANDROID
@@ -74,6 +74,22 @@ bool showFilePicker(const char *destFilename = nullptr);
**/
bool showCreateDocument(const char *suggestedName = nullptr);
/**
* Pokéwalker step bridge: asks GameActivity to read the hardware step
* counter and stage steps_pending.json in the save identity dir (see
* GameActivity.syncHealthSteps). Returns whether a sync could start.
*/
bool syncHealthSteps();
/**
* Full process relaunch (GameActivity.restartApp): schedules the app's
* launch intent and kills the process, because the in-process
* quit("restart") loop double-inits physfs and crashes (#575). On success
* the process dies inside the Java call and this never returns; false
* means the relaunch could not be scheduled.
**/
bool restartApp();
/*
* Helper functions for the filesystem module
*/
@@ -212,6 +212,24 @@ bool System::createFile(const char *suggestedName) const
#endif
}
bool System::syncHealthSteps() const
{
#ifdef LOVE_ANDROID
return love::android::syncHealthSteps();
#else
return false;
#endif
}
bool System::restartApp() const
{
#ifdef LOVE_ANDROID
return love::android::restartApp();
#else
return false;
#endif
}
bool System::hasBackgroundMusic() const
{
#if defined(LOVE_ANDROID)
@@ -127,6 +127,20 @@ public:
**/
virtual bool createFile(const char *suggestedName = nullptr) const;
/**
* Pokéwalker: stage pending real-world steps (steps_pending.json in the
* save dir) from the platform step source. Android-only; false elsewhere.
*/
virtual bool syncHealthSteps() const;
/**
* Relaunches the whole app with a fresh process (Android only; false
* elsewhere). The in-process love.event.quit("restart") double-inits
* physfs on Android and crashes, so src/core/HostShell.lua calls this
* instead (#575). Does not return on success -- the process exits.
**/
virtual bool restartApp() const;
/**
* Gets if the user is playing music on background.
* Throws an exception on unsupported platforms.
@@ -109,6 +109,20 @@ int w_createFile(lua_State *L)
return 1;
}
int w_syncHealthSteps(lua_State *L)
{
luax_pushboolean(L, instance()->syncHealthSteps());
return 1;
}
int w_restartApp(lua_State *L)
{
// Does not return on success: GameActivity.restartApp exits the process
// after scheduling the relaunch (#575).
luax_pushboolean(L, instance()->restartApp());
return 1;
}
int w_hasBackgroundMusic(lua_State *L)
{
lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -126,6 +140,8 @@ static const luaL_Reg functions[] =
{ "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp },
{ "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 }
};
@@ -37,16 +37,26 @@ import java.util.List;
import java.util.Map;
import android.Manifest;
import android.app.AlarmManager;
import android.app.AlertDialog;
import android.app.PendingIntent;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.content.res.AssetManager;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.os.Vibrator;
import android.util.Log;
import android.util.DisplayMetrics;
@@ -66,6 +76,8 @@ public class GameActivity extends SDLActivity {
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
public static final int FILE_PICKER_REQUEST_CODE = 4;
public static final int FILE_CREATE_REQUEST_CODE = 5;
public static final int STEP_PERMISSION_REQUEST_CODE = 6;
public static final int RESTART_REQUEST_CODE = 7;
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
@@ -78,8 +90,31 @@ public class GameActivity extends SDLActivity {
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
// Written when a SAF pick cannot be read at all, with the destination
// basename as its body, so RomImporter:focus can say so in the launcher
// instead of leaving the player on "No ROM imported" (issue #442).
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
// Step bridge (love.system.syncHealthSteps): pending-steps delivery
// consumed by the Pokéwalker mod, same contract as the iOS
// GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
// (cumulative since boot, counted by the OS whether or not any app is
// running), anchored in SharedPreferences so a walk is never credited
// twice.
private static final String PENDING_STEPS_FILENAME = "steps_pending.json";
private static final String STEP_PREFS = "pokewalker_steps";
private static final String STEP_PREF_ANCHOR = "anchor";
private static final String STEP_PREF_ANCHOR_WALLTIME = "anchor_walltime";
private static final long STEP_MAX_PER_SYNC = 50000;
// Destination basename for the in-flight SAF pick (set by showFilePicker).
// Saved/restored across instance state: the picker is a separate activity
// and Android may destroy this one while it is up (memory pressure, or
// "Don't keep activities"). A recreated instance still receives
// onActivityResult, so without this a mod or save pick came back with the
// field reset and was filed as picked_rom.gb, which Lua then rejected as a
// bad ROM instead of installing it (#553).
private String pendingPickFilename = PICKED_ROM_FILENAME;
private static final String STATE_PENDING_PICK = "pendingPickFilename";
private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName";
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
private String pendingCreateSuggestedName = "export.sav";
private static boolean immersiveActive = false;
@@ -144,6 +179,14 @@ public class GameActivity extends SDLActivity {
}
super.onCreate(savedInstanceState);
if (savedInstanceState != null) {
// Restore the in-flight SAF destinations, so a pick that returns to
// a recreated activity still lands under the basename it asked for.
String pick = savedInstanceState.getString(STATE_PENDING_PICK);
if (pick != null) pendingPickFilename = pick;
String create = savedInstanceState.getString(STATE_PENDING_CREATE);
if (create != null) pendingCreateSuggestedName = create;
}
metrics = getResources().getDisplayMetrics();
// Set low-latency audio values
@@ -291,12 +334,62 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel();
}
teardownSecondaryDisplay();
super.onPause();
}
@Override
public void onResume() {
super.onResume();
setupSecondaryDisplay();
}
/**
* SDL decides the activity's requested orientation at window creation
* (SDLActivity.setOrientationBis). With a resizable window and no
* SDL_HINT_ORIENTATIONS -- exactly what conf.lua produces on Android --
* it asks for SCREEN_ORIENTATION_FULL_SENSOR, and that request overrides
* the android:screenOrientation="fullUser" set in the manifest. The
* *_SENSOR constants follow the accelerometer even when the player has
* turned auto-rotate off, so the game kept rotating on a device whose
* rotation was locked.
*
* Remap SDL's choice onto the matching *_USER constant, which allows the
* same orientations but defers to the system rotation setting. Applied
* after super so SDL keeps deciding *which* orientations the window may
* take; this only changes who breaks the tie, the sensor or the player.
*/
@Override
public void setOrientationBis(int w, int h, boolean resizable, String hint) {
super.setOrientationBis(w, h, resizable, hint);
// The *_USER constants only exist from API 18; below that the sensor
// ones are all there is, so leave SDL's request alone.
if (android.os.Build.VERSION.SDK_INT < 18) {
return;
}
int requested = getRequestedOrientation();
int userRequested;
switch (requested) {
case ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR:
userRequested = ActivityInfo.SCREEN_ORIENTATION_FULL_USER;
break;
case ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE:
userRequested = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE;
break;
case ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT:
userRequested = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT;
break;
default:
// SENSOR / plain LANDSCAPE / PORTRAIT etc: either already
// explicit or never produced by setOrientationBis.
return;
}
Log.d("GameActivity", "requestedOrientation " + requested + " -> " + userRequested
+ " (honour the device rotation lock)");
setRequestedOrientation(userRequested);
}
@Keep
@@ -357,15 +450,23 @@ public class GameActivity extends SDLActivity {
* Shows the system document picker (Storage Access Framework) so the
* player can pick a ROM / mod / save from anywhere (Downloads, Drive,
* etc.) without needing to know where the app's external files folder
* is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
* arrives later in onActivityResult, not synchronously here.
* is. The picked file (if any) arrives later in onActivityResult, not
* synchronously here.
*
* API 21+ uses ACTION_OPEN_DOCUMENT; API 16-20 uses an ACTION_GET_CONTENT
* chooser instead. Below 19 OPEN_DOCUMENT does not exist, and on 19/20
* the stock DocumentsUI is unreliable -- it launches and then hands back
* RESULT_CANCELED with no data, which onActivityResult cannot tell apart
* from the player cancelling (#584). GET_CONTENT lets any installed file
* manager serve the pick, and both intents return the same content:// or
* file:// URI shapes, so the result path in onActivityResult stays
* picker-agnostic and unchanged.
*
* @param destFilename basename under the app save identity (e.g.
* picked_rom.gb, picked_mod.zip, picked_save.sav)
*/
@Keep
public static boolean showFilePicker(String destFilename) {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (destFilename == null || destFilename.length() == 0) {
@@ -379,11 +480,26 @@ public class GameActivity extends SDLActivity {
}
self.pendingPickFilename = destFilename;
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
if (android.os.Build.VERSION.SDK_INT >= 21) {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
try {
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
return true;
} catch (Exception e) {
// Some OEM / TV builds ship without DocumentsUI; fall through
// to the GET_CONTENT chooser below instead of giving up (#584).
Log.d("GameActivity", "could not open document picker: " + e.getMessage());
}
}
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
try {
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
self.startActivityForResult(
Intent.createChooser(intent, "Choose a file"),
FILE_PICKER_REQUEST_CODE);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open file picker: " + e.getMessage());
@@ -409,14 +525,60 @@ public class GameActivity extends SDLActivity {
return showFilePicker(PICKED_SAVE_FILENAME);
}
/**
* Relaunches the whole app for love.system.restartApp, used by
* src/core/HostShell.lua when a mod toggle needs a cold boot (#575).
* love.event.quit("restart") re-runs LOVE's boot inside the same
* process, and the second love.filesystem.init throws once physfs
* failed to deinit ("already initialized"), killing the app. Instead
* we hand our launch intent to AlarmManager and then exit the process:
* the alarm lives in system_server, so it survives our death and
* cannot race the exit the way a plain startActivity right before
* Runtime.exit can on some OEMs, and the dead process guarantees no
* native (physfs / SDL / JNI) state leaks into the fresh run.
*/
@Keep
public static boolean restartApp() {
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
try {
Context context = self.getApplicationContext();
Intent intent = context.getPackageManager()
.getLaunchIntentForPackage(context.getPackageName());
if (intent == null) return false;
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
int pendingFlags = PendingIntent.FLAG_CANCEL_CURRENT;
if (android.os.Build.VERSION.SDK_INT >= 23) {
// Mandatory mutability flag on API 31+; harmless from 23 up.
pendingFlags |= PendingIntent.FLAG_IMMUTABLE;
}
PendingIntent pending = PendingIntent.getActivity(
context, RESTART_REQUEST_CODE, intent, pendingFlags);
AlarmManager alarm = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
if (alarm == null) return false;
alarm.set(AlarmManager.RTC, System.currentTimeMillis() + 250, pending);
} catch (Exception e) {
Log.d("GameActivity", "could not schedule restart: " + e.getMessage());
return false;
}
Runtime.getRuntime().exit(0);
return true; // unreachable, but keeps the JNI signature honest
}
/**
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive /
* etc. Suggested name is the dialog's default filename.
*
* CREATE_DOCUMENT does not exist below API 19 and has no pre-SAF
* equivalent, so unlike showFilePicker this stays 19+ (#584); the false
* return degrades on the Lua side (RomImporter export) to "Exported
* inside the app folder", which is the correct pre-KitKat behavior.
*/
@Keep
public static boolean showCreateDocument(String suggestedName) {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
// (see showFilePicker for why the import side got a pre-19 path)
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (suggestedName == null || suggestedName.length() == 0) {
@@ -452,6 +614,171 @@ public class GameActivity extends SDLActivity {
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
}
/** Drops a small flag file in the save identity for Lua to consume on focus. */
private void writeSaveDirFlag(String name, String body) {
try {
FileOutputStream fos = new FileOutputStream(new File(saveIdentityDir(), name), false);
fos.write(body.getBytes());
fos.close();
} catch (IOException e) {
Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
}
}
/**
* Step sync, called from Lua as love.system.syncHealthSteps()
* (see modules/system/wrap_System.cpp). Asynchronous like the picker:
* returns whether a sync could be started; the result lands later as
* steps_pending.json in the save identity dir, where the Pokéwalker
* mod's poll consumes it.
*
* Android 10+ gates the step counter behind the ACTIVITY_RECOGNITION
* runtime permission; the first call shows the system prompt and a later
* sync (the mod retries on save load / option change) delivers.
*/
@Keep
public static boolean syncHealthSteps() {
final GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (android.os.Build.VERSION.SDK_INT >= 29
&& ActivityCompat.checkSelfPermission(self,
Manifest.permission.ACTIVITY_RECOGNITION)
!= PackageManager.PERMISSION_GRANTED) {
self.runOnUiThread(new Runnable() {
@Override
public void run() {
ActivityCompat.requestPermissions(self,
new String[]{Manifest.permission.ACTIVITY_RECOGNITION},
STEP_PERMISSION_REQUEST_CODE);
}
});
return true;
}
self.startStepSensorRead();
return true;
}
/**
* One-shot read of the cumulative hardware step counter. The sensor
* usually reports its cached value moments after registration; some
* devices hold the event until the next physical step, so the listener
* is given 20 seconds before being torn down (the next sync retries).
*/
private void startStepSensorRead() {
final SensorManager manager =
(SensorManager) getSystemService(Context.SENSOR_SERVICE);
if (manager == null) return;
Sensor counter = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER);
if (counter == null) {
Log.d("GameActivity", "no step counter sensor on this device");
return;
}
final SensorEventListener listener = new SensorEventListener() {
private boolean delivered = false;
@Override
public void onSensorChanged(SensorEvent event) {
if (delivered || event.values.length == 0) return;
delivered = true;
manager.unregisterListener(this);
deliverSteps((long) event.values[0]);
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
}
};
if (!manager.registerListener(listener, counter,
SensorManager.SENSOR_DELAY_NORMAL)) {
Log.d("GameActivity", "step counter listener registration failed");
return;
}
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
@Override
public void run() {
// No-op if the listener already delivered and unregistered.
manager.unregisterListener(listener);
}
}, 20000);
}
/**
* Convert a cumulative counter reading into pending steps. The counter
* resets to zero on reboot: a reading below the stored anchor re-anchors
* without crediting (steps walked between the reboot and this sync are
* lost, which errs on the honest side).
*/
private void deliverSteps(long counterNow) {
SharedPreferences prefs = getSharedPreferences(STEP_PREFS, MODE_PRIVATE);
long anchor = prefs.getLong(STEP_PREF_ANCHOR, -1);
long now = System.currentTimeMillis();
if (anchor < 0 || counterNow < anchor) {
prefs.edit()
.putLong(STEP_PREF_ANCHOR, counterNow)
.putLong(STEP_PREF_ANCHOR_WALLTIME, now)
.apply();
Log.d("GameActivity", "step anchor set at " + counterNow);
return;
}
long steps = Math.min(counterNow - anchor, STEP_MAX_PER_SYNC);
long fromWalltime = prefs.getLong(STEP_PREF_ANCHOR_WALLTIME, now);
if (steps <= 0) return;
prefs.edit()
.putLong(STEP_PREF_ANCHOR, counterNow)
.putLong(STEP_PREF_ANCHOR_WALLTIME, now)
.apply();
File dir = saveIdentityDir();
if (!dir.isDirectory() && !dir.mkdirs()) {
Log.d("GameActivity", "cannot create save dir for steps: " + dir);
return;
}
File pending = new File(dir, PENDING_STEPS_FILENAME);
long total = steps;
// Merge with an unconsumed earlier delivery so steps are never lost
// (same contract as the iOS bridge).
if (pending.isFile()) {
try {
byte[] raw = new byte[(int) Math.min(pending.length(), 4096)];
FileInputStream in = new FileInputStream(pending);
int read = in.read(raw);
in.close();
if (read > 0) {
org.json.JSONObject old =
new org.json.JSONObject(new String(raw, 0, read, "UTF-8"));
total += Math.max(0, old.optLong("steps", 0));
}
} catch (Exception e) {
Log.d("GameActivity", "ignoring unreadable pending steps: " + e);
}
}
try {
org.json.JSONObject payload = new org.json.JSONObject();
payload.put("steps", total);
payload.put("from", isoTime(fromWalltime));
payload.put("to", isoTime(now));
File tmp = new File(dir, PENDING_STEPS_FILENAME + ".tmp");
FileOutputStream out = new FileOutputStream(tmp);
out.write(payload.toString().getBytes("UTF-8"));
out.close();
if (!tmp.renameTo(pending)) {
tmp.delete();
Log.d("GameActivity", "could not publish pending steps");
return;
}
Log.d("GameActivity", total + " steps pending for the Pokewalker mod");
} catch (Exception e) {
Log.d("GameActivity", "could not write pending steps: " + e);
}
}
private static String isoTime(long millis) {
java.text.SimpleDateFormat format =
new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US);
format.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
return format.format(new java.util.Date(millis));
}
private boolean copyFileToUri(File source, Uri destUri) {
InputStream in = null;
OutputStream out = null;
@@ -475,6 +802,13 @@ public class GameActivity extends SDLActivity {
}
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putString(STATE_PENDING_PICK, pendingPickFilename);
outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
@@ -491,14 +825,7 @@ public class GameActivity extends SDLActivity {
Uri uri = data.getData();
if (copyFileToUri(source, uri)) {
// Signal Lua on next focus that the SAF export finished.
File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME);
try {
FileOutputStream fos = new FileOutputStream(flag, false);
fos.write("ok".getBytes());
fos.close();
} catch (IOException e) {
Log.d("GameActivity", "could not write export_done flag: " + e.getMessage());
}
writeSaveDirFlag(EXPORT_DONE_FILENAME, "ok");
// Keep pending_export.sav so a retry still works; Lua may remove it.
} else {
Log.d("GameActivity", "could not write export to " + uri);
@@ -521,19 +848,36 @@ public class GameActivity extends SDLActivity {
? pendingPickFilename : PICKED_ROM_FILENAME;
File destFile = new File(destDir, destName);
InputStream source;
// ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but
// some OEM shells (ColorOS) offer third-party file managers in a
// chooser, and those hand back either a provider URI this app has no
// grant for (SecurityException / FileNotFoundException) or a bare
// file:// path (unreadable without storage permission on targetSdk 34).
// Try the resolver, then the path, then tell Lua why nothing imported.
InputStream source = null;
try {
source = getContentResolver().openInputStream(uri);
} catch (FileNotFoundException e) {
} catch (Exception e) {
Log.d("GameActivity", "could not open picked file: " + e.getMessage());
return;
}
if (source == null && "file".equals(uri.getScheme()) && uri.getPath() != null) {
try {
source = new FileInputStream(uri.getPath());
} catch (FileNotFoundException e) {
Log.d("GameActivity", "could not open picked path: " + e.getMessage());
}
}
if (source == null) {
Log.d("GameActivity", "ContentResolver returned no stream for picked file");
Log.d("GameActivity", "no readable stream for picked file " + uri);
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
return;
}
if (!copyAssetFile(source, destFile.getPath())) {
Log.d("GameActivity", "could not copy picked file to " + destFile);
// A truncated pick would only fail verification later, so drop it
// and report instead.
destFile.delete();
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
}
}
@@ -665,6 +1009,17 @@ public class GameActivity extends SDLActivity {
}
break;
}
case STEP_PERMISSION_REQUEST_CODE: {
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Log.d("GameActivity", "Step permission granted");
// Deliver right away so the sync the player just
// opted into doesn't wait for the next launch.
startStepSensorRead();
} else {
Log.d("GameActivity", "Did not get step permission.");
}
break;
}
default:
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
@@ -847,4 +1202,182 @@ public class GameActivity extends SDLActivity {
return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so";
}
}
// Dual-screen: mirror the engine's bottom-screen canvas onto a secondary
// physical display. Driven from the engine through love_android_secondary_*
// in src/jni/love/src/common/android.cpp.
private static volatile SecondaryPresentation secondaryPresentation;
private static volatile boolean secondaryEnabled = false;
@Keep
public static void setSecondaryEnabled(final boolean on) {
secondaryEnabled = on;
final GameActivity self = (GameActivity) mSingleton;
if (self == null) return;
self.runOnUiThread(new Runnable() {
@Override public void run() {
if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay();
}
});
}
private static void setupSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
try {
android.hardware.display.DisplayManager dm =
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
if (dm == null) return;
Display chosen = null;
for (Display d : dm.getDisplays()) {
android.graphics.Point size = new android.graphics.Point();
d.getRealSize(size);
Log.d("GameActivity", "display id=" + d.getDisplayId() + " name=" + d.getName()
+ " size=" + size.x + "x" + size.y);
if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) {
chosen = d;
}
}
if (chosen == null) {
Display[] pres =
dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
if (pres != null && pres.length > 0) chosen = pres[0];
}
if (chosen == null) {
Log.d("GameActivity", "no secondary display found");
return;
}
SecondaryPresentation p = new SecondaryPresentation(self, chosen);
p.show();
secondaryPresentation = p;
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
} catch (Throwable t) {
Log.d("GameActivity", "secondary display setup failed: " + t);
secondaryPresentation = null;
}
}
private static void teardownSecondaryDisplay() {
SecondaryPresentation p = secondaryPresentation;
secondaryPresentation = null;
if (p != null) {
try { p.dismiss(); } catch (Throwable t) {}
}
}
@Keep
public static boolean hasSecondaryDisplay() {
return secondaryPresentation != null;
}
@Keep
public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) {
SecondaryPresentation p = secondaryPresentation;
if (p != null && buf != null && w > 0 && h > 0) {
p.updateFrame(buf, w, h);
}
}
private static class SecondaryPresentation extends android.app.Presentation {
private final FrameView frameView;
SecondaryPresentation(Context context, Display display) {
super(context, display);
frameView = new FrameView(context);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
android.view.Window w = getWindow();
if (w != null) {
w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
w.setLayout(WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT);
}
setContentView(frameView);
applyImmersive();
frameView.post(new Runnable() {
@Override public void run() { applyImmersive(); }
});
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) applyImmersive();
}
private void applyImmersive() {
android.view.Window w = getWindow();
if (w == null) return;
if (android.os.Build.VERSION.SDK_INT >= 30) {
w.setDecorFitsSystemWindows(false);
android.view.WindowInsetsController c = w.getInsetsController();
if (c != null) {
c.hide(android.view.WindowInsets.Type.systemBars());
c.setSystemBarsBehavior(
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
} else {
w.getDecorView().setSystemUiVisibility(
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
}
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
frameView.updateFrame(buf, w, h);
}
}
private static class FrameView extends View {
private android.graphics.Bitmap bitmap;
private final android.graphics.Rect dst = new android.graphics.Rect();
private final android.graphics.Paint paint = new android.graphics.Paint();
private final Object lock = new Object();
private int fw, fh;
FrameView(Context context) {
super(context);
paint.setFilterBitmap(false);
paint.setAntiAlias(false);
setBackgroundColor(0xFF000000);
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
synchronized (lock) {
if (bitmap == null || fw != w || fh != h) {
if (bitmap != null) bitmap.recycle();
bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888);
fw = w; fh = h;
}
buf.rewind();
bitmap.copyPixelsFromBuffer(buf);
}
postInvalidate();
}
@Override
protected void onDraw(android.graphics.Canvas canvas) {
synchronized (lock) {
if (bitmap == null || fw == 0 || fh == 0) return;
int vw = getWidth(), vh = getHeight();
int s = Math.min(vw / fw, vh / fh);
if (s < 1) s = 1;
int dw = fw * s, dh = fh * s;
int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
dst.set(dx, dy, dx + dw, dy + dh);
canvas.drawColor(0xFF000000);
canvas.drawBitmap(bitmap, null, dst, paint);
}
}
}
}
+1 -1
View File
@@ -1 +1 @@
11.5
12.0
+36 -37
View File
@@ -1,19 +1,36 @@
# iOS build (LÖVE 11.5)
# iOS build (LÖVE 12.0)
macOS + Xcode only. Pins the official **LÖVE 11.5** iOS Xcode tree
(`love-11.5-ios-source.zip` from [love2d/love releases](https://github.com/love2d/love/releases/tag/11.5)),
matching `conf.lua`'s `t.version = "11.5"`.
> **Native ROM/mod/save import.** The iOS build ships a Swift
> document-picker bridge (`native/GRPickerBridge.swift` + `GRBootstrap.m`)
> that `patch_love_src.py` wires into the LÖVE tree on every build:
>
> - `love.system.pickFile("rom"|"mod"|"sav")` and `love.system.createFile`
> are exposed to Lua on iOS (same contract as love-android's SAF picker:
> picks land in the save dir as `picked_rom.gb` / `picked_mod.zip` /
> `picked_save.sav`; exports signal via `export_done.flag`).
> - The Info.plist overlay enables `UIFileSharingEnabled` +
> `LSSupportsOpeningDocumentsInPlace`, and `GRBootstrap.m` sweeps
> `.gb/.gbc/.zip/.sav` files dropped in Documents (Files app / Finder)
> into the LÖVE save dir on every activation — drop a ROM, open the app,
> and it imports with no taps.
> - `src/import/RomImporter.lua` treats iOS as a mobile platform and polls
> for picker results (iOS pickers are in-process modals, so Android's
> refocus rescan never fires).
>
> The note below about a missing "UIDocumentPicker handoff" is
> resolved by this bridge.
There is no separate `love2d/love-ios` GitHub repo for 11.5; the release zip
**is** the vendored iOS project (Xcode project under
`love-src/platform/xcode/love.xcodeproj`, target `love-ios`).
macOS + Xcode only. Fetches the **LÖVE 12.0** source tree and matching Apple
dependencies from the official [LÖVE source](https://github.com/love2d/love)
and [Apple dependencies](https://github.com/love2d/love-apple-dependencies)
repositories. `conf.lua` declares LÖVE 12.0 on iOS and 11.5 elsewhere.
Pin file: [`LOVE_VERSION`](./LOVE_VERSION) → `11.5`.
Pin file: [`LOVE_VERSION`](./LOVE_VERSION) → `12.0`.
## Quick start (simulator)
```bash
# Fetch LÖVE 11.5 iOS sources (once) + build for Simulator
# Fetch LÖVE 12.0 iOS sources and dependencies (once) + build for Simulator
scripts/build_ios.sh --fetch
```
@@ -60,10 +77,10 @@ Manual out-of-band steps:
| Path | Role |
|------|------|
| `LOVE_VERSION` | Engine pin (`11.5`) |
| `LOVE_VERSION` | Engine pin (`12.0`) |
| `overlays/love-ios.plist` | Portrait-only Info.plist + display name **Pokemon Red** (copied over the upstream plist every build) |
| `love-src/` | Downloaded `love-11.5-ios-source` tree (**gitignored**, do not commit) |
| `cache/` | Downloaded zips (**gitignored**) |
| `love-src/` | Downloaded LÖVE 12.0 source tree (**gitignored**, do not commit) |
| `cache/` | Temporary source and dependency checkout data (**gitignored**) |
| `build/` | `xcodebuild` derived data (**gitignored**) |
Game payload lands at:
@@ -74,28 +91,10 @@ and is fused into the built `.app` (LÖVE auto-runs any bundled `*.love`).
## Apple libraries dependency
The official `love-11.5-ios-source.zip` already ships prebuilt iOS
xcframeworks under `platform/xcode/ios/libraries/` (SDL2, LuaJIT, freetype,
ogg, vorbis, theora, modplug).
If that folder is missing or incomplete (e.g. you cloned sources without
libs), download the matching prebuilts and install them:
```bash
curl -fL -o mobile/ios/cache/love-11.5-apple-libraries.zip \
https://github.com/love2d/love/releases/download/11.5/love-11.5-apple-libraries.zip
unzip -q mobile/ios/cache/love-11.5-apple-libraries.zip -d mobile/ios/cache
rm -rf mobile/ios/love-src/platform/xcode/ios/libraries
cp -R mobile/ios/cache/love-apple-dependencies/iOS/libraries \
mobile/ios/love-src/platform/xcode/ios/libraries
```
`scripts/build_ios.sh` checks for `libraries/SDL2.xcframework` and fails with
these instructions if it is absent.
Upstream also documents
[love-apple-dependencies](https://github.com/love2d/love-apple-dependencies)
as an alternate source of the same libraries.
`scripts/build_ios.sh --fetch` retrieves the matching iOS libraries and the
SDL3 framework from
[love-apple-dependencies](https://github.com/love2d/love-apple-dependencies).
Re-run it if either dependency directory is absent.
## App identity
@@ -114,7 +113,7 @@ so refreshing `love-src/` does not lose branding.
| Flag | Meaning |
|------|---------|
| *(default)* | Simulator, Debug, no signing |
| `--fetch` | Download/extract `love-11.5-ios-source.zip` if `love-src/` is missing |
| `--fetch` | Fetch the LÖVE 12.0 source tree and Apple dependencies if `love-src/` is missing |
| `--device` | Build against `iphoneos` instead of `iphonesimulator` |
| `--release` | `Release` configuration instead of `Debug` |
| `--package-only` | Zip `game.love` + apply plist overlay; skip `xcodebuild` |
@@ -127,5 +126,5 @@ Also: `scripts/build.sh ios` delegates here (`--release` is forwarded).
- iOS platform installed in Xcode (Settings → Platforms). `xcodebuild -showsdks`
should list `iphonesimulator` / `iphoneos`. A partial install can fail IB/xib
compiles with `iOS … Platform Not Installed` even when the SDK name appears.
- `love-src/` present (`--fetch` or manual unzip of `love-11.5-ios-source.zip`)
- iOS libraries under `love-src/platform/xcode/ios/libraries/` (see above)
- `love-src/` present (`--fetch`)
- iOS libraries under `love-src/platform/xcode/ios/libraries/` and SDL3 under `love-src/platform/xcode/shared/Frameworks/`
+23
View File
@@ -0,0 +1,23 @@
// gen1recomp iOS native bridge bootstrap.
//
// Runs the Files-app inbox sweep (GRPickerBridge.sweepInbox) every time the
// app becomes active, so ROMs/mods/saves dropped into the app's Documents
// folder land in the LÖVE save directory before the Lua importer rescans.
// Registered from a constructor so no LÖVE/SDL source needs to know about it.
#import <UIKit/UIKit.h>
__attribute__((constructor))
static void GRBootstrapInstall(void)
{
[[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationDidBecomeActiveNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
Class bridge = NSClassFromString(@"GRPickerBridge");
if ([bridge respondsToSelector:@selector(sweepInbox)]) {
[bridge performSelector:@selector(sweepInbox)];
}
}];
}
+121
View File
@@ -0,0 +1,121 @@
// gen1recomp iOS native bridge: Apple Health step sync (Pokéwalker mode).
//
// Reached from liblove's wrap_System.cpp through the Objective-C runtime as
// love.system.syncHealthSteps() see mobile/ios/patch_love_src.py. The Lua
// side (mods/pokewalker) opts in, calls sync, and later consumes
// steps_pending.json from the LÖVE save directory:
//
// { "steps": 4312, "from": "<ISO8601>", "to": "<ISO8601>" }
//
// Steps are counted from a persisted anchor (last successful sync; first
// run starts at midnight today) so the same walk is never credited twice.
// An unconsumed pending file is merged, not overwritten, so steps survive
// the player quitting before entering the overworld.
import Foundation
import HealthKit
@objc(GRHealthBridge)
public final class GRHealthBridge: NSObject {
private static let store = HKHealthStore()
private static let anchorKey = "GRHealthLastSyncDate"
private static let pendingName = "steps_pending.json"
// Sanity clamp per sync: guards against absurd backlogs (device clock
// changes, months-old anchors) turning into instant level-100 parties.
private static let maxStepsPerSync = 50_000
/// love.system.syncHealthSteps() -> bool ("sync started").
/// Requests read authorization on first use (system sheet appears over
/// the game), then asynchronously writes the pending-steps file.
@objc(syncStepsWithCommand:saveDir:)
public static func syncSteps(command: UnsafePointer<CChar>?,
saveDir: UnsafePointer<CChar>?) -> Bool {
guard HKHealthStore.isHealthDataAvailable(),
let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)
else { return false }
let dir = saveDir.map { String(cString: $0) } ?? ""
guard !dir.isEmpty else { return false }
store.requestAuthorization(toShare: nil, read: [stepType]) { _, error in
if let error = error {
NSLog("GRHealthBridge: authorization error: \(error)")
return
}
// Read authorization is intentionally opaque (a denied query
// just returns no samples), so always run the query.
runQuery(stepType: stepType, dir: dir)
}
return true
}
// MARK: - Query
private static func runQuery(stepType: HKQuantityType, dir: String) {
let defaults = UserDefaults.standard
let now = Date()
let anchor = (defaults.object(forKey: anchorKey) as? Date)
?? Calendar.current.startOfDay(for: now)
guard anchor < now else { return }
let predicate = HKQuery.predicateForSamples(withStart: anchor, end: now,
options: .strictStartDate)
let query = HKStatisticsQuery(quantityType: stepType,
quantitySamplePredicate: predicate,
options: .cumulativeSum) { _, stats, error in
if let error = error {
// Typical on first run before the user answers the sheet,
// or when access is denied; the anchor is left untouched so
// the next sync retries the same window.
NSLog("GRHealthBridge: step query error: \(error)")
return
}
let steps = Int(stats?.sumQuantity()?.doubleValue(for: .count()) ?? 0)
defaults.set(now, forKey: anchorKey)
deliver(steps: min(steps, maxStepsPerSync),
from: anchor, to: now, dir: dir)
}
store.execute(query)
}
// MARK: - Pending file
private static func deliver(steps: Int, from: Date, to: Date, dir: String) {
let url = URL(fileURLWithPath: dir).appendingPathComponent(pendingName)
// Merge with an unconsumed earlier delivery so steps are never lost.
var total = steps
var fromDate = from
if let data = try? Data(contentsOf: url),
let old = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
total += (old["steps"] as? Int) ?? 0
if let oldFrom = old["from"] as? String,
let parsed = isoFormatter.date(from: oldFrom), parsed < fromDate {
fromDate = parsed
}
}
guard total > 0 else { return }
let payload: [String: Any] = [
"steps": total,
"from": isoFormatter.string(from: fromDate),
"to": isoFormatter.string(from: to),
]
do {
try FileManager.default.createDirectory(
at: URL(fileURLWithPath: dir, isDirectory: true),
withIntermediateDirectories: true)
let data = try JSONSerialization.data(withJSONObject: payload)
try data.write(to: url, options: .atomic)
NSLog("GRHealthBridge: %d steps pending", total)
} catch {
NSLog("GRHealthBridge: could not write pending steps: \(error)")
}
}
private static let isoFormatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
}
+218
View File
@@ -0,0 +1,218 @@
// gen1recomp iOS native bridge: document picker + Files-app inbox sweep.
//
// liblove's wrap_System.cpp calls into this class through the Objective-C
// runtime (objc_getClass / objc_msgSend), so nothing here may be renamed
// without updating that patch (see mobile/ios/patch_love_src.py).
//
// Contract (mirrors love-android's GameActivity.showFilePicker):
// love.system.pickFile("rom"|"mod"|"sav") -> copies the user's pick into
// the LÖVE save directory as picked_rom.gb / picked_mod.zip /
// picked_save.sav; RomImporter's pending-file scan consumes it.
// love.system.createFile(name) -> exports save dir's pending_export.sav
// through the system picker, then writes export_done.flag.
import UIKit
import UniformTypeIdentifiers
@objc(GRPickerBridge)
public final class GRPickerBridge: NSObject {
// Every live picker keeps its own delegate here
// (UIDocumentPickerViewController holds its delegate weakly). A single
// "current delegate" slot would break under double activation: the
// engine's touch handling can fire a button twice (touch + synthesized
// mouse), the second present would replace the first picker's delegate,
// and the sheet the user actually sees would then pick into nil
// silently doing nothing.
private static var liveDelegates: [PickerDelegate] = []
// conf.lua t.identity where LÖVE puts the fused save directory on iOS
// (<sandbox>/Library/Application Support/<identity>).
private static let loveIdentity = "pokemon-love2d"
// MARK: - Entry points called from liblove (C strings on purpose)
@objc(presentPickerWithKind:saveDir:)
public static func presentPicker(kind: UnsafePointer<CChar>?,
saveDir: UnsafePointer<CChar>?) -> Bool {
let kindStr = kind.map { String(cString: $0) } ?? "rom"
guard let dir = resolvedSaveDir(saveDir) else { return false }
let destName: String
var types: [UTType] = []
switch kindStr {
case "mod":
destName = "picked_mod.zip"
types = [.zip]
case "sav":
destName = "picked_save.sav"
default:
destName = "picked_rom.gb"
for ext in ["gb", "gbc"] {
if let t = UTType(filenameExtension: ext) { types.append(t) }
}
}
// .gb/.gbc/.sav resolve to dynamic UTTypes on most devices; offering
// .data as well keeps every real file selectable. The importer
// validates by size + SHA-1, so a wrong pick is rejected safely.
types.append(.data)
if !types.contains(.item) { types.append(.item) }
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types,
asCopy: true)
picker.allowsMultipleSelection = false
let delegate = PickerDelegate { urls in
guard let src = urls.first else { return }
copyItem(at: src, into: dir, named: destName)
}
return present(picker, with: delegate)
}
@objc(presentExportWithName:saveDir:)
public static func presentExport(name: UnsafePointer<CChar>?,
saveDir: UnsafePointer<CChar>?) -> Bool {
let suggested = name.map { String(cString: $0) } ?? "export.sav"
guard let dir = resolvedSaveDir(saveDir) else { return false }
let staged = dir.appendingPathComponent("pending_export.sav")
guard FileManager.default.fileExists(atPath: staged.path) else { return false }
// Stage under the suggested name so the picker's filename field is
// prefilled; forExporting moves/copies it to the user's destination.
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent(suggested)
try? FileManager.default.removeItem(at: tmp)
do {
try FileManager.default.copyItem(at: staged, to: tmp)
} catch {
NSLog("GRPickerBridge: staging export failed: \(error)")
return false
}
let picker = UIDocumentPickerViewController(forExporting: [tmp], asCopy: true)
let delegate = PickerDelegate { urls in
guard !urls.isEmpty else { return }
// Same completion signal love-android's GameActivity writes;
// RomImporter:focus consumes it and clears pending_export.sav.
let flag = dir.appendingPathComponent("export_done.flag")
try? "ok".data(using: .utf8)?.write(to: flag)
}
return present(picker, with: delegate)
}
// Moves ROM/mod/save files the user dropped into the app's Documents
// folder (Files app / Finder file sharing) into the LÖVE save directory,
// where the importer's pending-file scan looks. Called on every
// UIApplicationDidBecomeActive (see GRBootstrap.m).
@objc public static func sweepInbox() {
let fm = FileManager.default
guard let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first,
let appSupport = fm.urls(for: .applicationSupportDirectory,
in: .userDomainMask).first else { return }
let saveDir = appSupport.appendingPathComponent(loveIdentity, isDirectory: true)
let wanted: Set<String> = ["gb", "gbc", "zip", "sav"]
guard let items = try? fm.contentsOfDirectory(at: docs,
includingPropertiesForKeys: nil) else { return }
for url in items where wanted.contains(url.pathExtension.lowercased()) {
ensureDirectory(saveDir)
let dest = saveDir.appendingPathComponent(url.lastPathComponent)
try? fm.removeItem(at: dest)
do {
try fm.moveItem(at: url, to: dest)
NSLog("GRPickerBridge: swept %@ into save dir", url.lastPathComponent)
} catch {
NSLog("GRPickerBridge: sweep failed for \(url.lastPathComponent): \(error)")
}
}
}
// MARK: - Helpers
private static func resolvedSaveDir(_ cstr: UnsafePointer<CChar>?) -> URL? {
var dir = cstr.map { String(cString: $0) } ?? ""
if dir.isEmpty {
guard let appSupport = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
else { return nil }
dir = appSupport.appendingPathComponent(loveIdentity).path
}
let url = URL(fileURLWithPath: dir, isDirectory: true)
ensureDirectory(url)
return url
}
private static func ensureDirectory(_ url: URL) {
try? FileManager.default.createDirectory(at: url,
withIntermediateDirectories: true)
}
private static func copyItem(at src: URL, into dir: URL, named name: String) {
let scoped = src.startAccessingSecurityScopedResource()
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
ensureDirectory(dir)
let dest = dir.appendingPathComponent(name)
try? FileManager.default.removeItem(at: dest)
do {
try FileManager.default.copyItem(at: src, to: dest)
NSLog("GRPickerBridge: delivered %@", name)
} catch {
NSLog("GRPickerBridge: copy failed: \(error)")
// Surface the failure in-game: the Lua pick poll turns this
// into an on-screen notice instead of a silent no-op.
let report = "Could not copy \(src.lastPathComponent): " +
error.localizedDescription
try? report.data(using: .utf8)?
.write(to: dir.appendingPathComponent("pick_error.txt"))
}
}
private static func present(_ picker: UIDocumentPickerViewController,
with delegate: PickerDelegate) -> Bool {
let doPresent = { () -> Bool in
// Double activation (touch + synthesized mouse) arrives within
// one frame before UIKit even exposes the first sheet via
// presentedViewController so gate on our own delegate list.
// Without this the stacked present makes the picker auto-dismiss
// with zero documents (observed as didPickDocumentsAt 0 urls)
// and the user's pick silently does nothing.
guard liveDelegates.isEmpty else {
NSLog("GRPickerBridge: picker already active; ignoring re-present")
return true
}
guard var top = UIApplication.shared.windows
.first(where: { $0.isKeyWindow })?.rootViewController
else { return false }
while let presented = top.presentedViewController { top = presented }
picker.delegate = delegate
liveDelegates.append(delegate)
delegate.onFinish = { [weak delegate] in
liveDelegates.removeAll { $0 === delegate }
}
top.present(picker, animated: true)
return true
}
if Thread.isMainThread { return doPresent() }
var ok = false
DispatchQueue.main.sync { ok = doPresent() }
return ok
}
}
private final class PickerDelegate: NSObject, UIDocumentPickerDelegate {
private let onPick: ([URL]) -> Void
var onFinish: (() -> Void)?
init(onPick: @escaping ([URL]) -> Void) { self.onPick = onPick }
func documentPicker(_ controller: UIDocumentPickerViewController,
didPickDocumentsAt urls: [URL]) {
NSLog("GRPickerBridge: didPickDocumentsAt %d url(s)", urls.count)
onPick(urls)
onFinish?()
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
// No file was written; the Lua side's pending-file poll simply
// never finds anything.
NSLog("GRPickerBridge: picker cancelled")
onFinish?()
}
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- gen1recomp iOS: read-only step counts for the Pokéwalker mod
(GRHealthBridge.swift / mods/pokewalker). -->
<key>com.apple.developer.healthkit</key>
<true/>
</dict>
</plist>
+13 -6
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Pokemon Red</string>
<string>gen1recomp</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
@@ -40,7 +40,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
@@ -49,10 +49,6 @@
<false/>
<key>UILaunchStoryboardName</key>
<string>Launch Screen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>opengles-2</string>
</array>
<key>UIStatusBarHidden</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
@@ -100,5 +96,16 @@
</dict>
</dict>
</array>
<!-- gen1recomp iOS: expose the app's Documents folder in the Files app /
Finder so ROMs, mod .zips, and .sav files can be dropped in without
the picker; GRBootstrap sweeps them into the LÖVE save dir. -->
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<!-- gen1recomp iOS: Pokéwalker mod reads step counts (opt-in, in the
in-game mod manager) and converts them to Pokémon EXP. -->
<key>NSHealthShareUsageDescription</key>
<string>The Pokéwalker mod reads your step count to give your Pokémon party experience points for real-world walking.</string>
</dict>
</plist>
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Applies gen1recomp's iOS native-bridge patches to the fetched LÖVE 12.0
source tree (mobile/ios/love-src/). Idempotent AND re-appliable: the first
run stashes a pristine `.orig` copy of every file it rewrites, and later
runs always start over from that copy so editing the patch content here
just works on the next build, no manual restore needed.
What it does:
1. Copies mobile/ios/native/ (GRPickerBridge.swift, GRHealthBridge.swift,
GRBootstrap.m) and the HealthKit entitlements into the LÖVE tree.
2. Patches liblove's wrap_System.cpp to expose love.system.pickFile,
love.system.createFile, and love.system.syncHealthSteps on iOS (each
calls a GR*Bridge Swift class through the Objective-C runtime, so
liblove never links against Swift directly).
3. Patches love.xcodeproj so the love-ios app target compiles the native
files (Swift 5, iOS 14 deployment for UTType/forExporting APIs).
"""
import re
import shutil
import sys
from pathlib import Path
IOS_DIR = Path(__file__).resolve().parent
LOVE_SRC = IOS_DIR / "love-src"
NATIVE_SRC = IOS_DIR / "native"
NATIVE_DST = LOVE_SRC / "platform" / "xcode" / "ios" / "native"
WRAP_SYSTEM = LOVE_SRC / "src" / "modules" / "system" / "wrap_System.cpp"
PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj"
ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements"
NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m")
MARKER = "gen1recomp iOS picker bridge"
# Headers must land outside `namespace love { namespace system {`.
WRAP_INCLUDES = """
// %s: headers for the native-bridge functions below.
#ifdef LOVE_IOS
#include <objc/runtime.h>
#include <objc/message.h>
#include <string>
#include "filesystem/Filesystem.h"
#endif
""" % MARKER
WRAP_FUNCS = """
// --- %s -------------------------------------------------
// love.system.pickFile / createFile / syncHealthSteps for iOS. pickFile and
// createFile mirror the love-android extension this project's importer
// already targets; syncHealthSteps feeds the Pokéwalker mod. Implemented in
// Swift (GR*Bridge classes, love-ios app target); reached via the ObjC
// runtime so liblove itself needs no Swift interop.
#ifdef LOVE_IOS
static const char *gr_saveDirectory()
{
static std::string saveDirectory;
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
if (fs == nullptr)
return "";
saveDirectory = fs->getSaveDirectory();
return saveDirectory.c_str();
}
static int gr_callBridge(lua_State *L, const char *className,
const char *selector, const char *arg)
{
Class cls = objc_getClass(className);
if (cls == nullptr)
{
lua_pushboolean(L, 0);
return 1;
}
typedef signed char (*GRMsg)(Class, SEL, const char *, const char *);
signed char ok = ((GRMsg)objc_msgSend)(cls, sel_registerName(selector),
arg, gr_saveDirectory());
lua_pushboolean(L, ok != 0);
return 1;
}
int w_pickFile(lua_State *L)
{
const char *kind = luaL_optstring(L, 1, "rom");
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
}
int w_createFile(lua_State *L)
{
const char *name = luaL_optstring(L, 1, "export.sav");
return gr_callBridge(L, "GRPickerBridge", "presentExportWithName:saveDir:", name);
}
int w_syncHealthSteps(lua_State *L)
{
return gr_callBridge(L, "GRHealthBridge", "syncStepsWithCommand:saveDir:", "sync");
}
#endif // LOVE_IOS
// ---------------------------------------------------------------------------
""" % MARKER
WRAP_REGISTRATION = """#ifdef LOVE_IOS
{ "pickFile", w_pickFile },
{ "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
#endif
"""
# Deterministic 24-hex-digit object IDs, chosen not to collide with the
# upstream project (grep-verified against love-11.5's pbxproj).
ID_FILE_PICKER = "6E1AC0DE0001000000000001"
ID_FILE_OBJC = "6E1AC0DE0001000000000002"
ID_FILE_HEALTH = "6E1AC0DE0001000000000003"
ID_BUILD_PICKER = "6E1AC0DE0002000000000001"
ID_BUILD_OBJC = "6E1AC0DE0002000000000002"
ID_BUILD_HEALTH = "6E1AC0DE0002000000000003"
SOURCES_PHASE_ID = "FA0B7F021A95AAF3000E1D17" # love-ios Sources phase
IOS_APP_CONFIG_IDS = (
"FA0B7F261A95AAF4000E1D17", # Debug
"FA0B7F271A95AAF4000E1D17", # Release
"FA0B7F281A95AAF4000E1D17", # Distribution
)
PBX_SOURCES = (
("GRPickerBridge.swift", ID_FILE_PICKER, ID_BUILD_PICKER, "sourcecode.swift"),
("GRHealthBridge.swift", ID_FILE_HEALTH, ID_BUILD_HEALTH, "sourcecode.swift"),
("GRBootstrap.m", ID_FILE_OBJC, ID_BUILD_OBJC, "sourcecode.c.objc"),
)
def fail(msg):
print(f"patch_love_src: error: {msg}", file=sys.stderr)
sys.exit(1)
def pristine(path: Path) -> str:
"""Text of `path` before any of our patching: backed by a `.orig` stash.
The stash is only trusted if it is itself unpatched; that protects
against a stash accidentally taken after an earlier patch run.
"""
orig = path.with_suffix(path.suffix + ".orig")
if orig.is_file():
text = orig.read_text()
if MARKER not in text and ID_FILE_PICKER not in text:
return text
text = path.read_text()
if MARKER in text or ID_FILE_PICKER in text:
fail(f"{path} is already patched and no pristine .orig stash exists;\n"
f" delete {LOVE_SRC} and re-run scripts/build_ios.sh --fetch")
orig.write_text(text)
return text
def copy_native_files():
NATIVE_DST.mkdir(parents=True, exist_ok=True)
for name in NATIVE_FILES:
src = NATIVE_SRC / name
if not src.is_file():
fail(f"missing {src}")
shutil.copy2(src, NATIVE_DST / name)
if not ENTITLEMENTS_SRC.is_file():
fail(f"missing {ENTITLEMENTS_SRC}")
shutil.copy2(ENTITLEMENTS_SRC, NATIVE_DST / "love-ios.entitlements")
print(f"patch_love_src: native files -> {NATIVE_DST}")
def patch_wrap_system():
text = pristine(WRAP_SYSTEM)
include_anchor = '#include "sdl/System.h"\n'
if include_anchor not in text:
fail(f"include anchor not found in {WRAP_SYSTEM}")
text = text.replace(include_anchor, include_anchor + WRAP_INCLUDES, 1)
anchor = "static const luaL_Reg functions[] ="
if anchor not in text:
fail(f"anchor not found in {WRAP_SYSTEM}")
text = text.replace(anchor, WRAP_FUNCS + anchor, 1)
reg_anchor = '\t{ "vibrate", w_vibrate },\n'
if reg_anchor not in text:
fail(f"registration anchor not found in {WRAP_SYSTEM}")
text = text.replace(reg_anchor, reg_anchor + WRAP_REGISTRATION, 1)
WRAP_SYSTEM.write_text(text)
print("patch_love_src: wrap_System.cpp patched "
"(pickFile/createFile/syncHealthSteps)")
def patch_pbxproj():
text = pristine(PBXPROJ)
build_files = "".join(
f"\t\t{build_id} /* {name} in Sources */ = "
f"{{isa = PBXBuildFile; fileRef = {file_id} /* {name} */; }};\n"
for name, file_id, build_id, _ in PBX_SOURCES
)
anchor = "/* Begin PBXBuildFile section */\n"
if anchor not in text:
fail("PBXBuildFile section not found")
text = text.replace(anchor, anchor + build_files, 1)
file_refs = "".join(
f"\t\t{file_id} /* {name} */ = "
f"{{isa = PBXFileReference; lastKnownFileType = {ftype}; "
f"name = {name}; path = ios/native/{name}; "
f"sourceTree = SOURCE_ROOT; }};\n"
for name, file_id, _, ftype in PBX_SOURCES
)
anchor = "/* Begin PBXFileReference section */\n"
if anchor not in text:
fail("PBXFileReference section not found")
text = text.replace(anchor, anchor + file_refs, 1)
# Add the files to the love-ios Sources phase.
phase_re = re.compile(
re.escape(SOURCES_PHASE_ID)
+ r" /\* Sources \*/ = \{.*?files = \(\n", re.S)
m = phase_re.search(text)
if not m:
fail("love-ios Sources phase not found")
insertion = "".join(
f"\t\t\t\t{build_id} /* {name} in Sources */,\n"
for name, _, build_id, _ in PBX_SOURCES
)
text = text[: m.end()] + insertion + text[m.end():]
# Swift + modern deployment target + HealthKit entitlements on the
# love-ios app target only (UTType and forExporting need iOS 14;
# liblove stays as upstream).
for config_id in IOS_APP_CONFIG_IDS:
cfg_re = re.compile(
re.escape(config_id) + r" /\* \w+ \*/ = \{.*?buildSettings = \{\n",
re.S)
m = cfg_re.search(text)
if not m:
fail(f"build configuration {config_id} not found")
settings = (
"\t\t\t\tSWIFT_VERSION = 5.0;\n"
"\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n"
'\t\t\t\tCODE_SIGN_ENTITLEMENTS = "ios/native/love-ios.entitlements";\n'
)
text = text[: m.end()] + settings + text[m.end():]
PBXPROJ.write_text(text)
print("patch_love_src: love.xcodeproj patched (native sources + Swift + entitlements)")
def main():
if not LOVE_SRC.is_dir():
fail("love-src/ missing; run scripts/build_ios.sh --fetch first")
copy_native_files()
patch_wrap_system()
patch_pbxproj()
if __name__ == "__main__":
main()
+13
View File
@@ -0,0 +1,13 @@
# Nuzlocke
An enforced Gen 1 Nuzlocke.
Oak configures Slow Start, whether duplicate evolutionary families are skipped
or consume an area's encounter, and whether Safari maps are separate areas.
After Slow Start, the mod enforces mandatory nicknames for starters, gifts,
and catches; one capture per area; no duplicate evolutionary families; and
permanent death. A party Pokémon says that it died and is removed immediately.
If the final party member dies, the game runs the credits to THE END and then
deletes the active save.
+224
View File
@@ -0,0 +1,224 @@
-- Nuzlocke rules. Uses the current engine's internal seams while the
-- equivalent public API hooks are being added.
return function(mod)
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
steps = next(steps, speech)
mod.ui.insertStepAfter(steps, "oak_welcome", {
id = "nuzlocke_intro", kind = "say", pic = "oak",
text = "A Nuzlocke is a\npromise.\fEvery loss is\npermanent.",
})
mod.ui.insertStepAfter(steps, "nuzlocke_intro", {
id = "nuzlocke_slow_start", kind = "yesno", pic = "oak",
saveKey = "slow_start", defaultNo = true,
text = "Use SLOW START?\nRules start with\nPOKé BALLS.",
})
mod.ui.insertStepAfter(steps, "nuzlocke_slow_start", {
id = "nuzlocke_dupes", kind = "choice", pic = "oak",
saveKey = "dupes_mode", text = "When you meet a\nknown family?",
choices = { "SKIP", "LOSE" }, values = { "skip", "strict" },
})
mod.ui.insertStepAfter(steps, "nuzlocke_dupes", {
id = "nuzlocke_safari", kind = "yesno", pic = "oak",
saveKey = "safari_sectors", text = "Separate SAFARI\nsectors?",
})
mod.ui.insertStepAfter(steps, "nuzlocke_safari", {
id = "nuzlocke_close", kind = "say", pic = "oak",
text = "Give every friend\na name. Keep them\nsafe. Good luck!",
})
return steps
end)
mod.events:on("intro.oak_speech.answered", function(ev)
if ev.saveKey then mod.save:set(ev.saveKey, ev.value) end
end)
local function active(game, battle)
if not (game and game.save) or (battle and (battle.demo or battle.ghost)) then return false end
if not mod.save:get("slow_start", false) then return true end
if mod.save:get("balls_unlocked", false) then return true end
for id, count in pairs(game.save.inventory or {}) do
if count > 0 and game.data.items[id] and game.data.items[id].ball then
mod.save:set("balls_unlocked", true)
return true
end
end
return false
end
local function areaKey(game, battle)
if battle and battle.safari and not mod.save:get("safari_sectors", false) then
return "SAFARI_ZONE"
end
return (game.overworld and game.overworld.map and game.overworld.map.id)
or (game.save.player and game.save.player.map) or "UNKNOWN"
end
local function family(data, species)
local found, pending = {}, { species }
while #pending > 0 do
local id = table.remove(pending)
if not found[id] then
found[id] = true
for _, evo in ipairs((data.pokemon[id] or {}).evolutions or {}) do pending[#pending + 1] = evo.species end
for parent, def in pairs(data.pokemon or {}) do
for _, evo in ipairs(def.evolutions or {}) do
if evo.species == id then pending[#pending + 1] = parent end
end
end
end
end
return found
end
local function ownsFamily(game, species)
local members = family(game.data, species)
local function owns(mon) return mon and members[mon.species] end
for _, mon in ipairs(game.save.party or {}) do if owns(mon) then return true end end
for _, box in ipairs(game.save.boxes or {}) do
for _, mon in ipairs(box) do if owns(mon) then return true end end
end
return false
end
local function caughtAreas()
local areas = mod.save:get("caught_areas")
if type(areas) ~= "table" then areas = {}; mod.save:set("caught_areas", areas) end
return areas
end
local function denied(game, battle, species)
if not active(game, battle) then return nil end
if caughtAreas()[areaKey(game, battle)] then return "area" end
if ownsFamily(game, species) then return "dupes" end
end
mod.events:on("pokemon.caught", function(ev)
-- A successful capture proves that Slow Start has ended even when it was
-- the last ball in the bag.
if mod.save:get("slow_start", false) then mod.save:set("balls_unlocked", true) end
if active(ev.game, ev.battle) then
caughtAreas()[areaKey(ev.game, ev.battle)] = ev.species
mod.save:set("caught_areas", caughtAreas())
end
end)
mod.events:on("game.ready", function()
local BattleState = require("src.battle.BattleState")
local Commands = require("src.script.Commands")
local Party = require("src.pokemon.Party")
local Boxes = require("src.pokemon.Boxes")
local Pokemon = require("src.pokemon.Pokemon")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Strings = require("src.core.Strings")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local Bag = require("src.inventory.Bag")
BattleState.askNicknameUI = function(self, mon)
self.lockedBall, self.blankForAskName = nil, false
return self:buildScreen("NamingScreen", {
title = Strings("NICKNAME?"), maxLen = 10,
onDone = function(name) mon.nickname = name or "A" end,
})
end
-- Gifts and starters use the same mandatory naming screen.
Commands.give_pokemon = function(ctx, species, level)
local gift = { ctx = ctx, species = species, level = level }
if ctx.game.mods then ctx.game.mods.events:emit("pokemon.before_give", gift) end
local mon = Pokemon.new(ctx.game.data, gift.species, gift.level)
ctx.game.stringBuffer, ctx.pendingPokemonName = ctx.game.data.pokemon[gift.species].name or gift.species, gift.species
BattleState.stampOT(ctx.save, mon)
local inParty = Party.add(ctx.save.party, mon)
local boxNum = inParty and nil or Boxes.deposit(ctx.save, mon)
if not inParty and not boxNum then ctx.lastCheck = false; return end
if ctx.save.pokedex then ctx.save.pokedex.seen[gift.species], ctx.save.pokedex.owned[gift.species] = true, true end
ctx.lastCheck, ctx.addedToParty, ctx.boxNum = true, inParty, boxNum
if ctx.runner then
Screens.push(ctx.game, "NamingScreen", {
title = Strings("NICKNAME?"), maxLen = 10,
onDone = function(name) mon.nickname = name or "A"; ctx.runner:resume() end,
})
ctx.runner:yield()
else mon.nickname = "A" end
if boxNum then
ctx.game.boxMonNicks, ctx.game.stringBuffer = mon.nickname, tostring(boxNum)
if ctx.runner then Commands.show_text(ctx, "_SentToBoxText") end
end
end
local vanillaThrowBall = BattleState.throwBall
BattleState.throwBall = function(self, ball)
local reason = denied(self.game, self, self.enemy and self.enemy.mon.species)
if reason then
if reason == "dupes" and mod.save:get("dupes_mode", "skip") == "strict" then
caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST"
mod.save:set("caught_areas", caughtAreas())
end
Bag.add(self.game.save, ball, 1, self.game.data)
self:say(reason == "area" and "This area already\nhas a captured POKéMON!"
or "You already have\nthis POKéMON family!")
return
end
return vanillaThrowBall(self, ball)
end
local vanillaOnFaint = BattleState.onFaint
BattleState.onFaint = function(self, battler)
if not (battler.isPlayer and active(self.game, self)) then return vanillaOnFaint(self, battler) end
if battler.faintQueued then return end
battler.faintQueued = true
if self.participants then self.participants[battler.mon] = nil end
Runtime.emit("battle.fainted", { battle = self, battler = battler })
for i, mon in ipairs(self.game.save.party) do
if mon == battler.mon then table.remove(self.game.save.party, i); break end
end
self:actNext(function()
battler.fainted = true
require("src.core.Sound").playCry(self.data, battler.mon.species)
require("src.core.Sound").play(self.data, "Faint_Fall")
self.fx = self.fx or {}; self.fx.faint = { battler = battler, frames = 30 }
end)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 30 })
self:sayNext(Strings("%s\ndied!", battler.name))
self:act(function() self:playerMonFainted() end)
end
local vanillaPlayerFainted = BattleState.playerMonFainted
BattleState.playerMonFainted = function(self)
if active(self.game, self) and not Party.firstHealthy(self.game.save.party) then
self.nuzlockeGameOver, self.result, self.afterQueue = true, "nuzlocke_game_over", "finish"
self:sayNext(Strings("All of your\nPOKéMON are dead..."))
return
end
return vanillaPlayerFainted(self)
end
local vanillaFinish = BattleState.finish
BattleState.finish = function(self)
if not self.nuzlockeGameOver then return vanillaFinish(self) end
self.nuzlockeGameOver = nil
self.game.stack:pop()
Runtime.emit("battle.ended", { battle = self, result = "nuzlocke_game_over" })
-- Game over has no victory lap: delete the slot, then use Credits only
-- as its existing THE END renderer / A-or-B wait screen.
local version = GameVersion.get()
local slot = SaveData.activeSlot(version)
if slot then SaveData.deleteSlot(version, slot)
elseif love and love.filesystem then
local main = SaveData.saveFilename(version)
love.filesystem.remove(main); love.filesystem.remove(main .. ".bak"); love.filesystem.remove(main .. ".tmp")
end
local ending = Screens.push(self.game, "Credits", function()
require("src.core.Music").stop()
while self.game.stack:top() do self.game.stack:pop() end
Screens.push(self.game, "IntroMovie", function()
if self.game.makeTitleState then self.game.stack:push(self.game:makeTitleState()) end
end)
end)
ending.phase, ending.timer = "end_wait", 0
end
end)
end
+19
View File
@@ -0,0 +1,19 @@
{
"id": "nuzlocke",
"name": "Nuzlocke",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "GAMEPLAY",
"game_version": ">=0.0.0-dev <1.0.0",
"priority": 100,
"permissions": [
"engine_internals"
],
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "A configurable Gen 1 Nuzlocke with permanent death and area catches.",
"github": "bryanthaboi/nuzlocke"
}
+8
View File
@@ -35,6 +35,7 @@ TARGET="all"
NOTARY_PROFILE="notary-profile"
NOTARIZE=true
IOS_RELEASE=false
IOS_IPA=false
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
@@ -48,6 +49,7 @@ while [ $# -gt 0 ]; do
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
--no-notarize) NOTARIZE=false ;;
--release) IOS_RELEASE=true ;;
--ipa) IOS_IPA=true ;;
*) fail "unknown argument: $1" ;;
esac
shift
@@ -298,6 +300,9 @@ build_android() {
if [ "$VERSION_EXPLICIT" = true ]; then
args+=(--version "$VERSION")
fi
if [ "$IOS_IPA" = true ]; then
args+=(--ipa)
fi
"$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"}
}
@@ -308,6 +313,9 @@ build_ios() {
if [ "$IOS_RELEASE" = true ]; then
args+=(--release)
fi
if [ "$VERSION_EXPLICIT" = true ]; then
args+=(--version "$VERSION")
fi
"$ROOT/scripts/build_ios.sh" ${args[@]+"${args[@]}"}
}
+29 -3
View File
@@ -198,6 +198,10 @@ pack_game_love() {
rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save
# row opens it in-process, so it must be inside the archive (see build.sh).
# Deliberately NO fused mods: a mod inside game.love sits in the read-only
# APK, so the mod manager's Delete can't remove it and it reappears every
# launch. Pokewalker ships as an importable .zip instead, which gives it
# a real install/upgrade/delete lifecycle.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
@@ -290,10 +294,32 @@ require_android_sdk() {
# --------------------------------------------------------------- gradle
run_gradle() {
local task="assembleEmbedNoRecordDebug"
say "building APK ($task)"
local build_dir="$ANDROID_DIR"
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
# the project path ("Your APP_BUILD_SCRIPT points to an unknown file").
# When this checkout lives at a spaced path (e.g. "~/xCode Projects/..."),
# shadow the android tree to a space-free location and build there; the
# shadow persists across runs so gradle/ndk builds stay incremental.
case "$ANDROID_DIR" in
*" "*)
build_dir="${TMPDIR:-/tmp}/gen1recomp-android-shadow"
say "path contains spaces (ndk-build cannot handle them);"
say "shadow-building in: $build_dir"
mkdir -p "$build_dir"
rsync -a --delete \
--exclude=".gradle" --exclude="app/build" --exclude="love/build" \
--exclude="local.properties" \
"$ANDROID_DIR/" "$build_dir/"
if [ -f "$ANDROID_DIR/local.properties" ]; then
cp "$ANDROID_DIR/local.properties" "$build_dir/local.properties"
fi
;;
esac
say "building APK ($task)"
if ! (
cd "$ANDROID_DIR"
cd "$build_dir"
./gradlew --no-daemon "$task"
); then
fail "gradle $task failed.
@@ -302,7 +328,7 @@ run_gradle() {
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
fi
local out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug"
if [ -d "$out_dir" ]; then
say "APK output:"
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
+361 -70
View File
@@ -1,21 +1,26 @@
#!/usr/bin/env bash
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 11.5's
# official iOS Xcode project (love-11.5-ios-source.zip).
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 12.0's
# iOS Xcode project.
#
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--package-only]
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--install]
# [--version X.Y.Z] [--package-only]
#
# (default) Simulator Debug (CODE_SIGNING_ALLOWED=NO)
# --device iphoneos SDK (needs signing / DEVELOPMENT_TEAM)
# (default) Simulator Debug (ad-hoc signed)
# --device iphoneos SDK; signing team auto-detected from the
# keychain when DEVELOPMENT_TEAM is not set
# --install after a --device build, install the app onto the
# first connected iPhone/iPad (unlock it first)
# --release Release configuration
# --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/
# --version X.Y.Z stamp MARKETING_VERSION / CURRENT_PROJECT_VERSION
# --fetch Fetch LÖVE 12.0 sources and Apple dependencies into mobile/ios/love-src/
# --package-only Zip game.love + apply plist overlay; skip xcodebuild
#
# Prerequisites:
# - macOS + Xcode (xcodebuild)
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
#
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
# dist/ios/gen1recomp.ipa (device builds only)
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp.app
set -euo pipefail
@@ -35,17 +40,36 @@ LIBS_DIR="$XCODE_DIR/ios/libraries"
APP_NAME="gen1recomp"
DISPLAY_NAME="gen1recomp"
BUNDLE_ID="com.theboisclub.pokemonred"
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)"
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
APPLE_LIBS_ZIP="love-${LOVE_VERSION}-apple-libraries.zip"
IOS_SOURCE_URL="https://github.com/love2d/love/releases/download/${LOVE_VERSION}/${IOS_SOURCE_ZIP}"
APPLE_LIBS_URL="https://github.com/love2d/love/releases/download/${LOVE_VERSION}/${APPLE_LIBS_ZIP}"
# Bundle ID resolution, most specific wins:
# 1. GEN1_BUNDLE_ID env var
# 2. mobile/ios/bundle_id.local (one line, gitignored — pins YOUR install
# so rebuilds keep updating the same app on your phone)
# 3. device builds: com.gen1recomp.t<your team id> — explicit App IDs are
# globally unique across ALL Apple accounts (and required once
# capabilities like HealthKit are involved), so a per-team default
# lets anyone build without colliding with someone else's app
# 4. simulator: the project default (no App ID registration involved)
BUNDLE_ID="${GEN1_BUNDLE_ID:-com.theboisclub.gen1recomp}"
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
fi
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 12.0)"
LOVE_SOURCE_REF="${LOVE_SOURCE_REF:-main}"
APPLE_DEPENDENCIES_REF="${APPLE_DEPENDENCIES_REF:-main}"
LOVE_SOURCE_REPO="https://github.com/love2d/love.git"
APPLE_DEPENDENCIES_REPO="https://github.com/love2d/love-apple-dependencies.git"
FETCH=false
DEVICE=false
RELEASE=false
PACKAGE_ONLY=false
INSTALL=false
CREATE_IPA=false
# Last resort for an incomplete source export, mirroring build_android.sh.
MANIFEST_BASE_URL="${MANIFEST_BASE_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main}"
MANIFESTS=""
VERSION=""
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
@@ -57,15 +81,69 @@ while [ $# -gt 0 ]; do
--device) DEVICE=true ;;
--release) RELEASE=true ;;
--package-only) PACKAGE_ONLY=true ;;
--install) INSTALL=true ;;
--ipa) CREATE_IPA=true ;;
--version) VERSION="$2"; shift ;;
-h|--help)
sed -n '2,22p' "$0"
sed -n '2,24p' "$0"
exit 0
;;
*) fail "unknown argument: $1 (try --fetch, --device, --release, or --package-only)" ;;
*) fail "unknown argument: $1 (try --fetch, --device, --release, --version, --install, or --package-only)" ;;
esac
shift
done
if $CREATE_IPA; then
DEVICE=true
fi
VERSION_CODE=""
if [ -n "$VERSION" ]; then
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
fail "invalid --version '$VERSION' (expected X.Y.Z)"
fi
major="${VERSION%%.*}"
rest="${VERSION#*.}"
minor="${rest%%.*}"
patch="${rest##*.}"
VERSION_CODE=$((major * 10000 + minor * 100 + patch))
fi
# ---------------------------------------------------------- signing identity
# Auto-detect the Apple Development team when the caller didn't set one.
# Prefer a *valid* identity from `find-identity` (the parenthetical there is
# the cert id, not the team), then read that cert's OU. Scanning every
# "Apple Development" certificate picks expired personal/work certs first.
detect_team() {
local cn
cn="$(security find-identity -v -p codesigning 2>/dev/null \
| sed -n -E 's/.*"Apple Development: ([^"]+)".*/\1/p' \
| head -1)"
[ -n "$cn" ] || return 1
security find-certificate -c "Apple Development: $cn" -p 2>/dev/null \
| openssl x509 -noout -subject 2>/dev/null \
| sed -n 's/.*OU *= *\([A-Z0-9]*\).*/\1/p' \
| head -1
}
if $DEVICE && [ -z "${DEVELOPMENT_TEAM:-}" ]; then
DEVELOPMENT_TEAM="$(detect_team || true)"
if [ -n "$DEVELOPMENT_TEAM" ]; then
say "signing team auto-detected from keychain: $DEVELOPMENT_TEAM"
else
fail "no Apple signing identity found.
Open Xcode -> Settings -> Accounts, press +, and sign in with your
Apple ID (a free account works). That creates the certificate this
script signs with. Then re-run this command."
fi
fi
if [ -z "$BUNDLE_ID" ]; then
if $DEVICE; then
BUNDLE_ID="com.gen1recomp.t$(printf '%s' "$DEVELOPMENT_TEAM" | tr '[:upper:]' '[:lower:]')"
else
BUNDLE_ID="com.theboisclub.pokemonred"
fi
fi
# --------------------------------------------------------------- host checks
if [ "$(uname -s)" != "Darwin" ]; then
fail "iOS builds require macOS (Darwin). This host is $(uname -s).
@@ -81,25 +159,19 @@ fi
# --------------------------------------------------------------- fetch love-src
fetch_love_ios() {
mkdir -p "$CACHE"
local zip_path="$CACHE/$IOS_SOURCE_ZIP"
if [ ! -f "$zip_path" ]; then
say "downloading $IOS_SOURCE_ZIP (LÖVE $LOVE_VERSION iOS sources)"
curl -fL --progress-bar "$IOS_SOURCE_URL" -o "$zip_path" \
|| fail "download failed: $IOS_SOURCE_URL"
else
say "using cached $zip_path"
fi
say "extracting into $LOVE_SRC"
rm -rf "$LOVE_SRC"
local tmp
tmp="$(mktemp -d "$CACHE/extract.XXXXXX")"
unzip -q "$zip_path" -d "$tmp"
# Zip root is love-<version>-ios-source/
local extracted
extracted="$(find "$tmp" -maxdepth 1 -mindepth 1 -type d ! -name '__MACOSX' | head -1)"
[ -n "$extracted" ] || fail "unexpected layout inside $IOS_SOURCE_ZIP"
mv "$extracted" "$LOVE_SRC"
say "fetching LÖVE $LOVE_VERSION sources ($LOVE_SOURCE_REF)"
git clone --depth 1 --branch "$LOVE_SOURCE_REF" "$LOVE_SOURCE_REPO" "$tmp/love" \
|| fail "failed to fetch LÖVE sources from $LOVE_SOURCE_REPO"
say "fetching Apple dependencies ($APPLE_DEPENDENCIES_REF)"
git clone --depth 1 --branch "$APPLE_DEPENDENCIES_REF" "$APPLE_DEPENDENCIES_REPO" "$tmp/dependencies" \
|| fail "failed to fetch Apple dependencies from $APPLE_DEPENDENCIES_REPO"
rm -rf "$LOVE_SRC"
mv "$tmp/love" "$LOVE_SRC"
mkdir -p "$LIBS_DIR" "$XCODE_DIR/shared"
cp -R "$tmp/dependencies/iOS/libraries/." "$LIBS_DIR"
cp -R "$tmp/dependencies/shared/." "$XCODE_DIR/shared"
rm -rf "$tmp"
say "love-src ready (LÖVE $LOVE_VERSION)"
}
@@ -109,18 +181,12 @@ if [ ! -d "$XCODE_DIR/love.xcodeproj" ]; then
fetch_love_ios
else
fail "LÖVE $LOVE_VERSION iOS sources not found at mobile/ios/love-src/.
Fetch them (documented download of love-${LOVE_VERSION}-ios-source.zip):
Fetch them:
scripts/build_ios.sh --fetch
Or manually:
mkdir -p mobile/ios/cache
curl -fL -o mobile/ios/cache/$IOS_SOURCE_ZIP \\
$IOS_SOURCE_URL
unzip -q mobile/ios/cache/$IOS_SOURCE_ZIP -d mobile/ios/cache
mv mobile/ios/cache/love-${LOVE_VERSION}-ios-source mobile/ios/love-src
See mobile/ios/README.md."
fi
elif $FETCH; then
say "love-src already present; skipping download (delete mobile/ios/love-src to refresh)"
say "love-src already present; skipping fetch (delete mobile/ios/love-src to refresh)"
fi
[ -d "$XCODE_DIR/love.xcodeproj" ] \
@@ -128,25 +194,16 @@ fi
# --------------------------------------------------------------- apple libraries
require_ios_libraries() {
if [ -d "$LIBS_DIR/SDL2.xcframework" ]; then
if [ -d "$LIBS_DIR/SDL2.xcframework" ] && [ -d "$XCODE_DIR/shared/Frameworks/SDL3.xcframework" ]; then
return 0
fi
fail "prebuilt iOS libraries missing at:
$LIBS_DIR
love-ios expects SDL2.xcframework (and friends) there.
and shared/Frameworks.
The official love-${LOVE_VERSION}-ios-source.zip normally includes them.
If they are absent, install love-${LOVE_VERSION}-apple-libraries.zip:
Re-fetch the LÖVE $LOVE_VERSION source tree and its Apple dependencies:
mkdir -p mobile/ios/cache
curl -fL -o mobile/ios/cache/$APPLE_LIBS_ZIP \\
$APPLE_LIBS_URL
unzip -q mobile/ios/cache/$APPLE_LIBS_ZIP -d mobile/ios/cache
rm -rf mobile/ios/love-src/platform/xcode/ios/libraries
cp -R mobile/ios/cache/love-apple-dependencies/iOS/libraries \\
mobile/ios/love-src/platform/xcode/ios/libraries
See mobile/ios/README.md (Apple libraries dependency)."
scripts/build_ios.sh --fetch"
}
require_ios_libraries
@@ -159,24 +216,130 @@ apply_ios_branding() {
cp "$OVERLAY_PLIST" "$dest"
}
apply_ios_icon() {
local source="$ROOT/assets/logo/logo.png"
local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset"
[ -f "$source" ] || fail "missing iOS icon source: $source"
[ -d "$target" ] || fail "missing iOS app icon set: $target"
local entry name size
while IFS=: read -r name size; do
sips -z "$size" "$size" "$source" --out "$target/$name" >/dev/null
done <<'EOF'
icon-1024pt@1x.png:1024
icon-29pt@1x.png:29
icon-29pt@2x.png:58
icon-29pt@3x.png:87
icon-40pt@1x.png:40
icon-40pt@2x.png:80
icon-40pt@3x.png:120
icon-60pt@2x.png:120
icon-60pt@3x.png:180
icon-76pt@1x.png:76
icon-76pt@2x.png:152
icon-83.5pt@2x.png:167
EOF
}
# --------------------------------------------------------------- game.love
# Every version's import manifest has to ship or that game's ROM import fails in
# the built app: decodeManifest (src/import/RomImporter.lua) errors outright when
# one is absent, and dev reads them off the source tree, so the miss only ever
# shows up in a build. iOS shipped without the Yellow one in 0.1.45 to 0.1.47
# for exactly that reason.
#
# The list is READ OUT OF src/core/GameVersion.lua rather than hand-kept here, so
# a fourth version cannot silently ship without its manifest, and a missing file
# is recovered from Git or the project repo the same way build_android.sh already
# recovers Yellow's. Recovery is a last resort for an incomplete source export:
# a manifest carries extraction metadata only, never a ROM or game data.
manifest_paths() {
python3 - "$ROOT/src/core/GameVersion.lua" <<'PY'
import re, sys
src = open(sys.argv[1]).read()
print(" ".join(dict.fromkeys(re.findall(r'manifest\s*=\s*"([^"]+)"', src))))
PY
}
manifest_is_valid() {
python3 - "$1" <<'PY'
import json, pathlib, sys
try:
m = json.loads(pathlib.Path(sys.argv[1]).read_text())
except (OSError, ValueError):
raise SystemExit(1)
sha = m.get("romSha1")
raise SystemExit(0 if isinstance(sha, str) and len(sha) == 40 else 1)
PY
}
ensure_manifests() {
MANIFESTS="$(manifest_paths)"
[ -n "$MANIFESTS" ] \
|| fail "could not read any manifest path out of src/core/GameVersion.lua"
local rel staged
for rel in $MANIFESTS; do
if manifest_is_valid "$ROOT/$rel"; then continue; fi
warn "$rel is missing or invalid; recovering it before packaging"
staged="$(mktemp)"
if git -C "$ROOT" show "HEAD:$rel" > "$staged" 2>/dev/null \
&& manifest_is_valid "$staged"; then
mkdir -p "$ROOT/$(dirname "$rel")"
mv "$staged" "$ROOT/$rel"
say "restored $rel from this checkout's Git data"
continue
fi
if command -v curl >/dev/null 2>&1 \
&& curl --fail --location --retry 2 --connect-timeout 15 \
--output "$staged" "$MANIFEST_BASE_URL/$rel" \
&& manifest_is_valid "$staged"; then
mkdir -p "$ROOT/$(dirname "$rel")"
mv "$staged" "$ROOT/$rel"
say "downloaded $rel from the project repository"
continue
fi
rm -f "$staged"
fail "$rel is unavailable: Git recovery failed and $MANIFEST_BASE_URL/$rel could not be downloaded"
done
say "import manifests: $MANIFESTS"
}
pack_game_love() {
say "packing game.love for love-ios resources"
mkdir -p "$RESOURCES_DIR"
rm -f "$LOVE_FILE"
# Same payload as scripts/build.sh / build_android.sh: game sources plus
# tools/save-editor, which the launcher's Edit button opens in-process.
# Deliberately NO fused mods: a mod inside game.love sits in the
# read-only app bundle, so the mod manager's Delete can't remove it and
# it reappears every launch. Mods install as .zips at runtime instead
# (launcher -> MODS -> Import mod .zip), the same lifecycle as every
# other platform.
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
$MANIFESTS \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
# NOTE: grep -q here would race pipefail — it exits on first match, unzip
# dies of SIGPIPE (141), and the pipeline "fails" nondeterministically.
# >/dev/null keeps grep reading the whole stream instead.
if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
| grep -E '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' >/dev/null; then
fail "game.love unexpectedly contains generated ROM data"
fi
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
# Same required-file gate as scripts/build.sh and scripts/build_android.sh.
# iOS only checked App.lua, which is why the Yellow manifest shipped missing
# in 0.1.45 through 0.1.47: decodeManifest (src/import/RomImporter.lua) errors
# outright when a version's manifest is absent, so Import ROM on Yellow died
# in the built app while dev, which reads the source tree, stayed green.
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
for required in src/update/Boot.lua tools/save-editor/App.lua \
tools/save-editor/Kit.lua tools/save-editor/panels/Party.lua \
$MANIFESTS; do
printf '%s\n' "$archive_entries" | grep -qx "$required" \
|| fail "game.love is missing $required"
done
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
}
@@ -271,6 +434,43 @@ PY
}
# --------------------------------------------------------------- xcodebuild
# love.system.pickFile and createFile are a native bridge compiled in by
# mobile/ios/patch_love_src.py, not part of LÖVE. A build that skipped the
# patch still links and still runs, then finds the field nil the moment anyone
# taps Import ROM (#482). #539 made that degrade to the copy-into-Files flow
# rather than crash, which is the right floor, but a build with no picker at all
# is a silent downgrade, so fail here instead of shipping one.
#
# Checked against the built binary rather than the source, because patching
# love-src proves nothing about what Xcode actually compiled: the shipped
# 0.1.45/0.1.46/0.1.47 IPAs all DO carry the bridge, so the reports that blamed
# a missing patch step were self-built IPAs, exactly the case this catches.
verify_native_bridge() {
local app="$1"
local exe bin missing=""
exe="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' \
"$app/Info.plist" 2>/dev/null || true)"
bin="$app/${exe:-love}"
[ -f "$bin" ] || bin="$app/love"
if [ ! -f "$bin" ]; then
warn "no executable inside $(basename "$app"); skipping native bridge check"
return 0
fi
# grep -q here would race pipefail the same way pack_game_love documents:
# it exits on first match, strings dies of SIGPIPE, the pipeline "fails"
# nondeterministically. >/dev/null keeps grep reading the whole stream.
for sym in pickFile createFile; do
strings -a "$bin" | grep -x "$sym" >/dev/null || missing="$missing $sym"
done
if [ -n "$missing" ]; then
fail "built app has no native bridge (missing:$missing).
Import ROM would fall back to copy-into-Files instead of opening the picker.
mobile/ios/patch_love_src.py did not take. Re-run:
scripts/build_ios.sh --fetch && scripts/build_ios.sh"
fi
say "native bridge present (pickFile, createFile)"
}
run_xcodebuild() {
local config sdk destination
if $RELEASE; then
@@ -291,6 +491,15 @@ run_xcodebuild() {
# Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires
# -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes.
# Always stamp both: the overlay plist expands $(MARKETING_VERSION) /
# $(CURRENT_PROJECT_VERSION), and love-ios has no project-level defaults.
local marketing_version="$LOVE_VERSION"
local project_version="1"
if [ -n "$VERSION" ]; then
marketing_version="$VERSION"
project_version="$VERSION_CODE"
fi
local args=(
-project "$PROJECT"
-target love-ios
@@ -300,18 +509,26 @@ run_xcodebuild() {
SYMROOT="$BUILD_DIR/Build/Products"
OBJROOT="$BUILD_DIR/Build/Intermediates"
PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID"
PRODUCT_NAME="$APP_NAME"
MARKETING_VERSION="$LOVE_VERSION"
MARKETING_VERSION="$marketing_version"
CURRENT_PROJECT_VERSION="$project_version"
ONLY_ACTIVE_ARCH=NO
)
if ! $DEVICE; then
# Simulator: no signing required
args+=(CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=)
# Simulator: ad-hoc signing (no certificate needed). A plain unsigned
# build would drop the entitlements file, and HealthKit refuses to run
# without the com.apple.developer.healthkit entitlement even in the
# simulator.
args+=(CODE_SIGNING_ALLOWED=YES CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=-)
else
warn "device build: configure signing in Xcode or set DEVELOPMENT_TEAM / CODE_SIGN_IDENTITY"
if [ -n "${DEVELOPMENT_TEAM:-}" ]; then
args+=(DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM")
# Automatic signing + provisioning updates lets xcodebuild register the
# bundle ID / create a development profile from the CLI, so a device
# build works without ever opening the project in Xcode.
args+=(DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM"
CODE_SIGN_STYLE=Automatic
-allowProvisioningUpdates)
fi
if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then
args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY")
@@ -326,11 +543,27 @@ run_xcodebuild() {
say "xcodebuild love-ios ($config / $sdk)"
set +e
(
cd "$XCODE_DIR"
xcodebuild "${args[@]}"
)
local xc_status=$?
local xc_status
if command -v xcbeautify >/dev/null 2>&1; then
(
cd "$XCODE_DIR"
xcodebuild "${args[@]}"
) 2>&1 | xcbeautify
local pipeline_status=("${PIPESTATUS[@]}")
local xcode_status=${pipeline_status[0]}
local beautify_status=${pipeline_status[1]}
if [ "$xcode_status" -ne 0 ]; then
xc_status=$xcode_status
else
xc_status=$beautify_status
fi
else
(
cd "$XCODE_DIR"
xcodebuild "${args[@]}"
)
xc_status=$?
fi
set -e
if [ "$xc_status" -ne 0 ]; then
fail "xcodebuild failed (exit $xc_status).
@@ -361,23 +594,81 @@ run_xcodebuild() {
cp "$LOVE_FILE" "$app/game.love"
fi
verify_native_bridge "$app"
local dist_dir="$DIST/${config}-${sdk}"
rm -rf "$dist_dir"
mkdir -p "$dist_dir"
cp -R "$app" "$dist_dir/"
say "copied to $dist_dir/$(basename "$app")"
cp -R "$app" "$dist_dir/$APP_NAME.app"
say "copied to $dist_dir/$APP_NAME.app"
if $DEVICE; then
package_ipa "$dist_dir/$APP_NAME.app"
fi
say "iOS app: $app"
say "bundle id: $BUNDLE_ID display: $DISPLAY_NAME"
if $DEVICE; then
warn "signing/provisioning is manual, see mobile/ios/README.md"
if $INSTALL; then
install_to_device "$app"
else
say "install with: scripts/build_ios.sh --device --install (iPhone plugged in + unlocked)"
fi
else
say "simulator tip: xcrun simctl install booted \"$app\""
fi
}
# Pack Payload/<app>.app into dist/ios/gen1recomp.ipa for release / sideload tools.
package_ipa() {
local app="$1"
local ipa="$DIST/$APP_NAME.ipa"
local tmp
tmp="$(mktemp -d "$DIST/ipa.XXXXXX")"
mkdir -p "$tmp/Payload"
cp -R "$app" "$tmp/Payload/$(basename "$app")"
rm -f "$ipa"
(cd "$tmp" && zip -q -r "$ipa" Payload)
rm -rf "$tmp"
say "ipa: $ipa ($(du -h "$ipa" | cut -f1))"
}
# ------------------------------------------------------------ device install
# Installs the freshly built .app onto the first connected iPhone/iPad via
# devicectl. The phone must be paired (plugged in at least once + "Trust
# This Computer") and UNLOCKED during the install.
install_to_device() {
local app="$1"
local line udid
line="$(xcrun devicectl list devices 2>/dev/null \
| grep -E 'iPhone|iPad' | grep -v 'Watch' | head -1 || true)"
udid="$(printf '%s' "$line" \
| grep -Eo '[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}' \
| head -1 || true)"
if [ -z "$udid" ]; then
fail "no iPhone/iPad found.
Plug the phone in with a cable, unlock it, tap 'Trust This Computer'
if asked, then re-run: scripts/build_ios.sh --device --install"
fi
say "installing onto: $(printf '%s' "$line" | sed 's/ .*//') ($udid)"
if xcrun devicectl device install app --device "$udid" "$app"; then
say "installed. On the phone: tap the new app on your Home Screen."
say "first launch may ask you to enable Developer Mode (Settings ->"
say "Privacy & Security -> Developer Mode) and to trust the developer"
say "(Settings -> General -> VPN & Device Management)."
else
fail "install failed. Most common cause: the phone was locked.
Unlock it, keep it plugged in, and re-run:
scripts/build_ios.sh --device --install"
fi
}
# --------------------------------------------------------------- main
apply_ios_branding
apply_ios_icon
say "applying iOS native bridge patches (picker/Files support)"
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
ensure_manifests
pack_game_love
ensure_game_love_in_xcode
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Static analysis for the engine Lua (config: .luacheckrc).
#
# Complements scripts/test.sh: the tests prove behavior, luacheck catches the
# defects that never run in a green test -- undefined globals/locals (the
# class that hid a music.volume crash: a hook read a `state` that was still
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
# cosmetic categories the codebase lives with, so what prints is worth a look.
#
# scripts/lint.sh lint src/
# scripts/lint.sh src tools lint specific paths
#
# Install once with: luarocks install luacheck
set -uo pipefail
cd "$(dirname "$0")/.."
if ! command -v luacheck >/dev/null 2>&1; then
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
exit 2
fi
luacheck "${@:-src}"
+409 -100
View File
@@ -4,7 +4,7 @@
-- Flow: intro -> menu (FIGHT/PKMN/ITEM/RUN) -> move select -> turn
-- resolution (a queue of messages/actions/UI pushes) -> back to menu,
-- until one side is out, then finish. Pops itself and calls
-- onFinish("win"|"lose"|"run"|"caught"|"skipped").
-- onFinish("win"|"lose"|"run"|"caught").
--
-- The Gen 1 move-effect pipeline (multi-hit, charge, trapping, thrash,
-- bide, recharge, confusion, screens, substitute, transform, ...) is
@@ -203,7 +203,16 @@ function BattleState.trainerPalette(data, trainer)
return namedPalette(data, "MEWMON")
end
function BattleState.trainerPicPath(data, trainer)
-- Yellow only: ROCKET with wTrainerNo >= $2a is Jessie & James, who share
-- the class and the name "ROCKET" but battle behind their own pic
-- (home/trainers2.asm IsFightingJessieJames). picJessieJames exists only
-- in a Yellow cache extracted after #439, so an older cache keeps the
-- grunt pic until it is re-imported.
function BattleState.trainerPicPath(data, trainer, oppClass, partyIndex)
if oppClass == "OPP_ROCKET" and (partyIndex or 1) >= 42
and trainer and trainer.picJessieJames then
return trainer.picJessieJames
end
if trainer and trainer.pic then return trainer.pic end
local base = trainer and trainer.basePic and data.trainers[trainer.basePic]
return base and base.pic or nil
@@ -282,10 +291,17 @@ function BattleState:picImage(img)
or PaletteFX.mode == "classic"
if self.grayPics or mono then return grayImage(img) end
-- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go
-- dark with the HP bars while the blackout text is up (#292). Below the
-- mono check on purpose: the forced-mono modes re-threshold the whole
-- frame downstream, and the DMG had no SGB darkening to begin with.
if self.blackedOut then return blackImage(self.data, img) end
-- dark with the HP bars while the blackout text is up (#292). The intro
-- silhouette slide (SlidePlayerAndEnemySilhouettesOnScreen) darkens the
-- same way: the original slides both pics in under the %11100100
-- silhouette palette and only runs SET_PAL_BATTLE once they have landed,
-- so a still-sliding pic reads as a black silhouette, exactly like the
-- evolution movie's PAL_BLACK (#577). Below the mono check on purpose:
-- the forced-mono modes re-threshold the whole frame downstream, and the
-- DMG had no SGB darkening to begin with.
if self.blackedOut or (self.introSlide or 0) > 0 then
return blackImage(self.data, img)
end
return fadeImage(img, self:activeBgp())
end
@@ -629,23 +645,70 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
self.trainerPic = getImage(BattleState.trainerPicPath(game.data, self.trainer),
self.trainerPic = getImage(
BattleState.trainerPicPath(game.data, self.trainer, oppClass, partyIndex),
BattleState.trainerPalette(game.data, self.trainer))
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
return self
end
-- The disguise itself. InitWildBattle .isGhost (engine/battle/core.asm)
-- swaps only the pic and the nick -- wEnemyMonSpecies2 still holds the real
-- species, so the SGB palette stays the disguised mon's. ghostReal keeps
-- what LoadEnemyMonData puts back when the scope unveils.
local function disguiseAsGhost(self)
self.ghostReal = { name = self.enemy.name, sprite = self.enemy.sprite }
self.enemy.name = "GHOST"
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
monPalette(self.data, self.enemy.mon.species))
self.introText = Strings("The GHOST\nappeared!")
end
-- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope
-- the enemy is "GHOST", you're too scared to attack, and balls fail.
function BattleState:makeGhost()
self.ghost = true
self.enemy.name = "GHOST"
-- the ghost keeps the disguised mon's SGB palette: InitWildBattle
-- swaps only the pic, wEnemyMonSpecies2 still holds the real species
-- (engine/battle/core.asm InitWildBattle .isGhost)
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
monPalette(self.data, self.enemy.mon.species))
self.introText = Strings("The GHOST\nappeared!")
disguiseAsGhost(self)
end
-- The RESTLESS SOUL with the SILPH SCOPE in the bag. Mechanically this is
-- not a ghost battle at all -- IsGhostBattle (core.asm) returns false the
-- moment the scope is in the bag, so the mon attacks, is attacked and rolls
-- for flight normally -- but InitWildBattle still enters disguised: its
-- .isGhost branch fires on wCurOpponent == RESTLESS_SOUL as well. Taking
-- the disguise off is PrintBeginningBattleText .isMarowak's job
-- (engine/battle/common_text.asm), which enter() queues through
-- queueScopeReveal (#492).
function BattleState:makeUnveiledGhost()
self.scopeReveal = true
disguiseAsGhost(self)
end
-- MarowakAnim (engine/battle/ghost_marowak_anim.asm) counted in frames: the
-- ghost pic is copied into OAM and flashed 8 times by xor-ing rOBP1 with
-- $80 (10 frames a side), the BG pic underneath is swapped to the RESTLESS
-- SOUL, rOBP1 is shifted left two bits every 10 frames until the ghost has
-- faded into the paper (3 shifts), and the Marowak copy fades back in the
-- same way (4 shifts). Alpha over the paper is what those OBP shifts look
-- like on screen.
BattleState.GHOST_FLASH_FRAMES = 80
BattleState.GHOST_FADE_OUT_FRAMES = 30
BattleState.GHOST_FADE_IN_FRAMES = 40
BattleState.GHOST_REVEAL_FRAMES = 150
-- The rows .isMarowak prints over the disguise, after the "GHOST appeared!"
-- box enter() has already queued: the unveil line, MarowakAnim, then "Wild
-- MAROWAK appeared!" under the restored name. The fall-through into
-- .playSFX at the end is the same SFX_TRAINER_APPEARED the port does not
-- play for trainer intros either, so it is left out here too.
function BattleState:queueScopeReveal()
local unveiled = self.data.text and self.data.text._UnveiledGhostText
self:say(unveiled
or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!"))
self:act(function() self.ghostReveal = { t = 0 } end)
table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES })
self:say(Strings("Wild %s\nappeared!",
self.ghostReal and self.ghostReal.name or self.enemy.name))
end
-- The old man's catch tutorial (BATTLE_TYPE_OLD_MAN,
@@ -659,6 +722,12 @@ end
function BattleState:makeOldManDemo(name)
self.demo = true
self.demoName = name or "OLD MAN"
-- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType:
-- BATTLE_TYPE_OLD_MAN gets .oldManName + OldManPicBack, BATTLE_TYPE_PIKACHU
-- gets .profOakName + ProfOakPicBack (pokeyellow core.asm). The thrower
-- name the caller passes IS that distinction here, so it picks the pic too
-- -- data/scripts/story2.lua is the only site that names PROF.OAK (#557).
self.oakDemo = name == "PROF.OAK"
-- Yellow's Pallet intro runs this before the player owns any mon
-- (BATTLE_TYPE_PIKACHU precedes the lab gift), so newWild flagged the
-- battle dead for lack of a party. The demo never sends out, draws, or
@@ -762,10 +831,17 @@ function BattleState:buildScreen(id, ...)
end
-- insert a wait for the HP bars to finish draining (UpdateHPBar):
-- the queue holds until every battler's displayed HP catches up
function BattleState:drainNext()
-- the queue holds until every battler's displayed HP catches up.
-- `stopAt` pins how far that battler's bar may drain on this row. A
-- multi-hit move takes every strike off the model while the turn is still
-- being queued, so an unpinned row would drain straight to the
-- post-last-hit HP and the later strikes would animate nothing (#394);
-- ApplyDamageToEnemyPokemon runs UpdateHPBar2 once per strike inside the
-- wNumAttacksLeft loop (engine/battle/core.asm:4727).
function BattleState:drainNext(battler, stopAt)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { drain = true })
table.insert(self.queue, self.nextInsert,
{ drain = true, battler = battler, stopAt = stopAt })
end
-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar):
@@ -774,14 +850,22 @@ end
function BattleState:stepHPDrain()
local busy = false
for _, b in ipairs({ self.player, self.enemy }) do
if b and b.shownHP and b.shownHP ~= b.mon.hp then
local step = math.max(1, b.mon.stats.hp) / 96
if b.shownHP > b.mon.hp then
b.shownHP = math.max(b.mon.hp, b.shownHP - step)
else
b.shownHP = math.min(b.mon.hp, b.shownHP + step)
if b and b.shownHP then
-- drainFloor is the stop the running row carries (see drainNext)
local goal = b.mon.hp
if b.drainFloor and b.drainFloor > goal
and b.shownHP >= b.drainFloor then
goal = b.drainFloor
end
if b.shownHP ~= goal then
local step = math.max(1, b.mon.stats.hp) / 96
if b.shownHP > goal then
b.shownHP = math.max(goal, b.shownHP - step)
else
b.shownHP = math.min(goal, b.shownHP + step)
end
busy = busy or b.shownHP ~= goal
end
busy = busy or b.shownHP ~= b.mon.hp
end
end
return busy
@@ -859,6 +943,8 @@ function BattleState:updateQueue()
if self.draining then
if self:stepHPDrain() then return true end
self.draining = nil
if self.player then self.player.drainFloor = nil end
if self.enemy then self.enemy.drainFloor = nil end
end
-- a move animation holds the queue until it finishes; its screen
-- effects (SE_*) and per-row sounds route into the fx layer as they
@@ -897,6 +983,7 @@ function BattleState:updateQueue()
end
if item.drain then
self.draining = true
if item.battler then item.battler.drainFloor = item.stopAt end
return true
end
if item.wait then
@@ -1119,10 +1206,34 @@ function BattleState:battleKind()
end
function BattleState:enter()
if self.dead then
-- Out of useable POKéMON before the battle even starts. pokered does not
-- skip the battle: .checkAnyPartyAlive (engine/battle/core.asm:158-162)
-- runs right after the intro and jumps to HandlePlayerBlackOut, so the
-- player blacks out and afterBattle's "lose" path revives the party at the
-- last heal point. Handing the map back with a 0 HP party instead bricked
-- the save: every later encounter aborted here too and sighted trainers
-- re-engaged forever (#425). self.dead alone is not the test --
-- makeOldManDemo and LinkBattle install a player battler after the
-- constructor flagged the battle dead.
if self.dead and not self.player then
local name = self.game.save.player.name
self.result = "lose"
self.game.stack:pop()
Runtime.emit("battle.ended", { battle = self, result = "skipped" })
if self.onFinish then self.onFinish("skipped") end
require("src.core.Music").restoreMap(self.data)
Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true })
local onFinish = self.onFinish
local function blackedOut()
if onFinish then onFinish("lose") end
end
-- the Oak's Lab starter rival returns above PlayerBlackedOutText2 and
-- afterBattle keeps the player in the lab, so print nothing there
if BattleState.isOaksLabStarterRival(self) then return blackedOut() end
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
-- playerMonFainted queues on the battle screen; there is no battle
-- screen to queue them on here, so they print over the map.
self.game.stack:push(require("src.render.TextBox").new(self.game,
Strings("%s is out of\nuseable POKéMON!", name) .. "\f"
.. Strings("%s blacked\nout!", name), blackedOut))
return
end
local Music = require("src.core.Music")
@@ -1137,8 +1248,11 @@ function BattleState:enter()
-- without a transition (link battles, scripted pushes)
Music.playBattle(self.data, self.musicKind)
-- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both
-- sides slide in; the trainer pics stay up until the send-outs
self.introSlide = 40
-- sides slide in as black silhouettes. The original scrolls SCX from
-- $90 to 0 two pixels per frame (72 frames); the port covers the full
-- 160px screen width, so 2px/frame is an 80-frame slide (slide offset is
-- introSlide*2 below). The trainer pics stay up until the send-outs.
self.introSlide = 80
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
@@ -1162,7 +1276,8 @@ function BattleState:enter()
-- fade, the ground-padding measurement and battle_sprite_scales.
local backPath, backTrueColor =
require("src.pokemon.Sprites").playerPath(self.data, "back",
{ kind = "battle", demo = self.demo, battle = self })
{ kind = "battle", demo = self.demo, oakDemo = self.oakDemo,
battle = self })
self.playerBackPic = getImage(backPath,
namedPalette(self.data, "MEWMON"), backTrueColor)
self.showPlayerBack = self.playerBackPic ~= nil
@@ -1177,11 +1292,16 @@ function BattleState:enter()
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry
-- sounds with the "Wild X appeared!" box instead of waiting on the A
-- press that clears its `prompt` (#303). The Silph-Scope-less tower
-- ghost gets no cry at all (common_text.asm:43-48).
if self.kind ~= "trainer" and self.kind ~= "link" and not self.ghost then
-- ghost gets no cry at all (common_text.asm:43-48), and neither does the
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
if self.kind ~= "trainer" and self.kind ~= "link"
and not self.ghost and not self.scopeReveal then
queueEnemyCry()
end
self:say(self.introText)
-- the unveil rides on that same box, before _InitBattleCommon clears the
-- intro chrome below (#492)
if self.scopeReveal then self:queueScopeReveal() end
-- _InitBattleCommon (core.asm:6755-6762): the instant the intro text is
-- dismissed both HUD blocks are cleared and ClearSprites drops the
-- pokeball OAM, so the intro chrome never returns for the rest of the
@@ -1402,6 +1522,7 @@ function BattleState:update(dt)
for _, b in ipairs({ self.player, self.enemy }) do
if b then
if b.shownHP then b.shownHP = b.mon.hp end
b.drainFloor = nil
b.shownStatus = b.mon.status
end
end
@@ -1454,10 +1575,14 @@ function BattleState:update(dt)
end
local col = (self.menuIndex - 1) % 2
local row = math.floor((self.menuIndex - 1) / 2)
if input:wasPressed("left") or input:wasPressed("right") then
col = 1 - col
elseif input:wasPressed("up") or input:wasPressed("down") then
row = 1 - row
if input:wasPressed("left") then
col = math.max(0, col - 1)
elseif input:wasPressed("right") then
col = math.min(1, col + 1)
elseif input:wasPressed("up") then
row = math.max(0, row - 1)
elseif input:wasPressed("down") then
row = math.min(1, row + 1)
end
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
@@ -1486,10 +1611,14 @@ function BattleState:update(dt)
end
local col = (self.menuIndex - 1) % 2
local row = math.floor((self.menuIndex - 1) / 2)
if input:wasPressed("left") or input:wasPressed("right") then
col = 1 - col
elseif input:wasPressed("up") or input:wasPressed("down") then
row = 1 - row
if input:wasPressed("left") then
col = math.max(0, col - 1)
elseif input:wasPressed("right") then
col = math.min(1, col + 1)
elseif input:wasPressed("up") then
row = math.max(0, row - 1)
elseif input:wasPressed("down") then
row = math.min(1, row + 1)
end
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
@@ -1959,6 +2088,15 @@ function BattleState:resolveSwitch(newMon)
end
function BattleState:endOfTurn()
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual
-- sweep and the trapping-counter release are skipped on the turn a
-- Teleport escape (or a win/loss/capture) settles it (#441). The
-- turn_ended hook still fires: mods count turns, not residuals.
if self.result then
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
return
end
-- sideToxic mirrors w*ToxicCounter: it advances only while the
-- battler's badly-poisoned flag (toxicCounter) is set, an item/AI
-- cure clears the flag but NOT the side counter, and a fresh Toxic
@@ -2176,6 +2314,31 @@ local function startPicKind(pf, kind)
pf.hidden = nil
end
-- PredefShakeScreenHorizontally (engine/gfx/screen_effects.asm): the window
-- jumps right by b for 5 frames then home for 4, b counting down to 1.
-- b = 8 for SE_SHAKE_SCREEN and the heavy applying-attack shake, b = 2 for
-- the light one.
local function fastShakeProg(b)
local prog = {}
for i = b, 1, -1 do
prog[#prog + 1] = { dx = i, frames = 5 }
prog[#prog + 1] = { dx = 0, frames = 4 }
end
return prog
end
-- AnimationShakeScreenHorizontallySlow (engine/battle/animations.asm:526):
-- rWX creeps 1px right every 2 frames b times, then back down to 0, c times
-- over. Silent -- this is the non-damaging move's feedback.
local function slowShakeProg(b, c)
local prog = {}
for _ = 1, c do
for i = 1, b do prog[#prog + 1] = { dx = i, frames = 2 } end
for i = b - 1, 0, -1 do prog[#prog + 1] = { dx = i, frames = 2 } end
end
return prog
end
-- Route one AnimPlayer event into the fx layer. Frame counts and
-- amplitudes are the routines' own (engine/battle/animations.asm;
-- shakes: engine/gfx/screen_effects.asm).
@@ -2217,14 +2380,7 @@ function BattleState:applyAnimEffect(ev)
-- ---------------------------------------------- screen shakes
elseif e == "SE_SHAKE_SCREEN" then
-- PredefShakeScreenHorizontally b=8: the window jumps right by b
-- for 5 frames then home for 4, b counting down 8..1
local prog = {}
for b = 8, 1, -1 do
prog[#prog + 1] = { dx = b, frames = 5 }
prog[#prog + 1] = { dx = 0, frames = 4 }
end
fx.shakeProg = prog
fx.shakeProg = fastShakeProg(8)
elseif e == "SE_ROCK_SLIDE_SHAKE" then
-- DoRockSlideSpecialEffects: 1px horizontal then vertical rumble
fx.shakeProg = { { dx = 1, frames = 5 }, { dx = 0, frames = 4 },
@@ -2321,34 +2477,77 @@ function BattleState:applyAnimEffect(ev)
-- battler.substituteHP is set (MoveEffects raises it with the move)
end
-- The target's post-animation hit feedback (PlayApplyingAttackAnimation,
-- engine/battle/animations.asm:475): the player's damaging moves blink
-- the ENEMY pic; the enemy's damaging moves shake the screen vertically
-- (ShakeScreenVertically -> PredefShakeScreenVertically b=8: the window
-- drops by b for 3 frames then home for 3, b counting down) -- the
-- player's pic never blinks. Damage sound with either. A hold keeps
-- The post-animation applying-attack feedback (PlayApplyingAttackAnimation
-- -> AnimationTypePointerTable, engine/battle/animations.asm:475-524).
-- hit.animType is wAnimationType, 1..6:
-- 1 enemy damaging, no added effect ShakeScreenVertically (b=8)
-- 2 enemy damaging, added effect fast horizontal shake, b=8
-- 3 enemy non-damaging slow horizontal shake, b=6, c=2
-- 4 player damaging, no added effect BlinkEnemyMonSprite
-- 5 player damaging, added effect fast horizontal shake, b=2
-- 6 player non-damaging slow horizontal shake, b=3, c=2
-- Types 3 and 6 are silent; the rest open with PlayApplyingAttackSound,
-- which is the damage sound hit.sfx already carries. Only 1 and 4 were
-- implemented, so every move with an added effect blinked (or shook
-- vertically) instead of shaking sideways and every status move showed
-- nothing at all -- Bubblebeam, Confusion, Hypnosis (#354). A hold keeps
-- the queue still until the effect finishes.
function BattleState:applyHitFx(hit)
if hit.blink then
self.fx = self.fx or {}
if hit.blink.isPlayer then
local prog = {}
for b = 8, 1, -1 do
prog[#prog + 1] = { dy = b, frames = 3 }
prog[#prog + 1] = { dy = 0, frames = 3 }
end
self.fx.shakeProg = prog
self.waitFrames = 48 -- the predef blocks until the shake settles
else
self.fx.blink = { target = hit.blink, frames = 20 }
self.waitFrames = 20
end
end
self.fx = self.fx or {}
-- rows queued before animType existed carry only the blink target
local t = hit.animType
if not t and hit.blink then t = hit.blink.isPlayer and 1 or 4 end
if hit.sfx then
require("src.core.Sound").play(self.data, hit.sfx)
end
if not t or not self:animationsOn() then return end
if t == 1 then
-- PredefShakeScreenVertically b=8: the window drops by b for 3 frames
-- then home for 3, b counting down
local prog = {}
for b = 8, 1, -1 do
prog[#prog + 1] = { dy = b, frames = 3 }
prog[#prog + 1] = { dy = 0, frames = 3 }
end
self.fx.shakeProg = prog
self.waitFrames = 48 -- the predef blocks until the shake settles
elseif t == 2 then
self.fx.shakeProg = fastShakeProg(8)
self.waitFrames = 72
elseif t == 3 then
self.fx.shakeProg = slowShakeProg(6, 2)
self.waitFrames = 48
elseif t == 4 then
if hit.blink then
self.fx.blink = { target = hit.blink, frames = 20 }
self.waitFrames = 20
end
elseif t == 5 then
self.fx.shakeProg = fastShakeProg(2)
self.waitFrames = 18
elseif t == 6 then
self.fx.shakeProg = slowShakeProg(3, 2)
self.waitFrames = 24
end
end
-- Primary status effects whose pokered handler ends in
-- PlayCurrentMoveAnimation2 (engine/battle/effects.asm:1448), which sets
-- wAnimationType 6 on the player's turn and 3 on the enemy's: sleep,
-- poison, confuse, disable and the primary stat-down effects. Every other
-- primary effect goes through PlayCurrentMoveAnimation and leaves the type
-- at 0 (no applying animation): paralysis (FreezeBurnParalyzeEffect),
-- leech seed, the stat-UP effects, Splash. Side-effect stat drops are
-- skipped too -- UpdateLoweredStatDone bails out for them because the
-- damaging move's own type 2/5 shake already played.
local SLOW_SHAKE_EFFECTS = {
SLEEP_EFFECT = true, POISON_EFFECT = true, CONFUSION_EFFECT = true,
DISABLE_EFFECT = true,
ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true,
DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true,
ACCURACY_DOWN1_EFFECT = true,
}
-- AnimateSendingOutMon (core.asm:6801-6838): the mon grows out of the
-- ball -- a 3-frame ball beat, 4 frames of the pic at 3/7 scale (a 3x3
-- block of its 7x7 tiles), 5 frames at 5/7 (5x5), then full size.
@@ -2532,6 +2731,35 @@ function BattleState:updateFx()
end
end
end
-- The Silph Scope unveil (MarowakAnim; queueScopeReveal parks a wait row
-- of GHOST_REVEAL_FRAMES over it): the ghost flashes, fades out, the pic
-- and the nick swap back the way LoadEnemyMonData restores them, and the
-- real mon fades in (#492).
local gr = self.ghostReveal
if gr and self.enemy then
gr.t = gr.t + 1
local pf = self:picFxFor(self.enemy)
local flashEnd = BattleState.GHOST_FLASH_FRAMES
local outEnd = flashEnd + BattleState.GHOST_FADE_OUT_FRAMES
if gr.t <= flashEnd then
pf.fade = (math.floor((gr.t - 1) / 10) % 2 == 1) and 0.5 or 1
elseif gr.t <= outEnd then
pf.fade = 1 - math.ceil((gr.t - flashEnd) / 10) / 3
else
if not gr.swapped then
gr.swapped = true
local real = self.ghostReal
if real then
self.enemy.name = real.name or self.enemy.name
self.enemy.sprite = real.sprite or self.enemy.sprite
end
end
pf.fade = math.min(1, math.ceil((gr.t - outEnd) / 10) / 4)
end
if gr.t >= BattleState.GHOST_REVEAL_FRAMES then
self.ghostReveal, self.scopeReveal, pf.fade = nil, nil, nil
end
end
-- the send-out grow-in (AnimateSendingOutMon): 3+4+5 frames, then
-- the pic draws at full size again
if self.growIn then
@@ -2546,10 +2774,24 @@ function BattleState:updateFx()
-- so a sounding siren rides out the next hit's HP drain instead of
-- dropping out mid-announcement (#293)
self.lowHealthAlarmOn = self:lowHealthAlarmActive()
if self.lowHealthAlarmOn then
Sound.startLoop(self.data, "Low_Health_Alarm")
-- battle.low_health_alarm: on/off toggle for the siren loop, ctx.on
-- mirrors self.lowHealthAlarmOn. Vanilla just starts/stops the loop
-- each frame; a mod can wrap this to reshape the toggle (e.g. force
-- ctx.on false after some budget) before letting vanilla act on it.
if Runtime.wantsHook("battle.low_health_alarm") then
Runtime.call("battle.low_health_alarm", function(ctx)
if ctx.on then
Sound.startLoop(ctx.battle.data, "Low_Health_Alarm")
else
Sound.stopLoop("Low_Health_Alarm")
end
end, { on = self.lowHealthAlarmOn, battle = self })
else
Sound.stopLoop("Low_Health_Alarm")
if self.lowHealthAlarmOn then
Sound.startLoop(self.data, "Low_Health_Alarm")
else
Sound.stopLoop("Low_Health_Alarm")
end
end
end
@@ -2568,6 +2810,12 @@ function BattleState:syncShownStatus()
end
function BattleState:executeAction(user, target, action)
-- MainInBattleLoop reads wEscapedFromBattle right after Execute*Move and
-- rets (core.asm:417-421, 456-460): a Teleport/Roar/Whirlwind escape ends
-- the turn where it lands and the second mover never moves. self.result
-- is only ever set once the battle is over (run/win/lose/caught), and the
-- faint cases are already covered by the HP guard below (#441)
if self.result then return end
if user.mon.hp <= 0 or target.mon.hp <= 0 then return end
if not action then return end
@@ -2932,6 +3180,8 @@ function BattleState:performMove(user, target, moveInst, isCalled)
-- (AlreadyAsleep / NothingHappened / ButItFailed print with no anim)
if primaryEffectFailed(msgs) then
self:cancelMoveAnim()
elseif SLOW_SHAKE_EFFECTS[move.effect] and self.moveAnimRow then
self.moveAnimRow.hit = { animType = user.isPlayer and 6 or 3 }
end
for _, m in ipairs(msgs) do
self:sayNext(m)
@@ -2985,6 +3235,10 @@ function BattleState:continueBide(user, target)
self:sayNext(Strings("But, it failed!"))
return
end
-- .UnleashEnergy (core.asm:3501-3529) re-points wPlayerMoveNum at BIDE
-- and rejoins HandleIfPlayerMoveMissed, so BIDE's own animation plays
-- here, after UnleashedEnergyText and before the damage (#375)
self:animNext("BIDE", user.isPlayer)
self:applyDamage(target, dmg)
if target.mon.hp <= 0 then self:onFaint(target) end
end
@@ -3009,7 +3263,7 @@ function BattleState:applyDamage(target, dmg)
end
local dealt = math.min(dmg, target.mon.hp)
target.mon.hp = target.mon.hp - dealt
if dealt > 0 then self:drainNext() end -- animate the bar down
if dealt > 0 then self:drainNext(target, target.mon.hp) end -- animate the bar down
if target.bideTurns then
target.bideDamage = (target.bideDamage or 0) + dealt
end
@@ -3073,7 +3327,10 @@ function BattleState:onFaint(battler)
end
end
function BattleState:enemyMonFainted()
-- Exp for the defeated enemy, shared by the faint path (enemyMonFainted)
-- and, when a mod's battle.catch_exp hook says so, the catch path
-- (storeCaughtMon).
function BattleState:awardExp()
-- exp is split among the mons that fought this enemy
-- (engine/battle/experience.asm); traded mons earn x1.5; each
-- participant gets the full stat exp
@@ -3151,27 +3408,44 @@ function BattleState:enemyMonFainted()
end
end
end
-- with EXP.ALL, participants split half the exp and the other half
-- is divided among the whole party (engine/battle/experience.asm)
local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
for _, mon in ipairs(alive) do
applyShare(mon, participants * (expAll and 2 or 1), true)
end
if expAll then
-- the second GainExperience pass sets the gain flags for the WHOLE
-- party, so DivideExpDataByNumMonsGainingExp divides the already
-- halved-and-participant-divided exp again by the party count, and
-- .partyMonLoop still skips fainted mons (core.asm:818-858 +
-- experience.asm:9-13); each mon gets its own GainedText with the
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
-- summary line
for _, mon in ipairs(self.game.save.party) do
if mon.hp > 0 then
applyShare(mon, math.max(1, participants) * #self.game.save.party * 2, "expAll")
-- battle.exp_award: the participant/EXP.ALL split, factored out so a
-- mod can replace it wholesale (e.g. a flat undivided share to every
-- non-fainted party mon) without re-deriving participants/alive.
-- ctx.applyShare(mon, split, announce) is the same helper vanilla uses.
local function vanillaExpAward(ctx)
-- with EXP.ALL, participants split half the exp and the other half
-- is divided among the whole party (engine/battle/experience.asm)
local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
for _, mon in ipairs(ctx.alive) do
ctx.applyShare(mon, ctx.participants * (expAll and 2 or 1), true)
end
if expAll then
-- the second GainExperience pass sets the gain flags for the WHOLE
-- party, so DivideExpDataByNumMonsGainingExp divides the already
-- halved-and-participant-divided exp again by the party count, and
-- .partyMonLoop still skips fainted mons (core.asm:818-858 +
-- experience.asm:9-13); each mon gets its own GainedText with the
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
-- summary line
for _, mon in ipairs(self.game.save.party) do
if mon.hp > 0 then
ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll")
end
end
end
end
local awardCtx = { battle = self, participants = participants, alive = alive,
applyShare = applyShare }
if Runtime.wantsHook("battle.exp_award") then
Runtime.call("battle.exp_award", vanillaExpAward, awardCtx)
else
vanillaExpAward(awardCtx)
end
self.participants = {}
end
function BattleState:enemyMonFainted()
self:awardExp()
if self.kind == "trainer" then
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
@@ -3210,10 +3484,12 @@ function BattleState:enemyMonFainted()
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
local shiftSwitchMon = nil
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
-- _TrainerAboutToUseText: "X is" / "about to use" then cont nick,
-- then para "Will PLAYER" / "change POKéMON?" with YES/NO.
self:say(Strings("%s is\nabout to use", self.trainer.name))
self:say(Strings("%s!", nextName))
-- _TrainerAboutToUseText (data/text/text_2.asm): "X is" / "about to
-- use" then a CONT to the nick, not a fresh box -- the box scrolls
-- "X is" off so "about to use" stays above the name, instead of the
-- page ending on a bare nick (#565). Then para "Will PLAYER" /
-- "change POKéMON?" with YES/NO.
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
self:sayChoice(
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
function(yes)
@@ -3331,8 +3607,18 @@ function BattleState:enemyMonFainted()
-- PrintEndBattleText prints one text box; a `para` (\f) inside it
-- starts a fresh page, which is a message row of its own here (five
-- EndBattleTexts carry one, e.g. _Route9Youngster1EndBattleText)
-- TrainerEndBattleText (home/trainers.asm) prints _TrainerNameText
-- (wNameBuffer then ": ", data/text/text_1.asm) and only then the
-- saved pointer, so the loss line opens with the trainer class tag on
-- its first line (RIVAL1/2/3 tag with the rival's name:
-- TrainerNamePointers aims those entries at wTrainerName). The tag
-- prints once, so a `para` page carries no second copy (#566).
local tag = self.trainer and self.trainer.name
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
if page ~= "" then self:sayNext(page) end
if page ~= "" then
self:sayNext(tag and (tag .. ": " .. page) or page)
tag = nil
end
end
end
self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize))
@@ -3732,6 +4018,12 @@ function BattleState:storeCaughtMon()
-- (item_effects.asm:472-501), regenerating its move list from the
-- base data -- a Mimic'd slot never leaves the battle with it
self:restoreMimicked(self.enemy)
-- battle.catch_exp: vanilla catches never grant exp; a mod can flip
-- this to true to pay out the same award a faint would have.
if Runtime.wantsHook("battle.catch_exp")
and Runtime.call("battle.catch_exp", function() return false end, { battle = self }) then
self:awardExp()
end
local game = self.game
local dex = game.save.pokedex
local species = self.enemy.mon.species
@@ -3856,12 +4148,15 @@ function BattleState:throwBall(ball)
self:act(function() self:endOfTurn() end)
return
end
if self.ghost then
if self.ghost or self.noCatch then
-- ItemUseBall's can't-be-caught path (item_effects.asm:149-153):
-- the ball is thrown (TossBallAnimation still picks the arc from
-- wCurItem, so a Master/Ultra toss keeps its flicker), dodged
-- ($10 anim data, no wobbles), and the turn is spent like any
-- failed throw
-- failed throw. battle.noCatch is the .notOldManBattle half of the
-- same check (item_effects.asm:166-175): the POKEMON_TOWER_6F
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
-- so it is not a ghost battle any more (#444)
self:animNext(self:tossAnimFor(ball), true, nil, ball)
self:sayNext(Strings("It dodged the\nthrown BALL!"))
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
@@ -4158,6 +4453,18 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
end
if battler.fainted then return end
-- MarowakAnim's rOBP1 fades (updateFx): alpha over the paper is what those
-- palette shifts look like, and the test has to sit ahead of the plain
-- fast path below (#492)
local fadePf = self.picFx and self.picFx[battler]
if fadePf and fadePf.fade then
local cr, cg, cb, ca = love.graphics.getColor()
love.graphics.setColor(cr, cg, cb, ca * fadePf.fade)
love.graphics.draw(img, x, y, 0, scale, scale)
love.graphics.setColor(cr, cg, cb, ca)
return
end
local pf = self.picFx and self.picFx[battler]
if not pf or (not pf.kind and not pf.hidden and not pf.minimized
and (pf.ox or 0) == 0 and (pf.oy or 0) == 0) then
@@ -4345,7 +4652,9 @@ function BattleState:sgbBattlePals()
-- true white, which is what drew a white box around each pic on the pink
-- field. Snap every zone's color 0/3 to the global GBC white/black; the
-- mid shades (and the green bar the user prefers) stay untouched.
if PaletteFX.mode == "ogred" then
-- Boot-ROM OG only (Red/Blue): snap zone paper to the global GBC white/black.
-- OG YELLOW keeps each CGBBasePalettes endpoint (already near-white / near-black).
if PaletteFX.mode == "ogred" and not require("src.core.GameVersion").isYellow() then
local white, black = PaletteFX.GBC_BG[1], PaletteFX.GBC_BG[4]
for i = 0, 3 do
local c = out[i]
@@ -4921,7 +5230,7 @@ function BattleState:drawClassic()
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
sx = self.frame % 4 < 2 and 2 or -2
end
local slide = (self.introSlide or 0) * 4 -- intro slide-in offset
local slide = (self.introSlide or 0) * 2 -- intro slide-in offset (2px/frame)
if self:colorMode() then
-- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the
+14
View File
@@ -93,6 +93,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
-- MoveHitTest's INVULNERABLE branch sets the same wMoveMissed as a
-- failed accuracy roll (core.asm:5260), and the miss handler still
-- runs the explode effect ("even if Explosion or Selfdestruct
-- missed, its effect still needs to be activated", core.asm:3223),
-- so the user faints against a mid-Fly/Dig target too (#528)
if record and record.onMiss then record.onMiss(ctx, "invulnerable") end
return
end
@@ -186,7 +192,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- hitRow carries the blink instead.
local hitSfx = info.typeMult > 10 and "Super_Effective"
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
-- GetPlayerAnimationType / GetEnemyAnimationType (engine/battle/core.asm
-- :3159 / :5555): wAnimationType is 4 (blink the enemy pic) or 1 (shake
-- the screen vertically) for a damaging move with no added effect, and
-- 5 / 2 (a horizontal shake) as soon as the move HAS one -- which is why
-- Bubblebeam and Confusion shake instead of blinking (#354)
local added = move.effect ~= nil and move.effect ~= "NO_ADDITIONAL_EFFECT"
local hitFx = { sfx = hitSfx,
animType = user.isPlayer and (added and 5 or 4)
or (added and 2 or 1),
blink = battle:animationsOn() and target or nil }
local totalDealt = 0
+6
View File
@@ -609,10 +609,16 @@ MoveEffects.full = {
},
BIDE_EFFECT = {
-- BideEffect (effects.asm:764-789) is a ResidualEffects2 entry: the
-- storing turn plays XSTATITEM_ANIM (XSTATITEM_DUPLICATE_ANIM on the
-- enemy side) and never BIDE's own animation, which belongs to the
-- release turn in .UnleashEnergy (#375)
perform = function(ctx)
local user = ctx.user
user.bideTurns = ctx.rng(2, 3)
user.bideDamage = 0
ctx.battle:cancelMoveAnim()
ctx.anim(user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM")
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
end,
},
+39 -6
View File
@@ -183,6 +183,21 @@ local function drawCommandMenu(battle)
Font.drawBox(0, 13, 20, 5)
Font.drawBox(20, 13, 18, 5)
love.graphics.setColor(0, 0, 0, 1)
-- The old-man / PROF.OAK catch demo has no party, so makeOldManDemo parks
-- the WILD mon in battle.player as a placeholder (BattleState:1209). The
-- classic layout's demo branch never names anyone (DisplayBattleMenu,
-- core.asm:2038-2049, just draws the menu), so naming it here printed
-- "What will PIKACHU do?" over Oak's scripted throw (#557). Leave the
-- prompt side blank and run the same scripted hand the classic does.
if battle.demo then
Font.draw(Strings("FIGHT"), 176, 112)
Font.drawCode(0xE1, 240, 112); Font.drawCode(0xE2, 248, 112)
Font.draw(Strings("ITEM"), 176, 128)
Font.draw(Strings("RUN"), 240, 128)
-- next to FIGHT for the first 80 frames, then ITEM
Font.drawCode(0xED, 168, (battle.demoTimer or 0) <= 80 and 112 or 128)
return
end
Font.draw(Strings("What will"), 8, 112)
local who = battle.player and battle.player.name or ""
Font.draw(fitName(who, 112) .. Strings(" do?"), 8, 128)
@@ -305,22 +320,40 @@ function WideBattle.draw(battle)
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
sx = battle.frame % 4 < 2 and 2 or -2
end
local slide = (battle.introSlide or 0) * 4
local slide = (battle.introSlide or 0) * 2
-- Each side keeps its original sprite pixels and placement math: the two
-- 160x144 OAM regions are translated apart and clipped into the wider
-- battlefield rather than either monster being scaled. wideRegion tells
-- drawBattlerPic its own side window is already the clip.
-- drawBattlerPic its own side window is already the clip. A shake moves
-- each region's clip with its contents, so a pic pushed toward a region
-- edge is not sheared off it (a vertical shake used to clip the player's
-- feet at FIELD_BOTTOM).
battle.wideRegion = true
inRegion(0, 32, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
inRegion(sx, 32 + sy, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
function() battle:drawPicsLayer(slide, 0, 0, "player", true) end)
inRegion(160, 0, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
battle.wideRegion = nil
drawHUDs(battle, slide)
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the
-- shakes move IS the whole screen: PredefShakeScreenHorizontally,
-- PredefShakeScreenVertically and AnimationShakeScreenHorizontallySlow
-- displace the HUDs and the message window along with the pics, exactly as
-- drawClassic offsets its whole BG canvas. Shaking only the two pic
-- regions left the foe gliding sideways across a nailed-down screen on
-- every applying-attack shake -- TAIL WHIP and every other status move
-- (#562). The OAM anim layer stays put, as it does in drawClassic.
local function shaken(fn)
if sx == 0 and sy == 0 then return fn() end
g.push()
g.translate(sx, sy)
fn()
g.pop()
end
shaken(function() drawHUDs(battle, slide) end)
drawAnimationLayer(battle)
drawTextArea(battle)
shaken(function() drawTextArea(battle) end)
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
g.setColor(1, 1, 1, 0.85)
+39 -5
View File
@@ -32,17 +32,20 @@ local MUSIC_BUFFER_COUNT = ChipSynth.MUSIC_BUFFER_COUNT
-- [1] pulse 1 [2] pulse 2 [3] wave [4] noise / drums
-- Volume: 1 = authentic, 0 = mute, >1 boosts
-- Pitch: 1 = authentic, 2 = +1 octave, 0.5 = -1 octave
-- The shipped values stay at 1: 0.25 / 0.5 on the wave channel buried the Ch3
-- countermelodies an octave low (#429), and ChipSynth already applies the
-- wave channel's own hardware octave (frequency * 0.5).
-- ---------------------------------------------------------------------------
local CHANNEL_VOLUME = {
[1] = 1, -- pulse 1
[2] = 1, -- pulse 2
[3] = 0.25, -- wave
[3] = 1, -- wave
[4] = 1, -- noise / drums
}
local CHANNEL_PITCH = {
[1] = 1, -- pulse 1
[2] = 1, -- pulse 2
[3] = 0.5, -- wave
[3] = 1, -- wave
[4] = 1, -- noise / drums
}
ChipSynth.setChannelVolumes(CHANNEL_VOLUME)
@@ -55,6 +58,14 @@ local currentMusic
local pendingBuf -- a current-gen buffer popped from the worker but not yet
-- queued because the Source was momentarily full
-- Music holds playback while a fanfare owns the music channels (#398).
-- Pausing the Source is not enough on its own: this module is what starts a
-- chip song (immediately on the sync path, on the first worker buffer on the
-- threaded one), so a song that begins during a jingle would come up
-- underneath it. Music.duckForFanfare sets the hold, Music releases it when
-- the jingle ends.
local musicHeld = false
-- ---------------------------------------------------------------------------
-- worker management
-- ---------------------------------------------------------------------------
@@ -148,7 +159,7 @@ local function playMusicSync(data, header, allowLoops)
currentMusic = { source = source, engine = engine, threaded = false,
started = true, finished = false }
fillSync(MUSIC_FILL_INITIAL)
source:play()
if not musicHeld then source:play() end
return source
end
@@ -222,7 +233,7 @@ local function updateThreaded()
end
end
end
if not m.started then
if not m.started and not musicHeld then
if (MUSIC_BUFFER_COUNT - m.source:getFreeBufferCount()) > 0 then
pcall(function() m.source:play() end)
m.started = true
@@ -245,7 +256,7 @@ end
-- pause/resume behavior.
function ChipAudio.ensureMusicPlaying()
local m = currentMusic
if not m or m.finished then return end
if not m or m.finished or musicHeld then return end
if m.threaded then
if not m.started then return end
local ok, playing = pcall(function() return m.source:isPlaying() end)
@@ -263,6 +274,18 @@ function ChipAudio.ensureMusicPlaying()
end
end
-- Silence the song for the length of a fanfare and start whatever was held
-- back once it ends. Held state outlives a song change: Music.play may swap
-- songs while the jingle is still sounding.
function ChipAudio.holdMusic(held)
held = not not held
if held == musicHeld then return end
musicHeld = held
if held then return end
ChipAudio.update()
ChipAudio.ensureMusicPlaying()
end
-- Threaded playMusic returns an empty QueueableSource and only calls
-- Source:play once the first worker buffer lands (~1 frame later). Until
-- then Source:isPlaying is false -- callers that treat that as "song over"
@@ -303,6 +326,17 @@ function ChipAudio.invalidate()
if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end
end
-- End the worker thread. LOVE waits for every live love.thread before the
-- process exits and the worker's command loop only returns on "quit", so
-- skipping this leaves the process running after the window is gone (#339).
function ChipAudio.shutdown()
ChipAudio.stopMusic()
if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end
if worker then pcall(function() worker:wait() end) end
worker, cmdCh, outCh = nil, nil, nil
workerReady = false
end
-- Runtime mix for one hardware channel (1..4). Takes effect on the next
-- synthesized buffer (live music) and on any SFX/cry rendered after the call.
function ChipAudio.setChannelVolume(hw, scale)
+8 -8
View File
@@ -14,12 +14,12 @@ local MODULES = {
-- Optional for compatibility with developer and stale caches.
local OPTIONAL = { "audio", "palettes", "icons" }
-- The rules the engine still carries as literals. The constants registry
-- deep-merges over these, so a value has to exist before a mod can patch
-- it; each one is the number the engine hard-codes today, so seeding them
-- changes nothing on a mod-free boot.
-- Vanilla defaults for rules exposed through the constants registry. A
-- value has to exist before a mod can patch it; each one matches the
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
-- boot.
local CONSTANT_DEFAULTS = {
bagSize = 20, -- BAG_ITEM_CAPACITY (src/inventory/Bag.lua)
bagSize = 20, -- BAG_ITEM_CAPACITY (Bag.capacity fallback)
partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
moveMax = 4,
@@ -144,8 +144,8 @@ end
-- no def_trainers row (DisplayTextID routes to his ASM script), so the
-- extractor emits headers only for the four blackbelts ([2]..[5]). Seed
-- object index [1] so he behaves like the real leader:
-- * range 4 (matches the strongest blackbelt) -> CheckFightingMapTrainers
-- spots the player in his DOWN line and he challenges on sight (#197),
-- * range 0: FightingDojoDefaultScript, not trainer sight, starts his
-- battle from the single tile to his left (#495),
-- * battle = his pre-battle challenge, won = "Hwa! Arrgh! Beaten!",
-- * after = the "Stay and train at Karate with us!" re-talk line.
-- Deliberately NO `event`: EVENT_BEAT_KARATE_MASTER is owned by
@@ -160,7 +160,7 @@ function Data:seedFightingDojoKarateMaster()
headers.FightingDojo = headers.FightingDojo or {}
if headers.FightingDojo[1] then return end
headers.FightingDojo[1] = {
range = 4,
range = 0,
battle = "_FightingDojoKarateMasterText",
won = "_FightingDojoKarateMasterDefeatedText",
after = "_FightingDojoKarateMasterStayAndTrainWithUsText",
+97 -2
View File
@@ -172,7 +172,28 @@ function Game:returnToTitle()
end
function Game:step(dt)
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
-- the same fixed-step boundary as a physical controller. Run them before
-- Input:step promotes queued edges so a button chosen here is visible to
-- this logic tick, not one tick later. With no wrapper this is a no-op.
ModRuntime.call("input.step", function() end, self, dt)
self.input:step()
-- A+B+SELECT+START held for 16 steps: SoftReset (home/init.asm) stops the
-- audio, whites the palettes out and falls through into Init, i.e. the
-- power-on boot, so everything unsaved is gone and the title sequence
-- comes back -- exactly what returnToTitle does for the START menu's QUIT.
-- The check lives here, above stack:update, so it fires from any state:
-- overworld, battle, a menu or a cutscene, the way _Joypad's does (#563).
-- Input:reset clears the four still-physically-held buttons so the title
-- screen does not read A as a menu choice on its first frame.
-- a tool mod (or a harness) may hand Game a stand-in input with no
-- chord bookkeeping; those simply never soft reset
if self.input.softResetStep and self.input:softResetStep() then
Input:reset()
TouchControls:reset()
self:returnToTitle()
return
end
-- serviced unconditionally: a link battle's ENet transport must not
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
-- on top of BattleState (see LinkBattle.new)
@@ -339,7 +360,13 @@ function Game:draw()
if worldBelow and self.overworld.sgbWorldZones then
worldZones = self.overworld:sgbWorldZones()
end
Renderer:endFrame(zones, worldZones)
local viewport = Renderer:endFrame(zones, worldZones)
-- Persistent tool status is screen-space UI: draw it over the completed
-- render pipeline with exact playfield/margin geometry, but below mobile
-- controls. It never becomes an updating game state.
if ModRuntime.wantsHook("render.hud") then
ModRuntime.call("render.hud", function() end, self, viewport)
end
-- on-screen mobile controls: pure screen-space, over the finished frame
TouchControls:draw()
end
@@ -464,8 +491,16 @@ function Game:restartWithMods()
require("src.core.HostShell").restart()
end
-- Releases reach Input even while a top state captures raw input: a
-- swallowed key-up would strand a held-state flag for a key Input saw go
-- down before the capture armed (the stuck-flag hazard Input:reset
-- exists for). The top state only OBSERVES the release afterwards,
-- unlike onKeyPressed above which owns the press, so BindingsMenu can
-- commit a capture on the key-up (#589).
function Game:keyreleased(key)
Input:keyreleased(key)
local top = self.stack and self.stack:top()
if top and top.onKeyReleased then top:onKeyReleased(key) end
end
function Game:gamepadpressed(joystick, button)
@@ -482,7 +517,10 @@ function Game:gamepadpressed(joystick, button)
end
function Game:gamepadreleased(joystick, button)
-- same observe-after-Input contract as Game:keyreleased (#589)
Input:gamepadreleased(joystick, button)
local top = self.stack and self.stack:top()
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
end
function Game:gamepadaxis(joystick, axis, value)
@@ -491,6 +529,40 @@ function Game:gamepadaxis(joystick, axis, value)
Input:gamepadaxis(joystick, axis, value)
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".
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
function Game:joystickpressed(joystick, button)
if isAccelerometer(joystick) then return end
TouchControls:noteGamepad()
Input:joystickpressed(joystick, button)
end
function Game:joystickreleased(joystick, button)
if isAccelerometer(joystick) then return end
Input:joystickreleased(joystick, button)
end
function Game:joystickaxis(joystick, axis, value)
if isAccelerometer(joystick) then return end
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
Input:joystickaxis(joystick, axis, value)
end
function Game:joystickhat(joystick, hat, direction)
if isAccelerometer(joystick) then return end
if direction ~= "c" then TouchControls:noteGamepad() end
Input:joystickhat(joystick, hat, direction)
end
-- Window focus/visibility flips: a release due while unfocused/hidden can
-- be swallowed by the OS. Reset on both edges -- gaining focus with a
-- physically held key won't re-fire keypressed, so trusting leftover
@@ -544,6 +616,11 @@ end
-- Capture the live world state into the save table and persist it.
-- Options are flushed to options.lua as part of SaveData.save.
function Game:writeSave()
-- Tool sessions can be deliberately ephemeral. Give them one narrow veto
-- before captureSave mutates the snapshot or any progress bytes reach disk.
if ModRuntime.call("save.write", function() return true end, self) == false then
return false
end
if self.overworld and self.overworld.captureSave then
self.overworld:captureSave(self.save)
end
@@ -554,7 +631,7 @@ function Game:writeSave()
if ModRuntime.wants("save.writing") then
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
end
SaveData.save(self.save)
return SaveData.save(self.save)
end
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
@@ -584,7 +661,25 @@ function Game:applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
-- Scale the optional presentation extras to the device's performance
-- tier. Every heavy feature was just applied from the stored options
-- above; here we clamp the *live* state down for a weaker device without
-- rewriting what the player saved, so raising the tier later restores
-- their exact TILT / GBC FX / ZOOM / MAX FPS choices. A HIGH tier (the
-- default on a normal desktop, and every options.lua predating this
-- option) clamps nothing, so it is a no-op for the common case.
local caps = require("src.core.Performance").applyOptions(opts)
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end
local Zoom = require("src.render.Zoom")
Zoom.allowSurvey = caps.survey
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
if caps.fpsMax then
local FrameCap = require("src.core.FrameCap")
if FrameCap.current > caps.fpsMax then FrameCap.apply(caps.fpsMax) end
end
Input:applyBindings(opts.bindings)
TouchControls:applyOptions(opts)
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
if gbcCleared then self:writeOptions() end
end
+21
View File
@@ -26,9 +26,30 @@ end
-- ("Failed to initialize filesystem: already initialized") and the relaunch
-- crashes. So on an AppImage we relaunch the executable; the fresh process's
-- Boot step mounts any downloaded update exactly as a manual relaunch would.
-- Android hits the same wall (#575): the vendored love.cpp loops runlove()
-- in-process on "restart", and PHYSFS_deinit in the old Filesystem module's
-- destructor fails ("files still open") whenever any physfs handle survives
-- lua_close, so the second PHYSFS_init throws the same "already initialized"
-- and the app dies. There we relaunch through the GameActivity.restartApp
-- JNI bridge (love.system.restartApp), which schedules our launch intent
-- and kills the process so no native state can leak into the fresh run.
-- On every other platform the in-process restart works, so keep it.
function HostShell.restart()
if not (love and love.event and love.event.quit) then return end
local osName = love.system and love.system.getOS and love.system.getOS()
if osName == "Android" then
-- restartApp kills the process on success, so a true return is never
-- observed; false means the bridge could not schedule the relaunch.
-- An older APK whose liblove predates the bridge (love.system.restartApp
-- is nil) has no crash-free in-process restart, so quit to the OS
-- cleanly and let the player relaunch by hand -- worse than restarting,
-- but better than the guaranteed crash of quit("restart") (#575).
if love.system.restartApp and love.system.restartApp() then return end
love.event.quit()
return
end
local appimage = os.getenv("APPIMAGE")
if not appimage then
love.event.quit("restart")
+93
View File
@@ -39,6 +39,20 @@ local DEFAULT_GAMEPAD_BINDINGS = {
local STICK_ON = 0.5
local STICK_OFF = 0.3
-- Generic SDL joysticks expose the left stick as the first two numbered
-- axes and the D-pad as a hat. This is common on Linux handhelds whose
-- controller has no game-controller database entry.
local RAW_BUTTON_BINDINGS = {
[1] = "a", [2] = "b",
[7] = "select", [8] = "start", [9] = "select", [10] = "start",
}
local HAT_DIRECTIONS = {
u = { "up" }, d = { "down" }, l = { "left" }, r = { "right" },
lu = { "left", "up" }, ru = { "right", "up" },
ld = { "left", "down" }, rd = { "right", "down" },
}
function Input:init()
self:applyBindings(nil)
self:reset()
@@ -79,6 +93,7 @@ function Input:reset()
self.sources = {}
self.stickAxis = { x = 0, y = 0 }
self.stickDir = nil
self.hatDirs = {}
end
-- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
@@ -179,6 +194,16 @@ function Input:gamepadreleased(joystick, button)
end
end
function Input:joystickpressed(joystick, button)
local btn = RAW_BUTTON_BINDINGS[button]
if btn then press(self, btn, "joy:" .. button) end
end
function Input:joystickreleased(joystick, button)
local btn = RAW_BUTTON_BINDINGS[button]
if btn then release(self, btn, "joy:" .. button) end
end
-- left stick treated as a continuous held direction, same 4-way rule as
-- the touch swipe d-pad: whichever axis has the larger magnitude wins.
function Input:gamepadaxis(joystick, axis, value)
@@ -214,12 +239,80 @@ function Input:gamepadaxis(joystick, axis, value)
end
end
function Input:joystickaxis(joystick, axis, value)
if axis == 1 then
self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then
self:gamepadaxis(joystick, "lefty", value)
end
end
function Input:joystickhat(joystick, hat, direction)
local source = "hat:" .. hat
for _, btn in ipairs(self.hatDirs[hat] or {}) do
release(self, btn, source)
end
local dirs = HAT_DIRECTIONS[direction] or {}
for _, btn in ipairs(dirs) do
press(self, btn, source)
end
self.hatDirs[hat] = dirs
end
function Input:isDown(btn)
return self.state[btn] or false
end
-- True when the on-screen overlay is one of the live sources holding this
-- button (see overlayPressed above). Player:turnWindow widens the
-- turn-in-place tap window on touch, where a press and release can never be
-- as short as a physical d-pad's (#415).
function Input:isTouchDown(btn)
local sources = self.sources[btn]
return (sources and sources["touch:" .. btn]) and true or false
end
function Input:wasPressed(btn)
return self.pressed[btn] or false
end
-- Soft reset (#563). _Joypad (engine/joypad.asm) tests the RAW joypad read
-- with `cp PAD_BUTTONS` -- an equality, not a mask -- so the combo counts
-- only while A, B, SELECT and START are the only buttons down; any d-pad
-- direction in the mix cancels it. That test sits ahead of the wJoyIgnore
-- and BIT_DISABLE_JOYPAD masking below it, which is why the reset still
-- works mid-battle and mid-cutscene where ordinary input is thrown away.
-- TrySoftReset then burns one DelayFrame per pass and decrements hSoftReset,
-- seeded with 16 by Init (home/init.asm), so the combo has to survive 16
-- consecutive polls. That hold is also what keeps the on-screen overlay
-- safe: it already takes four separate fingers on four separate controls,
-- and they all have to stay put for better than a quarter of a second.
local SOFT_RESET_FRAMES = 16
function Input:softResetHeld()
if not (self.state.a and self.state.b
and self.state.start and self.state.select) then
return false
end
return not (self.state.up or self.state.down
or self.state.left or self.state.right)
end
-- Ticked once per fixed step by Game:step; true on the step the countdown
-- runs out. hSoftReset is never re-seeded on release in the original, so
-- its count leaks across a whole session; a port that copied that would
-- eventually reset on a stray four-button press, so the counter re-arms as
-- soon as the combo drops.
function Input:softResetStep()
if not self:softResetHeld() then
self.softResetFrames = nil
return false
end
local left = (self.softResetFrames or SOFT_RESET_FRAMES) - 1
self.softResetFrames = left
if left > 0 then return false end
self.softResetFrames = nil
return true
end
return Input
+12 -1
View File
@@ -23,6 +23,13 @@ local volumeScale = 1
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
local filterLevel = 0
-- Forward-declared here so applyVolume (below) closes over the real playback
-- state rather than a nil global: the table literal is assigned further down,
-- but a `local state = {}` there would leave every reference above it bound
-- to the global `state`. Before this, registering the `music.volume` mod
-- hook crashed applyVolume on `state.current` (a nil index).
local state
local function applyVolume(src)
if not src then return end
local vol = VOLUME * volumeScale
@@ -63,7 +70,7 @@ local function applyFilter(src)
end
end
local state = {
state = {
current = nil, -- song label
chip = false, -- the playing song is a synthesized channel program
source = nil, -- currently playing source
@@ -85,6 +92,7 @@ local function fanfareActive()
local ok, playing = pcall(src.isPlaying, src)
if ok and playing then return true end
state.fanfare = nil
require("src.core.ChipAudio").holdMusic(false)
return false
end
@@ -94,6 +102,9 @@ end
function Music.duckForFanfare(src)
if not src then return end
state.fanfare = src
-- ChipAudio is what starts a chip song, so the pause below cannot hold one
-- that has not started yet (nor one Music.play swaps in mid-jingle) (#398)
require("src.core.ChipAudio").holdMusic(true)
if state.source then
local ok, playing = pcall(state.source.isPlaying, state.source)
if ok and playing then
+156
View File
@@ -0,0 +1,156 @@
-- Graphics performance tier: one knob that scales the port's optional,
-- non-faithful presentation extras down for weaker hardware.
--
-- The heavy extras are all things the Game Boy never had and this port
-- adds on top: the whole-screen 3D TILT (transforms the entire map as a
-- ground plane), the GBC FX post-process shader (a fullscreen pass), and
-- survey ZOOM (zooming out renders the connected neighbor maps -- a lot of
-- extra overdraw). A hard FPS ceiling caps present cost on top of that.
-- None of this touches game logic, which is fixed-step off dt
-- (src/core/FixedStep.lua), so every tier plays identically; they differ
-- only in how much optional eye-candy the renderer is allowed to do.
--
-- The tier is persisted as save.options.performance:
-- auto pick a default from the device (see detect())
-- high everything on -- the historical behavior
-- balanced no TILT, no GBC FX (kept: survey zoom, colors, uncapped FPS)
-- low no TILT, no GBC FX, no survey zoom, FPS capped
--
-- AUTO only chooses the *default* for the current device; every tier is
-- selectable in OPTIONS, so a heuristic that guesses wrong is one row away
-- from being overridden. The clamps are applied live in Game:applyOptions
-- against the stored options without rewriting them, so raising the tier
-- restores exactly the TILT / GBC FX / ZOOM / FPS the player had.
--
-- Zero requires: loads during love.conf and under plain Lua for tools and
-- tests, the same way src/core/GameVersion.lua does.
local Performance = {}
-- Option-row order (auto first, then most to least capable).
Performance.TIERS = { "auto", "high", "balanced", "low" }
Performance.LABELS = {
auto = "AUTO",
high = "HIGH",
balanced = "BALANCED",
low = "LOW",
}
-- What each concrete tier permits. `auto` is resolved to one of these
-- before caps are read, so it has no row here. fpsMax = false means no
-- extra ceiling (the player's own MAX FPS still applies).
Performance.CAPS = {
high = { tilt = true, gbcfx = true, survey = true, fpsMax = false },
balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false },
low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 },
}
-- Live resolved tier (never "auto"); Game:applyOptions sets it and the
-- renderer / options row read it back. Defaults to the unclamped tier so
-- a pre-boot launcher behaves exactly as it always did.
Performance.tier = "high"
local function loveOS()
if love and love.system and love.system.getOS then
return love.system.getOS()
end
return nil
end
local function processorCount()
if love and love.system and love.system.getProcessorCount then
return love.system.getProcessorCount()
end
return nil
end
-- CPU architecture via LuaJIT's jit.arch when present: "arm"/"arm64" on
-- phones and PortMaster handhelds, "x86"/"x64" on desktops. nil under a
-- plain-Lua tool or test with no jit table, which resolves to HIGH (no
-- clamping) so tooling is never surprised.
local function cpuArch()
return (jit and jit.arch) or nil
end
-- Default tier for the current device. Deliberately conservative: only
-- the platforms that are reliably weak drop below HIGH, and AUTO is always
-- overridable, so a wrong guess costs one OPTIONS row. A normal
-- multi-core desktop -- the common case, and every existing install whose
-- options.lua predates this option -- resolves to HIGH and behaves exactly
-- as before.
function Performance.detect()
local os = loveOS()
local arch = cpuArch()
local isArm = arch == "arm" or arch == "arm64"
local cores = processorCount()
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
-- already ships a build for): the weakest target here.
if isArm and os ~= "Android" and os ~= "iOS" then
return "low"
end
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
-- balanced additionally drops the 3D tilt, the heaviest remaining extra.
if os == "Android" or os == "iOS" then
return "balanced"
end
-- Desktop: a dual-core (or single-core) box is the low-end line.
if cores and cores <= 2 then
return "balanced"
end
return "high"
end
-- Fold a stored option value to a concrete tier, resolving "auto" (and any
-- hand-edited garbage) through detect().
function Performance.resolve(value)
if value == "high" or value == "balanced" or value == "low" then
return value
end
return Performance.detect()
end
-- Normalize a stored value to a valid option id (keeps "auto"); a bad value
-- degrades to auto so a corrupt options.lua still lands on something sane.
function Performance.normalize(value)
for _, id in ipairs(Performance.TIERS) do
if value == id then return id end
end
return "auto"
end
-- Row label for a stored value: the tier's own name (AUTO / HIGH / ...).
function Performance.label(value)
return Performance.LABELS[Performance.normalize(value)]
end
-- Concrete caps for a stored option value (resolving auto).
function Performance.caps(value)
return Performance.CAPS[Performance.resolve(value)]
end
-- Resolve the tier for these options, record it live, and return its caps.
-- Called from Game:applyOptions, which clamps the live presentation modules
-- against the returned caps.
function Performance.applyOptions(opts)
local tier = Performance.resolve(opts and opts.performance)
Performance.tier = tier
return Performance.CAPS[tier]
end
-- Cycle the OPTIONS row: auto -> high -> balanced -> low -> auto (dir -1
-- reverses). Lua's `%` is non-negative for a positive modulus, so a
-- negative dir wraps correctly.
function Performance.cycle(value, dir)
value = Performance.normalize(value)
local i = 1
for idx, id in ipairs(Performance.TIERS) do
if id == value then i = idx break end
end
local n = #Performance.TIERS
local nextIdx = (i - 1 + (dir or 1)) % n + 1
return Performance.TIERS[nextIdx]
end
return Performance
+40
View File
@@ -0,0 +1,40 @@
-- Usable window rect for mobile chrome (notch / Dynamic Island / home
-- indicator / Android display cutouts). Wraps love.window.getSafeArea when
-- the engine provides it; otherwise the full graphics window.
--
-- Desktop and headless stubs return the full window, so callers can always
-- layout against this rect without platform branches. Interactive chrome
-- (touch overlay, launcher) should prefer this over getDimensions; the game
-- canvas may still letterbox into the full framebuffer for immersion.
local SafeArea = {}
function SafeArea.rect()
local ww, wh = 0, 0
if love and love.graphics and love.graphics.getDimensions then
ww, wh = love.graphics.getDimensions()
end
if ww <= 0 then ww = 1 end
if wh <= 0 then wh = 1 end
if not (love and love.window and love.window.getSafeArea) then
return 0, 0, ww, wh
end
local x, y, w, h = love.window.getSafeArea()
if type(x) ~= "number" or type(y) ~= "number"
or type(w) ~= "number" or type(h) ~= "number"
or w <= 0 or h <= 0 then
return 0, 0, ww, wh
end
-- Clamp to the drawable window so a bad / mid-rotation backend cannot
-- push layout outside the surface.
x = math.max(0, math.min(x, ww))
y = math.max(0, math.min(y, wh))
w = math.max(1, math.min(w, ww - x))
h = math.max(1, math.min(h, wh - y))
return x, y, w, h
end
return SafeArea
+27 -1
View File
@@ -223,6 +223,9 @@ function SaveData.defaultOptions()
-- 0-7 like the GB's NR50 master volume
musicVol = 7,
sfxVol = 7,
-- Yellow only: 0-7 trim for Pikachu's PCM voice clips on top of sfxVol
-- (the PIKACHU VOL row appears only on Yellow; see Sound.lua)
pikaVol = 7,
musicFilter = 0,
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
speed = 1,
@@ -238,6 +241,11 @@ function SaveData.defaultOptions()
videoMode = "windowed",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- graphics performance tier: auto | high | balanced | low. "auto"
-- picks a default from the device (ARM handhelds/phones drop the heavy
-- extras); scales TILT / GBC FX / survey ZOOM / FPS but never game
-- logic. See src/core/Performance.lua.
performance = "auto",
-- Per-pipeline display levels, keyed by render_pipelines id (see
-- src/render/Pipelines.lua). A level for a mod that is not installed
-- is kept rather than pruned, so re-enabling the mod restores the mode
@@ -246,6 +254,24 @@ function SaveData.defaultOptions()
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
-- GitHub release checks for mods with a manifest "github" field
-- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours.
modUpdateCache = {},
-- Community mod indexes the player has chosen to browse
-- (src/mods/ModIndex.lua), in the order they added them. Empty by
-- default and never populated automatically: adding an index is how a
-- player says they trust whoever publishes it, so the launcher asks
-- rather than shipping one. Rows are { url, feed, base, fallback,
-- label }.
modIndexes = {},
-- Parsed index listings keyed by feed URL; TTL is 24 hours, matching how
-- often the feeds themselves rebuild.
modIndexCache = {},
-- On-screen touch overlay (Android/iOS; see src/core/TouchControls.lua).
-- enabled=false hides it permanently (distinct from auto-hide-on-gamepad).
-- positions are optional normalized centers {x=0..1, y=0..1} per control
-- (dpad/a/b/start/select); nil means the default layout.
touchControls = { enabled = true },
}
end
@@ -1032,7 +1058,7 @@ local function reclaim(save, data, report)
if type(entry) == "table" and known(data.items, entry.id) then
table.remove(orphaned.items, i)
if entry.from == "pcItems" or type(save.inventory) ~= "table"
or not Bag.add(save, entry.id, entry.count or 1) then
or not Bag.add(save, entry.id, entry.count or 1, data) then
save.pcItems = save.pcItems or {}
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
end
+51 -10
View File
@@ -16,6 +16,30 @@ local cache = {}
-- scaling the 0.8 base every source gets
local BASE_VOLUME = 0.8
local volumeScale = 1
-- port addition (Yellow only): 0-7 trim from save.options.pikaVol on top of
-- the SFX level, for Pikachu's voice clips alone. Yellow voices every
-- Pikachu cry with PCM samples that are far louder and far more frequent
-- than the chip cries around them (the follower alone talks on every
-- interaction), so this is the one source players want to pull down
-- without muting the rest of the SFX bus. 7 = untouched, 0 = silent.
local pikaScale = 1
-- cache keys whose volume the Pikachu trim applies to: the PCM clips, plus
-- the chip PIKACHU cry that a Yellow cache without extracted clips falls
-- back to (playCry). Red/Blue never reach the second branch, so a shared
-- options.lua carrying a low pikaVol cannot quiet their Pikachu.
local function isPikaKey(key)
if type(key) ~= "string" then return false end
if key:sub(1, 8) == "pikacry:" then return true end
return key == "cry:PIKACHU"
and require("src.core.GameVersion").isYellow()
end
local function volumeFor(key)
local scale = volumeScale
if isPikaKey(key) then scale = scale * pikaScale end
return BASE_VOLUME * scale
end
-- Fanfares occupy the music's tone channels on the Game Boy: their sfx
-- headers claim channels 5-7 (= hardware channels 1-3), silencing the
@@ -91,7 +115,7 @@ local function playPath(data, key, def, pitch, tempo)
reportBadDef("sfx", key, owner(data, "sfx", key), err)
return nil
end
s:setVolume(BASE_VOLUME * volumeScale)
s:setVolume(volumeFor(key))
cache[key] = s
src = s
end
@@ -111,6 +135,9 @@ local function played(kind, name, species)
Runtime.emit("sound.played", { kind = kind, name = name, species = species })
end
-- returns the started source (nil headless, or when the def failed to load)
-- so callers that block on a fanfare like the original's
-- PlaySoundWaitForCurrent -> WaitForSoundToFinish can poll it
function Sound.play(data, name)
local sfx = data.audio and data.audio.sfx
local def = sfx and sfx[name]
@@ -120,6 +147,7 @@ function Sound.play(data, name)
require("src.core.Music").duckForFanfare(src)
end
played("sfx", name)
return src
end
-- Play a move's sound with its MoveSoundTable pitch/tempo modifiers
@@ -211,7 +239,7 @@ function Sound.playPikaCry(data, n)
cache[key] = false
return nil
end
s:setVolume(BASE_VOLUME * volumeScale)
s:setVolume(volumeFor(key))
cache[key] = s
src = s
end
@@ -245,7 +273,7 @@ function Sound.playCry(data, species)
owner(data, "cries", species), err)
return nil
end
s:setVolume(BASE_VOLUME * volumeScale)
s:setVolume(volumeFor(key))
cache[key] = s
src = s
end
@@ -321,7 +349,7 @@ function Sound.startLoop(data, name)
return
end
s:setLooping(true)
s:setVolume(BASE_VOLUME * volumeScale)
s:setVolume(volumeFor(name))
loopCache[name] = s
src = s
end
@@ -345,14 +373,26 @@ end
-- 0-7 SFX volume level (0 mutes); cached sources (menu beeps, cries,
-- the low-health alarm loop) update immediately so the change is heard
-- on the next play
local function reapplyVolumes()
for key, src in pairs(cache) do
if src then pcall(src.setVolume, src, volumeFor(key)) end
end
for key, src in pairs(loopCache) do
if src then pcall(src.setVolume, src, volumeFor(key)) end
end
end
function Sound.setVolumeLevel(level)
volumeScale = math.max(0, math.min(7, level or 7)) / 7
for _, src in pairs(cache) do
if src then pcall(src.setVolume, src, BASE_VOLUME * volumeScale) end
end
for _, src in pairs(loopCache) do
if src then pcall(src.setVolume, src, BASE_VOLUME * volumeScale) end
end
reapplyVolumes()
end
-- 0-7 Pikachu-voice trim on top of the SFX level (7 = no trim, 0 mutes the
-- clips while the rest of the SFX bus keeps its level). Yellow only: on
-- Red/Blue no cached key answers isPikaKey, so this is inert there.
function Sound.setPikaVolumeLevel(level)
pikaScale = math.max(0, math.min(7, level or 7)) / 7
reapplyVolumes()
end
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
@@ -386,6 +426,7 @@ Assets.register(Sound.invalidate)
-- loading a save)
function Sound.applyOptions(opts)
Sound.setVolumeLevel(opts and opts.sfxVol or 7)
Sound.setPikaVolumeLevel(opts and opts.pikaVol or 7)
end
return Sound
+213 -50
View File
@@ -12,12 +12,18 @@
-- (main.lua then drives it with the mouse); POKEPORT_TOUCH=0 forces it
-- off everywhere.
--
-- Player preferences (options.touchControls) can permanently disable the
-- overlay and/or override per-control positions as normalized window
-- fractions. The launcher editor (src/ui/TouchControlsEditor.lua) writes
-- those; applyOptions reads them at boot and whenever options change.
--
-- Controls press GB buttons through Input:overlayPressed/Released -- their
-- own input source, not a keyboard alias -- so a held overlay direction
-- merges cleanly with a keyboard key or stick holding the same button,
-- and a player rebind can never detach the overlay.
local Input = require("src.core.Input")
local SafeArea = require("src.core.SafeArea")
local TouchControls = {}
@@ -39,6 +45,7 @@ local DPAD_DEAD = 0.16
local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 }
local BUTTONS = { "a", "b", "start", "select" }
local CONTROLS = { "dpad", "a", "b", "start", "select" }
local IMAGES = {
dpad = "assets/touch/dpad.png",
@@ -52,6 +59,12 @@ local IMAGES = {
select = "assets/touch/select.png",
}
local function clamp01(v)
if v < 0 then return 0 end
if v > 1 then return 1 end
return v
end
local function wantsOverlay()
local env = os.getenv("POKEPORT_TOUCH")
if env == "1" then return true end
@@ -60,8 +73,62 @@ local function wantsOverlay()
return osName == "Android" or osName == "iOS"
end
-- Normalize a persisted touchControls table into {enabled, positions}.
-- Unknown / garbage keys are dropped so a bad options.lua cannot brick
-- the overlay.
function TouchControls.normalizeConfig(tc)
local out = { enabled = true, positions = nil }
if type(tc) ~= "table" then return out end
if tc.enabled == false then out.enabled = false end
if type(tc.positions) == "table" then
local pos = {}
for _, name in ipairs(CONTROLS) do
local p = tc.positions[name]
if type(p) == "table" and type(p.x) == "number" and type(p.y) == "number" then
pos[name] = { x = clamp01(p.x), y = clamp01(p.y) }
end
end
if next(pos) then out.positions = pos end
end
return out
end
-- Pure default layout in LOVE units for a usable rect of size ww x wh at
-- origin (ox, oy). Shared by layout() and the editor's Reset path so
-- defaults stay in one place. ox/oy default to 0 for the headless tests
-- and for callers that already pass a full-window size.
function TouchControls.defaultLayout(ww, wh, ox, oy)
ox, oy = ox or 0, oy or 0
local short = math.min(ww, wh)
local dpadW = math.min(180, short * 0.34)
local abW = dpadW * 0.46
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
return {
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW },
start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
select = { cx = ox + ww / 2 - ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
}
end
local function loadImages()
local img = {}
for name, path in pairs(IMAGES) do
local ok, im = pcall(love.graphics.newImage, path)
if not ok then return nil end
im:setFilter("linear", "linear")
img[name] = im
end
return img
end
function TouchControls:init()
self.active = wantsOverlay()
self.enabled = true
self.positions = nil
self.preview = false
self.controllerHidden = false
self.touches = {}
-- per-GB-button owner count: two fingers on A must not double-press it,
@@ -70,51 +137,94 @@ function TouchControls:init()
self.dpadTouch = nil
self.layoutW, self.layoutH = nil, nil
self.img = nil
if not self.active then return end
-- soft-fail: a missing/corrupt PNG must never block boot; the overlay
-- stays off and keyboard/controller play still works
local img = {}
for name, path in pairs(IMAGES) do
local ok, im = pcall(love.graphics.newImage, path)
if not ok then
img = nil
break
end
-- smooth UI icons; the global default filter is nearest for GB pixels
im:setFilter("linear", "linear")
img[name] = im
-- Images load whenever the platform wants the overlay OR the launcher
-- editor forces a preview (desktop testing of the editor).
if self.active then
self.img = loadImages()
end
end
-- Ensure art is loaded for the launcher editor even when wantsOverlay()
-- is false (desktop without POKEPORT_TOUCH).
function TouchControls:ensureImages()
if self.img then return true end
self.img = loadImages()
return self.img ~= nil
end
-- Apply options.touchControls. Called from Game:applyOptions and from
-- the launcher editor after a save.
function TouchControls:applyOptions(opts)
local cfg = TouchControls.normalizeConfig(opts and opts.touchControls)
self.enabled = cfg.enabled
self.positions = cfg.positions
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
if not self.enabled then
self.controllerHidden = false
self:reset()
end
end
function TouchControls:config()
return {
enabled = self.enabled ~= false,
positions = self.positions,
}
end
-- Preview mode: force-draw the overlay for the layout editor, ignoring
-- platform / enabled / gamepad gates. Gameplay input still respects
-- enabled via touchpressed.
function TouchControls:setPreview(on)
self.preview = on and true or false
if on then
self:ensureImages()
self.controllerHidden = false
end
self.img = img
end
function TouchControls:visible()
return self.active and self.img ~= nil and not self.controllerHidden
if self.preview then return self.img ~= nil end
return self.active and self.enabled ~= false and self.img ~= nil
and not self.controllerHidden
end
-- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
local function clampZone(zone, x0, y0, x1, y1)
local half = zone.w * 0.5
zone.cx = math.max(x0 + half, math.min(x1 - half, zone.cx))
zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
end
-- Layout in LOVE units (density-independent on mobile), recomputed when
-- the window size changes (rotation, resize). D-pad bottom-left, B/A
-- bottom-right with A above B (the Game Boy diagonal), START/SELECT
-- flanking the bottom center.
-- the window or safe area changes (rotation, resize, notch insets).
-- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy
-- diagonal), START/SELECT flanking the bottom center -- all inside the
-- device safe area so thumbs clear the home indicator / cutouts.
-- Custom positions (normalized 0..1 within the safe rect) override centers
-- while sizes stay derived from the short edge.
function TouchControls:layout()
local ww, wh = love.graphics.getDimensions()
if self.layoutW == ww and self.layoutH == wh then return self.L end
self.layoutW, self.layoutH = ww, wh
local short = math.min(ww, wh)
-- ~a third of the short edge, capped so tablets don't get a dinner plate
local dpadW = math.min(180, short * 0.34)
local abW = dpadW * 0.46
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
-- START/SELECT hug the bottom center: on a narrow portrait phone the
-- d-pad and B leave little room, so a tight pair is what keeps them off
-- the neighboring controls
self.L = {
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
}
local ox, oy, sw, sh = SafeArea.rect()
if self.layoutW == sw and self.layoutH == sh
and self.layoutOx == ox and self.layoutOy == oy and self.L then
return self.L
end
self.layoutW, self.layoutH = sw, sh
self.layoutOx, self.layoutOy = ox, oy
self.L = TouchControls.defaultLayout(sw, sh, ox, oy)
if self.positions then
for _, name in ipairs(CONTROLS) do
local p = self.positions[name]
local zone = self.L[name]
if p and zone then
zone.cx = ox + p.x * sw
zone.cy = oy + p.y * sh
clampZone(zone, ox, oy, ox + sw, oy + sh)
end
end
end
local ssW = self.L.start.w
local fontSize = math.max(8, math.floor(ssW * 0.26))
if not self.labelFont or self.fontSize ~= fontSize then
self.fontSize = fontSize
@@ -123,12 +233,49 @@ function TouchControls:layout()
return self.L
end
-- Move one control to a screen-space point and persist its normalized
-- position within the safe rect. Used by the layout editor while dragging.
function TouchControls:setControlCenter(name, cx, cy)
local ox, oy, sw, sh = SafeArea.rect()
local L = self:layout()
local zone = L[name]
if not zone then return end
zone.cx, zone.cy = cx, cy
clampZone(zone, ox, oy, ox + sw, oy + sh)
self.positions = self.positions or {}
self.positions[name] = {
x = sw > 0 and (zone.cx - ox) / sw or 0,
y = sh > 0 and (zone.cy - oy) / sh or 0,
}
end
function TouchControls:clearPositions()
self.positions = nil
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
end
local function inCircle(zone, x, y, slop)
local r = zone.w * 0.5 * slop
local dx, dy = x - zone.cx, y - zone.cy
return dx * dx + dy * dy <= r * r
end
-- Which control (if any) contains (x, y). Prefer face buttons over the
-- d-pad when they overlap, matching touchpressed's order.
function TouchControls:hitTest(x, y)
local L = self:layout()
for _, btn in ipairs(BUTTONS) do
if inCircle(L[btn], x, y, SLOP[btn]) then return btn end
end
local dz = L.dpad
local half = dz.w * 0.65
if math.abs(x - dz.cx) <= half and math.abs(y - dz.cy) <= half then
return "dpad"
end
return nil
end
local function dpadDir(zone, x, y)
local dx, dy = x - zone.cx, y - zone.cy
local dead = zone.w * DPAD_DEAD
@@ -165,7 +312,9 @@ local function setDpad(self, touch, dir)
end
function TouchControls:touchpressed(id, x, y)
if not (self.active and self.img) then return end
-- preview mode is layout-edit only: never press GB buttons
if self.preview then return end
if not (self.active and self.enabled ~= false and self.img) then return end
-- a controller hid the overlay; the first touch only brings it back
if self.controllerHidden then
self.controllerHidden = false
@@ -192,6 +341,7 @@ function TouchControls:touchpressed(id, x, y)
end
function TouchControls:touchmoved(id, x, y)
if self.preview then return end
local touch = self.touches[id]
-- only the d-pad tracks movement (slide between directions without
-- lifting); buttons hold until release wherever the finger wanders
@@ -200,6 +350,7 @@ function TouchControls:touchmoved(id, x, y)
end
function TouchControls:touchreleased(id, x, y)
if self.preview then return end
local touch = self.touches[id]
if not touch then return end
self.touches[id] = nil
@@ -216,7 +367,7 @@ end
-- touchreleased and would strand its button held forever. Called from
-- Game alongside Input:reset() on focus/visibility loss.
function TouchControls:reset()
for btn in pairs(self.held) do
for btn in pairs(self.held or {}) do
Input:overlayReleased(btn)
end
self.held = {}
@@ -225,9 +376,13 @@ function TouchControls:reset()
end
-- a gamepad is being used: hide the overlay (dropping anything it held)
-- until the next screen touch asks for it back
-- until the next screen touch asks for it back. No-op when the player
-- permanently disabled the overlay -- there is nothing to hide, and a
-- later accidental touch must not resurrect it.
function TouchControls:noteGamepad()
if not self.active or self.controllerHidden then return end
if not self.active or self.enabled == false or self.controllerHidden then
return
end
self.controllerHidden = true
self:reset()
end
@@ -242,40 +397,46 @@ function TouchControls:joystickremoved()
end
end
local function drawIcon(img, zone, pressed)
love.graphics.setColor(1, 1, 1, pressed and BACK_PRESSED or BACK)
local function drawIcon(img, zone, pressed, alphaMul)
alphaMul = alphaMul or 1
love.graphics.setColor(1, 1, 1, (pressed and BACK_PRESSED or BACK) * alphaMul)
love.graphics.circle("fill", zone.cx, zone.cy, zone.w * 0.58)
local scale = zone.w / img:getWidth()
love.graphics.setColor(1, 1, 1, pressed and ALPHA_PRESSED or ALPHA)
love.graphics.setColor(1, 1, 1, (pressed and ALPHA_PRESSED or ALPHA) * alphaMul)
love.graphics.draw(img, zone.cx - zone.w / 2,
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
end
-- Screen-space, called by Game:draw after Renderer:endFrame so the
-- overlay rides on top of everything (world, UI, CRT/GBC FX included).
-- Also used by the launcher layout editor under preview mode.
function TouchControls:draw()
if not self:visible() then return end
local L = self:layout()
-- when the player disabled the overlay but the editor is previewing,
-- draw dimmed so the layout is still editable
local alphaMul = (self.preview and self.enabled == false) and 0.45 or 1
love.graphics.push("all")
love.graphics.origin()
local dpadTouch = self.dpadTouch and self.touches[self.dpadTouch]
local dir = dpadTouch and dpadTouch.dir
drawIcon(dir and self.img["dpad_" .. dir] or self.img.dpad, L.dpad,
dir ~= nil)
dir ~= nil, alphaMul)
for _, btn in ipairs(BUTTONS) do
drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil)
drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil, alphaMul)
end
-- the +/- glyphs alone don't say which is which; shadowed so the text
-- reads on both the black letterbox and battle's white one
-- reads on both the black letterbox and battle's white one. Each label
-- tracks its own control's cy/w so dragging START cannot move SELECT.
love.graphics.setFont(self.labelFont)
local ly = L.start.cy + L.start.w * 0.66
local function label(text, zone)
local ly = zone.cy + zone.w * 0.66
local w = self.labelFont:getWidth(text)
love.graphics.setColor(0, 0, 0, 0.6)
love.graphics.setColor(0, 0, 0, 0.6 * alphaMul)
love.graphics.print(text, zone.cx - w / 2 + 1, ly + 1)
love.graphics.setColor(1, 1, 1, ALPHA + 0.2)
love.graphics.setColor(1, 1, 1, (ALPHA + 0.2) * alphaMul)
love.graphics.print(text, zone.cx - w / 2, ly)
end
label("START", L.start)
@@ -284,4 +445,6 @@ function TouchControls:draw()
love.graphics.pop()
end
TouchControls.CONTROLS = CONTROLS
return TouchControls
+1 -1
View File
@@ -214,7 +214,7 @@ function VERBS.give(self, rest)
end
elseif game.data.items and game.data.items[id] then
local n = tonumber(count) or 1
if require("src.inventory.Bag").add(save, id, n) then
if require("src.inventory.Bag").add(save, id, n, game.data) then
self:print(("%s x%d added"):format(id, n))
else
self:print("bag full")
+42 -3
View File
@@ -179,6 +179,38 @@ local function resolveUnmount()
return physfsUnmountFn
end
-- PHYSFS_getMountPoint, resolved the same way: a non-NULL return means `dir`
-- is already somewhere in the search path. withMounted needs it because
-- PHYSFS_mount reports success for an already-mounted directory without
-- adding a second entry, so its unmount would drop a mount it did not make
-- (#413).
local physfsMountPointFn = nil
local function resolveMountPoint()
if physfsMountPointFn ~= nil then return physfsMountPointFn end
physfsMountPointFn = false
local ok, ffi = pcall(require, "ffi")
if not ok then return physfsMountPointFn end
pcall(ffi.cdef, "const char *PHYSFS_getMountPoint(const char *dir);")
local libs = {
function() return ffi.C end,
function() return ffi.load("love") end,
}
for _, getlib in ipairs(libs) do
local okl, lib = pcall(getlib)
if okl and lib then
local oks, fn = pcall(function() return lib.PHYSFS_getMountPoint end)
if oks and fn then
physfsMountPointFn = function(d)
local okr, ret = pcall(fn, d)
return okr and ret ~= nil and ret ~= ffi.NULL
end
break
end
end
end
return physfsMountPointFn
end
-- The portable game folder when the cache should live there, else nil.
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
-- desktop portable install (SaveData) and a working windowless mkdir.
@@ -398,13 +430,20 @@ end
-- resolves -- which is what makes it safe to point at a folder whose contents
-- nobody has validated.
--
-- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, or the
-- mount was refused), which callers must treat as "could not look", not as
-- "nothing there". An error inside fn still unmounts before it propagates.
-- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, the
-- mount was refused, or `dir` is already on the read path), which callers must
-- treat as "could not look", not as "nothing there". An error inside fn still
-- unmounts before it propagates.
function CacheFs.withMounted(dir, mountPoint, fn)
if not dir or dir == "" then return nil end
local mount, unmount = resolveMount(), resolveUnmount()
if not (mount and unmount) then return nil end
-- A directory already in the search path cannot be borrowed: PHYSFS_mount
-- returns success without adding an entry, and the unmount below would then
-- remove the mount somebody else is relying on -- for the portable game
-- folder, the one that makes its cache and mods readable at all (#413)
local mountedAt = resolveMountPoint()
if mountedAt and mountedAt(dir) then return nil end
if not mount(dir, mountPoint, true) then return nil end
local ok, res = pcall(fn)
unmount(dir)
+46
View File
@@ -1179,6 +1179,13 @@ function RomExtractor:extractPokemon()
completed = completed + 1
self:tick("Pokemon", completed, #self.manifest.dexOrder + 10)
end
-- Yellow-only third back pic: LoadPlayerBackPic (engine/battle/core.asm)
-- picks OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for
-- BATTLE_TYPE_PIKACHU, the Pallet Town catch scene (#557). Outside the
-- ticked loop so the progress denominator stays as it was.
if self.symbols["ProfOakPicBack"] then
self:writeCompressedPic("ProfOakPicBack", "battle/profoakb.png")
end
local balls = self:symbol("PokeballTileGraphics")
self:write2bpp(self.rom:bytes(balls.bank, balls.address, 64),
32, 8, "battle/balls.png", true)
@@ -1307,6 +1314,16 @@ function RomExtractor:extractTrainers()
}
self:tick("Trainers", index, #order)
end
-- Yellow's Jessie & James fight as OPP_ROCKET but carry their own pic
-- (home/trainers2.asm IsFightingJessieJames); only Yellow's manifest has
-- the symbol, so Red and Blue skip this (#439).
if self.symbols.JessieJamesPic then
local relative = "battle/trainers/jessie_james.png"
self:writeCompressedPic("JessieJamesPic", relative)
if out.OPP_ROCKET then
out.OPP_ROCKET.picJessieJames = "assets/generated/" .. relative
end
end
self:write("trainers", out)
return out
end
@@ -1816,6 +1833,35 @@ function RomExtractor:extractField()
end
end
-- Yellow-only: TalkToPikachu's framed portrait, one 5x5 base frame per
-- PikaPicAnimScript -- each script's FIRST pikapic_loadgfx in
-- data/pikachu/pikachu_pic_animation.asm, the pic PikaPicAnimBGFrames_4
-- (PikaAnimTilemap_1, column order) paints. Scripts 18/22/23/24 have no
-- compressed base and take PikaPicAnimBGFrames_5 -> PikaAnimTilemap_9,
-- which is ROW order over a raw 25-tile sheet, hence no columns flag.
-- Script 26 shares script 11's base. The pikaframe overlays each script
-- draws ON TOP of the base are a second full pose out of the same blob and
-- stay unripped: PikachuFollower.picLift stands in for their motion
-- (#561, still on #407's stand-in).
local PIKAPIC_BASE = {
"Pic_e4000", "Pic_e411c", "Pic_e4272", "Pic_e4383", "Pic_e458b",
"Pic_e467b", "Pic_e476e", "Pic_e49d1", "Pic_e4b39", "Pic_e4c3e",
"Pic_e5000", "Pic_e523f", "Pic_e548e", "Pic_e56d1", "Pic_e5924",
"Pic_e5b7d", "Pic_e5ddd", "GFX_e6020", "Pic_e6340", "Pic_e6587",
"Pic_e67d6", "GFX_e6e6f", "GFX_e718f", "GFX_e74af", "Pic_e77cf",
"Pic_e5000", "Pic_f0abf", "Pic_f0cf4",
}
if self.symbols[PIKAPIC_BASE[1]] then
for script, label in ipairs(PIKAPIC_BASE) do
local path = "pikachu/pikapic_" .. script .. ".png"
if label:sub(1, 4) == "GFX_" then
self:raw2bpp(label, 40, 40, path, { matte = true })
else
self:writeCompressedPic(label, path)
end
end
end
self:raw1bpp("LedgeHoppingShadow", 8, 8,
"fx/shadow.png", true); tick()
for _, spec in ipairs({
+2107 -160
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -75,7 +75,10 @@ function SaveFileIO.importToSlot(source, version)
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
:format(SAVE_SIZE, #bytes)
end
local save, convertErr = SaveConvert.importSav(bytes, version)
-- 3rd arg: the crosswalk has to come from THIS game's ROM cache. The
-- launcher imports before the cache is mounted on the un-prefixed paths, so
-- SaveConvert cannot find the generated tables by itself here (#420).
local save, convertErr = SaveConvert.importSav(bytes, version, version)
if not save then return false, convertErr end
-- Tag the game version and normalize the meta stamp: SaveConvert leaves
-- meta.format = "gen1_import", but SaveData.load's migration pass compares
@@ -103,7 +106,7 @@ function SaveFileIO.exportActiveSlot(version)
version = version or GameVersion.get()
local save = SaveData.load(version)
if not save then return false, "this game has no save to export yet" end
local bytes, exportErr = SaveConvert.exportSav(save)
local bytes, exportErr = SaveConvert.exportSav(save, version)
if not bytes then return false, exportErr end
local slotId = SaveData.activeSlot(version) or "save"
local fs = love and love.filesystem
+23 -7
View File
@@ -1,11 +1,26 @@
-- The 20-slot bag (BAG_ITEM_CAPACITY, constants/menu_constants.asm):
-- a distinct item id occupies one slot regardless of quantity; badges
-- live in the inventory table but are not bag items. save.bagOrder
-- keeps acquisition order like wBagItems (SELECT can reorder it).
-- The bag defaults to 20 slots (BAG_ITEM_CAPACITY,
-- constants/menu_constants.asm), but mods may replace that limit through
-- Data.constants.bagSize. A distinct item id occupies one slot regardless
-- of quantity; badges live in the inventory table but are not bag items.
-- save.bagOrder keeps acquisition order like wBagItems (SELECT can reorder
-- it).
local Bag = {}
Bag.CAPACITY = 20
local DEFAULT_CAPACITY = 20
-- `data` is injectable for the save editor and headless mod tests. Normal
-- gameplay may omit it because the loader merges mods into the Data
-- singleton before any item can be added. The fallback keeps old/stale
-- generated caches and isolated callers at the vanilla limit.
function Bag.capacity(data)
data = data or require("src.core.Data")
local configured = data and data.constants and data.constants.bagSize
if type(configured) == "number" and configured >= 1 then
return math.floor(configured)
end
return DEFAULT_CAPACITY
end
local function isBadge(id)
return id:find("BADGE", 1, true) ~= nil
@@ -55,9 +70,10 @@ end
-- Add qty of an item; returns false (and adds nothing) when a new slot
-- is needed and the bag is full, or when the stack would pass 99
-- (AddItemToInventory's per-slot quantity cap).
function Bag.add(save, id, qty)
function Bag.add(save, id, qty, data)
local inv = save.inventory
if not inv[id] and not isBadge(id) and Bag.slots(save) >= Bag.CAPACITY then
if not inv[id] and not isBadge(id)
and Bag.slots(save) >= Bag.capacity(data) then
return false
end
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
+7 -1
View File
@@ -10,7 +10,6 @@
-- "ball" caller must throw it (battle only)
-- "learn", moveId caller must run the learn-move flow
local Pokemon = require("src.pokemon.Pokemon")
local Flags = require("src.script.Flags")
local Strings = require("src.core.Strings")
@@ -433,6 +432,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if battle then
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
-- FishingInit (engine/items/item_effects.asm): cp wWalkBikeSurfState, 2
-- (surfing) sets carry, and every ItemUseXRod does jp c, ItemUseNotTime
-- on that carry -- surfing refuses the rod with the same OAK text as
-- the mid-battle case above, no rod-specific message (#533)
if ow and ow.player and ow.player.surfing then
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
return "fish", itemId
end
+8 -1
View File
@@ -128,7 +128,14 @@ end
-- ------- the link surface
local SPECIES_FIELDS = { "baseStats", "types", "catchRate", "baseExp",
-- catchRate stays out (#511): no link mode ever reads it -- a ball thrown
-- in a link battle is a trainer-battle throw and always refused (pokered
-- engine/items/item_effects.asm ItemUseBall), and the trade rebuild never
-- touches it. Hashing it split Red/Blue from Yellow, whose only link
-- surface delta is the Dragonair/Dragonite catch-rate bytes
-- (data/pokemon/base_stats/dragonair.asm db 45 vs 27, dragonite.asm 45
-- vs 9), when the real cable links R/B/Y freely.
local SPECIES_FIELDS = { "baseStats", "types", "baseExp",
"growthRate", "evolutions" }
local MOVE_FIELDS = { "power", "type", "accuracy", "pp", "effect", "category",
"priority", "highCrit", "fixedDamage", "multiHit",
+9 -12
View File
@@ -178,19 +178,16 @@ end
-- ------- subset negotiation
-- the species and moves this party actually references, so the exchange
-- stays small (six mons) instead of shipping the whole catalog
-- every record hash, not just this party's slice: the receiver filters
-- its OWN party against these (eligibleParty), so a message limited to the
-- sender's party read "not on the other game" for every species the two
-- parties did not happen to share, and a subset trade between different
-- games showed neither side (#511). The full catalog is ~300 short hash
-- strings -- still one message.
function Protocol.recordsMessage(data, party)
local species = Fingerprint.records(data, "pokemon")
local moves = Fingerprint.records(data, "moves")
local outSpecies, outMoves = {}, {}
for _, mon in ipairs(party or {}) do
if species[mon.species] then outSpecies[mon.species] = species[mon.species] end
for _, mv in ipairs(mon.moves or {}) do
if moves[mv.id] then outMoves[mv.id] = moves[mv.id] end
end
end
return { type = "records", pokemon = outSpecies, moves = outMoves }
return { type = "records",
pokemon = Fingerprint.records(data, "pokemon"),
moves = Fingerprint.records(data, "moves") }
end
-- a mon may cross the wire only if both peers rebuild it identically: the
+10 -3
View File
@@ -384,9 +384,16 @@ function Tournament:update(dt)
else
self.pendingBattleOpts.seed = msg.seed
end
local battle, why = self.isHost
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
-- Split rather than `cond and newHost() or newGuest()`: the and/or
-- idiom truncates a call to its first result, so the second return
-- (the specific reason) was always dropped and every failure showed
-- the generic fallback instead of "same mods on both games" etc.
local battle, why
if self.isHost then
battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
else
battle, why = LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
end
if not battle then
self:exitWith(why or Strings("Link battle\ncan't start."))
return
+124 -16
View File
@@ -95,8 +95,15 @@ function LauncherMods.deriveList(manifests, options)
local byId, enabledSet = {}, {}
for _, m in ipairs(ordered) do
byId[m.id] = m
-- missing entry means enabled, matching the loader's default
if mods[m.id] ~= false then enabledSet[m.id] = true end
-- missing entry means enabled, matching the loader -- except experimental
-- mods, which stay off until the player opts in
if mods[m.id] == false then
-- stay off
elseif mods[m.id] == true then
enabledSet[m.id] = true
elseif not m.experimental then
enabledSet[m.id] = true
end
end
local out = {}
@@ -104,16 +111,19 @@ function LauncherMods.deriveList(manifests, options)
local enabled = enabledSet[m.id] == true
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
local raw = m.raw or {}
local badge = tostring(raw.category or m.profile or "MOD"):upper()
if m.experimental then badge = "EXPERIMENTAL" end
out[#out + 1] = {
id = m.id,
name = m.name or m.id,
version = m.version,
-- category, then profile, then a generic fallback -- uppercased
badge = tostring(raw.category or m.profile or "MOD"):upper(),
badge = badge,
description = m.description or "",
enabled = enabled,
status = status,
statusDetail = detail,
github = m.github,
experimental = m.experimental == true,
}
end
return out
@@ -168,6 +178,21 @@ function LauncherMods.pickStrays(found, installed)
return out
end
-- isReadableRoot(folder, source, cacheRoot) -> is folder already on the
-- physfs read path, pure. source is love.filesystem.getSource(), cacheRoot
-- the mounted portable game folder (CacheFs.root()); a fused build has both,
-- and they are different paths (the archive inside the executable vs the
-- folder beside it). Either one's mods/ is readable already, so nothing in
-- it is a stray -- and the portable folder must additionally never be handed
-- to CacheFs.withMounted: PHYSFS_mount reports success for a directory
-- already in the search path WITHOUT adding a second entry, so the paired
-- unmount tears down the one real mount and the panel loses every mod in the
-- game folder (#413).
function LauncherMods.isReadableRoot(folder, source, cacheRoot)
if not folder or folder == "" then return false end
return folder == source or folder == cacheRoot
end
-- ------- discovery (love.filesystem)
local function decodeManifest(raw, path)
@@ -198,7 +223,9 @@ local function discover()
for _, name in ipairs(fs.getDirectoryItems("mods")) do
local path = "mods/" .. name
local info = fs.getInfo(path)
if info and info.type == "directory" then
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
-- setSymlinksEnabled(true); see the matching note in Loader:_discover.
if info and (info.type == "directory" or info.type == "symlink") then
local raw = fs.read(path .. "/manifest.json")
if raw then
local manifest = decodeManifest(raw, path)
@@ -216,8 +243,15 @@ end
-- options.mods enable-state the loader persists, so a toggle here is what the
-- game sees on its next boot.
function LauncherMods.list()
local options = SaveData.loadOptions()
return LauncherMods.deriveList(discover(), options)
local ok, result = pcall(function()
local options = SaveData.loadOptions()
return LauncherMods.deriveList(discover(), options)
end)
if not ok then
-- a single bad options/mod file must not blank the launcher
return {}
end
return result or {}
end
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
@@ -349,17 +383,20 @@ end
local STRAY_MOUNT = "stray_scan"
-- Run fn(mountedModsRoot) for each game folder that has a readable mods/
-- directory, one mount at a time. Folders that are already the physfs source
-- are skipped: their mods/ is discoverable by definition, so anything there is
-- installed already and not a stray (this is every `love <gamedir>` dev run).
-- directory, one mount at a time. Folders already on the read path are
-- skipped (isReadableRoot): the physfs source, which is every `love <gamedir>`
-- dev run, and the portable game folder CacheFs mounted, where re-mounting is
-- what used to drop the mount (#413).
local function eachStrayRoot(fn)
local SaveData_ = require("src.core.SaveData")
local fs = love and love.filesystem
if not fs then return end
local source = fs.getSource and fs.getSource()
local cacheRoot = CacheFs.root()
local seen = {}
for _, folder in ipairs(SaveData_.gameFolders() or {}) do
if not seen[folder] and folder ~= source then
if not seen[folder]
and not LauncherMods.isReadableRoot(folder, source, cacheRoot) then
seen[folder] = true
CacheFs.withMounted(folder, STRAY_MOUNT, function()
local root = STRAY_MOUNT .. "/mods"
@@ -436,13 +473,22 @@ function LauncherMods.strays() return scanStrays(false) end
-- on the player's behalf is not this function's call to make.
function LauncherMods.adoptStrays() return scanStrays(true) end
-- installZip(source) -> true, id | nil, errString
-- installZip(source [, opts]) -> true, id | nil, errString
-- source is an external path or a love DroppedFile. The archive is validated
-- BEFORE anything is copied; every path unmounts and clears the staged temp
-- file, and a failed copy rolls its partial tree back. A dropped file outside
-- the save dir is staged into a save-dir temp first, because
-- love.filesystem.mount only reaches a save-directory-relative path.
function LauncherMods.installZip(source)
-- opts.replace = true uninstalls an existing same-id mod first (updates /
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
function LauncherMods.installZip(source, opts)
local ok, result, err = pcall(LauncherMods._installZipInner, source, opts)
if not ok then return nil, "import failed: " .. tostring(result) end
return result, err
end
function LauncherMods._installZipInner(source, opts)
opts = opts or {}
if not (love and love.filesystem) then
return nil, "mod install needs LOVE"
end
@@ -483,12 +529,24 @@ function LauncherMods.installZip(source)
cleanup()
return nil, "invalid mod manifest: " .. tostring(manifestErr)
end
if opts.expectId and manifest.id ~= opts.expectId then
cleanup()
return nil, ("zip is for '%s', expected '%s'")
:format(manifest.id, opts.expectId)
end
-- reject a duplicate before touching the mods tree
local dest = "mods/" .. manifest.id
if fs.getInfo(dest) then
cleanup()
return nil, "a mod named '" .. manifest.id .. "' is already installed"
if not opts.replace then
cleanup()
return nil, "a mod named '" .. manifest.id .. "' is already installed"
end
-- drop the old tree before copy; enable-flag is preserved (uninstall
-- would clear it, which would surprise an update)
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
removeTree(dest)
CacheFs.prefix = savedPrefix
end
-- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...);
@@ -511,6 +569,56 @@ function LauncherMods.installZip(source)
return true, manifest.id
end
-- Install (or replace) a mod from a GitHub release zip URL.
-- Returns true, version | nil, errString. Soft-fails: download / install /
-- cleanup errors never throw into the launcher UI.
function LauncherMods.installFromRelease(modId, release)
local ok, result, err = pcall(function()
if type(modId) ~= "string" or modId == "" then
return nil, "missing mod id"
end
if type(release) ~= "table" or not release.zip or not release.zip.url then
return nil, "release has no downloadable .zip"
end
local ModUpdate = require("src.mods.ModUpdate")
local tmpName = ("mod_update_%s_%s.zip"):format(
tostring(modId), tostring(release.version or os.time()))
local localPath, dlErr = ModUpdate.downloadZip(release.zip.url, tmpName)
if not localPath then return nil, dlErr end
local installed, res = LauncherMods.installZip(localPath, {
replace = true, expectId = modId,
})
pcall(love.filesystem.remove, localPath)
if not installed then return nil, res end
return true, release.version or res
end)
if not ok then return nil, "install failed: " .. tostring(result) end
return result, err
end
-- Install a mod listed in a community index (src/mods/ModIndex.lua).
-- The index only ever tells us WHERE the zip is; resolving that URL is
-- ModIndex's job and installing it is installFromRelease's, so this is the
-- seam between them and nothing about the archive is special-cased. expectId
-- comes from the listing, so a feed that points an entry at somebody else's
-- zip fails the manifest check instead of installing the wrong mod.
-- Returns true, version | nil, errString.
function LauncherMods.installFromIndex(entry)
local ok, result, err = pcall(function()
if type(entry) ~= "table" or type(entry.id) ~= "string" then
return nil, "index entry has no mod id"
end
local ModIndex = require("src.mods.ModIndex")
local release, why = ModIndex.releaseFor(entry)
if not release then
return nil, why or "this mod cannot be installed from the index"
end
return LauncherMods.installFromRelease(entry.id, release)
end)
if not ok then return nil, "install failed: " .. tostring(result) end
return result, err
end
-- uninstall(id) -> true | nil, errString
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
+17 -1
View File
@@ -206,7 +206,11 @@ function Loader:_discover()
for _, name in ipairs(self.fs.getDirectoryItems(root)) do
local path = root .. "/" .. name
local info = self.fs.getInfo(path)
if info and info.type == "directory" then
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
-- setSymlinksEnabled(true) -- PhysFS never resolves the symlink's
-- own getInfo, only traversal into it. readManifest below still
-- correctly no-ops on a symlink that isn't a directory.
if info and (info.type == "directory" or info.type == "symlink") then
local manifest, err = readManifest(self.fs, path)
if manifest then
if self.mods[manifest.id] then
@@ -834,6 +838,18 @@ function Loader:load(data)
require("src.mods.Builtins").install(self.content, data)
self:_loadState()
self:_discover()
-- Experimental mods stay off until the player opts in: a missing
-- options.mods entry normally means enabled, but experimental flips that.
do
local options = SaveData.loadOptions(self.fs)
local modsOpt = options.mods or {}
for id, mod in pairs(self.mods) do
if not self.disabled[id] and modsOpt[id] == nil
and mod.manifest.experimental then
self.disabled[id] = true
end
end
end
for id, mod in pairs(self.mods) do
mod.enabled = not self.disabled[id]
mod.state = mod.enabled and "pending" or "disabled"
+25 -6
View File
@@ -324,6 +324,12 @@ function ManagerState:detailRows(m)
rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
action = function() self:goTo("permissions") end }
end
if m.github then
rows[#rows + 1] = { inert = true, label = "GH " .. m.github }
end
if m.experimental then
rows[#rows + 1] = { inert = true, label = "EXPERIMENTAL" }
end
if m.error then
rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
action = function() self:goTo("errors") end }
@@ -607,13 +613,26 @@ function ManagerState:beginToggle(m)
r = ManagerState.resolveToggle(self:manifestMap(), m.id, want,
self:enabledSet())
end
if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then
self:openBlocked(r)
elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then
self:openCascade(r, m, want)
else
self:commitToggle(r.apply)
local function proceed()
if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then
self:openBlocked(r)
elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then
self:openCascade(r, m, want)
else
self:commitToggle(r.apply)
end
end
-- Experimental mods ask once on enable; disable is silent.
if want and m.experimental then
self:openConfirm({
"EXPERIMENTAL MOD",
"THIS MOD IS MARKED",
"EXPERIMENTAL.",
"ENABLE ANYWAY?",
}, proceed)
return
end
proceed()
end
function ManagerState:commitToggle(apply)
+99 -2
View File
@@ -50,8 +50,94 @@ local function parseSpecs(list, field)
return specs
end
-- Optional GitHub repo for launcher auto-update / other-versions.
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
function Manifest.parseGithub(value)
if value == nil or value == "" then return nil end
assert(type(value) == "string", "github must be a string")
local trimmed = value:match("^%s*(.-)%s*$") or value
if trimmed == "" then return nil end
local owner, repo = trimmed:match(
"^https?://github%.com/([%w%._%-]+)/([%w%._%-]+)/?$")
if not owner then
owner, repo = trimmed:match(
"^https?://github%.com/([%w%._%-]+)/([%w%._%-]+)%.git/?$")
end
if not owner then
owner, repo = trimmed:match("^([%w%._%-]+)/([%w%._%-]+)$")
end
assert(owner and repo and owner ~= "" and repo ~= "",
"github must be owner/repo or a github.com URL")
repo = repo:gsub("%.git$", "")
return owner .. "/" .. repo
end
-- conflicts + incompatible (alias) merged, first-wins on duplicate ids
local function mergeConflictLists(conflicts, incompatible)
local seen, out = {}, {}
for _, list in ipairs({ array(conflicts), array(incompatible) }) do
for _, entry in ipairs(list) do
if not seen[entry] then
seen[entry] = true
out[#out + 1] = entry
end
end
end
return out
end
-- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs,
-- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises
-- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a
-- panel may draw must be scrubbed here -- the one place every mod manifest
-- passes through -- or a single mangled description crashes the whole MODS
-- panel instead of misrendering one card.
local function scrubUtf8(s)
if type(s) ~= "string" then return s end
s = s:gsub("^\239\187\191", "")
local out, i, n = {}, 1, #s
while i <= n do
local b = s:byte(i)
local len
if b < 0x80 then len = 1
elseif b >= 0xC2 and b <= 0xDF then len = 2
elseif b >= 0xE0 and b <= 0xEF then len = 3
elseif b >= 0xF0 and b <= 0xF4 then len = 4
end
local ok = len ~= nil and i + len - 1 <= n
if ok and len > 1 then
for j = i + 1, i + len - 1 do
local c = s:byte(j)
if c < 0x80 or c > 0xBF then ok = false; break end
end
if ok then
-- boundary lead bytes narrow their second byte: no overlongs
-- (E0/F0), no surrogates (ED), nothing past U+10FFFF (F4)
local b2 = s:byte(i + 1)
if (b == 0xE0 and b2 < 0xA0) or (b == 0xED and b2 > 0x9F)
or (b == 0xF0 and b2 < 0x90) or (b == 0xF4 and b2 > 0x8F) then
ok = false
end
end
end
if ok then
out[#out + 1] = s:sub(i, i + len - 1)
i = i + len
else
i = i + 1
end
end
return table.concat(out)
end
function Manifest.validate(raw, path)
assert(type(raw) == "table", "manifest must be an object")
-- scrubbed in place so every later reader agrees, including the launcher's
-- badge derivation, which reads raw.category rather than the validated copy
raw.name = scrubUtf8(raw.name)
raw.version = scrubUtf8(raw.version)
raw.description = scrubUtf8(raw.description)
raw.category = scrubUtf8(raw.category)
assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"),
"manifest id must contain only letters, numbers, _ or -")
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
@@ -86,6 +172,12 @@ function Manifest.validate(raw, path)
assert(gameVersionOk, ("malformed game_version %q: %s")
:format(tostring(raw.game_version), tostring(gameVersionErr)))
local github = Manifest.parseGithub(raw.github)
assert(raw.experimental == nil or type(raw.experimental) == "boolean",
"experimental must be a boolean")
local experimental = raw.experimental == true
-- overhauls and total conversions are assumed to move the link
-- fingerprint unless the manifest says otherwise; content packs are not
local affectsLink = profile ~= "content"
@@ -97,6 +189,8 @@ function Manifest.validate(raw, path)
return value
end
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
return {
id = raw.id,
name = raw.name,
@@ -106,13 +200,16 @@ function Manifest.validate(raw, path)
priority = tonumber(raw.priority) or 0,
dependencies = array(raw.dependencies),
optional_dependencies = array(raw.optional_dependencies),
conflicts = array(raw.conflicts),
conflicts = conflicts,
incompatible = array(raw.incompatible),
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
conflictSpecs = parseSpecs(array(raw.conflicts), "conflicts"),
conflictSpecs = parseSpecs(conflicts, "conflicts"),
category = raw.category or "OTHER",
game_version = raw.game_version,
description = raw.description or "",
github = github,
experimental = experimental,
profile = profile,
affects_link = affectsLink,
permissions = permissions,
+623
View File
@@ -0,0 +1,623 @@
-- Community mod index consumer (the "Find mods" launcher tab).
--
-- An index is metadata only: a published index.json feed listing mods that
-- live in their authors' own repos. Nothing here clones or vendors the index
-- repo -- the feed is the whole contract, and every install still goes through
-- the same LauncherMods.installZip path an "Import mod .zip" does, so a listing
-- buys a mod no trust it would not otherwise have.
--
-- Shape of the split mirrors src/mods/ModUpdate.lua, which this borrows its
-- host I/O from: everything above "host I/O" is pure (no love, no filesystem,
-- no network) so the engine tier can table-drive it, and the fetch/cache half
-- reaches for curl and options.lua.
--
-- Sources are never added automatically. options.modIndexes is a player-built
-- list -- adding an index is a deliberate act of trusting whoever publishes it,
-- so the launcher ships with none and asks.
--
-- schema_version is a hard gate, not a hint: a bumped feed may reuse a field
-- name for something else, so an unknown version is refused outright rather
-- than parsed hopefully.
local ModIndex = {}
-- The feed is rebuilt on every push and refreshed nightly, so a day-old copy
-- is the worst a cached listing can be. ModUpdate's six hours is tuned for a
-- single repo's releases; a whole index is heavier and changes more slowly.
ModIndex.CACHE_TTL = 24 * 60 * 60
ModIndex.SCHEMA_VERSION = 1
-- ------- pure: source resolution
local function trim(s)
return (tostring(s):gsub("^%s+", ""):gsub("%s+$", ""))
end
-- Split "owner/repo" out of the several ways a player can name a GitHub repo.
local function githubSlug(url)
local owner, repo = url:match("^https?://github%.com/([%w%-%.]+)/([%w%-%.]+)")
if not owner then
owner, repo = url:match("^([%w%-%.]+)/([%w%-%.]+)$")
end
if not owner then return nil end
repo = repo:gsub("%.git$", "")
return owner, repo
end
-- resolveSource(input) -> { feed, base, fallback, label } | nil, err
--
-- Players paste whichever URL they happened to have, so all four shapes of the
-- same index resolve to one source: the Pages root, the feed file itself, the
-- GitHub repo page, or a bare "owner/repo". `base` is what relative thumbnail
-- and description_url paths resolve against, and it always keeps its trailing
-- slash so joinUrl can stay a concatenation.
--
-- The raw.githubusercontent fallback exists because Pages deploys lag a push
-- by a minute or two; it is only ever consulted when the feed fetch fails.
function ModIndex.resolveSource(input)
if type(input) ~= "string" then return nil, "missing index URL" end
local url = trim(input)
if url == "" then return nil, "missing index URL" end
local owner, repo = githubSlug(url)
if owner then
return {
feed = ("https://%s.github.io/%s/data/index.json"):format(owner, repo),
base = ("https://%s.github.io/%s/"):format(owner, repo),
fallback = ("https://raw.githubusercontent.com/%s/%s/main/site/data/index.json")
:format(owner, repo),
label = owner .. "/" .. repo,
}
end
if not url:match("^https?://") then
return nil, "index must be an http(s) URL or owner/repo"
end
-- A feed URL names the file; the Pages root is what is left once the
-- "data/index.json" tail comes off (any other .json keeps only its folder).
if url:match("%.json$") then
local base = url:match("^(.*/)data/index%.json$") or url:match("^(.*/)")
return { feed = url, base = base or url, label = ModIndex.labelFor(base or url) }
end
local base = url:match("/$") and url or (url .. "/")
return {
feed = base .. "data/index.json",
base = base,
label = ModIndex.labelFor(base),
}
end
-- A short human name for a source row: "owner/repo" for a Pages host, else the
-- host plus first path segment. Only ever cosmetic.
function ModIndex.labelFor(url)
url = tostring(url or "")
local owner, repo = url:match("^https?://([%w%-%.]+)%.github%.io/([%w%-%.]+)/")
if owner then return owner .. "/" .. repo end
local host, first = url:match("^https?://([^/]+)/([^/]*)")
if host and first and first ~= "" then return host .. "/" .. first end
return host or url
end
-- joinUrl(base, rel) -> absolute URL | nil
-- Relative feed paths resolve against the Pages root; an already-absolute one
-- is handed back untouched (the schema allows either), and anything else --
-- nil, "", a non-string -- is simply absent rather than an error, because a
-- missing thumbnail is not a broken index.
function ModIndex.joinUrl(base, rel)
if type(rel) ~= "string" or rel == "" then return nil end
if rel:match("^https?://") then return rel end
if type(base) ~= "string" or base == "" then return nil end
if not base:match("/$") then base = base .. "/" end
return base .. (rel:gsub("^/", ""))
end
-- ------- pure: feed parsing
local function str(v)
return type(v) == "string" and v or nil
end
local function strArray(v)
local out = {}
if type(v) == "table" then
for _, entry in ipairs(v) do
if type(entry) == "string" then out[#out + 1] = entry end
end
end
return out
end
-- Decode one release blob from the feed's `latest` field into the same shape
-- ModUpdate.parseRelease produces, so LauncherMods.installFromRelease takes it
-- without a translation layer.
local function parseLatest(raw)
if type(raw) ~= "table" then return nil end
local zip = nil
if type(raw.zip) == "table" and str(raw.zip.url) then
zip = { name = str(raw.zip.name), url = raw.zip.url,
size = tonumber(raw.zip.size) }
end
return {
version = str(raw.version),
tag = str(raw.tag),
name = str(raw.name),
prerelease = raw.prerelease == true,
published_at = str(raw.published_at),
zip = zip,
}
end
local function parseEntry(raw)
if type(raw) ~= "table" or not str(raw.id) then return nil end
return {
folder = str(raw.folder),
id = raw.id,
title = str(raw.title) or raw.id,
author = str(raw.author),
version = str(raw.version),
summary = str(raw.summary) or "",
categories = strArray(raw.categories),
tags = strArray(raw.tags),
license = str(raw.license),
repo = str(raw.repo),
github = str(raw.github),
downloadURL = str(raw.downloadURL),
api = tonumber(raw.api),
game_version = str(raw.game_version),
profile = str(raw.profile),
affects_link = raw.affects_link == true,
experimental = raw.experimental == true,
permissions = strArray(raw.permissions),
dependencies = raw.dependencies,
conflicts = raw.conflicts,
thumbnail = str(raw.thumbnail),
description_url = str(raw.description_url),
latest = parseLatest(raw.latest),
update_check = str(raw.update_check) or "pending",
}
end
-- parse(jsonText [, Json]) -> { schemaVersion, generatedAt, categories, mods }
-- | nil, err
-- Never throws: a truncated download, an HTML error page, or a feed from a
-- future schema all come back as a message the panel can print.
function ModIndex.parse(jsonText, Json)
local ok, result, err = pcall(function()
Json = Json or require("src.link.Json")
local doc, decodeErr = Json.decode(jsonText)
if type(doc) ~= "table" then
return nil, decodeErr or "index.json is not an object"
end
local schema = tonumber(doc.schema_version)
if schema == nil then
return nil, "index.json has no schema_version"
end
if schema ~= ModIndex.SCHEMA_VERSION then
return nil, ("index schema %d is not supported (this build reads %d)")
:format(schema, ModIndex.SCHEMA_VERSION)
end
if type(doc.mods) ~= "table" then
return nil, "index.json has no mods array"
end
local mods = {}
for _, raw in ipairs(doc.mods) do
local entry = parseEntry(raw)
if entry then mods[#mods + 1] = entry end
end
return {
schemaVersion = schema,
generatedAt = str(doc.generated_at),
categories = strArray(doc.categories),
mods = mods,
}
end)
if not ok then return nil, "could not read the index: " .. tostring(result) end
return result, err
end
-- ------- pure: install resolution
-- installUrl(entry) -> url, kind | nil, reason
--
-- The same order the engine's zip import already implies: a verified release
-- asset first, then the author's fixed downloadURL. A GitHub source-archive
-- URL is never invented -- codeload gives you the repo, not the built mod, and
-- the folder layout would be wrong even when the download succeeds.
function ModIndex.installUrl(entry)
if type(entry) ~= "table" then return nil, "no entry" end
if entry.update_check == "ok" and entry.latest and entry.latest.zip
and entry.latest.zip.url then
return entry.latest.zip.url, "release"
end
if entry.downloadURL and entry.downloadURL ~= "" then
return entry.downloadURL, "download"
end
if entry.update_check == "off" then
return nil, "the author does not publish installable releases"
end
if entry.update_check == "no installable release" then
return nil, "no release with a .zip asset yet"
end
if type(entry.update_check) == "string"
and entry.update_check:match("^error") then
return nil, entry.update_check
end
return nil, "nothing installable listed"
end
function ModIndex.canInstall(entry)
return ModIndex.installUrl(entry) ~= nil
end
-- The version to show on a card: the release the index resolved when it could
-- reach GitHub, else whatever meta.json declared.
function ModIndex.displayVersion(entry)
if type(entry) ~= "table" then return "?" end
if entry.update_check == "ok" and entry.latest and entry.latest.version then
return entry.latest.version
end
return entry.version or "?"
end
-- The release table LauncherMods.installFromRelease wants. A downloadURL
-- entry has no release behind it, so one is synthesised around the URL; the
-- installer still validates the manifest inside and still refuses a zip whose
-- id is not the one being installed.
function ModIndex.releaseFor(entry)
local url, kind = ModIndex.installUrl(entry)
if not url then return nil, kind end
if kind == "release" then return entry.latest end
return {
version = ModIndex.displayVersion(entry),
zip = { url = url, name = entry.id .. ".zip" },
}
end
-- ------- pure: compatibility
-- compatIssues(entry, ctx) -> array of { level, text }
--
-- Soft gate by design: an index entry is metadata an author wrote, possibly
-- months ago, and hiding a mod because a range looks wrong is how a working
-- mod becomes invisible. Everything here warns; the confirm dialog shows the
-- list and the player decides. ctx carries { modApi, engineVersion,
-- installed = { id -> version }, enabled = { id -> true } }.
function ModIndex.compatIssues(entry, ctx)
local out = {}
if type(entry) ~= "table" then return out end
ctx = ctx or {}
local function warn(text) out[#out + 1] = { level = "warn", text = text } end
local modApi = tonumber(ctx.modApi)
if entry.api and modApi and entry.api > modApi then
warn(("Needs mod API %d; this build provides %d")
:format(entry.api, modApi))
end
if entry.game_version and ctx.engineVersion then
local okSemver, Semver = pcall(require, "src.mods.Semver")
if okSemver and not Semver.satisfies(ctx.engineVersion, entry.game_version) then
warn(("Needs engine %s (have %s)")
:format(entry.game_version, ctx.engineVersion))
end
end
if entry.profile and entry.profile ~= "content" then
warn(("Profile '%s' changes engine behaviour beyond content")
:format(entry.profile))
end
if entry.affects_link then
warn("Changes link play; both sides need the same mods")
end
if entry.experimental then
warn("Marked experimental by its author")
end
for _, name in ipairs(entry.permissions or {}) do
warn("Requests permission: " .. name)
end
-- dependencies / conflicts arrive as the manifest's own vocabulary: either
-- an array of "id" / "id@<range>" strings or an id -> range map.
local installed = ctx.installed or {}
local function eachSpec(spec, fn)
if type(spec) ~= "table" then return end
for k, v in pairs(spec) do
if type(k) == "number" and type(v) == "string" then
local id, range = v:match("^([^@]+)@(.+)$")
fn(id or v, range)
elseif type(k) == "string" then
fn(k, type(v) == "string" and v or nil)
end
end
end
eachSpec(entry.dependencies, function(id, range)
if installed[id] == nil then
warn("Needs " .. id .. (range and (" " .. range) or "") .. " (not installed)")
end
end)
eachSpec(entry.conflicts, function(id)
if installed[id] ~= nil then
warn("Conflicts with installed " .. id)
end
end)
return out
end
-- ------- pure: search / filter
local function haystack(entry)
return (tostring(entry.title or "") .. " " .. tostring(entry.author or "")
.. " " .. tostring(entry.summary or "") .. " " .. tostring(entry.id or ""))
:lower()
end
-- matches(entry, query) -> bool. Every whitespace-separated term must appear
-- somewhere in title / author / summary / id, so typing more narrows.
function ModIndex.matches(entry, query)
if type(query) ~= "string" or trim(query) == "" then return true end
local hay = haystack(entry)
for term in trim(query):lower():gmatch("%S+") do
if not hay:find(term, 1, true) then return false end
end
return true
end
-- filter(mods, opts) -> a new array. opts = { query, category, tag }.
-- Category and tag compare case-insensitively; feed order (already sorted by
-- title) is preserved.
function ModIndex.filter(mods, opts)
opts = opts or {}
local want = opts.category and tostring(opts.category):lower() or nil
local wantTag = opts.tag and tostring(opts.tag):lower() or nil
local out = {}
for _, entry in ipairs(mods or {}) do
local keep = ModIndex.matches(entry, opts.query)
if keep and want then
keep = false
for _, c in ipairs(entry.categories or {}) do
if tostring(c):lower() == want then keep = true; break end
end
end
if keep and wantTag then
keep = false
for _, t in ipairs(entry.tags or {}) do
if tostring(t):lower() == wantTag then keep = true; break end
end
end
if keep then out[#out + 1] = entry end
end
return out
end
-- Every category actually used by a feed, in the feed's declared order, with
-- anything an entry names that the header forgot appended. Drives the filter
-- row without hard-coding the vocabulary.
function ModIndex.categoriesIn(index)
local out, seen = {}, {}
if type(index) ~= "table" then return out end
local used = {}
for _, entry in ipairs(index.mods or {}) do
for _, c in ipairs(entry.categories or {}) do used[c] = true end
end
for _, c in ipairs(index.categories or {}) do
if used[c] and not seen[c] then seen[c] = true; out[#out + 1] = c end
end
for _, entry in ipairs(index.mods or {}) do
for _, c in ipairs(entry.categories or {}) do
if not seen[c] then seen[c] = true; out[#out + 1] = c end
end
end
return out
end
-- ------- sources (options.modIndexes)
local function loadOptions()
return require("src.core.SaveData").loadOptions()
end
-- The player's index list, normalised. Rows are { url, feed, base, fallback,
-- label }; `url` is what they typed, kept so the row reads back the way they
-- entered it.
function ModIndex.sources()
local ok, opts = pcall(loadOptions)
if not ok or type(opts) ~= "table" then return {} end
local out = {}
for _, row in ipairs(opts.modIndexes or {}) do
if type(row) == "table" and type(row.feed) == "string" then
out[#out + 1] = row
end
end
return out
end
-- addSource(input) -> row | nil, err. Idempotent on the resolved feed URL, so
-- pasting the repo page and the Pages root in either order adds one source.
function ModIndex.addSource(input)
local source, err = ModIndex.resolveSource(input)
if not source then return nil, err end
local ok, result, addErr = pcall(function()
local SaveData = require("src.core.SaveData")
local opts = loadOptions()
opts.modIndexes = opts.modIndexes or {}
for _, row in ipairs(opts.modIndexes) do
if row.feed == source.feed then
return nil, "that index is already added"
end
end
source.url = trim(input)
opts.modIndexes[#opts.modIndexes + 1] = source
SaveData.saveOptions(opts)
return source
end)
if not ok then return nil, "could not save the index: " .. tostring(result) end
return result, addErr
end
-- removeSource(feed) -> true | nil, err. Drops the cached listing with it:
-- keeping a feed's mods around after its source is gone is how a stale card
-- outlives the index it came from.
function ModIndex.removeSource(feed)
if type(feed) ~= "string" or feed == "" then return nil, "missing index" end
local ok, result = pcall(function()
local SaveData = require("src.core.SaveData")
local opts = loadOptions()
local kept, found = {}, false
for _, row in ipairs(opts.modIndexes or {}) do
if row.feed == feed then found = true else kept[#kept + 1] = row end
end
if not found then return nil end
opts.modIndexes = kept
if type(opts.modIndexCache) == "table" then opts.modIndexCache[feed] = nil end
SaveData.saveOptions(opts)
return true
end)
if not ok then return nil, tostring(result) end
if not result then return nil, "that index is not in the list" end
return true
end
-- ------- cache (options.modIndexCache[feed])
function ModIndex.readCache(feed)
if type(feed) ~= "string" or feed == "" then return nil end
local ok, opts = pcall(loadOptions)
if not ok or type(opts) ~= "table" then return nil end
local entry = opts.modIndexCache and opts.modIndexCache[feed]
if type(entry) ~= "table" or type(entry.checkedAt) ~= "number" then
return nil
end
if type(entry.mods) ~= "table" then return nil end
return entry
end
function ModIndex.cacheFresh(entry, now, ttl)
now = now or os.time()
ttl = ttl or ModIndex.CACHE_TTL
return entry ~= nil and type(entry.checkedAt) == "number"
and (now - entry.checkedAt) < ttl
end
function ModIndex.writeCache(feed, index)
if type(feed) ~= "string" or feed == "" then return false end
local ok = pcall(function()
local SaveData = require("src.core.SaveData")
local opts = loadOptions()
opts.modIndexCache = opts.modIndexCache or {}
opts.modIndexCache[feed] = {
checkedAt = os.time(),
generatedAt = index.generatedAt,
categories = index.categories,
mods = index.mods,
}
SaveData.saveOptions(opts)
end)
return ok
end
-- ------- host I/O (curl, via ModUpdate's shell plumbing)
local function shq(s)
s = tostring(s)
if love and love.system and love.system.getOS
and love.system.getOS() == "Windows" then
return '"' .. s:gsub('"', '') .. '"'
end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
-- Plain GET returning the body. No GitHub Accept header: the feed and the
-- description markdown are static files on Pages, and the public feed is
-- explicitly unauthenticated.
function ModIndex.httpGet(url)
local ModUpdate = require("src.mods.ModUpdate")
if not ModUpdate.haveCurl() then
return nil, "curl is not available on this platform"
end
local HostShell = require("src.core.HostShell")
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
.. "-H " .. shq("User-Agent: gen1recomp-mod-index") .. " "
.. shq(url)
local pipeOk, pipe = pcall(HostShell.popen, cmd)
if not pipeOk or not pipe then return nil, "could not run curl" end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
if not readOk then return nil, "fetch failed: " .. tostring(out) end
if not out or out == "" then return nil, "empty response from " .. url end
return out
end
-- fetch(source, opts) -> index, err, meta
-- index is the parse() table; meta is { fromCache, stale }. opts.force skips
-- the 24h cache. A failed live fetch falls back to whatever is cached, marked
-- stale, so going offline degrades the listing rather than emptying it.
function ModIndex.fetch(source, opts)
opts = opts or {}
if type(source) ~= "table" or type(source.feed) ~= "string" then
return nil, "missing index source"
end
local feed = source.feed
local function cached(stale)
local entry = ModIndex.readCache(feed)
if not entry then return nil end
return {
schemaVersion = ModIndex.SCHEMA_VERSION,
generatedAt = entry.generatedAt,
categories = entry.categories or {},
mods = entry.mods or {},
}, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt }
end
if not opts.force then
local entry = ModIndex.readCache(feed)
if ModIndex.cacheFresh(entry) then return cached(false) end
end
local body, err = ModIndex.httpGet(feed)
-- Pages deploys trail a push; the raw mirror is the same file, so a feed
-- that 404s right after a release is worth one retry elsewhere before it
-- counts as an outage.
if not body and source.fallback then
body = ModIndex.httpGet(source.fallback)
end
if not body then
local index, _, meta = cached(true)
if index then return index, nil, meta end
return nil, err
end
local index, parseErr = ModIndex.parse(body)
if not index then
local stale, _, meta = cached(true)
if stale then return stale, parseErr, meta end
return nil, parseErr
end
ModIndex.writeCache(feed, index)
return index, nil, { fromCache = false, checkedAt = os.time() }
end
-- Fetch a description_url / any index-relative text file. Returns the raw
-- markdown; callers run it through ModUpdate.cleanBody for display.
function ModIndex.fetchText(url)
if type(url) ~= "string" or url == "" then return nil, "no description" end
return ModIndex.httpGet(url)
end
-- Download a thumbnail into the save directory and return the love.filesystem
-- relative path. Reuses ModUpdate.downloadZip, which is a plain curl -o with
-- a non-empty-file check -- nothing in it is zip-specific.
function ModIndex.downloadThumbnail(url, modId)
if type(url) ~= "string" or url == "" then return nil, "no thumbnail" end
local ModUpdate = require("src.mods.ModUpdate")
local ext = url:match("%.(%a%a%a?%a?)$") or "png"
local name = ("mod_thumb_%s.%s"):format(tostring(modId):gsub("[^%w%-_]", "_"), ext)
return ModUpdate.downloadZip(url, name)
end
return ModIndex
+361
View File
@@ -0,0 +1,361 @@
-- GitHub release helpers for mod auto-update / other-versions.
-- Pure parsing is love-free; fetch/download use HostShell + curl when available.
-- Release lists are cached in options.modUpdateCache for CACHE_TTL seconds
-- (default 6 hours). The launcher owns UI and install.
local ModUpdate = {}
ModUpdate.CACHE_TTL = 6 * 60 * 60 -- six hours
local function stripV(tag)
return (tostring(tag):gsub("^[vV]", ""))
end
-- Prefer "<id>-<version>.zip", then any "<id>*.zip", then the first .zip.
function ModUpdate.pickZipAsset(assets, modId, version)
if type(assets) ~= "table" then return nil end
local prefer = nil
if modId and version then
prefer = tostring(modId) .. "-" .. tostring(version) .. ".zip"
end
local idPrefix, idPrefixZip, anyZip = nil, nil, nil
if modId then idPrefix = tostring(modId):lower() end
for _, a in ipairs(assets) do
if type(a) == "table" and type(a.name) == "string" then
local name = a.name
if name:lower():match("%.zip$") then
local row = {
name = name,
url = a.browser_download_url,
size = tonumber(a.size),
}
if prefer and name == prefer then return row end
if idPrefix and not idPrefixZip
and name:lower():find(idPrefix, 1, true) == 1 then
idPrefixZip = row
end
if not anyZip then anyZip = row end
end
end
end
return idPrefixZip or anyZip
end
-- Byte-length cut that never lands mid UTF-8 codepoint.
local function utf8Cut(s, maxBytes)
if type(s) ~= "string" or maxBytes <= 0 then return "" end
if #s <= maxBytes then return s end
s = s:sub(1, maxBytes)
-- Drop trailing continuation bytes (10xxxxxx).
while #s > 0 do
local b = s:byte(#s)
if b < 0x80 or b >= 0xC0 then break end
s = s:sub(1, #s - 1)
end
-- Drop a lead byte whose continuation was truncated away.
if #s > 0 then
local b = s:byte(#s)
if b >= 0xC0 then
s = s:sub(1, #s - 1)
end
end
return s
end
-- Strip common markdown / HTML noise for the launcher changelog preview.
function ModUpdate.cleanBody(text, maxChars)
if type(text) ~= "string" or text == "" then return "" end
local s = text
s = s:gsub("\r\n", "\n"):gsub("\r", "\n")
s = s:gsub("<!%-%-.-%-%->", "")
s = s:gsub("<[^>]+>", "")
s = s:gsub("%[%!%[[^%]]*%]%([^%)]*%)%]", "") -- images ![alt](url)
s = s:gsub("%[([^%]]+)%]%([^%)]+%)", "%1") -- links [text](url) -> text
s = s:gsub("```[^\n]*\n(.-)```", "%1")
s = s:gsub("`([^`]+)`", "%1")
s = s:gsub("^#+%s*", "", 1)
s = s:gsub("\n#+%s*", "\n")
s = s:gsub("%*%*([^*]+)%*%*", "%1")
s = s:gsub("%*([^*]+)%*", "%1")
s = s:gsub("__([^_]+)__", "%1")
s = s:gsub("_([^_]+)_", "%1")
s = s:gsub("^\n+", ""):gsub("\n+$", "")
s = s:gsub("\n\n\n+", "\n\n")
maxChars = tonumber(maxChars) or 0
if maxChars > 0 and #s > maxChars then
-- ASCII "..." (not U+2026) so later pixel ellipsize stays byte-safe.
s = utf8Cut(s, maxChars):gsub("%s+%S*$", "") .. "..."
end
return s
end
-- One-line preview for tight UI rows: cleaned, newlines collapsed, ellipsized.
function ModUpdate.previewLine(text, maxChars)
local s = ModUpdate.cleanBody(text, 0)
if s == "" then return "" end
s = s:gsub("\n+", " "):gsub("%s+", " ")
s = s:match("^%s*(.-)%s*$") or s
maxChars = tonumber(maxChars) or 80
if #s > maxChars then
s = utf8Cut(s, maxChars):gsub("%s+%S*$", "") .. "..."
end
return s
end
-- Decode one GitHub release object into { version, tag, zip, prerelease, body }.
function ModUpdate.parseRelease(doc, modId)
if type(doc) ~= "table" or not doc.tag_name then
return nil, "no tag_name in release"
end
local version = stripV(doc.tag_name)
if not version:match("^%d+%.%d+%.%d+") then
return nil, "release tag is not semver-like: " .. tostring(doc.tag_name)
end
local triple = version:match("^(%d+%.%d+%.%d+)")
local zip = ModUpdate.pickZipAsset(doc.assets, modId, triple)
local body = type(doc.body) == "string" and doc.body or ""
return {
version = triple,
tag = tostring(doc.tag_name),
zip = zip,
prerelease = doc.prerelease == true,
name = type(doc.name) == "string" and doc.name or triple,
body = body,
}
end
-- Decode a releases array (GET /repos/.../releases) into a sorted list
-- (newest first). Releases without a .zip asset are dropped. Never throws.
function ModUpdate.parseReleases(jsonText, modId, Json)
local ok, result, err = pcall(function()
Json = Json or require("src.link.Json")
local doc, decodeErr = Json.decode(jsonText)
if type(doc) ~= "table" then
return nil, decodeErr or "releases json is not an array"
end
if type(doc.message) == "string" and not doc.tag_name and not doc[1] then
return nil, "GitHub: " .. doc.message
end
if doc.tag_name then
local one, oneErr = ModUpdate.parseRelease(doc, modId)
if not one then return nil, oneErr end
if not one.zip then return nil, "latest release has no .zip asset" end
return { one }
end
local out = {}
for _, entry in ipairs(doc) do
local rel = ModUpdate.parseRelease(entry, modId)
if rel and rel.zip then out[#out + 1] = rel end
end
return out
end)
if not ok then return nil, "could not parse releases: " .. tostring(result) end
return result, err
end
function ModUpdate.apiReleasesUrl(repo)
return "https://api.github.com/repos/" .. repo .. "/releases?per_page=30"
end
function ModUpdate.apiLatestUrl(repo)
return "https://api.github.com/repos/" .. repo .. "/releases/latest"
end
function ModUpdate.isNewer(installed, candidate)
local Semver = require("src.mods.Semver")
if type(installed) ~= "string" or type(candidate) ~= "string" then
return false
end
local a, b = Semver.parse(installed), Semver.parse(candidate)
if not a or not b then return false end
return Semver.compare(candidate, installed) > 0
end
-- Newest non-prerelease, else newest overall.
function ModUpdate.pickBest(releases)
if type(releases) ~= "table" or #releases == 0 then return nil end
for _, rel in ipairs(releases) do
if not rel.prerelease then return rel end
end
return releases[1]
end
-- ------- cache (options.modUpdateCache[repo])
local function cacheStore()
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
opts.modUpdateCache = opts.modUpdateCache or {}
return opts
end
function ModUpdate.readCache(repo)
if type(repo) ~= "string" or repo == "" then return nil end
local ok, opts = pcall(function()
return require("src.core.SaveData").loadOptions()
end)
if not ok or type(opts) ~= "table" then return nil end
local entry = opts.modUpdateCache and opts.modUpdateCache[repo]
if type(entry) ~= "table" or type(entry.checkedAt) ~= "number" then
return nil
end
if type(entry.releases) ~= "table" then return nil end
return entry
end
function ModUpdate.cacheFresh(entry, now, ttl)
now = now or os.time()
ttl = ttl or ModUpdate.CACHE_TTL
return entry and type(entry.checkedAt) == "number"
and (now - entry.checkedAt) < ttl
end
function ModUpdate.writeCache(repo, releases)
if type(repo) ~= "string" or repo == "" then return false end
local ok = pcall(function()
local SaveData = require("src.core.SaveData")
local opts = cacheStore()
local best = ModUpdate.pickBest(releases)
-- Persist a lean copy: enough to paint the UI and reinstall without
-- re-fetching within the TTL.
local lean = {}
for i, rel in ipairs(releases or {}) do
lean[i] = {
version = rel.version,
tag = rel.tag,
name = rel.name,
prerelease = rel.prerelease == true,
body = type(rel.body) == "string" and rel.body or "",
zip = rel.zip and {
name = rel.zip.name,
url = rel.zip.url,
size = rel.zip.size,
} or nil,
}
end
opts.modUpdateCache[repo] = {
checkedAt = os.time(),
latest = best and best.version or nil,
releases = lean,
}
SaveData.saveOptions(opts)
end)
return ok
end
-- Status vs an installed version using a cache entry / release list.
-- Returns "available" | "current" | "unknown".
function ModUpdate.statusFor(installedVersion, releases)
local best = ModUpdate.pickBest(releases)
if not best then return "unknown", nil end
if ModUpdate.isNewer(installedVersion, best.version) then
return "available", best
end
return "current", best
end
-- ------- host I/O (curl)
local function shq(s)
s = tostring(s)
if love and love.system and love.system.getOS
and love.system.getOS() == "Windows" then
return '"' .. s:gsub('"', '') .. '"'
end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
local function curlCapture(url)
local HostShell = require("src.core.HostShell")
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
.. "-H " .. shq("User-Agent: gen1recomp-mod-updater") .. " "
.. "-H " .. shq("Accept: application/vnd.github+json") .. " "
.. shq(url)
local pipeOk, pipe = pcall(HostShell.popen, cmd)
if not pipeOk or not pipe then return nil, "could not run curl" end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
if not readOk then return nil, "curl read failed: " .. tostring(out) end
if not out or out == "" then return nil, "empty response from GitHub" end
return out
end
function ModUpdate.haveCurl()
local HostShell = require("src.core.HostShell")
local pipeOk, pipe = pcall(HostShell.popen, "curl --version")
if not pipeOk or not pipe then return false end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
end
-- Fetch release list. opts.force bypasses the 6h cache.
-- Returns releases, err, meta where meta = { fromCache = bool }.
function ModUpdate.fetchReleases(repo, modId, opts)
opts = opts or {}
if type(repo) ~= "string" or repo == "" then
return nil, "missing github repo"
end
if not opts.force then
local cached = ModUpdate.readCache(repo)
if ModUpdate.cacheFresh(cached) then
return cached.releases, nil, { fromCache = true }
end
end
if not ModUpdate.haveCurl() then
-- Stale cache is better than nothing when offline
local cached = ModUpdate.readCache(repo)
if cached and cached.releases then
return cached.releases, nil, { fromCache = true, stale = true }
end
return nil, "curl is not available on this platform"
end
local body, curlErr = curlCapture(ModUpdate.apiReleasesUrl(repo))
if not body then
local cached = ModUpdate.readCache(repo)
if cached and cached.releases then
return cached.releases, nil, { fromCache = true, stale = true }
end
return nil, curlErr
end
local list, parseErr = ModUpdate.parseReleases(body, modId)
if not list then return nil, parseErr end
ModUpdate.writeCache(repo, list)
return list, nil, { fromCache = false }
end
function ModUpdate.downloadZip(url, destName)
if type(url) ~= "string" or url == "" then
return nil, "missing download url"
end
if not (love and love.filesystem) then
return nil, "download needs LOVE"
end
if not ModUpdate.haveCurl() then
return nil, "curl is not available on this platform"
end
local HostShell = require("src.core.HostShell")
local name = destName or ("mod_update_" .. tostring(os.time()) .. ".zip")
name = tostring(name):gsub("[/\\]", "_")
local saveOk, saveDir = pcall(function()
return love.filesystem.getSaveDirectory()
end)
if not saveOk or not saveDir or saveDir == "" then
return nil, "no save directory"
end
local abs = saveDir .. "/" .. name
local cmd = "curl -fsSL --connect-timeout 15 --max-time 300 -o "
.. shq(abs) .. " " .. shq(url)
local pipeOk, pipe = pcall(HostShell.popen, cmd)
if not pipeOk or not pipe then return nil, "could not start download" end
pcall(function() pipe:read("*a") end)
pcall(function() pipe:close() end)
local infoOk, info = pcall(love.filesystem.getInfo, name)
if not infoOk or not info or (info.size or 0) == 0 then
pcall(love.filesystem.remove, name)
return nil, "download failed"
end
return name
end
return ModUpdate
+7 -4
View File
@@ -428,6 +428,7 @@ R.pokemon = {
spriteFront = f.path, spriteBack = f.path, frontSize = f.int(1, 7),
dexEntry = f.opt(f.rec{ kind = f.str, heightFt = f.int(0),
heightIn = f.int(0, 11), weight = f.num,
heightM = f.opt(f.num), weightKg = f.opt(f.num),
text = f.str }),
icon = f.opt(f.union{ f.str, f.rec{ image = f.path,
frames = f.opt(f.int(1)) } }),
@@ -1185,11 +1186,13 @@ R.field = {
nest = f.opt(f.any) },
flyOrder = f.list(f.str),
-- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle
-- back pic, the catch tutorial's old man, and the front pic the intro,
-- trainer card and Hall of Fame share. Every key is optional so a
-- conversion can replace one pic and inherit the rest.
-- back pic, the catch tutorial's old man, Yellow's PROF.OAK variant of
-- it (#557), and the front pic the intro, trainer card and Hall of Fame
-- share. Every key is optional so a conversion can replace one pic and
-- inherit the rest.
playerPics = f.rec{
back = f.opt(f.str), demoBack = f.opt(f.str), front = f.opt(f.str) },
back = f.opt(f.str), demoBack = f.opt(f.str),
oakBack = f.opt(f.str), front = f.opt(f.str) },
-- the new-game and boot config a total conversion replaces
boot = f.rec{
startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)),
+13 -1
View File
@@ -46,20 +46,32 @@ end
-- opts.kind: "battle" | "intro" | "trainer_card" | "hof"
-- opts.demo: the catch tutorial, where the old man fights in the player's
-- place and stands in for the back pic
-- opts.oakDemo: the Yellow variant of that demo (BATTLE_TYPE_PIKACHU), where
-- PROF.OAK fights in the player's place behind his own back pic
-- opts.battle: the live battle, for kind == "battle"
-- Returns path, trueColor.
function Sprites.playerPath(data, side, opts)
opts = opts or {}
side = side == "back" and "back" or "front"
-- one key per pic, so a conversion can replace the back and inherit the
-- rest; fieldValue folds data.field over FieldDefaults per key
-- rest; fieldValue folds data.field over FieldDefaults per key. The two
-- demo keys mirror LoadPlayerBackPic's wBattleType branch (#557).
local key = side == "front" and "front"
or (opts.oakDemo and "oakBack")
or (opts.demo and "demoBack" or "back")
local path = FieldDefaults.fieldValue(data, "playerPics", key)
-- ProfOakPicBack is a Yellow-only rip, so a cache imported before it
-- existed has no file there; fall back to the old man rather than hand a
-- missing path to getImage (#557)
if key == "oakBack" and path
and not require("src.render.Assets").exists(path) then
path = FieldDefaults.fieldValue(data, "playerPics", "demoBack")
end
local ctx = {
side = side,
kind = opts.kind or "battle",
demo = opts.demo and true or false,
oakDemo = opts.oakDemo and true or false,
battle = opts.battle,
trueColor = false,
data = data,
+148 -44
View File
@@ -5,11 +5,13 @@
-- then drawn once per zone through a shader that remaps the four DMG
-- shades to that zone's palette.
--
-- Port display option: COLORS (OG RED / SGB / ADVANCED / OG / OG INV /
-- SGB INV / CLASSIC) transforms every zone's palette at send time via
-- effectiveColors. ADVANCED (the `redpp` id below) swaps the named-palette
-- pack for pokered-gbc SuperPalettes (data/palettes_gbc.lua), including
-- per-species mon colors.
-- Port display option: COLORS (OG RED / OG BLUE / OG YELLOW / SGB /
-- ADVANCED / OG / OG INV / SGB INV / CLASSIC) transforms every zone's
-- palette at send time via effectiveColors. ADVANCED (the `redpp` id
-- below) swaps the named-palette pack for pokered-gbc SuperPalettes
-- (data/palettes_gbc.lua), including per-species mon colors. OG YELLOW
-- (Yellow playthrough + `ogred` id) uses pokeyellow CGBBasePalettes
-- (data/palettes_yellow.lua / ROM cgbBase).
local GameVersion = require("src.core.GameVersion")
@@ -17,10 +19,11 @@ local PaletteFX = {}
local shader -- false = unavailable (headless / no shader support)
local gbcPack -- false = missing; nil = not loaded yet
local yellowPack -- false = missing; nil = not loaded yet
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, ADVANCED =
-- pokered-gbc per-tile), then the DMG-shade novelty modes.
-- come first (OG RED/BLUE/YELLOW = GBC hardware, SGB = per-map Super Game Boy,
-- ADVANCED = pokered-gbc per-tile), then the DMG-shade novelty modes.
PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
-- `gbc`/`gbc_inv`/`redpp` keep their save-value ids for back-compat while
-- their LABELS say what the mode actually is: "SGB"/"SGB INV" because the old
@@ -35,6 +38,48 @@ PaletteFX.MODE_LABELS = {
}
PaletteFX.mode = "gbc"
-- ------- dark-cave state (wMapPalOffset)
--
-- Unlike the per-frame shadeMap further down, this outlives a frame: ADVANCED
-- resolves real colour per tile and BAKES it (tileset atlas, sprite sheets),
-- so FadePal2's shift has to reach those bakes and their cache keys rather
-- than a shader (#383). OverworldState owns it: armed before the map's atlas
-- is built, cleared by FLASH.
local darkWorld = false
-- returns true when the flag actually changed, so the caller can rebuild
function PaletteFX.setDarkWorld(on)
on = on and true or false
if darkWorld == on then return false end
darkWorld = on
return true
end
function PaletteFX.darkWorld() return darkWorld end
-- cache-key suffix for anything baked under the dark shift
function PaletteFX.darkKey() return darkWorld and "#dark" or "" end
-- FadePal2 sets rOBP0 = `dc 3,3,3,2` as well as rBGP, so EVERY OBJ colour a
-- sprite can carry lands on shade 3: the player, trainers and item balls are
-- black silhouettes until FLASH (#383). Applied to whatever 4-colour OBP the
-- active mode resolved, with a distinct cache group so the lit and dark bakes
-- of one sheet never collide in SpriteRenderer's obpCache.
function PaletteFX.darkObp(colors, group)
if not (colors and darkWorld) then return colors, group end
return PaletteFX.permute(colors, PaletteFX.DARK_BGP), tostring(group) .. "dark"
end
-- the same shift folded into a baked 8-group world palette array (ADVANCED)
local function darkGroups(groups)
if not (groups and darkWorld) then return groups end
local out = {}
for i = 1, #groups do
out[i] = PaletteFX.permute(groups[i], PaletteFX.DARK_BGP)
end
return out
end
-- Classic DMG pea-soup greens (#9BBC0F / #8BAC0F / #306230 / #0F380F)
PaletteFX.CLASSIC = {
{ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 },
@@ -75,14 +120,19 @@ PaletteFX.GBC_OBJ_BLUE = {
}
-- The active game's OG boot-ROM background palette: blue for a Blue
-- playthrough, red otherwise. White (index 1) and black (index 4) are
-- identical across versions, so callers that only touch the endpoints
-- (e.g. BattleState's zone white/black snap) need no version branch.
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes) and has no extracted
-- boot-ROM auto-palette here; keep the Red ramp -- never Blue's GBC_BG_BLUE.
-- playthrough, red for Red. White (index 1) and black (index 4) are
-- identical across Red/Blue, so callers that only touch the endpoints
-- (e.g. BattleState's zone white/black snap) need no version branch there.
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes): named zones go through
-- pal() / usesYellowCgb(), and ogBg() falls back to CGBBase PAL_ROUTE for any
-- remaining whole-screen callers -- never Blue's GBC_BG_BLUE.
function PaletteFX.ogBg()
if GameVersion.isBlue() then return PaletteFX.GBC_BG_BLUE end
if GameVersion.isYellow() then return PaletteFX.GBC_BG end
if GameVersion.isYellow() then
local y = PaletteFX.yellowPack()
local route = y and y.cgbBase and y.cgbBase.ROUTE
if route then return route end
end
return PaletteFX.GBC_BG
end
@@ -91,11 +141,13 @@ end
-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys
-- its baked-image cache by (image path, group): a shared group would collide a
-- Red bake with a Blue one and one version would show the other's colors.
-- Yellow: same Red OBJ green as above until Yellow-specific tables land.
-- Yellow OG does not bake a boot-ROM OBJ (usesSpriteObp is false); this path
-- is unused there and kept as Red green only as a safe leftover.
function PaletteFX.ogObj()
if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end
if GameVersion.isYellow() then return PaletteFX.GBC_OBJ, "gbcobj" end
return PaletteFX.GBC_OBJ, "gbcobj"
if GameVersion.isBlue() then
return PaletteFX.darkObp(PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue")
end
return PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj")
end
-- The DMG object ramp every mode except OG RED bakes onto overworld sprites,
@@ -114,7 +166,7 @@ PaletteFX.OBP0_SHADES = {
}
function PaletteFX.dmgObj()
return PaletteFX.OBP0_SHADES, "obp0"
return PaletteFX.darkObp(PaletteFX.OBP0_SHADES, "obp0")
end
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
@@ -225,13 +277,31 @@ function PaletteFX.gbcPack()
return gbcPack or nil
end
-- pokeyellow SuperPalettes + CGBBasePalettes (committed; not wiped by import).
-- ROM import may also stamp palettes.cgbBase; yellowCgbNamedPal prefers that.
function PaletteFX.yellowPack()
if yellowPack == nil then
local ok, pack = pcall(require, "data.palettes_yellow")
yellowPack = ok and pack or false
end
return yellowPack or nil
end
function PaletteFX.usesGbcPack(mode)
mode = mode or PaletteFX.mode
return mode == "redpp"
end
-- Yellow's authentic GBC look is CGBBasePalettes (per-map), not a boot-ROM
-- auto-palette. The shared `ogred` save id wears that table on a Yellow
-- playthrough and labels itself "OG YELLOW".
function PaletteFX.usesYellowCgb(mode)
mode = mode or PaletteFX.mode
return GameVersion.isYellow() and mode == "ogred"
end
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
-- (the OBP bake + post-zone redraw path). OG RED alone does: the Game Boy
-- (the OBP bake + post-zone redraw path). OG RED / OG BLUE do: the Game Boy
-- Color boot ROM hands the game one global object palette (PaletteFX.ogObj --
-- green over Red's red background, pink over Blue's blue background), so on
-- that machine the player and NPCs carry a fixed object color instead of
@@ -242,6 +312,10 @@ end
-- regression. Terrain is unaffected -- pal() below still hands SGB its per-map
-- BG palette; only OG RED short-circuits BG to the one global red palette.
--
-- OG YELLOW does NOT. Yellow ships CGB code and colors regions via
-- CGBBasePalettes the same way SGB mode colors SuperPalettes, so sprites tint
-- with the map zone (usesSpriteObp stays off).
--
-- SGB does NOT. The Super Game Boy colorizes the composited DMG picture it is
-- handed and cannot tell an OBJ pixel from a BG one; pokered never sends the
-- OBJ_TRN packet that would enable SGB sprite mode (data/sgb/sgb_packets.asm
@@ -260,7 +334,7 @@ end
-- usesGbcPack() path in SpriteRenderer instead.
function PaletteFX.usesSpriteObp(mode)
mode = mode or PaletteFX.mode
return mode == "ogred"
return mode == "ogred" and not GameVersion.isYellow()
end
-- ------- post-zone sprite redraw (OG RED)
@@ -317,8 +391,8 @@ end
-- read these from the ROM-imported table or the title ribbon stays red
-- and the Game Corner reels keep Red's pink (issue #128).
-- Yellow is intentionally NOT in BLUE_VERSIONED: skip Blue LOGO1/SLOTS*
-- recolors. When CGBBasePalettes were imported (palettes.cgbBase), Yellow
-- prefers those over SGB SuperPalettes for named zones.
-- recolors. OG YELLOW (usesYellowCgb) reads CGBBasePalettes; SGB mode on
-- Yellow keeps SuperPalettes.
local BLUE_VERSIONED = {
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
}
@@ -330,31 +404,42 @@ end
local function yellowCgbNamedPal(data, name)
local p = data and data.palettes
return p and p.cgbBase and p.cgbBase[name]
local fromRom = p and p.cgbBase and p.cgbBase[name]
if fromRom then return fromRom end
local y = PaletteFX.yellowPack()
return y and y.cgbBase and y.cgbBase[name] or nil
end
-- named palette from the active pack (nil on stale builds / missing name).
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
-- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
-- (the hardware had a single BGP for the whole game), so terrain zones,
-- battle HP bars / text, and menu boxes all come out red -- everything a
-- background tile drew. Objects do not come through here (they bake
-- GBC_OBJ green), so this stays a BG-only hook.
-- OG RED / OG BLUE short-circuit EVERY name to the one global GBC boot-ROM
-- BG palette (the hardware had a single BGP for the whole game), so terrain
-- zones, battle HP bars / text, and menu boxes all come out red/blue --
-- everything a background tile drew. Objects do not come through here
-- (they bake GBC_OBJ), so this stays a BG-only hook.
-- OG YELLOW instead resolves each name through CGBBasePalettes.
function PaletteFX.pal(data, name)
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
if PaletteFX.mode == "ogred" and not GameVersion.isYellow() then
return PaletteFX.ogBg()
end
-- Blue-only ROM override for versioned SuperPals. Yellow (isYellow) and
-- Red keep the active pack / Red-like path -- do not apply Blue recolors.
if GameVersion.isBlue() and BLUE_VERSIONED[name] then
local fromRom = romNamedPal(data, name)
if fromRom then return fromRom end
end
if GameVersion.isYellow() then
if PaletteFX.usesYellowCgb() then
local fromCgb = yellowCgbNamedPal(data, name)
if fromCgb then return fromCgb end
end
local p = PaletteFX.pack(data)
local c = p and p.palettes[name]
if c then return c end
if GameVersion.isYellow() then
local y = PaletteFX.yellowPack()
local yc = y and y.palettes and y.palettes[name]
if yc then return yc end
end
if PaletteFX.usesGbcPack() then
return romNamedPal(data, name)
end
@@ -367,14 +452,20 @@ end
-- Transformed mon's pic is tinted gray, not the copied species' own
-- SGB color). RED++ uses per-species pals from mon_palettes.asm.
function PaletteFX.monPal(data, species, transformed)
-- OG RED: a battle mon pic is a BG tile on the Game Boy Color (drawn into
-- the tilemap, colored by BGP), so it wears the global red BG palette, not
-- a per-species one -- matching the hardware capture where both mons are
-- red/pink on the white field.
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
-- OG RED / OG BLUE: a battle mon pic is a BG tile on the Game Boy Color
-- (drawn into the tilemap, colored by BGP), so it wears the global boot-ROM
-- BG palette, not a per-species one -- matching the hardware capture where
-- both mons are red/pink (or blue/pink) on the white field.
-- OG YELLOW keeps per-species CGBBasePalettes (Yellow had real CGB code).
if PaletteFX.mode == "ogred" and not GameVersion.isYellow() then
return PaletteFX.ogBg()
end
local p = PaletteFX.pack(data)
if not p then return nil end
if transformed then
if PaletteFX.usesYellowCgb() then
return PaletteFX.pal(data, "GRAYMON")
end
return p.palettes.GRAYMON
or (data and data.palettes and data.palettes.palettes.GRAYMON)
end
@@ -384,17 +475,32 @@ function PaletteFX.monPal(data, species, transformed)
-- so fall back to it when the pack itself doesn't carry the name.
local def = data and data.pokemon and data.pokemon[species]
if def and def.palette then
if PaletteFX.usesYellowCgb() then
local yc = PaletteFX.pal(data, def.palette)
if yc then return yc end
end
local pal = p.palettes[def.palette]
or (data and data.palettes and data.palettes.palettes[def.palette])
if pal then return pal end
end
local name = p.pokemon[species] or "MEWMON"
if PaletteFX.usesYellowCgb() then
local yc = PaletteFX.pal(data, name)
if yc then return yc end
end
local c = p.palettes[name]
if c then return c end
if PaletteFX.usesGbcPack() and data and data.palettes then
name = data.palettes.pokemon[species] or "MEWMON"
return data.palettes.palettes[name]
end
if GameVersion.isYellow() then
local y = PaletteFX.yellowPack()
if y and y.pokemon then
name = y.pokemon[species] or "MEWMON"
return y.palettes and y.palettes[name]
end
end
return nil
end
@@ -503,7 +609,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
local w = pack and pack.world
local base = w and w.groupColors[tileset]
if not base then return nil end
if not w.roofGroup[tileset] then return base end
if not w.roofGroup[tileset] then return darkGroups(base) end
local roofMapId = mapId
if mapId == ROUTE_6_SAFFRON.mapId and playerCellY
and playerCellY < ROUTE_6_SAFFRON.cellYBelow then
@@ -511,7 +617,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
end
local roofMap = data and data.maps and data.maps[roofMapId]
local roof = roofMap and w.roofByMapIndex[roofMap.index]
if not roof then return base end
if not roof then return darkGroups(base) end
local out = {}
for i = 1, 8 do out[i] = base[i] end
-- LoadTownPalette only overwrites W2_BgPaletteData + $32, i.e. colors 1
@@ -521,7 +627,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
-- material's 2 middle shades are town-specific
local base4 = base[ROOF_GROUP + 1]
out[ROOF_GROUP + 1] = { base4[1], roof[1], roof[2], base4[4] }
return out
return darkGroups(out)
end
-- an overworld sprite's resolved 4-color OBJ palette (ColorOverworldSprite),
@@ -552,7 +658,7 @@ function PaletteFX.spriteObp(spriteDef, seed)
for i = 1, #seed do h = (h * 31 + seed:byte(i)) % 4294967296 end
group = h % 4
end
return w.spritePalettes[group], group
return PaletteFX.darkObp(w.spritePalettes[group], group)
end
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
@@ -661,12 +767,10 @@ end
function PaletteFX.modeLabel(mode)
mode = mode or PaletteFX.mode
-- The GBC boot-ROM mode wears the running game's name: it is red for Red and
-- blue for Blue (see ogBg), so a Blue playthrough shows "OG BLUE".
-- Yellow still uses the Red boot-ROM ramp (no Yellow table yet), so keep
-- the "OG RED" label rather than inventing an "OG YELLOW" without colors.
-- The GBC hardware mode wears the running game's name: OG RED / OG BLUE
-- (boot-ROM auto-palette) or OG YELLOW (pokeyellow CGBBasePalettes).
if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end
if mode == "ogred" and GameVersion.isYellow() then return "OG RED" end
if mode == "ogred" and GameVersion.isYellow() then return "OG YELLOW" end
return PaletteFX.MODE_LABELS[mode] or "GBC"
end
+1 -1
View File
@@ -120,7 +120,7 @@ end
-- shape (love.graphics.newCanvas), so accept either and nothing else.
local function isCanvas(v)
if type(v) == "userdata" then
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
end
if type(v) == "table" then
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
+100 -35
View File
@@ -32,7 +32,7 @@ Renderer.MAX_UI_HEIGHT = 576
-- would otherwise take the frame down with it.
local function isCanvas(v)
if type(v) == "userdata" then
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
end
if type(v) == "table" then
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
@@ -437,12 +437,26 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
return true
end
-- clamp a scissor rect to the viewport box
local function scissorClamped(x, y, w, h, ox, oy, vpw, vph)
-- Clamp a scissor rect to the viewport box, then round it outward to whole
-- framebuffer pixels. love.graphics.setScissor truncates x, y, w and h to
-- pixels independently, so a rect with fractional unit edges (Android's
-- non-integer DPI puts fitScale/dpi in Sx/Sy) loses up to a pixel per side
-- and two adjacent SGB zones stop sharing an edge: the letterbox clear shows
-- through as a horizontal seam at every zone boundary (#373). Rounding
-- outward makes neighbours overlap by at most one row instead -- the overlap
-- redraws the same canvas pixels one palette later, and past the canvas edge
-- there is nothing to draw. The half pixel keeps LOVE's truncation on the
-- snapped edge rather than one short of it.
local function scissorClamped(x, y, w, h, ox, oy, vpw, vph, dpiX, dpiY)
local x2, y2 = math.min(x + w, ox + vpw), math.min(y + h, oy + vph)
x, y = math.max(x, ox), math.max(y, oy)
if x2 <= x or y2 <= y then return false end
love.graphics.setScissor(x, y, x2 - x, y2 - y)
dpiX, dpiY = dpiX or 1, dpiY or 1
local px1, py1 = math.floor(x * dpiX), math.floor(y * dpiY)
local px2, py2 = math.ceil(x2 * dpiX), math.ceil(y2 * dpiY)
love.graphics.setScissor((px1 + 0.5) / dpiX, (py1 + 0.5) / dpiY,
(px2 - px1 + 0.5) / dpiX,
(py2 - py1 + 0.5) / dpiY)
return true
end
@@ -460,6 +474,41 @@ local function withTrueColor(zoneList, pass)
return merged
end
-- Palette-correct blit of `canvas` at (sx, sy) LOVE-unit scales into origin
-- (bx, by), scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/
-- zoneSy convert zone coords (canvas-space) into screen units. Public so a
-- render.compose mod can composite the world/UI canvases into its own layout.
function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then
love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
love.graphics.setScissor()
return
end
love.graphics.setShader(shader)
-- a colors == false zone is the trueColor opt-out: its rect draws with
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
-- list never toggles and issues exactly the calls it always did.
local bare = false
for _, z in ipairs(zoneList) do
local plain = z.colors == false
if plain ~= bare then
bare = plain
love.graphics.setShader(not plain and shader or nil)
end
if not plain then PaletteFX.sendColors(shader, z.colors) end
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
z.w * zoneSx, z.h * zoneSy,
boxX, boxY, boxW, boxH, dpiX, dpiY) then
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
end
love.graphics.setScissor()
love.graphics.setShader()
end
-- zones: optional list of SGB palette regions (see PaletteFX) in
-- 160x144 UI space, applied to the UI pass. worldZones: optional
-- regions in world-canvas pixels (overworld survey zoom colors each
@@ -492,6 +541,36 @@ function Renderer:endFrame(zones, worldZones)
zones = withTrueColor(zones, "ui")
worldZones = withTrueColor(worldZones, "world")
-- render.compose: hand a mod the finished world + UI canvases (and their
-- SGB zones), the frame metrics, Renderer:blitCanvas and the SecondScreen
-- bridge, letting it lay the two passes out however it likes -- e.g. as two
-- stacked Game Boy screens, or driving one onto a second physical display.
-- The mod returns true to take over the whole window; anything else (or no
-- mod wrapping the hook) falls through to the normal composite below.
if Runtime.wantsHook("render.compose") then
local ctx = {
renderer = self,
worldCanvas = self.worldCanvas, uiCanvas = self.canvas,
worldOverride = self.worldOverride,
worldActive = self.worldActive and true or false,
zones = zones, worldZones = worldZones,
ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy,
vpw = vpw, vph = vph, uiw = uiw, uih = uih,
scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY,
secondScreen = require("src.render.SecondScreen"),
}
if Runtime.call("render.compose", function() return false end, self, ctx) == true then
self.worldActive = false
self.uprightActive = false
self.worldOverride = nil
PaletteFX.setPass(nil)
return {
width = ww, height = wh, gameX = ox, gameY = oy,
gameWidth = vpw, gameHeight = vph, scale = Sp, dpiX = dpiX, dpiY = dpiY,
}
end
end
-- A post-process pipeline needs the whole composite in a canvas for the
-- same reason GBC FX does, so either one alone is enough to take the
-- present path; with neither, the frame draws straight to the screen
@@ -546,38 +625,12 @@ function Renderer:endFrame(zones, worldZones)
})
end
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
-- convert zone coords (canvas-space) into screen units.
-- see Renderer:blitCanvas; bound here to the frame's dpi so the composite
-- call sites below stay unchanged.
local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then
love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
love.graphics.setScissor()
return
end
love.graphics.setShader(shader)
-- a colors == false zone is the trueColor opt-out: its rect draws with
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
-- list never toggles and issues exactly the calls it always did.
local bare = false
for _, z in ipairs(zoneList) do
local plain = z.colors == false
if plain ~= bare then
bare = plain
love.graphics.setShader(not plain and shader or nil)
end
if not plain then PaletteFX.sendColors(shader, z.colors) end
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
z.w * zoneSx, z.h * zoneSy,
boxX, boxY, boxW, boxH) then
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
end
love.graphics.setScissor()
love.graphics.setShader()
return self:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
end
if self.worldOverride then
@@ -588,7 +641,12 @@ function Renderer:endFrame(zones, worldZones)
-- runs, so dialogs, menus and the HUD sit on top as usual.
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
local loveMajor = love.getVersion()
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY)
else
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
end
love.graphics.setScissor()
-- the screen-space overlays the flat path draws over its composite
local fade = self.worldFadeAlpha
@@ -706,6 +764,13 @@ function Renderer:endFrame(zones, worldZones)
self.uprightActive = false
self.worldOverride = nil
PaletteFX.setPass(nil)
return {
width = ww, height = wh,
gameX = ox, gameY = oy,
gameWidth = vpw, gameHeight = vph,
scale = Sp,
dpiX = dpiX, dpiY = dpiY,
}
end
return Renderer
+59
View File
@@ -0,0 +1,59 @@
-- Bridge to native secondary-display output (Android Presentation). The C
-- functions live in mobile/android/love/src/jni/love/src/common/android.cpp.
-- Everything is guarded: off Android, or if the symbols cannot be resolved,
-- this stays inert and the renderer keeps the in-window stacked layout.
local SecondScreen = {}
local C = nil
local function log(msg)
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
end
do
local ok, ffi = pcall(require, "ffi")
if not (ok and ffi) then
log("ffi unavailable (not LuaJIT); second display disabled")
else
pcall(ffi.cdef, [[
int love_android_secondary_ready();
void love_android_push_secondary(const void *rgba, int w, int h);
void love_android_secondary_enable(int on);
]])
local okLib, lib = pcall(ffi.load, "love")
if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then
C = lib
log("bridge linked via ffi.load('love')")
elseif pcall(function() return ffi.C.love_android_secondary_ready end) then
C = ffi.C
log("bridge linked via default namespace")
else
log(("bridge symbols not found (ffi.load ok=%s); second display disabled")
:format(tostring(okLib)))
end
end
end
function SecondScreen.usable()
return C ~= nil
end
function SecondScreen.available()
if not C then return false end
local ok, r = pcall(C.love_android_secondary_ready)
return ok and r ~= 0
end
function SecondScreen.push(imageData, w, h)
if not C or not imageData then return false end
return pcall(function()
C.love_android_push_secondary(imageData:getFFIPointer(), w, h)
end)
end
function SecondScreen.setEnabled(on)
if not C then return end
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
end
return SecondScreen
+36 -1
View File
@@ -135,7 +135,10 @@ local function blitFrame(image, quad, x, y, flip, redraw)
end
end
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
-- topHalf blits only the upper 8 rows of the frame: FishingAnim overwrites the
-- bottom tile row of the standing frames with the fishing pose art, which the
-- caller then draws itself through :drawTile (Player:draw, #384)
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf)
local x = math.floor(px - camX)
local y = math.floor(py - camY) - 4
local image = self.image
@@ -190,7 +193,39 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
flip = true
end
local quad = self.frames[frame] or self.frames[0]
if topHalf then
self.halfFrames = self.halfFrames or {}
if not self.halfFrames[frame] then
local iw, ih = self.image:getDimensions()
self.halfFrames[frame] = love.graphics.newQuad(0, frame * 16, 16, 8, iw, ih)
end
quad = self.halfFrames[frame]
end
blitFrame(image, quad, x, y, flip, redraw)
end
-- Blit a loose 16-wide fx tile at screen (x, y) wearing THIS sprite's OBJ
-- palette, mirroring the mode branches in :draw above. The fishing pose row
-- overwrites the sheet's own tiles in VRAM in the original, so it has to be
-- recolored and OG-RED-redrawn exactly like the sheet rather than blitted as
-- raw DMG shades (#384).
function SpriteRenderer:drawTile(path, x, y, flip)
local image, redraw = getImage(path), false
if self.def.trueColor then
PaletteFX.markTrueColor(x, y, 16, 8)
elseif PaletteFX.usesGbcPack() then
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then image = getObpImage(path, colors, group) end
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
image, redraw = getObpImage(path, PaletteFX.ogObj()), true
else
image = getObpImage(path, PaletteFX.dmgObj())
end
local iw, ih = image:getDimensions()
self.tileQuads = self.tileQuads or {}
self.tileQuads[path] = self.tileQuads[path]
or love.graphics.newQuad(0, 0, iw, ih, iw, ih)
blitFrame(image, self.tileQuads[path], x, y, flip, redraw)
end
return SpriteRenderer
+13 -4
View File
@@ -402,8 +402,15 @@ end
-- the route's own default roof (Vermilion's) throughout.
local gbcAtlasCache = {}
-- Cache suffix for a map's RED++ bake. A dark cave folds FadePal2 into the
-- palette worldGroupColors hands the bake (#383), so the lit and dark bakes of
-- one map are different images and must not share a key.
local function gbcKeyFor(mapId)
return "#gbc:" .. mapId .. PaletteFX.darkKey()
end
local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data)
local key = imagePath .. "#gbc:" .. mapId
local key = imagePath .. gbcKeyFor(mapId)
if gbcAtlasCache[key] ~= nil then return gbcAtlasCache[key] or nil end
local img = false
if love.image and love.image.newImageData then
@@ -473,7 +480,7 @@ function TileRenderer.new(map, data)
self.gbcAtlas = true
-- also recolors the animated water/flower entries below, so they
-- match the atlas's static tiles instead of showing raw grayscale
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = "#gbc:" .. map.id,
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = gbcKeyFor(map.id),
groupColors = PaletteFX.worldGroupColors(data, map.tileset.id, map.id, nil) }
-- ...and feeds the color-0-keyed single tiles the feet overdraw needs
-- (see getKeyedTile): same source image and palette groups, so keep the
@@ -481,6 +488,7 @@ function TileRenderer.new(map, data)
gbcCtx.imagePath = map.tileset.image
gbcCtx.perRow = map.tileset.tilesPerRow
self.gbcCtx = gbcCtx
self.gbcAtlasKey = map.tileset.image .. gbcCtx.key
self.gbcKeyed = {}
end
end
@@ -582,7 +590,7 @@ local function ensureWaterBorderFill(self)
local groupColors = PaletteFX.worldGroupColors(
self.data, map.tileset.id, map.id, nil)
colors = group and groupColors and groupColors[group + 1] or nil
gbcKey = "#gbc:" .. map.id
gbcKey = gbcKeyFor(map.id)
end
local textures = getShiftVariants(map.tileset.image, perRow, WATER_TILE,
colors, gbcKey)
@@ -913,8 +921,9 @@ function TileRenderer:release()
self.gbcCtx = nil
end
if self.gbcAtlas and self.image then
local key = self.map.tileset.image .. "#gbc:" .. self.map.id
local key = self.gbcAtlasKey or (self.map.tileset.image .. gbcKeyFor(self.map.id))
if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end
self.gbcAtlasKey = nil
safeRelease(self.image)
self.image = nil
self.gbcAtlas = nil
+10
View File
@@ -10,6 +10,13 @@ local Zoom = {}
Zoom.offset = 0
-- Survey zoom (zooming out past FIT, which renders connected neighbor maps)
-- is the port's most expensive optional extra. The performance tier sets
-- this false on LOW hardware (Game:applyOptions); offsetRange then floors
-- the range at FIT so the option row, hotkey, and mouse wheel all stop at
-- close-up. Nil/true keeps the historical full range.
Zoom.allowSurvey = true
-- 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.
function Zoom.offsetRange(S)
@@ -21,6 +28,9 @@ function Zoom.offsetRange(S)
hi = math.floor(tonumber(hi) or S)
if lo > hi then lo, hi = hi, lo end
end
-- LOW performance tier: no survey (negative offsets), even if a mod's
-- zoom.range widened it. == false so nil/true stays permissive.
if Zoom.allowSurvey == false and lo < 0 then lo = 0 end
return lo, hi
end
+58
View File
@@ -84,6 +84,15 @@ O.coins = O.mainData + 685 -- 2B BCD
-- (wWalkBikeSurfState) + 10 = 359, so 685 + 359 = 1044 as well.
O.townVisited = O.mainData + 1044 -- 2B (flag_array NUM_CITY_MAPS)
O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits)
-- Progress bits vanilla keeps OUTSIDE wEventFlags that this port still spells
-- as save.flags entries (#396). Offsets walk forward from wTownVisitedFlag
-- over the same ram/wram.asm declaration run the 60-byte gap above sums:
-- +29 wStatusFlags1, +35 wStatusFlags4, +41 wElite4Flags,
-- +44 wCompletedInGameTradeFlags.
O.statusFlags1 = O.townVisited + 29 -- 1B
O.statusFlags4 = O.townVisited + 35 -- 1B
O.elite4Flags = O.townVisited + 41 -- 1B
O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_NPC_TRADES)
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
@@ -314,6 +323,34 @@ local BADGE_BY_BIT = {
local BADGE_BY_BIT_SET = {}
for _, name in pairs(BADGE_BY_BIT) do BADGE_BY_BIT_SET[name] = true end
-- save.flags names whose vanilla home is NOT wEventFlags (#396: exporting a
-- save and importing it back made the Saffron gate guards thirsty again,
-- because BIT_GAVE_SAFFRON_GUARDS_DRINK is a wStatusFlags1 bit and nothing
-- carried it). Bit numbers are constants/ram_constants.asm; the trade bits
-- are wWhichTrade, which engine/events/in_game_trades.asm uses to index
-- wCompletedInGameTradeFlags, i.e. the data/events/trades.asm row order the
-- port's `trade` command takes 1-based.
local EXTRA_FLAG_BITS = {
EVENT_GOT_OLD_ROD = { O.statusFlags1, 3 },
EVENT_GOT_GOOD_ROD = { O.statusFlags1, 4 },
EVENT_GOT_SUPER_ROD = { O.statusFlags1, 5 },
EVENT_GAVE_GUARDS_DRINK = { O.statusFlags1, 6 },
EVENT_GOT_LAPRAS = { O.statusFlags4, 0 },
EVENT_STARTED_ELITE_4 = { O.elite4Flags, 1 },
EVENT_TRADED_NIDORINO_FOR_NIDORINA = { O.tradeFlags, 0 },
EVENT_TRADED_ABRA_FOR_MR_MIME = { O.tradeFlags, 1 },
EVENT_TRADED_PONYTA_FOR_SEEL = { O.tradeFlags, 3 },
EVENT_TRADED_SPEAROW_FOR_FARFETCHD = { O.tradeFlags, 4 },
EVENT_TRADED_SLOWBRO_FOR_LICKITUNG = { O.tradeFlags, 5 },
EVENT_TRADED_POLIWHIRL_FOR_JYNX = { O.tradeFlags, 6 },
EVENT_TRADED_RAICHU_FOR_ELECTRODE = { O.tradeFlags, 7 },
EVENT_TRADED_VENONAT_FOR_TANGELA = { O.tradeFlags, 8 },
EVENT_TRADED_NIDORAN_M_FOR_NIDORAN_F = { O.tradeFlags, 9 },
}
-- port-local name -> the wEventFlags name it means (#396)
local FLAG_ALIAS = { EVENT_RECEIVED_BIKE_VOUCHER = "EVENT_GOT_BIKE_VOUCHER" }
-- STATUS_* bits (constants/battle_constants.asm): 0-2 sleep-turns-left,
-- 3 PSN, 4 BRN, 5 FRZ, 6 PAR
local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 }
@@ -671,6 +708,14 @@ function GenSave.decode(bytes, data, opts)
end
end
-- the same progress under names that are not wEventFlags bits (#396)
for name, spec in pairs(EXTRA_FLAG_BITS) do
if bitGet(bytes, spec[1], spec[2]) then save.flags[name] = true end
end
for portName, vanillaName in pairs(FLAG_ALIAS) do
if save.flags[vanillaName] then save.flags[portName] = true end
end
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
-- into de and rotates it right one bit per iteration with b counting up
@@ -794,6 +839,19 @@ function GenSave.encode(save, data, template)
local bitIdx = events.byName[name]
if bitIdx then bitSet(buf, O.eventFlags, bitIdx, true) end
end
for portName, vanillaName in pairs(FLAG_ALIAS) do
local bitIdx = events.byName[vanillaName]
if bitIdx and save.flags[portName] then bitSet(buf, O.eventFlags, bitIdx, true) end
end
end
-- Non-wEventFlags progress, written both ways: this port's save is the only
-- authority for these names, so a flag it does not hold must clear the
-- template's bit rather than survive in the export (#396).
if save.flags then
for name, spec in pairs(EXTRA_FLAG_BITS) do
bitSet(buf, spec[1], spec[2], save.flags[name] and true or false)
end
end
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
+75 -26
View File
@@ -11,9 +11,14 @@
-- tables load through `require`, exactly how src/core/Data.lua pulls the
-- generated modules -- which resolves under both plain luajit (package.path
-- "./?.lua") for the headless CLI/tests and love.filesystem for a fused
-- build, with an OS-path fallback for odd working directories. The only
-- place `love` is referenced is inside a guarded fallback, so running under
-- stock Lua never touches it.
-- build, with an OS-path fallback for odd working directories. `love` is only
-- ever referenced inside guarded fallbacks, so running under stock Lua never
-- touches it.
--
-- When a caller names the game a save belongs to, the generated tables come
-- out of that version's ROM cache through CacheFs instead: the launcher does
-- its importing before the cache is mounted onto the un-prefixed paths, so
-- require alone cannot see them there (#420).
local GenSave = require("src.save_convert.GenSave")
@@ -63,20 +68,59 @@ local function loadTable(requirePath, filePath)
:format(requirePath, requirePath, filePath)
end
local crosswalk -- { pokemon=, moves=, items=, maps=, eventFlags= }
-- The four generated tables live in one game's ROM cache, and the launcher
-- reaches this code before that cache is on the un-prefixed read path:
-- CacheFs.mountVersion only runs from main.lua's bootGame (after Play), and
-- Blue/Yellow keep their cache under GameVersion.cachePrefix. So a bare
-- require sees Red's copy at best, and nothing at all in a fused portable
-- build (the game folder is only readable through CacheFs's PhysFS mount) --
-- read the tables out of the cache whenever the caller names the game the
-- save belongs to, and let the require path above cover everything else
-- (#420).
local function loadCacheTable(gameVersion, filePath)
if not (gameVersion and love and love.filesystem) then return nil end
if not filePath:match("^data/generated/") then return nil end
local okc, CacheFs = pcall(require, "src.import.CacheFs")
local okg, GameVersion = pcall(require, "src.core.GameVersion")
if not (okc and okg and type(CacheFs) == "table") then return nil end
local info = GameVersion.VERSIONS[gameVersion]
if not info then return nil end
-- CacheFs.prefix is launcher-owned global state (it points at whatever
-- import last ran), so borrow it for this read and put it back.
local saved = CacheFs.prefix
CacheFs.prefix = info.cachePrefix
local okr, bytes = pcall(CacheFs.read, filePath)
CacheFs.prefix = saved
if not (okr and type(bytes) == "string") then return nil end
local chunk = loadstring(bytes, "@" .. info.cachePrefix .. filePath)
if not chunk then return nil end
local okx, mod = pcall(chunk)
if okx and type(mod) == "table" then return mod end
return nil
end
-- Crosswalk sets keyed by the game whose cache they came from ("*" for the
-- require-resolved set): Yellow's tables are not Red's, so one import must
-- never be handed the previous import's data (#420).
local crosswalks = {} -- [key] = { pokemon=, moves=, items=, maps=, eventFlags= }
local charmapReady
local function ensureData()
if not crosswalk then
local function ensureData(gameVersion)
local key = gameVersion or "*"
if not crosswalks[key] then
local data = {}
for key, spec in pairs(DATA_MODULES) do
if key ~= "charmap" then
local mod, e = loadTable(spec[1], spec[2])
if not mod then return nil, e end
data[key] = mod
for name, spec in pairs(DATA_MODULES) do
if name ~= "charmap" then
local mod = loadCacheTable(gameVersion, spec[2])
if not mod then
local e
mod, e = loadTable(spec[1], spec[2])
if not mod then return nil, e end
end
data[name] = mod
end
end
crosswalk = data
crosswalks[key] = data
end
if not charmapReady then
local cm, err = loadTable(DATA_MODULES.charmap[1], DATA_MODULES.charmap[2])
@@ -84,13 +128,14 @@ local function ensureData()
GenSave.setCharmap(cm)
charmapReady = true
end
return crosswalk
return crosswalks[key]
end
-- Exposed for the CLI/tests so they can share the exact data set the codec
-- uses (and so a caller can pre-warm the cache). Returns data, err.
function SaveConvert.loadData()
return ensureData()
-- uses (and so a caller can pre-warm the cache). gameVersion picks whose ROM
-- cache the generated tables come from. Returns data, err.
function SaveConvert.loadData(gameVersion)
return ensureData(gameVersion)
end
-- ------------------------------------------------------------------
@@ -108,7 +153,8 @@ local function defaultsSave()
options = {
textSpeed = 3, animations = true, battleStyle = "shift",
battleLayout = "og",
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0,
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, pikaVol = 7,
musicFilter = 0,
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
videoMode = "windowed", mods = {},
},
@@ -138,20 +184,22 @@ SaveConvert.mergeDefaults = mergeDefaults
-- Public API
-- ------------------------------------------------------------------
-- importSav(bytes, version) -> saveTable, err
-- importSav(bytes, version, gameVersion) -> saveTable, err
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
-- checksum, decodes through GenSave, and returns a save table fully merged
-- over the new-game defaults and tagged with `version`, ready to hand to
-- SaveSerializer.encode for a slot file. On any failure returns nil + a
-- message (never raises).
function SaveConvert.importSav(bytes, version)
-- SaveSerializer.encode for a slot file. gameVersion ("red"/"blue"/"yellow")
-- names the game the save is being imported for, which is what selects the
-- crosswalk tables; omit it to take whatever `require` resolves. On any
-- failure returns nil + a message (never raises).
function SaveConvert.importSav(bytes, version, gameVersion)
if type(bytes) ~= "string" then
return nil, "expected raw save bytes as a string"
end
if #bytes ~= GenSave.SAVE_SIZE then
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
end
local data, derr = ensureData()
local data, derr = ensureData(gameVersion)
if not data then return nil, derr end
local ok, decoded = pcall(GenSave.decode, bytes, data)
@@ -170,16 +218,17 @@ function SaveConvert.importSav(bytes, version)
return mergeDefaults(decoded, version)
end
-- exportSav(saveTable) -> bytes, err
-- exportSav(saveTable, gameVersion) -> bytes, err
-- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware:
-- if the table still carries the stashed import template (saveTable.rawImport)
-- GenSave reproduces every unmodeled region from it; otherwise those regions
-- are zero-filled. On failure returns nil + a message (never raises).
function SaveConvert.exportSav(saveTable)
-- are zero-filled. gameVersion selects the crosswalk tables exactly as in
-- importSav. On failure returns nil + a message (never raises).
function SaveConvert.exportSav(saveTable, gameVersion)
if type(saveTable) ~= "table" then
return nil, "expected a save table"
end
local data, derr = ensureData()
local data, derr = ensureData(gameVersion)
if not data then return nil, derr end
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
if not ok then return nil, "encode failed: " .. tostring(bytes) end
+35 -7
View File
@@ -210,11 +210,12 @@ end
-- text (label or literal; {RAM:wStringBuffer} becomes the item name);
-- pass false when the script shows its own received-text row.
function Commands.give_item(ctx, itemId, count, gotText)
-- the 20-slot bag can refuse (BAG_ITEM_CAPACITY): say so and halt
-- the bag can refuse at its configured capacity (20 in vanilla): halt
-- the script, so later set_flag rows don't burn the gift -- make
-- room and talk again, like the original (pokered's `jr nc, .bag_full`
-- skips the received text entirely when AddItemToInventory refuses)
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
if not require("src.inventory.Bag").add(
ctx.save, itemId, count or 1, ctx.game.data) then
Commands.show_text(ctx, ctx.game.data.text
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
return math.huge
@@ -225,11 +226,23 @@ function Commands.give_item(ctx, itemId, count, gotText)
ctx.game.stringBuffer = def and def.name or itemId
-- the jingle rides the box -- Sound.play routes fanfares through
-- Music.duckForFanfare, like PlaySoundWaitForCurrent
require("src.core.Sound").play(ctx.game.data,
(def and def.keyItem) and "Get_Key_Item" or "Get_Item1")
local Sound = require("src.core.Sound")
local jingle = (def and def.keyItem) and "Get_Key_Item" or "Get_Item1"
if gotText ~= false then
-- the gift texts carry the jingle as a trailing text command
-- (sound_get_item_1 / sound_get_key_item -> home/text.asm
-- TextCommand_SOUND), so it only fires once the last page has typed
-- out, blocks on WaitForSoundToFinish, and AfterDisplayingTextID's
-- button wait runs after it (#374)
ctx.textOpts = ctx.textOpts or {}
ctx.textOpts.auto = {
sound = function() return Sound.play(ctx.game.data, jingle) end,
wait = true,
}
Commands.show_text(ctx, gotText
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
else
Sound.play(ctx.game.data, jingle)
end
end
@@ -253,7 +266,18 @@ function Commands.start_battle(ctx, kind, a, b)
ctx.lastBattleResult = result
ctx.lastCheck = result == "win"
if ctx.overworld then
ctx.overworld:afterBattle(result, battle)
-- A map script often follows a trainer battle with its own text.
-- Keep a level evolution behind that text: otherwise afterBattle
-- pushes the evolution screen, then this runner resumes and pushes
-- the trainer's text on top of it.
if result == "win" then
ctx.afterScript = ctx.afterScript or {}
table.insert(ctx.afterScript, function()
ctx.overworld:afterBattle(result, battle)
end)
else
ctx.overworld:afterBattle(result, battle)
end
end
runner:resume()
end
@@ -711,11 +735,15 @@ function Commands.record_hall_of_fame(ctx)
if game.overworld then
game.overworld.lastOutdoor = ctx.save.lastOutdoor
end
if game.writeSave then game:writeSave() end
local saveAllowed = true
if game.writeSave then saveAllowed = game:writeSave() ~= false end
-- writeSave's captureSave re-stamps the live HALL_OF_FAME coords;
-- re-apply home and persist so CONTINUE resumes in the bedroom.
SaveData.applyPostGameHome(ctx.save, boot)
SaveData.save(ctx.save)
-- A tool session may veto Game:writeSave to keep its in-memory run
-- isolated from the player's real save. The relocation write is part
-- of that same save operation and must honor the same decision.
if saveAllowed then SaveData.save(ctx.save) end
end)
end)
runner:yield()
+4
View File
@@ -125,6 +125,10 @@ function ScriptRunner:run(script, extra)
end
self.co = coroutine.create(function()
self:exec(script, ctx)
-- Commands can defer a game action until the script's own dialogue is
-- done. start_battle uses this for win-path evolutions, which must not
-- be covered by post-battle trainer text.
for _, callback in ipairs(ctx.afterScript or {}) do callback() end
if ctx.onDone then ctx.onDone() end
if Runtime.wants("script.ended") then
Runtime.emit("script.ended", { ctx = ctx, completed = true })
+16 -3
View File
@@ -93,6 +93,17 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
end
if result == "bicycle" then
-- StartMenu_Item .useOrTossItem (engine/menus/start_sub_menus.asm):
-- while BIT_ALWAYS_ON_BIKE of wStatusFlags6 is set -- the Cycling Road,
-- armed by the forced-bike tiles and cleared by the Route 16/18 gate
-- scripts -- the BICYCLE refuses with _CannotGetOffHereText and jumps
-- back to ItemMenuLoop, so the bag stays open and no dismount happens
-- (#513). The gate sits ahead of UseItem, before ItemUseBicycle ever
-- runs, which is why it precedes list:close() here.
if game.save.forcedBike then
showMessages(game, { Strings("You can't get off\nhere.") })
return
end
list:close()
local ow = game.overworld
local Music = require("src.core.Music")
@@ -305,9 +316,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
-- HP medicine: fill the bar in the still-open picker first, then print
-- and close, the order item_effects.asm .doneHealing runs in
-- (SFX_HEAL_HP -> UpdateHPBar2 -> RedrawPartyMenu prints the message).
-- picker is nil for every other item and for in-battle use, which keeps
-- the pop-then-print path below. #252
if picker and extra and extra.healedFrom and target then
-- Only a keepOpen picker is still on the stack to animate: every other
-- item, and every in-battle use, popped it in PartyMenu before onSwitch,
-- and takes the pop-then-print path below -- which is the path that
-- spends the battle turn. #252, #379
if picker and picker.keepOpen and extra and extra.healedFrom and target then
picker:animateTo(target, extra.healedFrom, function()
showMessages(game, payload, closePicker)
end)
+174 -30
View File
@@ -6,6 +6,7 @@
local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu")
local ChoiceBox = require("src.ui.ChoiceBox")
local Input = require("src.core.Input")
local Strings = require("src.core.Strings")
@@ -13,16 +14,18 @@ local BindingsMenu = setmetatable({}, { __index = ListMenu })
BindingsMenu.__index = BindingsMenu
-- Input.lua's map, primary key first where several keys share a button.
-- `pad` is the default SDL gamecontroller button (see Input.lua); shown
-- on the SELECT row so controller Back/View is discoverable (#73).
-- `pad` mirrors DEFAULT_GAMEPAD_BINDINGS in src/core/Input.lua row for
-- row; keep the two in sync. Every row shows its key and pad so the
-- controller side is discoverable (#73, #589), and the swap in
-- storeBinding leans on each row holding a value in both slots.
local BUTTONS = {
{ id = "up", label = "UP", key = "up" },
{ id = "down", label = "DOWN", key = "down" },
{ id = "left", label = "LEFT", key = "left" },
{ id = "right", label = "RIGHT", key = "right" },
{ id = "a", label = "A", key = "z" },
{ id = "b", label = "B", key = "x" },
{ id = "start", label = "START", key = "escape" },
{ id = "up", label = "UP", key = "up", pad = "dpup" },
{ id = "down", label = "DOWN", key = "down", pad = "dpdown" },
{ id = "left", label = "LEFT", key = "left", pad = "dpleft" },
{ id = "right", label = "RIGHT", key = "right", pad = "dpright" },
{ id = "a", label = "A", key = "z", pad = "a" },
{ id = "b", label = "B", key = "x", pad = "b" },
{ id = "start", label = "START", key = "escape", pad = "start" },
{ id = "select", label = "SELECT", key = "tab", pad = "back" },
}
@@ -41,14 +44,33 @@ local function boundPad(overlay, def)
return def.pad
end
-- Key column for every row. SELECT also appends "/PAD" (default BACK)
-- so controller Select/View is visible without opening a second legend.
-- The right column is KEY/PAD (e.g. "Z/A"). The row is 20 tiles and the
-- widest label ("SELECT") ends at x=64, so each half is clamped to 5
-- glyphs: 5+1+5 right-aligned at x=152 starts no further left than x=64.
-- SDL names longer than 5 get a fixed short form before the clamp.
local KEY_SHORT = {
escape = "ESC", backspace = "BKSP", ["return"] = "ENTER",
kpenter = "ENTER", space = "SPACE",
}
local PAD_SHORT = {
dpup = "D-UP", dpdown = "D-DN", dpleft = "D-LT", dpright = "D-RT",
leftshoulder = "LB", rightshoulder = "RB",
leftstick = "LS", rightstick = "RS", guide = "GUIDE",
}
local function shortName(name, shorts)
local s = shorts[name]
if s then return s end
s = name:upper()
return #s > 5 and s:sub(1, 5) or s
end
-- Right column for every row: effective key and pad together, so a
-- controller player can read the whole map without a second legend (#589).
local function boundRight(overlay, def)
local key = boundKey(overlay, def)
if def.id ~= "select" then return key:upper() end
local key = shortName(boundKey(overlay, def), KEY_SHORT)
local pad = boundPad(overlay, def)
if pad then return (key .. "/" .. pad):upper() end
return key:upper()
if pad then return key .. "/" .. shortName(pad, PAD_SHORT) end
return key
end
function BindingsMenu.new(game)
@@ -61,37 +83,122 @@ function BindingsMenu.new(game)
items[i] = { label = Strings(def.label),
right = boundRight(overlay, def), button = def }
end
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
BindingsMenu)
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {
-- 6 rows leaves the bottom two lines free for the hint; a clear or
-- reset nobody can see on screen may as well not exist (#589)
rows = 6,
footer = Strings("SELECT:CLEAR ROW\nSTART:RESET ALL"),
}), BindingsMenu)
self.onChoose = function(item) self:beginCapture(item) end
-- SELECT deletes one row's rebind: dropping the overlay entry is enough
-- because Input:applyBindings rebuilds the whole map from the defaults
-- on every call (#589)
self.onSelectKey = function(item) self:clearBinding(item) end
-- A rebind reaches Input only when this screen closes (#510). The menu
-- steers by the live map, so applying "B = Z" the instant it was captured
-- turned the player's next confirm press into a cancel and shut the
-- screen mid-swap. options.bindings is still written immediately, and
-- Game:applyOptions re-applies it on load, so a close that skips this
-- hook still ends up with the saved map.
self.onCancel = function() self:commitBindings() end
return self
end
-- the capture handlers are per-instance slots, so Game's raw-input
-- routing only ever sees this screen while a capture is armed
function BindingsMenu:beginCapture(item)
self.capture = item
self.onKeyPressed = BindingsMenu.captureKey
self.onGamepadPressed = BindingsMenu.capturePad
-- Cross-file contract with src/core/Input.lua: the saved overlay reaches
-- the live map here, on close, and nowhere else in this screen.
function BindingsMenu:commitBindings()
local game = self.game
local opts = game and game.save and game.save.options
if opts then Input:applyBindings(opts.bindings) end
end
-- The capture handlers are per-instance slots, so Game's raw-input
-- routing only ever sees this screen while a capture is armed. A capture
-- no longer commits on the press: it commits when that press is RELEASED,
-- and a second key or pad button going down while the first is still held
-- cancels instead. That gives a bare controller a way to back out of an
-- armed row, where Escape cannot help (#589).
function BindingsMenu:beginCapture(item)
self.capture = item
self.pending = nil
self.onKeyPressed = BindingsMenu.captureKey
self.onGamepadPressed = BindingsMenu.capturePad
self.onKeyReleased = BindingsMenu.captureKeyRelease
self.onGamepadReleased = BindingsMenu.capturePadRelease
end
function BindingsMenu:endCapture()
self.capture = nil
self.pending = nil
self.onKeyPressed = nil
self.onGamepadPressed = nil
self.onKeyReleased = nil
self.onGamepadReleased = nil
end
-- Escape is the capture's way out, so it is never captured: every other
-- key is bindable, which otherwise leaves an armed row with no exit but
-- to bind something (#510). Escape stays START in Input's default map,
-- which no rebind removes, so reserving it costs the player nothing.
function BindingsMenu:captureKey(key)
self:storeBinding("key", key)
if key == "escape" or self.pending then return self:endCapture() end
self.pending = { slot = "key", value = key }
end
function BindingsMenu:capturePad(button)
self:storeBinding("pad", button)
if self.pending then return self:endCapture() end
self.pending = { slot = "pad", value = button }
end
-- Game forwards every release to Input BEFORE these hooks (see
-- Game:keyreleased): the capture observes releases, it never owns them,
-- so Input's held-state stays honest for keys it saw go down before the
-- capture armed. A release that does not match the pending input (the
-- press that armed the row, a cancelled capture's stragglers) is noise.
function BindingsMenu:captureKeyRelease(key)
local p = self.pending
if p and p.slot == "key" and p.value == key then
self:storeBinding("key", key)
end
end
function BindingsMenu:capturePadRelease(button)
local p = self.pending
if p and p.slot == "pad" and p.value == button then
self:storeBinding("pad", button)
end
end
function BindingsMenu:storeBinding(slot, value)
local item = self.capture
self.capture = nil
self.onKeyPressed = nil
self.onGamepadPressed = nil
self:endCapture()
local game = self.game
if not (item and value and game.save and game.save.options) then return end
local opts = game.save.options
opts.bindings = opts.bindings or {}
-- Swap, never steal (#589): when the captured input is another row's
-- effective binding in this slot, that row inherits this row's previous
-- binding. Every BUTTONS row has a default in both slots, so `prev`
-- always exists: no row goes empty and no input serves two rows.
-- Default key ALIASES (W beside Up, Space beside Z; DEFAULT_BINDINGS in
-- Input.lua) are not effective bindings, so capturing one costs the
-- other row a spare alias, never its shown key.
local effective = (slot == "key") and boundKey or boundPad
local prev = effective(opts.bindings, item.button)
if value ~= prev then
for _, other in ipairs(self.items) do
if other ~= item and effective(opts.bindings, other.button) == value then
local ob = opts.bindings[other.button.id]
if type(ob) ~= "table" then
ob = { key = type(ob) == "string" and ob or nil }
end
ob[slot] = prev
opts.bindings[other.button.id] = ob
other.right = boundRight(opts.bindings, other.button)
break
end
end
end
local b = opts.bindings[item.button.id]
if type(b) ~= "table" then
-- keep a direct-edited plain key string when only the pad changes
@@ -100,21 +207,58 @@ function BindingsMenu:storeBinding(slot, value)
b[slot] = value
opts.bindings[item.button.id] = b
item.right = boundRight(opts.bindings, item.button)
Input:applyBindings(opts.bindings)
if game.writeOptions then game:writeOptions() end
end
-- SELECT: forget one row's rebind and fall back to the defaults. #510's
-- deferral still holds: only options change here, the live map catches up
-- in commitBindings on close.
function BindingsMenu:clearBinding(item)
local game = self.game
local opts = game and game.save and game.save.options
if not (opts and opts.bindings and opts.bindings[item.button.id]) then
return
end
opts.bindings[item.button.id] = nil
item.right = boundRight(opts.bindings, item.button)
if game.writeOptions then game:writeOptions() end
end
-- START: confirm, then drop the whole overlay (#589). The footer doubles
-- as the prompt while the YES/NO box is up, the same bottom-line pattern
-- the mart and PC screens use.
function BindingsMenu:confirmReset()
local game = self.game
local hint = self.footer
self.footer = Strings("RESET ALL BINDINGS?")
game.stack:push(ChoiceBox.new(game, function(yes)
self.footer = hint
if not yes then return end
local opts = game.save and game.save.options
if opts then opts.bindings = nil end
for _, it in ipairs(self.items) do
it.right = boundRight(nil, it.button)
end
if game.writeOptions then game:writeOptions() end
end, { defaultNo = true }))
end
function BindingsMenu:update(dt)
if self.capture then return end -- the raw capture owns the input
if self.game.input:wasPressed("start") then
return self:confirmReset()
end
ListMenu.update(self, dt)
end
function BindingsMenu:draw()
ListMenu.draw(self)
if self.capture then
Font.drawBox(1, 6, 18, 4)
Font.drawBox(1, 6, 18, 6)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("PRESS A BUTTON"), 24, 60)
Font.draw(Strings("RELEASE TO SET"), 24, 72)
Font.draw(Strings("ESC/2ND CANCELS"), 24, 84)
love.graphics.setColor(1, 1, 1, 1)
end
end
+4
View File
@@ -66,6 +66,7 @@ local function withdraw(game)
end
game.stack:push(ListMenu.new(game,
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
local mon = box[item.value]
if not mon then return end
@@ -112,6 +113,7 @@ local function deposit(game)
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
local mon = game.save.party[item.value]
if not mon then return end
@@ -157,6 +159,7 @@ local function release(game)
end
game.stack:push(ListMenu.new(game,
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(_, list)
local mon = box[list.index]
if not mon then return end
@@ -189,6 +192,7 @@ local function changeBox(game)
})
end
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
-- the original asks BEFORE switching ("When you change a #MON
-- BOX, data will be saved. OK?"); declining aborts the change

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