Compare commits

..

102 Commits

Author SHA1 Message Date
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
bryanthaboi 97e3fb296c Update tool_mod_hooks.lua 2026-07-31 10:45:12 -04: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
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 25d241f12c Merge pull request #456 from johnjohto/fix-yellow-npc-trades
Fix Yellow NPC trades
2026-07-30 11:26:56 -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 b9a89c1fd2 Merge pull request #434 from johnjohto/fuchsia-swamp-trainer-support-upstream
Support custom trainer palettes and base portraits
2026-07-30 11:23:56 -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
johnjohto 7b208043b9 Fix Yellow NPC trades 2026-07-30 10:17:30 -04:00
johnjohto 26dfad0682 Merge remote-tracking branch 'upstream/main' 2026-07-30 10:13:03 -04: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
johnjohto 17eee1ca0e Test custom trainer portrait support 2026-07-30 00:00:36 -04:00
johnjohto 7aa06942be Support custom trainer palettes and base portraits 2026-07-29 23:59:59 -04:00
johnjohto d3010dc060 Merge pull request #1 from johnjohto/fuchsia-swamp-trainer-support
Support custom trainer palettes and base portraits
2026-07-29 23:52:41 -04:00
johnjohto 53e1a535dd Support custom trainer palettes and base portraits 2026-07-29 23:51:10 -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
bryanthaboi 5c0a7d1456 Merge pull request #422 from bryanthaboi/dev
big android energy
2026-07-29 16:07:31 -04:00
bryanthaboi e10f6e2032 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-07-29 16:01:06 -04:00
bryanthaboi c61643edaa big android energy 2026-07-29 16:01:04 -04:00
bryanthaboi 0b7b0b48d1 Add Kotaku badge to README 2026-07-29 15:33:43 -04:00
bryanthaboi 2ff459e2c4 Update README to include Poke Yellow 2026-07-29 15:17:24 -04:00
bryanthaboi 8c877a64f0 Merge pull request #421 from bryanthaboi/dev
yellow fixes and othe random shit
2026-07-29 15:11:17 -04:00
bryanthaboi 8fdfd5c843 Merge pull request #416 from johnjohto/fix-true-color-dex-intro
fix true color UI Pokemon sprites

Two conflicts against dev, both structural rather than behavioural:

DexEntryMenu.render became a static function on dev so the printer could
share the entry page; the branch still marked true color from the method
form.  Threaded the flag through as a render() parameter.  The printer
caller leaves it nil on purpose -- it renders to its own PNG canvas, and a
mark left there would bleed into the next real frame.

TitleState moved the cycling mon and Red's OAM into the non-Yellow branch
on dev (title_yellow.asm has neither); the branch still drew them at top
level.  Kept dev's structure and folded the true color marking into it.
2026-07-29 15:10:03 -04:00
bryanthaboi 579c1744bd Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-07-29 15:06:37 -04:00
bryanthaboi 118c45f8f6 CLOSES #407, CLOSES #408, CLOSES #409, CLOSES #410, CLOSES #411, CLOSES #417
CLOSES #407, CLOSES #408, CLOSES #409, CLOSES #410, CLOSES #411, CLOSES #417
2026-07-29 15:06:33 -04:00
bryanthaboi beaa502ff9 Merge pull request #419 from johnjohto/fix-exported-save-layout
keep imported save cache on export
2026-07-29 14:20:05 -04:00
bryanthaboi 730b7b842c Add video update section to README
Added a section to watch the latest update video.
2026-07-29 14:16:56 -04:00
bryanthaboi 784832ca95 Revise README for support and announcements
Updated support section with new announcements and logos.
2026-07-29 14:09:27 -04:00
johnjohto bbe894fb4f keep imported save cache on export
Red restores the current-map cache from SRAM before Continue. Dropping the import template made a later export boot with zeroed map data.
2026-07-29 14:01:19 -04:00
johnjohto 4c17e606fd fix true color UI Pokemon sprites
The UI drew sprite paths but discarded their trueColor flag, so the SGB palette pass recolored full-color Pokemon.
2026-07-29 13:31:50 -04:00
bryanthaboi 73d81a72b8 Merge pull request #412 from johnjohto/fix-wide-option-battle-screens
Keep wide layout through battle menus
2026-07-29 13:26:54 -04:00
johnjohto 8b0f770c57 Keep wide layout through battle menus 2026-07-29 13:14:56 -04:00
bryanthaboi a89d43f315 Merge pull request #405 from bryanthaboi/dev
Update build.sh
2026-07-29 12:17:11 -04:00
bryanthaboi 96a64d439e Update build.sh 2026-07-29 12:15:56 -04:00
bryanthaboi 401f79877c Merge pull request #404 from bryanthaboi/dev
big freaking (yellow) push
2026-07-29 12:02:46 -04:00
bryanthaboi c52a35288a Merge pull request #369 from pmarcus93/launcher-page-scroll
Scroll the launcher page so the footer stops covering the panel
2026-07-29 11:57:47 -04:00
bryanthaboi a37d1a0676 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-07-29 11:57:39 -04:00
bryanthaboi 0e56e0a539 Update release.yml 2026-07-29 11:57:34 -04:00
Marcus Pereira cc67be341f Scroll the launcher page so the footer stops covering the panel
On a window too short for the stacked single-column layout -- a phone, or a
narrow desktop window -- the ROM / SAVE FILES / Play / SAVE SLOT stack ran
past the bottom of the window while the footer stayed pinned there and drew
over it. Nothing clipped the panel and nothing scrolled it, so the overflow
was unreachable.

Everything under the tab bar (panel, updater banner, footer) is now one
scrolling column, used only when it is taller than the room below the tab
bar. The strip, logo and tab bar stay pinned, so navigation is always on
screen, and the footer is laid out downward from footerTop right after the
content instead of upward from the window bottom.

- RomImporter.pageScrollFor is the whole decision, pure and covered by
  tests/engine/launcher_page_scroll.lua. A window that grows back drags the
  offset down with it, so the page never stays parked past its own end.
- The panels return their natural height as they draw, so the measurement is
  the previous frame's: the same one-frame settle the slot and mod lists
  already rely on.
- One scroll axis at a time. While the page scrolls, the panels draw paged:
  the slot and mod lists take their natural height, keep no inner scroll
  region and report a max of 0, so wheel, right stick and drag all move the
  page. Two-column layouts do not overflow, paged stays false, and every one
  of these behaves exactly as before.
- inside() and _ptIn() reject a rect that scrolled out of the viewport, so a
  control that slid under the tab bar cannot be clicked through it. Tab chips
  are pinned and exempt. pageScroll resets on a tab change.

Android had no scroll gesture at all: the launcher is handed no move events
(main.lua forwards neither touchmoved nor mousemoved while it is up) and its
mouse emulation was never trusted, which is what "no reliable pointer
polling" referred to. That was survivable while every scroll region was an
inner list, and useless once the page itself scrolls, since a phone is
exactly where it overflows. love.touch is pollable, so _pointerHold reads the
first active touch there and hands _updateSlotDrag the same (held, y) pair
the mouse gives on desktop. Slot rows and mod toggles consequently arm on
press and commit on release on Android too, matching desktop, so a swipe that
starts on a card scrolls instead of selecting it. All of it is gated on
touchPollable: without love.touch every Android path is exactly what it was.

conf.lua also grows minwidth/minheight (480x360) for the desktop window,
under which the cards stop being readable. Mobile is fullscreen and ignores
it.

Verified on the Android emulator (1080x2400) against a build of the parent
commit: before, the footer painted over the SAVE SLOT card with no way to
reach it; after, the page pans by touch and the footer is reachable and
intact.
2026-07-29 12:53:24 -03:00
bryanthaboi ff829569b0 Merge pull request #364 from johnjohto/mods-stray-folder-notice
Adopt mods dropped beside the game instead of ignoring them
2026-07-29 11:50:14 -04:00
bryanthaboi bc4afcba71 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-07-29 11:46:36 -04:00
bryanthaboi dde25ec7d0 yellow alpha 2026-07-29 11:46:32 -04:00
bryanthaboi 9eb80575e5 Merge pull request #400 from johnjohto/fix-ss-anne-snorlax-dex
Show Snorlax's Pokédex entry
2026-07-29 11:26:31 -04:00
bryanthaboi 590e082328 Merge pull request #401 from johnjohto/fix-oak-aide-repeat-dialog
Repeat Oak's Aide item explanations
2026-07-29 11:26:16 -04:00
johnjohto 2f52a0651a Repeat Oak's Aide item explanations 2026-07-29 10:23:39 -04:00
johnjohto e393ec06e3 Show Snorlax's Pokédex entry 2026-07-29 10:17:02 -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
johnjohto 0dddb32305 Adopt mods dropped beside the game instead of ignoring them
love.filesystem looks for "mods/" in two places: the save directory, and
-- portable installs only -- the game folder, which CacheFs mounts. So a
player who unzips a mod next to the executable of an ordinary install,
which is where very nearly every other game would want it, gets no error
and no mod. The panel just comes up empty, with nothing on screen to
suggest the files are sitting in the wrong folder twenty centimetres away.

That is a hard failure to self-diagnose, and it is worse behind a
launcher: the install lives somewhere the player never opens, so "the
game's mods folder" is a guess to begin with.

The mods panel now looks in those folders before its first listing and
copies what it finds into the tree the game really reads, reporting what
it took in the notice line. It happens on open rather than behind a
button because the failure being fixed is one where nothing suggests
there is anything to press.

Looking is scoped: CacheFs.withMounted puts the folder on the read path
at its own mount point, runs the scan, and takes it straight back off.
Nothing a stray folder contains can shadow a game file or change what the
running game resolves, which is what makes it safe to point at a folder
whose contents nobody has validated. Adoption skips ids the game can
already see, so it is idempotent and never nags twice, and it leaves the
loose folder alone -- deleting files outside the save directory on the
player's behalf is not this code's call to make.

Which strays are worth taking is pure (LauncherMods.pickStrays), matching
how deriveList and locateRoot are already split out, so the engine tier
covers the rules without needing love. SaveData.gameFolders is the old
detectPortable candidate list lifted out unchanged -- portable mode is
just the case where one of those folders holds the marker.

Claude-Session: https://claude.ai/code/session_01JvEthuoNBPfxpvHUD9Pd4N
2026-07-28 21:34:09 -04:00
bryanthaboi f3165ceb71 Merge pull request #361 from bryanthaboi/dev
tiled stuff
2026-07-28 20:58:16 -04:00
bryanthaboi d6e36d457f Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-07-28 20:32:30 -04:00
bryanthaboi 52e3a0d23f update for tiled stuff 2026-07-28 20:32:29 -04: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
bryanthaboi f1950348d4 Merge pull request #344 from bryanthaboi/dev
bazinga linux
2026-07-28 15:39:59 -04:00
bryanthaboi 5603477787 Merge pull request #343 from GFlorio/appimage-fixes
Fix ROM picker + Auto Updater in the Linux Appimage release
2026-07-28 15:36:54 -04:00
Gabriel Florio 6a9e77cde0 Fix app crashing on self-restart in the AppImage release 2026-07-28 16:02:20 -03:00
Gabriel Florio 4943d3e5f4 Unset LD_LIBRARY_PATH when calling system tools from AppImage 2026-07-28 14:42:45 -03:00
236 changed files with 80435 additions and 1861 deletions
+34
View File
@@ -18,6 +18,19 @@ body:
validations:
required: true
- type: dropdown
id: game
attributes:
label: Which game were you playing
description: Pick every version you saw the bug in.
multiple: true
options:
- Red
- Blue
- Yellow
validations:
required: true
- type: dropdown
id: os
attributes:
@@ -27,10 +40,31 @@ body:
- Windows
- Linux
- Android
- iOS
- Multiple platforms
validations:
required: true
- type: dropdown
id: mods_enabled
attributes:
label: Were any mods on
description: Check the MODS tab in the launcher if you're not sure.
options:
- "No"
- "Yes"
validations:
required: true
- type: input
id: mods_which
attributes:
label: Which mods (if any were on)
description: List the enabled mods. Leave blank if none were on.
placeholder: nuzlocke 1.0.0, running-shoes 0.3
validations:
required: false
- type: input
id: version
attributes:
@@ -26,6 +26,20 @@ body:
validations:
required: true
- type: dropdown
id: game
attributes:
label: Which game is this about
description: Pick every version it applies to.
multiple: true
options:
- Red
- Blue
- Yellow
- Not version-specific
validations:
required: true
- type: input
id: discord
attributes:
+14
View File
@@ -23,6 +23,20 @@ body:
validations:
required: true
- type: dropdown
id: game
attributes:
label: Which game is this for
description: Pick every version the mod should cover.
multiple: true
options:
- Red
- Blue
- Yellow
- Not version-specific
validations:
required: true
- type: input
id: discord
attributes:
+77 -16
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.
@@ -170,6 +170,15 @@ jobs:
set -euo pipefail
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
- 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 +235,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"
@@ -254,19 +267,37 @@ jobs:
v="${{ steps.ver.outputs.version }}"
tag="${{ steps.ver.outputs.tag }}"
# Issues this release closes, per GitHub's own "closing issues" links
# (works for squash, rebase, and merge commits alike). Scans every
# commit since the previous tag so a skipped release run doesn't drop
# issues on the floor.
# Issues this release closes. Three sources, deduped by number:
# 1. GitHub's own "closing issues" links on every PR whose
# commits are in the range (works for squash, rebase, and
# merge commits alike) -- including PRs that were merged
# into a branch this release PR is itself merging in.
# 2. CLOSES/fixes/resolves text in those PRs' titles + bodies
# that GitHub didn't turn into a closing link (a PR into a
# non-default branch never gets one).
# 3. The same text in raw commit messages, so a direct push
# with "CLOSES #N #M" still lands in the notes.
# Scans every commit since the previous tag so a skipped release
# run doesn't drop issues on the floor.
prev_tag="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${tag}$" | head -1 || true)"
range="${prev_tag:+${prev_tag}..}$GITHUB_SHA"
closed=""
for pr in $(git log --pretty=%H "$range" \
| xargs -I{} gh api "repos/$GITHUB_REPOSITORY/commits/{}/pulls" \
--jq '.[].number' 2>/dev/null \
| sort -un || true); do
closed+="$(gh api graphql \
# every #N on a line that carries a closing keyword (handles the
# multi-issue "CLOSES #1 #2 #3" form the tracker uses)
scan_closes() {
grep -iE '\b(close[sd]?|fix(es|ed)?|resolve[sd]?)\b' \
| grep -oE '#[0-9]+' | tr -d '#' || true
}
nums=""
nums+=" $(git log --pretty=%B "$range" | scan_closes | tr '\n' ' ')"
prs="$(git log --pretty=%H "$range" \
| xargs -I{} gh api "repos/$GITHUB_REPOSITORY/commits/{}/pulls" \
--jq '.[].number' 2>/dev/null \
| sort -un || true)"
for pr in $prs; do
nums+=" $(gh api graphql \
-f owner="${GITHUB_REPOSITORY%/*}" \
-f name="${GITHUB_REPOSITORY#*/}" \
-F pr="$pr" \
@@ -274,19 +305,48 @@ jobs:
query($owner:String!, $name:String!, $pr:Int!) {
repository(owner:$owner, name:$name) {
pullRequest(number:$pr) {
closingIssuesReferences(first:50) { nodes { number title } }
closingIssuesReferences(first:50) { nodes { number } }
}
}
}' \
--jq '.data.repository.pullRequest.closingIssuesReferences.nodes[]
| "- #\(.number) \(.title)"' 2>/dev/null || true)"$'\n'
--jq '.data.repository.pullRequest.closingIssuesReferences.nodes[].number' \
2>/dev/null | grep -E '^[0-9]+$' | tr '\n' ' ' || true)"
nums+=" $(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr" \
--jq '.title + " " + (.body // "")' 2>/dev/null \
| scan_closes | tr '\n' ' ' || true)"
done
# dedupe, drop anything that is a PR or does not exist, keep
# titles (gh api prints the response body to stdout on an HTTP
# error, so only a zero exit counts)
closed=""
for n in $(printf '%s' "$nums" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -un); do
if title="$(gh api "repos/$GITHUB_REPOSITORY/issues/$n" \
--jq 'if .pull_request then empty else .title end' \
2>/dev/null)"; then
[ -n "$title" ] && closed+="- #$n $title"$'\n'
fi
done
closed="$(printf '%s' "$closed" | grep . | sort -t'#' -k2 -n || true)"
# Everyone whose commits are in the range: GitHub login when the
# commit is linked to an account, the raw git author name when not;
# CI bots filtered out.
base="${prev_tag:-$(git rev-list --max-parents=0 "$GITHUB_SHA" | tail -1)}"
contributors="$(gh api --paginate \
"repos/$GITHUB_REPOSITORY/compare/${base}...${GITHUB_SHA}" \
--jq '.commits[] | if .author and .author.login
then "@" + .author.login
else .commit.author.name end' 2>/dev/null \
| grep -viE '\[bot\]$' | sort -uf | sed 's/^/- /' || true)"
notes="Download the correct version for your computer below."
if [ -n "$closed" ]; then
notes+=$'\n\n## Issues closed\n\n'"$closed"
fi
if [ -n "$contributors" ]; then
notes+=$'\n\n## Contributors\n\n'"$contributors"
fi
printf 'Release notes:\n%s\n' "$notes"
gh release create "$tag" \
@@ -297,6 +357,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"
+7
View File
@@ -35,3 +35,10 @@ mobile/ios/build/
# Legacy manual convenience-copy location (superseded by /dist/android/)
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
+7
View File
@@ -0,0 +1,7 @@
Copyright 2026 BOIS CLUB GAMES, LLC
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+72 -14
View File
@@ -1,31 +1,75 @@
# Gen1Recomp
A native LÖVE2D recreation of Poke Red and Blue. The engine and map
A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map
behavior are hand-written Lua; game data and graphics are decoded from a ROM
supplied by the player.
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
<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/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
@@ -98,11 +142,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
```
@@ -133,6 +177,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
@@ -150,6 +201,11 @@ reference — lives on the
Shipped example mods, one per kind of author, live in `[mods/](mods/)`.
Maps can be edited in our own build of [Tiled](https://www.mapeditor.org),
[bryanthaboi/tiled_gen1recomp](https://github.com/bryanthaboi/tiled_gen1recomp/releases),
and exported back out as a mod; see
[docs/tiled-map-editing.md](docs/tiled-map-editing.md).
## Bugs and Ideas
Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math
@@ -180,4 +236,6 @@ request with real detail is one that can actually get built.
This project would not be possible without [pret](https://github.com/pret) >
the pret band of decompiling maniacs > and their
[pokered](https://github.com/pret/pokered) disassembly.
[pokered](https://github.com/pret/pokered) disassembly.
<p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p>
+20 -1
View File
@@ -35,6 +35,14 @@ function love.conf(t)
-- starting size, not the game's resolution.
t.window.width = 1024
t.window.height = 768
-- Floor for the resizable desktop window. The launcher's single-column
-- layout is laid out against ~420 logical px of content, and the game
-- canvas letterboxes fine below that, so this only stops a drag that would
-- squeeze the cards past the point where their buttons still read. Kept
-- well under the smallest supported desktop display; mobile ignores it
-- (fullscreen), so the handheld ports are unaffected.
t.window.minwidth = 480
t.window.minheight = 360
end
t.version = "11.5"
t.window.vsync = 1
@@ -54,7 +62,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
@@ -70,6 +81,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
},
+79 -24
View File
@@ -1,33 +1,88 @@
-- Celadon Mansion 3F Game Designer (pokered/scripts/CeladonMansion3F.asm
-- CeladonMansion3FGameDesignerText): text_asm counts set bits in
-- wPokedexOwned and compares against NUM_POKEMON - 1 (discounts Mew).
-- If the player owns >= 150 species, shows the "completed" text
-- (originally followed by DisplayDiploma, which has no equivalent UI
-- in this port, so we just show the congratulatory text); otherwise
-- shows the normal encouragement text.
-- Celadon Mansion 3F, the GAME FREAK dev floor
-- (pokered+pokeyellow/scripts/CeladonMansion3F.asm). Every dev's
-- text_asm counts set bits in wPokedexOwned against NUM_POKEMON - 1
-- (150, discounting Mew). The game designer shows the diploma
-- (DisplayDiploma -> src/ui/Diploma.lua) on a completed dex; Yellow's
-- graphic artist then offers the printed copy (PrintDiploma, stood in
-- by src/core/Printer.lua's PNG export like the Pokédex PRNT item).
local function ownedCount(game)
local dex = game.save.pokedex
local owned = 0
if dex and dex.owned then
for _ in pairs(dex.owned) do owned = owned + 1 end
end
return owned
end
return {
CELADON_MANSION_3F = {
talk = {
TEXT_CELADONMANSION3F_GAME_DESIGNER = function(game, ow, npc, done)
local t = game.data.text
local dex = game.save.pokedex
local owned = 0
if dex and dex.owned then
for _ in pairs(dex.owned) do
owned = owned + 1
end
end
-- NUM_POKEMON - 1 = 150 (discounts Mew, per pokered)
local label, fallback
if owned >= 150 then
label, fallback = "_CeladonMansion3FGameDesignerCompletedDexText",
"Wow! Excellent!\nYou completed\nyour POKeDEX!\nCongratulations!"
else
label, fallback = "_CeladonMansion3FGameDesignerText",
"Is that right?\nI'm the game\ndesigner!\nFilling up your\nPOKeDEX is tough,\nbut don't quit!\nWhen you finish,\ncome tell me!"
end
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, t[label] or fallback, done))
if ownedCount(game) < 150 then
game.stack:push(TextBox.new(game,
t._CeladonMansion3FGameDesignerText
or "Is that right?\nI'm the game\ndesigner!\fFilling up your\nPOKéDEX is tough,\nbut don't quit!",
done))
return
end
game.stack:push(TextBox.new(game,
t._CeladonMansion3FGameDesignerCompletedDexText
or "Wow! Excellent!\nYou completed\nyour POKéDEX!\nCongratulations!",
function()
local Diploma = require("src.ui.Diploma")
game.stack:push(Diploma.new(game, function()
-- Yellow tags on the unlocked-printing line (CompletedDexText2)
local after = require("src.core.GameVersion").isYellow()
and (t._CeladonMansion3FGameDesignerCompletedDexText2
or "You can print out\nyour diploma with\nthe GAME BOY\nPrinter!")
or nil
if after then
game.stack:push(TextBox.new(game, after, done))
else
done()
end
end))
end))
end,
TEXT_CELADONMANSION3F_GRAPHIC_ARTIST = function(game, ow, npc, done)
local t = game.data.text
local TextBox = require("src.render.TextBox")
local yellow = require("src.core.GameVersion").isYellow()
if not (yellow and ownedCount(game) >= 150) then
game.stack:push(TextBox.new(game,
t._CeladonMansion3FGraphicArtistText
or "I'm the graphic\nartist!\nI drew you!", done))
return
end
-- _CeladonMansion3FGraphicArtistText2: offer to print the diploma
game.stack:push(TextBox.new(game,
t._CeladonMansion3FGraphicArtistText2
or "I'm the graphic\nartist!\fShould I print\nyour diploma?",
function()
local ChoiceBox = require("src.ui.ChoiceBox")
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then
game.stack:push(TextBox.new(game,
t._CeladonMansion3FGraphicArtistText3
or "Oh. But it's a\nspecial diploma!", done))
return
end
local Printer = require("src.core.Printer")
local Diploma = require("src.ui.Diploma")
local Strings = require("src.core.Strings")
local saved, err = Printer.save("diploma", 160, 144, function()
Diploma.render(game)
end)
-- the PRNT stand-in always reports where the PNG landed
game.stack:push(TextBox.new(game, saved
and Strings("There you go!\fSaved as\n%s\vin the save\nfolder.", saved)
or Strings("Printer error!\n%s", tostring(err)), done))
end))
end))
end,
},
},
@@ -42,6 +42,7 @@ local function middleAgedMan(game, ow, npc, done)
end
local menu = ListMenu.new(game, "", items, {
onChoose = function(item)
game.stack:pop()
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,
},
}
+7 -7
View File
@@ -7,19 +7,19 @@
-- CheckFightingMapTrainers/TalkToTrainer via the existing trainer
-- system, and the item pickups carry no talk text of their own here.
--
-- TEXT_SSANNE2FROOMS_GENTLEMAN3 also opens the POKéDEX entry for
-- SNORLAX after the text box (DisplayPokedex in the original), which
-- has no equivalent talk-script hook in this port (DexEntryMenu has
-- no done-callback the way other pushed UI states do) -- only the
-- flavor line is ported here.
-- TEXT_SSANNE2FROOMS_GENTLEMAN3 opens SNORLAX's POKéDEX entry after its
-- text box, like DisplayPokedex in the original. The preview marks it seen
-- but does not mark it owned.
return {
SS_ANNE_2F_ROOMS = {
talk = {
-- SSAnne2FRoomsGentleman3Text: PrintText(_SSAnne2FRoomsGentleman3Text)
-- (then DisplayPokedex SNORLAX -- not ported, see note above)
-- SSAnne2FRoomsGentleman3Text: PrintText(_SSAnne2FRoomsGentleman3Text),
-- then DisplayPokedex SNORLAX.
TEXT_SSANNE2FROOMS_GENTLEMAN3 = {
{ "face_player" },
{ "show_text", "_SSAnne2FRoomsGentleman3Text" },
{ "mark_seen", "SNORLAX" },
{ "push_screen", "DexEntryMenu", "SNORLAX" },
},
-- SSAnne2FRoomsGentleman4Text: PrintText(_SSAnne2FRoomsGentleman4Text)
+20 -1
View File
@@ -12,11 +12,18 @@
-- different files can each add NPCs to the same map), and mod
-- contributions from the map_scripts registry compose on top of it.
local GameVersion = require("src.core.GameVersion")
local MapScripts = require("src.script.MapScripts")
-- OaksLab is a full Yellow rewrite (one Eevee ball + forced Pikachu);
-- Red/Blue keep the three-starter choose flow.
local oaksLab = GameVersion.isYellow()
and "data.scripts.oaks_lab_yellow"
or "data.scripts.oaks_lab"
for _, mapEntry in ipairs({
{ "PALLET_TOWN", "data.scripts.pallet_town" },
{ "OAKS_LAB", "data.scripts.oaks_lab" },
{ "OAKS_LAB", oaksLab },
{ "REDS_HOUSE_1F", "data.scripts.reds_house" },
{ "CELADON_MANSION_ROOF_HOUSE", "data.scripts.celadon_eevee" },
}) do
@@ -36,6 +43,18 @@ for _, file in ipairs({ "data.scripts.story", "data.scripts.story2",
end
end
-- Yellow-only content on top of the shared tables (talk keys merge per
-- TEXT constant): the Kanto-starter gift quests and Jessie & James.
if GameVersion.isYellow() then
for _, file in ipairs({ "data.scripts.yellow_gifts",
"data.scripts.yellow_jessie_james",
"data.scripts.yellow_beach_house" }) do
for mapId, mod in pairs(require(file)) do
MapScripts.attachBase(mapId, mod)
end
end
end
local M = {}
function M.get(mapId)
+296
View File
@@ -0,0 +1,296 @@
-- Hand-ported from pret/pokeyellow scripts/OaksLab.asm.
-- Yellow: one Eevee ball on the table. Rival snatches it; Oak then
-- gives the player the wild Pikachu he caught earlier (STARTER_PIKACHU).
-- Object indices differ from Red (no Charmander/Squirtle/Bulbasaur balls):
-- 1 RIVAL (4,3), 2 EEVEE_POKE_BALL (7,3), 3 OAK1 (5,2),
-- 4-5 POKEDEX (2,1)/(3,1), 6 OAK2 (door), 7 GIRL, 8-9 SCIENTIST.
--
-- wRivalStarter rides in save.rivalStarter (RIVAL_STARTER_* 1 JOLTEON /
-- 2 FLAREON / 3 VAPOREON): JOLTEON baseline at the snatch
-- (OaksLabRivalTakesPokeballScript), FLAREON on a lab win / VAPOREON on
-- a lab loss (OaksLabRivalEndBattleScript), and Route 22's first battle
-- upgrades FLAREON back to JOLTEON (Route22Rival1AfterBattleScript).
local OAK1 = 3
local RIVAL = 1
return {
talk = {
TEXT_OAKSLAB_OAK1 = {
{ "face_player" },
-- Yellow's OaksLabOak1Text leads with the dex-rating branch: once
-- EVENT_PALLET_AFTER_GETTING_POKEBALLS is set (converted saves) or
-- 2+ species are owned, Oak asks how the Pokédex is coming and
-- rates it (predef DisplayDexRating)
{ "check_flag", "EVENT_PALLET_AFTER_GETTING_POKEBALLS" },
{ "jump_if_true", "dex_rating" },
{ "check_dex_owned", 2 },
{ "jump_if_true", "dex_rating" },
{ "check_item", "POKE_BALL" },
{ "jump_if_true", "come_see" },
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
{ "jump_if_true", "give_balls" },
{ "check_flag", "EVENT_GOT_POKEDEX" },
{ "jump_if_true", "around_world" },
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
{ "jump_if_false", "pre_lab_battle" },
{ "check_item", "OAKS_PARCEL" },
{ "jump_if_false", "raise_young" },
-- .DeliverParcelText: parcel handover, then the Pokédex scene
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
{ "show_text", "_OaksLabOak1DeliverParcelText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ParcelThanksText" },
{ "take_item", "OAKS_PARCEL", 1 },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalGrampsText" },
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
{ "place_npc", RIVAL, 4, 7, "up" },
{ "move_npc_to", RIVAL, 4, 3 },
{ "play_music", "Music_OaksLab" },
{ "face_object", RIVAL, "up" },
{ "face_object", OAK1, "down" },
-- Yellow opens with the rival bragging, not Red's "what did you
-- call me for" (OaksLabOakGivesPokedexScript text order)
{ "show_text", "_OaksLabRivalMyPokemonHasGrownStrongerText" },
{ "face_object", RIVAL, "up" },
{ "face_object", OAK1, "down" },
{ "show_text", "_OaksLabOakIHaveARequestText" },
{ "face_object", RIVAL, "up" },
{ "face_object", OAK1, "down" },
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
{ "show_text", "_OaksLabOakGotPokedexText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
{ "face_object", RIVAL, "up" },
{ "face_object", OAK1, "down" },
{ "show_text", "_OaksLabOakThatWasMyDreamText" },
{ "face_object", RIVAL, "right" },
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" },
{ "set_flag", "EVENT_GOT_POKEDEX" },
{ "set_flag", "EVENT_OAK_GOT_PARCEL" },
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" },
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "move_npc_to", RIVAL, 4, 7 },
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
{ "play_music", "Music_OaksLab" },
{ "set_flag", "EVENT_1ST_ROUTE22_RIVAL_BATTLE" },
{ "clear_flag", "EVENT_2ND_ROUTE22_RIVAL_BATTLE" },
{ "set_flag", "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" },
{ "show_object", "ROUTE_22", "ROUTE22_RIVAL1" },
{ "jump", "end" },
{ "label", "raise_young" },
-- Yellow: talk-to-it (starter Pikachu) instead of Red raise-young line.
{ "show_text", "_OaksLabOak1YouShouldTalkToIt" },
{ "jump", "end" },
{ "label", "pre_lab_battle" },
{ "check_flag", "EVENT_GOT_STARTER" },
{ "jump_if_true", "can_fight" },
{ "show_text", "_OaksLabOak1GoAheadItsYours" },
{ "jump", "end" },
{ "label", "can_fight" },
{ "show_text", "_OaksLabOak1YourPokemonCanFightText" },
{ "jump", "end" },
{ "label", "around_world" },
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" },
{ "jump", "end" },
{ "label", "give_balls" },
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "jump_if_true", "come_see" },
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "give_item", "POKE_BALL", 5, false },
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
{ "jump", "end" },
{ "label", "come_see" },
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" },
{ "jump", "end" },
{ "label", "dex_rating" },
{ "show_text", "_OaksLabOak1HowIsYourPokedexComingText" },
{ "dex_rating" },
},
-- OaksLabEeveePokeBallText / OaksLabRivalExclamationScript ->
-- OaksLabChoseStarterScript..OaksLabPlayerReceivesPikachuScript:
-- before Oak's choose speech the ball is just flavor; after it, the
-- rival "!"s, shoves the player off the table, snatches the ball,
-- then the player is walked over to Oak and handed Pikachu.
TEXT_OAKSLAB_EEVEE_POKE_BALL = function(game, ow, npc, done)
local flags = game.save.flags
if flags.EVENT_GOT_STARTER then
done()
return
end
if not flags.EVENT_OAK_ASKED_TO_CHOOSE_MON then
ow.runner:run({
{ "show_text", "_OaksLabThatsAPokeball" },
}, { onDone = done })
return
end
local px, py = ow.player.cellX, ow.player.cellY
local rows = {
-- 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)
if py == 4 then
rows[#rows + 1] = { "walk_npc", RIVAL,
{ "down", "right", "right", "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 }
else
rows[#rows + 1] = { "move_npc_to", RIVAL, 7, 4 }
end
rows[#rows + 1] = { "face_object", RIVAL, "up" }
rows[#rows + 1] = { "hide_object", "OAKS_LAB", "OAKSLAB_EEVEE_POKE_BALL" }
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" }
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText2" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText5" }
-- OaksLabRLE_PlayerWalksToOak: the simulated-joypad buffer plays
-- its RLE list BACKWARDS (StartSimulatingJoypadStates consumes
-- from wSimulatedJoypadStatesEnd), so the real order is LEFT 1,
-- DOWN 1, LEFT 3, UP 2 -- from the shove spot (9,4) the player
-- rounds the BOTTOM of the table to (5,3), directly below Oak
if py == 4 then
rows[#rows + 1] = { "walk_npc", "player",
{ "left", "down", "left", "left", "left", "up", "up" } }
else
rows[#rows + 1] = { "walk_npc", "player", { "left" } }
end
rows[#rows + 1] = { "face_player_dir", "up" }
rows[#rows + 1] = { "face_object", OAK1, "down" }
-- OaksLabPlayerReceivedMonText: no nickname prompt -- the starter
-- Pikachu keeps its species name
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" }
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5, true }
rows[#rows + 1] = { "set_flag", "EVENT_GOT_STARTER" }
rows[#rows + 1] = { "set_flag", "EVENT_CHOSE_PIKACHU" }
ow.runner:run(rows, { npc = npc, onDone = done })
end,
TEXT_OAKSLAB_RIVAL = {
{ "face_player" },
{ "check_flag", "EVENT_GOT_STARTER" },
{ "jump_if_false", "pre_starter" },
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" },
{ "jump", "end" },
{ "label", "pre_starter" },
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB_2" },
{ "jump_if_false", "gramps_gone" },
{ "show_text", "_OaksLabRivalIllGetABetterPokemonThanYou" },
{ "jump", "end" },
{ "label", "gramps_gone" },
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" },
},
},
onEnter = function(game, ow)
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
return
end
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX2")
end,
onStep = function(game, ow, x, y)
local flags = game.save.flags
-- OaksLabPlayerDontGoAwayScript: y==6 without the starter walks the
-- player back up a tile
if flags.EVENT_FOLLOWED_OAK_INTO_LAB and not flags.EVENT_GOT_STARTER
and y >= 6 then
ow.runner:run({
{ "face_object", OAK1, "down" },
{ "face_object", RIVAL, "down" },
{ "show_text", "_OaksLabOakDontGoAwayYetText" },
{ "move_player", "up", 1 },
}, {})
return true
end
-- OaksLabRivalChallengesPlayerScript..OaksLabPikachuDislikesPokeballsScript:
-- heading for the door with Pikachu starts the rival battle, his
-- exit walk, then Pikachu pops out of its ball.
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB
and y >= 6 then
local rival = ow:npcByIndex(RIVAL)
if not rival then return false end
local rows = {
{ "face_player_dir", "up" },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalIllTakeYouOnText" },
}
-- FindPathToPlayer with the Y distance decremented: the rival
-- stops one tile above the player
local target
for _, c in ipairs({ { x, y - 1 }, { x - 1, y }, { x + 1, y },
{ x, y + 1 } }) do
if ow.map:inBounds(c[1], c[2]) and ow.map:isWalkableCell(c[1], c[2]) then
target = c
break
end
end
if target then
table.insert(rows, { "move_npc_to", RIVAL, target[1], target[2] })
end
table.insert(rows, { "face_object", RIVAL,
target and target[2] < y and "down"
or target and target[2] > y and "up"
or target and target[1] < x and "right" or "left" })
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", 1 })
table.insert(rows, { "heal_party" })
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
-- OaksLabRivalEndBattleScript: his Eevee's future evolution is
-- decided here -- FLAREON if the player won, VAPOREON otherwise
table.insert(rows, { "check_battle_result", "win" })
table.insert(rows, { "jump_if_false", "lost_lab" })
table.insert(rows, { "set_field", "rivalStarter", 2 })
table.insert(rows, { "jump", "exit" })
table.insert(rows, { "label", "lost_lab" })
table.insert(rows, { "set_field", "rivalStarter", 3 })
table.insert(rows, { "label", "exit" })
-- OaksLabRivalStartsExitScript: parting shot, walk out past the
-- player, restore the lab theme
table.insert(rows, { "wait", 20 })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
table.insert(rows, { "play_music", "Music_OaksLab" })
-- OaksLabPikachuEscapesPokeballScript: Pikachu hates its ball.
-- The overworld follower itself is still an open port
-- (docs/yellow-version.md runtime backlog); the story beat plays.
table.insert(rows, { "play_cry", "PIKACHU" })
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
ow.runner:run(rows, { npc = rival })
return true
end
return false
end,
}
+54 -8
View File
@@ -19,19 +19,60 @@ local FEE = 500
local BALLS = 30
local STEPS = 502
local function startGame(game, t, done)
game.save.money = game.save.money - FEE
game.save.safari = { balls = BALLS, steps = STEPS }
local function startGame(game, t, done, balls, introText)
game.save.safari = { balls = balls or BALLS, steps = STEPS }
game.save.safariNags = nil
local TextBox = require("src.render.TextBox")
local paid = (t._SafariZoneGateSafariZoneWorker1ThatllBe500PleaseText
or "That'll be ¥500\nplease!\f{PLAYER} received\n30 SAFARI BALLs!")
:gsub("{PLAYER}", game.save.player.name)
local paid = introText
or (t._SafariZoneGateSafariZoneWorker1ThatllBe500PleaseText
or "That'll be ¥500\nplease!\f{PLAYER} received\n30 SAFARI BALLs!")
:gsub("{NUM:[^}]*}", "500")
paid = paid:gsub("{PLAYER}", game.save.player.name)
local pa = t._SafariZoneGateSafariZoneWorker1CallYouOnThePAText
or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!"
local luck = t._SafariZoneGateSafariZoneWorker1GoodLuckText or "Good Luck!"
game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, done))
end
-- Yellow's soft-lock fix (scripts/SafariZoneGate_2.asm): a player short of
-- the full fee still gets in.
-- 0 < money < 500: SafariZoneEntranceCalculateLowCostAdmission takes
-- everything and hands over min(money/23 + 1, 29) balls.
-- money == 0: SafariZoneEntranceGetLowCostAdmissionText nags four times
-- (LowCostText5/6/7/8), then relents -- free entry, one ball.
local function yellowLowCost(game, ow, t, done, back)
local TextBox = require("src.render.TextBox")
if game.save.money > 0 then
local balls = math.min(math.floor(game.save.money / 23) + 1, 29)
game.save.money = 0
local intro =
(t._SafariZoneGateSafariZoneWorker1NotEnoughMoneyText
or "Oops! Not enough\nmoney!")
.. (t._SafariZoneLowCostText1
or "\fOh, all right, pay\nme what you have.")
.. "\f" .. (t._SafariZoneLowCostText2
or "But, I can't give\nyou all 30 BALLs.")
startGame(game, t, done, balls, intro)
return
end
local nag = game.save.safariNags or 0
game.save.safariNags = nag + 1
if nag >= 3 then
local intro =
(t._SafariZoneLowCostText8 or "Read my lips, NO!\nGet it?")
.. (t._SafariZoneLowCostText3
or "\fYou're persistent,\naren't you?\fOK, you can go in\nfor free, but\njust this once!")
startGame(game, t, done, 1, intro)
return
end
local nags = {
t._SafariZoneLowCostText5 or "I'm sorry, but you\nhave to pay to\nenter.",
t._SafariZoneLowCostText6 or "You can't enter\nwithout paying!",
t._SafariZoneLowCostText7 or "I said, no money,\nno entry!",
}
back(nags[nag + 1])
end
local function joinPrompt(game, ow, done)
done = done or function() end
local TextBox = require("src.render.TextBox")
@@ -51,9 +92,14 @@ local function joinPrompt(game, ow, done)
back(t._SafariZoneGateSafariZoneWorker1PleaseComeAgainText
or "OK! Please come\nagain!")
elseif game.save.money < FEE then
back(t._SafariZoneGateSafariZoneWorker1NotEnoughMoneyText
or "Oops! Not enough\nmoney!")
if require("src.core.GameVersion").isYellow() then
yellowLowCost(game, ow, t, done, back)
else
back(t._SafariZoneGateSafariZoneWorker1NotEnoughMoneyText
or "Oops! Not enough\nmoney!")
end
else
game.save.money = game.save.money - FEE
startGame(game, t, done)
end
end))
+78 -16
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,
}
@@ -666,6 +670,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 +738,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 +750,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
+104 -90
View File
@@ -1,6 +1,10 @@
-- More hand-ported events: the Pallet Town intro, the thirsty Saffron
-- gate guards, the Bike Voucher chain, fossils and the day-care.
-- Registered via data/scripts/init.lua; each cites its pokered source.
-- Registered via data/scripts/init.lua; Red/Blue cite pokered, Yellow
-- cites pokeyellow (Pallet stop row, Oak spawn, walk RLE, and the
-- wild-Pikachu beat before the lab escort).
local GameVersion = require("src.core.GameVersion")
local M = {}
@@ -35,72 +39,79 @@ function escort.findPath(fromX, fromY, toX, toY)
return path
end
-- PalletTownOakWalksToPlayerScript: Oak appears at his object spot
-- (8,5) and zigzags to one tile below the player (hNPCPlayerYDistance
-- is pre-decremented before predef FindPathToPlayer).
-- Red: Oak object (8,5) -> one tile below the player at y=1.
-- Yellow: Oak object (10,4); stop fires at y=0 (pokeyellow PalletTown).
function escort.oakApproach(playerX)
if GameVersion.isYellow() then
return escort.findPath(10, 4, playerX, 1)
end
return escort.findPath(8, 5, playerX, 2)
end
-- RLEList_ProfOakWalkToLab (engine/overworld/auto_movement.asm):
-- DOWN x5, LEFT, DOWN x5, RIGHT x3, UP -- Oak's last step lands on the
-- lab door (12,11). (The trailing NPC_CHANGE_FACING is a march-in-
-- place beat on the mat; here Oak just stands his final beat.)
escort.oakSteps = {
"down", "down", "down", "down", "down",
"left",
"down", "down", "down", "down", "down",
"right", "right", "right",
"up",
}
-- RLEList_ProfOakWalkToLab (engine/overworld/auto_movement.asm).
-- Yellow differs: first DOWN is x6 (Oak starts one tile farther north).
local function buildOakSteps()
if GameVersion.isYellow() then
return {
"down", "down", "down", "down", "down", "down",
"left",
"down", "down", "down", "down", "down",
"right", "right", "right",
"up",
}
end
return {
"down", "down", "down", "down", "down",
"left",
"down", "down", "down", "down", "down",
"right", "right", "right",
"up",
}
end
-- RLEList_PlayerWalkToLab decodes to UP x2, RIGHT x3, DOWN x5, LEFT,
-- DOWN x6 and plays in REVERSE buffer order (wSimulatedJoypadStatesEnd
-- grows downward): DOWN x6, LEFT, DOWN x5, RIGHT x3, UP x2 -- Oak's
-- exact path one step behind. The 17th press (the second UP) is eaten
-- by the door-warp frame (WarpFound clears hJoyHeld via EnterMap), so
-- only 16 real steps happen; the walk ends on the door at (12,11).
escort.oakSteps = buildOakSteps()
-- Player stays one step behind Oak (simplified reverse-RLE port).
escort.playerSteps = { "down" }
for _, d in ipairs(escort.oakSteps) do
escort.playerSteps[#escort.playerSteps + 1] = d
end
local function npcNamed(ow, name)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == name then return n end
end
return nil
end
M.PALLET_TOWN = {
talk = require("data.scripts.pallet_town").talk,
escort = escort,
-- Oak stops you at the north row (PalletTownDefaultScript's
-- `wYCoord == 1` check), walks up from (8,5), and leads you to his
-- lab with the player one step behind (scripts/PalletTown.asm +
-- PalletMovementScriptPointerTable in
-- engine/overworld/auto_movement.asm), then the lab walk-in and the
-- choose-mon exchange (scripts/OaksLab.asm OaksLabDefaultScript ..
-- OaksLabOakChooseMonSpeechScript).
-- Red: stop at y==1 from (8,5). Yellow: stop at y==0 from (10,4),
-- then a wild Pikachu battle before the lab escort (pokeyellow
-- PalletTownPikachuBattleScript).
onStep = function(game, ow, x, y)
if y ~= 1 or game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB
local yellow = GameVersion.isYellow()
local stopY = yellow and 0 or 1
if y ~= stopY or game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB
or game.save.flags.EVENT_GOT_STARTER then
return false
end
local TextBox = require("src.render.TextBox")
local Commands = require("src.script.Commands")
local Music = require("src.core.Music")
local BattleState = require("src.battle.BattleState")
local t = game.data.text
local ctx = { save = game.save, game = game, overworld = ow }
-- PalletTownDefaultScript: stop the player, turn them around
-- (wPlayerMovingDirection = PLAYER_DIR_DOWN applies on the very
-- next frame, before the text box opens) and strike up the "oak
-- appears" theme (MUSIC_MEET_PROF_OAK)
ow.player.facing = "down"
-- Red turns the player down; Yellow faces up at the north exit.
ow.player.facing = yellow and "up" or "down"
Music.play(game.data, "Music_MeetProfOak")
-- DelayFrames-style hold: the world pauses (input stays locked)
-- for `frames` frames, then cb runs. Reuses the emote pause slot;
-- with an `npc` the "!" bubble draws above it (EmotionBubble).
local function hold(frames, npc, cb)
ow.emote = { frames = frames, npc = npc, onDone = cb }
end
-- chain single-tile scriptMoves through a direction list
local function walkList(entity, steps, done)
local i = 0
local function nextStep()
@@ -114,10 +125,8 @@ M.PALLET_TOWN = {
nextStep()
end
-- ---- Oak's Lab side (scripts/OaksLab.asm) ----------------------
-- OaksLabOakChooseMonSpeechScript: the fed-up / choose-mon /
-- what-about-me / be-patient exchange, Delay3 between boxes
-- OaksLabOakChooseMonSpeechScript. Yellow's OakChooseMon text is
-- the single-ball speech, not Red's "there are 3 POKéMON".
local function chooseMonSpeech()
local function say(key, fb, next)
game.stack:push(TextBox.new(game, t[key] or fb, next))
@@ -126,7 +135,9 @@ M.PALLET_TOWN = {
"{RIVAL}: Gramps!\nI'm fed up with\nwaiting!", function()
hold(3, nil, function()
say("_OaksLabOakChooseMonText",
"OAK: Here, {PLAYER}!\fThere are 3\nPOKéMON here!\fYou can have one!\nChoose!", function()
yellow
and "OAK: Look, {PLAYER}! Do\nyou see that ball\non the table?"
or "OAK: Here, {PLAYER}!\fThere are 3\nPOKéMON here!\fYou can have one!\nChoose!", function()
hold(3, nil, function()
say("_OaksLabRivalWhatAboutMeText",
"{RIVAL}: Hey!\nGramps! What\nabout me?", function()
@@ -143,30 +154,18 @@ M.PALLET_TOWN = {
end)
end
-- entering the lab: the door Oak (OAKSLAB_OAK2, (5,10)) walks up 3
-- ahead of the player (OaksLabOakEntersLabScript OakEntryMovement),
-- swaps for the desk Oak (OAKSLAB_OAK1, (5,2)), then the player
-- walks up 8 from the mat (PlayerEntryMovementRLE) while the rival
-- and Oak turn with them (OaksLabPlayerEntersLabScript /
-- OaksLabFollowedOakScript)
-- Door Oak is OAKSLAB_OAK2: Red index 8, Yellow index 6 (one ball).
local function labWalkIn()
local oak2 = ow:npcByIndex(8)
local oak2 = npcNamed(ow, "OAKSLAB_OAK2") or ow:npcByIndex(yellow and 6 or 8)
local function swapOaks()
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK1")
hold(3, nil, function() -- Delay3
Commands.face_object(ctx, 1, "down") -- rival watches you pass
hold(3, nil, function()
Commands.face_object(ctx, 1, "down")
ow:scriptMove(ow.player, "up", 8, function()
-- OaksLabFollowedOakScript: flags only after the walk-in, so a
-- stray step on the door mat can't fire the "don't go away"
-- push-up (oaks_lab.lua onStep) mid-cutscene. Outdoor escort
-- still re-arms on F1 mid-escort -- these flags stay clear
-- until the lab walk finishes.
game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB_2 = true
Commands.face_object(ctx, 1, "up")
-- res BIT_NO_MAP_MUSIC + PlayDefaultMusic: the lab theme
-- only starts once the walk-in is done
Music.playMap(game.data, "OAKS_LAB")
chooseMonSpeech()
end)
@@ -179,9 +178,6 @@ M.PALLET_TOWN = {
end
end
-- PalletMovementScript_Done + the door warp: Oak is hidden as the
-- player steps into the doorway; PALLET_TOWN warp 3 -> OAKS_LAB
-- warp 2 = (5,11), with the door SFX (WarpFound -> SFX_GO_INSIDE)
local function enterLab()
Commands.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
@@ -190,10 +186,6 @@ M.PALLET_TOWN = {
{ keepMusic = true })
end
-- PalletMovementScript_WalkToLab: Oak's NPC movement and the
-- player's simulated joypad run simultaneously, in lockstep; the
-- player retraces Oak's path one step behind and follows him into
-- the doorway on the final beat
local function walkToLab(oak)
local i = 0
local function tick()
@@ -206,9 +198,6 @@ M.PALLET_TOWN = {
if oak and escort.oakSteps[i] then
ow:scriptMove(oak, escort.oakSteps[i], 1)
elseif oak then
-- RLEList_ProfOakWalkToLab's trailing NPC_CHANGE_FACING beat:
-- Oak marches in place on the door mat while the player takes
-- the final step up behind him (movement.asm ChangeFacingDirection)
ow:marchInPlace(oak)
end
ow:scriptMove(ow.player, playerStep, 1, tick)
@@ -216,9 +205,6 @@ M.PALLET_TOWN = {
tick()
end
-- PalletMovementScript_OakMoveLeft/_PlayerMoveLeft: from the right
-- tile (x == 11) Oak sidesteps left first, then the player follows
-- left (wNumStepsToTake = wXCoord - 10), and only then both walk
local function escortToLab(oak)
local numSteps = x - 10
if oak and numSteps > 0 then
@@ -232,37 +218,65 @@ M.PALLET_TOWN = {
end
end
-- ---- Pallet Town side (scripts/PalletTown.asm) -----------------
-- After Oak reaches the player: Red goes straight to "It's unsafe!".
-- Yellow (PalletTownOakGreetsPlayerScript..AfterPikachuBattleScript):
-- ThatWasClose -> Oak faces the grass patch -> BATTLE_TYPE_PIKACHU
-- (the old-man-style simulated battle where PROF.OAK throws the ball
-- and always catches the lv5 Pikachu) -> Whew -> ComeWithMe.
local function afterOakArrives(oak)
if not yellow then
game.stack:push(TextBox.new(game,
t._PalletTownOakItsUnsafeText
or "OAK: It's unsafe!\nWild POKéMON\nlive in tall grass!",
function() escortToLab(oak) end))
return
end
local function comeWithMe()
game.stack:push(TextBox.new(game,
t._PalletTownOakComeWithMe
or "OAK: Here, come with\nme!",
function() escortToLab(oak) end))
end
local function afterPikaBattle()
if oak then oak.facing = "up" end
game.stack:push(TextBox.new(game,
t._PalletTownOakWhewText or "OAK: Whew...",
comeWithMe))
end
game.stack:push(TextBox.new(game,
t._PalletTownOakThatWasCloseText
or "OAK: That was\nclose!\fWild POKéMON live\nin tall grass!",
function()
-- Oak turns toward the horizontally adjacent grass (left exit
-- looks right, right exit looks left -- the
-- EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN branch)
if oak then oak.facing = x == 10 and "right" or "left" end
local battle = BattleState.newWild(game, "PIKACHU", 5)
battle:makeOldManDemo("PROF.OAK")
battle.onFinish = function()
afterPikaBattle()
end
game.stack:push(battle)
end))
end
-- PalletTownOakWalksToPlayerScript: Oak appears at (8,5), faces up
-- (SetSpriteFacingDirectionAndDelay + Delay3), then zigzags to the
-- player; the "It's unsafe!" text follows and the escort begins
local function oakAppearsAndWalks()
Commands.show_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
local oak = ow:npcByIndex(1)
local oak = npcNamed(ow, "PALLETTOWN_OAK") or ow:npcByIndex(1)
if oak then oak.facing = "up" end
hold(6, nil, function()
walkList(oak, escort.oakApproach(x), function()
-- PalletTownOakNotSafeComeWithMeScript: the second text waits
-- for a button, then the escort starts
game.stack:push(TextBox.new(game,
t._PalletTownOakItsUnsafeText
or "OAK: It's unsafe!\nWild POKéMON\nlive in tall grass!",
function() escortToLab(oak) end))
afterOakArrives(oak)
end)
end)
end
-- The "Hey! Wait!" box ends without a button wait (auto), then the
-- "!" bubble shows over the player WHILE the box is still on screen
-- (PalletTownOakText: DelayFrames 10 then EmotionBubble, box not yet
-- cleared). onOverlap sets the bubble during the box's last frames;
-- the box pops after `overlap`, and the bubble's 60-frame hold then
-- runs to Oak's appearance (the bubble is static while the box is up,
-- since the overworld pauses under it, so 10 overlap + 50 = 60).
game.stack:push(TextBox.new(game,
t._PalletTownOakHeyWaitDontGoOutText or "OAK: Hey! Wait!\nDon't go out!",
nil, { auto = { delay = 10, overlap = 10, onOverlap = function()
-- .HeyWaitDontGoOutText turns the player to face down (toward
-- the approaching Oak) before the exclamation bubble
ow.player.facing = "down"
ow.emote = { npc = ow.player, frames = 50, onDone = oakAppearsAndWalks }
end } }))
return true
+108 -112
View File
@@ -128,6 +128,11 @@ M.POKEMON_TOWER_6F = {
t._PokemonTower6FBeGoneText or "Be gone...\nIntruders...", function()
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(game, "MAROWAK", 30)
-- 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
if not game.save.inventory.SILPH_SCOPE then
battle:makeGhost()
end
@@ -182,17 +187,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 +209,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 +236,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 +252,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" })
-- -------------------------------------------------------------------
@@ -695,6 +659,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 +703,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)
+32 -7
View File
@@ -37,16 +37,15 @@ local function countOwned(save)
return n
end
local function oaksAide(threshold, itemId)
local function oaksAide(threshold, itemId, repeatText)
return function(game, ow, npc, done)
local t = text(game)
local flags = game.save.flags
local itemName = game.data.items[itemId].name
local flag = "EVENT_GOT_" .. itemId
if flags[flag] then
push(game, fill(t._OaksAideComeBackText or
"I already gave\nyou the {RAM:}!",
{ num = threshold, ram = itemName }), done)
push(game, t[repeatText] or
fill("I already gave\nyou the {RAM:}!", { ram = itemName }), done)
return
end
ask(game, fill(t._OaksAideHiText or
@@ -83,13 +82,16 @@ local function oaksAide(threshold, itemId)
end
M.ROUTE_2_GATE = {
talk = { TEXT_ROUTE2GATE_OAKS_AIDE = oaksAide(10, "HM_FLASH") },
talk = { TEXT_ROUTE2GATE_OAKS_AIDE = oaksAide(10, "HM_FLASH",
"_Route2GateOaksAideFlashExplanationText") },
}
M.ROUTE_11_GATE_2F = {
talk = { TEXT_ROUTE11GATE2F_OAKS_AIDE = oaksAide(30, "ITEMFINDER") },
talk = { TEXT_ROUTE11GATE2F_OAKS_AIDE = oaksAide(30, "ITEMFINDER",
"_Route11Gate2FOaksAideItemfinderDescriptionText") },
}
M.ROUTE_15_GATE_2F = {
talk = { TEXT_ROUTE15GATE2F_OAKS_AIDE = oaksAide(50, "EXP_ALL") },
talk = { TEXT_ROUTE15GATE2F_OAKS_AIDE = oaksAide(50, "EXP_ALL",
"_Route15Gate2FOaksAideExpAllText") },
}
-- -------------------------------------------------------------------
@@ -169,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 =
@@ -195,6 +219,7 @@ M.FIGHTING_DOJO = {
end
return false
end,
onStep = dojoMasterGate,
}
-- -------------------------------------------------------------------
+20 -3
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",
@@ -836,6 +839,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 +864,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,
}
+39
View File
@@ -173,7 +173,46 @@ local function syncGymGatesAfterBattle(game, ow)
applyGymGates(game, ow)
end
-- Yellow's quiz-first rule (scripts/CinnabarGym.asm SuperNerd2..7): a
-- gate guardian refuses to battle until his quiz was attempted -- a
-- wrong answer sics him on you (the onInteract path below), a right one
-- opens his gate; walking up and talking first just gets the room's
-- CinnabarGymText_N lecture (Func_f2150's pointer table).
local function yellowQuizTalk(machineIndex)
return function(game, ow, npc, done)
local yellow = require("src.core.GameVersion").isYellow()
local defeated = ow:trainerDefeated(npc)
local gateOpen = game.save.flags[gymGateFlag(machineIndex)]
if yellow and not defeated and not gateOpen then
local TextBox = require("src.render.TextBox")
local t = game.data.text
game.stack:push(TextBox.new(game,
t["_CinnabarGymText_" .. machineIndex]
or "You have to take\nthe quiz first!", done))
return
end
-- gate open (or Red/Blue): the ordinary trainer engagement /
-- after-battle line, mirroring talkTo's generic trainer branch
npc:facePlayer(ow.player)
if not defeated then
ow:engageTrainer(npc, done)
return
end
local header = game.data:trainerHeader(ow.map.def.label, npc.def.index)
local after = header and header.after and game.data.text[header.after]
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, after or "...", done))
end
end
M.CINNABAR_GYM = {
talk = (function()
local talk = {}
for i = 1, 6 do
talk["TEXT_CINNABARGYM_SUPER_NERD" .. (i + 1)] = yellowQuizTalk(i)
end
return talk
end)(),
onEnter = applyGymGates,
onVictory = syncGymGatesAfterBattle,
onInteract = function(game, ow, fx, fy)
+131
View File
@@ -0,0 +1,131 @@
-- Summer Beach House (scripts/SummerBeachHouse.asm), Yellow's Route 19
-- surf shack. The Surfin' Dude only lets a party Pikachu that knows
-- SURF ride (IsSurfingPikachuInParty, home/map_objects.asm); saying yes
-- runs the Surfing Pikachu minigame (src/ui/SurfingMinigame.lua). The
-- corner printer shows/prints the high score once you have surfed this
-- visit (BIT_PIKACHU_MAP_SURF_SELECT is a per-map-load flag, so the
-- session markers live on the overworld state, not the save).
local function surfingPikachu(game)
for _, mon in ipairs(game.save.party or {}) do
if mon.species == "PIKACHU" then
for _, mv in ipairs(mon.moves or {}) do
if mv.id == "SURF" then return mon end
end
end
end
return nil
end
local function push(game, text, done)
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, text, done))
end
-- the two-variant posters: the surf-capable line once a surfing
-- Pikachu is along, the plain one otherwise
local function poster(n)
return function(game, ow, npc, done)
local t = game.data.text
local key = ("_SummerBeachHousePoster%dText%d"):format(
n, surfingPikachu(game) and 1 or 2)
push(game, t[key] or "A surfing poster.", done)
end
end
return {
SUMMER_BEACH_HOUSE = {
talk = {
TEXT_SUMMERBEACHHOUSE_SURFINDUDE = function(game, ow, npc, done)
local t = game.data.text
if not surfingPikachu(game) then
push(game, t._SummerBeachHouseSurfinDudeText4
or "Dogs and burgers\non special today!", done)
return
end
-- Text1 on the first ask each visit, the short Text3 after
local ask = ow.surfinDudeAsked
and (t._SummerBeachHouseSurfinDudeText3 or "Wanna go SURF?")
or (t._SummerBeachHouseSurfinDudeText1
or "Whoa!\nYour PIKACHU knows\nhow to SURF!\fGive it a go?")
ow.surfinDudeAsked = true
push(game, ask, function()
local ChoiceBox = require("src.ui.ChoiceBox")
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then
push(game, t._SummerBeachHouseSurfinDudeText2
or "Come SURF anytime,\nmy friend!", done)
return
end
local SurfingMinigame = require("src.ui.SurfingMinigame")
game.stack:push(SurfingMinigame.new(game, function()
ow.surfedThisVisit = true -- BIT_PIKACHU_MAP_SURF_SELECT
require("src.core.Music").playMap(game.data, ow.map.id)
done()
end))
end))
end)
end,
TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done)
local t = game.data.text
push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!",
function()
require("src.core.Sound").playCry(game.data, "PIKACHU")
done()
end)
end,
TEXT_SUMMERBEACHHOUSE_POSTER1 = poster(1),
TEXT_SUMMERBEACHHOUSE_POSTER2 = poster(2),
TEXT_SUMMERBEACHHOUSE_POSTER3 = poster(3),
TEXT_SUMMERBEACHHOUSE_PRINTER = function(game, ow, npc, done)
local t = game.data.text
if not surfingPikachu(game) then
push(game, t._SummerBeachHousePrinterText1
or "It's some sort of\na machine...", done)
return
end
push(game, t._SummerBeachHousePrinterText2
or "SUMMER BEACH HOUSE\nPRINTER, it says.", function()
if not ow.surfedThisVisit then
done()
return
end
push(game, t._SummerBeachHousePrinterText3
or "The Hi-Score is\nshown.\fPRINT it out?", function()
local ChoiceBox = require("src.ui.ChoiceBox")
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then
done()
return
end
-- PrintSurfingMinigameHighScore -> PNG stand-in
local Printer = require("src.core.Printer")
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local hi = game.save.surfingHighScore or 0
local name = game.save.player.name or "RED"
local saved, err = Printer.save("surf_hiscore", 160, 64,
function()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 64)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", 2.5, 2.5, 155, 59)
Font.draw(Strings("SUMMER BEACH HOUSE"), 8, 10)
Font.draw(Strings("SURFING Hi-Score"), 8, 24)
Font.draw(name, 8, 40)
Font.draw(Strings("%d pts", hi), 96, 40)
end)
push(game, saved
and Strings("Printed!\fSaved as\n%s\vin the save\nfolder.",
saved)
or Strings("Printer error!\n%s", tostring(err)), done)
end))
end)
end)
end,
},
},
}
+113
View File
@@ -0,0 +1,113 @@
-- Yellow's three Kanto-starter side-quests, hand-ported from
-- scripts/CeruleanMelaniesHouse.asm, scripts/Route24.asm
-- (Route24CooltrainerM4Text) and scripts/VermilionCity_2.asm
-- (VermilionCityPrintOfficerJennyText). Registered on top of the shared
-- Red map scripts by data/scripts/init.lua on a Yellow boot only, so the
-- table keys compose with story.lua / story4.lua's existing entries.
local M = {}
-- Melanie hands over her Bulbasaur once the starter Pikachu trusts you
-- (wPikachuHappiness >= 147).
M.CERULEAN_MELANIES_HOUSE = {
talk = {
TEXT_CERULEANMELANIESHOUSE_MELANIE = function(game, ow, npc, done)
local rows = { { "face_player" } }
if game.save.flags.EVENT_GOT_BULBASAUR_IN_CERULEAN then
rows[#rows + 1] = { "show_text", "MelanieText4" }
elseif (game.save.pikachuHappiness or 90) < 147 then
rows[#rows + 1] = { "show_text", "MelanieText1" }
else
rows[#rows + 1] = { "show_text", "MelanieText1" }
rows[#rows + 1] = { "ask", "MelanieText2" }
rows[#rows + 1] = { "jump_if_false", "declined" }
rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10 }
rows[#rows + 1] = { "jump_if_false", "end" } -- party + box full
-- 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] = { "show_text", "MelanieText3" }
rows[#rows + 1] = { "jump", "end" }
rows[#rows + 1] = { "label", "declined" }
rows[#rows + 1] = { "show_text", "MelanieText5" }
end
ow.runner:run(rows, { npc = npc, onDone = done })
end,
-- pet flavor: the text with the species' cry over it
TEXT_CERULEANMELANIESHOUSE_BULBASAUR = {
{ "play_cry", "BULBASAUR" },
{ "show_text", "MelanieBulbasaurText" },
},
TEXT_CERULEANMELANIESHOUSE_ODDISH = {
{ "play_cry", "ODDISH" },
{ "show_text", "MelanieOddishText" },
},
TEXT_CERULEANMELANIESHOUSE_SANDSHREW = {
{ "play_cry", "SANDSHREW" },
{ "show_text", "MelanieSandshrewText" },
},
},
}
-- Damian gives away the Charmander he thinks is too weak (EVENT_54F).
M.ROUTE_24 = {
talk = {
TEXT_ROUTE24_COOLTRAINER_M4 = {
{ "face_player" },
{ "check_flag", "EVENT_54F" },
{ "jump_if_true", "after" },
{ "ask", "_Route24DamianText1" },
{ "jump_if_false", "declined" },
{ "give_pokemon", "CHARMANDER", 10 },
{ "jump_if_false", "end" },
-- 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" },
{ "jump", "end" },
{ "label", "after" },
{ "show_text", "_Route24DamianText4" },
},
},
}
-- Officer Jenny's Squirtle: kept until you carry the Thunder Badge.
M.VERMILION_CITY = {
talk = {
TEXT_VERMILIONCITY_OFFICER_JENNY = function(game, ow, npc, done)
local rows = { { "face_player" } }
if game.save.flags.EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY then
rows[#rows + 1] = { "show_text", "_OfficerJennyText5" }
elseif not (game.save.inventory
and game.save.inventory.THUNDERBADGE) then
rows[#rows + 1] = { "show_text", "_OfficerJennyText1" }
else
rows[#rows + 1] = { "ask", "_OfficerJennyText2" }
rows[#rows + 1] = { "jump_if_false", "declined" }
rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10 }
rows[#rows + 1] = { "jump_if_false", "end" }
-- 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" }
end
ow.runner:run(rows, { npc = npc, onDone = done })
end,
},
}
return M
+275
View File
@@ -0,0 +1,275 @@
-- Jessie & James, Yellow's Team Rocket duo, at all four ambush sites:
-- Mt Moon B2F (scripts/MtMoonB2F.asm), Rocket Hideout B4F
-- (scripts/RocketHideoutB4F.asm), Pokemon Tower 7F
-- (scripts/PokemonTower7F.asm) and Silph Co 11F (scripts/SilphCo11F.asm).
-- Every site shares one shape: a coordinate trigger swaps the map theme
-- for Music_MeetJessieJames, the motto plays, the duo closes in, one
-- battle against the shared OPP_ROCKET party fights them both, and after
-- their parting lines they vanish together under a second sting of the
-- theme before the map theme resumes (PlayDefaultMusic ->
-- play_default_music).
--
-- Registered on top of the shared tables by data/scripts/init.lua on a
-- Yellow boot; MT_MOON_B2F's onStep chains story2's Super Nerd / fossil
-- trigger and SILPH_CO_11F's chains story's Giovanni trigger, since
-- non-talk hooks replace rather than merge.
local M = {}
-- Capture the FUNCTION, not the table: attachBase stores the module
-- table itself, so once this file's onStep is attached the table's slot
-- points back here -- delegating through the table would self-recurse.
local baseMtMoonStep = require("data.scripts.story2").MT_MOON_B2F.onStep
local baseSilph11Step = require("data.scripts.story").SILPH_CO_11F.onStep
M.MT_MOON_B2F = {
talk = {
TEXT_MTMOONB2F_JESSIE = {
{ "face_player" }, { "show_text", "_MtMoonJessieJamesText1" },
},
TEXT_MTMOONB2F_JAMES = {
{ "face_player" }, { "show_text", "_MtMoonJessieJamesText1" },
},
},
onStep = function(game, ow, x, y)
if baseMtMoonStep and baseMtMoonStep(game, ow, x, y) then
return true
end
local f = game.save.flags
if x ~= 3 or y ~= 5 then return false end
if f.EVENT_BEAT_MT_MOON_3_JESSIE_JAMES then return false end
if not (f.EVENT_GOT_DOME_FOSSIL or f.EVENT_GOT_HELIX_FOSSIL) then
return false
end
ow.runner:run({
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
{ "show_text", "_MtMoonJessieJamesText1" },
{ "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" },
{ "jump_if_false", "end" },
{ "show_text", "_MtMoonJessieJamesText3" },
{ "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" },
}, {})
return true
end,
}
-- -------------------------------------------------------------------
-- Rocket Hideout B4F (RocketHideoutB4FScript_455a5..Script13): the
-- motto plays from off-screen FIRST, then the duo pops in at (25,10) /
-- (24,10) and whichever of them shares the player's column ($18=24 or
-- $19=25, EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT) walks the three
-- tiles down to loom over the player while the other steps one. A loss
-- re-hides them (RocketHideoutB4FResetScripts via EVENT_6A0), so the
-- trigger re-arms clean.
-- -------------------------------------------------------------------
M.ROCKET_HIDEOUT_B4F = {
talk = {
TEXT_ROCKETHIDEOUTB4F_JESSIE = {
{ "face_player" }, { "show_text", "_RocketHideoutJessieJamesText1" },
},
TEXT_ROCKETHIDEOUTB4F_JAMES = {
{ "face_player" }, { "show_text", "_RocketHideoutJessieJamesText1" },
},
},
onStep = function(game, ow, x, y)
local f = game.save.flags
if y ~= 14 or (x ~= 24 and x ~= 25) then return false end
if f.EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES then return false end
-- ON_LEFT: player under James's column (25); movement data pairs
-- RocketHideoutB4FJessieJamesMovementData_45605/45606 swap so the
-- column-mate walks 3, the other 1.
local onLeft = (x == 25)
ow.runner:run({
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
{ "show_text", "_RocketHideoutJessieJamesText1" },
{ "face_player_dir", "up" },
{ "emote", "player", "shock", 30 },
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
-- James (object 2) then Jessie (object 3), Script4..Script9 order
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
{ "face_object", 2, onLeft and "down" or "left" },
{ "walk_npc", 3, onLeft and { "down" } or { "down", "down", "down" } },
{ "face_object", 3, onLeft and "right" or "down" },
{ "show_text", "_RocketHideoutJessieJamesText2" },
{ "start_battle", "trainer", "OPP_ROCKET", 43 },
{ "check_battle_result", "win" },
{ "jump_if_false", "lost" },
{ "show_text", "_RocketHideoutJessieJamesText3" },
{ "show_text", "_RocketHideoutJessieJamesText4" },
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
{ "fade", "out" },
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
{ "fade", "in" },
{ "play_default_music" },
{ "set_flag", "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES" },
{ "jump", "end" },
{ "label", "lost" },
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
}, {})
return true
end,
}
-- -------------------------------------------------------------------
-- Pokemon Tower 7F (PokemonTower7FScript_60d2a..Script10): same beat
-- one floor below Fuji, except the duo pops in BEFORE the motto and
-- Jessie ($a=10 column) leads the walk-down; ON_LEFT ($b=11) hands the
-- three-tile walk to James instead. On a loss vanilla only resets the
-- script counter (the blackout warp reloads the map anyway).
-- -------------------------------------------------------------------
M.POKEMON_TOWER_7F = {
talk = {
TEXT_POKEMONTOWER7F_JESSIE = {
{ "face_player" }, { "show_text", "_PokemonTowerJessieJamesText1" },
},
TEXT_POKEMONTOWER7F_JAMES = {
{ "face_player" }, { "show_text", "_PokemonTowerJessieJamesText1" },
},
},
onStep = function(game, ow, x, y)
local f = game.save.flags
if y ~= 12 or (x ~= 10 and x ~= 11) then return false end
if f.EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES then return false end
local onLeft = (x == 11) -- EVENT_POKEMONTOWER_7_JESSIE_JAMES_ON_LEFT
ow.runner:run({
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
{ "show_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JESSIE" },
{ "show_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JAMES" },
{ "show_text", "_PokemonTowerJessieJamesText1" },
{ "face_player_dir", "up" },
{ "emote", "player", "shock", 30 },
-- Jessie (object 1) then James (object 2), Script1..Script6 order
{ "walk_npc", 1, onLeft and { "down" } or { "down", "down", "down" } },
{ "face_object", 1, onLeft and "right" or "down" },
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
{ "face_object", 2, onLeft and "down" or "left" },
{ "show_text", "_PokemonTowerJessieJamesText2" },
{ "start_battle", "trainer", "OPP_ROCKET", 44 },
{ "check_battle_result", "win" },
{ "jump_if_false", "end" },
{ "show_text", "_PokemonTowerJessieJamesText3" },
{ "show_text", "_PokemonTowerJessieJamesText4" },
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
{ "fade", "out" },
{ "hide_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JESSIE" },
{ "hide_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JAMES" },
{ "fade", "in" },
{ "play_default_music" },
{ "set_flag", "EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES" },
}, {})
return true
end,
}
-- -------------------------------------------------------------------
-- Silph Co 11F (SilphCo11FScript_6229c..Script14): the only site where
-- the duo starts VISIBLE (toggleable_objects.asm keeps SILPHCO11F_JAMES
-- / _JESSIE ON), flanking Giovanni at (2,8)/(3,8), so they are talkable
-- before the ambush (SilphCo11FJessieJamesText = the full motto). The
-- trigger is the top row (y=3, x<4); EVENT_780/EVENT_781 pick one of
-- three approach paths (SilphCo11FMovementData_622f5..62311, $5=up
-- $6=left) that route James then Jessie up to the player without ever
-- crossing the player's tile. Their duo text lives in
-- text/SilphCo10F.asm (_SilphCoJessieJamesText*).
-- -------------------------------------------------------------------
M.SILPH_CO_11F = {
talk = {
TEXT_SILPHCO11F_JESSIE = {
{ "face_player" }, { "show_text", "_SilphCoJessieJamesText1" },
},
TEXT_SILPHCO11F_JAMES = {
{ "face_player" }, { "show_text", "_SilphCoJessieJamesText1" },
},
},
onStep = function(game, ow, x, y)
if baseSilph11Step and baseSilph11Step(game, ow, x, y) then
return true
end
local f = game.save.flags
if y ~= 3 or x > 3 then return false end
if f.EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES then return false end
-- x==3 -> base path, x==2 -> EVENT_780 variant, x<=1 -> EVENT_781
local jamesDirs, jamesFace, jessieDirs, jessieFace
if x == 3 then
jamesDirs, jamesFace = { "up", "up", "up", "up", "up" }, "right"
jessieDirs, jessieFace = { "up", "up", "up", "up" }, "up"
elseif x == 2 then
jamesDirs, jamesFace = { "up", "up", "up", "up" }, "up"
jessieDirs, jessieFace = { "up", "up", "up", "up", "up" }, "left"
else
jamesDirs = { "up", "up", "left", "up", "up" }
jamesFace = "up"
jessieDirs = { "up", "up", "up", "left", "up", "up" }
jessieFace = "left"
end
ow.runner:run({
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
{ "show_text", "_SilphCoJessieJamesText1" },
{ "face_player_dir", "down" },
{ "emote", "player", "shock", 30 },
-- James (object 4) then Jessie (object 6), Script5..Script10 order
{ "walk_npc", 4, jamesDirs },
{ "face_object", 4, jamesFace },
{ "walk_npc", 6, jessieDirs },
{ "face_object", 6, jessieFace },
{ "show_text", "_SilphCoJessieJamesText2" },
{ "start_battle", "trainer", "OPP_ROCKET", 45 },
{ "check_battle_result", "win" },
{ "jump_if_false", "end" },
{ "show_text", "_SilphCoJessieJamesText3" },
{ "show_text", "_SilphCoJessieJamesText4" },
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
{ "fade", "out" },
{ "hide_object", "SILPH_CO_11F", "SILPHCO11F_JAMES" },
{ "hide_object", "SILPH_CO_11F", "SILPHCO11F_JESSIE" },
{ "fade", "in" },
{ "play_default_music" },
{ "set_flag", "EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES" },
}, {})
return true
end,
}
return M
-131
View File
@@ -1,131 +0,0 @@
# Building a Blue Version of the port
pokered builds Red and Blue from one source tree: the Makefile
assembles everything twice with `rgbasm -D _RED` or `-D _BLUE`, and
every in-game difference sits in an `IF DEF(_RED)` / `IF DEF(_BLUE)`
block. Our extraction pipeline resolves those conditionals the same
way (`tools/extract/util.py` `ASM_DEFINES`), so most of a Blue build
falls out of re-running the extractors. Only three things are
hand-ported on the Lua side and need swapping by hand.
## What differs between Red and Blue
| Where (pokered) | What |
| --- | --- |
| `data/wild/maps/*.asm` (34 files) | Version-exclusive encounters (Red: Ekans, Oddish, Growlithe, Mankey, Scyther, Electabuzz, Blue: Sandshrew, Bellsprout, Vulpix, Meowth, Pinsir, Magmar) |
| `data/pokemon/title_mons.asm` | The 16 title-screen Pokémon |
| `engine/movie/title.asm` + `gfx/version.asm` | The "Red Version" / "Blue Version" ribbon |
| `constants/player_constants.asm` | Preset names (Red: RED/ASH/JACK + BLUE/GARY/JOHN) |
| `data/sgb/sgb_palettes.asm`, `sgb_border.asm` | Super Game Boy palettes and border |
| `data/events/prizes.asm`, `prize_mon_levels.asm` | Game Corner prize mons, costs and levels |
| `engine/movie/intro.asm`, `data/credits/credits_text.asm`, `engine/slots/slot_machine.asm`, `engine/battle/animations.asm`, `audio/sfx/save_3.asm` | Small gated tweaks (credits say "BLUE VERSION STAFF", etc.) |
## Step 1, flip the extraction define
`tools/extract/util.py`:
```python
ASM_DEFINES = {"_RED"} # -> {"_BLUE"}
```
Then regenerate everything (same as scripts/setup.sh does):
```sh
cd tools
../.venv/bin/python3 build_data.py \
--pokered /Users/bryanbassett/Documents/development/pokered \
--out ../data/generated --assets ../assets/generated
```
This alone switches the wild encounters, preset names, SGB palettes
and credits text. **Caveat:** `parse_preset_names` sanity-checks in
`tools/extract/field.py:1193` expect "RED" in the player presets,
relax that check for a Blue build (Blue's presets are BLUE/GARY/JOHN
for the player and RED/ASH/JACK for the rival).
`tools/extract/palettes.py` uses its own raw reader with hardcoded
`IF DEF(_BLUE)` skipping (around line 44), invert that too, or port
it to `read_asm` so `ASM_DEFINES` covers it.
## Step 2, title screen (hand-ported)
`src/ui/TitleState.lua`:
1. **Ribbon art.** The extractor writes
`assets/generated/title/red_version.png`; add `blue_version.png`
to `gfx.extract_title` in `tools/extract/gfx.py` (source:
`gfx/title/blue_version.png`, 64×8). In `TitleState:draw()`, the
Red strip needs two quads (tiles 01 "Red", skip, tiles 59
"Version", `title.asm` `VersionOnTitleScreenText`). Blue's strip
prints its tiles contiguously (`db $61..$68`), so draw the whole
64×8 image at px (56, 64) and verify with the title driver
screenshot.
2. **Title mons.** Replace `CYCLE_SPECIES` with Blue's list from
`data/pokemon/title_mons.asm`:
```lua
local CYCLE_SPECIES = {
"SQUIRTLE", "CHARMANDER", "BULBASAUR", "MANKEY", "HITMONLEE",
"VULPIX", "CHANSEY", "AERODACTYL", "JOLTEON", "SNORLAX",
"GLOOM", "POLIWAG", "DODUO", "PORYGON", "GENGAR", "RAICHU",
}
```
## Step 3, Game Corner prizes (hand-ported)
`data/scripts/story3.lua` (~line 209) carries the Red prize tables.
Blue's values (`prizes.asm` + `prize_mon_levels.asm`):
| Prize | Cost | Level |
| --- | --- | --- |
| ABRA | 120 | 6 |
| CLEFAIRY | 750 | 12 |
| NIDORINO | 1200 | 17 |
| PINSIR | 2500 | 20 |
| DRATINI | 4600 | 24 |
| PORYGON | 6500 | 18 |
(TM prizes are identical in both versions.)
## Step 4, verify
```sh
luajit tests/run_tests.lua
```
Plus two spot checks:
```sh
# every grass table must have exactly 10 slots (a conditional-parsing
# regression shows up as 1920 slots)
luajit -e 'local e=dofile("data/generated/encounters.lua")
for m,d in pairs(e) do if type(d)=="table" and d.grass and #d.grass.slots>0
and #d.grass.slots~=10 then print("BAD",m,#d.grass.slots) end end'
# a Blue exclusive should now appear (and Growlithe should not)
grep -c VULPIX data/generated/encounters.lua
grep -c GROWLITHE data/generated/encounters.lua
```
Title screenshot: run the driver in
`tests/drivers/` style (`SHOT_DIR=... POKEPORT_DRIVER=... love .`) and
eyeball the ribbon, title mon, and copyright row.
## What you get for free / what to skip
- Free after re-extraction: encounters (incl. Super Rod groups), SGB
palettes, preset names, credits text, the gated sfx/animation
tweaks.
- Trades, gift Pokémon, story scripts, gym data: identical in Western
Red/Blue, nothing to touch.
- Save files: a Red save loads fine, but dex AREA nests and new
encounters will be Blue's. Trainer parties are identical.
## Making it a runtime toggle instead
If you want one build with both versions, extraction would need to
emit both branches keyed by version (e.g. `slots` / `slotsBlue`) and
the three hand-ported spots would read a `save.version` or
`conf.lua` flag. That is a bigger change than the rebuild above,
the flip-and-regenerate route needs no engine changes at all.
+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).
+61
View File
@@ -202,3 +202,64 @@ column, slot card below Play, when the window is too narrow for both
(wheel, or drag on touch/desktop) clamped to their own content extent,
recomputed every draw. The tab bar labels only the active chip so it stays
narrow-safe, and content caps out at `~1440 * s` wide, centered.
The desktop window has a floor of 480x360 (`conf.lua` `minwidth`/`minheight`),
under which the cards stop being readable at all. Mobile ignores it: those
windows are fullscreen.
### Page scroll
Two columns fit any window the launcher is likely to open in; one stacked
column does not. On a phone-shaped window the ROM card, SAVE FILES, Play and
SAVE SLOT together run past the bottom, and a footer pinned to the window
bottom painted over them with the overflow unreachable.
So the whole column under the tab bar -- panel, updater banner, footer --
scrolls as one page whenever it is taller than the room below the tab bar:
- The strip, logo and tab bar stay pinned, so navigation is always on screen.
Everything else draws at `contentTop - pageScroll` inside a scissor, and the
footer is laid out downward from `footerTop` right after the content instead
of upward from the window bottom.
- `RomImporter.pageScrollFor(naturalH, viewportH, scroll)` is the whole
decision, pure and pinned by `tests/engine/launcher_page_scroll.lua`. A
window that grows back drags the offset down with it, so the page can never
stay parked past its own end.
- The panels report their natural height as they draw (`_drawGamePanel` and
`_drawModsPanel` return it), so the decision reads the previous frame's
measurement -- the same one-frame settle the two lists already rely on.
- **One scroll axis at a time.** While the page scrolls, the panels draw
`paged`: the slot and mod lists take their natural height, keep no inner
scroll region and report a max of 0, so the wheel, the right stick and a drag
all move the page and never fight a list for the same gesture. Two-column
layouts do not overflow, `paged` stays false, and every one of these behaves
exactly as it did before.
- Hit testing follows the clip: `inside` (clicks) and `_ptIn` (hover) reject a
rect that scrolled out of the viewport, so a control that slid under the tab
bar cannot be clicked through it. Tab chips carry `pinned = true` and are
exempt. `pageScroll` resets on a tab change, each tab being a different
length.
- A press on empty background pans the page, resolved in `_updateSlotDrag` like
every other drag here.
### Dragging on Android
The launcher is handed no move events on any platform: `main.lua` forwards
neither `touchmoved` nor `mousemoved` while it is up, which is why every drag
here is resolved by polling inside `draw` instead. Desktop polls the mouse;
Android used to poll nothing at all ("no reliable pointer polling" meant its
mouse emulation), so it had no scroll gesture whatsoever -- fine while every
scroll region was an inner list with a wheel alternative, useless the moment
the page itself became the thing that scrolls, since a phone is exactly where
it overflows.
`love.touch` is pollable, so `_pointerHold` reads the first active touch there
and hands `_updateSlotDrag` the same (held, y) pair the mouse gives on desktop.
Consequences:
- Slot rows and mod toggles ARM on press and commit on release on Android too,
matching desktop, so a swipe that starts on a card scrolls instead of
selecting the row it started on.
- `touchPollable` (set once in `new`) gates all of it. Where `love.touch` is
missing, every Android path is exactly what it was: act on press, never arm,
no drag.
+37
View File
@@ -18,6 +18,23 @@ Regenerate the reference straight into a wiki checkout:
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
```
## Editing maps in Tiled
Maps are data, not assets, so they can be authored in a real map editor and
exported as a mod. `tools/tiled_export.py` builds a
[Tiled](https://www.mapeditor.org) workspace out of the imported ROM cache:
```sh
python3 tools/tiled_export.py # -> build/tiled/ (gitignored)
```
Open `build/tiled/gen1.tiled-project`, edit any of the 222 maps (or
`kanto.world` for the stitched overworld), and export with the
`gen1-mod-export` extension — one map file, or a whole loadable mod folder.
An edited vanilla map becomes a `mod.content.maps:patch` carrying only the
fields that moved; a new map becomes a `:register`. See
`docs/new-features.md` and the extension's own README.
## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it
@@ -150,5 +167,25 @@ 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.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
+58 -6
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".)
@@ -193,6 +199,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
@@ -259,6 +270,15 @@ stick push hides it, the next screen touch brings it back, and unplugging
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
@@ -338,3 +358,35 @@ second, relabelling itself to `Confirm?` in between.
A validation pill in the tab rail mirrors what the running game would
quarantine on load; clicking it jumps to the tab holding the first problem.
## Tiled map editing (mod authoring)
`tools/tiled_export.py` turns the imported ROM cache into a Tiled workspace,
so maps can be edited in a real map editor and exported back out as a mod.
It has its own document: docs/tiled-map-editing.md.
## Pokédex diploma (both versions)
The Celadon Mansion 3F game designer shows the dex-completion diploma
once 150 species are owned. On Yellow, the graphic artist next to him
then offers to print it, saving the certificate as a PNG under `prints/`
in the save directory, and Bill's PC gains Yellow's PRINT BOX item which
exports the current box list the same way.
## Pokédex printing (Yellow)
Yellow's Game Boy Printer PRNT option in the Pokédex side menu is stood in
for by an image export: choosing PRNT renders the mon's entry page (sprite,
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.
+65
View File
@@ -0,0 +1,65 @@
# Tiled map editing (mod authoring)
`tools/tiled_export.py` turns the imported ROM cache into a
[Tiled](https://www.mapeditor.org) workspace, so maps can be edited in a
real map editor and exported back out as a mod. The original had no map
editor at all; the port's own map data is plain Lua, which is what makes
this a data path rather than an asset path.
Editing is done in our own Tiled build,
[bryanthaboi/tiled_gen1recomp](https://github.com/bryanthaboi/tiled_gen1recomp/releases),
which ships the `gen1-mod-export` extension the workspace relies on. Grab it
from that repo's releases; upstream Tiled opens the workspace but cannot
export a mod out of it.
```sh
python3 tools/tiled_export.py # -> build/tiled/ (gitignored)
```
Then open `build/tiled/gen1.tiled-project` in that build of Tiled.
- **The overworld is one surface.** All 222 maps become `maps/*.tmj`, and
`kanto.world` places the 36 connected overworld maps at their real
connection offsets. That world is pre-loaded (seeded into the workspace's
Tiled session), so opening any one overworld map draws its neighbors around
it and you scroll and edit straight across the seams. Everything else is a
double-click away in Tiled's project panel.
- **Extending Kanto wires both ends.** A connection lives on both maps, so
hooking a new map onto a base map also emits the return connection as a
patch on that base map, keeping its other directions intact. The return
offset is derived, not guessed: all 78 vanilla reciprocal pairs satisfy
`back.offset == -offset`.
- **A Tiled tile is a gen1 block.** Each of the 24 tilesets becomes a Tiled
tileset whose tiles are its 32x32 blocks, composited from the 8x8 sheet,
so a tile layer *is* the map's `blocks` array. Warps, signs and objects
sit on the 16px cell grid in object layers, which is the grid the engine
addresses them on.
- **Collision is visible.** View > Show Tile Collision Shapes draws the real
walkability: a rectangle covers each cell whose feet tile is not in the
tileset's `walkable` list, which is the rule `src/world/Map.lua` applies.
- **Maps are shown in their real colors.** Each map is atlased in the SGB
palette it renders with, so Cerulean is blue and Lavender is purple in the
editor exactly as in game. Vanilla resolves that through a cascade with
interiors inheriting the last outdoor map, so the workspace mirrors the
cascade and walks the warp graph to colour interiors. Changing a map's
`palette` exports `palette = "..."` on the record, which beats the cascade,
and the editor offers the real palette names as a dropdown.
- **New blocks and new tilesets.** `blocksets/*.tmj` show a tileset's blocks
as raw 8x8 tiles, four by four, so new blocks can be composed there;
per-tile flags on `tilesets/tiles_*.tsj` become `walkable`, `waterTiles`,
`doorTiles` and the rest.
- **Export is a diff, not a fork of the data.** The `gen1-mod-export`
extension (shipped in `tiled_gen1recomp`) writes either one map file or a whole
loadable mod folder. An edited vanilla map diffs against the imported data
and emits `mod.content.maps:patch` carrying *only* the fields that moved, so
a mod covers the parts it changes and leaves the rest to the base game; a
new map gets `:register` at an index of 1000 or above. An unchanged map
exports nothing at all. Exports pass `tools/modkit.py validate` and `lint`.
- **Or the whole record, on request.** Ticking `exactExport` on a map switches
it to `mod.content.maps:override`, pinning the map to exactly what the
editor shows. It is off by default because an override wins outright over
any other mod patching that map, where a patch composes.
No ROM-derived art travels into an exported mod: a tileset still drawing on
the player's own imported sheet references that path rather than shipping the
pixels, and only a sheet the author supplied is copied in.
+129 -19
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,11 +134,31 @@ 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); scripted and headless
-- runs fall back to POKEPORT_VERSION, then Red. Set the active version and
-- overlay its extracted cache BEFORE anything requires generated data, so
-- data/generated + assets/generated resolve to that version's files.
-- The launcher hands us the chosen game (Red / Blue / Yellow); scripted and
-- headless runs fall back to POKEPORT_VERSION, then Red. Set the active
-- version and overlay its extracted cache BEFORE anything requires generated
-- data, so data/generated + assets/generated resolve to that version's files.
local GameVersion = require("src.core.GameVersion")
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
require("src.import.CacheFs").mountVersion(GameVersion.get())
@@ -231,19 +251,25 @@ function love.load(args)
return
end
-- Interactive: the launcher always runs. Red and Blue are each live: a
-- column shows Play when that game's ROM is already imported, or Choose ROM
-- / drag-drop when it is not (Yellow is still a placeholder). Any dropped
-- .gb is routed to Red or Blue by its SHA-1; pressing Play boots that game.
-- Edit on a save row opens the bundled editor on that slot (openEditor).
-- Interactive: the launcher always runs. Red, Blue, and Yellow are each
-- live: a column shows Play when that game's ROM is already imported, or
-- Choose ROM / drag-drop when it is not. Any dropped .gb is routed by its
-- SHA-1 (GameVersion.forSha1); pressing Play boots that game. Edit on a
-- save row opens the bundled editor on that slot (openEditor).
Importer = RomImporter.new(function(version)
Importer = nil
bootGame(version)
end, { launcher = true, forceImport = forceImport, onEditSave = openEditor })
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,11 +456,18 @@ 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 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 return Importer:mousepressed(x, y, button) end
if editorMode and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, button)
@@ -394,6 +478,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 +492,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 +503,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 +517,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 +564,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
+5 -2
View File
@@ -70,7 +70,10 @@ The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
### Payload path
`app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`,
`data/`, `assets/`, and `tools/rom_manifest.json`. Generated game data,
`data/`, `assets/`, and the Red, Blue, and Yellow ROM manifests. The Android
packer verifies the Yellow manifest before it packages; if a partial source
export omitted it, it restores the file from this checkout's Git data and then
falls back to the project's GitHub copy. Generated game data,
scripts, tests, and mobile build sources are excluded.
## Branding (applied by the build script)
@@ -79,7 +82,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 |
@@ -41,6 +41,7 @@ import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.content.res.AssetManager;
import android.media.AudioManager;
@@ -78,6 +79,10 @@ 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";
// Destination basename for the in-flight SAF pick (set by showFilePicker).
private String pendingPickFilename = PICKED_ROM_FILENAME;
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
@@ -299,6 +304,54 @@ public class GameActivity extends SDLActivity {
super.onResume();
}
/**
* 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
public void setImmersiveMode(boolean immersive_mode) {
if (android.os.Build.VERSION.SDK_INT >= 28) {
@@ -452,6 +505,17 @@ 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());
}
}
private boolean copyFileToUri(File source, Uri destUri) {
InputStream in = null;
OutputStream out = null;
@@ -491,14 +555,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 +578,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);
}
}
+20
View File
@@ -1,5 +1,25 @@
# iOS build (LÖVE 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.
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"`.
+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>
+12 -1
View File
@@ -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>
@@ -100,5 +100,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>
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Applies gen1recomp's iOS native-bridge patches to the fetched LÖVE 11.5
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 "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()
{
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
return fs != nullptr ? fs->getSaveDirectory() : "";
}
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: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"
}
+12 -3
View File
@@ -66,17 +66,23 @@ rm -f "$LOVE_FILE"
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "game.love unexpectedly contains generated ROM data"
fi
# The editor is only reachable if its entry point and both module directories
# made it in; a silent miss would ship a launcher whose Edit button crashes.
# made it in, and every version's import manifest has to ship or that game's
# ROM import fails in the built app (dev reads them off the source tree, so
# the miss only ever shows up in a build -- the Yellow manifest shipped this
# way once).
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
tools/save-editor/panels/Party.lua; do
tools/save-editor/panels/Party.lua \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json; do
unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \
|| fail "game.love is missing $required (save editor would not load)"
|| fail "game.love is missing $required"
done
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
@@ -302,6 +308,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[@]}"}
}
+67 -1
View File
@@ -26,6 +26,11 @@ APP_NAME="gen1recomp"
APPLICATION_ID="com.theboisclub.pokemonred"
LOVE_ANDROID_VERSION="11.5a"
NDK_VERSION="25.2.9519653"
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
# A fresh source checkout normally supplies this through Git. This URL is
# deliberately only a last resort for incomplete source exports: the manifest
# contains extraction metadata, never a ROM or extracted game data.
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
VERSION=""
PACKAGE_ONLY=false
@@ -72,6 +77,59 @@ if [ ! -d "$ANDROID_DIR/love/src/jni/love/src" ]; then
Re-clone or 'git checkout -- mobile/android'. See mobile/ANDROID.md."
fi
# ------------------------------------------------------- Yellow import metadata
# Android packages game.love itself rather than reusing scripts/build.sh's
# archive. Keep a partial source export from silently shipping an APK that can
# list Yellow but cannot import it. Prefer the exact manifest from this
# checkout's Git object database; only then fall back to the public repository.
yellow_manifest_is_valid() {
local path="$1"
python3 - "$path" <<'PY'
import json, pathlib, sys
try:
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
except (OSError, ValueError):
raise SystemExit(1)
raise SystemExit(0 if manifest.get("romSha1") ==
"cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1" else 1)
PY
}
ensure_yellow_manifest() {
local manifest="$ROOT/$YELLOW_MANIFEST_RELATIVE"
local staged
staged="$(mktemp)"
if yellow_manifest_is_valid "$manifest"; then
rm -f "$staged"
return
fi
warn "Yellow import manifest is missing or invalid; recovering it before packaging"
if git -C "$ROOT" show "HEAD:$YELLOW_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \
&& yellow_manifest_is_valid "$staged"; then
mkdir -p "$(dirname "$manifest")"
mv "$staged" "$manifest"
say "restored Yellow import manifest from this checkout's Git data"
return
fi
if command -v curl >/dev/null 2>&1 \
&& curl --fail --location --retry 2 --connect-timeout 15 \
--output "$staged" "$YELLOW_MANIFEST_URL" \
&& yellow_manifest_is_valid "$staged"; then
mkdir -p "$(dirname "$manifest")"
mv "$staged" "$manifest"
say "downloaded Yellow import manifest from the project repository"
return
fi
rm -f "$staged"
fail "Yellow import manifest is unavailable. Git recovery failed and could not download $YELLOW_MANIFEST_URL"
}
# --------------------------------------------------------------- branding
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
# Manifest still gets permission trims. Re-applied every build so refreshing
@@ -135,6 +193,7 @@ PY
# --------------------------------------------------------------- game.love
pack_game_love() {
say "packing game.love for love-android embed flavor"
ensure_yellow_manifest
mkdir -p "$EMBED_ASSETS"
rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save
@@ -142,14 +201,21 @@ pack_game_love() {
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "game.love unexpectedly contains generated ROM data"
fi
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
# Do not pipe unzip straight into grep here: on a large archive grep can
# finish early and make unzip report SIGPIPE under `set -o pipefail`.
local archive_entries
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
grep -qx 'tools/save-editor/App.lua' <<< "$archive_entries" \
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
grep -qx "$YELLOW_MANIFEST_RELATIVE" <<< "$archive_entries" \
|| fail "game.love is missing the Yellow ROM import manifest"
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
# This script packs its own game.love (it does not reuse build.sh's), so it
+164 -16
View File
@@ -2,11 +2,16 @@
# 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).
#
# 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
# --version X.Y.Z stamp MARKETING_VERSION / CURRENT_PROJECT_VERSION
# --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/
# --package-only Zip game.love + apply plist overlay; skip xcodebuild
#
@@ -16,6 +21,7 @@
# - 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,7 +41,19 @@ LIBS_DIR="$XCODE_DIR/ios/libraries"
APP_NAME="gen1recomp"
DISPLAY_NAME="gen1recomp"
BUNDLE_ID="com.theboisclub.pokemonred"
# 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:-}"
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 11.5)"
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
APPLE_LIBS_ZIP="love-${LOVE_VERSION}-apple-libraries.zip"
@@ -46,6 +64,8 @@ FETCH=false
DEVICE=false
RELEASE=false
PACKAGE_ONLY=false
INSTALL=false
VERSION=""
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
@@ -57,15 +77,64 @@ while [ $# -gt 0 ]; do
--device) DEVICE=true ;;
--release) RELEASE=true ;;
--package-only) PACKAGE_ONLY=true ;;
--install) INSTALL=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
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).
@@ -166,16 +235,24 @@ pack_game_love() {
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.
(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 \
-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' \
unzip -Z1 "$LOVE_FILE" | grep -x 'tools/save-editor/App.lua' >/dev/null \
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
}
@@ -291,6 +368,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 +386,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")
@@ -364,20 +458,74 @@ run_xcodebuild() {
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
say "applying iOS native bridge patches (picker/Files support)"
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
pack_game_love
ensure_game_love_in_xcode
+7 -4
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env bash
# Build game data from a user-provided Pokemon Red ROM and install LÖVE.
# Build game data from a user-provided Pokemon Red/Blue/Yellow ROM and install LÖVE.
#
# Usage:
# scripts/setup.sh --rom /path/to/pokemon-red.gb
# scripts/setup.sh --rom /path/to/pokemon-yellow.gbc
# ROM_PATH=/path/to/pokemon-red.gb scripts/setup.sh
#
# With no explicit path, the first *.gb file in the project root is used.
# With no explicit path, the first *.gb / *.gbc file in the project root is used.
set -euo pipefail
@@ -34,15 +35,17 @@ command -v python3 >/dev/null 2>&1 \
|| fail "Python 3 is required to decode the ROM"
if [ -z "$ROM" ]; then
for candidate in "$ROOT"/*.gb; do
shopt -s nullglob
for candidate in "$ROOT"/*.gb "$ROOT"/*.gbc; do
if [ -f "$candidate" ]; then
ROM="$candidate"
break
fi
done
shopt -u nullglob
fi
[ -n "$ROM" ] && [ -f "$ROM" ] \
|| fail "Pokemon Red ROM not found. Put your .gb file in $ROOT or pass --rom /path/to/file.gb"
|| fail "Pokemon ROM not found. Put a .gb or .gbc in $ROOT or pass --rom /path/to/file"
if [ ! -x "$VENV/bin/python3" ]; then
say "creating Python environment"
+1 -1
View File
@@ -22,7 +22,7 @@ local NOTES = {
-- mirrors ChipAudio's snapTicks so authored drums land on the same sample
-- grid as the ROM's own drum tables
local function snapTicks(ticks)
return math.floor((ticks * 735 + 256) / 512)
return math.floor((ticks * 1470 + 256) / 512)
end
-- ------- validation
+282 -70
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
@@ -38,15 +38,16 @@ BattleState.letterboxWhite = true
-- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on
-- a 304x144 surface (src/battle/WideBattle.lua). Only the composition
-- differs; every battler, queue and animation below is shared. The wide
-- layout is live only while this battle is the state being drawn on top --
-- a party menu or bag pushed over it is a 160x144 screen, so the surface
-- goes back with it and the battle underneath is not drawn at all.
function BattleState:wideLayout()
-- differs; every battler, queue and animation below is shared. Menus and
-- prompts pushed during a wide battle keep its wide canvas, while drawing
-- their classic 160px UI centred within it (Game:draw).
function BattleState:isWideBattleLayout()
local options = self.game and self.game.save and self.game.save.options
if not options or options.battleLayout ~= "wide" then return false end
local stack = self.game.stack
return (stack and stack.top and stack:top()) == self
return options and options.battleLayout == "wide" or false
end
function BattleState:wideLayout()
return self:isWideBattleLayout()
end
-- Renderer:setUISize asks the top state for its surface before anything draws
@@ -187,6 +188,36 @@ local function namedPalette(data, name)
return { name = key, colors = colors }
end
-- Custom trainer portraits can opt into the same Advanced OBJ palette source
-- as their overworld walker. Vanilla trainers preserve the hardware-faithful
-- MEWMON fallback used during the battle introduction.
function BattleState.trainerPalette(data, trainer)
local source = trainer and trainer.paletteSource
if source then
local PaletteFX = require("src.render.PaletteFX")
local colors, group = PaletteFX.spriteObp({ paletteSource = source }, trainer.id)
if colors then
return { name = "trainer:" .. source .. ":" .. tostring(group), colors = colors }
end
end
return namedPalette(data, "MEWMON")
end
-- 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
end
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
-- SetAnimationBGPalette effects remap the four BG shades; on the SGB
-- the colorizer then colors the REMAPPED shade, so a faded pic shows
@@ -607,7 +638,9 @@ 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(self.trainer.pic, namedPalette(game.data, "MEWMON"))
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
@@ -630,8 +663,22 @@ end
-- player mon; the battle menu appears under the OLD MAN's name and a
-- scripted cursor hovers FIGHT, hops to ITEM and forces the item menu
-- (one POKé BALL x50). The throw always catches; nothing is kept.
function BattleState:makeOldManDemo()
-- Yellow's Pallet intro (BATTLE_TYPE_PIKACHU) is the same simulated
-- script under "PROF.OAK" (pokeyellow core.asm .profOakName), so the
-- displayed thrower name is a parameter.
function BattleState:makeOldManDemo(name)
self.demo = true
self.demoName = name or "OLD MAN"
-- 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
-- acts with the player side; a hidden placeholder battler keeps the
-- shared battle phases nil-safe.
if not self.player then
self.dead = false
self.player = makeBattler(self.game.data,
Pokemon.new(self.game.data, self.enemy.mon.species, 5), true)
end
end
-- Safari Zone battles (engine/battle/core.asm safari sections +
@@ -725,10 +772,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):
@@ -737,14 +791,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
@@ -822,6 +884,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
@@ -860,6 +924,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
@@ -1046,6 +1111,10 @@ function BattleState:computeMusicKind()
end
end
end
-- init_battle.asm: challenging a gym leader (wGymLeaderNo, the badge
-- fights only -- not Lance or the Champion) bumps the companion's
-- happiness the moment the battle starts
self.isGymLeader = isBoss
if self.kind == "trainer" and self.trainer
and self.trainer.id == "OPP_RIVAL3" then
return "final"
@@ -1078,14 +1147,42 @@ 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")
self.musicKind = self:computeMusicKind()
if self.isGymLeader then
require("src.world.PikachuFollower")
.modifyHappiness(self.game.save, "GYMLEADER")
end
-- normally already playing: the transition wipe starts the theme
-- (audio/play_battle_music.asm runs before the transition, and
-- Music.play no-ops on the same song); this covers battles pushed
@@ -1357,6 +1454,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
@@ -1409,10 +1507,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
@@ -1441,10 +1543,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
@@ -1711,7 +1817,7 @@ function BattleState:oldManThrow()
self.phase = "messages"
self.afterQueue = "finish"
self.result = "run" -- nothing is kept; wBattleResult only ends the demo
self:say(Strings("OLD MAN used\nPOKé BALL!"))
self:say(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN"))
self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss")
-- ItemUseBall's beat before the toss chain (like throwBall)
@@ -1914,6 +2020,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
@@ -2131,6 +2246,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).
@@ -2172,14 +2312,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 },
@@ -2276,34 +2409,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.
@@ -2523,6 +2699,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
@@ -2887,6 +3069,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)
@@ -2940,6 +3124,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
@@ -2964,7 +3152,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
@@ -2986,6 +3174,17 @@ function BattleState:onFaint(battler)
self.participants[battler.mon] = nil
end
Runtime.emit("battle.fainted", { battle = self, battler = battler })
if battler.isPlayer then
-- HandlePlayerMonFainted (core.asm:1070-1085): the companion loses
-- happiness on its own faint; an enemy 30+ levels above it makes
-- that the CARELESSTRAINER hit instead
local enemyLevel = self.enemy and self.enemy.mon
and self.enemy.mon.level or 0
local reason = (enemyLevel - (battler.mon.level or 0)) >= 30
and "CARELESSTRAINER" or "FAINTED"
require("src.world.PikachuFollower")
.modifyHappiness(self.game.save, reason, battler.mon)
end
-- the faint slide + cry ride the queue (after the move animation and
-- the HP-bar drain, pokered's order); the slide finishes before the
-- faint text via a queued hold
@@ -3070,6 +3269,9 @@ function BattleState:enemyMonFainted()
-- the move-learn checks (experience.asm:245-256)
local game = self.game
for _, lv in ipairs(levels) do
-- experience.asm:248 fires per grew-level text
require("src.world.PikachuFollower")
.modifyHappiness(game.save, "LEVELUP", mon)
self:sayNext(Strings("%s grew\nto level %d!", name, lv))
self:uiNext(function()
require("src.core.Sound").play(game.data, "Level_Up")
@@ -3797,12 +3999,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!"))
@@ -3890,7 +4095,10 @@ function BattleState:finish()
-- way back -- an unrecoverable state, not merely a wrong one.
-- playerMonFainted is the path that should have caught this; if we land
-- here it did not, so say so rather than silently papering over it.
if self.result ~= "lose" and not Party.firstHealthy(self.game.save.party) then
-- The old-man / PROF.OAK demo also skips it: the party never fought
-- (Yellow's Pallet intro runs before the player owns a mon at all).
if self.result ~= "lose" and not self.demo
and not Party.firstHealthy(self.game.save.party) then
Logger.warn("battle finished %s with no healthy party; forcing blackout",
tostring(self.result))
self.result = "lose"
@@ -4283,7 +4491,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]
@@ -4802,7 +5012,9 @@ function BattleState:drawTextArea()
Font.drawCode(Font.BORDER.br, 80, 96)
love.graphics.setColor(0, 0, 0, 1)
for i, mv in ipairs(self.player.curMoves) do
Font.draw(self.data.moves[mv.id].name, 48, 96 + i * 8)
-- unknown ids (mod-injected moves) print raw instead of crashing
local def = self.data.moves[mv.id]
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
end
Font.drawCode((self.moveSwapIndex == self.moveIndex) and 0xEC or 0xED,
40, 96 + self.moveIndex * 8)
@@ -4811,10 +5023,10 @@ function BattleState:drawTextArea()
end
local sel = self.player.curMoves[self.moveIndex]
if sel then
local def = self.data.moves[sel.id]
if self.player.disabledSlot == self.moveIndex then
Font.draw(Strings("disabled!"), 8, 80)
else
local def = self.data.moves[sel.id]
elseif def then
Font.draw(Strings("TYPE/"), 8, 72)
-- the type record's display name (a mod type shows its name, and
-- PSYCHIC_TYPE prints PSYCHIC like the original)
+8
View File
@@ -186,7 +186,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,
},
+118 -4
View File
@@ -25,6 +25,32 @@ local SAMPLE_RATE = ChipSynth.SAMPLE_RATE
local MUSIC_BUFFER_SAMPLES = ChipSynth.MUSIC_BUFFER_SAMPLES
local MUSIC_BUFFER_COUNT = ChipSynth.MUSIC_BUFFER_COUNT
-- ---------------------------------------------------------------------------
-- Per-channel mix (edit these)
-- Applied on load and whenever this file hot-reloads.
-- Runtime: ChipAudio.setChannelVolume / setChannelPitch.
-- [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] = 1, -- wave
[4] = 1, -- noise / drums
}
local CHANNEL_PITCH = {
[1] = 1, -- pulse 1
[2] = 1, -- pulse 2
[3] = 1, -- wave
[4] = 1, -- noise / drums
}
ChipSynth.setChannelVolumes(CHANNEL_VOLUME)
ChipSynth.setChannelPitches(CHANNEL_PITCH)
-- currentMusic: { source, gen, threaded, started, finished, engine }
-- threaded songs stream from the worker (engine is nil here);
-- the fallback path owns a local engine and fills the source itself.
@@ -32,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
-- ---------------------------------------------------------------------------
@@ -125,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
@@ -152,13 +186,23 @@ function ChipAudio.playMusic(data, header, allowLoops)
musicGen = musicGen + 1
local gen = musicGen
cmdCh:push({ cmd = "play", gen = gen, header = header,
allowLoops = allowLoops, audio = slimAudio(data) })
allowLoops = allowLoops, audio = slimAudio(data),
channelVolumes = ChipSynth.getChannelVolumes(),
channelPitches = ChipSynth.getChannelPitches() })
currentMusic = { source = source, gen = gen, threaded = true,
started = false, finished = false }
-- playback starts in update() once the first buffer arrives (~1 frame)
return source
end
local function pushChannelMix()
if workerReady and cmdCh then
cmdCh:push({ cmd = "channelMix",
volumes = ChipSynth.getChannelVolumes(),
pitches = ChipSynth.getChannelPitches() })
end
end
-- move finished buffers from the worker into the Source; start playback once
-- the first one lands
local function updateThreaded()
@@ -189,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
@@ -212,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)
@@ -230,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"
@@ -270,6 +326,64 @@ 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)
ChipSynth.setChannelVolume(hw, scale)
pushChannelMix()
end
function ChipAudio.getChannelVolume(hw)
return ChipSynth.getChannelVolume(hw)
end
function ChipAudio.setChannelVolumes(volumes)
ChipSynth.setChannelVolumes(volumes)
pushChannelMix()
end
function ChipAudio.getChannelVolumes()
return ChipSynth.getChannelVolumes()
end
function ChipAudio.setChannelPitch(hw, scale)
ChipSynth.setChannelPitch(hw, scale)
pushChannelMix()
end
function ChipAudio.getChannelPitch(hw)
return ChipSynth.getChannelPitch(hw)
end
function ChipAudio.setChannelPitches(pitches)
ChipSynth.setChannelPitches(pitches)
pushChannelMix()
end
function ChipAudio.getChannelPitches()
return ChipSynth.getChannelPitches()
end
-- aliases for channel 4 (noise / drums)
function ChipAudio.setNoiseVolume(scale)
ChipAudio.setChannelVolume(4, scale)
end
function ChipAudio.getNoiseVolume()
return ChipAudio.getChannelVolume(4)
end
-- a stale song must not keep sounding past the flush that replaced its
-- program (20 §2 cache contract, chip music row)
Assets.register(ChipAudio.invalidate)
+124 -47
View File
@@ -14,26 +14,97 @@ local bit = require("bit")
local ChipSynth = {}
local SAMPLE_RATE = 22050
local SAMPLE_RATE = 44100
local TICKS_PER_SECOND = 15360
local FRAME_TICKS = 256
local GB_CLOCK = 4194304
-- one 4096-sample stereo SoundData is the unit both the worker hands off and
-- one 8192-sample stereo SoundData is the unit both the worker hands off and
-- the synchronous fallback queues; the source keeps MUSIC_BUFFER_COUNT of them
-- (~6s) for stall tolerance (window resize, a long GC pause)
local MUSIC_BUFFER_SAMPLES = 4096
-- (~6s at 44100) for stall tolerance (window resize, a long GC pause)
local MUSIC_BUFFER_SAMPLES = 8192
local MUSIC_BUFFER_COUNT = 32
ChipSynth.SAMPLE_RATE = SAMPLE_RATE
ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES
ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT
-- Runtime mix per hardware channel (1 pulse, 2 pulse, 3 wave, 4 noise).
-- Volume: 1 = authentic GB, 0 = mute. Pitch: 1 = authentic, 2 = +1 octave,
-- 0.5 = -1 octave. Applied at sample time so a live change reaches the next
-- buffer on both the sync path and the worker (via ChipAudio).
local channelVolume = { 1, 1, 1, 1 }
local channelPitch = { 1, 1, 1, 1 }
local function clampScale(scale)
return math.max(0, tonumber(scale) or 0)
end
local function setChannelTable(table, hw, scale)
hw = tonumber(hw)
if not hw or hw < 1 or hw > 4 then return end
table[hw] = clampScale(scale)
end
local function setChannelTables(table, values)
if type(values) ~= "table" then return end
for hw = 1, 4 do
if values[hw] ~= nil then table[hw] = clampScale(values[hw]) end
end
end
function ChipSynth.setChannelVolume(hw, scale)
setChannelTable(channelVolume, hw, scale)
end
function ChipSynth.getChannelVolume(hw)
return channelVolume[tonumber(hw) or 0] or 1
end
function ChipSynth.setChannelVolumes(volumes)
setChannelTables(channelVolume, volumes)
end
function ChipSynth.getChannelVolumes()
return { channelVolume[1], channelVolume[2], channelVolume[3], channelVolume[4] }
end
function ChipSynth.setChannelPitch(hw, scale)
setChannelTable(channelPitch, hw, scale)
end
function ChipSynth.getChannelPitch(hw)
return channelPitch[tonumber(hw) or 0] or 1
end
function ChipSynth.setChannelPitches(pitches)
setChannelTables(channelPitch, pitches)
end
function ChipSynth.getChannelPitches()
return { channelPitch[1], channelPitch[2], channelPitch[3], channelPitch[4] }
end
-- aliases for the noise/drum layer
function ChipSynth.setNoiseVolume(scale)
ChipSynth.setChannelVolume(4, scale)
end
function ChipSynth.getNoiseVolume()
return ChipSynth.getChannelVolume(4)
end
local PITCHES = {
0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23,
0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA,
}
local DUTY = { [0] = 0.125, [1] = 0.25, [2] = 0.5, [3] = 0.75 }
-- LuaGB / DMG 8-step duty tables (index 0-3); stored on channels as that index
local WAVE_PATTERN_TABLES = {
[0] = {0, 0, 0, 0, 0, 0, 0, 1},
[1] = {1, 0, 0, 0, 0, 0, 0, 1},
[2] = {1, 0, 0, 0, 0, 1, 1, 1},
[3] = {0, 1, 1, 1, 1, 1, 1, 0},
}
local WAVE_LEVEL = { [0] = 0, [1] = 1, [2] = 0.5, [3] = 0.25 }
local NOISE_DIVISORS = {
[0] = 8, [1] = 16, [2] = 32, [3] = 48,
@@ -41,7 +112,7 @@ local NOISE_DIVISORS = {
}
local function snapTicks(ticks)
return math.floor((ticks * 735 + 256) / 512)
return math.floor((ticks * 1470 + 256) / 512)
end
local cachedProgramFile
@@ -143,7 +214,7 @@ function Channel.new(engine, spec, options)
speed = 12,
volume = 12,
fade = 0,
duty = 0.5,
duty = 2,
octave = 4,
waveInstrument = 0,
waveLevel = 1,
@@ -317,7 +388,7 @@ function Channel:nextEvent()
target = self:frequency(bit.band(packed, 0x0F), octave),
}
elseif command == 0xEC then
self.duty = DUTY[bit.band(self:byte(), 3)] or 0.5
self.duty = bit.band(self:byte(), 3)
elseif command == 0xED then
self.engine.tempo = self:byte() * 0x100 + self:byte()
elseif command == 0xEE then
@@ -329,10 +400,10 @@ function Channel:nextEvent()
elseif command == 0xFC then
local packed = self:byte()
self.duty = {
DUTY[bit.band(bit.rshift(packed, 6), 3)],
DUTY[bit.band(bit.rshift(packed, 4), 3)],
DUTY[bit.band(bit.rshift(packed, 2), 3)],
DUTY[bit.band(packed, 3)],
bit.band(bit.rshift(packed, 6), 3),
bit.band(bit.rshift(packed, 4), 3),
bit.band(bit.rshift(packed, 2), 3),
bit.band(packed, 3),
}
elseif command == 0xFD then
self.callStack[#self.callStack + 1] = self.address + 2
@@ -423,27 +494,24 @@ function Channel:sampleNoise(parameter)
parameter = parameter or 0
local divisor = NOISE_DIVISORS[bit.band(parameter, 7)]
local shift = bit.rshift(parameter, 4)
local output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
if shift >= 14 then return output end
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE
local width7 = bit.band(parameter, 8) ~= 0
local remaining = cycles
local area = 0
while remaining > 0 do
local untilClock = 1 - self.noiseClock
local span = math.min(remaining, untilClock)
output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
area = area + output * span
self.noiseClock = self.noiseClock + span
remaining = remaining - span
if self.noiseClock >= 1 - 1e-12 then
self.noiseClock = 0
self:clockNoise(width7)
if shift < 14 then
local pitch = channelPitch[self.hardware] or 1
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE * pitch
local width7 = bit.band(parameter, 8) ~= 0
local remaining = cycles
while remaining > 0 do
local untilClock = 1 - self.noiseClock
local span = math.min(remaining, untilClock)
self.noiseClock = self.noiseClock + span
remaining = remaining - span
if self.noiseClock >= 1 - 1e-12 then
self.noiseClock = 0
self:clockNoise(width7)
end
end
end
return area / cycles
-- LuaGB: instantaneous inverted LFSR LSB (high when bit0 == 0)
return bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
end
local function sweepCalculation(register, sweep)
@@ -481,7 +549,7 @@ function Channel:sampleDrum(event, sampleIndex)
end
local elapsed = (sampleIndex - segment.startSample) / SAMPLE_RATE
local volume = envelopeVolume(segment.volume, segment.fade, elapsed)
return self:sampleNoise(segment.parameter) * volume / 15 * 0.35
return self:sampleNoise(segment.parameter) * volume / 15
end
function Channel:sample()
@@ -498,11 +566,14 @@ function Channel:sample()
event.sample = sampleIndex + 1
if event.silence then return 0 end
if event.drum then return self:sampleDrum(event, sampleIndex) end
local gain = channelVolume[self.hardware] or 1
if event.drum then
return self:sampleDrum(event, sampleIndex) * gain
end
local volume = envelopeVolume(
event.volume or 0, event.fade or 0, event.elapsed)
if event.noise then
return self:sampleNoise(event.noiseParameter) * volume / 15 * 0.35
return self:sampleNoise(event.noiseParameter) * volume / 15 * gain
end
local register = event.register
@@ -527,7 +598,8 @@ function Channel:sample()
end
end
end
local frequency = 131072 / (2048 - math.min(register, 2047))
local pitch = channelPitch[self.hardware] or 1
local frequency = 131072 / (2048 - math.min(register, 2047)) * pitch
if event.wave then frequency = frequency * 0.5 end
local phase = self.phase
self.phase = (phase + frequency / SAMPLE_RATE) % 1
@@ -537,13 +609,18 @@ function Channel:sample()
-- a def-local program may omit its wave table entirely
if not wave then return 0 end
local index = math.min(32, math.floor(phase * 32) + 1)
return wave[index] * event.waveLevel * 0.55
return wave[index] * event.waveLevel * gain
end
local duty = event.duty
if type(duty) == "table" then
duty = duty[frame % 4 + 1]
end
return (phase < duty and 1 or -1) * volume / 15 * 0.5
local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2]
local step = math.floor(phase * 8) % 8
if pattern[step + 1] == 0 then
return -volume / 15 * gain
end
return volume / 15 * gain
end
local Engine = {}
@@ -597,8 +674,8 @@ local function readWaves(banks, audio, engineNumber)
for byteIndex = 0, 15 do
local packed = romByte(
banks, spec.bank, spec.address + wave * 16 + byteIndex)
values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5
values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5
values[#values + 1] = (bit.rshift(packed, 4) - 8) / 8
values[#values + 1] = (bit.band(packed, 0x0F) - 8) / 8
end
waves[#waves + 1] = values
end
@@ -606,8 +683,8 @@ local function readWaves(banks, audio, engineNumber)
for byteIndex = 0, 15 do
local packed = romByte(
banks, spec.bank, spec.address + 5 * 16 + byteIndex)
values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5
values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5
values[#values + 1] = (bit.rshift(packed, 4) - 8) / 8
values[#values + 1] = (bit.band(packed, 0x0F) - 8) / 8
end
for _ = 1, 4 do waves[#waves + 1] = values end
return waves
@@ -615,7 +692,7 @@ end
-- def-local waves are authored either as raw 0-15 nibbles (the ROM's own
-- units) or as the -1..1 samples readWaves produces; the synth wants the
-- latter
-- latter (LuaGB: (nibble - 8) / 8)
local function normalizeWaves(source)
local waves = {}
for index, values in ipairs(source) do
@@ -625,7 +702,7 @@ local function normalizeWaves(source)
end
local wave = {}
for position, value in ipairs(values) do
wave[position] = nibbles and (value - 7.5) / 7.5 or value
wave[position] = nibbles and (value - 8) / 8 or value
end
waves[index] = wave
end
@@ -690,7 +767,7 @@ end
function Engine:sample()
local value = 0
for _, channel in ipairs(self.channels) do value = value + channel:sample() end
return math.max(-1, math.min(1, value * 0.5))
return math.max(-1, math.min(1, value / 4))
end
function Engine:sampleStereo()
@@ -701,8 +778,8 @@ function Engine:sampleStereo()
if not event or event.panLeft ~= false then left = left + value end
if not event or event.panRight ~= false then right = right + value end
end
return math.max(-1, math.min(1, left * 0.5)),
math.max(-1, math.min(1, right * 0.5))
return math.max(-1, math.min(1, left / 4)),
math.max(-1, math.min(1, right / 4))
end
function Engine:sampleChannel(number)
@@ -711,7 +788,7 @@ function Engine:sampleChannel(number)
local value = channel:sample()
if channel.number == number then selected = value end
end
return math.max(-1, math.min(1, selected * 0.5))
return math.max(-1, math.min(1, selected / 4))
end
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
+33 -3
View File
@@ -38,6 +38,22 @@ local CONSTANT_DEFAULTS = {
},
}
-- data/events/trades.asm in Pokemon Yellow has its own TradeMons table.
-- Old Yellow imports were built from the Red manifest, so correct that
-- table after loading as well as in the fixed import manifest below.
local YELLOW_TRADES = {
{ give = "LICKITUNG", get = "DUGTRIO", dialogset = 1, nickname = "GURIO" },
{ give = "CLEFAIRY", get = "MR_MIME", dialogset = 1, nickname = "MILES" },
{ give = "BUTTERFREE", get = "BEEDRILL", dialogset = 3, nickname = "STINGER" },
{ give = "KANGASKHAN", get = "MUK", dialogset = 1, nickname = "STICKY" },
{ give = "MEW", get = "MEW", dialogset = 3, nickname = "BART" },
{ give = "TANGELA", get = "PARASECT", dialogset = 1, nickname = "SPIKE" },
{ give = "PIDGEOT", get = "PIDGEOT", dialogset = 2, nickname = "MARTY" },
{ give = "GOLDUCK", get = "RHYDON", dialogset = 2, nickname = "BUFFY" },
{ give = "GROWLITHE", get = "DEWGONG", dialogset = 3, nickname = "CEZANNE" },
{ give = "CUBONE", get = "MACHOKE", dialogset = 3, nickname = "RICKY" },
}
-- field.boot is the total-conversion override point for the new game; the
-- values match what SaveData.newGame and the Oak speech used to inline.
local BOOT_DEFAULTS = {
@@ -58,6 +74,12 @@ local function copy(value)
return out
end
function Data:applyVersionedFieldData()
if require("src.core.GameVersion").isYellow() then
self.field.trades = copy(YELLOW_TRADES)
end
end
-- Fills only what the cache is missing, so an importer that learns to
-- stamp one of these keys silently takes over from the engine.
function Data:seedDefaults()
@@ -77,6 +99,7 @@ function Data:seedDefaults()
if constants.dexDigits == nil then
constants.dexDigits = math.max(3, #tostring(constants.dexSize))
end
self:applyVersionedFieldData()
local boot = self.field.boot
if boot == nil then
boot = {}
@@ -85,6 +108,13 @@ function Data:seedDefaults()
for key, value in pairs(BOOT_DEFAULTS) do
if boot[key] == nil then boot[key] = copy(value) end
end
-- Yellow boots its own attract movie (engine/movie/intro_yellow.asm);
-- only the un-overridden default flips, so a total conversion that set
-- field.boot.screens.splash keeps its choice on any version.
if boot.screens.splash == BOOT_DEFAULTS.screens.splash
and require("src.core.GameVersion").isYellow() then
boot.screens.splash = "YellowIntro"
end
-- the naming screen presets the importer already extracts but nothing
-- ever read (field.presetNames)
if boot.namePresets == nil then
@@ -114,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
@@ -130,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",
+126 -16
View File
@@ -90,10 +90,13 @@ function Game:load()
self.save.player.x, self.save.player.y, self.save.player.facing)
else
local titleState = self:makeTitleState()
-- the copyright splash + Nidorino-vs-Gengar attract movie plays
-- before the title (engine/movie/splash.asm + intro.asm); the ids come
-- from field.boot.screens so a total conversion owns the whole boot
Screens.push(self, bootScreens(self).splash or "IntroMovie", function()
-- the copyright splash + attract movie plays before the title
-- (engine/movie/splash.asm + intro.asm; Yellow swaps in its own
-- 18-scene movie, engine/movie/intro_yellow.asm); the ids come from
-- field.boot.screens so a total conversion owns the whole boot
local splash = require("src.core.GameVersion").isYellow()
and "YellowIntro" or "IntroMovie"
Screens.push(self, bootScreens(self).splash or splash, function()
StateStack:push(titleState)
end)
end
@@ -169,6 +172,11 @@ 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()
-- serviced unconditionally: a link battle's ENet transport must not
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
@@ -241,35 +249,93 @@ end
-- exactly as the owning state computed it
local function sameZones(_, zones) return zones end
-- A wide battle owns the surface until it leaves the stack. The party,
-- bag, choice and text states it opens still draw their original 160px UI,
-- but the canvas must not snap to 160px between those states.
function Game.wideBattleInStack(stack)
for i = #(stack and stack.states or {}), 1, -1 do
local state = stack.states[i]
if state and state.isWideBattleLayout and state:isWideBattleLayout() then
return state
end
end
return nil
end
-- Shift classic SGB zones to the centred UI. A full-width base zone extends
-- into both margins, keeping the canvas' paper color continuous; narrower
-- sprite and status zones move with the classic UI content.
local function centerClassicZones(zones, offset)
if not zones or offset == 0 then return zones end
local shifted = {}
for i, zone in ipairs(zones) do
local copy = {}
for key, value in pairs(zone) do copy[key] = value end
if copy.x == 0 and copy.w == Renderer.WIDTH then
copy.w = copy.w + offset * 2
else
copy.x = (copy.x or 0) + offset
end
shifted[i] = copy
end
return shifted
end
function Game:draw()
-- the UI canvas clears transparent when the overworld's world pass
-- shows through beneath it; opaque full-screen states get the classic
-- white clear
local base = self.stack:visibleBase()
local worldBelow = self.stack.states[base] == self.overworld
-- The UI surface is resolved once, before any state draws: the top state
-- may want more than the Game Boy's 160x144 (the widescreen battle layout
-- asks for 304x144). Anything else keeps the classic surface, so a menu
-- pushed over a wide battle brings the screen straight back to 160x144.
-- A wide battle holds its 304px surface through every menu or prompt it
-- opens. States that do not draw the wide battle composition are centred
-- in that surface below, so their classic coordinates and hit testing stay
-- unchanged. Outside a battle, including the title screen, the option is
-- intentionally inactive because it is a battle-layout setting.
local top = self.stack:top()
if top and top.uiSize then
local wideBattle = Game.wideBattleInStack(self.stack)
local classicOffset = 0
if wideBattle and wideBattle.uiSize then
Renderer:setUISize(wideBattle:uiSize())
classicOffset = math.floor((select(1, Renderer:uiSize()) - Renderer.WIDTH) / 2)
elseif top and top.uiSize then
Renderer:setUISize(top:uiSize())
else
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
end
Renderer:beginFrame(worldBelow)
self.stack:draw()
for i = self.stack:visibleBase(), #self.stack.states do
local state = self.stack.states[i]
local wideState = state and state.isWideBattleLayout
and state:isWideBattleLayout()
if state and state.draw then
if classicOffset ~= 0 and not wideState then
love.graphics.push()
love.graphics.translate(classicOffset, 0)
state:draw()
love.graphics.pop()
else
state:draw()
end
end
end
-- SGB colorization: the topmost state that knows its palette owns the
-- screen (overlays like text boxes inherit from what's beneath them);
-- the overworld's world pass colors each visible map area separately
local zones, worldZones
local zones, worldZones, zoneOwner
for i = #self.stack.states, 1, -1 do
local s = self.stack.states[i]
if s.sgbPalettes then
zones = s:sgbPalettes(self)
zoneOwner = s
break
end
end
if classicOffset ~= 0 and zoneOwner
and not (zoneOwner.isWideBattleLayout
and zoneOwner:isWideBattleLayout()) then
zones = centerClassicZones(zones, classicOffset)
end
-- 14's render.zones: weather/lighting overlays and custom colorization
-- recolor or add zones before the blit
if ModRuntime.wantsHook("render.zones") then
@@ -278,7 +344,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
@@ -400,9 +472,7 @@ end
-- LÖVE process ensures scripts, registries, and assets are all rebuilt from
-- the newly selected mod state.
function Game:restartWithMods()
if love.event and love.event.quit then
love.event.quit("restart")
end
require("src.core.HostShell").restart()
end
function Game:keyreleased(key)
@@ -432,6 +502,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
@@ -485,6 +589,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
@@ -495,7 +604,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
@@ -526,6 +635,7 @@ function Game:applyOptions(opts)
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
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
+25 -8
View File
@@ -1,12 +1,13 @@
-- Which Gen-1 game this process is running: Red (the historical default) or
-- Blue. One source of truth for everything that differs by version -- the
-- accepted ROM hash, the import manifest, where the extracted cache lives,
-- and the save-file suffix -- so the importer, cache mount, SaveData, title
-- screen and palette all agree.
-- Which Gen-1 game this process is running: Red (the historical default),
-- Blue, or Yellow. One source of truth for everything that differs by
-- version -- the accepted ROM hash, the import manifest, where the
-- extracted cache lives, and the save-file suffix -- so the importer,
-- cache mount, SaveData, title screen and palette all agree.
--
-- Red keeps every un-suffixed path it always used (save.lua, the root cache),
-- so existing installs are untouched; Blue is namespaced under blue/ and
-- _blue so both can be imported and played side by side.
-- _blue, Yellow under yellow/ and _yellow, so all three can be imported and
-- played side by side.
--
-- Zero requires, so it loads during love.conf and under plain Lua for tools
-- and tests. The active version is a process-global set once at boot from
@@ -19,6 +20,7 @@ GameVersion.VERSIONS = {
id = "red",
label = "Red",
displayName = "Pokemon Red",
launcherName = "Red", -- game-panel header in the launcher
sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
manifest = "tools/rom_manifest.json",
cachePrefix = "", -- Red owns the cache root (backwards compatible)
@@ -28,15 +30,26 @@ GameVersion.VERSIONS = {
id = "blue",
label = "Blue",
displayName = "Pokemon Blue",
launcherName = "Blue",
sha1 = "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2",
manifest = "tools/rom_manifest_blue.json",
cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated
saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp
},
yellow = {
id = "yellow",
label = "Yellow",
displayName = "Pokemon Yellow",
launcherName = "Yellow (alpha)",
sha1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1",
manifest = "tools/rom_manifest_yellow.json",
cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated
saveSuffix = "_yellow", -- save_yellow.lua / .bak / .tmp
},
}
-- Launcher column order (Yellow is still a placeholder, handled by the UI).
GameVersion.ORDER = { "red", "blue" }
-- Launcher column order.
GameVersion.ORDER = { "red", "blue", "yellow" }
GameVersion.current = "red"
@@ -53,6 +66,10 @@ function GameVersion.isBlue()
return GameVersion.current == "blue"
end
function GameVersion.isYellow()
return GameVersion.current == "yellow"
end
-- Metadata for a version id, defaulting to the active one.
function GameVersion.info(id)
return GameVersion.VERSIONS[id or GameVersion.current]
+51
View File
@@ -0,0 +1,51 @@
-- Helpers for calling host tools (curl, zenity/kdialog, ...).
local HostShell = {}
-- Our AppRun exports LD_LIBRARY_PATH="$APPDIR/lib:..." so every subprocess we
-- spawn tries to link against the libraries we're shipping instead of the
-- system ones. We want to unset the var so that any system tools can find
-- their proper libraries. Only needed when running in an AppImage.
function HostShell.envPrefix()
if os.getenv("APPIMAGE") then
return "env -u LD_LIBRARY_PATH "
end
return ""
end
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
function HostShell.popen(command, mode)
local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
if not ok or not pipe then return nil end
return pipe
end
-- Restart the whole app. The obvious love.event.quit("restart") re-runs LÖVE's
-- boot in-process, which calls love.filesystem.init a second time -- and inside
-- an AppImage physfs is already initialized, so that second init throws
-- ("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.
-- 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 appimage = os.getenv("APPIMAGE")
if not appimage then
love.event.quit("restart")
return
end
-- We have to restart the process with this cursed execv call to prevent the
-- PID from changing, which might cause SteamOS and other Linux launchers to
-- think the app has crashed.
local ffi = require("ffi")
pcall(ffi.cdef, [[
int execv(const char *path, char *const argv[]);
int unsetenv(const char *name);
]])
ffi.C.unsetenv("LD_LIBRARY_PATH")
local argv = ffi.new("const char *[2]", appimage, nil)
ffi.C.execv(appimage, ffi.cast("char *const *", argv))
end
return HostShell
+45
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,6 +239,26 @@ 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
+4
View File
@@ -85,6 +85,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 +95,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
+44
View File
@@ -0,0 +1,44 @@
-- Game Boy Printer stand-in. Yellow's printer jobs
-- (engine/printer/printer.asm: PrintPokedexEntry and friends) drove a
-- serial thermal printer; this port renders the same printout into a PNG
-- under prints/ in the save directory instead, and the caller shows a
-- dialog with where it landed. Scaled up 4x so the "print" is legible
-- on a modern screen.
local Logger = require("src.core.Logger")
local Printer = {}
local SCALE = 4
-- Render drawFn (which draws a w x h GB-pixel image at 0,0) into
-- prints/<name>_<stamp>.png. Returns the save-dir-relative path, or nil
-- and an error string (headless / no canvas support degrades gracefully).
function Printer.save(name, w, h, drawFn)
if not (love.graphics and love.graphics.newCanvas) then
return nil, "no graphics"
end
local ok, canvas = pcall(love.graphics.newCanvas, w * SCALE, h * SCALE)
if not ok then return nil, tostring(canvas) end
love.graphics.push("all")
love.graphics.setCanvas(canvas)
love.graphics.origin()
love.graphics.scale(SCALE, SCALE)
love.graphics.clear(1, 1, 1, 1)
love.graphics.setColor(1, 1, 1, 1)
local drawOk, drawErr = pcall(drawFn)
love.graphics.pop()
if not drawOk then return nil, tostring(drawErr) end
local data
ok, data = pcall(canvas.newImageData, canvas)
if not ok then return nil, tostring(data) end
love.filesystem.createDirectory("prints")
local path = ("prints/%s_%s.png"):format(name, os.date("%Y-%m-%d_%H%M%S"))
local encOk, err = pcall(data.encode, data, "png", path)
if not encOk then return nil, tostring(err) end
Logger.info("printed %s -> %s/%s",
name, love.filesystem.getSaveDirectory(), path)
return path
end
return Printer
+60 -24
View File
@@ -24,10 +24,11 @@ local GameVersion = require("src.core.GameVersion")
local SaveData = {}
-- Progress files carry the game-version suffix so Red and Blue saves coexist:
-- Red keeps save.lua / .bak / .tmp exactly as before; Blue is save_blue.lua
-- (+ .bak/.tmp). options.lua is deliberately shared across versions (it holds
-- global preferences and the mod enable-state, not per-playthrough data).
-- Progress files carry the game-version suffix so Red / Blue / Yellow saves
-- coexist: Red keeps save.lua / .bak / .tmp exactly as before; Blue is
-- save_blue.lua and Yellow is save_yellow.lua (+ .bak/.tmp). options.lua is
-- deliberately shared across versions (it holds global preferences and the
-- mod enable-state, not per-playthrough data).
local OPTIONS_FILENAME = "options.lua"
-- Main / backup / staged-witness names for a version (defaults to the active
@@ -109,11 +110,14 @@ local function makePortableFs(dir)
}
end
local function detectPortable()
if portableChecked then return portableBase end
portableChecked = true
portableBase = false
if not (love and love.filesystem) then return false end
-- Every folder a player might reasonably call "the game folder", best first:
-- the packaged-app container, the folder holding the executable, then the
-- source itself. Portable mode is the case where one of these holds the
-- marker; the list itself is just locations, marker or not, which is also
-- what the mods panel needs to notice a mod dropped beside the game by hand
-- (LauncherMods.strays). Empty on Android/iOS and outside LOVE.
function SaveData.gameFolders()
if not (love and love.filesystem) then return {} end
-- Desktop only: portable mode carries the save (and, since issue #74, the
-- ROM cache) in the game folder next to the executable/source. On
-- Android/iOS the source is a read-only package with no such folder, so
@@ -121,7 +125,7 @@ local function detectPortable()
if love.system and love.system.getOS then
local osName = love.system.getOS()
if osName ~= "Windows" and osName ~= "Linux" and osName ~= "OS X" then
return false
return {}
end
end
local src = love.filesystem.getSource and love.filesystem.getSource()
@@ -149,15 +153,24 @@ local function detectPortable()
-- Order: the packaged-app containing folder (macOS .app / Linux AppImage),
-- then the source-base directory (next to a packaged .exe), then the source
-- itself (a `love <gamedir>` run drops portable.txt in the game folder).
-- First one holding the marker wins. Built by appending so a nil (e.g. no
-- .app in the path) never truncates the ipairs scan.
-- Built by appending so a nil (e.g. no .app in the path) never truncates
-- the ipairs scan.
local candidates = {}
local appDir = appContainer(src) or appContainer(sbd) or appImageContainer()
if appDir then candidates[#candidates + 1] = appDir end
if sbd then candidates[#candidates + 1] = sbd end
if src then candidates[#candidates + 1] = src end
for _, base in ipairs(candidates) do
if base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then
if sbd and sbd ~= "" then candidates[#candidates + 1] = sbd end
if src and src ~= "" then candidates[#candidates + 1] = src end
return candidates
end
-- The game folder carrying portable.txt, or false. First candidate holding
-- the marker wins.
local function detectPortable()
if portableChecked then return portableBase end
portableChecked = true
portableBase = false
for _, base in ipairs(SaveData.gameFolders()) do
if pathExists(base .. SEP .. PORTABLE_MARKER) then
portableBase = base
break
end
@@ -210,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,
@@ -233,6 +249,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
@@ -323,8 +357,9 @@ end
-- under options.saveSlots[version]; the active slot is also cached
-- process-wide (like GameVersion.current) so the hot saveNames path does
-- not re-read options every call. A false cache entry means "no slot in
-- use" and the flat legacy path (save.lua / save_blue.lua) is used, which
-- keeps a brand-new install and every pre-slots caller working unchanged.
-- use" and the flat legacy path (save.lua / save_blue.lua / save_yellow.lua)
-- is used, which keeps a brand-new install and every pre-slots caller
-- working unchanged.
local activeSlotCache = {} -- version -> slotId in use, or false when none
local slotsChecked = {} -- version -> true once resolved this process
@@ -336,17 +371,18 @@ local function slotNames(version, id)
end
-- the pre-slots flat names a version always used (save.lua for Red,
-- save_blue.lua for Blue); still the destination before any slot exists
-- save_blue.lua / save_yellow.lua for the others); still the destination
-- before any slot exists
local function legacyNames(version)
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- Slot resolution is only meaningful for versions GameVersion actually knows
-- (red/blue). The launcher also renders a locked placeholder tab ("yellow")
-- that has no info entry and therefore no saveSuffix; resolving its legacy
-- names would index a nil info table and crash. Treat any unknown version as
-- having no slots so the slot APIs degrade to empty/no-op instead.
-- (red / blue / yellow). An unknown id has no info entry and therefore no
-- saveSuffix; resolving its legacy names would index a nil info table and
-- crash. Treat any unknown version as having no slots so the slot APIs
-- degrade to empty/no-op instead.
local function knownVersion(version)
return GameVersion.info(version) ~= nil
end
@@ -892,7 +928,7 @@ end)
-- a .tmp witness before the swap, so a crash mid-write is recoverable.
function SaveData.save(data, mods)
-- write to the file matching this save's own version, not just the active
-- one, so a Blue playthrough always lands in save_blue.lua
-- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version)
if data.options then
SaveData.saveOptions(data.options)
+85 -9
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
@@ -192,10 +220,45 @@ local function newCrySource(data, species, def)
return newFileSource(resolved)
end
-- Yellow's voiced Pikachu clips (audio/pikachu_pcm.asm
-- PlayPikachuSoundClip): 1-bit PCM decoded to WAVs at import
-- (data.audio.pikaCries = clip count). Returns the source, nil when the
-- cache carries no clips (Red/Blue) or headless.
function Sound.playPikaCry(data, n)
if not love.audio then return nil end
local count = data.audio and data.audio.pikaCries
if not count then return nil end
n = math.max(1, math.min(count, n or 1))
local key = "pikacry:" .. n
local src = cache[key]
if src == false then return nil end
if not src then
local ok, s = pcall(love.audio.newSource,
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(n), "static")
if not ok then
cache[key] = false
return nil
end
s:setVolume(volumeFor(key))
cache[key] = s
src = s
end
src:stop()
src:play()
played("cry", "PIKACHU_PCM_" .. n, "PIKACHU")
return src
end
-- returns the source (nil headless) so callers that block on the cry
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
function Sound.playCry(data, species)
if not love.audio then return nil end
-- Yellow voices every Pikachu cry with the PCM clips (the chip cry is
-- never used for the species there); clip 1 is the everyday "Pika!"
if species == "PIKACHU" then
local src = Sound.playPikaCry(data, 1)
if src then return src end
end
local cries = data.audio and data.audio.cries
local def = cries and cries[species]
if not def then return nil end
@@ -210,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
@@ -286,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
@@ -310,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
@@ -351,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
+195 -48
View File
@@ -12,6 +12,11 @@
-- (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,
@@ -39,6 +44,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 +58,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 +72,59 @@ 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 given window size. Shared by
-- layout() and the editor's Reset path so defaults stay in one place.
function TouchControls.defaultLayout(ww, wh)
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 = 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 },
}
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 +133,86 @@ 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
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
local function clampZone(zone, ww, wh)
local half = zone.w * 0.5
zone.cx = math.max(half, math.min(ww - half, zone.cx))
zone.cy = math.max(half, math.min(wh - 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 size changes (rotation, resize). Default: d-pad bottom-left,
-- B/A bottom-right with A above B (the Game Boy diagonal), START/SELECT
-- flanking the bottom center. Custom positions (normalized 0..1) 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
if self.layoutW == ww and self.layoutH == wh and self.L 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 },
}
self.L = TouchControls.defaultLayout(ww, wh)
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 = p.x * ww
zone.cy = p.y * wh
clampZone(zone, ww, wh)
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 +221,45 @@ function TouchControls:layout()
return self.L
end
-- Move one control to a screen-space point and persist its normalized
-- position. Used by the layout editor while dragging.
function TouchControls:setControlCenter(name, cx, cy)
local ww, wh = love.graphics.getDimensions()
local L = self:layout()
local zone = L[name]
if not zone then return end
zone.cx, zone.cy = cx, cy
clampZone(zone, ww, wh)
self.positions = self.positions or {}
self.positions[name] = { x = zone.cx / ww, y = zone.cy / wh }
end
function TouchControls:clearPositions()
self.positions = nil
self.layoutW, self.layoutH = 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 +296,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 +325,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 +334,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 +351,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 +360,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 +381,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 +429,6 @@ function TouchControls:draw()
love.graphics.pop()
end
TouchControls.CONTROLS = CONTROLS
return TouchControls
+12 -1
View File
@@ -5,8 +5,10 @@
--
-- Protocol -- main thread pushes command tables onto the "chipaudio_cmd"
-- channel and drains produced buffers off "chipaudio_out":
-- cmd = "play" { gen, header, allowLoops, audio } start a song
-- cmd = "play" { gen, header, allowLoops, audio,
-- channelVolumes?, channelPitches? }
-- cmd = "stop" halt production
-- cmd = "channelMix" { volumes, pitches } per-hw volume/pitch
-- cmd = "invalidate" drop the bank cache
-- cmd = "quit" end the thread
-- out buffers are tagged with the play's `gen` so the main thread can
@@ -45,6 +47,12 @@ local function handle(cmd)
engine = nil
outCh:clear() -- drop any buffers left from the previous song
data = { audio = cmd.audio }
if cmd.channelVolumes ~= nil then
ChipSynth.setChannelVolumes(cmd.channelVolumes)
end
if cmd.channelPitches ~= nil then
ChipSynth.setChannelPitches(cmd.channelPitches)
end
local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header,
{ allowLoops = cmd.allowLoops })
if ok then
@@ -58,6 +66,9 @@ local function handle(cmd)
engine = nil
finished = false
outCh:clear()
elseif cmd.cmd == "channelMix" then
if cmd.volumes ~= nil then ChipSynth.setChannelVolumes(cmd.volumes) end
if cmd.pitches ~= nil then ChipSynth.setChannelPitches(cmd.pitches) end
elseif cmd.cmd == "invalidate" then
ChipSynth.invalidateBanks()
elseif cmd.cmd == "quit" then
+86 -20
View File
@@ -32,11 +32,11 @@ local CacheFs = {}
local SEP = package.config:sub(1, 1)
-- Cache-relative paths are prefixed with this before every read/write, so a
-- Blue import lands in blue/ (see src.core.GameVersion) while a Red import
-- keeps the historical root. The launcher sets it per import / per readiness
-- check; it stays "" for Red. Runtime *reads* (require / newImage) do NOT go
-- through here -- CacheFs.mountVersion overlays the active version's subtree
-- onto the un-prefixed paths instead.
-- Blue/Yellow import lands under its GameVersion.cachePrefix (blue/, yellow/)
-- while a Red import keeps the historical root. The launcher sets it per
-- import / per readiness check; it stays "" for Red. Runtime *reads*
-- (require / newImage) do NOT go through here -- CacheFs.mountVersion overlays
-- the active version's subtree onto the un-prefixed paths instead.
CacheFs.prefix = ""
local function withPrefix(rel)
@@ -126,9 +126,9 @@ local function resolveMount()
if okl and lib then
local oks, fn = pcall(function() return lib.PHYSFS_mount end)
if oks and fn then
physfsMountFn = function(d, append)
physfsMountFn = function(d, mountPoint, append)
if append == nil then append = true end
local okr, ret = pcall(fn, d, "", append and 1 or 0)
local okr, ret = pcall(fn, d, mountPoint or "", append and 1 or 0)
return okr and ret ~= 0
end
break
@@ -145,7 +145,7 @@ end
local function mountReadable(dir, append)
local fn = resolveMount()
if not fn then return false end
return fn(dir, append)
return fn(dir, "", append)
end
-- PHYSFS_unmount, resolved the same way PHYSFS_mount is. Only
@@ -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.
@@ -330,16 +362,16 @@ end
-- Overlay the active version's extracted cache onto the un-prefixed read
-- paths, so require("data.generated.*") and love.graphics.newImage(
-- "assets/generated/*") resolve to that version's files. Red lives at the
-- cache root and needs nothing; Blue lives under blue/ and is *prepended* so
-- it wins over any Red copy at the root and over the game source. Called
-- once at boot, before Game:load (main.lua). Returns true when nothing was
-- needed or the mount succeeded.
-- cache root and needs nothing; non-Red versions (blue/, yellow/, …) are
-- *prepended* so they win over any Red copy at the root and over the game
-- source. Called once at boot, before Game:load (main.lua). Returns true
-- when nothing was needed or the mount succeeded.
function CacheFs.mountVersion(version)
local prefix = require("src.core.GameVersion").cachePrefix(version)
if prefix == "" then return true end -- Red: already at the root
local sub = prefix:gsub("/+$", "") -- "blue/" -> "blue"
local sub = prefix:gsub("/+$", "") -- "blue/" / "yellow/" -> bare dir
-- The cache root is the portable game folder when active, else LÖVE's OS
-- save directory (where love.filesystem wrote blue/...).
-- save directory (where love.filesystem wrote blue/... or yellow/...).
local base = CacheFs.root()
if not base and love.filesystem.getSaveDirectory then
base = love.filesystem.getSaveDirectory()
@@ -355,12 +387,12 @@ function CacheFs.mountVersion(version)
end
-- Undo mountVersion. A process normally mounts exactly one version and then
-- boots it, but the launcher can open the save editor on a Blue save, close
-- it, and press Play on Red: with blue/ still prepended, Red's
-- require("data.generated.*") and its generated art would silently resolve to
-- Blue's files. Callers must also drop the generated modules from
-- package.loaded (src.core.Data:unloadGenerated) -- unmounting alone only
-- fixes the read path, not what require already cached.
-- boots it, but the launcher can open the save editor on a Blue/Yellow save,
-- close it, and press Play on Red: with that version's subtree still
-- prepended, Red's require("data.generated.*") and its generated art would
-- silently resolve to the other game's files. Callers must also drop the
-- generated modules from package.loaded (src.core.Data:unloadGenerated) --
-- unmounting alone only fixes the read path, not what require already cached.
--
-- Returns true when nothing was mounted or the unmount took. Red is a no-op
-- because its cache lives at the root and was never overlaid.
@@ -385,4 +417,38 @@ function CacheFs.unmountVersion(version)
return done
end
-- Mount `dir` at `mountPoint` for the length of `fn()`, then take it back off
-- the read path and hand back whatever fn returned.
--
-- Every other mount here is permanent and lands at the physfs root: this one
-- exists to *look* at a folder the game has deliberately not mounted, which
-- is a different job. The mods panel uses it to read a mods/ folder sitting
-- beside the executable of a non-portable install (LauncherMods.strays).
-- Because it unmounts again, and because a non-empty mountPoint keeps the
-- tree in its own corner of the namespace while it is up, a folder inspected
-- this way can never shadow a game file or change what the running game
-- 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, 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)
if not ok then error(res, 0) end
return res
end
return CacheFs
+355 -49
View File
@@ -163,8 +163,12 @@ function RomExtractor:extractTilesets()
for pos = offset, offset + 15 do block[#block + 1] = blocksRaw[pos] end
blocks[#blocks + 1] = block
end
-- Red/Blue keep collision lists in ROM0; Yellow moved them to bank 1
-- (pokeyellow Overworld_Coll at 01:4ac2). Pointers in $4000-$7FFF are
-- banked; treat ROM0-range pointers as bank 0.
local collBank = collisionPointer < 0x4000 and 0 or 1
local walkable = sorted(self:readTerminated(
0, collisionPointer, 0xFF))
collBank, collisionPointer, 0xFF))
local warpPointer = self.rom:word(
warpPointers.bank, warpPointers.address + (index - 1) * 2)
local warpTiles = unique(self:readTerminated(
@@ -447,14 +451,26 @@ function RomExtractor:extractSprites()
local pointer = self.rom:word(pointerTable.bank, address)
local firstHalf = self.rom:byte(pointerTable.bank, address + 2)
local bank = self.rom:byte(pointerTable.bank, address + 3)
local byteLength = spec.imageWidth * spec.imageHeight / 4
local frames = spec.imageHeight / 16
local width = spec.imageWidth
local height = spec.imageHeight
local byteLength = width * height / 4
local frames = height / 16
local expected = firstHalf * (frames >= 6 and 2 or 1)
assert(byteLength == expected, constName .. ": sprite length mismatch")
if byteLength ~= expected then
-- Commercial ROM sheet length wins over pret PNG atlases (Yellow nurse
-- PNG is taller than the 12-tile SpriteSheetPointerTable entry).
byteLength = expected
assert(byteLength * 4 % width == 0,
constName .. ": ROM sprite length not tile-aligned")
height = byteLength * 4 / width
frames = height / 16
expected = firstHalf * (frames >= 6 and 2 or 1)
assert(byteLength == expected, constName .. ": sprite length mismatch")
end
local base = spec.imageBase
if not written[base] then
self:write2bpp(self.rom:bytes(bank, pointer, byteLength),
spec.imageWidth, spec.imageHeight,
width, height,
"sprites/" .. base .. ".png", true)
written[base] = true
end
@@ -862,20 +878,24 @@ function RomExtractor:extractPalettes()
local order = self.manifest.paletteOrder
local paletteTable = self:symbol("SuperPalettes")
local function scale5(value) return round(value * 255 / 31) end
local palettes = {}
for index, name in ipairs(order) do
local colors = {}
for color = 0, 3 do
local value = self.rom:word(paletteTable.bank,
paletteTable.address + (index - 1) * 8 + color * 2)
colors[#colors + 1] = {
scale5(bit.band(value, 0x1F)),
scale5(bit.band(bit.rshift(value, 5), 0x1F)),
scale5(bit.band(bit.rshift(value, 10), 0x1F)),
}
local function readTable(symbol, names)
local out = {}
for index, name in ipairs(names) do
local colors = {}
for color = 0, 3 do
local value = self.rom:word(symbol.bank,
symbol.address + (index - 1) * 8 + color * 2)
colors[#colors + 1] = {
scale5(bit.band(value, 0x1F)),
scale5(bit.band(bit.rshift(value, 5), 0x1F)),
scale5(bit.band(bit.rshift(value, 10), 0x1F)),
}
end
out[name] = colors
end
palettes[name] = colors
return out
end
local palettes = readTable(paletteTable, order)
local monsterTable = self:symbol("MonsterPalettes")
local monsterPalettes = {}
for index, species in ipairs(self.manifest.dexOrder) do
@@ -887,6 +907,11 @@ function RomExtractor:extractPalettes()
source = "ROM:SuperPalettes + MonsterPalettes",
palettes = palettes, order = order, pokemon = monsterPalettes,
}
-- Yellow (and GBC carts) also carry CGBBasePalettes beside SuperPalettes.
if self.symbols["CGBBasePalettes"] then
data.cgbBase = readTable(self:symbol("CGBBasePalettes"), order)
data.source = data.source .. " + CGBBasePalettes"
end
self:write("palettes", data)
self:tick("Color palettes", 1, 1)
return data
@@ -915,6 +940,10 @@ function RomExtractor:extractIcons()
GRASS = "assets/generated/icons/plant.png",
SNAKE = "assets/generated/icons/snake.png",
QUADRUPED = "assets/generated/icons/quadruped.png",
-- Yellow's ICON_PIKACHU draws from the overworld PikachuSprite sheet
-- (data/icon_pointers.asm mon_icon_header PikachuSprite, 0/12);
-- only referenced when the manifest's iconOrder includes it
PIKACHU = "assets/generated/sprites/pikachu.png",
}
local frames = {
{ "bug", "BugIconFrame1", "BugIconFrame2" },
@@ -1048,7 +1077,9 @@ function RomExtractor:extractPokemon()
local typeById = self:typesById()
local names = self:symbol("MonsterNames")
local baseStats = self:symbol("BaseStats")
local mewStats = self:symbol("MewBaseStats")
-- Red/Blue keep Mew outside BaseStats (pret pokered MewBaseStats).
-- Yellow stores Mew as dex 151 inside BaseStats (pret/pokeyellow).
local mewStats = self.symbols["MewBaseStats"] and self:symbol("MewBaseStats")
local decodedNames = {}
for index = 1, #speciesOrder do
decodedNames[index] = self.rom:decodeText(
@@ -1067,7 +1098,7 @@ function RomExtractor:extractPokemon()
local dex = assert(dexBySpecies[species],
"missing dex number for " .. species)
local row
if species == "MEW" then
if species == "MEW" and mewStats then
row = self.rom:bytes(mewStats.bank, mewStats.address, 28)
else
row = self.rom:bytes(
@@ -1276,6 +1307,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
@@ -1410,6 +1451,142 @@ function RomExtractor:extractText()
}
end
function RomExtractor:extractYellowTitleArt()
-- pret/pokeyellow engine/movie/title_yellow.asm: the Yellow title is a
-- tilemap composition over BOTH tile banks. LoadYellowTitleScreenGFX
-- loads PokemonLogoGraphics into vChars2 (BG ids $00-$7F),
-- TitlePikachuBGGraphics into vChars1 (ids $80-$EF),
-- TitlePikachuOBGraphics at vChars1 tile $70 (ids $F0-$FC, also the eye
-- OAM tiles), and PokemonLogoCornerGraphics at vChars1 tile $7D (ids
-- $FD-$FF). Every tilemap mixes ids from several of those sheets, so a
-- single-sheet lookup shows checkerboard garbage where a foreign-bank id
-- lands (e.g. blank id $00 = logo tile 0, not Pikachu BG tile 0).
if not self.symbols["TitlePikachuBGGraphics"] then return end
-- raw sheets, kept for debugging / mod reference
self:raw2bpp("TitlePikachuBGGraphics", 128, 32,
"title/pikachu_bg.png", { transparent = true })
self:raw2bpp("TitlePikachuOBGraphics", 96, 8,
"title/pikachu_ob.png", { transparent = true })
-- Tile counts are the Graphics..GraphicsEnd symbol gaps in pokeyellow.sym.
local function sheetTiles(label, count, transparent)
local symbol = self:symbol(label)
local raw = self.rom:bytes(symbol.bank, symbol.address, count * 16)
local tiles = {}
for offset = 1, #raw, 16 do
local one = {}
for i = offset, offset + 15 do one[#one + 1] = raw[i] end
tiles[#tiles + 1] = ImageWriter.decode2bpp(one, 8, 8, transparent)
end
return tiles
end
local logo = sheetTiles("PokemonLogoGraphics", 115)
local corner = sheetTiles("PokemonLogoCornerGraphics", 3)
local bg = sheetTiles("TitlePikachuBGGraphics", 64)
local ob = sheetTiles("TitlePikachuOBGraphics", 12)
local obClear = sheetTiles("TitlePikachuOBGraphics", 12, true)
local function tileFor(id)
if id < 0x80 then return logo[id + 1] end
if id < 0xF0 then return bg[id - 0x80 + 1] end
if id < 0xFD then return ob[id - 0xF0 + 1] end
return corner[id - 0xFD + 1]
end
-- OAM-style blit: color-0 pixels stay whatever the target already holds
-- (ImageWriter.blit copies alpha-0 pixels wholesale, which would punch
-- holes into the face under the eye sprites).
local function blitSprite(target, tile, tx, ty, flipX)
for y = 0, 7 do
for x = 0, 7 do
local sx = flipX and 7 - x or x
local r, g, b, a = tile:getPixel(sx, y)
if a ~= 0 then target:setPixel(tx + x, ty + y, r, g, b, a) end
end
end
end
-- cells = { {id, col, row}, ... }; untouched cells stay transparent
local function compose(cols, rows, cells)
local pose = ImageWriter.blank(cols * 8, rows * 8, 1, 1, 1, 0)
for _, cell in ipairs(cells) do
local tile = tileFor(cell[1])
if tile then ImageWriter.blit(pose, tile, cell[2] * 8, cell[3] * 8) end
end
return pose
end
local function mapCells(map, cols, rows)
local ids = self.rom:bytes(map.bank, map.address, cols * rows)
local cells = {}
for index, id in ipairs(ids) do
cells[#cells + 1] =
{ id, (index - 1) % cols, math.floor((index - 1) / cols) }
end
return cells
end
-- TitleScreen_PlacePokemonLogo: 16x7 box at (2,1). Yellow's logo sheet
-- is deduplicated (unlike Red's sequential rip), so the raw2bpp
-- pokemon_logo.png from extractField is scrambled; overwrite it with the
-- tilemap composition. Kept opaque: TitleState clears to white behind it.
self:save(compose(16, 7,
mapCells(self:symbol("TitleScreenPokemonLogoTilemap"), 16, 7)),
"title/pokemon_logo.png")
-- TitleScreen_PlacePikaSpeechBubble: 7x4 box at (6,4) plus the two tail
-- tiles $64/$65 the routine pokes at (9,8) -- one row below the box, over
-- blank cells of the Pikachu row. Composed 7x5 with the tail at (3,4);
-- matteColor0 clears the outside-the-balloon whites, the outline protects
-- the interior.
local bubbleCells = mapCells(
self:symbol("TitleScreenPikaBubbleTilemap"), 7, 4)
bubbleCells[#bubbleCells + 1] = { 0x64, 3, 4 }
bubbleCells[#bubbleCells + 1] = { 0x65, 4, 4 }
self:save(ImageWriter.matteColor0(compose(7, 5, bubbleCells)),
"title/pika_bubble.png")
-- TitleScreen_PlacePikachu: 12x9 box at (4,8) plus the right-ear edge
-- tiles it pokes down column 16 (rows 10-13) -- composed 13x9 with those
-- at relative column 12, rows 2-5. The open eyes are OAM
-- (TitleScreenPikachuEyesOAMData, copied at place time): OB tiles 0-3 at
-- screen (56,80)/(88,80) blocks, the left eye x-flipped (attr $22); baked
-- into the composition relative to the box origin px(32,64).
local pikaCells = mapCells(self:symbol("TitleScreenPikachuTilemap"), 12, 9)
pikaCells[#pikaCells + 1] = { 0x96, 12, 2 }
pikaCells[#pikaCells + 1] = { 0x9d, 12, 3 }
pikaCells[#pikaCells + 1] = { 0xa7, 12, 4 }
pikaCells[#pikaCells + 1] = { 0xb1, 12, 5 }
local pikachu = ImageWriter.matteColor0(compose(13, 9, pikaCells))
-- DoTitleScreenFunction's blink rewrites the eye OAM tile ids with
-- `and $f3 / or e` (e = 0 open / 4 half / 8 closed), so the OB sheet
-- holds three 4-tile eye sets. Bake the open set into pikachu.png and
-- save half/closed as standalone overlays for TitleState's blink.
local EYE_LAYOUT = {
{ 2, 24, 16, true }, { 1, 32, 16, true },
{ 4, 24, 24, true }, { 3, 32, 24, true },
{ 1, 56, 16 }, { 2, 64, 16 },
{ 3, 56, 24 }, { 4, 64, 24 },
}
-- Blink overlays for the (24,16)-(71,31) eye band: the BG face is
-- eyeless (the eyes are OAM), so each overlay = the blank-face crop
-- with the half (+4) / closed (+8) tile set composited color-0
-- transparent -- exactly what the hardware shows mid-blink.
local overlays = {}
for suffix, base in pairs({ eyes_half = 4, eyes_closed = 8 }) do
local overlay = ImageWriter.blank(48, 16, 1, 1, 1, 0)
ImageWriter.blit(overlay, pikachu, 0, 0, 24, 16, 48, 16)
for _, e in ipairs(EYE_LAYOUT) do
blitSprite(overlay, obClear[base + e[1]], e[2] - 24, e[3] - 16, e[4])
end
overlays[suffix] = overlay
end
-- open eyes bake into pikachu.png AFTER the blank-face crops
for _, e in ipairs(EYE_LAYOUT) do
blitSprite(pikachu, obClear[e[1]], e[2], e[3], e[4])
end
self:save(pikachu, "title/pikachu.png")
for suffix, overlay in pairs(overlays) do
self:save(overlay, "title/" .. suffix .. ".png")
end
end
function RomExtractor:raw2bpp(label, width, height, relative, options)
options = options or {}
local expected = width * height / 4
@@ -1454,6 +1631,8 @@ function RomExtractor:extractField()
"title/copyright.png"); tick()
self:raw2bpp("GameFreakLogoGraphics", 72, 8,
"title/gamefreak_inc.png"); tick()
-- Yellow fixed Pikachu title art (no-op on Red/Blue manifests).
self:extractYellowTitleArt(); tick()
local fallingStar = self:raw2bpp(
"FallingStar", 8, 8, "intro/falling_star.png",
@@ -1502,35 +1681,71 @@ function RomExtractor:extractField()
end
self:save(star, "intro/big_star.png"); tick()
local gengar = self:symbol("FightIntroBackMon")
local gengarRaw = self.rom:bytes(
gengar.bank, gengar.address, 96 * 16)
local gengarTiles = {}
for offset = 1, #gengarRaw, 16 do
local raw = {}
for index = offset, offset + 15 do raw[#raw + 1] = gengarRaw[index] end
gengarTiles[#gengarTiles + 1] = ImageWriter.decode2bpp(raw, 8, 8)
end
for number = 1, 3 do
local tilemap = self:symbol("GengarIntroTiles" .. number)
local tileIds = self.rom:bytes(tilemap.bank, tilemap.address, 49)
local pose = ImageWriter.blank(56, 56, 0, 0, 0, 0)
for index, tileId in ipairs(tileIds) do
ImageWriter.blit(pose, gengarTiles[tileId + 1],
(index - 1) % 7 * 8, math.floor((index - 1) / 7) * 8)
-- Yellow has no FightIntro Gengar/Nidorino fight (pret/pokeyellow
-- engine/movie/intro_yellow.asm); write blank placeholders so Title/
-- Intro still find the expected paths. Red/Blue keep the tilemap rip.
if self.symbols["FightIntroBackMon"] then
local gengar = self:symbol("FightIntroBackMon")
local gengarRaw = self.rom:bytes(
gengar.bank, gengar.address, 96 * 16)
local gengarTiles = {}
for offset = 1, #gengarRaw, 16 do
local raw = {}
for index = offset, offset + 15 do raw[#raw + 1] = gengarRaw[index] end
gengarTiles[#gengarTiles + 1] = ImageWriter.decode2bpp(raw, 8, 8)
end
for number = 1, 3 do
local tilemap = self:symbol("GengarIntroTiles" .. number)
local tileIds = self.rom:bytes(tilemap.bank, tilemap.address, 49)
local pose = ImageWriter.blank(56, 56, 0, 0, 0, 0)
for index, tileId in ipairs(tileIds) do
ImageWriter.blit(pose, gengarTiles[tileId + 1],
(index - 1) % 7 * 8, math.floor((index - 1) / 7) * 8)
end
pose = ImageWriter.matteColor0(pose)
self:save(pose, "intro/gengar_" .. number .. ".png"); tick()
end
else
for number = 1, 3 do
self:save(ImageWriter.blank(56, 56, 0, 0, 0, 0),
"intro/gengar_" .. number .. ".png"); tick()
end
pose = ImageWriter.matteColor0(pose)
self:save(pose, "intro/gengar_" .. number .. ".png"); tick()
end
for number, label in ipairs({
"FightIntroFrontMon", "FightIntroFrontMon2", "FightIntroFrontMon3",
}) do
self:raw2bpp(label, 48, 48,
"intro/red_nidorino_" .. number .. ".png",
{ transparent = true, columns = true })
tick()
if self.symbols["FightIntroFrontMon"] then
for number, label in ipairs({
"FightIntroFrontMon", "FightIntroFrontMon2", "FightIntroFrontMon3",
}) do
self:raw2bpp(label, 48, 48,
"intro/red_nidorino_" .. number .. ".png",
{ transparent = true, columns = true })
tick()
end
else
for number = 1, 3 do
self:save(ImageWriter.blank(48, 48, 1, 1, 1, 0),
"intro/red_nidorino_" .. number .. ".png"); tick()
end
end
-- Optional Yellow-only intro atlas (pret/pokeyellow gfx/yellow_intro.asm).
if self.symbols["YellowIntroGraphics1"] then
self:raw2bpp("YellowIntroGraphics1", 128, 64,
"intro/yellow_intro_1.png")
end
if self.symbols["YellowIntroGraphics2"] then
-- atlas2 doubles as the intro's OBJ tile bank (vChars0); OBJ color 0
-- is hardware-transparent, and the BG draws it over a white clear so
-- BG cells lose nothing
self:raw2bpp("YellowIntroGraphics2", 128, 128,
"intro/yellow_intro_2.png", { transparent = true })
end
-- Yellow intro clouds (intro_yellow.asm YellowIntroCloudGFX): 8 tiles,
-- two 4-tile animation frames -- saved 32x16, one frame per row.
if self.symbols["YellowIntroCloudGFX"] then
self:raw2bpp("YellowIntroCloudGFX", 32, 16, "intro/clouds.png")
end
for number = 1, 2 do
self:writeCompressedPic(
"ShrinkPic" .. number, "intro/shrink" .. number .. ".png")
@@ -1563,10 +1778,27 @@ function RomExtractor:extractField()
end
self:save(symbolSheet, "slots/symbols.png"); tick()
local emotes = ImageWriter.blank(48, 16, 1, 1, 1, 0)
for index, label in ipairs({
"ShockEmote", "QuestionEmote", "HappyEmote",
}) do
-- Emote sheet layout comes from manifest.field.emotionBubbles so the
-- versions can differ: Red ships the three shared bubbles, Yellow adds
-- the five Pikachu-only ones (emotion_bubbles.asm Skull/Heart/Bolt/
-- Zzz/FishEmote, used by the PikachuEmotionTable reactions).
local EMOTE_SYMBOLS = {
EXCLAMATION_BUBBLE = "ShockEmote", QUESTION_BUBBLE = "QuestionEmote",
SMILE_BUBBLE = "HappyEmote", SKULL_BUBBLE = "SkullEmote",
HEART_BUBBLE = "HeartEmote", BOLT_BUBBLE = "BoltEmote",
ZZZ_BUBBLE = "ZzzEmote", FISH_BUBBLE = "FishEmote",
}
local bubbleDefs = self.manifest.field.emotionBubbles
and self.manifest.field.emotionBubbles.bubbles
local emoteLabels = {}
for _, b in ipairs(bubbleDefs or {}) do
emoteLabels[#emoteLabels + 1] = EMOTE_SYMBOLS[b.name]
end
if #emoteLabels == 0 then
emoteLabels = { "ShockEmote", "QuestionEmote", "HappyEmote" }
end
local emotes = ImageWriter.blank(#emoteLabels * 16, 16, 1, 1, 1, 0)
for index, label in ipairs(emoteLabels) do
local symbol = self:symbol(label)
local image = ImageWriter.decode2bpp(
self.rom:bytes(symbol.bank, symbol.address, 64), 16, 16, true)
@@ -1574,6 +1806,26 @@ function RomExtractor:extractField()
end
self:save(emotes, "emotes.png"); tick()
-- Yellow-only: the Surfing Pikachu minigame sheets
-- (gfx/surfing_pikachu.asm) at pret's canvas widths, so
-- src/ui/SurfingMinigame.lua's quads can be read off the source pngs.
-- 1a is the BG set (water/beach/score tiles, opaque); 1b the OAM pose
-- sheet and 1c the intro set (both color-0 transparent).
for _, spec in ipairs({
{ "SurfingPikachu1Graphics1", 65, 40, false, "minigame/surf_1a.png" },
{ "SurfingPikachu1Graphics2", 256, 128, true, "minigame/surf_1b.png" },
{ "SurfingPikachu1Graphics3", 144, 96, true, "minigame/surf_1c.png" },
}) do
if self.symbols[spec[1]] then
local symbol = self:symbol(spec[1])
local tilesPerRow = spec[3] / 8
local image = ImageWriter.decode2bpp(
self.rom:bytes(symbol.bank, symbol.address, spec[2] * 16),
spec[3], spec[2] / tilesPerRow * 8, spec[4])
self:save(image, spec[5])
end
end
self:raw1bpp("LedgeHoppingShadow", 8, 8,
"fx/shadow.png", true); tick()
for _, spec in ipairs({
@@ -1664,7 +1916,9 @@ end
function RomExtractor:extractAudio()
self:beginStage("Sound programs")
local metadata = copy(self.manifest.audio)
local bankOrder = { 2, 8, 31 }
-- Yellow adds a fourth music bank ($20: Jessie & James, Surfing
-- Pikachu, GB Printer); the manifest names the pack when it needs it.
local bankOrder = metadata.programBanks or { 2, 8, 31 }
local chunks = {}
for index, bank in ipairs(bankOrder) do
local first = Rom.offset(bank, 0x4000) + 1
@@ -1683,6 +1937,7 @@ function RomExtractor:extractAudio()
for name, header in pairs(metadata.musicHeaders) do
songs[name] = header
end
metadata.pikaCries = self:extractPikachuCries()
local cries = {}
local cryData = metadata.cryData
for index, species in ipairs(self.manifest.constants.speciesOrder) do
@@ -1709,6 +1964,57 @@ function RomExtractor:extractAudio()
return metadata
end
-- Yellow's voiced Pikachu clips (audio/pikachu_cries_pointers.asm
-- PikachuCriesPointerTable, 42 `dba` rows; each clip is `dw length` then
-- 1-bit PCM, MSB first -- home/pikachu_cries.asm PlayPikachuPCM toggles
-- rAUD3LEVEL per bit at roughly 190 CPU cycles a sample). Decoded to
-- plain 8-bit mono WAVs; returns the clip count for data.audio.pikaCries,
-- or nil when the manifest has no pointer table (Red/Blue).
function RomExtractor:extractPikachuCries()
if not self.symbols["PikachuCriesPointerTable"] then return nil end
local NUM = 42 -- NUM_PIKA_CRIES
local RATE = 22050 -- ~4.19 MHz / ~190 cycles per sample
-- byte -> 8 samples, MSB first (LoadNextSoundClipSample: `and $80`)
local lut = {}
for byte = 0, 255 do
local out = {}
for bit = 7, 0, -1 do
local on = math.floor(byte / 2 ^ bit) % 2 == 1
out[#out + 1] = string.char(on and 0xE0 or 0x20)
end
lut[byte] = table.concat(out)
end
local function u16(v)
return string.char(v % 256, math.floor(v / 256) % 256)
end
local function u32(v)
return string.char(v % 256, math.floor(v / 256) % 256,
math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256)
end
local CacheFs = require("src.import.CacheFs")
local pointers = self:symbol("PikachuCriesPointerTable")
for index = 0, NUM - 1 do
local row = self.rom:bytes(pointers.bank, pointers.address + index * 3, 3)
local bank, address = row[1], row[2] + row[3] * 256
local header = self.rom:bytes(bank, address, 2)
local length = header[1] + header[2] * 256
local raw = self.rom:bytes(bank, address + 2, length)
local samples = {}
for i, byte in ipairs(raw) do samples[i] = lut[byte] end
local pcm = table.concat(samples)
local wav = "RIFF" .. u32(36 + #pcm) .. "WAVEfmt " .. u32(16)
.. u16(1) .. u16(1) .. u32(RATE) .. u32(RATE) .. u16(1) .. u16(8)
.. "data" .. u32(#pcm) .. pcm
local ok, err = CacheFs.write(
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(index + 1),
wav)
if not ok then
error("could not write pika cry " .. (index + 1) .. ": " .. tostring(err))
end
end
return NUM
end
function RomExtractor:run()
local results = {}
results.constants = self:extractConstants()
+2253 -251
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
+35
View File
@@ -183,6 +183,12 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
local b = battle.player
-- PIKAHAPPY_USEDXITEM (item_effects.asm ItemUseXAccuracy /
-- GuardSpec / DireHit / XStat) on the active companion
if itemId ~= "POKE_DOLL" then
require("src.world.PikachuFollower")
.modifyHappiness(save, "USEDXITEM", b and b.mon)
end
if itemId == "X_ACCURACY" then
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
-- (not an accuracy stage)
@@ -253,6 +259,18 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) }
end
-- PIKAHAPPY_USEDITEM (item_effects.asm ItemUseMedicine, item id up to
-- CALCIUM): fires once a medicine has a target, before the effect
-- resolves -- potions, status cures, revives and vitamins all count,
-- RARE_CANDY does not (its success is a LEVELUP bump instead)
if target and (HEAL_AMOUNT[itemId] or STATUS_HEAL[itemId]
or itemId == "MAX_POTION" or itemId == "FULL_RESTORE"
or itemId == "REVIVE" or itemId == "MAX_REVIVE"
or VITAMINS[itemId]) then
require("src.world.PikachuFollower")
.modifyHappiness(save, "USEDITEM", target)
end
local heal = HEAL_AMOUNT[itemId]
if heal or itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
-- a FULL RESTORE on a statused mon already at full HP acts as a
@@ -323,12 +341,29 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local old = target.stats
target.stats = Stats.calc(speciesDef, target.level, target.dvs, target.statExp)
target.hp = math.min(target.stats.hp, target.hp + (target.stats.hp - old.hp))
-- PIKAHAPPY_LEVELUP on a candy level (item_effects.asm:1540)
require("src.world.PikachuFollower")
.modifyHappiness(save, "LEVELUP", target)
return "consumed", { Strings("%s grew\nto level %d!", monName(data, target), target.level) },
{ leveledTo = target.level }
end
if STONES[itemId] then
if not target then return "failed", { Strings("It won't have\nany effect.") } end
-- Yellow's starter Pikachu never evolves: ItemUseEvoStone runs
-- IsThisPartyMonStarterPikachu (OT identity match) before
-- TryEvolvingMon and bails with the voiced cry + RefusingText.
-- The stone is NOT consumed on the refuse path.
if target.species == "PIKACHU"
and require("src.core.GameVersion").isYellow()
and target.ot == save.player.name
and target.otId == save.player.id then
require("src.core.Sound").playCry(data, "PIKACHU")
local raw = data.text and data.text._RefusingText
local line = raw and raw:gsub("{RAM:[^}]*}", monName(data, target))
or Strings("%s\nis refusing!", monName(data, target))
return "failed", { line }
end
local speciesDef = data.pokemon[target.species]
for _, evo in ipairs(speciesDef.evolutions) do
if evo.method == "ITEM" and evo.item == itemId then
+6
View File
@@ -382,6 +382,12 @@ function TradeSession:apply(game)
Runtime.emit("pokemon.received",
{ mon = received, from = "link", peerName = self.peerName })
self.party[self.myPick] = received
-- PIKAHAPPY_TRADE (engine/link/cable_club.asm:801): trading the
-- companion away is the biggest happiness hit and zeroes the mood
if game and sent then
require("src.world.PikachuFollower")
.modifyHappiness(game.save, "TRADE", sent)
end
if game and game.save.pokedex then
game.save.pokedex.seen[received.species] = true
game.save.pokedex.owned[received.species] = true
+255 -14
View File
@@ -16,9 +16,19 @@
-- fused build), which is why those mods still loaded while landing in the
-- wrong place.
--
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
-- no filesystem, so the engine tier can table-drive it; the discovery,
-- install, and uninstall paths reach for love.filesystem and SaveData.
-- The same split decides where a mod is FOUND, and that has a sharp edge: a
-- non-portable install never reads the game folder at all, so a mod unzipped
-- next to the executable -- where most games would want it -- is not wrong so
-- much as invisible, with an empty panel and no error to explain it.
-- adoptStrays looks in those folders anyway (a scoped mount that comes down
-- again, CacheFs.withMounted) and copies what it finds into the tree the game
-- really reads, so the mistake costs a line of notice rather than a support
-- thread.
--
-- Split in two: the pure derivation (deriveList, locateRoot, pickStrays) has
-- no love and no filesystem, so the engine tier can table-drive it; the
-- discovery, install, uninstall, and stray-scan paths reach for
-- love.filesystem and SaveData.
local Manifest = require("src.mods.Manifest")
local ManagerState = require("src.mods.ManagerState")
@@ -85,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 = {}
@@ -94,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
@@ -136,6 +156,43 @@ function LauncherMods.locateRoot(paths)
return nil, "no manifest.json found in the .zip"
end
-- pickStrays(found, installed) -> the rows worth adopting, pure.
-- found is an array of { id, name, folder, path } in scan order (game folder
-- order, then directory order); installed is the id -> true set of what the
-- game can already see. An installed id is dropped -- the player has a
-- working copy and the loose folder is just where they first put it -- and a
-- duplicate id across two game folders keeps the first, the same first-wins
-- rule discover() uses. Sorted by id so the notice reads the same every time.
function LauncherMods.pickStrays(found, installed)
installed = installed or {}
local out, seen = {}, {}
for _, row in ipairs(found or {}) do
local id = row.id
if id and not installed[id] and not seen[id] then
seen[id] = true
out[#out + 1] = { id = id, name = row.name or id,
folder = row.folder, path = row.path }
end
end
table.sort(out, function(a, b) return a.id < b.id end)
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)
@@ -184,8 +241,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
@@ -301,13 +365,128 @@ local function removeTree(path)
fs.remove(path)
end
-- installZip(source) -> true, id | nil, errString
-- ------- strays: mods dropped beside the game that it cannot see
-- love.filesystem looks in two places for "mods/": the save directory, and --
-- portable installs only -- the game folder, which CacheFs mounts. A player
-- who unzips a mod next to the executable of an ordinary install, which is
-- where very nearly every other game would want it, gets no error and no mod.
-- The MODS panel simply stays empty, and there is nothing on screen to
-- suggest the files are twenty centimetres away in the wrong folder.
--
-- The scan mounts each game folder at a private mount point just long enough
-- to list mods/ inside it and drops it again (CacheFs.withMounted), so the
-- read path the game actually runs on is never touched and a stray can never
-- shadow a real file.
local STRAY_MOUNT = "stray_scan"
-- Run fn(mountedModsRoot) for each game folder that has a readable mods/
-- 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 not LauncherMods.isReadableRoot(folder, source, cacheRoot) then
seen[folder] = true
CacheFs.withMounted(folder, STRAY_MOUNT, function()
local root = STRAY_MOUNT .. "/mods"
if fs.getInfo(root) then fn(root, folder) end
end)
end
end
end
-- Every valid mod folder sitting in a game folder's mods/, in scan order.
-- Only reads. The rows carry the mounted path, which is live for the length
-- of the mount and dead after it -- copying has to happen inside the same
-- scan, which is why adoption is a flag here rather than a second pass.
local function findStrays(fs, adopt, installed)
local found, adopted = {}, {}
eachStrayRoot(function(root, folder)
local batch = {}
for _, name in ipairs(fs.getDirectoryItems(root)) do
local path = root .. "/" .. name
local info = fs.getInfo(path)
if info and info.type == "directory" then
local raw = fs.read(path .. "/manifest.json")
local manifest = raw and decodeManifest(raw, path)
if manifest then
batch[#batch + 1] = { id = manifest.id,
name = manifest.name or manifest.id,
folder = folder, path = path }
end
end
end
-- filtered per mount, so a copy only ever runs for a row that survived
-- the pure rules -- and so the second game folder sees the first one's
-- ids as taken
for _, row in ipairs(LauncherMods.pickStrays(batch, installed)) do
if adopt then
-- same root pin installZip uses: the mods tree is shared by Red and
-- Blue, never version-prefixed (#330)
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
local dest = "mods/" .. row.id
local copied, copyErr = copyTree(row.path, dest)
if not copied then removeTree(dest) end
CacheFs.prefix = savedPrefix
if not copied then row.err = copyErr or "could not copy the files" end
end
installed[row.id] = true
row.path = nil -- dead once this mount comes down
adopted[#adopted + 1] = row
found[#found + 1] = row
end
end)
return LauncherMods.pickStrays(found, {})
end
-- The strays, optionally adopted. A folder whose id the game can already see
-- is left out: the player has a working copy, and the loose one is just where
-- they first put it. Rows that failed to copy come back with .err set.
local function scanStrays(adopt)
local fs = love and love.filesystem
if not fs then return {} end
local installed = {}
for _, m in ipairs(discover()) do installed[m.id] = true end
return findStrays(fs, adopt, installed)
end
-- strays() -> the rows, nothing copied.
function LauncherMods.strays() return scanStrays(false) end
-- adoptStrays() -> the rows, each one copied into the mods tree the game
-- really reads (rows carrying .err failed). Idempotent: a second call finds
-- the ids installed and returns nothing, so the panel can run this on every
-- open without duplicating anything or nagging twice. The loose folder is
-- deliberately left where it is -- deleting files outside the save directory
-- on the player's behalf is not this function's call to make.
function LauncherMods.adoptStrays() return scanStrays(true) end
-- 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
@@ -348,12 +527,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/...);
@@ -376,6 +567,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]
+12
View File
@@ -834,6 +834,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)
+49 -2
View File
@@ -50,6 +50,42 @@ 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
function Manifest.validate(raw, path)
assert(type(raw) == "table", "manifest must be an object")
assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"),
@@ -86,6 +122,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 +139,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 +150,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
+16
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)) } }),
@@ -493,6 +494,12 @@ R.maps = {
width = f.int(1), height = f.int(1),
blocks = f.list(f.int(0, 255)),
borderBlock = f.opt(f.int(0, 255)),
-- A named SGB palette, which wins over the field.palettes cascade
-- (OverworldController.lua:506 reads map.def.palette first). Deliberately
-- a plain string rather than f.id("palettes"): the ROM-free fixture base
-- carries no palettes at all, so an id reference would fail validation for
-- a perfectly good mod wherever there is no imported dataset.
palette = f.opt(f.str),
warps = f.opt(f.list(f.rec{ x = f.int(0), y = f.int(0),
destMap = f.str, destWarp = f.int(0) })),
objects = f.opt(f.list(f.any)),
@@ -555,6 +562,11 @@ R.trainers = {
index = f.opt(f.int(0, 255)),
-- unused vanilla classes ship without a pic, so it cannot be required
pic = f.opt(f.path),
-- Optional Advanced-mode OBJ palette source for a custom trainer portrait.
-- It follows the same ROM crosswalk form as sprites.paletteSource.
paletteSource = f.opt(f.str),
-- Reuse a base trainer class's portrait without redistributing its asset.
basePic = f.opt(f.id("trainers")),
baseMoney = f.opt(f.int(0)),
parties = f.list(f.list(f.rec{ level = f.int(1),
species = f.id("pokemon") })),
@@ -574,6 +586,10 @@ R.sprites = {
frames = f.int(1),
walker = f.opt(f.bool),
trueColor = f.opt(f.bool),
-- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ
-- palette assignment without claiming that the image itself came from
-- the ROM (which is what `source` documents on imported records).
paletteSource = f.opt(f.str),
},
example = 'mod.content.sprites:register("SPRITE_HERO", { image = "...", frames = 6 })',
}
+2 -1
View File
@@ -18,7 +18,8 @@ local function samePath(path) return path end
-- side: "front" | "back"
-- opts.mon: the live mon when available (per-instance skins)
-- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade"
-- | "title" | "oak" | "credits" (informational for wrappers)
-- | "title" | "oak" | "credits" | "overworld" (informational
-- for wrappers)
-- Returns path, trueColor.
function Sprites.path(data, species, side, opts)
opts = opts or {}
+159 -33
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,11 +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.
-- 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
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
@@ -88,9 +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 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
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,
@@ -109,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 }
@@ -220,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
@@ -237,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
@@ -255,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)
@@ -311,6 +390,9 @@ end
-- Red-derived pokered-gbc pack, so under RED++ a Blue playthrough must
-- 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. OG YELLOW (usesYellowCgb) reads CGBBasePalettes; SGB mode on
-- Yellow keeps SuperPalettes.
local BLUE_VERSIONED = {
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
}
@@ -320,22 +402,44 @@ local function romNamedPal(data, name)
return p and p.palettes and p.palettes[name]
end
local function yellowCgbNamedPal(data, name)
local p = data and data.palettes
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 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
@@ -348,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
@@ -365,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
@@ -484,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
@@ -492,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
@@ -502,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),
@@ -518,7 +643,7 @@ end
function PaletteFX.spriteObp(spriteDef, seed)
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
local src = spriteDef and spriteDef.source
local src = spriteDef and (spriteDef.paletteSource or spriteDef.source)
if not (w and src) then return nil end
local idx = tonumber(src:match("%[(%d+)%]"))
-- RedBikeSprite loads outside SpriteSheetPointerTable
@@ -533,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
@@ -642,9 +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".
-- 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 YELLOW" end
return PaletteFX.MODE_LABELS[mode] or "GBC"
end
+25 -4
View File
@@ -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
@@ -572,7 +586,7 @@ function Renderer:endFrame(zones, worldZones)
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
boxX, boxY, boxW, boxH, dpiX, dpiY) then
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
end
@@ -706,6 +720,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
+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
+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
+79 -31
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 = {},
},
@@ -116,13 +162,12 @@ local function defaultsSave()
end
-- Merge a GenSave.decode() result over the new-game defaults, exactly the
-- way convert.lua did, then stamp the requested version. The 32768-byte
-- import template GenSave stashes as `rawImport` and the decode `warnings`
-- are dropped here: neither belongs in a serialized slot file (a fresh
-- export always starts zero-filled -- see GenSave.lua's header).
-- way convert.lua did, then stamp the requested version. Keep the imported
-- SRAM image with the slot: Pokémon Red restores its saved current-map cache
-- before Continue, and an export needs that unmodeled data to remain bootable.
-- Decode warnings are only import diagnostics and do not belong in the slot.
local function mergeDefaults(decoded, version)
decoded.warnings = nil
decoded.rawImport = nil
local save = defaultsSave()
for k, v in pairs(decoded) do save[k] = v end
save.lastHeal = { map = save.player.map, x = save.player.x, y = save.player.y }
@@ -139,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)
@@ -171,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
+119 -7
View File
@@ -173,6 +173,27 @@ function Commands.check_item(ctx, itemId)
ctx.lastCheck = (ctx.save.inventory[itemId] or 0) > 0
end
-- check_dex_owned <n>: lastCheck = the player owns at least n species
-- (the CountSetBits-over-wPokedexOwned gate in Yellow's OaksLabOak1Text)
function Commands.check_dex_owned(ctx, n)
local owned = 0
for _ in pairs(ctx.save.pokedex and ctx.save.pokedex.owned or {}) do
owned = owned + 1
end
ctx.lastCheck = owned >= (n or 1)
end
-- dex_rating: DisplayDexRating (engine/events/pokedex_rating.asm) --
-- Oak's seen/owned tally plus the per-decade rating line; blocks until
-- the box closes. Headless-safe no-op without an overworld.
function Commands.dex_rating(ctx)
local ow = ctx.overworld
if not ow then return end
local runner = ctx.runner
ow:dexRating(function() runner:resume() end)
runner:yield()
end
function Commands.jump_if_true(ctx, target)
if ctx.lastCheck then return target end
end
@@ -204,11 +225,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
@@ -232,7 +265,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
@@ -497,6 +541,17 @@ function Commands.play_cry(ctx, species, waitForButton)
ctx.pendingCryWait = waitForButton or nil
end
-- mark_seen <species>: DisplayPokedex (pokedex.asm) records the species as
-- seen before opening its entry. Map scripts use this for NPC-driven
-- Pokédex previews that do not begin a battle or give the player a Pokémon.
function Commands.mark_seen(ctx, species)
local dex = ctx.save and ctx.save.pokedex
if dex then
dex.seen = dex.seen or {}
dex.seen[species] = true
end
end
-- check_battle_result <r1> [r2 ...]: lastCheck = the last scripted
-- battle ended with any of the given results
-- ("win"|"lose"|"run"|"caught"), for branches like
@@ -561,7 +616,10 @@ end
-- AskName runs for party (AddPartyMon) and box (SendNewMonToBox) when a
-- script runner is present; mods that pre-set gift.nickname skip it.
-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37).
function Commands.give_pokemon(ctx, species, level)
-- skipNickname suppresses the AskName prompt: Yellow's lab Pikachu is
-- added straight through AddPartyMon (pokeyellow scripts/OaksLab.asm
-- OaksLabPlayerReceivedMonText) -- the starter Pikachu keeps its name.
function Commands.give_pokemon(ctx, species, level, skipNickname)
-- Native mods can transform a gift before the Pokémon object is created.
-- This is intentionally an event rather than a special-case starter hook:
-- mods can use the same seam for story gifts, fossils, or custom scripts.
@@ -597,7 +655,7 @@ function Commands.give_pokemon(ctx, species, level)
ctx.boxNum = boxNum
-- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks
-- and callback-style callers with no script runner to yield on.
if not gift.nickname and ctx.runner then
if not gift.nickname and not skipNickname and ctx.runner then
askNickname(ctx, mon)
end
if boxNum then
@@ -676,11 +734,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()
@@ -748,7 +810,46 @@ end
-- player CHARMANDER -> base+0, SQUIRTLE -> base+1, BULBASAUR -> base+2.
-- offsets (flag -> party offset) lets a modded roster remap the pick;
-- field.starterCounterpicks is the data-side default when stamped.
-- Yellow's rival parties key off wRivalStarter (save.rivalStarter,
-- 1 JOLTEON / 2 FLAREON / 3 VAPOREON -- set in oaks_lab_yellow.lua), not
-- the player's starter counterpick. Keyed by the Red call-site party so
-- the shared story scripts need no version branches:
-- Route 22 #1 RIVAL1 4 -> party 2 (fixed; Route22Script_50ed6), and
-- a win upgrades FLAREON to JOLTEON (Route22Rival1AfterBattleScript)
-- Cerulean RIVAL1 7 -> party 3 (fixed; CeruleanCity.asm:143)
-- S.S. Anne RIVAL2 1 -> party 1 (fixed; SSAnne2F.asm:98)
-- Tower 2F RIVAL2 4 -> 1 + starter (PokemonTower2F.asm:148)
-- Silph 7F RIVAL2 7 -> 4 + starter (SilphCo7F.asm:185)
-- Route 22 #2 RIVAL2 10 -> 7 + starter (Route22Script_50ee1)
-- Champion RIVAL3 1 -> 0 + starter (ChampionsRoom.asm:69)
local YELLOW_RIVAL_PARTIES = {
OPP_RIVAL1 = {
[4] = { party = 2, upgradeOnWin = { from = 2, to = 1 } },
[7] = { party = 3 },
},
OPP_RIVAL2 = {
[1] = { party = 1 }, [4] = { base = 1 },
[7] = { base = 4 }, [10] = { base = 7 },
},
OPP_RIVAL3 = { [1] = { base = 0 } },
}
function Commands.rival_battle(ctx, oppClass, baseParty, offsets)
local GameVersion = require("src.core.GameVersion")
if GameVersion.isYellow() then
local spec = YELLOW_RIVAL_PARTIES[oppClass]
and YELLOW_RIVAL_PARTIES[oppClass][baseParty]
if spec then
local starter = ctx.save.rivalStarter or 1
local party = spec.party or (spec.base + starter)
Commands.start_battle(ctx, "trainer", oppClass, party)
if spec.upgradeOnWin and ctx.lastBattleResult == "win"
and ctx.save.rivalStarter == spec.upgradeOnWin.from then
ctx.save.rivalStarter = spec.upgradeOnWin.to
end
return
end
end
offsets = offsets
or (ctx.game.data.field and ctx.game.data.field.starterCounterpicks)
local offset = 0
@@ -951,6 +1052,17 @@ function Commands.stop_music(ctx)
require("src.core.Music").stop()
end
-- play_default_music: PlayDefaultMusic -- resume the current map's own
-- theme (data.audio.mapSongs) after a cutscene override, keeping the
-- bike/surf substitution rules. Headless-safe no-op without an overworld.
function Commands.play_default_music(ctx)
local ow = ctx.overworld
if not ow then return end
require("src.core.Music").playMap(ctx.game.data, ow.map.id,
ctx.save and ctx.save.onBike,
ow.player and ow.player.surfing)
end
-- replace_block <bx> <by> <blockId>: the Cut-tree/card-key-door idiom,
-- on the current map
function Commands.replace_block(ctx, bx, by, blockId)
+20 -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 })
@@ -191,8 +195,18 @@ function ScriptRunner:yield()
end
function ScriptRunner:resume(...)
if not self.co then return end
local ok, err = coroutine.resume(self.co, ...)
local co = self.co
if not co then return end
-- A completion callback can fire synchronously from inside the running
-- coroutine (e.g. a battle that finishes during its own stack push when
-- the party is already fainted). Resuming a running coroutine is an
-- error that would kill the whole script, so land the pending yield
-- first and continue on the next update tick instead.
if coroutine.status(co) == "running" then
self.waitingFrames = 1
return
end
local ok, err = coroutine.resume(co, ...)
if not ok then
local source = self.ctx and self.ctx.source
local where = source
@@ -209,8 +223,10 @@ function ScriptRunner:resume(...)
self.co = nil
self.waitingFrames = nil
self.waitingCheck = nil
elseif coroutine.status(self.co) == "dead" then
self.co = nil
-- status via the captured co: a nested resume during the call above may
-- already have torn self.co down, and status(nil) would throw
elseif coroutine.status(co) == "dead" then
if self.co == co then self.co = nil end
end
end
+23 -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")
@@ -158,15 +169,22 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
local moveId = payload
local mdef = game.data.moves[moveId]
local function teach()
-- PIKAHAPPY_USEDTMHM on a successful teach (item_effects.asm:2500)
local function taught()
require("src.world.PikachuFollower")
.modifyHappiness(game.save, "USEDTMHM", target)
end
if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp })
showMessages(game, { Strings("%s learned\n%s!", target.nickname or
game.data.pokemon[target.species].name, mdef.name) })
if result == "learn" then consume(game, id) end
taught()
else
require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId,
function(learned)
if learned and result == "learn" then consume(game, id) end
if learned then taught() end
end)
end
end
@@ -298,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)
+22 -2
View File
@@ -64,9 +64,24 @@ function BindingsMenu.new(game)
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
BindingsMenu)
self.onChoose = function(item) self:beginCapture(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
-- 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
function BindingsMenu:beginCapture(item)
@@ -75,7 +90,12 @@ function BindingsMenu:beginCapture(item)
self.onGamepadPressed = BindingsMenu.capturePad
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)
if key == "escape" then return self:storeBinding("key", nil) end
self:storeBinding("key", key)
end
@@ -100,7 +120,6 @@ 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
@@ -112,9 +131,10 @@ end
function BindingsMenu:draw()
ListMenu.draw(self)
if self.capture then
Font.drawBox(1, 6, 18, 4)
Font.drawBox(1, 6, 18, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("PRESS A BUTTON"), 24, 60)
Font.draw(Strings("ESC TO CANCEL"), 24, 72)
love.graphics.setColor(1, 1, 1, 1)
end
end
+45 -3
View File
@@ -127,6 +127,9 @@ local function deposit(game)
end
table.remove(game.save.party, item.value)
table.insert(active, mon)
-- PIKAHAPPY_DEPOSITED (engine/pokemon/bills_pc.asm:247)
require("src.world.PikachuFollower")
.modifyHappiness(game.save, "DEPOSITED", mon)
local name = monName(game, mon)
game.stringBuffer = name
game.boxNumString = tostring(game.save.currentBox)
@@ -220,13 +223,43 @@ local function drawChrome(game)
love.graphics.setColor(1, 1, 1, 1)
end
-- PrintPCBox (engine/printer/printer.asm): Yellow's box-list print job,
-- box number plus each stored mon's name, level and dex number; the PNG
-- under prints/ stands in for the printer paper.
local function printBox(game)
local box = Boxes.active(game.save)
local Printer = require("src.core.Printer")
local TextBox = require("src.render.TextBox")
local h = 32 + math.max(1, #box) * 10
local saved, err = Printer.save("box_" .. (game.save.currentBox or 1),
160, h, function()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, h)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("BOX No.%d", game.save.currentBox or 1), 8, 8)
if #box == 0 then Font.draw(Strings("Empty."), 8, 24) end
for i, mon in ipairs(box) do
local def = game.data.pokemon[mon.species]
Font.draw(mon.nickname or (def and def.name) or tostring(mon.species),
8, 14 + i * 10)
Font.draw(Strings(":L%d No.%03d", mon.level or 0,
def and def.dex or 0), 88, 14 + i * 10)
end
love.graphics.setColor(1, 1, 1, 1)
end)
game.stack:push(TextBox.new(game, saved
and Strings("Printed BOX %d!\fSaved as\n%s\vin the save\nfolder.",
game.save.currentBox or 1, saved)
or Strings("Printer error!\n%s", tostring(err))))
end
function BoxMenu.new(game)
Boxes.ensure(game.save)
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
-- 12x10 → total 14x12. "CHANGE BOX" / "WITHDRAW <PK><MN>" need the
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
local menu = Menu.new(game, {
local items = {
{ label = Strings("WITHDRAW <PK><MN>"), keepOpen = true,
onSelect = function() withdraw(game) end },
{ label = Strings("DEPOSIT <PK><MN>"), keepOpen = true,
@@ -235,10 +268,19 @@ function BoxMenu.new(game)
onSelect = function() release(game) end },
{ label = Strings("CHANGE BOX"), keepOpen = true,
onSelect = function() changeBox(game) end },
{ label = Strings("SEE YA!") },
}
-- Yellow's PRINT BOX item (bills_pc.asm _YELLOW -> PrintPCBox): the
-- Game Boy Printer box list becomes a PNG under prints/, like the
-- Pokédex PRNT stand-in
if require("src.core.GameVersion").isYellow() then
items[#items + 1] = { label = Strings("PRINT BOX"), keepOpen = true,
onSelect = function() printBox(game) end }
end
items[#items + 1] = { label = Strings("SEE YA!") }
local menu = Menu.new(game, items,
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/pokemon_pc.asm)
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
{ tx = 0, ty = 0, tw = 14, th = #items * 2 + 2, noSound = true })
local baseDraw = menu.draw
function menu:draw()
baseDraw(self)
+32 -11
View File
@@ -35,14 +35,15 @@ function DexEntryMenu.new(game, speciesOrOpts)
local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
self.def = game.data.pokemon[species]
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
{ kind = "dex" })
local path, trueColor = require("src.pokemon.Sprites").path(
game.data, species, "front", { kind = "dex" })
-- `path and pcall(...)` truncates to one value, so img was always nil and
-- every dex page drew without its pic (#307); the guard has to be a
-- statement for pcall's second return to survive.
local ok, img = false, nil
if path then ok, img = pcall(love.graphics.newImage, path) end
self.sprite = ok and img or nil
self.spriteTrueColor = self.sprite and trueColor or false
require("src.core.Sound").playCry(game.data, species)
return self
end
@@ -55,11 +56,26 @@ function DexEntryMenu:update(dt)
end
function DexEntryMenu:draw()
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
self.spriteTrueColor)
end
-- Static entry-page renderer, shared with the printer stand-in
-- (src/core/Printer.lua renders the same page into a PNG the way
-- PrintPokedexEntry rendered it to the Game Boy Printer).
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local def = self.def
if self.sprite then
love.graphics.draw(self.sprite, 8, math.max(0, 60 - self.sprite:getHeight()))
if sprite then
local y = math.max(0, 60 - sprite:getHeight())
love.graphics.draw(sprite, 8, y)
-- a full-color pic has to sit out the SGB recolor, so mark its bounds
-- for the unshaded pass (#350). The printer path leaves trueColor nil:
-- it renders to its own PNG canvas, and a mark left behind there would
-- bleed into the next real frame.
if trueColor then
require("src.render.PaletteFX").markTrueColor(8, y, sprite:getDimensions())
end
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(def.name, 72, 8)
@@ -70,10 +86,10 @@ function DexEntryMenu:draw()
Font.draw(e.kind or "?", 72, 20)
-- same number width as the list (constants.dexDigits), so a dex past 999
-- prints the extra digit everywhere at once
local digits = (self.game.data.constants or {}).dexDigits or 3
local digits = (game.data.constants or {}).dexDigits or 3
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
local owned = self.forceOwned
or (self.game.save.pokedex and self.game.save.pokedex.owned[def.id])
local owned = forceOwned
or (game.save.pokedex and game.save.pokedex.owned[def.id])
-- height/weight print only once owned, like the description
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
-- height, weight, or description")
@@ -81,10 +97,15 @@ function DexEntryMenu:draw()
-- feet/inches use the dex screen's /″ glyphs ("HT ???″" in
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
-- engine/gfx/load_pokedex_tiles.asm)
Font.draw(Strings("HT %d%02d″", e.heightFt, e.heightIn or 0), 72, 44)
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
if e.heightM then
Font.draw((("GR. %.1fm"):format(e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 64, 44)
Font.draw((("GEW. %.1fkg"):format(e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 64, 54)
else
Font.draw(Strings("HT %d%02d″", e.heightFt, e.heightIn or 0), 72, 44)
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
end
end
local text = owned and e.text and self.game.data.text[e.text] or nil
local text = owned and e.text and game.data.text[e.text] or nil
local y = 72
if text then
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
+50
View File
@@ -0,0 +1,50 @@
-- The dex-completion diploma (engine/events/diploma.asm DisplayDiploma /
-- diploma2.asm DisplayDiplomaTop): a bordered certificate page with the
-- player's name, shown by the Celadon Mansion 3F game designer once 150
-- species are owned. Diploma.render also backs the Yellow-only printed
-- copy (engine/printer/printer.asm PrintDiploma -> src/core/Printer.lua).
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Diploma = {}
Diploma.__index = Diploma
Diploma.isOpaque = true
function Diploma.new(game, onDone)
return setmetatable({ game = game, onDone = onDone }, Diploma)
end
function Diploma:update()
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b") then
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
-- the DisplayDiplomaTop layout, hlcoord tiles kept as x*8 / y*8 pixels
function Diploma.render(game)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", 2.5, 2.5, 155, 139)
Font.draw(Strings("<Diploma>"), 40, 16) -- hlcoord 5,2
Font.draw(Strings("Player"), 24, 32) -- hlcoord 3,4
Font.draw(game.save.player.name or "RED", 80, 32) -- hlcoord 10,4
local congrats = { -- hlcoord 2,6
"Congrats! This", "diploma certifies", "that you have",
"completed your", "POKéDEX.",
}
for i, line in ipairs(congrats) do
Font.draw(Strings(line), 16, 48 + (i - 1) * 10)
end
Font.draw(Strings("GAME FREAK"), 72, 128) -- hlcoord 9,16
love.graphics.setColor(1, 1, 1, 1)
end
function Diploma:draw()
Diploma.render(self.game)
end
return Diploma
+23 -11
View File
@@ -48,11 +48,11 @@ end
local FLASH_FRAMES = 220
local function frontSprite(game, species, mon)
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
{ mon = mon, kind = "evolution" })
if not path then return nil end
local path, trueColor = require("src.pokemon.Sprites").path(
game.data, species, "front", { mon = mon, kind = "evolution" })
if not path then return nil, false end
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
return ok and img or nil, ok and trueColor or false
end
function EvolutionState.new(game, mon, newSpecies, onDone, via)
@@ -69,8 +69,8 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via)
-- wForceEvolution clear and so honour B (#290, #213).
self.cancelable = (via ~= "TRADE" and via ~= "ITEM")
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
self.oldSprite = frontSprite(game, mon.species, mon)
self.newSprite = frontSprite(game, newSpecies, mon)
self.oldSprite, self.oldSpriteTrueColor = frontSprite(game, mon.species, mon)
self.newSprite, self.newSpriteTrueColor = frontSprite(game, newSpecies, mon)
self.t = 0
self.done = false
self.canceled = false
@@ -127,18 +127,30 @@ function EvolutionState:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144)
-- accelerating flash between the two forms
local sprite
local sprite, spriteTrueColor
if self.done then
-- a cancelled evolution settles back on the original form
sprite = self.canceled and self.oldSprite or self.newSprite
if self.canceled then
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
else
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
end
else
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
local showNew = math.floor(self.t / period) % 2 == 1
sprite = showNew and self.newSprite or self.oldSprite
if showNew then
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
else
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
end
end
if sprite then
love.graphics.draw(sprite, math.floor((160 - sprite:getWidth()) / 2),
math.max(8, 64 - sprite:getHeight()))
local x = math.floor((160 - sprite:getWidth()) / 2)
local y = math.max(8, 64 - sprite:getHeight())
love.graphics.draw(sprite, x, y)
if spriteTrueColor then
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
end
end
love.graphics.setColor(0, 0, 0, 1)
+1 -1
View File
@@ -191,7 +191,7 @@ function ListMenu:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.title, 8, 4)
Font.draw(Strings(self.title), 8, 4)
if #self.items == 0 then
Font.draw(Strings("Nothing here."), 16, 64)
end
+14
View File
@@ -19,6 +19,20 @@ function Menu.new(game, items, opts)
self.tx = opts.tx or 10
self.ty = opts.ty or 0
self.tw = opts.tw or 10
-- grow the box to the widest label so longer (e.g. localized) labels don't
-- overflow the frame; nudge tx left to keep the box on-screen (20 tiles).
do
local widest = 0
for _, it in ipairs(items) do
if it.label then
local n = #Font.split(it.label)
if n > widest then widest = n end
end
end
local needed = widest + 3
if needed > self.tw then self.tw = needed end
if self.tx + self.tw > 20 then self.tx = math.max(0, 20 - self.tw) end
end
self.rowStep = opts.rowStep or 2
-- maxVisible: cap the box to this many rows and scroll the rest instead
-- of growing past it (e.g. the start menu, whose row count varies with
+1 -1
View File
@@ -183,7 +183,7 @@ function NamingScreen:draw()
end
for r, row in ipairs(self:grid()) do
for c, cell in ipairs(row) do
Font.draw(cell, c * 16, 32 + r * 16)
Font.draw(Strings(cell), c * 16, 32 + r * 16)
end
end
Font.drawCode(Theme.cursor, self.col * 16 - 8, 32 + self.row * 16)
+38 -20
View File
@@ -61,7 +61,7 @@ local function tryImage(path)
return ok and img or nil
end
-- Resolve a pic descriptor to (image, flip).
-- Resolve a pic descriptor to (image, flip, trueColor).
-- Descriptors:
-- "oak" | "rival" | "player" shorthand
-- { type = "trainer", id = "OPP_PROF_OAK" }
@@ -70,7 +70,7 @@ end
-- { type = "image", path = "..." }
-- { type = "sprite", id = "SPRITE_RED" }
function OakSpeech.resolvePic(game, desc, speech)
if desc == nil then return nil, false end
if desc == nil then return nil, false, false end
if type(desc) == "string" then
if desc == "oak" then
desc = { type = "trainer", id = "OPP_PROF_OAK" }
@@ -86,35 +86,37 @@ function OakSpeech.resolvePic(game, desc, speech)
local t = desc.type
if t == "trainer" then
if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then
return speech.oakPic, false
return speech.oakPic, false, false
end
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then
return speech.rivalPic, false
return speech.rivalPic, false, false
end
local trainers = game.data.trainers or {}
local tr = trainers[desc.id]
return tryImage(tr and tr.pic), false
return tryImage(tr and tr.pic), false, false
elseif t == "pokemon" then
if speech and desc.id == speech.demoSpecies and speech.demoPic then
return speech.demoPic, desc.flip and true or false
return speech.demoPic, desc.flip and true or false, speech.demoTrueColor
end
local path = require("src.pokemon.Sprites").path(
local path, trueColor = require("src.pokemon.Sprites").path(
game.data, desc.id, "front", { kind = "oak" })
return tryImage(path), desc.flip and true or false
return tryImage(path), desc.flip and true or false, trueColor
elseif t == "player" then
if speech and speech.playerPic and not desc.path then
return speech.playerPic, false
return speech.playerPic, false, speech.playerTrueColor
end
if desc.path then return tryImage(desc.path), false end
return tryImage(require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "intro" })), false
if desc.path then return tryImage(desc.path), false, false end
local path, trueColor = require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "intro" })
return tryImage(path), false, trueColor
elseif t == "image" then
return tryImage(desc.path), desc.flip and true or false
return tryImage(desc.path), desc.flip and true or false, false
elseif t == "sprite" then
local sp = game.data.sprites and game.data.sprites[desc.id]
return tryImage(sp and sp.image), desc.flip and true or false
return tryImage(sp and sp.image), desc.flip and true or false,
sp and sp.trueColor or false
end
return nil, false
return nil, false, false
end
-- Vanilla step list. Ids are the stable anchors mods insert around.
@@ -219,15 +221,18 @@ function OakSpeech.new(game, onDone)
-- the show-off mon and the name length cap come from data; the vanilla
-- literals stay as the fallbacks
self.demoSpecies = oakGfx.demoSpecies or "NIDORINO"
local demoPath = require("src.pokemon.Sprites").path(
local demoPath, demoTrueColor = require("src.pokemon.Sprites").path(
game.data, self.demoSpecies, "front", { kind = "oak" })
self.demoPic = tryImage(demoPath)
self.demoTrueColor = self.demoPic and demoTrueColor or false
local constants = game.data.constants or {}
self.nameLen = constants.playerNameLength or 7
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and
-- the ShrinkPic1/ShrinkPic2 frames (gfx/player/shrink{1,2}.png)
self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "intro" }))
local playerPath, playerTrueColor = require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "intro" })
self.playerPic = tryImage(playerPath)
self.playerTrueColor = self.playerPic and playerTrueColor or false
self.shrinkPic1 = tryImage(oakGfx.shrink1
or "assets/generated/intro/shrink1.png")
self.shrinkPic2 = tryImage(oakGfx.shrink2
@@ -278,14 +283,17 @@ end
function OakSpeech:applyPic(step)
if step.pic == nil then return end
local img, flip = OakSpeech.resolvePic(self.game, step.pic, self)
local img, flip, trueColor = OakSpeech.resolvePic(self.game, step.pic, self)
if img then
self.pic = img
self.picFlip = flip or false
self.picTrueColor = trueColor or false
elseif step.pic == "player" or (type(step.pic) == "table" and step.pic.type == "player") then
-- mirror the old fallback: player pic missing → oak
self.pic = self.playerPic or self.oakPic
self.picFlip = false
self.picTrueColor = self.pic == self.playerPic and self.playerTrueColor
or false
end
end
@@ -342,6 +350,7 @@ function OakSpeech:runStep(step)
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
self.pic = self.demoPic
self.picFlip = true
self.picTrueColor = self.demoTrueColor
self:revealPic("wipe", function()
Sound.playCry(self.game.data, self.demoSpecies)
self:say(Strings("_OakSpeechText2A"), function() self:advance() end)
@@ -477,7 +486,10 @@ end
function OakSpeech:advance()
self.step = self.step + 1
self.picFlip = false
-- picFlip belongs to the pic, not to the step: OakSpeechText2 prints 2A
-- and 2B over one flipped NIDORINO with no redraw between them
-- (oak_speech.asm:80-83), so a pic-less step must not un-mirror what is
-- still on screen; only applyPic and the demo step may change it (#397)
local steps = self.steps
if not steps then
-- enter() builds steps; keep a path for callers that advance early
@@ -536,8 +548,10 @@ function OakSpeech:update(dt)
s.frame = s.frame + 1
if s.frame == 5 then
self.pic = self.shrinkPic1 or self.pic
self.picTrueColor = false
elseif s.frame == 9 then
self.pic = self.shrinkPic2 or self.pic
self.picTrueColor = false
-- wAudioFadeOutControl = 10: the music ramps to silence over ~70
-- frames (7 levels x 10), reaching 0 just as the fade-to-white
-- begins at frame 79, instead of a hard cut (oak_speech.asm:145-149,
@@ -545,6 +559,7 @@ function OakSpeech:update(dt)
Music.fadeOut(10)
elseif s.frame == 29 then
self.pic = nil
self.picTrueColor = false
self.walkVisible = true
elseif s.frame >= 79 and s.frame <= 102 then
self.fadeLevel = math.floor((s.frame - 79) / 8) + 1
@@ -584,6 +599,9 @@ function OakSpeech:draw()
else
love.graphics.draw(self.pic, x + off, y)
end
if self.picTrueColor then
require("src.render.PaletteFX").markTrueColor(x + off, y, w, h)
end
love.graphics.setColor(1, 1, 1, 1)
end
if self.walkVisible and self.walkSheet then
+65 -6
View File
@@ -2,7 +2,8 @@
-- (engine/menus/main_menu.asm DisplayOptionMenu), the battle ruleset
-- (cycles the merged rulesets registry; gen1_faithful keeps the original
-- quirks), plus the port's audio rows and display rows: music/SFX
-- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
-- volume (0-7), PIKACHU VOL (0-7, Yellow only: trims the PCM voice clips
-- under SFX VOL), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
-- GBC FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens
-- the mod manager.
-- Rows are descriptors fed through the ui.options.rows hook, so mods can
@@ -16,6 +17,7 @@ local GBCFX = require("src.render.GBCFX")
local Zoom = require("src.render.Zoom")
local TileRenderer = require("src.render.TileRenderer")
local GameSpeed = require("src.core.GameSpeed")
local GameVersion = require("src.core.GameVersion")
local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap")
local Logger = require("src.core.Logger")
@@ -82,7 +84,7 @@ local function rulesetName(game)
local ids = rulesetIds(game)
local id = ids[rulesetIndex(game, ids)] or game.save.options.ruleset
local record = id and rulesets[id]
return record and record.name or id or "----"
return Strings(record and record.name or id or "----")
end
-- 0-7 volume level display (0 = OFF)
@@ -118,7 +120,7 @@ local function sameRows(_, rows) return rows end
local function buildRows(game)
local rows = {
{ id = "textSpeed", label = Strings("TEXT SPEED"),
value = function(g) return SPEEDS[speedIndex(g)][2] end,
value = function(g) return Strings(SPEEDS[speedIndex(g)][2]) end,
step = function(g)
local i = speedIndex(g) % #SPEEDS + 1
g.save.options.textSpeed = SPEEDS[i][1]
@@ -126,7 +128,7 @@ local function buildRows(game)
end },
{ id = "animations", label = Strings("BATTLE ANIMATION"),
value = function(g)
return g.save.options.animations == false and "OFF" or "ON"
return g.save.options.animations == false and Strings("OFF") or Strings("ON")
end,
step = function(g)
local o = g.save.options
@@ -135,7 +137,7 @@ local function buildRows(game)
end },
{ id = "battleStyle", label = Strings("BATTLE STYLE"),
value = function(g)
return g.save.options.battleStyle == "set" and "SET" or "SHIFT"
return g.save.options.battleStyle == "set" and Strings("SET") or Strings("SHIFT")
end,
step = function(g)
local o = g.save.options
@@ -146,7 +148,7 @@ local function buildRows(game)
-- widescreen composition (src/battle/WideBattle.lua)
{ id = "battleLayout", label = Strings("BATTLE LAYOUT"),
value = function(g)
return g.save.options.battleLayout == "wide" and "WIDE" or "OG"
return g.save.options.battleLayout == "wide" and Strings("WIDE") or Strings("OG")
end,
step = function(g)
local o = g.save.options
@@ -178,6 +180,18 @@ local function buildRows(game)
require("src.core.Sound").setVolumeLevel(o.sfxVol)
return true
end },
-- Yellow only (filtered out below on Red/Blue): trims Pikachu's voice
-- clips under the SFX level, since Yellow plays them constantly (every
-- follower interaction, the title screen, the dex) and they are mixed
-- much hotter than the chip cries.
{ id = "pikaVol", label = Strings("PIKACHU VOL"),
value = function(g) return volLabel(g.save.options.pikaVol) end,
step = function(g, dir)
local o = g.save.options
o.pikaVol = stepVolume(o.pikaVol, dir)
require("src.core.Sound").setPikaVolumeLevel(o.pikaVol)
return true
end },
{ id = "musicFilter", label = Strings("MUSIC FILTER"),
value = function(g)
return FILTERS[(g.save.options.musicFilter or 0) + 1]
@@ -308,6 +322,25 @@ local function buildRows(game)
activate = function(g)
require("src.ui.Screens").push(g, "BindingsMenu")
end },
-- permanent on-screen pad toggle (#327); layout editing stays in the
-- launcher. Hidden where the overlay never appears (desktop without
-- POKEPORT_TOUCH), so the row costs a non-mobile install nothing.
{ id = "touchControls", label = Strings("TOUCH PAD"),
value = function(g)
local tc = g.save.options.touchControls
local on = not (type(tc) == "table" and tc.enabled == false)
return on and Strings("ON") or Strings("OFF")
end,
step = function(g)
local o = g.save.options
local tc = type(o.touchControls) == "table" and o.touchControls or {}
local on = not (tc.enabled == false)
tc.enabled = not on
-- keep any saved positions when toggling
o.touchControls = tc
require("src.core.TouchControls"):applyOptions(o)
return true
end },
}
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
if not GBCFX.isSupported() then
@@ -317,6 +350,32 @@ local function buildRows(game)
end
rows = filtered
end
-- TOUCH PAD only where the overlay can appear (mobile, or desktop with
-- POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off everywhere.
do
local env = os.getenv("POKEPORT_TOUCH")
local osName = love.system and love.system.getOS and love.system.getOS()
local show = env == "1"
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
if not show then
local filtered = {}
for _, row in ipairs(rows) do
if row.id ~= "touchControls" then filtered[#filtered + 1] = row end
end
rows = filtered
end
end
-- PIKACHU VOL only means something where the voice clips exist: Yellow
-- (data.audio.pikaCries is the clip count the importer wrote). Red/Blue
-- keep the row list they always had.
local audio = game and game.data and game.data.audio
if not (GameVersion.isYellow() and audio and audio.pikaCries) then
local filtered = {}
for _, row in ipairs(rows) do
if row.id ~= "pikaVol" then filtered[#filtered + 1] = row end
end
rows = filtered
end
-- A mod's render pipelines are display modes like TILT, so their rows sit
-- with it rather than at the end of the list where a mod's own
-- ui.options.rows additions land. Nothing registered means nothing

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