Compare commits

..

90 Commits

Author SHA1 Message Date
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 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
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
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 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
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
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
bryanthaboi 0ca1defb66 Merge pull request #341 from bryanthaboi/dev
fix internet, fix the mf grass in advanced
2026-07-28 15:16:09 -04:00
bryanthaboi 370ddea0b8 fix the mf grass 2026-07-28 15:14:48 -04:00
Gabriel Florio 6a9e77cde0 Fix app crashing on self-restart in the AppImage release 2026-07-28 16:02:20 -03:00
bryanthaboi a03f69926e bazinga 2026-07-28 15:01:59 -04:00
bryanthaboi 04ece522d7 anbernic in build and disable gbcfx on it 2026-07-28 14:04:49 -04:00
Gabriel Florio 4943d3e5f4 Unset LD_LIBRARY_PATH when calling system tools from AppImage 2026-07-28 14:42:45 -03:00
bryanthaboi 5a48a61f2e anbernic included in next release 2026-07-28 12:49:04 -04:00
bryanthaboi fd66a04686 Merge pull request #338 from bryanthaboi/dev
squashing all bugs known to man
2026-07-28 12:24:13 -04:00
bryanthaboi b69f8c27c9 Merge pull request #332 from johnjohto/fix-hof-dex-rating
Run the HoF dex rating through the standard text box (#314)
2026-07-28 12:19:43 -04:00
bryanthaboi 66f0326492 Merge pull request #333 from johnjohto/fix-midstep-buttons
Gate overworld buttons on the completed step (#286)
2026-07-28 12:19:31 -04:00
bryanthaboi dbab4a2116 Merge pull request #334 from johnjohto/fix-export-fidelity
Export fidelity: name padding, wOptions, as-caught catchRate (#206)
2026-07-28 12:19:17 -04:00
bryanthaboi 3e8da9b232 Merge pull request #335 from johnjohto/feat-save-slot-names
Nameable save slots in the launcher (#205)
2026-07-28 12:19:05 -04:00
bryanthaboi 223202f230 Merge pull request #336 from GFlorio/fix-linux-portable
Fix portable mode on the Linux AppImage release
2026-07-28 12:18:48 -04:00
bryanthaboi 3078c20e1d Merge pull request #337 from bryanthaboi/squashing-bugs-jul-28
bug squash an additional features
2026-07-28 12:18:17 -04:00
bryanthaboi 617a87a47b anbernic 2026-07-28 12:14:58 -04:00
bryanthaboi 8539a6b268 launcher editor and widescreen battle 2026-07-28 12:00:15 -04:00
bryanthaboi f9f38d161f CLOSES #223, CLOSES #233, CLOSES #236, CLOSES #240, CLOSES #241, CLOSES #249, CLOSES #252, CLOSES #255, CLOSES #257, CLOSES #258, CLOSES #263, CLOSES #265, CLOSES #274, CLOSES #275, CLOSES #276, CLOSES #279, CLOSES #280, CLOSES #282, CLOSES #283, CLOSES #287, CLOSES #291, CLOSES #292, CLOSES #293, CLOSES #301, CLOSES #304, CLOSES #315, CLOSES #316, CLOSES #317, CLOSES #321, CLOSES #322, CLOSES #330 2026-07-28 10:29:05 -04:00
Gabriel Florio 8b85cda84c get proper appDir in the AppImage linux release 2026-07-28 10:58:42 -03:00
johnjohto 1deba07106 Nameable save slots in the launcher (#205)
The reporter labels runs by abusing the in-game player name; give slots
a real label instead.  SaveData.renameSlot persists a trimmed label in
the options registry (options.saveSlots[version].names) -- never in the
save file, so renaming needs no save rewrite and an empty slot can be
labeled too -- listSlots rows carry it as `label`, and deleteSlot
drops it with the slot.

In the launcher, right-clicking a slot row opens an inline rename modal
(Enter commits, Esc cancels, empty clears; 24 whole-codepoint cap via
local UTF-8 helpers, since plain luajit has no utf8 library).  The row
title shows the label over the player name; badges/time/caught stay on
the meta line.  Desktop-only: touch has no secondary button.  main.lua
now forwards love.textinput to the importer while it is up.

Backend covered by a new renameSlot block in tests/engine/save_slots.lua
(78/78); docs/launcher.md's registry section documents the label.
2026-07-28 09:25:55 -04:00
johnjohto c24c7cbcb7 Export fidelity: name padding, wOptions, as-caught catchRate (#206)
Auditing the reporter's PKHeX screenshots against the emitted bytes
turned up three fidelity bugs in the .sav export:

- Name fields on a templateless export stayed zero-filled after the
  $50 terminator; every real save the naming screen wrote $50-pads
  the tail, and the zero tail is what PKHeX rendered as "JOHN{}".
  encodeName now pads zero tail bytes with $50 (nonzero template
  bytes still survive untouched).
- wOptions was never written, dropping text speed / battle style /
  battle effects on export.  Templateless exports now pack it from
  save.options (the recomp's textSpeed 1/3/5 are pokered's exact
  values); with a template the cartridge's own byte still wins.
- The party/box catch-rate byte was always re-derived from the
  current species, but Gen1 freezes it at catch time (evolution does
  not update it), which is why PKHeX demanded "a preevolution catch
  rate".  Pokemon.new now stamps the as-caught catchRate and
  Evolution.apply's in-place mutation preserves it.

The reported emulator crash itself is not addressed here: the encoder
is byte-identical to the reporter's version, and a byte-level audit
(offsets vs Bulbapedia's save map, all three checksums, a real
gameplay save round-trip, pokered's LoadMapData regenerating every
zeroed cache) shows the current export is structurally valid.
2026-07-28 09:00:07 -04:00
johnjohto c2334f2f91 Gate overworld buttons on the completed step (#286)
OverworldLoop (home/overworld.asm) jumps straight to .moveAhead while
wWalkCounter is nonzero: JoypadOverworld -- the START check, the A
check, and direction initiation -- only ever runs while the player
stands on a tile, and a button pressed mid-step is simply never seen.
The port ran handleInput() every frame regardless, so a mid-step
A/START pushed its TextBox/StartMenu right there and froze Red between
tiles, mid-animation (the Nurse Joy run-up in the report).

Gate handleInput on player.moving like the original.  The port-invented
wall-bonk SFX cooldown is hoisted above the gate so it keeps ticking on
held-direction frames mid-step exactly as before.

Adds tests/parity_midstep_buttons.lua: mid-step and last-frame A/START
presses are swallowed and the step completes, and both buttons work
again once the player stands on the tile.
2026-07-28 08:07:17 -04:00
johnjohto bfac5fe327 Run the HoF dex rating through the standard text box (#314)
HoFDisplayPlayerStats prints its three dex texts (seen/owned, the
POKéDEX Rating: header, the tier line) through HoFPrintTextAndDelay ->
PrintText, the standard two-row bottom box, each followed by 120
DelayFrames.  The port hand-drew a 6-row box and flattened the tier
text's cont (\v) rows into plain newlines, so a 3+ row rating painted
its third row over the box's bottom border and dropped the rest.

Push the three texts as a chain of auto-advancing TextBox states
instead: the cont rows scroll inside the two-row box with the original's
A/B wait (_ContText -> ManualTextScroll), and each box closes itself
after the 120-frame hold.

Adds tests/parity_hof_rating.lua: box order/content and the auto-close
hold are asserted over a full headless induction.
2026-07-28 07:32:35 -04:00
bryanthaboi 3b1032cc63 CLOSES #303, CLOSES #307, CLOSES #314, CLOSES #318 2026-07-28 06:16:21 -04:00
bryanthaboi 72f83760b7 Merge pull request #328 from johnjohto/fix-fieldmove-whiteflash
Run the field-move white blink under its text, not after (#320)
2026-07-28 05:10:06 -04:00
bryanthaboi 63a179b77a Merge pull request #326 from johnjohto/fix-windows-picker-unicode
Fix launcher crash on non-ASCII picked filenames (#325)
2026-07-28 05:09:51 -04:00
bryanthaboi 37ba5c3879 Merge pull request #323 from johnjohto/fix-shake-name-dupe
Stop HUD names ghosting during the battle window shake (#295)
2026-07-28 05:09:21 -04:00
bryanthaboi 00f86ca192 Merge pull request #324 from johnjohto/fix-oakspeech-strings-gate
Route the Oak speech fallback texts through Strings.source
2026-07-28 05:08:59 -04:00
johnjohto b0868e7571 Run the field-move white blink under its text, not after (#320)
The vanilla surf/strength flow closed the menu, showed the overworld,
then fired a solid-white blink on the empty map, and set the surfing
sprite while the player was still on land.  pokered's
GBPalWhiteOutWithDelay3 runs while the text is still up, so the blink
reads as a text flash.

The blink now sits under the textbox on the stack (only the top state
updates, so it holds its frames until the text closes), and surfing
applies only when the step onto the water happens.  Same reorder for
the party-menu STRENGTH texts.  The parity surf tests now dismiss the
got-on text before asserting the mount, matching the new order.
2026-07-27 22:42:47 -04:00
johnjohto 9ec3ff26d8 Fix launcher crash on non-ASCII picked filenames (#325)
The Windows pickers shell out to PowerShell, which writes the chosen
path in the console's OEM codepage (CP437 on en-US): a file named
"Pokémon ...zip" came back as Pok\x82mon.  The import then failed and
the error notice carrying those bytes hard-crashed the mods panel's
UTF-8-validating text draw.

- All three Windows picker scripts (ROM, mod, save) now force
  [Console]::OutputEncoding to UTF-8, so returned paths and any notice
  built from them are valid.
- The mod picker also copies the pick to a plain-ASCII temp name and
  returns that, so a non-ASCII filename actually imports instead of
  failing the io.open (Windows io.open needs ANSI bytes).
2026-07-27 22:08:57 -04:00
johnjohto 726e1d4ec5 Route the Oak speech fallback texts through Strings.source
The intro API upgrade left six player-visible literals in OakSpeech's
FALLBACKS table outside the Strings routing, so the strings-coverage
gate failed on dev. Mark them with Strings.source, the catalog idiom
for declaration-site literals, and the gate is green again.
2026-07-27 20:46:06 -04:00
johnjohto 5b3c5f5da8 Stop HUD names ghosting during the battle window shake (#295)
The zone pass composited two copies of the baked canvas on shake
frames: a base copy and the offset copy. The canvas holds window-layer
content (HUD names, the text box), so the vacated strip showed a full
second copy of the enemy name. Draw only the shifted copy and fill the
strip blank, like the hardware revealing empty BG.
2026-07-27 20:44:09 -04:00
bryanthaboi c45f6be8f2 Update README.md 2026-07-27 20:36:30 -04:00
bryanthaboi 3b2e489325 Merge pull request #312 from bryanthaboi/dev
Lots of bug fixes and additional API greatness
2026-07-27 13:47:04 -04:00
bryanthaboi 10acfe0710 Merge main into dev to clear the #312 conflicts
main carried #299 (api upgrades, windows dev tools, android fixes) as a
single squash, while the same work reached dev through #297/#298/#269 and
was refined afterwards.  Every conflicting hunk is dev already being ahead
of that squash:

- src/import/RomImporter.lua, src/ui/OakSpeech.lua: dev routes the launcher
  and intro literals through src/core/Strings.lua, main still inlines them.
- src/pokemon/Sprites.lua: dev adds the Sprites.playerPath seam over
  field.playerPics; OakSpeech's player pic resolves through it instead of
  hardcoding trainer_card/red.png.
- tests/fs_io.lua: dev adds FsIo.globPrefix.
- tests/mod_qol_hooks_tests.lua, tests/mod_ui_tests.lua: dev adds the
  player.sprite, #270 submenu-cancel and #308 finish-once coverage.

Resolved to dev on all six; the merged tree is byte-identical to dev, so
this only records the ancestry.  T1/T2/T4 pass.
2026-07-27 13:44:27 -04:00
johnjohto 5113cfd951 Fire intro.oak_speech.finished only once (#308) (#309)
The shrink timeline called finish() every frame once past its end.
finish() emits finished and then pops the stack, so a listener that
pushes a screen (a warp, a menu) had that screen popped in the speech's
place: the speech stayed alive and re-fired the event every frame,
repeating the listener's side effects each time. Clear the shrink
state before finishing so the timeline cannot run finish() twice.

Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>
2026-07-27 13:39:12 -04:00
johnjohto 43158d724b Add a fieldmove.eligibility hook to partyKnows (#310)
Mods that widen field-move rules (use an HM without teaching it, a
rental mon) had to monkey-patch partyKnows, the one function every
field-move path funnels through. Wrap the vanilla check in a hook
instead: next_ is the whole badge-and-knows-move check, so a wrapper
that calls it first keeps vanilla answers winning and only fills the
cases vanilla denies. No hook, no behavior change.

Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>
2026-07-27 13:38:17 -04:00
bryanthaboi f0a88ea473 Bug squashing and translation mods (#311)
* audio timing stuff

* bug fixes and translation additions

* translation stuff

* Update modkit.py

* better asset resolution
2026-07-27 13:37:05 -04:00
johnjohto 31365d8dfd Keep the used-move text up during battle animations (#296) (#302)
The battle textbox redraws its text every frame, but only while a
message was current. Once the player dismissed "Enemy X used GUST!"
and the move animation started, nothing drew the text, so the box sat
empty for the whole animation. The original game's animations never
touch the textbox, so the text stays up. Draw the held lines while an
animation is playing; everything else is unchanged.

Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>
2026-07-27 10:38:03 -04:00
johnjohto 531a719794 Return to the start menu when a pause submenu is cancelled (#270) (#300)
Selecting a start-menu row pops the start menu before the submenu is
pushed (Menu's generic A handler), so B in a submenu had no parent to
return to and dropped straight to the overworld.  pokered redisplays
the start menu instead (RedisplayStartMenu from the party/item/
trainer-card/option handlers), so give each vanilla submenu an
onCancel that re-opens the start menu; the saved cursor row
(wBattleAndStartSavedMenuItem) restores on re-entry.

- PokedexMenu/BagMenu forward opts.onCancel into their ListMenu
- TrainerCard dismisses back via onCancel on A or B
  (WaitForTextScrollButtonPress then RedisplayStartMenu)
- OptionsMenu fires onCancel from both B/START and the CANCEL row
- PlayerPC rows are keepOpen so B in WITHDRAW/DEPOSIT/TOSS returns to
  the PC root menu (players_pc.asm), matching the BoxMenu pattern

The SAVE flow is untouched: StartMenu_SaveReset falls through to
HoldTextDisplayOpen and never redisplays the menu.  Battle-opened
party/bag screens get no onCancel, so mid-battle cancel behavior is
unchanged.

Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>
2026-07-27 10:05:15 -04:00
johnjohto ff90062741 Make the T3 content tier run on Windows (#269)
* Make ROM-free test tiers actually run on Windows

Suite discovery, the extension-point catalog scan, mod test-dir pickup,
and the meta-coverage corpus all shelled out to ls/find/test -d, which do
not exist in cmd.exe. Every listing came back empty on Windows, so tiers
ran 0 suites and still reported ALL TESTS PASSED.

Add portable probes to tests/fs_io.lua (same Unix commands on
Linux/macOS; dir /b and a shell-free rename-self existence check on
Windows) and rewire the four call sites to them. 15/15 engine suites and
2/2 modkit suites now genuinely run and pass on Windows.

Fixes #266

* Make the T3 content tier run on Windows

The content tier had the same Unix-shell assumptions as the tier
discovery fixed in #267, one level down:

- run_tests.lua redirected to /dev/null when chaining tier runners
- mod_runtime_tests.lua called ffi setenv/unsetenv, which msvcrt lacks
  (_putenv with an empty value unsets)
- modkit_tests.lua captured exit codes with POSIX '; echo 0' (cmd
  needs /v:on and !errorlevel!), called python3 (python on Windows),
  and used mkdir -p / rm -rf; cmd mkdir makes parents on its own and
  read -p as a directory name
- the save-editor suites globbed save backups with ls
- tools/save-editor/Catalog.lua scraped mod flags with ls; this one
  affects the real editor on Windows, not just tests

With a ROM imported, run_tests.lua goes from 12 failures to the 3 that
track the missing audio.lua generation (#268), which fails on any OS
and needs a maintainer decision.

---------

Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>
2026-07-27 10:04:35 -04:00
bryanthaboi 3448fae3f5 Android SCALING fixes (#298)
* android fixes for mods and saves

* perhaps this is the true scaling android issue fix
2026-07-27 09:27:24 -04:00
bryanthaboi 25df838b45 android fixes for mods and saves (#297) 2026-07-27 09:05:57 -04:00
bryanthaboi be90e5b42c intro api upgrade (#294)
* intro api upgrade

* api updates

* fix tests

* updated templates
2026-07-27 08:37:11 -04:00
johnjohto 08e8dfc416 Make ROM-free test tiers actually run on Windows (#267)
Suite discovery, the extension-point catalog scan, mod test-dir pickup,
and the meta-coverage corpus all shelled out to ls/find/test -d, which do
not exist in cmd.exe. Every listing came back empty on Windows, so tiers
ran 0 suites and still reported ALL TESTS PASSED.

Add portable probes to tests/fs_io.lua (same Unix commands on
Linux/macOS; dir /b and a shell-free rename-self existence check on
Windows) and rewire the four call sites to them. 15/15 engine suites and
2/2 modkit suites now genuinely run and pass on Windows.

Fixes #266

Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>
2026-07-27 07:06:53 -04:00
bryanthaboi 6a27be54de add CI to dev branch 2026-07-27 07:01:38 -04:00
bryanthaboi d4055209e6 pop up a fake save 2026-07-27 06:54:18 -04:00
292 changed files with 83492 additions and 4056 deletions
+33
View File
@@ -18,6 +18,19 @@ body:
validations: validations:
required: true 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 - type: dropdown
id: os id: os
attributes: attributes:
@@ -31,6 +44,26 @@ body:
validations: validations:
required: true 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 - type: input
id: version id: version
attributes: attributes:
+15 -1
View File
@@ -11,7 +11,7 @@ body:
If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod
could ship — running shoes, alternate sprites, day/night, shiny indicators, could ship — running shoes, alternate sprites, day/night, shiny indicators,
Gen 2-like battle toggles, soundtrack packs — open a Gen 2-like battle toggles, soundtrack packs — open a
**[Mod request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=mod_request.yml)** **[Mod request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=mod_request.yml)**
instead. instead.
"Can we add X" on its own is hard to act on. Say what you want, why you want it, "Can we add X" on its own is hard to act on. Say what you want, why you want it,
@@ -26,6 +26,20 @@ body:
validations: validations:
required: true 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 - type: input
id: discord id: discord
attributes: attributes:
+15 -1
View File
@@ -10,7 +10,7 @@ body:
soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc. soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc.
The engine already exposes a lot of this through registries and hooks The engine already exposes a lot of this through registries and hooks
([modding wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki)). ([modding wiki](https://github.com/bryanthaboi/gen1recomp/wiki)).
Use a **Feature request** instead for launcher / ports / video options / Use a **Feature request** instead for launcher / ports / video options /
networking / save tooling / new API seams. networking / save tooling / new API seams.
@@ -23,6 +23,20 @@ body:
validations: validations:
required: true 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 - type: input
id: discord id: discord
attributes: attributes:
+77 -15
View File
@@ -1,7 +1,8 @@
name: Release name: Release
# Builds the macOS, Windows, and Linux desktop apps plus an Android APK # Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the
# on the self-hosted Mac runner, and publishes them as a GitHub Release. # Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted
# Mac runner, and publishes them as a GitHub Release.
# #
# Versioning: # Versioning:
# - First ever release is 0.1.0. # - First ever release is 0.1.0.
@@ -169,6 +170,13 @@ jobs:
set -euo pipefail set -euo pipefail
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}" scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
- name: Build Anbernic RG34XXSP port
run: |
set -euo pipefail
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
# runtime from PortMaster-GUI, so it needs no signing/notarization.
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
- name: Notarize & staple macOS app - name: Notarize & staple macOS app
run: | run: |
set -euo pipefail set -euo pipefail
@@ -218,6 +226,12 @@ jobs:
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; } [ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk" cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
# 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"
[ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; }
cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
# Platform-independent update payload, built alongside the desktop # Platform-independent update payload, built alongside the desktop
# apps above (same game.love that gets fused into each of them). # apps above (same game.love that gets fused into each of them).
love_file=".bazinga/work/game.love" love_file=".bazinga/work/game.love"
@@ -240,19 +254,37 @@ jobs:
v="${{ steps.ver.outputs.version }}" v="${{ steps.ver.outputs.version }}"
tag="${{ steps.ver.outputs.tag }}" tag="${{ steps.ver.outputs.tag }}"
# Issues this release closes, per GitHub's own "closing issues" links # Issues this release closes. Three sources, deduped by number:
# (works for squash, rebase, and merge commits alike). Scans every # 1. GitHub's own "closing issues" links on every PR whose
# commit since the previous tag so a skipped release run doesn't drop # commits are in the range (works for squash, rebase, and
# issues on the floor. # 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)" prev_tag="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${tag}$" | head -1 || true)"
range="${prev_tag:+${prev_tag}..}$GITHUB_SHA" range="${prev_tag:+${prev_tag}..}$GITHUB_SHA"
closed="" # every #N on a line that carries a closing keyword (handles the
for pr in $(git log --pretty=%H "$range" \ # multi-issue "CLOSES #1 #2 #3" form the tracker uses)
| xargs -I{} gh api "repos/$GITHUB_REPOSITORY/commits/{}/pulls" \ scan_closes() {
--jq '.[].number' 2>/dev/null \ grep -iE '\b(close[sd]?|fix(es|ed)?|resolve[sd]?)\b' \
| sort -un || true); do | grep -oE '#[0-9]+' | tr -d '#' || true
closed+="$(gh api graphql \ }
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 owner="${GITHUB_REPOSITORY%/*}" \
-f name="${GITHUB_REPOSITORY#*/}" \ -f name="${GITHUB_REPOSITORY#*/}" \
-F pr="$pr" \ -F pr="$pr" \
@@ -260,19 +292,48 @@ jobs:
query($owner:String!, $name:String!, $pr:Int!) { query($owner:String!, $name:String!, $pr:Int!) {
repository(owner:$owner, name:$name) { repository(owner:$owner, name:$name) {
pullRequest(number:$pr) { pullRequest(number:$pr) {
closingIssuesReferences(first:50) { nodes { number title } } closingIssuesReferences(first:50) { nodes { number } }
} }
} }
}' \ }' \
--jq '.data.repository.pullRequest.closingIssuesReferences.nodes[] --jq '.data.repository.pullRequest.closingIssuesReferences.nodes[].number' \
| "- #\(.number) \(.title)"' 2>/dev/null || true)"$'\n' 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 done
closed="$(printf '%s' "$closed" | grep . | sort -t'#' -k2 -n || true)" 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." notes="Download the correct version for your computer below."
if [ -n "$closed" ]; then if [ -n "$closed" ]; then
notes+=$'\n\n## Issues closed\n\n'"$closed" notes+=$'\n\n## Issues closed\n\n'"$closed"
fi fi
if [ -n "$contributors" ]; then
notes+=$'\n\n## Contributors\n\n'"$contributors"
fi
printf 'Release notes:\n%s\n' "$notes" printf 'Release notes:\n%s\n' "$notes"
gh release create "$tag" \ gh release create "$tag" \
@@ -283,6 +344,7 @@ jobs:
"dist/release/gen1recomp-${v}-windows.zip" \ "dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \ "dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk" \ "dist/release/gen1recomp-${v}-android.apk" \
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
"dist/release/gen1recomp-${v}.love" \ "dist/release/gen1recomp-${v}.love" \
"dist/release/sha256sums.txt" "dist/release/sha256sums.txt"
+4
View File
@@ -35,3 +35,7 @@ mobile/ios/build/
# Legacy manual convenience-copy location (superseded by /dist/android/) # Legacy manual convenience-copy location (superseded by /dist/android/)
mobile/dist/ 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/
+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.
+103 -39
View File
@@ -1,13 +1,25 @@
# Pokemon Gen 1 Recompilation Project # Gen1Recomp
A native LÖVE2D recreation of Pokemon 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 behavior are hand-written Lua; game data and graphics are decoded from a ROM
supplied by the player. 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.polygon.com/pokemon-red-blue-3d-voxel-mod-battle-pixels-gameplay-footage-remake/"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-POLYGON-ea2e49?style=for-the-badge" alt="As seen on Polygon"> </a> </p>
<p align="center"> <a href="https://kotaku.com/pokemon-red-blue-recompilation-project-voxel-3d-mod-2000720281"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-KOTAKU-ea2e49?style=for-the-badge" alt="As seen on KOTAKU"> </a> </p>
### Watch the latest update video
[![Watch the latest update video](https://img.youtube.com/vi/TbHdJIrKJJU/maxresdefault.jpg)](https://www.youtube.com/watch?v=TbHdJIrKJJU)
This project does not include a ROM, emulate the Game Boy, transpile assembly, This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only or download a disassembly. A canonical US Poke Red or Blue ROM is the only
game content input. game content input.
The ROM is verified, used during import, and then released from memory. It is The ROM is verified, used during import, and then released from memory. It is
@@ -33,46 +45,82 @@ audio channel programs copied out of the verified ROM.
## Controls ## Controls
| Action | Keyboard | Controller |
|--------|----------|------------| | Action | Keyboard | Controller |
| Move | Arrow keys / WASD | D-pad / left stick | | ------ | ----------------- | ------------------ |
| A | Z / Enter / Space | A | | Move | Arrow keys / WASD | D-pad / left stick |
| B | X / Backspace | B | | A | Z / Enter / Space | A |
| Start | Escape | Start | | B | X / Backspace | B |
| Select | Tab / Shift | Back / Select | | Start | Escape | Start |
| Select | Tab / Shift | Back / Select |
Rebind any of these in-game under **OPTIONS → CONTROLS**. Controllers are Rebind any of these in-game under **OPTIONS → CONTROLS**. Controllers are
supported out of the box. supported out of the box.
### Hotkeys ### Hotkeys
| Key | What it does |
|-----|----------------| | Key | What it does |
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) | | --------- | ---------------------------------------------------- |
| `2` | Cycle COLORS | | `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
| `3` | Cycle TILT (free-roam overworld) | | `2` | Cycle COLORS |
| `4` | Cycle ZOOM through every level (free-roam overworld) | | `3` | Cycle TILT (free-roam overworld) |
| `5` | Cycle GBC FX | | `4` | Cycle ZOOM through every level (free-roam overworld) |
| `F1` | Save | | `5` | Cycle GBC FX |
| `F2` | Load | | `F1` | Save |
| `F10` | Open / close the mod manager | | `F2` | Load |
| `F10` | Open / close the mod manager |
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
and persist in `options.lua`. and persist in `options.lua`.
### Rulesets
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
Both rulesets share the same damage formulas; they differ only in whether
the original's quirks are kept. The setting persists in `options.lua`, and
mods can register their own.
`gen1_faithful` is the default and reproduces the original cartridge,
famous bugs included:
| Rule | Behavior |
| --------------------------- | ----------------------------------------------------- |
| `oneIn256Miss` | A 100%-accurate move still misses on a roll of 255 |
| `critUsesBaseSpeed` | Crit rate reads base speed, not the current stat |
| `critIgnoresStages` | Crit rate ignores stat stages |
| `focusEnergyBug` | FOCUS ENERGY quarters the crit rate instead of x4 |
| `enemyUnlimitedPP` | Enemies never spend PP, so they never Struggle |
| `hyperBeamSkipRechargeOnKO` | HYPER BEAM skips its recharge when the target faints |
| `randMin` / `randMax` | Damage random factor 217-255 |
`modern_clean` keeps the formulas but removes the notorious quirks:
| Rule | Behavior |
| --------------------------- | ----------------------------------------------------- |
| `oneIn256Miss` | Off: a 100%-accurate move always hits |
| `critUsesBaseSpeed` | Unchanged: crit rate still reads base speed |
| `critIgnoresStages` | Off: stat stages count toward the crit rate |
| `focusEnergyBug` | Off: FOCUS ENERGY raises the crit rate as intended |
| `enemyUnlimitedPP` | Off: enemies deplete PP and Struggle when empty |
| `hyperBeamSkipRechargeOnKO` | Off: HYPER BEAM always recharges, like Gen 2+ |
| `randMin` / `randMax` | Damage random factor 217-255, same as faithful |
## Running From Source ## 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 or Blue ROM in the project folder and
double-click `Play-Mac.command` or `Play-Windows.bat`, or run: double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
```sh ```sh
scripts/setup.sh --rom "/path/to/Pokemon Red.gb" # or Pokemon Blue.gb scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Poke Blue.gb
scripts/run.sh scripts/run.sh
``` ```
then `love .` for later launches. Windows PowerShell scripts, the optional then `love .` for later launches. Windows PowerShell scripts, the optional
developer data build, test suites, and cache management are covered in developer data build, test suites, and cache management are covered in
[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup). [Developer Setup](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Developer-Setup).
## Portable Mode ## Portable Mode
@@ -80,7 +128,7 @@ By default the game keeps your save, options, and the private ROM-derived
data cache in your OS's normal per-user app data folder. To keep everything data cache in your OS's normal per-user app data folder. To keep everything
next to the game instead (handy for a USB stick or portable drive you carry next to the game instead (handy for a USB stick or portable drive you carry
between computers), drop an empty file named `portable.txt` next to the app between computers), drop an empty file named `portable.txt` next to the app
(next to `PokemonRed.app`/`.exe`, or next to `main.lua`/`conf.lua` when (next to `gen1recomp.app`/`.exe`, or next to `main.lua`/`conf.lua` when
running from source), then launch the game. Portable mode is desktop-only running from source), then launch the game. Portable mode is desktop-only
(Windows, Linux, macOS); it has no effect on Android or iOS, where the app (Windows, Linux, macOS); it has no effect on Android or iOS, where the app
runs from a read-only package. runs from a read-only package.
@@ -88,14 +136,21 @@ runs from a read-only package.
With `portable.txt` present: With `portable.txt` present:
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to - `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
that same folder instead of the OS save directory. that same folder instead of the OS save directory.
- A ROM import writes the generated `data/generated` and `assets/generated` - A ROM import writes the generated `data/generated` and `assets/generated`
cache straight into that folder too (nothing is left in the OS save cache straight into that folder too (nothing is left in the OS save
directory), so a later launch reuses it without asking for the ROM again directory), so a later launch reuses it without asking for the ROM again
even on a different computer, as long as the same folder comes along. even on a different computer, as long as the same folder comes along.
- Deleting `portable.txt` switches back to the normal OS save directory; nothing - Deleting `portable.txt` switches back to the normal OS save directory; nothing
already written to either location is touched automatically, so copy files already written to either location is touched automatically, so copy files
over yourself if you want to carry existing progress across the switch. over yourself if you want to carry existing progress across the switch.
## Handhelds
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
ships with every release as `gen1recomp-*-rg34xxsp-stockos64-mod.zip`.
Install steps, controls, and troubleshooting live in
[docs/anbernic-rg34xxsp.md](docs/anbernic-rg34xxsp.md).
## Modding ## Modding
@@ -103,36 +158,45 @@ The game ships a native mod platform: content registries, events and hooks,
per-mod saves and options, and an in-game manager. The full modding book — per-mod saves and options, and an in-game manager. The full modding book —
getting started, a twelve-rung tutorial ladder, a cookbook, and the generated getting started, a twelve-rung tutorial ladder, a cookbook, and the generated
reference — lives on the reference — lives on the
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki). [project wiki](https://github.com/bryanthaboi/gen1recomp/wiki).
Shipped example mods, one per kind of author, live in [`mods/`](mods/). 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 ## Bugs and Ideas
Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math
that looks wrong, text in the wrong box, anything that does not match the that looks wrong, text in the wrong box, anything that does not match the
original game. original game.
[Open a bug report](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=bug_report.yml). [Open a bug report](https://github.com/bryanthaboi/gen1recomp/issues/new?template=bug_report.yml).
Attach a screenshot if you can. It saves a lot of back and forth, and if you Attach a screenshot if you can. It saves a lot of back and forth, and if you
can't get one, the form asks you to describe what you saw instead. can't get one, the form asks you to describe what you saw instead.
Thought of a feature that could be good, or a way to improve one that already Thought of a feature that could be good, or a way to improve one that already
exists? exists?
[Open a feature request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=feature_request.yml). [Open a feature request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=feature_request.yml).
Say what you want, why it is worth doing, and how you picture it working. A Say what you want, why it is worth doing, and how you picture it working. A
request with real detail is one that can actually get built. request with real detail is one that can actually get built.
## More ## More
- [Link play](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Link-Play) - [Link play](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Link-Play)
— START > LINK connects two copies directly over UDP. — START > LINK connects two copies directly over UDP.
- [Save editor](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Save-Editor) - [Save editor](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Save-Editor)
— edit party, boxes, items, events, and Pokédex flags outside the game. — edit party, boxes, items, events, and Pokédex flags outside the game.
- `docs/architecture.md` — runtime details; - `docs/architecture.md` — runtime details;
`docs/behavior-porting-notes.md` — formula provenance. `docs/behavior-porting-notes.md` — formula provenance.
## Special Thanks ## Special Thanks
This project would not be possible without [pret](https://github.com/pret) > This project would not be possible without [pret](https://github.com/pret) >
the pret band of decompiling maniacs > and their 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>
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env bash
# Build a PortMaster-style aarch64 port of gen1recomp for Anbernic RG34XXSP
# on Stock OS 64-bit MOD (H700; cbepx-me StockOS MOD / official V1.0.6 base
# + PortMaster).
#
# The stock Anbernic firmware resolves ports relative to the launch script
# (roms/PORTS/...), not PortMaster's /$directory/ports/... path. This pack
# uses SHDIR-relative paths and bundles the LÖVE 11.5 aarch64 runtime so the
# device does not need a separate love_11.5 runtime download on first launch.
#
# Usage:
# ./build-rg34xxsp.sh [--version X.Y.Z]
#
# Output:
# dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip
#
# Install on device:
# 1. Flash / run Stock OS 64-bit MOD with PortMaster installed.
# 2. Unzip into the SD card's roms/PORTS/ folder so you have:
# roms/PORTS/Gen1recomp.sh
# roms/PORTS/gen1recomp/...
# 3. Copy a legal US Red or Blue .gb into roms/PORTS/gen1recomp/lovegame/
# 4. Launch "Gen1recomp" from the Ports list; press Choose ROM (scans that
# folder when zenity is missing).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
HERE="$ROOT/.bazinga"
CACHE="$HERE/cache/rg34xxsp"
WORK="$HERE/work/rg34xxsp"
DIST="$ROOT/dist/rg34xxsp"
APP_NAME="gen1recomp"
# Artifact suffix matches the CFW this port targets (Anbernic H700 Stock OS
# 64-bit MOD for RG34XXSP). Release uploads stage it as
# gen1recomp-<ver>-rg34xxsp-stockos64-mod.zip.
ARTIFACT_SUFFIX="rg34xxsp-stockos64-mod"
PORT_DIR_NAME="gen1recomp"
LAUNCHER_NAME="Gen1recomp.sh"
LOVE_VERSION="11.5"
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
# Official PortMaster LÖVE 11.5 aarch64 runtime (small love stub + liblove).
PM_RUNTIME_BASE="https://raw.githubusercontent.com/PortsMaster/PortMaster-GUI/main/PortMaster/runtimes/love_${LOVE_VERSION}"
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="$2"; shift ;;
-h|--help)
sed -n '2,24p' "$0"
exit 0
;;
*) fail "unknown argument: $1" ;;
esac
shift
done
command -v curl >/dev/null || fail "curl is required"
command -v zip >/dev/null || fail "zip is required"
command -v unzip >/dev/null || fail "unzip is required"
mkdir -p "$CACHE" "$WORK" "$DIST"
download() {
local url="$1" dest="$2"
if [ -f "$dest" ] && [ -s "$dest" ]; then
return 0
fi
say "downloading $(basename "$dest")"
curl -fL --progress-bar "$url" -o "$dest.tmp" \
|| fail "download failed: $url"
mv "$dest.tmp" "$dest"
}
# --------------------------------------------------------------- game tree
# Unpacked directory (not a .love zip) so the player can drop a .gb next to
# main.lua and RomImporter can scan it without zenity/kdialog.
say "staging lovegame/"
GAME_SRC="$WORK/lovegame"
rm -rf "$GAME_SRC"
mkdir -p "$GAME_SRC"
# Same payload as scripts/build.sh's game.love — never ship ROM-derived cache.
# tools/save-editor is part of that payload: the launcher's Edit button on a
# save row opens it in-process (main.lua).
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$WORK/game-payload.zip" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "payload unexpectedly contains generated ROM data"
fi
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
rm -f "$WORK/game-payload.zip"
# Stamp release version into the staged tree only (never the working tree).
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$GAME_SRC/src/core/Version.lua"
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
"$GAME_SRC/src/core/Version.lua" \
|| fail "version stamp failed"
else
say "version '$VERSION' is not X.Y.Z — shipping default engine (no stamp)"
fi
# Portable marker: saves + ROM cache live next to the game on the SD card.
: > "$GAME_SRC/portable.txt"
# --------------------------------------------------------------- love runtime
say "fetching LÖVE $LOVE_VERSION aarch64 runtime"
LOVE_BIN="$CACHE/love.aarch64"
LOVE_LIB="$CACHE/liblove-11.5.so"
LUAJIT_LIB="$CACHE/libluajit-5.1.so.2"
MODPLUG_LIB="$CACHE/libmodplug.so.1"
OGG_LIB="$CACHE/libogg.so.0"
download "$PM_RUNTIME_BASE/love.aarch64" "$LOVE_BIN"
download "$PM_RUNTIME_BASE/libs.aarch64/liblove-11.5.so" "$LOVE_LIB"
download "$PM_RUNTIME_BASE/libs.aarch64/libluajit-5.1.so.2" "$LUAJIT_LIB"
download "$PM_RUNTIME_BASE/libs.aarch64/libmodplug.so.1" "$MODPLUG_LIB"
download "$PM_RUNTIME_BASE/libs.aarch64/libogg.so.0" "$OGG_LIB"
# Sanity: love stub must be an aarch64 ELF.
file "$LOVE_BIN" | grep -qi 'aarch64\|ARM aarch64' \
|| fail "love.aarch64 does not look like an aarch64 ELF (got: $(file "$LOVE_BIN"))"
# --------------------------------------------------------------- port tree
say "assembling port package"
PORT_ROOT="$WORK/port"
rm -rf "$PORT_ROOT"
mkdir -p "$PORT_ROOT/$PORT_DIR_NAME/bin" \
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64" \
"$PORT_ROOT/$PORT_DIR_NAME/licenses" \
"$PORT_ROOT/$PORT_DIR_NAME/conf"
cp -R "$GAME_SRC" "$PORT_ROOT/$PORT_DIR_NAME/lovegame"
cp "$LOVE_BIN" "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
# Drop a short license pointer for the bundled LÖVE bits.
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
(https://github.com/PortsMaster/PortMaster-GUI). LÖVE is zlib-licensed;
see https://love2d.org/ for full terms.
EOF
# --------------------------------------------------------------- launcher
# Stock OS fix: resolve GAMEDIR from this script's directory (SHDIR), not
# PortMaster's $directory variable — Anbernic uses roms/PORTS with different
# casing and mount points (mmc / sdcard / TF1 / TF2).
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
#!/bin/bash
# gen1recomp — Anbernic RG34XXSP stock OS / PortMaster launcher
# Uses SHDIR-relative paths so stock firmware finds the game folder.
export HOME="${HOME:-/root}"
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
if [ -d "/opt/system/Tools/PortMaster/" ]; then
controlfolder="/opt/system/Tools/PortMaster"
elif [ -d "/opt/tools/PortMaster/" ]; then
controlfolder="/opt/tools/PortMaster"
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
controlfolder="$XDG_DATA_HOME/PortMaster"
elif [ -d "/mnt/mmc/Roms/PORTS/PortMaster" ]; then
controlfolder="/mnt/mmc/Roms/PORTS/PortMaster"
elif [ -d "/mnt/sdcard/Roms/PORTS/PortMaster" ]; then
controlfolder="/mnt/sdcard/Roms/PORTS/PortMaster"
elif [ -d "/roms/ports/PortMaster" ]; then
controlfolder="/roms/ports/PortMaster"
else
controlfolder="/roms/PORTS/PortMaster"
fi
SHDIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1090
source "$controlfolder/control.txt"
get_controls
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
GAMEDIR="$SHDIR/gen1recomp"
CONFDIR="$GAMEDIR/conf"
mkdir -p "$CONFDIR"
cd "$GAMEDIR" || exit 1
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
export XDG_DATA_HOME="$CONFDIR"
export XDG_CONFIG_HOME="$CONFDIR"
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
# Mali / H700: prefer GLES where available
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
# Same GPU class as a phone, but getOS() here says "Linux", so the Android
# gate (issue #136) would not fire on its own: refuse GBC FX explicitly.
# Hides the OPTIONS row, pins the level to OFF, and heals a level already
# persisted in options.lua.
export POKEPORT_GBCFX="${POKEPORT_GBCFX:-0}"
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
if [ -n "${GPTOKEYB:-}" ]; then
$GPTOKEYB "love.aarch64" &
fi
if type pm_platform_helper >/dev/null 2>&1; then
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
fi
./bin/love.aarch64 "$GAMEDIR/lovegame"
if type pm_finish >/dev/null 2>&1; then
pm_finish
else
if [ -n "${ESUDO:-}" ]; then
$ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true
else
kill -9 $(pidof gptokeyb) 2>/dev/null || true
fi
fi
EOF
chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
# --------------------------------------------------------------- metadata
cat > "$PORT_ROOT/port.json" <<EOF
{
"version": 2,
"name": "gen1recomp.zip",
"items": [
"$LAUNCHER_NAME",
"$PORT_DIR_NAME"
],
"items_opt": null,
"attr": {
"title": "gen1recomp",
"desc": "Native LÖVE2D recreation of Pokemon Red and Blue. Supply your own legal US Red or Blue ROM.",
"inst": "Requires Anbernic RG34XXSP Stock OS 64-bit MOD with PortMaster. Copy a canonical US Red or Blue .gb into gen1recomp/lovegame/, then launch and press Choose ROM.",
"genres": ["adventure", "rpg"],
"porter": ["gen1recomp"],
"image": {},
"rtr": true,
"runtime": null,
"reqs": [],
"arch": ["aarch64"]
}
}
EOF
cat > "$PORT_ROOT/gameinfo.xml" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<gameList>
<game>
<path>./$LAUNCHER_NAME</path>
<name>gen1recomp</name>
<desc>Native LÖVE2D recreation of Pokemon Red and Blue. Requires your own legal US Red or Blue ROM.</desc>
<releasedate>20250101T000000</releasedate>
<developer>the bois club</developer>
<publisher>the bois club</publisher>
<genre>RPG</genre>
</game>
</gameList>
EOF
cat > "$PORT_ROOT/README.md" <<'EOF'
## gen1recomp (RG34XXSP / Stock OS 64-bit MOD)
Native LÖVE 11.5 port of gen1recomp for Anbernic RG34XXSP on
**Stock OS 64-bit MOD** (H700, PortMaster).
### Install
1. Use **Stock OS 64-bit MOD** with PortMaster installed (TF1).
2. Unzip so `Gen1recomp.sh` and the `gen1recomp/` folder sit in `roms/PORTS/`.
3. Copy a legal US Pokemon Red or Blue `.gb` into `gen1recomp/lovegame/`.
4. Refresh Ports / restart EmulationStation and launch **Gen1recomp**.
### Controls (launcher)
| Input | Action |
|--|--|
| D-pad / left stick | Move cursor |
| A | Click |
| L1 / R1 | Switch tabs |
| Right stick | Scroll lists |
| Start / Select | Play or Choose ROM |
In-game controls use the normal PortMaster / SDL pad map (rebind under OPTIONS → CONTROLS).
### Display options
GBC FX is disabled on this device (the H700's Mali GPU compiles that present
pass and then shows a black frame), so the OPTIONS row is hidden. COLORS,
TILT, ZOOM, VOID FILL and MAX FPS all work. To try it anyway, launch with
`POKEPORT_GBCFX=1`.
### First run
Stock OS has no zenity file picker. Put the `.gb` in `lovegame/`, then press
**Choose ROM** — the game scans that folder. After import, the ROM-derived
cache and saves stay beside the game (`portable.txt`).
Canonical SHA-1 (1 MiB US carts only):
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
### Thanks
LÖVE runtime binaries from [PortMaster](https://portmaster.games/).
Stock OS 64-bit MOD: [cbepx-me](https://github.com/cbepx-me/Anbernic-H700-RG-xx-StockOS-Modification).
EOF
# --------------------------------------------------------------- zip
ZIP_OUT="$DIST/$APP_NAME-$ARTIFACT_SUFFIX.zip"
rm -f "$ZIP_OUT" "$DIST/$APP_NAME-rg34xxsp.zip"
say "packing $ZIP_OUT"
(cd "$PORT_ROOT" && zip -q -9 -r "$ZIP_OUT" \
"$LAUNCHER_NAME" "$PORT_DIR_NAME" port.json gameinfo.xml README.md)
say "done."
say "artifact: $ZIP_OUT ($(du -h "$ZIP_OUT" | cut -f1))"
say "copy onto the RG34XXSP SD card under roms/PORTS/ (Stock OS 64-bit MOD), then drop your .gb into gen1recomp/lovegame/"
+14 -1
View File
@@ -13,7 +13,12 @@ function love.conf(t)
_G.POKEPORT_DEV_MODE = developer _G.POKEPORT_DEV_MODE = developer
if editor then if editor then
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor" -- Same identity as the game, deliberately: the editor edits the game's
-- saves and reads the game's ROM cache, both of which live under this
-- folder. A private editor identity would point love.filesystem at an
-- empty directory in a packaged build, so `--editor` could not find
-- data/generated at all (SaveIO.defaultPath already assumed this name).
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d"
t.window.title = "Pokemon Save Editor" t.window.title = "Pokemon Save Editor"
t.window.width = 1280 t.window.width = 1280
t.window.height = 800 t.window.height = 800
@@ -30,6 +35,14 @@ function love.conf(t)
-- starting size, not the game's resolution. -- starting size, not the game's resolution.
t.window.width = 1024 t.window.width = 1024
t.window.height = 768 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 end
t.version = "11.5" t.version = "11.5"
t.window.vsync = 1 t.window.vsync = 1
+79 -24
View File
@@ -1,33 +1,88 @@
-- Celadon Mansion 3F Game Designer (pokered/scripts/CeladonMansion3F.asm -- Celadon Mansion 3F, the GAME FREAK dev floor
-- CeladonMansion3FGameDesignerText): text_asm counts set bits in -- (pokered+pokeyellow/scripts/CeladonMansion3F.asm). Every dev's
-- wPokedexOwned and compares against NUM_POKEMON - 1 (discounts Mew). -- text_asm counts set bits in wPokedexOwned against NUM_POKEMON - 1
-- If the player owns >= 150 species, shows the "completed" text -- (150, discounting Mew). The game designer shows the diploma
-- (originally followed by DisplayDiploma, which has no equivalent UI -- (DisplayDiploma -> src/ui/Diploma.lua) on a completed dex; Yellow's
-- in this port, so we just show the congratulatory text); otherwise -- graphic artist then offers the printed copy (PrintDiploma, stood in
-- shows the normal encouragement text. -- 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 { return {
CELADON_MANSION_3F = { CELADON_MANSION_3F = {
talk = { talk = {
TEXT_CELADONMANSION3F_GAME_DESIGNER = function(game, ow, npc, done) TEXT_CELADONMANSION3F_GAME_DESIGNER = function(game, ow, npc, done)
local t = game.data.text 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") 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, end,
}, },
}, },
+10 -2
View File
@@ -1,13 +1,21 @@
-- pokered/scripts/PewterNidoranHouse.asm -- pokered/scripts/PewterNidoranHouse.asm
-- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then -- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then
-- text_asm plays the NIDORAN_M cry (PlayCry/WaitForSoundToFinish) before ending. -- text_asm plays the NIDORAN_M cry (PlayCry/WaitForSoundToFinish) before ending.
-- No sound-effect command exists in Commands.lua, so we port the dialogue line -- play_cry only stashes ctx.pendingCry for the very next show_text row, so it
-- only; the cry SFX is cosmetic and has no gameplay effect. -- has to sit immediately before that row (src/script/Commands.lua); the cry
-- then sounds once the box has typed out, matching the asm's print-then-cry
-- order. Its waitForButton form keeps DisplayTextID's trailing
-- WaitForTextScrollButtonPress -- PewterNidoranHouse_Script is
-- `jp EnableAutoTextBoxDrawing`, which zeroes
-- wDoNotWaitForButtonPressAfterDisplayingText -- so the box still holds until
-- A/B. The cry row was missing, leaving the NIDORAN silent (#247).
return { return {
PEWTER_NIDORAN_HOUSE = { PEWTER_NIDORAN_HOUSE = {
talk = { talk = {
TEXT_PEWTERNIDORANHOUSE_NIDORAN = { TEXT_PEWTERNIDORANHOUSE_NIDORAN = {
{ "face_player" }, { "face_player" },
-- text_asm: ld a, NIDORAN_M / call PlayCry
{ "play_cry", "NIDORAN_M", true },
{ "show_text", "_PewterNidoranHouseNidoranText" }, { "show_text", "_PewterNidoranHouseNidoranText" },
}, },
}, },
+22
View File
@@ -28,7 +28,29 @@ local function badgeGuard(badge, passFlag)
} }
end end
-- Route23SetVictoryRoadBoulders (pokered scripts/Route23.asm:8): every entry
-- to Route 23 resets the Victory Road boulder puzzle behind you. The 2F/3F
-- switch events clear (so those barriers are closed again on the next floor
-- load) and the boulder that fell through 3F's hole goes back upstairs:
-- ShowObject TOGGLE_VICTORY_ROAD_3F_BOULDER / HideObject
-- TOGGLE_VICTORY_ROAD_2F_BOULDER, which are VICTORYROAD3F_BOULDER4 and
-- VICTORYROAD2F_BOULDER3 (data/maps/toggleable_objects.asm). 1F's event is
-- reset by the Indigo Plateau lobby and by Victory Road 2F, not here. This
-- is what makes the puzzle "completely reset" on a return trip (#258).
-- onEnter is the port's BIT_CUR_MAP_LOADED_2 equivalent: setMap runs it on
-- every map entry, connection crossings included, like EnterMap.
M.ROUTE_23 = { M.ROUTE_23 = {
onEnter = function(game, ow)
local f = game.save.flags
f.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 = nil
f.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 = nil
f.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = nil
f.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = nil
local Commands = require("src.script.Commands")
local ctx = { save = game.save, overworld = ow, game = game }
Commands.show_object(ctx, "VICTORY_ROAD_3F", "VICTORYROAD3F_BOULDER4")
Commands.hide_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3")
end,
talk = { talk = {
-- Route23Guard1Text: EventFlagBit ..., EVENT_PASSED_EARTHBADGE_CHECK -- Route23Guard1Text: EventFlagBit ..., EVENT_PASSED_EARTHBADGE_CHECK
-- -> wWhichBadge = EARTHBADGE -- -> wWhichBadge = EARTHBADGE
+7 -7
View File
@@ -7,19 +7,19 @@
-- CheckFightingMapTrainers/TalkToTrainer via the existing trainer -- CheckFightingMapTrainers/TalkToTrainer via the existing trainer
-- system, and the item pickups carry no talk text of their own here. -- system, and the item pickups carry no talk text of their own here.
-- --
-- TEXT_SSANNE2FROOMS_GENTLEMAN3 also opens the POKéDEX entry for -- TEXT_SSANNE2FROOMS_GENTLEMAN3 opens SNORLAX's POKéDEX entry after its
-- SNORLAX after the text box (DisplayPokedex in the original), which -- text box, like DisplayPokedex in the original. The preview marks it seen
-- has no equivalent talk-script hook in this port (DexEntryMenu has -- but does not mark it owned.
-- no done-callback the way other pushed UI states do) -- only the
-- flavor line is ported here.
return { return {
SS_ANNE_2F_ROOMS = { SS_ANNE_2F_ROOMS = {
talk = { talk = {
-- SSAnne2FRoomsGentleman3Text: PrintText(_SSAnne2FRoomsGentleman3Text) -- SSAnne2FRoomsGentleman3Text: PrintText(_SSAnne2FRoomsGentleman3Text),
-- (then DisplayPokedex SNORLAX -- not ported, see note above) -- then DisplayPokedex SNORLAX.
TEXT_SSANNE2FROOMS_GENTLEMAN3 = { TEXT_SSANNE2FROOMS_GENTLEMAN3 = {
{ "face_player" }, { "face_player" },
{ "show_text", "_SSAnne2FRoomsGentleman3Text" }, { "show_text", "_SSAnne2FRoomsGentleman3Text" },
{ "mark_seen", "SNORLAX" },
{ "push_screen", "DexEntryMenu", "SNORLAX" },
}, },
-- SSAnne2FRoomsGentleman4Text: PrintText(_SSAnne2FRoomsGentleman4Text) -- SSAnne2FRoomsGentleman4Text: PrintText(_SSAnne2FRoomsGentleman4Text)
+7 -3
View File
@@ -59,13 +59,17 @@ M.VIRIDIAN_CITY = {
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are -- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
-- defined without a leading underscore in pokered/text/ViridianCity.asm -- defined without a leading underscore in pokered/text/ViridianCity.asm
-- and aren't present in data/generated/text.lua, so we fall back to -- and aren't present in data/generated/text.lua, so we fall back to
-- the literal strings from pokered. -- the literal strings from pokered. Those fallbacks have to carry the
-- extractor's markers, not plain newlines: line -> \n, cont -> \v,
-- para -> \f. Spelling cont/para as \n and \n\n put all six lines on
-- one page with nothing to wait on, so the whole speech scrolled past
-- without a button press (#250).
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done) TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
local t = text(game) local t = text(game)
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
or "You want to know\nabout the 2 kinds\nof caterpillar\nPOKéMON?", function(yes) or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
if yes then if yes then
push(game, "CATERPIE has no\npoison, but\nWEEDLE does.\n\nWatch out for its\nPOISON STING!", done) push(game, "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
else else
push(game, "Oh, OK then!", done) push(game, "Oh, OK then!", done)
end end
@@ -3,12 +3,16 @@
return { return {
VIRIDIAN_NICKNAME_HOUSE = { VIRIDIAN_NICKNAME_HOUSE = {
talk = { talk = {
-- ViridianNicknameHouseSpearowText: text_asm that shows the text -- ViridianNicknameHouseSpearowText: text_asm that PrintTexts the
-- then plays a SPEAROW cry (PlayCry/WaitForSoundToFinish). The -- line, then plays the SPEAROW cry (PlayCry/WaitForSoundToFinish)
-- cry playback isn't in the talk-script command vocabulary, so -- before TextScriptEnd. play_cry only arms the show_text row that
-- only the flavor line is ported here. -- follows it, so the cry sounds once the box has typed out; its
-- waitForButton form keeps DisplayTextID's trailing
-- WaitForTextScrollButtonPress, so the box then holds until A/B like
-- any other NPC line rather than popping itself (#251).
TEXT_VIRIDIANNICKNAMEHOUSE_SPEAROW = { TEXT_VIRIDIANNICKNAMEHOUSE_SPEAROW = {
{ "show_text", "_ViridianNicknameHouseSpearowText" }, { "play_cry", "SPEAROW", true }, -- 1 PlayCry
{ "show_text", "_ViridianNicknameHouseSpearowText" }, -- 2 PrintText
}, },
}, },
}, },
+20 -1
View File
@@ -12,11 +12,18 @@
-- different files can each add NPCs to the same map), and mod -- different files can each add NPCs to the same map), and mod
-- contributions from the map_scripts registry compose on top of it. -- contributions from the map_scripts registry compose on top of it.
local GameVersion = require("src.core.GameVersion")
local MapScripts = require("src.script.MapScripts") 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({ for _, mapEntry in ipairs({
{ "PALLET_TOWN", "data.scripts.pallet_town" }, { "PALLET_TOWN", "data.scripts.pallet_town" },
{ "OAKS_LAB", "data.scripts.oaks_lab" }, { "OAKS_LAB", oaksLab },
{ "REDS_HOUSE_1F", "data.scripts.reds_house" }, { "REDS_HOUSE_1F", "data.scripts.reds_house" },
{ "CELADON_MANSION_ROOF_HOUSE", "data.scripts.celadon_eevee" }, { "CELADON_MANSION_ROOF_HOUSE", "data.scripts.celadon_eevee" },
}) do }) do
@@ -36,6 +43,18 @@ for _, file in ipairs({ "data.scripts.story", "data.scripts.story2",
end end
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 = {} local M = {}
function M.get(mapId) 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 BALLS = 30
local STEPS = 502 local STEPS = 502
local function startGame(game, t, done) local function startGame(game, t, done, balls, introText)
game.save.money = game.save.money - FEE game.save.safari = { balls = balls or BALLS, steps = STEPS }
game.save.safari = { balls = BALLS, steps = STEPS } game.save.safariNags = nil
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local paid = (t._SafariZoneGateSafariZoneWorker1ThatllBe500PleaseText local paid = introText
or "That'll be ¥500\nplease!\f{PLAYER} received\n30 SAFARI BALLs!") or (t._SafariZoneGateSafariZoneWorker1ThatllBe500PleaseText
:gsub("{PLAYER}", game.save.player.name) 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 local pa = t._SafariZoneGateSafariZoneWorker1CallYouOnThePAText
or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!" or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!"
local luck = t._SafariZoneGateSafariZoneWorker1GoodLuckText or "Good Luck!" local luck = t._SafariZoneGateSafariZoneWorker1GoodLuckText or "Good Luck!"
game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, done)) game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, done))
end 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) local function joinPrompt(game, ow, done)
done = done or function() end done = done or function() end
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
@@ -51,9 +92,14 @@ local function joinPrompt(game, ow, done)
back(t._SafariZoneGateSafariZoneWorker1PleaseComeAgainText back(t._SafariZoneGateSafariZoneWorker1PleaseComeAgainText
or "OK! Please come\nagain!") or "OK! Please come\nagain!")
elseif game.save.money < FEE then elseif game.save.money < FEE then
back(t._SafariZoneGateSafariZoneWorker1NotEnoughMoneyText if require("src.core.GameVersion").isYellow() then
or "Oops! Not enough\nmoney!") yellowLowCost(game, ow, t, done, back)
else
back(t._SafariZoneGateSafariZoneWorker1NotEnoughMoneyText
or "Oops! Not enough\nmoney!")
end
else else
game.save.money = game.save.money - FEE
startGame(game, t, done) startGame(game, t, done)
end end
end)) end))
+66 -16
View File
@@ -16,6 +16,14 @@ M.VIRIDIAN_MART = {
-- StartSimulatingJoypadStates walks the player to the counter -- StartSimulatingJoypadStates walks the player to the counter
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5)) -- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
-- and ViridianMartOaksParcelScript hands the parcel over. The player -- and ViridianMartOaksParcelScript hands the parcel over. The player
-- never presses A.
--
-- The RLE list is consumed back to front: DecodeRLEList fills forward
-- from wSimulatedJoypadStatesEnd, the script seeds wSimulatedJoypadStates-
-- Index with the count, and home/overworld.asm decrements that index
-- before each read. So PAD_LEFT 1, PAD_UP 2 actually plays up, up, left,
-- and the walk ends facing the clerk. Replaying the list front to back
-- put the player at the counter facing up instead (#235). The player
-- never presses A. This matters beyond convenience: the parcel gates -- never presses A. This matters beyond convenience: the parcel gates
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees -- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
-- it on entry rather than letting you walk out without it. -- it on entry rather than letting you walk out without it.
@@ -28,8 +36,8 @@ M.VIRIDIAN_MART = {
if not f.EVENT_GOT_STARTER then return end if not f.EVENT_GOT_STARTER then return end
ow:queueScript({ ow:queueScript({
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" }, { "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
{ "move_player", "left", 1 },
{ "move_player", "up", 2 }, { "move_player", "up", 2 },
{ "move_player", "left", 1 },
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!" -- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" }, { "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" }, { "set_flag", "EVENT_GOT_OAKS_PARCEL" },
@@ -86,6 +94,27 @@ M.VIRIDIAN_CITY = {
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end) { "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end)
}, },
}, },
-- Re-apply the Pokedex old-man swap for a save that already holds the flag
-- but was never standing here when it fired: a vanilla .sav imported
-- through src/save_convert, whose codec does not model pokered's
-- wToggleableObjectFlags array, so save.objectToggles arrives empty and
-- both objects fall back to their compiled-in defaults -- the sleeper ON
-- (data/maps/toggleable_objects.asm toggleable_objects_for VIRIDIAN_CITY),
-- still lying across the north path (#234).
-- OaksLabOakGivesPokedexScript (scripts/OaksLab.asm) sets EVENT_GOT_POKEDEX
-- and, with no branch in between, runs HideObject TOGGLE_LYING_OLD_MAN /
-- ShowObject TOGGLE_OLD_MAN, so that one flag settles both toggles exactly.
-- Same shape as M.OAKS_LAB.onEnter in data/scripts/oaks_lab.lua, which
-- re-applies the same script's other two HideObjects on entry (#106).
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, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY")
Commands.show_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN")
end,
-- ViridianCityCheckGotPokedexScript: the north corridor is gated on -- ViridianCityCheckGotPokedexScript: the north corridor is gated on
-- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers -- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers
-- on exactly one cell -- (19,9), the gap east of the sleeper (18,9) -- on exactly one cell -- (19,9), the gap east of the sleeper (18,9)
@@ -711,11 +740,20 @@ local function boulderAt(ow, x, y)
return npc and npc.def.sprite == "SPRITE_BOULDER" and npc or nil return npc and npc.def.sprite == "SPRITE_BOULDER" and npc or nil
end end
-- Each barrier is stamped BOTH ways on entry. pokered only ever stamps the
-- OPEN block (ReplaceTileBlock edits the loaded block map, which the next map
-- load throws away, so a fresh entry always shows the .blk's closed barrier),
-- but OverworldState:replaceBlock writes through Map:setBlock into the SHARED
-- map record in Game.data, so the port has to put the closed block back
-- itself or a solved barrier stays open for the rest of the session (#258).
-- The closed ids are the shipped .blk bytes: VictoryRoad1F.blk (4,6) = $25,
-- VictoryRoad2F.blk (3,4) = $37 and (11,7) = $25, VictoryRoad3F.blk
-- (3,5) = $25. Same both-ways pattern as the card-key doors in
-- OverworldState:setMap.
M.VICTORY_ROAD_1F = { M.VICTORY_ROAD_1F = {
onEnter = function(game, ow) onEnter = function(game, ow)
if game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH then ow:replaceBlock(4, 6,
ow:replaceBlock(4, 6, 0x1D) game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH and 0x1D or 0x25)
end
end, end,
onBoulderMoved = function(game, ow, npc) onBoulderMoved = function(game, ow, npc)
if npc.cellX == 17 and npc.cellY == 13 if npc.cellX == 17 and npc.cellY == 13
@@ -728,12 +766,14 @@ M.VICTORY_ROAD_1F = {
M.VICTORY_ROAD_2F = { M.VICTORY_ROAD_2F = {
onEnter = function(game, ow) onEnter = function(game, ow)
if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 then -- VictoryRoad2FResetBoulderEventScript (scripts/VictoryRoad2F.asm:19):
ow:replaceBlock(3, 4, 0x15) -- entering 2F clears the 1F switch event, so 1F's barrier is closed
end -- again the next time you climb down (#258)
if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 then game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH = nil
ow:replaceBlock(11, 7, 0x1D) ow:replaceBlock(3, 4,
end game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 and 0x15 or 0x37)
ow:replaceBlock(11, 7,
game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 and 0x1D or 0x25)
end, end,
onBoulderMoved = function(game, ow, npc) onBoulderMoved = function(game, ow, npc)
if npc.cellX == 1 and npc.cellY == 16 if npc.cellX == 1 and npc.cellY == 16
@@ -751,9 +791,8 @@ M.VICTORY_ROAD_2F = {
M.VICTORY_ROAD_3F = { M.VICTORY_ROAD_3F = {
onEnter = function(game, ow) onEnter = function(game, ow)
if game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 then ow:replaceBlock(3, 5,
ow:replaceBlock(3, 5, 0x1D) game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 and 0x1D or 0x25)
end
end, end,
onBoulderMoved = function(game, ow, npc) onBoulderMoved = function(game, ow, npc)
if npc.cellX == 3 and npc.cellY == 5 if npc.cellX == 3 and npc.cellY == 5
@@ -761,12 +800,23 @@ M.VICTORY_ROAD_3F = {
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = true game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = true
ow:replaceBlock(3, 5, 0x1D) ow:replaceBlock(3, 5, 0x1D)
end end
-- the hole at (23,15): the boulder drops to 2F next to switch 2 -- the hole at (23,15): the boulder drops to 2F next to switch 2.
if npc.cellX == 23 and npc.cellY == 15 then -- VictoryRoad3FDefaultScript .handle_hole gates the swap on
-- CheckAndSetEvent EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2, then
-- HideObject TOGGLE_VICTORY_ROAD_3F_BOULDER / ShowObject
-- TOGGLE_VICTORY_ROAD_2F_BOULDER. Those two toggles are
-- VICTORYROAD3F_BOULDER4 and VICTORYROAD2F_BOULDER3
-- (data/maps/toggleable_objects.asm); the old "VICTORYROAD2F_BOULDER"
-- matched no object_event name, so the toggle landed under a key
-- objectVisible never reads: harmless only while nothing hid that
-- boulder, and fatal once Route 23's reset does (#258).
if npc.cellX == 23 and npc.cellY == 15
and not game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 then
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = true
local Commands = require("src.script.Commands") local Commands = require("src.script.Commands")
local ctx = { save = game.save, overworld = ow, game = game } local ctx = { save = game.save, overworld = ow, game = game }
Commands.hide_object(ctx, "VICTORY_ROAD_3F", npc.def.name) Commands.hide_object(ctx, "VICTORY_ROAD_3F", npc.def.name)
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER") Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3")
end end
end, end,
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole -- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
+105 -91
View File
@@ -1,6 +1,10 @@
-- More hand-ported events: the Pallet Town intro, the thirsty Saffron -- More hand-ported events: the Pallet Town intro, the thirsty Saffron
-- gate guards, the Bike Voucher chain, fossils and the day-care. -- 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 = {} local M = {}
@@ -35,72 +39,79 @@ function escort.findPath(fromX, fromY, toX, toY)
return path return path
end end
-- PalletTownOakWalksToPlayerScript: Oak appears at his object spot -- Red: Oak object (8,5) -> one tile below the player at y=1.
-- (8,5) and zigzags to one tile below the player (hNPCPlayerYDistance -- Yellow: Oak object (10,4); stop fires at y=0 (pokeyellow PalletTown).
-- is pre-decremented before predef FindPathToPlayer).
function escort.oakApproach(playerX) function escort.oakApproach(playerX)
if GameVersion.isYellow() then
return escort.findPath(10, 4, playerX, 1)
end
return escort.findPath(8, 5, playerX, 2) return escort.findPath(8, 5, playerX, 2)
end end
-- RLEList_ProfOakWalkToLab (engine/overworld/auto_movement.asm): -- RLEList_ProfOakWalkToLab (engine/overworld/auto_movement.asm).
-- DOWN x5, LEFT, DOWN x5, RIGHT x3, UP -- Oak's last step lands on the -- Yellow differs: first DOWN is x6 (Oak starts one tile farther north).
-- lab door (12,11). (The trailing NPC_CHANGE_FACING is a march-in- local function buildOakSteps()
-- place beat on the mat; here Oak just stands his final beat.) if GameVersion.isYellow() then
escort.oakSteps = { return {
"down", "down", "down", "down", "down", "down", "down", "down", "down", "down", "down",
"left", "left",
"down", "down", "down", "down", "down", "down", "down", "down", "down", "down",
"right", "right", "right", "right", "right", "right",
"up", "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, escort.oakSteps = buildOakSteps()
-- DOWN x6 and plays in REVERSE buffer order (wSimulatedJoypadStatesEnd
-- grows downward): DOWN x6, LEFT, DOWN x5, RIGHT x3, UP x2 -- Oak's -- Player stays one step behind Oak (simplified reverse-RLE port).
-- 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.playerSteps = { "down" } escort.playerSteps = { "down" }
for _, d in ipairs(escort.oakSteps) do for _, d in ipairs(escort.oakSteps) do
escort.playerSteps[#escort.playerSteps + 1] = d escort.playerSteps[#escort.playerSteps + 1] = d
end 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 = { M.PALLET_TOWN = {
talk = require("data.scripts.pallet_town").talk, talk = require("data.scripts.pallet_town").talk,
escort = escort, escort = escort,
-- Oak stops you at the north row (PalletTownDefaultScript's -- Red: stop at y==1 from (8,5). Yellow: stop at y==0 from (10,4),
-- `wYCoord == 1` check), walks up from (8,5), and leads you to his -- then a wild Pikachu battle before the lab escort (pokeyellow
-- lab with the player one step behind (scripts/PalletTown.asm + -- PalletTownPikachuBattleScript).
-- PalletMovementScriptPointerTable in
-- engine/overworld/auto_movement.asm), then the lab walk-in and the
-- choose-mon exchange (scripts/OaksLab.asm OaksLabDefaultScript ..
-- OaksLabOakChooseMonSpeechScript).
onStep = function(game, ow, x, y) 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 or game.save.flags.EVENT_GOT_STARTER then
return false return false
end end
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Commands = require("src.script.Commands") local Commands = require("src.script.Commands")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local BattleState = require("src.battle.BattleState")
local t = game.data.text local t = game.data.text
local ctx = { save = game.save, game = game, overworld = ow } local ctx = { save = game.save, game = game, overworld = ow }
-- PalletTownDefaultScript: stop the player, turn them around -- Red turns the player down; Yellow faces up at the north exit.
-- (wPlayerMovingDirection = PLAYER_DIR_DOWN applies on the very ow.player.facing = yellow and "up" or "down"
-- next frame, before the text box opens) and strike up the "oak
-- appears" theme (MUSIC_MEET_PROF_OAK)
ow.player.facing = "down"
Music.play(game.data, "Music_MeetProfOak") 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) local function hold(frames, npc, cb)
ow.emote = { frames = frames, npc = npc, onDone = cb } ow.emote = { frames = frames, npc = npc, onDone = cb }
end end
-- chain single-tile scriptMoves through a direction list
local function walkList(entity, steps, done) local function walkList(entity, steps, done)
local i = 0 local i = 0
local function nextStep() local function nextStep()
@@ -114,10 +125,8 @@ M.PALLET_TOWN = {
nextStep() nextStep()
end end
-- ---- Oak's Lab side (scripts/OaksLab.asm) ---------------------- -- OaksLabOakChooseMonSpeechScript. Yellow's OakChooseMon text is
-- the single-ball speech, not Red's "there are 3 POKéMON".
-- OaksLabOakChooseMonSpeechScript: the fed-up / choose-mon /
-- what-about-me / be-patient exchange, Delay3 between boxes
local function chooseMonSpeech() local function chooseMonSpeech()
local function say(key, fb, next) local function say(key, fb, next)
game.stack:push(TextBox.new(game, t[key] or 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() "{RIVAL}: Gramps!\nI'm fed up with\nwaiting!", function()
hold(3, nil, function() hold(3, nil, function()
say("_OaksLabOakChooseMonText", 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() hold(3, nil, function()
say("_OaksLabRivalWhatAboutMeText", say("_OaksLabRivalWhatAboutMeText",
"{RIVAL}: Hey!\nGramps! What\nabout me?", function() "{RIVAL}: Hey!\nGramps! What\nabout me?", function()
@@ -143,30 +154,18 @@ M.PALLET_TOWN = {
end) end)
end end
-- entering the lab: the door Oak (OAKSLAB_OAK2, (5,10)) walks up 3 -- Door Oak is OAKSLAB_OAK2: Red index 8, Yellow index 6 (one ball).
-- 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)
local function labWalkIn() 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() local function swapOaks()
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2") Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK1") Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK1")
hold(3, nil, function() -- Delay3 hold(3, nil, function()
Commands.face_object(ctx, 1, "down") -- rival watches you pass Commands.face_object(ctx, 1, "down")
ow:scriptMove(ow.player, "up", 8, function() 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 = true
game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB_2 = true game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB_2 = true
Commands.face_object(ctx, 1, "up") 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") Music.playMap(game.data, "OAKS_LAB")
chooseMonSpeech() chooseMonSpeech()
end) end)
@@ -179,9 +178,6 @@ M.PALLET_TOWN = {
end end
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() local function enterLab()
Commands.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK") Commands.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2") Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
@@ -190,10 +186,6 @@ M.PALLET_TOWN = {
{ keepMusic = true }) { keepMusic = true })
end 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 function walkToLab(oak)
local i = 0 local i = 0
local function tick() local function tick()
@@ -206,9 +198,6 @@ M.PALLET_TOWN = {
if oak and escort.oakSteps[i] then if oak and escort.oakSteps[i] then
ow:scriptMove(oak, escort.oakSteps[i], 1) ow:scriptMove(oak, escort.oakSteps[i], 1)
elseif oak then 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) ow:marchInPlace(oak)
end end
ow:scriptMove(ow.player, playerStep, 1, tick) ow:scriptMove(ow.player, playerStep, 1, tick)
@@ -216,9 +205,6 @@ M.PALLET_TOWN = {
tick() tick()
end 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 function escortToLab(oak)
local numSteps = x - 10 local numSteps = x - 10
if oak and numSteps > 0 then if oak and numSteps > 0 then
@@ -232,37 +218,65 @@ M.PALLET_TOWN = {
end end
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() local function oakAppearsAndWalks()
Commands.show_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK") 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 if oak then oak.facing = "up" end
hold(6, nil, function() hold(6, nil, function()
walkList(oak, escort.oakApproach(x), function() walkList(oak, escort.oakApproach(x), function()
-- PalletTownOakNotSafeComeWithMeScript: the second text waits afterOakArrives(oak)
-- 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))
end) end)
end) 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, game.stack:push(TextBox.new(game,
t._PalletTownOakHeyWaitDontGoOutText or "OAK: Hey! Wait!\nDon't go out!", t._PalletTownOakHeyWaitDontGoOutText or "OAK: Hey! Wait!\nDon't go out!",
nil, { auto = { delay = 10, overlap = 10, onOverlap = function() 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 } ow.emote = { npc = ow.player, frames = 50, onDone = oakAppearsAndWalks }
end } })) end } }))
return true return true
@@ -761,7 +775,7 @@ M.CINNABAR_LAB_FOSSIL_ROOM = {
-- #118: do not raise mon.level until a paid retrieve (pokered reverts -- #118: do not raise mon.level until a paid retrieve (pokered reverts
-- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into -- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into
-- mon.exp once and clear them so a second talk cannot re-apply the same -- mon.exp once and clear them so a second talk cannot re-apply the same
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here -- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here --
-- TextBox.TOKENS.RAM only knows wStringBuffer. -- TextBox.TOKENS.RAM only knows wStringBuffer.
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
+1 -1
View File
@@ -300,7 +300,7 @@ local function elevator(elevatorMapId, keyGate, preFrames)
-- Rocket Hideout: without LIFT_KEY the panel only prints the need- -- Rocket Hideout: without LIFT_KEY the panel only prints the need-
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are -- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
-- still seeded above so walking out returns to the entry floor -- still seeded above so walking out returns to the entry floor
-- instead of the car's ROM default (B1F) #90 / #105. -- instead of the car's ROM default (B1F) -- #90 / #105.
if keyGate and not game.save.inventory[keyGate.item] then if keyGate and not game.save.inventory[keyGate.item] then
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
+9 -7
View File
@@ -37,16 +37,15 @@ local function countOwned(save)
return n return n
end end
local function oaksAide(threshold, itemId) local function oaksAide(threshold, itemId, repeatText)
return function(game, ow, npc, done) return function(game, ow, npc, done)
local t = text(game) local t = text(game)
local flags = game.save.flags local flags = game.save.flags
local itemName = game.data.items[itemId].name local itemName = game.data.items[itemId].name
local flag = "EVENT_GOT_" .. itemId local flag = "EVENT_GOT_" .. itemId
if flags[flag] then if flags[flag] then
push(game, fill(t._OaksAideComeBackText or push(game, t[repeatText] or
"I already gave\nyou the {RAM:}!", fill("I already gave\nyou the {RAM:}!", { ram = itemName }), done)
{ num = threshold, ram = itemName }), done)
return return
end end
ask(game, fill(t._OaksAideHiText or ask(game, fill(t._OaksAideHiText or
@@ -83,13 +82,16 @@ local function oaksAide(threshold, itemId)
end end
M.ROUTE_2_GATE = { 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 = { 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 = { M.ROUTE_15_GATE_2F = {
talk = { TEXT_ROUTE15GATE2F_OAKS_AIDE = oaksAide(50, "EXP_ALL") }, talk = { TEXT_ROUTE15GATE2F_OAKS_AIDE = oaksAide(50, "EXP_ALL",
"_Route15Gate2FOaksAideExpAllText") },
} }
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
+147 -19
View File
@@ -293,7 +293,11 @@ pewterEscort.guySteps = {
"right", "right", "right", "right", "right", "right",
} }
-- Walk home: reverse of guySteps with opposite facings (gym → spawn). -- Reverse of guySteps with opposite facings, i.e. the gym-to-spawn
-- mirror of RLEList_PewterGymGuy. Kept as the documented inverse that
-- tests/parity_pewter_escort.lua checks; the youngster does NOT walk it
-- home any more, because its first step is LEFT through the player
-- parked on (11,18) (#241). See walkHome below.
do do
local opp = { up = "down", down = "up", left = "right", right = "left" } local opp = { up = "down", down = "up", left = "right", right = "left" }
local ret = {} local ret = {}
@@ -371,20 +375,44 @@ local function pewterGymEscort(game, ow)
local head = plan.guyHeadStart local head = plan.guyHeadStart
-- After the walk: face the player, restore map music, "Go take on -- After the walk: face the player, restore map music, "Go take on
-- BROCK", then retrace RLEList_PewterGymGuy back to his spawn (35,16). -- BROCK", then MovementData_PewterGymGuyExit -- five steps RIGHT out of
-- (pokered teleports him via MovementData_PewterGymGuyExit; we walk -- (12,18), the cell PewterCityYoungsterShowsPlayerGymScript pins him to
-- the same route home instead. Brock victory still HideObject's him.) -- with SetSpritePosition1 (hSpriteMapXCoord 16 / hSpriteMapYCoord 22,
-- minus the +4 border offset object_event coords carry per
-- macros/scripts/maps.asm) and exactly where the escort leaves him.
-- That lands him on (17,18), the last walkable cell before the fence at
-- (18,18) and one column past the screen edge with the player parked on
-- (11,18). PewterCityHideYoungsterScript then HideObject's him and
-- PewterCityResetYoungsterScript's SetSpritePosition2 + ShowObject put
-- him back on his object_event spawn (35,16) facing DOWN, which is the
-- snap below (the vanish/reappear is off screen, same as the original).
--
-- The old code retraced guyReturnSteps instead, whose first step is
-- LEFT into (11,18) -- the cell the player is standing on -- and
-- scriptMove is a pure tween with no entity test, so he walked straight
-- through Red (#241). There is no honest route home: (17,18) is a
-- dead-end pocket ((18,18) fence, (17,17) wall), which is precisely why
-- the original teleports. guyReturnSteps stays as the documented
-- mirror of RLEList_PewterGymGuy that parity_pewter_escort asserts.
-- Brock victory still HideObject's him.
local function walkHome() local function walkHome()
if not guy then return end if not guy then return end
local ret = pewterEscort.guyReturnSteps
local i = 0 local i = 0
local function tick() local function tick()
i = i + 1 i = i + 1
if not ret[i] then if i > 5 then
-- SetSpritePosition2: same field writes as Commands.place_npc,
-- including the target clear -- a stale targetX/targetY would
-- leave OverworldState:npcAtCell reserving (17,18) forever and
-- silently wall the player out of that pocket.
guy.cellX, guy.cellY = 35, 16
guy.px, guy.py = 35 * 16, 16 * 16
guy.moving = false
guy.targetX, guy.targetY = nil, nil
guy.facing = "down" guy.facing = "down"
return return
end end
ow:scriptMove(guy, ret[i], 1, tick) ow:scriptMove(guy, "right", 1, tick)
end end
tick() tick()
end end
@@ -468,23 +496,48 @@ end
-- Gym) and again in Viridian Gym; we derive the same windows from the -- Gym) and again in Viridian Gym; we derive the same windows from the
-- surrounding story flags so old saves work too. -- surrounding story flags so old saves work too.
-- Rival1Exit → Viridian (right/down); Rival2Exit → League (left). -- Rival1Exit → Viridian (right/down); Rival2Exit → League (left).
-- Both exit lists are keyed on wSavedCoordIndex -- WHICH entry of
-- Route22DefaultScript.Route22RivalBattleCoords the player matched -- not
-- on where the rival ended up. CheckCoords (home/map_objects.asm:107)
-- zeroes wCoordIndex and `inc [hl]` BEFORE each compare, so the first
-- entry reports 1: index 1 is (29,4), index 2 is (29,5).
-- Route22Rival1AfterBattleScript (Route22.asm:175) takes
-- ...ExitMovementData1 on index 1 and ...Data2 otherwise. From the top
-- tile the rival stands BELOW the player on (29,5) and leaves east along
-- row 5; from the bottom tile he stands LEFT of him on (28,5) and has to
-- step UP to row 4 to get around him. The port had the two branches
-- swapped, so the top tile started the walk with UP out of (28,4) into
-- the cliff cell (28,3), which is not walkable (#236).
local function route22ExitDirs(n, py) local function route22ExitDirs(n, py)
if n == 2 then if n == 2 then
if py == 5 then return { "left", "left", "left", "left" } end -- Route22Rival2ExitMovementData1 falls through into ...Data2: LEFT x4
-- from (29,5), LEFT x3 from (28,5), both back onto the spawn (25,5).
if py == 4 then return { "left", "left", "left", "left" } end
return { "left", "left", "left" } return { "left", "left", "left" }
end end
if py == 5 then -- Route22Rival1ExitMovementData1: (29,5) -> (31,5) -> (31,10)
if py == 4 then
return { "right", "right", "down", "down", "down", "down", "down" } return { "right", "right", "down", "down", "down", "down", "down" }
end end
-- Route22Rival1ExitMovementData2: (28,5) -> (28,4) -> (31,4) -> (31,10)
return { "up", "right", "right", "right", return { "up", "right", "right", "right",
"down", "down", "down", "down", "down", "down" } "down", "down", "down", "down", "down", "down" }
end end
local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py) local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
-- Route22MoveRivalRightScript (Route22.asm:39) walks him RIGHT along his
-- own row from the object_event spawn (25,5): the full four-RIGHT
-- Route22RivalMovementData on coord index 1 (player on (29,4)), so he
-- stops BELOW the player on (29,5); `inc de` drops one RIGHT on index 2
-- (player on (29,5)), so he stops LEFT of him on (28,5). He never
-- leaves row 5. Route22Rival{1,2}StartBattleScript (Route22.asm:110)
-- then faces him UP on index 1 and RIGHT otherwise (#236).
local rx = (py == 4) and 29 or 28
local rivalFacing = (py == 4) and "up" or "right"
return { return {
{ "show_object", "ROUTE_22", objName }, -- 1 { "show_object", "ROUTE_22", objName }, -- 1
{ "move_npc_to", objIndex, 28, py }, -- 2 { "move_npc_to", objIndex, rx, 5 }, -- 2
{ "face_object", objIndex, "right" }, -- 3 { "face_object", objIndex, rivalFacing }, -- 3
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4 { "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
{ "rival_battle", oppClass, baseParty }, -- 5 { "rival_battle", oppClass, baseParty }, -- 5
{ "jump_if_false", 11 }, -- 6 { "jump_if_false", 11 }, -- 6
@@ -496,20 +549,25 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
} }
end end
-- Route22Rival{1,2}StartBattleScript turns the player toward him too:
-- PLAYER_DIR_DOWN on coord index 1 (the (29,4) tile, rival below him),
-- and the PLAYER_DIR_LEFT Route22DefaultScript already set on index 2
-- (the (29,5) tile, rival to his left) (#236).
M.ROUTE_22 = { M.ROUTE_22 = {
onStep = function(game, ow, x, y) onStep = function(game, ow, x, y)
if not inCoords({ { 29, 4 }, { 29, 5 } }, x, y) then return false end if not inCoords({ { 29, 4 }, { 29, 5 } }, x, y) then return false end
local f = game.save.flags local f = game.save.flags
local playerFacing = (y == 4) and "down" or "left"
if f.EVENT_GOT_POKEDEX and not f.EVENT_BEAT_BROCK if f.EVENT_GOT_POKEDEX and not f.EVENT_BEAT_BROCK
and not f.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then and not f.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then
return runAmbush(game, ow, return runAmbush(game, ow,
route22Scene(1, 1, "ROUTE22_RIVAL1", "OPP_RIVAL1", 4, route22Scene(1, 1, "ROUTE22_RIVAL1", "OPP_RIVAL1", 4,
"EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), "left") "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), playerFacing)
end end
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
return runAmbush(game, ow, return runAmbush(game, ow,
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10, route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), "left") "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing)
end end
return false return false
end, end,
@@ -545,7 +603,7 @@ end
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief, -- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief,
-- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket -- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket
-- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 / -- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 /
-- Hide ROCKET → GBFadeInFromBlack not a bare hide_object. -- Hide ROCKET → GBFadeInFromBlack -- not a bare hide_object.
local rocketRows = { local rocketRows = {
{ "face_player" }, -- 1 { "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_TM28" }, -- 2 { "check_flag", "EVENT_GOT_TM28" }, -- 2
@@ -564,7 +622,7 @@ local rocketRows = {
{ "fade", "out" }, -- 15 GBFadeOutToBlack { "fade", "out" }, -- 15 GBFadeOutToBlack
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2 -- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south -- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
-- door neighbour the swap reconnects the city (Bill's ticket does -- door neighbour -- the swap reconnects the city (Bill's ticket does
-- the same in story.lua; either route is enough). -- the same in story.lua; either route is enough).
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16 { "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17 { "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
@@ -628,13 +686,83 @@ M.MUSEUM_1F = {
} }
-- The Pewter Center's singing JIGGLYPUFF (scripts/PewterPokecenter.asm -- The Pewter Center's singing JIGGLYPUFF (scripts/PewterPokecenter.asm
-- plays MUSIC_JIGGLYPUFF_SONG, then the map theme resumes) -- PewterPokecenterJigglypuffText). The text_asm sets
-- wDoNotWaitForButtonPressAfterDisplayingText before PrintText, so the box
-- prints with no A prompt and the script keeps running underneath it:
-- SFX_STOP_ALL_MUSIC, DelayFrames 32, PlayMusic MUSIC_JIGGLYPUFF_SONG,
-- then .spinMovementLoop writes the next of DOWN -> LEFT -> UP -> RIGHT (a
-- clockwise turn) every 24 frames for as long as the song's CHAN1/CHAN2
-- still sound, DelayFrames 48, PlayDefaultMusic, TextScriptEnd. Only that
-- last step closes the box, so the whole dance plays out unskippably
-- (#249). Music.playOnce's pendingRestore stands in for both the channel
-- poll and PlayDefaultMusic, so the Center's theme comes back the frame
-- the song ends rather than 48 frames later.
local JIGGLYPUFF_SPIN = { "down", "left", "up", "right" }
local JIGGLYPUFF_SILENCE, JIGGLYPUFF_STEP, JIGGLYPUFF_TAIL = 32, 24, 48
-- Built as a TextBox `auto` table: auto.sound fires the frame the last
-- page has typed out (PrintText returning), and auto.tick then runs once
-- per frame while the gate it returns still reads as playing.
local function jigglypuffDance(game, npc)
local Music = require("src.core.Music")
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
-- matching the sprite's current facing (showMapText has just turned it
-- toward the player), and the first write is that same facing, so the
-- first visible quarter turn lands 24 frames in
local step = 1
for i, dir in ipairs(JIGGLYPUFF_SPIN) do
if npc and npc.facing == dir then step = i end
end
local frames, phase = 0, "silence"
return {
sound = function()
Music.stop() -- SFX_STOP_ALL_MUSIC
return { isPlaying = function() return phase ~= "done" end }
end,
tick = function()
frames = frames + 1
if phase == "silence" then
if frames < JIGGLYPUFF_SILENCE then return end
frames = 0
if Music.playOnce(game.data, "Music_JigglypuffSong") then
phase = "spin"
else
-- no song def (or headless): Music.stop above already took the
-- map theme down and nothing armed pendingRestore, so put it
-- back by hand and fall through to the tail rather than hold
-- the box on a poll that would never clear
Music.restoreMap(game.data)
phase = "tail"
end
return
end
if phase == "spin" then
if frames < JIGGLYPUFF_STEP then return end
frames = 0
-- the loop tests the channels after the delay and before the next
-- write, so a song that just ended costs no extra quarter turn
if not Music.oneShotPlaying() then
phase = "tail"
return
end
step = step % #JIGGLYPUFF_SPIN + 1
if npc then npc.facing = JIGGLYPUFF_SPIN[step] end
return
end
if frames >= JIGGLYPUFF_TAIL then phase = "done" end
end,
}
end
M.PEWTER_POKECENTER = { M.PEWTER_POKECENTER = {
talk = { talk = {
TEXT_PEWTERPOKECENTER_JIGGLYPUFF = function(game, ow, npc, done) TEXT_PEWTERPOKECENTER_JIGGLYPUFF = function(game, ow, npc, done)
require("src.core.Music").playOnce(game.data, "Music_JigglypuffSong") -- not the local push() helper: this box needs opts.auto, which is
push(game, text(game)._PewterPokecenterJigglypuffText -- also what suppresses the blinking arrow and the A dismissal
or "JIGGLYPUFF: Puu\npupuu!", done) local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
done, { auto = jigglypuffDance(game, npc) }))
end, end,
}, },
} }
+39
View File
@@ -173,7 +173,46 @@ local function syncGymGatesAfterBattle(game, ow)
applyGymGates(game, ow) applyGymGates(game, ow)
end 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 = { 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, onEnter = applyGymGates,
onVictory = syncGymGatesAfterBattle, onVictory = syncGymGatesAfterBattle,
onInteract = function(game, ow, fx, fy) onInteract = function(game, ow, fx, fy)
+1 -1
View File
@@ -16,7 +16,7 @@
-- `dialogue` is the end-battle + post-battle text chain each gym leader -- `dialogue` is the end-battle + post-battle text chain each gym leader
-- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM -- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM
-- script). Leaders are not def_trainers entries, so engageTrainer has -- script). Leaders are not def_trainers entries, so engageTrainer has
-- no header.won checkVictoryRewards shows this chain instead of a -- no header.won -- checkVictoryRewards shows this chain instead of a
-- synthetic "received badge/TM" stub. -- synthetic "received badge/TM" stub.
local function range(prefix, first, last) local function range(prefix, first, last)
+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,
},
},
}
+102
View File
@@ -0,0 +1,102 @@
-- 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
rows[#rows + 1] = { "show_text", "MelanieText3" }
rows[#rows + 1] = { "hide_object", "CERULEAN_MELANIES_HOUSE",
"CERULEANMELANIESHOUSE_BULBASAUR" }
rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" }
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" },
{ "show_text", "_Route24DamianText2" },
{ "set_flag", "EVENT_54F" },
{ "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" }
rows[#rows + 1] = { "show_text", "_OfficerJennyText3" }
rows[#rows + 1] = { "set_flag",
"EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" }
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
+261
View File
@@ -0,0 +1,261 @@
-- 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", "right" },
{ "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" },
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
{ "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
+91
View File
@@ -0,0 +1,91 @@
# Anbernic RG34XXSP (Stock OS 64-bit MOD)
Download `gen1recomp-*-rg34xxsp-stockos64-mod.zip` from
[Releases](https://github.com/bryanthaboi/gen1recomp/releases). This build
targets **Stock OS 64-bit MOD** on the RG34XXSP with PortMaster installed
(TF1).
## Install
1. Unzip the release on your computer. You get `Gen1recomp.sh` and a
`gen1recomp/` folder.
2. Copy **both** onto the SD card under **`Roms/PORTS/`** so the layout is:
```
Roms/PORTS/Gen1recomp.sh
Roms/PORTS/gen1recomp/
```
On the device that path is `/mnt/mmc/Roms/PORTS/`. Keep the launcher and
the `gen1recomp/` folder as siblings — do not nest the `.sh` inside the
folder.
3. Put your legal US Red and/or Blue `.gb` files inside the game folder:
```
Roms/PORTS/gen1recomp/lovegame/
```
Example: `Roms/PORTS/gen1recomp/lovegame/Pokemon - Red Version.gb`
4. Eject the card, boot the handheld, open **Ports → Gen1recomp**.
5. On the launcher, move the cursor with the D-pad or left stick, press **A**
to click. Choose the Red or Blue tab, then **Choose ROM** — with no file
picker on stock OS, that scans `lovegame/` for the `.gb` you dropped in.
The port ships with `portable.txt` already in place, so after import the
saves and ROM-derived cache stay next to the game on the SD card. See
[Portable Mode](../README.md#portable-mode) for what that means.
Only the canonical 1 MiB US carts import:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
## Launcher controls
| Input | Action |
| ------------------ | ------------------ |
| D-pad / left stick | Move cursor |
| A | Click |
| L1 / R1 | Switch tabs |
| Right stick | Scroll lists |
| Start / Select | Play or Choose ROM |
In-game controls use the normal PortMaster / SDL pad map, rebindable under
**OPTIONS → CONTROLS**.
## Notes
**GBC FX is off on this device.** The launcher exports `POKEPORT_GBCFX=0`,
which hides the GBC FX row from OPTIONS, pins the level to OFF, and clears a
level carried over in an `options.lua` from another machine. The H700's Mali
GPU is in the same class as the phone GPUs that compile that present pass and
then show a black frame (issue #136), and `love.system.getOS()` reports
`"Linux"` here, so the Android gate would not have caught it. Every other
display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
put the row back.
The pack bundles the LÖVE 11.5 aarch64 runtime from
[PortMaster](https://portmaster.games/), so the device does not need a
separate `love_11.5` runtime download on first launch. The launcher resolves
paths relative to its own directory rather than PortMaster's `$directory`,
because stock Anbernic firmware runs ports out of `roms/PORTS/` with its own
casing and mount points.
If a launch fails, `Roms/PORTS/gen1recomp/log.txt` holds the output of the
last run.
## Building the port
Release runs build this automatically (see `.github/workflows/release.yml`).
To build it by hand:
```sh
./build-rg34xxsp.sh --version 0.1.0 # -> dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip
```
`install-rg34xxsp.sh` copies that pack straight onto a mounted SD card for
local testing.
Stock OS 64-bit MOD comes from
[cbepx-me](https://github.com/cbepx-me/Anbernic-H700-RG-xx-StockOS-Modification).
+1 -1
View File
@@ -59,7 +59,7 @@ the same core data and graphics into the source tree for verification.
| | `src/battle/Experience.lua`, `Catching.lua`, `TrainerAI.lua` | exp/levels, Gen 1 catch algorithm, AI | | | `src/battle/Experience.lua`, `Catching.lua`, `TrainerAI.lua` | exp/levels, Gen 1 catch algorithm, AI |
| | `src/battle/rulesets/` | `gen1_faithful` (default) vs `modern_clean` | | | `src/battle/rulesets/` | `gen1_faithful` (default) vs `modern_clean` |
| ui | `src/ui/*` | start menu, generic menu, yes/no box, party/bag lists | | ui | `src/ui/*` | start menu, generic menu, yes/no box, party/bag lists |
| | `tools/save-editor/` | Standalone save editor (`love . --editor`) | | | `tools/save-editor/` | Save editor: shipped in every build, opened from the launcher's Edit button or standalone with `love . --editor` |
## Map scripts ## Map scripts
-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.
+70
View File
@@ -69,6 +69,15 @@ keeps working unchanged.
- **Registry.** The ordered slot list and which one is active persist in - **Registry.** The ordered slot list and which one is active persist in
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`): `options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
`options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`. `options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`.
Custom slot labels (#205) live alongside them in the same registry:
`options.saveSlots[version].names = { slot1 = "Nuzlocke" }`, written by
`SaveData.renameSlot` (trimmed; an empty label clears it) and surfaced on
each `listSlots` row as `label` (the launcher row shows `label`, falling
back to the player name). `deleteSlot` drops the label with the slot.
Renaming never touches the save file, so an empty slot can be labeled.
On desktop, right-clicking a slot row opens the inline rename modal
(Enter commits, Esc cancels); touch has no secondary button, so the
affordance is desktop-only.
- **Active slot resolution.** `saveNames(version)`, the function every - **Active slot resolution.** `saveNames(version)`, the function every
existing caller (`TitleState` hasSave/load/save, recovery order) already existing caller (`TitleState` hasSave/load/save, recovery order) already
goes through, now resolves the *active* slot instead of a fixed flat name. goes through, now resolves the *active* slot instead of a fixed flat name.
@@ -193,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, (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 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. 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.
+23 -6
View File
@@ -1,23 +1,40 @@
# Native modding # Native modding
The modding book lives on the The modding book lives on the
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki). [project wiki](https://github.com/bryanthaboi/gen1recomp/wiki).
- [Getting started](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Getting-Started) - [Getting started](https://github.com/bryanthaboi/gen1recomp/wiki/Getting-Started)
— install a mod, write a first one, enable and disable it. — install a mod, write a first one, enable and disable it.
- [Tutorials](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Tutorials) - [Tutorials](https://github.com/bryanthaboi/gen1recomp/wiki/Tutorials)
— twelve dependency-ordered rungs, each a runnable mod. — twelve dependency-ordered rungs, each a runnable mod.
- [Cookbook](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Cookbook) - [Cookbook](https://github.com/bryanthaboi/gen1recomp/wiki/Cookbook)
— task-sized recipes. — task-sized recipes.
- [Registry reference](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Reference-Registries) - [Registry reference](https://github.com/bryanthaboi/gen1recomp/wiki/Reference-Registries)
— every registry, generated from `src/mods/Schemas.lua`. — every registry, generated from `src/mods/Schemas.lua`.
Regenerate the reference straight into a wiki checkout: Regenerate the reference straight into a wiki checkout:
```sh ```sh
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki 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 ## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it Most registries hand the engine *content*. `render_pipelines` hands it
+175 -7
View File
@@ -86,7 +86,7 @@ Game Boy equivalent:
## Colors mode ## Colors mode
The `2` key (and the Options menu COLORS row) cycles the display mode The `2` key (and the Options menu COLORS row) cycles the display mode
through **OG RED → SGB → RED++ → OG → OG INV → SGB INV → CLASSIC → OG RED**. through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**.
The first three are the real colorizations; the rest are DMG-shade novelties: 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 - **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
@@ -97,8 +97,9 @@ The first three are the real colorizations; the rest are DMG-shade novelties:
- **SGB** (default): the per-map Super Game Boy region palettes - **SGB** (default): the per-map Super Game Boy region palettes
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on (`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
real SGB. (This is the mode formerly mislabeled "GBC".) real SGB. (This is the mode formerly mislabeled "GBC".)
- **RED++**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus - **ADVANCED**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
per-species mon colors (`data/palettes_gbc.lua`). per-species mon colors (`data/palettes_gbc.lua`). (Formerly labeled
"RED++"; it is the richest colorization rather than anything Red-specific.)
- **OG**: force the four DMG grays (colorization off). - **OG**: force the four DMG grays (colorization off).
- **OG INV**: inverted DMG grays. - **OG INV**: inverted DMG grays.
- **SGB INV**: each SGB zone palette with shade order reversed. - **SGB INV**: each SGB zone palette with shade order reversed.
@@ -109,7 +110,8 @@ The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
it covers overworld, menus, battles, and tilt upright billboards. OG RED's it covers overworld, menus, battles, and tilt upright billboards. OG RED's
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
override in the overworld pass). Persisted as `save.options.colors`; the override in the overworld pass). Persisted as `save.options.colors`; the
`gbc` / `gbc_inv` save ids are kept for back-compat under the new labels. `gbc` / `gbc_inv` / `redpp` save ids are kept for back-compat under the new
labels.
## GBC FX ## GBC FX
@@ -127,6 +129,14 @@ It runs as a final present pass after world + UI composite in
([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)). ([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)).
Default OFF; persisted as `save.options.gbcfx`. Default OFF; persisted as `save.options.gbcfx`.
Mobile GPUs often compile the pass but present a black frame, so Android and
iOS hide the row entirely, pin the level to OFF, and rewrite a level already
persisted in `options.lua` (issue #136). `POKEPORT_GBCFX` overrides that
decision either way, same tri-state as `POKEPORT_TOUCH`: `=0` refuses the
effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
its launcher because the device reports `"Linux"` while its GPU is in the
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
## Peer-to-peer link play (lua-enet) ## Peer-to-peer link play (lua-enet)
Trades and link battles connect two copies of the game directly over Trades and link battles connect two copies of the game directly over
@@ -141,6 +151,30 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
also exercises real UDP pairing. also exercises real UDP pairing.
## Fair play in link and online matches
A link session is decided by the battle and nothing else, so for its
duration:
- **Game speed is pinned to normal.** The GAME SPEED option and
`POKEPORT_SPEED` are ignored from the moment LINK PLAY opens until it
closes, and apply again after. Fast-forward otherwise runs one peer's
queue faster than the peer it is locked to and drains a tournament shot
clock faster than the opponent racing it.
- **Online play runs vanilla.** Picking ONLINE MATCH or TOURNAMENT with
mods enabled offers to switch them all off and relaunch (mods merge at
boot, so a restart is the only way). The restart is confirmed, not
silent. They stay listed as disabled, ready to switch back on.
- **Only a meaningful split ends a match.** The per-turn state signature
both peers exchange is split three ways: `actives` and `bench` carry
species, HP, status, stat stages, PP and the rest of the party, and a
divergence there ends the match as a draw. `volatile` carries per-turn
flags both sides recompute anyway - a divergence there is logged and
reported to mods, and play continues.
The relay logs which component diverged on which turn, so a desync report
names something specific.
## Custom boot text ## Custom boot text
The boot sequence replaces the Nintendo / GAME FREAK identifiers with The boot sequence replaces the Nintendo / GAME FREAK identifiers with
@@ -161,8 +195,9 @@ migrated once into `options.lua` on load.
- Music / SFX volume - Music / SFX volume
- Music Filter - Music Filter
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules) - OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
- COLORS (OG RED / SGB / RED++ / OG / OG INV / SGB INV / CLASSIC), also - BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc - COLORS (OG RED / SGB / ADVANCED / OG / OG INV / SGB INV / CLASSIC), also
hotkey `2` (OG RED = GBC boot-ROM look; ADVANCED uses pokered-gbc
SuperPalettes + per-species mon colors) SuperPalettes + per-species mon colors)
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming - TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and - ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
@@ -181,6 +216,36 @@ void outside the OG wipe fills in lockstep. Once the battle state is up,
letterbox voids around the battle canvas fill **white** instead of black letterbox voids around the battle canvas fill **white** instead of black
so the whole window reads as one continuous battle screen. so the whole window reads as one continuous battle screen.
## Widescreen battle layout
Options **BATTLE LAYOUT** picks the battle screen's composition: **OG**
(the default: the original 160×144 arrangement, unchanged) or **WIDE**,
which gives battles a 304×144 native-pixel surface and a Gen 3-style
arrangement on it:
- the foe's status box upper left, the foe's picture upper right;
- the player's picture lower left, the player's status box lower right,
with a longer HP bar and the numeric HP under it;
- a full-width message window;
- a split "What will X do?" prompt / 2×2 command window;
- a 2×2 move menu, navigated with all four directions, with a PP and type
panel attached to its right.
Only the composition changes. Pictures, palettes, HP-bar colors, font
pages, window borders, sounds, animations, timing and every battle rule
stay the engine's, so a COLORS mode or an asset mod still owns the look.
Each side's picture keeps its original pixels and placement math and is
composited into its own region of the wider battlefield -- nothing is
scaled or squeezed -- and animations, which are authored in the original
160-pixel space, shift as one rigid group onto whichever side they play
on. The whole screen is drawn at the window's integer fit scale for the
wider surface, so a 304-pixel screen is drawn a step smaller than a
160-pixel one in the same window.
The wide surface is live only while the battle itself is the screen on
top: a party menu, the bag or a nickname prompt is a 160×144 screen and
brings the classic surface back with it.
## On-screen touch controls (mobile) ## On-screen touch controls (mobile)
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
@@ -193,4 +258,107 @@ appears while no controller is being used: the first gamepad button or
stick push hides it, the next screen touch brings it back, and unplugging stick push hides it, the next screen touch brings it back, and unplugging
the last controller restores it immediately. Layout re-derives from the the last controller restores it immediately. Layout re-derives from the
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces 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 overlay on and lets the mouse act as a finger (`=0` forces it off).
## Translation support
Every string the player can read is now reachable from a mod, so a
translation is an ordinary content mod rather than a fork.
Two things had to change. Text layout stopped counting bytes: the dialogue
box measures a line in glyphs (charmap sequences), so a 3-byte character
costs one column, a cut never lands inside a character, and a page with a
non-default `advance` re-measures instead of overflowing. That also fixed
25 vanilla English lines that were wrapping early because `é` in POKéMON
and POKéDEX costs two bytes ("I study POKéMON as" is 19 bytes and 18
glyphs, and the box was breaking it).
Second, the text the engine writes itself - battle messages, item results,
menu labels, the link-play screens - moved behind `src/core/Strings.lua`
and the new `strings` registry. Extracted script text was already
overridable through `text`; this covers the other half. Entries are keyed
by the English source, so a translation that has not reached a string yet
keeps rendering in English and a half-finished translation stays playable.
Authors generate the whole thing:
```sh
python3 tools/modkit.py translation francais --language "Francais"
```
That scaffolds a mod with every translatable string as an empty catalog,
plus a glyph-page and charmap stub, a naming-grid stub, and a
`francais-worksheet/` directory holding the English to translate from
(deliberately outside the mod: extracted text is ROM content and must not
be packed). `--refresh` re-harvests after an engine update, keeping
existing translations and parking orphaned keys rather than dropping them.
See the wiki's Translations guide.
## Save editor (bundled, reachable from the launcher)
The save editor ships inside every build instead of being a developer-only
script, and the launcher's SAVE SLOT card grows an **Edit** label next to
Delete on every slot that actually holds a save. Edit suspends the
launcher, opens that slot's file in the editor, and **Close** hands the
process back to the launcher with the slot list re-read (a rename, a badge
or a dex change shows up on the row immediately). Unsaved edits arm a
confirm first, so leaving cannot lose work. `love . --editor` still opens
it standalone, where Close quits instead; `--save <path>` points it at any
file, and a save can be dragged onto the window.
The editor now wears the launcher's visual language - the same navy radial
field, 16px translucent cards, tri-colour version rail and green/yellow/red
semantics - so the two windows read as one app. Six tabs:
- **Party**: the roster with sprites, HP bars and level chips on the left,
and the mon inspector permanently docked on the right instead of floating
over the list. Species, level, DVs and moves all round-trip through the
Gen 1 formulas, so the inspector can never show illegal stats.
- **Boxes**: the 12 PC boxes as a 5x4 grid with a fill meter per box and a
party dock, so deposit and withdraw live in one place. Empty slots are
clickable and create a mon there.
- **Items**: money, a searchable item picker (replacing the arrows that
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
with no slot cap, and the eight badges as toggle chips.
- **Events**: flags, defeated trainers, taken items and per-map object
toggles, with a real filter field and a two-column paged grid.
- **Map**: any map rendered with the game's own renderer, warps followable,
and the player / lastHeal / lastOutdoor spawn points settable by clicking
a cell. Setting lastOutdoor on a map the game would not accept as an
outdoor source is refused with the reason.
- **Dex**: seen / owned completion meters and a four-column grid; owning
implies seen and un-seeing clears owned, exactly as the game requires.
Two rules run through all of it. Every mutation goes through one funnel
that sets the dirty flag and writes the status line together, so nothing
changes silently and no branch can quietly no-op - "Party is full", "Bag is
full", "click a cell first" all say so. And every destructive verb (Remove,
Release, Clear all, Wipe dex) arms on the first click and commits on the
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.
+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.
+1 -1
View File
@@ -65,7 +65,7 @@ Each tagged release `vX.Y.Z` carries the existing per-platform archives
A release missing either asset is treated as "no in-place update available": A release missing either asset is treated as "no in-place update available":
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()` `Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
(`https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest`). (`https://github.com/bryanthaboi/gen1recomp/releases/latest`).
## Save-directory layout ## Save-directory layout
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# After first boot of the freshly flashed Stock OS Mod, reinsert the SD card
# and run this to install gen1recomp + Red/Blue ROMs into roms/PORTS.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
STAGE="$ROOT/.bazinga/work/rg34xxsp-install"
DECPREP="$(cd "$ROOT/../decprep" && pwd)"
ZIP="$ROOT/dist/rg34xxsp/gen1recomp-rg34xxsp.zip"
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
# Find the Anbernic user/ROMs volume (usually "NO NAME" after first-boot expand).
find_roms_root() {
local v candidate
for v in /Volumes/*; do
[ -d "$v" ] || continue
# Prefer a volume that already has Roms/ or PORTS/
if [ -d "$v/Roms" ] || [ -d "$v/roms" ] || [ -d "$v/PORTS" ] || [ -d "$v/ports" ]; then
echo "$v"
return 0
fi
done
# Fallback: large FAT volume named NO NAME on disk8
for v in "/Volumes/NO NAME" /Volumes/NO\ NAME /Volumes/ROMS /Volumes/EASYROMS; do
if [ -d "$v" ]; then
echo "$v"
return 0
fi
done
return 1
}
say "looking for Anbernic ROMs volume"
ROMS_ROOT="$(find_roms_root)" || fail "no ROMs volume mounted. Boot the RG34XXSP once (wait for first-boot setup), power off, reinsert the SD, then rerun."
say "using: $ROMS_ROOT"
# Resolve PORTS dir (stock uses Roms/PORTS)
if [ -d "$ROMS_ROOT/Roms/PORTS" ]; then
PORTS="$ROMS_ROOT/Roms/PORTS"
elif [ -d "$ROMS_ROOT/roms/PORTS" ]; then
PORTS="$ROMS_ROOT/roms/PORTS"
elif [ -d "$ROMS_ROOT/Roms/ports" ]; then
PORTS="$ROMS_ROOT/Roms/ports"
elif [ -d "$ROMS_ROOT/PORTS" ]; then
PORTS="$ROMS_ROOT/PORTS"
else
mkdir -p "$ROMS_ROOT/Roms/PORTS"
PORTS="$ROMS_ROOT/Roms/PORTS"
fi
say "PORTS: $PORTS"
# Refresh staged payload
mkdir -p "$STAGE/PORTS"
if [ -f "$ZIP" ]; then
rm -rf "$STAGE/PORTS/Gen1recomp.sh" "$STAGE/PORTS/gen1recomp" "$STAGE/PORTS/port.json" \
"$STAGE/PORTS/gameinfo.xml" "$STAGE/PORTS/README.md"
unzip -q -o "$ZIP" -d "$STAGE/PORTS"
else
fail "missing $ZIP — run ./build-rg34xxsp.sh first"
fi
# Ensure ROMs are in lovegame (Choose ROM scans this folder on stock OS)
[ -f "$DECPREP/Pokemon - Red Version.gb" ] || fail "missing Red ROM in $DECPREP"
[ -f "$DECPREP/Pokemon - Blue Version.gb" ] || fail "missing Blue ROM in $DECPREP"
cp -f "$DECPREP/Pokemon - Red Version.gb" "$STAGE/PORTS/gen1recomp/lovegame/"
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$STAGE/PORTS/gen1recomp/lovegame/"
say "copying gen1recomp port"
rm -rf "$PORTS/gen1recomp" "$PORTS/Gen1recomp.sh"
cp -R "$STAGE/PORTS/gen1recomp" "$PORTS/"
cp -f "$STAGE/PORTS/Gen1recomp.sh" "$PORTS/"
cp -f "$STAGE/PORTS/port.json" "$PORTS/gen1recomp/" 2>/dev/null || true
cp -f "$STAGE/PORTS/README.md" "$PORTS/gen1recomp/" 2>/dev/null || true
chmod +x "$PORTS/Gen1recomp.sh" "$PORTS/gen1recomp/bin/love.aarch64"
# Also drop carts in the stock GB folder for the emulator library
GB_DIR=""
for candidate in "$ROMS_ROOT/Roms/GB" "$ROMS_ROOT/roms/GB" "$ROMS_ROOT/Roms/gb"; do
if [ -d "$candidate" ]; then GB_DIR="$candidate"; break; fi
done
if [ -n "$GB_DIR" ]; then
say "copying .gb into $GB_DIR"
cp -f "$DECPREP/Pokemon - Red Version.gb" "$GB_DIR/"
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$GB_DIR/"
fi
sync
say "installed:"
ls -lh "$PORTS/Gen1recomp.sh"
ls -lh "$PORTS/gen1recomp/lovegame/"*.gb
say "eject the SD, insert TF1 in the RG34XXSP, open Ports → Gen1recomp, Choose ROM."
+128 -19
View File
@@ -1,8 +1,12 @@
-- Native LÖVE2D port of Pokemon Red. A packaged build creates its private -- Native LÖVE2D port of Pokemon Red. A packaged build creates its private
-- game-data cache from a user-provided ROM on first boot. -- game-data cache from a user-provided ROM on first boot.
-- --
-- Set POKEPORT_EDITOR=1 or pass `--editor` to `love .` to boot the save -- The save editor (tools/save-editor/) ships inside every build and is
-- editor tool (tools/save-editor/) instead of the game. -- reachable two ways:
-- * standalone: POKEPORT_EDITOR=1 or `love . --editor`, its own window
-- * from the launcher: Edit on a save row, which suspends the launcher,
-- opens the editor on that slot's file, and restores the launcher when
-- the editor's Close button is pressed (openEditor / closeEditor below)
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
@@ -31,11 +35,110 @@ local function scriptedIterations()
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride))) return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
end end
-- ------------------------------------------------------------ save editor
-- The launcher instance parked while the editor is up, plus the version whose
-- cache the editor mounted (so closing can put the read path back).
local editorHost, editorVersion, editorWindow
local closeEditor -- forward declaration: openEditor hands it to the editor
-- The editor's modules use flat names (require("Kit"), require("Party")), so
-- their directories have to be on the require path. It must be
-- love.filesystem's path, not package.path: in a packaged build these files
-- live inside the .love archive, which the stock Lua searcher cannot open.
local function addEditorRequirePath()
local fs = love.filesystem
if not (fs.setRequirePath and fs.getRequirePath) then
-- very old LOVE: a source checkout still resolves through package.path
package.path = fs.getSource() .. "/tools/save-editor/?.lua;"
.. fs.getSource() .. "/tools/save-editor/panels/?.lua;"
.. package.path
return
end
local current = fs.getRequirePath()
if current:find("tools/save%-editor") then return end
fs.setRequirePath("tools/save-editor/?.lua;tools/save-editor/panels/?.lua;"
.. current)
end
-- Desktop only: the launcher window (1024x768) is tighter than the editor's
-- design size, so grow it while editing and put it back on Close. Never
-- shrinks, never touches a fullscreen or mobile window.
local function resizeForEditor()
if not (love.window and love.window.getMode and love.window.setMode) then return end
local osName = love.system.getOS()
if osName ~= "OS X" and osName ~= "Windows" and osName ~= "Linux" then return end
local w, h, flags = love.window.getMode()
if flags.fullscreen then return end
local dw, dh = love.window.getDesktopDimensions()
local wantW = math.max(w, math.min(1360, math.floor((dw or w) * 0.92)))
local wantH = math.max(h, math.min(860, math.floor((dh or h) * 0.88)))
if wantW <= w and wantH <= h then return end
editorWindow = { w = w, h = h }
love.window.setMode(wantW, wantH, flags)
end
local function restoreWindow()
if not editorWindow then return end
local _, _, flags = love.window.getMode()
love.window.setMode(editorWindow.w, editorWindow.h, flags)
editorWindow = nil
end
-- Open the editor on a launcher save row. The version's cache has to be
-- mounted before the editor's Data:load runs, or a Blue save would be edited
-- against Red's species/item tables.
local function openEditor(version, slotId)
local SaveData = require("src.core.SaveData")
local path = SaveData.slotDiskPath(version, slotId)
if not path then
if Importer then
Importer.saveNotice = Importer.saveNotice or {}
Importer.saveNotice[version] =
{ ok = false, text = "Could not resolve that save slot on disk." }
end
return
end
local GameVersion = require("src.core.GameVersion")
GameVersion.set(version)
require("src.import.CacheFs").mountVersion(version)
editorVersion = version
editorHost = Importer
Importer = nil
editorMode = true
resizeForEditor()
addEditorRequirePath()
EditorApp = require("App")
EditorApp.load(path, { version = version, slotId = slotId, embedded = true,
onClose = function() closeEditor() end })
end
-- Back to the launcher. Everything the editor mounted or cached has to come
-- back out: the version overlay (CacheFs) and the generated modules require
-- cached behind it (Data), or pressing Play on the OTHER game would boot it
-- with this one's data.
function closeEditor()
local version = editorVersion
editorMode = false
if EditorApp and EditorApp.unload then EditorApp.unload() end
EditorApp = nil
if version then
require("src.import.CacheFs").unmountVersion(version)
require("src.core.Data"):unloadGenerated()
end
editorVersion = nil
restoreWindow()
Importer = editorHost
editorHost = nil
if Importer and version and Importer.savesChanged then
Importer:savesChanged(version)
end
end
local function bootGame(version) local function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue); scripted and headless -- The launcher hands us the chosen game (Red / Blue / Yellow); scripted and
-- runs fall back to POKEPORT_VERSION, then Red. Set the active version and -- headless runs fall back to POKEPORT_VERSION, then Red. Set the active
-- overlay its extracted cache BEFORE anything requires generated data, so -- version and overlay its extracted cache BEFORE anything requires generated
-- data/generated + assets/generated resolve to that version's files. -- data, so data/generated + assets/generated resolve to that version's files.
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red") GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
require("src.import.CacheFs").mountVersion(GameVersion.get()) require("src.import.CacheFs").mountVersion(GameVersion.get())
@@ -81,12 +184,17 @@ function love.load(args)
end end
love.graphics.setDefaultFilter("nearest", "nearest") love.graphics.setDefaultFilter("nearest", "nearest")
-- Standalone editor. A bare `--editor` run has no launcher behind it, so
-- Close quits; --save points it at a specific file, otherwise it opens the
-- default save path for POKEPORT_VERSION (Red unless overridden), whose
-- cache has to be mounted before the editor's Data:load.
if editorMode then if editorMode then
package.path = love.filesystem.getSource() .. "/tools/save-editor/?.lua;" local version = os.getenv("POKEPORT_VERSION") or "red"
.. love.filesystem.getSource() .. "/tools/save-editor/panels/?.lua;" require("src.core.GameVersion").set(version)
.. package.path require("src.import.CacheFs").mountVersion(version)
addEditorRequirePath()
EditorApp = require("App") EditorApp = require("App")
EditorApp.load(savePath) EditorApp.load(savePath, { version = version })
return return
end end
@@ -123,14 +231,15 @@ function love.load(args)
return return
end end
-- Interactive: the launcher always runs. Red and Blue are each live: a -- Interactive: the launcher always runs. Red, Blue, and Yellow are each
-- column shows Play when that game's ROM is already imported, or Choose ROM -- live: a column shows Play when that game's ROM is already imported, or
-- / drag-drop when it is not (Yellow is still a placeholder). Any dropped -- Choose ROM / drag-drop when it is not. Any dropped .gb is routed by its
-- .gb is routed to Red or Blue by its SHA-1; pressing Play boots that game. -- 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 = RomImporter.new(function(version)
Importer = nil Importer = nil
bootGame(version) bootGame(version)
end, { launcher = true, forceImport = forceImport }) end, { launcher = true, forceImport = forceImport, onEditSave = openEditor })
end end
function love.update(dt) function love.update(dt)
@@ -206,19 +315,19 @@ end
function love.gamepadpressed(joystick, button) function love.gamepadpressed(joystick, button)
if editorMode then return end if editorMode then return end
if Importer then return end if Importer then return Importer:gamepadpressed(joystick, button) end
Game:gamepadpressed(joystick, button) Game:gamepadpressed(joystick, button)
end end
function love.gamepadreleased(joystick, button) function love.gamepadreleased(joystick, button)
if editorMode then return end if editorMode then return end
if Importer then return end if Importer then return Importer:gamepadreleased(joystick, button) end
Game:gamepadreleased(joystick, button) Game:gamepadreleased(joystick, button)
end end
function love.gamepadaxis(joystick, axis, value) function love.gamepadaxis(joystick, axis, value)
if editorMode then return end if editorMode then return end
if Importer then return end if Importer then return Importer:gamepadaxis(joystick, axis, value) end
Game:gamepadaxis(joystick, axis, value) Game:gamepadaxis(joystick, axis, value)
end end
@@ -302,7 +411,7 @@ function love.mousemoved(x, y)
end end
function love.textinput(text) function love.textinput(text)
if Importer then return end if Importer then return Importer:textinput(text) end
if editorMode and EditorApp.textinput then if editorMode and EditorApp.textinput then
return EditorApp.textinput(text) return EditorApp.textinput(text)
end end
+4 -1
View File
@@ -70,7 +70,10 @@ The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
### Payload path ### Payload path
`app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`, `app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`,
`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. scripts, tests, and mobile build sources are excluded.
## Branding (applied by the build script) ## Branding (applied by the build script)
@@ -2,6 +2,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" > <manifest xmlns:android="http://schemas.android.com/apk/res/android" >
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH" />
<!-- Link play (src/link/): both the LAN backend (enet UDP) and the relay
backend (luasocket TCP) are ordinary sockets, and Android denies those
to a UID without INTERNET. Bind and connect then come back EPERM, which
the link screen shows as "(Operation not permitted)" (issue #287).
scripts/build_android.sh must not strip this again. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- OpenGL ES 2.0 --> <!-- OpenGL ES 2.0 -->
<uses-feature android:glEsVersion="0x00020000" /> <uses-feature android:glEsVersion="0x00020000" />
<!-- Touchscreen support --> <!-- Touchscreen support -->
+1 -1
View File
@@ -8,7 +8,7 @@
return { return {
summary = "Oak's Charmander gift becomes a level 20 Mew with inverted sprites.", summary = "Oak's Charmander gift becomes a level 20 Mew with inverted sprites.",
author = "Pokemon Gen 1 Recompilation Project", author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "beginner", "cosmetic", "data-only", "legacy" }, tags = { "beginner", "cosmetic", "data-only", "legacy" },
differences = { differences = {
changed = { changed = {
@@ -3,7 +3,7 @@
return { return {
summary = "Faster final starters, half-price TMs, a re-slotted Route 1.", summary = "Faster final starters, half-price TMs, a re-slotted Route 1.",
author = "Pokemon Gen 1 Recompilation Project", author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "balance", "data-only", "beginner" }, tags = { "balance", "data-only", "beginner" },
differences = { differences = {
changed = { changed = {
+1 -1
View File
@@ -3,7 +3,7 @@
return { return {
summary = "A start-menu dex overlay with seen/owned counts and an inter-mod API.", summary = "A start-menu dex overlay with seen/owned counts and an inter-mod API.",
author = "Pokemon Gen 1 Recompilation Project", author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "ui", "tool", "quality-of-life" }, tags = { "ui", "tool", "quality-of-life" },
differences = { differences = {
changed = { "the START menu gains a DEXNAV row above SAVE" }, changed = { "the START menu gains a DEXNAV row above SAVE" },
+1 -1
View File
@@ -3,7 +3,7 @@
return { return {
summary = "An authored chip song for Pallet Town, a new Mew cry, and a jukebox screen.", summary = "An authored chip song for Pallet Town, a new Mew cry, and a jukebox screen.",
author = "Pokemon Gen 1 Recompilation Project", author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "audio", "chiptune", "ui" }, tags = { "audio", "chiptune", "ui" },
differences = { differences = {
changed = { changed = {
+1 -1
View File
@@ -3,7 +3,7 @@
return { return {
summary = "A courier in Viridian lost a parcel in Pewter. Fetch it for a NUGGET.", summary = "A courier in Viridian lost a parcel in Pewter. Fetch it for a NUGGET.",
author = "Pokemon Gen 1 Recompilation Project", author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "quest", "story", "scripting" }, tags = { "quest", "story", "scripting" },
differences = { differences = {
changed = { changed = {
@@ -3,7 +3,7 @@
return { return {
summary = "Sable Cove: one town, three species, one badge. The smallest whole conversion.", summary = "Sable Cove: one town, three species, one badge. The smallest whole conversion.",
author = "Pokemon Gen 1 Recompilation Project", author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "total-conversion", "capstone" }, tags = { "total-conversion", "capstone" },
differences = { differences = {
changed = { changed = {
+1 -1
View File
@@ -3,7 +3,7 @@
return { return {
summary = "A teal player recolor derived from your own cache, plus two palette records.", summary = "A teal player recolor derived from your own cache, plus two palette records.",
author = "Pokemon Gen 1 Recompilation Project", author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "cosmetic", "graphics", "beginner" }, tags = { "cosmetic", "graphics", "beginner" },
screenshots = { screenshots = {
{ transform = "shots/pallet_town.lua", caption = "Pallet Town under the recolored palette" }, { transform = "shots/pallet_town.lua", caption = "Pallet Town under the recolored palette" },
+1 -1
View File
@@ -2,7 +2,7 @@
return { return {
summary = "Oak asks dumb questions during the intro and remembers your answers.", summary = "Oak asks dumb questions during the intro and remembers your answers.",
author = "Pokemon Gen 1 Recompilation Project", author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "intro", "ui", "oak", "hooks" }, tags = { "intro", "ui", "oak", "hooks" },
differences = { differences = {
changed = { changed = {
+1 -1
View File
@@ -3,7 +3,7 @@
return { return {
summary = "Opt-in rain: WATER hits harder, FIRE hits softer, for five turns a battle.", summary = "Opt-in rain: WATER hits harder, FIRE hits softer, for five turns a battle.",
author = "Pokemon Gen 1 Recompilation Project", author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "battle", "mechanic", "ruleset", "hardcore" }, tags = { "battle", "mechanic", "ruleset", "hardcore" },
differences = { differences = {
changed = { changed = {
+19 -1
View File
@@ -56,16 +56,34 @@ done
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux" mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
# --------------------------------------------------------------- game.love # --------------------------------------------------------------- game.love
# tools/save-editor is part of the shipped app, not a dev-only script: the
# launcher's Edit button on a save row opens it in-process (main.lua), and
# `--editor` / POKEPORT_EDITOR=1 opens it standalone. It is required through
# love.filesystem's require path, so it has to live inside the archive.
say "packing game.love" say "packing game.love"
LOVE_FILE="$WORK/game.love" LOVE_FILE="$WORK/game.love"
rm -f "$LOVE_FILE" rm -f "$LOVE_FILE"
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \ (cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \ 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/*') -x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \ if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then | grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "game.love unexpectedly contains generated ROM data" fail "game.love unexpectedly contains generated ROM data"
fi fi
# The editor is only reachable if its entry point and both module directories
# 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 \
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"
done
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)" say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
# ------------------------------------------------------- stamp release version # ------------------------------------------------------- stamp release version
+76 -4
View File
@@ -26,6 +26,11 @@ APP_NAME="gen1recomp"
APPLICATION_ID="com.theboisclub.pokemonred" APPLICATION_ID="com.theboisclub.pokemonred"
LOVE_ANDROID_VERSION="11.5a" LOVE_ANDROID_VERSION="11.5a"
NDK_VERSION="25.2.9519653" NDK_VERSION="25.2.9519653"
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="" VERSION=""
PACKAGE_ONLY=false 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." Re-clone or 'git checkout -- mobile/android'. See mobile/ANDROID.md."
fi 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 # --------------------------------------------------------------- branding
# love-android 11.5+ reads app id / name / orientation from gradle.properties. # love-android 11.5+ reads app id / name / orientation from gradle.properties.
# Manifest still gets permission trims. Re-applied every build so refreshing # Manifest still gets permission trims. Re-applied every build so refreshing
@@ -113,11 +171,12 @@ import pathlib, re, sys
path = pathlib.Path(sys.argv[1]) path = pathlib.Path(sys.argv[1])
text = path.read_text() text = path.read_text()
# Drop network / mic / legacy storage, not needed for offline play. # Drop mic / legacy storage, not needed by this game.
# Keep VIBRATE (love.system.vibrate) and BLUETOOTH (optional gamepads). # Keep VIBRATE (love.system.vibrate), BLUETOOTH (optional gamepads) and
# INTERNET: link play is not offline-only any more, and stripping INTERNET
# made every LAN host and every relay connect fail with EPERM (issue #287).
# Orientation / label come from gradle.properties placeholders. # Orientation / label come from gradle.properties placeholders.
for perm in ( for perm in (
"android.permission.INTERNET",
"android.permission.RECORD_AUDIO", "android.permission.RECORD_AUDIO",
"android.permission.WRITE_EXTERNAL_STORAGE", "android.permission.WRITE_EXTERNAL_STORAGE",
): ):
@@ -134,16 +193,29 @@ PY
# --------------------------------------------------------------- game.love # --------------------------------------------------------------- game.love
pack_game_love() { pack_game_love() {
say "packing game.love for love-android embed flavor" say "packing game.love for love-android embed flavor"
ensure_yellow_manifest
mkdir -p "$EMBED_ASSETS" mkdir -p "$EMBED_ASSETS"
rm -f "$LOVE_FILE" rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save
# row opens it in-process, so it must be inside the archive (see build.sh).
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \ (cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \ 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 '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*') -x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \ if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then | grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "game.love unexpectedly contains generated ROM data" fail "game.love unexpectedly contains generated ROM data"
fi fi
# 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" 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 # This script packs its own game.love (it does not reuse build.sh's), so it
+6 -2
View File
@@ -164,15 +164,19 @@ pack_game_love() {
say "packing game.love for love-ios resources" say "packing game.love for love-ios resources"
mkdir -p "$RESOURCES_DIR" mkdir -p "$RESOURCES_DIR"
rm -f "$LOVE_FILE" rm -f "$LOVE_FILE"
# Same payload as scripts/build.sh / build_android.sh: game sources only. # Same payload as scripts/build.sh / build_android.sh: game sources plus
# tools/save-editor, which the launcher's Edit button opens in-process.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \ (cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \ 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 '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*') -x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \ if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then | grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "game.love unexpectedly contains generated ROM data" fail "game.love unexpectedly contains generated ROM data"
fi fi
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE" say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
} }
+7 -4
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env bash #!/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: # Usage:
# scripts/setup.sh --rom /path/to/pokemon-red.gb # 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 # 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 set -euo pipefail
@@ -34,15 +35,17 @@ command -v python3 >/dev/null 2>&1 \
|| fail "Python 3 is required to decode the ROM" || fail "Python 3 is required to decode the ROM"
if [ -z "$ROM" ]; then 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 if [ -f "$candidate" ]; then
ROM="$candidate" ROM="$candidate"
break break
fi fi
done done
shopt -u nullglob
fi fi
[ -n "$ROM" ] && [ -f "$ROM" ] \ [ -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 if [ ! -x "$VENV/bin/python3" ]; then
say "creating Python environment" say "creating Python environment"
+6
View File
@@ -116,7 +116,13 @@ if [ -f data/generated/maps.lua ]; then
echo "-- T3 content: skipped (--quick)" echo "-- T3 content: skipped (--quick)"
else else
run_tier "T3 content behavior (Red)" run_content_behavior run_tier "T3 content behavior (Red)" run_content_behavior
# The save editor ships inside every build (the launcher's Edit button on
# a save row opens it), so its panel suites run in CI rather than by hand.
run_tier "T3 save editor" "$LUA" tests/run_save_editor_tests.lua run_tier "T3 save editor" "$LUA" tests/run_save_editor_tests.lua
run_tier "T3 save editor: boxes + items" "$LUA" tests/save_editor_task6_tests.lua
run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
fi fi
else else
+1 -1
View File
@@ -22,7 +22,7 @@ local NOTES = {
-- mirrors ChipAudio's snapTicks so authored drums land on the same sample -- mirrors ChipAudio's snapTicks so authored drums land on the same sample
-- grid as the ROM's own drum tables -- grid as the ROM's own drum tables
local function snapTicks(ticks) local function snapTicks(ticks)
return math.floor((ticks * 735 + 256) / 512) return math.floor((ticks * 1470 + 256) / 512)
end end
-- ------- validation -- ------- validation
+703 -207
View File
File diff suppressed because it is too large Load Diff
+13 -12
View File
@@ -8,6 +8,7 @@
local MoveEffects = require("src.battle.MoveEffects") local MoveEffects = require("src.battle.MoveEffects")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local StatusRegistry = require("src.battle.StatusRegistry") local StatusRegistry = require("src.battle.StatusRegistry")
local Strings = require("src.core.Strings")
local EffectRegistry = {} local EffectRegistry = {}
@@ -91,7 +92,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if target.invulnerable and not neverMiss then if target.invulnerable and not neverMiss then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
return return
end end
@@ -113,7 +114,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if not battle:accuracyRoll(move, user, target) then if not battle:accuracyRoll(move, user, target) then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
-- Jump Kick crash, Explode self-destruct -- Jump Kick crash, Explode self-destruct
if record and record.onMiss then record.onMiss(ctx, "accuracy") end if record and record.onMiss then record.onMiss(ctx, "accuracy") end
user.trappingTurns = nil user.trappingTurns = nil
@@ -140,7 +141,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end end
if not counterable or (battle.lastDamage or 0) == 0 then if not counterable or (battle.lastDamage or 0) == 0 then
battle:cancelMoveAnim() battle:cancelMoveAnim()
battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
return return
end end
dmg = math.min(65535, battle.lastDamage * 2) dmg = math.min(65535, battle.lastDamage * 2)
@@ -163,14 +164,14 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if info.typeMult == 0 then if info.typeMult == 0 then
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim -- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
if not (record and record.explode) then battle:cancelMoveAnim() end if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target))) battle:sayNext(Strings("It doesn't affect\n%s!", displayName(target)))
if record and record.onMiss then record.onMiss(ctx, "immune") end if record and record.onMiss then record.onMiss(ctx, "immune") end
return return
end end
if info.missed then if info.missed then
-- 0.25x floored the damage to zero: the original registers a miss -- 0.25x floored the damage to zero: the original registers a miss
if not (record and record.explode) then battle:cancelMoveAnim() end if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
if record and record.onMiss then record.onMiss(ctx, "floored") end if record and record.onMiss then record.onMiss(ctx, "floored") end
return return
end end
@@ -214,12 +215,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back -- multi-hit loop (core.asm .moveDidNotMiss before the jump back
-- to GetPlayerAnimationType), so crit/effectiveness reprint on -- to GetPlayerAnimationType), so crit/effectiveness reprint on
-- every strike -- damage was only rolled once -- every strike -- damage was only rolled once
if info.crit then battle:sayNext("Critical hit!") end if info.crit then battle:sayNext(Strings("Critical hit!")) end
if info.ohko then battle:sayNext("One-hit KO!") end if info.ohko then battle:sayNext(Strings("One-hit KO!")) end
if info.typeMult > 10 then if info.typeMult > 10 then
battle:sayNext("It's super\neffective!") battle:sayNext(Strings("It's super\neffective!"))
elseif info.typeMult < 10 then elseif info.typeMult < 10 then
battle:sayNext("It's not very\neffective...") battle:sayNext(Strings("It's not very\neffective..."))
end end
if Runtime.wants("battle.damage_dealt") then if Runtime.wants("battle.damage_dealt") then
Runtime.emit("battle.damage_dealt", { Runtime.emit("battle.damage_dealt", {
@@ -237,9 +238,9 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if hits > 1 then if hits > 1 then
-- player: _MultiHitText; enemy: _HitXTimesText (always plural) -- player: _MultiHitText; enemy: _HitXTimesText (always plural)
if user.isPlayer then if user.isPlayer then
battle:sayNext(("Hit the enemy\n%d times!"):format(hits)) battle:sayNext(Strings("Hit the enemy\n%d times!", hits))
else else
battle:sayNext(("Hit %d times!"):format(hits)) battle:sayNext(Strings("Hit %d times!", hits))
end end
end end
@@ -251,7 +252,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
elseif moveInst.struggle then elseif moveInst.struggle then
-- struggle recoils even when its effect id resolves to no record -- struggle recoils even when its effect id resolves to no record
local recoil = math.max(1, math.floor(dmg / 2)) local recoil = math.max(1, math.floor(dmg / 2))
battle:sayNext(("%s's\nhit with recoil!"):format(displayName(user))) battle:sayNext(Strings("%s's\nhit with recoil!", displayName(user)))
battle:applyDamage(user, recoil) battle:applyDamage(user, recoil)
end end
+59 -56
View File
@@ -15,6 +15,7 @@ local Logger = require("src.core.Logger")
local StatusRegistry = require("src.battle.StatusRegistry") local StatusRegistry = require("src.battle.StatusRegistry")
local TurnOrder = require("src.battle.TurnOrder") local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart") local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local MoveEffects = {} local MoveEffects = {}
@@ -36,14 +37,14 @@ local STAT_LABEL = {
local function changeStage(battle, who, stat, delta, fromEnemy) local function changeStage(battle, who, stat, delta, fromEnemy)
if fromEnemy and (who.substituteHP or who.mist) then if fromEnemy and (who.substituteHP or who.mist) then
if who.mist then if who.mist then
return { displayName(who) .. " is\nprotected by MIST!" } return { Strings("%s is\nprotected by MIST!", displayName(who)) }
end end
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
local cur = who.stages[stat] or 0 local cur = who.stages[stat] or 0
local new = math.max(-6, math.min(6, cur + delta)) local new = math.max(-6, math.min(6, cur + delta))
if new == cur then if new == cur then
return { ("Nothing happened!") } return { Strings("Nothing happened!") }
end end
who.stages[stat] = new who.stages[stat] = new
-- effects.asm:505-506: after any stat-stage change, modified stats are -- effects.asm:505-506: after any stat-stage change, modified stats are
@@ -53,13 +54,13 @@ local function changeStage(battle, who, stat, delta, fromEnemy)
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the -- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
-- two-stage variants scroll "greatly" onto a third line -- two-stage variants scroll "greatly" onto a third line
if delta >= 2 then if delta >= 2 then
return { ("%s's\n%s\ngreatly rose!"):format(displayName(who), STAT_LABEL[stat]) } return { Strings("%s's\n%s\ngreatly rose!", displayName(who), STAT_LABEL[stat]) }
elseif delta == 1 then elseif delta == 1 then
return { ("%s's\n%s rose!"):format(displayName(who), STAT_LABEL[stat]) } return { Strings("%s's\n%s rose!", displayName(who), STAT_LABEL[stat]) }
elseif delta == -1 then elseif delta == -1 then
return { ("%s's\n%s fell!"):format(displayName(who), STAT_LABEL[stat]) } return { Strings("%s's\n%s fell!", displayName(who), STAT_LABEL[stat]) }
end end
return { ("%s's\n%s\ngreatly fell!"):format(displayName(who), STAT_LABEL[stat]) } return { Strings("%s's\n%s\ngreatly fell!", displayName(who), STAT_LABEL[stat]) }
end end
MoveEffects.changeStage = changeStage MoveEffects.changeStage = changeStage
@@ -88,10 +89,10 @@ end
local function statusMove(status) local function statusMove(status)
return function(battle, user, target, move) return function(battle, user, target, move)
if target.mon.status then if target.mon.status then
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
if status == "PSN" and target.substituteHP then if status == "PSN" and target.substituteHP then
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
local msgs = inflictStatus(battle, target, status, { local msgs = inflictStatus(battle, target, status, {
toxic = move and move.id == "TOXIC", toxic = move and move.id == "TOXIC",
@@ -99,7 +100,7 @@ local function statusMove(status)
source = move and move.id, source = move and move.id,
}) })
if #msgs == 0 then if #msgs == 0 then
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
return msgs return msgs
end end
@@ -111,7 +112,7 @@ local function statusSide(status, chance)
-- target (regardless of the burn roll) -- target (regardless of the burn roll)
if move and move.type == "FIRE" and target.mon.status == "FRZ" then if move and move.type == "FIRE" and target.mon.status == "FRZ" then
target.mon.status = nil target.mon.status = nil
return { ("Fire defrosted\n%s!"):format(displayName(target)) } return { Strings("Fire defrosted\n%s!", displayName(target)) }
end end
if battle.rng(0, 255) >= chance then return {} end if battle.rng(0, 255) >= chance then return {} end
return inflictStatus(battle, target, status, { return inflictStatus(battle, target, status, {
@@ -144,10 +145,10 @@ end
local function confuse(battle, target, pierceSub) local function confuse(battle, target, pierceSub)
if target.confusedTurns or (target.substituteHP and not pierceSub) then if target.confusedTurns or (target.substituteHP and not pierceSub) then
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
target.confusedTurns = battle.rng(2, 5) target.confusedTurns = battle.rng(2, 5)
return { ("%s\nbecame confused!"):format(displayName(target)) } return { Strings("%s\nbecame confused!", displayName(target)) }
end end
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
@@ -181,53 +182,53 @@ MoveEffects.primary = {
LEECH_SEED_EFFECT = function(battle, user, target) LEECH_SEED_EFFECT = function(battle, user, target)
-- leech_seed.asm has no substitute check: seeding lands through one -- leech_seed.asm has no substitute check: seeding lands through one
if target.leechSeeded then if target.leechSeeded then
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
for _, t in ipairs(target.curTypes) do for _, t in ipairs(target.curTypes) do
if t == "GRASS" then return { "But, it failed!" } end if t == "GRASS" then return { Strings("But, it failed!") } end
end end
target.leechSeeded = true target.leechSeeded = true
return { ("%s\nwas seeded!"):format(displayName(target)) } return { Strings("%s\nwas seeded!", displayName(target)) }
end, end,
HEAL_EFFECT = function(battle, user, target, move) HEAL_EFFECT = function(battle, user, target, move)
local mon = user.mon local mon = user.mon
if move.id == "REST" then if move.id == "REST" then
if mon.hp == mon.stats.hp then return { "But, it failed!" } end if mon.hp == mon.stats.hp then return { Strings("But, it failed!") } end
mon.hp = mon.stats.hp mon.hp = mon.stats.hp
mon.status = "SLP" mon.status = "SLP"
user.sleepTurns = 2 user.sleepTurns = 2
user.toxicCounter = nil user.toxicCounter = nil
return { ("%s\nstarted sleeping!"):format(displayName(user)) } return { Strings("%s\nstarted sleeping!", displayName(user)) }
end end
if mon.hp == mon.stats.hp then return { "But, it failed!" } end if mon.hp == mon.stats.hp then return { Strings("But, it failed!") } end
mon.hp = math.min(mon.stats.hp, mon.hp + math.floor(mon.stats.hp / 2)) mon.hp = math.min(mon.stats.hp, mon.hp + math.floor(mon.stats.hp / 2))
return { ("%s\nregained health!"):format(displayName(user)) } return { Strings("%s\nregained health!", displayName(user)) }
end, end,
LIGHT_SCREEN_EFFECT = function(battle, user) LIGHT_SCREEN_EFFECT = function(battle, user)
if user.lightScreen then return { "But, it failed!" } end if user.lightScreen then return { Strings("But, it failed!") } end
user.lightScreen = true user.lightScreen = true
return { ("%s's\nprotected against\nspecial attacks!"):format(displayName(user)) } return { Strings("%s's\nprotected against\nspecial attacks!", displayName(user)) }
end, end,
REFLECT_EFFECT = function(battle, user) REFLECT_EFFECT = function(battle, user)
if user.reflect then return { "But, it failed!" } end if user.reflect then return { Strings("But, it failed!") } end
user.reflect = true user.reflect = true
return { ("%s\ngained armor!"):format(displayName(user)) } return { Strings("%s\ngained armor!", displayName(user)) }
end, end,
MIST_EFFECT = function(battle, user) MIST_EFFECT = function(battle, user)
if user.mist then return { "But, it failed!" } end if user.mist then return { Strings("But, it failed!") } end
user.mist = true user.mist = true
-- _ShroudedInMistText (lowercase "mist") -- _ShroudedInMistText (lowercase "mist")
return { ("%s's\nshrouded in mist!"):format(displayName(user)) } return { Strings("%s's\nshrouded in mist!", displayName(user)) }
end, end,
FOCUS_ENERGY_EFFECT = function(battle, user) FOCUS_ENERGY_EFFECT = function(battle, user)
if user.focusEnergy then return { "But, it failed!" } end if user.focusEnergy then return { Strings("But, it failed!") } end
user.focusEnergy = true user.focusEnergy = true
return { ("%s's\ngetting pumped!"):format(displayName(user)) } return { Strings("%s's\ngetting pumped!", displayName(user)) }
end, end,
HAZE_EFFECT = function(battle, user, target) HAZE_EFFECT = function(battle, user, target)
@@ -254,33 +255,33 @@ MoveEffects.primary = {
target.skipMove = true target.skipMove = true
end end
target.mon.status = nil target.mon.status = nil
return { "All STATUS changes\nare eliminated!" } return { Strings("All STATUS changes\nare eliminated!") }
end, end,
SUBSTITUTE_EFFECT = function(battle, user) SUBSTITUTE_EFFECT = function(battle, user)
if user.substituteHP then return { ("%s\nhas a SUBSTITUTE!"):format(displayName(user)) } end if user.substituteHP then return { Strings("%s\nhas a SUBSTITUTE!", displayName(user)) } end
local cost = math.floor(user.mon.stats.hp / 4) local cost = math.floor(user.mon.stats.hp / 4)
-- substitute.asm only fails on subtraction underflow (current HP -- substitute.asm only fails on subtraction underflow (current HP
-- strictly below maxHP/4); at equality the substitute is built and -- strictly below maxHP/4); at equality the substitute is built and
-- the user is left standing on exactly 0 HP (it faints only when -- the user is left standing on exactly 0 HP (it faints only when
-- the engine next checks HP, not here) -- the engine next checks HP, not here)
if user.mon.hp < cost then if user.mon.hp < cost then
return { "Too weak to make\na SUBSTITUTE!" } return { Strings("Too weak to make\na SUBSTITUTE!") }
end end
user.mon.hp = user.mon.hp - cost user.mon.hp = user.mon.hp - cost
user.substituteHP = cost + 1 user.substituteHP = cost + 1
-- _SubstituteText -- _SubstituteText
return { "It created a\nSUBSTITUTE!" } return { Strings("It created a\nSUBSTITUTE!") }
end, end,
CONVERSION_EFFECT = function(battle, user, target) CONVERSION_EFFECT = function(battle, user, target)
-- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE) -- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE)
if target.invulnerable then if target.invulnerable then
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
user.curTypes = { target.curTypes[1], target.curTypes[2] } user.curTypes = { target.curTypes[1], target.curTypes[2] }
-- _ConvertedTypeText -- _ConvertedTypeText
return { ("Converted type to\n%s's!"):format(displayName(target)) } return { Strings("Converted type to\n%s's!", displayName(target)) }
end, end,
-- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect -- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect
@@ -311,27 +312,27 @@ MoveEffects.primary = {
table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true }) table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true })
end end
-- _TransformedText: the copied name prints bare (wNameBuffer) -- _TransformedText: the copied name prints bare (wNameBuffer)
return { ("%s\ntransformed into\n%s!"):format(displayName(user), target.name) } return { Strings("%s\ntransformed into\n%s!", displayName(user), target.name) }
end, end,
DISABLE_EFFECT = function(battle, user, target) DISABLE_EFFECT = function(battle, user, target)
if target.disabledSlot then return { "But, it failed!" } end if target.disabledSlot then return { Strings("But, it failed!") } end
local usable = {} local usable = {}
for i, mv in ipairs(target.curMoves) do for i, mv in ipairs(target.curMoves) do
if mv.pp > 0 then table.insert(usable, i) end if mv.pp > 0 then table.insert(usable, i) end
end end
if #usable == 0 then return { "But, it failed!" } end if #usable == 0 then return { Strings("But, it failed!") } end
local slot = usable[battle.rng(1, #usable)] local slot = usable[battle.rng(1, #usable)]
target.disabledSlot = slot target.disabledSlot = slot
target.disabledTurns = battle.rng(1, 8) target.disabledTurns = battle.rng(1, 8)
local id = target.curMoves[slot].id local id = target.curMoves[slot].id
-- _MoveWasDisabledText: "X's / MOVE was / disabled!" -- _MoveWasDisabledText: "X's / MOVE was / disabled!"
return { ("%s's\n%s was\ndisabled!"):format(displayName(target), return { Strings("%s's\n%s was\ndisabled!", displayName(target),
battle.data.moves[id].name) } battle.data.moves[id].name) }
end, end,
SPLASH_EFFECT = function() SPLASH_EFFECT = function()
return { "No effect!" } return { Strings("No effect!") }
end, end,
} }
@@ -428,7 +429,9 @@ local function drainHalf(text)
local mon = ctx.user.mon local mon = ctx.user.mon
mon.hp = math.min(mon.stats.hp, mon.hp + heal) mon.hp = math.min(mon.stats.hp, mon.hp + heal)
ctx.drain() ctx.drain()
ctx.say(text:format(displayName(ctx.target))) -- `text` arrives as a source string (Strings.source at the call
-- site keeps it in the catalog); look it up here, at use time
ctx.say(Strings(text, displayName(ctx.target)))
end end
end end
@@ -436,7 +439,7 @@ end
-- flags the miss before the special-damage override) -- flags the miss before the special-damage override)
local function immuneMsg(ctx) local function immuneMsg(ctx)
if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then
return ("It doesn't affect\n%s!"):format(displayName(ctx.target)) return Strings("It doesn't affect\n%s!", displayName(ctx.target))
end end
return nil return nil
end end
@@ -498,12 +501,12 @@ MoveEffects.full = {
-- removed): overkill and substitute hits recoil at full strength -- removed): overkill and substitute hits recoil at full strength
local recoil = math.max(1, math.floor(ctx.rawDamage local recoil = math.max(1, math.floor(ctx.rawDamage
/ (ctx.moveInst.struggle and 2 or 4))) / (ctx.moveInst.struggle and 2 or 4)))
ctx.say(("%s's\nhit with recoil!"):format(displayName(ctx.user))) ctx.say(Strings("%s's\nhit with recoil!", displayName(ctx.user)))
ctx.battle:applyDamage(ctx.user, recoil) ctx.battle:applyDamage(ctx.user, recoil)
end, end,
}, },
DRAIN_HP_EFFECT = { DRAIN_HP_EFFECT = {
afterDamage = drainHalf("Sucked health from\n%s!"), afterDamage = drainHalf(Strings.source("Sucked health from\n%s!")),
}, },
DREAM_EATER_EFFECT = { DREAM_EATER_EFFECT = {
-- only works on sleeping targets (checked before damage) -- only works on sleeping targets (checked before damage)
@@ -511,7 +514,7 @@ MoveEffects.full = {
if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end
return true return true
end, end,
afterDamage = drainHalf("%s's\ndream was eaten!"), afterDamage = drainHalf(Strings.source("%s's\ndream was eaten!")),
}, },
-- charge moves: first turn just charges; Fly AND Dig go -- charge moves: first turn just charges; Fly AND Dig go
@@ -557,7 +560,7 @@ MoveEffects.full = {
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
if not user.confusedTurns then if not user.confusedTurns then
user.confusedTurns = ctx.rng(2, 5) user.confusedTurns = ctx.rng(2, 5)
ctx.say(("%s\nbecame confused!"):format(displayName(user))) ctx.say(Strings("%s\nbecame confused!", displayName(user)))
end end
end end
end end
@@ -566,7 +569,7 @@ MoveEffects.full = {
JUMP_KICK_EFFECT = { JUMP_KICK_EFFECT = {
onMiss = function(ctx, reason) onMiss = function(ctx, reason)
if reason ~= "accuracy" then return end if reason ~= "accuracy" then return end
ctx.say(("%s\nkept going and\ncrashed!"):format(displayName(ctx.user))) ctx.say(Strings("%s\nkept going and\ncrashed!", displayName(ctx.user)))
ctx.damage(ctx.user, 1) ctx.damage(ctx.user, 1)
end, end,
}, },
@@ -595,7 +598,7 @@ MoveEffects.full = {
afterDamage = function(ctx) afterDamage = function(ctx)
local battle = ctx.battle local battle = ctx.battle
battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level
ctx.say("Coins scattered\neverywhere!") ctx.say(Strings("Coins scattered\neverywhere!"))
end, end,
}, },
SWIFT_EFFECT = { neverMiss = true }, SWIFT_EFFECT = { neverMiss = true },
@@ -610,7 +613,7 @@ MoveEffects.full = {
local user = ctx.user local user = ctx.user
user.bideTurns = ctx.rng(2, 3) user.bideTurns = ctx.rng(2, 3)
user.bideDamage = 0 user.bideDamage = 0
ctx.say(("%s\nis storing energy!"):format(displayName(user))) ctx.say(Strings("%s\nis storing energy!", displayName(user)))
end, end,
}, },
SWITCH_AND_TELEPORT_EFFECT = { SWITCH_AND_TELEPORT_EFFECT = {
@@ -631,27 +634,27 @@ MoveEffects.full = {
end end
if ok then if ok then
if move.id == "ROAR" then if move.id == "ROAR" then
ctx.say(("%s\nran away scared!"):format(displayName(target))) ctx.say(Strings("%s\nran away scared!", displayName(target)))
elseif move.id == "WHIRLWIND" then elseif move.id == "WHIRLWIND" then
ctx.say(("%s\nwas blown away!"):format(displayName(target))) ctx.say(Strings("%s\nwas blown away!", displayName(target)))
else else
ctx.say(("%s\nran from battle!"):format(displayName(user))) ctx.say(Strings("%s\nran from battle!", displayName(user)))
end end
battle.result = "run" battle.result = "run"
battle.afterQueue = "finish" battle.afterQueue = "finish"
elseif move.id == "TELEPORT" then elseif move.id == "TELEPORT" then
battle:cancelMoveAnim() battle:cancelMoveAnim()
ctx.say("But, it failed!") ctx.say(Strings("But, it failed!"))
else else
battle:cancelMoveAnim() battle:cancelMoveAnim()
ctx.say(("It didn't affect\n%s!"):format(displayName(target))) ctx.say(Strings("It didn't affect\n%s!", displayName(target)))
end end
elseif move.id == "TELEPORT" then elseif move.id == "TELEPORT" then
battle:cancelMoveAnim() battle:cancelMoveAnim()
ctx.say("But, it failed!") ctx.say(Strings("But, it failed!"))
else else
battle:cancelMoveAnim() battle:cancelMoveAnim()
ctx.say(("%s\nis unaffected!"):format(displayName(target))) ctx.say(Strings("%s\nis unaffected!", displayName(target)))
end end
end, end,
}, },
@@ -669,7 +672,7 @@ MoveEffects.full = {
callsMove = function(ctx) callsMove = function(ctx)
local last = ctx.target.lastMove local last = ctx.target.lastMove
if not last then if not last then
ctx.say("The MIRROR MOVE\nfailed!") ctx.say(Strings("The MIRROR MOVE\nfailed!"))
return nil return nil
end end
return last return last
+31 -21
View File
@@ -5,6 +5,8 @@
-- and residual sweep. Callers without a battle (pure-module tests) fall -- and residual sweep. Callers without a battle (pure-module tests) fall
-- back to the vanilla records, which is bit-identical behavior. -- back to the vanilla records, which is bit-identical behavior.
local Strings = require("src.core.Strings")
local Status = {} local Status = {}
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy -- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
@@ -28,8 +30,11 @@ local function hasType(battler, wanted)
end end
-- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the -- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the
-- Toxic counter (HandlePoisonBurnLeechSeed) -- Toxic counter (HandlePoisonBurnLeechSeed). The caller passes the whole
local function damageOverTime(what) -- sentence rather than the noun: "hurt by poison" and "hurt by the burn"
-- decline differently once translated, so a shared fragment cannot be the
-- translatable unit.
local function damageOverTime(template)
return function(battler) return function(battler)
local mon = battler.mon local mon = battler.mon
local base = math.max(1, math.floor(mon.stats.hp / 16)) local base = math.max(1, math.floor(mon.stats.hp / 16))
@@ -39,10 +44,15 @@ local function damageOverTime(what)
battler.toxicCounter = battler.toxicCounter + 1 battler.toxicCounter = battler.toxicCounter + 1
end end
mon.hp = math.max(0, mon.hp - dmg) mon.hp = math.max(0, mon.hp - dmg)
return { ("%s's\nhurt by %s!"):format(name(battler), what) } return { Strings(template, name(battler)) }
end end
end end
-- Labels stay plain literals on purpose: this table is built at require
-- time, before Strings.load has a catalog, so a Strings() here would
-- freeze the English. They are already translatable through the
-- statuses registry (mod.content.statuses:patch(id, { label = ... })).
--
-- The five persistent conditions as records: the beforeMove gauntlet, the -- The five persistent conditions as records: the beforeMove gauntlet, the
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict), -- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the -- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
@@ -57,13 +67,13 @@ Status.RECORDS = {
battler.sleepTurns = (battler.sleepTurns or 1) - 1 battler.sleepTurns = (battler.sleepTurns or 1) - 1
if battler.sleepTurns <= 0 then if battler.sleepTurns <= 0 then
battler.mon.status = nil battler.mon.status = nil
return false, { name(battler) .. "\nwoke up!" } -- wakes but loses the turn return false, { Strings("%s\nwoke up!", name(battler)) } -- wakes, loses the turn
end end
return false, { name(battler) .. "\nis fast asleep!" } return false, { Strings("%s\nis fast asleep!", name(battler)) }
end, end,
onInflict = function(battle, target, opts, display) onInflict = function(battle, target, opts, display)
target.sleepTurns = battle.rng(1, 7) target.sleepTurns = battle.rng(1, 7)
return { ("%s\nfell asleep!"):format(display) } return { Strings("%s\nfell asleep!", display) }
end, end,
}, },
FRZ = { FRZ = {
@@ -71,35 +81,35 @@ Status.RECORDS = {
catchBonus = 25, shakeBonus = 10, catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 30, beforeMovePriority = 30,
beforeMove = function(battler) beforeMove = function(battler)
return false, { name(battler) .. "\nis frozen solid!" } return false, { Strings("%s\nis frozen solid!", name(battler)) }
end, end,
canInflict = function(target) return not hasType(target, "ICE") end, canInflict = function(target) return not hasType(target, "ICE") end,
onInflict = function(_, _, _, display) onInflict = function(_, _, _, display)
return { ("%s\nwas frozen solid!"):format(display) } return { Strings("%s\nwas frozen solid!", display) }
end, end,
}, },
PSN = { PSN = {
id = "PSN", label = "PSN", hudLabel = "PSN", id = "PSN", label = "PSN", hudLabel = "PSN",
catchBonus = 12, shakeBonus = 5, catchBonus = 12, shakeBonus = 5,
residual = damageOverTime("poison"), residual = damageOverTime(Strings.source("%s's\nhurt by poison!")),
canInflict = function(target) return not hasType(target, "POISON") end, canInflict = function(target) return not hasType(target, "POISON") end,
onInflict = function(_, target, opts, display) onInflict = function(_, target, opts, display)
if opts.toxic then if opts.toxic then
target.toxicCounter = 1 target.toxicCounter = 1
-- _BadlyPoisonedText -- _BadlyPoisonedText
return { ("%s's\nbadly poisoned!"):format(display) } return { Strings("%s's\nbadly poisoned!", display) }
end end
return { ("%s\nwas poisoned!"):format(display) } return { Strings("%s\nwas poisoned!", display) }
end, end,
}, },
BRN = { BRN = {
id = "BRN", label = "BRN", hudLabel = "BRN", id = "BRN", label = "BRN", hudLabel = "BRN",
catchBonus = 12, shakeBonus = 5, catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "attack", div = 2 }, statPenalty = { stat = "attack", div = 2 },
residual = damageOverTime("the burn"), residual = damageOverTime(Strings.source("%s's\nhurt by the burn!")),
canInflict = function(target) return not hasType(target, "FIRE") end, canInflict = function(target) return not hasType(target, "FIRE") end,
onInflict = function(_, _, _, display) onInflict = function(_, _, _, display)
return { ("%s\nwas burned!"):format(display) } return { Strings("%s\nwas burned!", display) }
end, end,
}, },
PAR = { PAR = {
@@ -110,7 +120,7 @@ Status.RECORDS = {
beforeMove = function(battler, rng) beforeMove = function(battler, rng)
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256) -- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
if rng(0, 255) < 63 then if rng(0, 255) < 63 then
return false, { name(battler) .. "'s\nfully paralyzed!" } return false, { Strings("%s's\nfully paralyzed!", name(battler)) }
end end
return true, {} return true, {}
end, end,
@@ -120,7 +130,7 @@ Status.RECORDS = {
end, end,
onInflict = function(_, _, _, display) onInflict = function(_, _, _, display)
-- _ParalyzedMayNotAttackText (primary and secondary paralysis) -- _ParalyzedMayNotAttackText (primary and secondary paralysis)
return { ("%s's\nparalyzed! It may\nnot attack!"):format(display) } return { Strings("%s's\nparalyzed! It may\nnot attack!", display) }
end, end,
}, },
} }
@@ -156,7 +166,7 @@ function Status.beforeMove(battler, rng, battle)
end end
if battler.flinched then if battler.flinched then
battler.flinched = false battler.flinched = false
return false, { name(battler) .. "\nflinched!" } return false, { Strings("%s\nflinched!", name(battler)) }
end end
local record = Status.recordFor(battleStatuses(battle), mon.status) local record = Status.recordFor(battleStatuses(battle), mon.status)
local handler = record and record.beforeMove local handler = record and record.beforeMove
@@ -174,23 +184,23 @@ function Status.beforeMove(battler, rng, battle)
end end
if battler.boundTurns and battler.boundTurns > 0 then if battler.boundTurns and battler.boundTurns > 0 then
battler.boundTurns = battler.boundTurns - 1 battler.boundTurns = battler.boundTurns - 1
msgs[#msgs + 1] = name(battler) .. "\ncan't move!" msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler))
return false, msgs return false, msgs
end end
if battler.disabledTurns then if battler.disabledTurns then
battler.disabledTurns = battler.disabledTurns - 1 battler.disabledTurns = battler.disabledTurns - 1
if battler.disabledTurns <= 0 then if battler.disabledTurns <= 0 then
battler.disabledTurns, battler.disabledSlot = nil, nil battler.disabledTurns, battler.disabledSlot = nil, nil
table.insert(msgs, name(battler) .. "'s\ndisabled no more!") table.insert(msgs, Strings("%s's\ndisabled no more!", name(battler)))
end end
end end
if battler.confusedTurns then if battler.confusedTurns then
battler.confusedTurns = battler.confusedTurns - 1 battler.confusedTurns = battler.confusedTurns - 1
if battler.confusedTurns <= 0 then if battler.confusedTurns <= 0 then
battler.confusedTurns = nil battler.confusedTurns = nil
table.insert(msgs, name(battler) .. "\nsnapped out of\nconfusion!") table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler)))
else else
table.insert(msgs, name(battler) .. "\nis confused!") table.insert(msgs, Strings("%s\nis confused!", name(battler)))
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256) -- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
if rng(0, 255) < 128 then if rng(0, 255) < 128 then
return false, msgs, true -- hurt itself return false, msgs, true -- hurt itself
@@ -231,7 +241,7 @@ function Status.residual(battler, opponent, battle)
dmg = math.min(dmg, mon.hp) dmg = math.min(dmg, mon.hp)
mon.hp = mon.hp - dmg mon.hp = mon.hp - dmg
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg) opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
table.insert(msgs, ("LEECH SEED saps\n%s!"):format(name(battler))) table.insert(msgs, Strings("LEECH SEED saps\n%s!", name(battler)))
end end
return msgs return msgs
end end
+2 -1
View File
@@ -5,6 +5,7 @@
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Status = require("src.battle.Status") local Status = require("src.battle.Status")
local Strings = require("src.core.Strings")
local StatusRegistry = {} local StatusRegistry = {}
@@ -45,7 +46,7 @@ function StatusRegistry.inflict(battle, target, status, opts)
if record and record.onInflict then if record and record.onInflict then
msgs = record.onInflict(battle, target, opts, display) msgs = record.onInflict(battle, target, opts, display)
else else
msgs = { ("%s\nwas afflicted\nby %s!"):format(display, msgs = { Strings("%s\nwas afflicted\nby %s!", display,
record and record.label or tostring(status)) } record and record.label or tostring(status)) }
end end
Runtime.emit("battle.status_inflicted", { Runtime.emit("battle.status_inflicted", {
+5 -4
View File
@@ -16,9 +16,10 @@
-- the MINIMUM-scored move is chosen, ties broken uniformly among the -- the MINIMUM-scored move is chosen, ties broken uniformly among the
-- tied minima (core.asm:2971-3002). A non-minimal move is never -- tied minima (core.asm:2971-3002). A non-minimal move is never
-- selectable. Respects Disable (and PP only when the ruleset depletes -- selectable. Respects Disable (and PP only when the ruleset depletes
-- enemy PP Gen 1 AI never reads wEnemyMonPP). -- enemy PP -- Gen 1 AI never reads wEnemyMonPP).
local TypeChart = require("src.battle.TypeChart") local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local TrainerAI = {} local TrainerAI = {}
@@ -99,7 +100,7 @@ function TrainerAI.useItem(battle, item)
local enemy = battle.enemy local enemy = battle.enemy
local trainerName = battle.trainer.name local trainerName = battle.trainer.name
local itemName = battle.data.items[item] and battle.data.items[item].name or item local itemName = battle.data.items[item] and battle.data.items[item].name or item
local msgs = { ("%s\nused %s!"):format(trainerName, itemName) } local msgs = { Strings("%s\nused %s!", trainerName, itemName) }
if item == "FULL_HEAL" then if item == "FULL_HEAL" then
enemy.mon.status = nil enemy.mon.status = nil
enemy.toxicCounter = nil enemy.toxicCounter = nil
@@ -112,10 +113,10 @@ function TrainerAI.useItem(battle, item)
elseif X_STAT[item] then elseif X_STAT[item] then
local stat = X_STAT[item] local stat = X_STAT[item]
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1) enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
table.insert(msgs, ("%s's\n%s rose!"):format(enemy.name, stat:upper())) table.insert(msgs, Strings("%s's\n%s rose!", enemy.name, stat:upper()))
elseif item == "GUARD_SPEC" then elseif item == "GUARD_SPEC" then
enemy.mist = true enemy.mist = true
table.insert(msgs, ("%s's\nprotected against\nstat changes!"):format(enemy.name)) table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", enemy.name))
end end
return msgs return msgs
end end
+379
View File
@@ -0,0 +1,379 @@
-- Widescreen battle layout (OPTION -> BATTLE LAYOUT -> WIDE).
--
-- The battle simulation, timing, animations and rules stay BattleState's;
-- this module only replaces the composition and asks the renderer for a
-- 304x144 native-pixel UI surface while it is up. Pictures, font pages,
-- border glyphs, species palettes and HP tiles all resolve through the
-- engine, so a COLORS mode or an asset mod still owns the look.
--
-- The extra 144 pixels of width buy a Gen 3-style arrangement: foe status
-- upper left with its picture upper right, the player's picture lower left
-- with their status lower right, a full-width message window, a split
-- prompt/command window, and a 2x2 move menu with an attached PP/type panel.
local Font = require("src.render.Font")
local HudTiles = require("src.render.HudTiles")
local PaletteFX = require("src.render.PaletteFX")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local TypeChart = require("src.battle.TypeChart")
local WideBattle = {
WIDTH = 304,
HEIGHT = 144,
-- everything above this line is battlefield; the 40 rows below it are
-- the message / command / move windows
FIELD_BOTTOM = 104,
}
-- The forced-mono display modes re-threshold the whole finished frame
-- through the shade shader, and picImage hands them raw DMG grays for that
-- (#207). The wide layout has to know: it exposes a matching whole-surface
-- zone and leaves the HP bar fill gray, exactly like the zone pass does in
-- the classic layout (#229). Keep in sync with picImage / ensureZones.
local function monoMode()
local m = PaletteFX.mode
return m == "og" or m == "og_inv" or m == "classic"
end
local function shownHP(battler)
return math.max(0, math.floor(battler.shownHP or battler.mon.hp or 0))
end
-- a name truncated to `pixels` with a trailing '.', measured through the
-- font's own advances so a variable-width page still fits
local function fitName(text, pixels)
local spans = Font.split(text or "")
local n = Font.spansFitting(spans, pixels)
if n >= #spans then return text or "" end
local out = {}
for i = 1, math.max(0, n - 1) do
out[#out + 1] = (text or ""):sub(spans[i].from, spans[i].to)
end
return table.concat(out) .. "."
end
local function saveScissor()
if not love.graphics.getScissor then return nil end
local x, y, w, h = love.graphics.getScissor()
if x == nil then return false end
return { x, y, w, h }
end
local function restoreScissor(saved)
if not love.graphics.setScissor then return end
if saved and saved ~= false then
love.graphics.setScissor(saved[1], saved[2], saved[3], saved[4])
else
love.graphics.setScissor()
end
end
-- Draw fn's content translated by (dx, dy) and clipped to a surface rect.
-- The scissor is in canvas space, so it bounds the region itself while the
-- translate moves the classic 160x144 coordinates into it.
local function inRegion(x, y, w, h, dx, dy, fn)
local g = love.graphics
local saved = saveScissor()
g.setScissor(x, y, w, h)
g.push()
g.translate(dx, dy)
fn()
g.pop()
restoreScissor(saved)
end
local function levelAt(battle, battler, x, y)
if battler.shownStatus then
Font.draw(battle:statusLabel({ status = battler.shownStatus }), x, y)
else
HudTiles.tile(0x6E, x, y) -- '<LV>'
Font.draw(tostring(battler.mon.level), x + 8, y)
end
end
-- One side's status box: name and level on the first line, a long HP bar
-- under it, and the numeric HP on the player's box only (the foe's exact
-- HP is never shown, like the original).
local function drawStatusPanel(battle, battler, x, y, player)
local tx, ty = math.floor(x / 8), math.floor(y / 8)
local tw, th = player and 15 or 16, player and 5 or 4
Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1)
local nameWidth = player and 64 or 80
Font.draw(fitName(battler.name, nameWidth), x + 8, y + 8)
levelAt(battle, battler, x + tw * 8 - 40, y + 8)
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
hp = shownHP(battler),
stats = battler.mon.stats,
}, nil, monoMode(), tw - 5)
if player then
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
x + tw * 8 - 64, y + 24)
end
end
-- the party ball rows DrawAllPokeballs puts up with the intro text, moved
-- out to the wide screen's own corners
local function drawIntroBalls(battle)
if not battle.introBalls then return end
if battle.enemyParty and
(battle.kind == "trainer" or battle.kind == "link") then
battle:drawBallRow(battle.enemyParty, 88, 40, -8)
end
battle:drawBallRow(battle.playerParty or battle.game.save.party, 216, 96, 8)
end
local function drawHUDs(battle, slide)
if battle.enemy and not battle.showEnemyTrainer
and not battle.enemySendingOut and not battle:growInScale(battle.enemy)
and slide == 0 and not battle.introBalls and not battle.enemy.fainted then
drawStatusPanel(battle, battle.enemy, 0, 0, false)
end
if battle.safari then
Font.drawBox(23, 7, 15, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("BALLx%2d"):format(battle.safari.balls), 200, 72)
elseif battle.player and not battle.demo and not battle.showPlayerBack
and slide == 0 then
drawStatusPanel(battle, battle.player, 184, 56, true)
end
drawIntroBalls(battle)
end
local function drawMessageBox(battle)
Font.drawBox(0, 13, 38, 5)
love.graphics.setColor(0, 0, 0, 1)
if battle.scrollPx and battle.scrollPx > 0 then
battle.scrollPx = battle.scrollPx - 2
if battle.scrollPx <= 0 then battle.scrollPx = nil end
end
local off = battle.scrollPx or 0
local ys = { 112, 128 }
for li, line in ipairs(battle.shown or {}) do
local y = (ys[li] or 128) + off
for i = 1, #line do
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
end
end
if (battle.msgWaiting or battle.msgPrompt) and battle.frame % 60 < 30 then
Font.drawCode(0xEE, 288, 132)
end
end
local function drawCommandMenu(battle)
local col = (battle.menuIndex - 1) % 2
local row = math.floor((battle.menuIndex - 1) / 2)
if battle.safari then
Font.drawBox(0, 13, 38, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("BALLx"), 16, 112)
Font.draw(Strings("BAIT"), 168, 112)
Font.draw(Strings("THROW ROCK"), 16, 128)
Font.draw(Strings("RUN"), 168, 128)
Font.drawCode(0xED, col == 0 and 8 or 160, 112 + row * 16)
return
end
-- the prompt on the left, the 2x2 commands on the right
Font.drawBox(0, 13, 20, 5)
Font.drawBox(20, 13, 18, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("What will"), 8, 112)
local who = battle.player and battle.player.name or ""
Font.draw(fitName(who, 112) .. Strings(" do?"), 8, 128)
Font.draw(Strings("FIGHT"), 176, 112)
Font.drawCode(0xE1, 240, 112) -- 'PK'
Font.drawCode(0xE2, 248, 112) -- 'MN'
Font.draw(Strings("ITEM"), 176, 128)
Font.draw(Strings("RUN"), 240, 128)
Font.drawCode(0xED, col == 0 and 168 or 232, 112 + row * 16)
end
local function drawMoveDetails(battle, move)
Font.drawBox(28, 13, 10, 5)
if not move then return end
local def = battle.data.moves[move.id]
if not def then return end
local maxPP = def.pp + (move.ppUps or 0) * math.floor(def.pp / 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("PP %2d/%2d"):format(move.pp or 0, maxPP), 232, 112)
Font.draw(fitName(TypeChart.displayName(def.type), 64), 232, 128)
end
local function drawMoveGrid(battle, moves, selected)
-- The 8px font needs 28 tiles for two complete twelve-character move
-- names plus their cursors; the details panel gets the other ten.
Font.drawBox(0, 13, 28, 5)
love.graphics.setColor(0, 0, 0, 1)
for i, move in ipairs(moves or {}) do
local col = (i - 1) % 2
local row = math.floor((i - 1) / 2)
local x, y = col == 0 and 16 or 120, 112 + row * 16
local def = battle.data.moves[move.id]
Font.draw(fitName(def and def.name or move.id or "", 96), x, y)
end
local col = (selected - 1) % 2
local row = math.floor((selected - 1) / 2)
Font.drawCode(0xED, col == 0 and 8 or 112, 112 + row * 16)
drawMoveDetails(battle, moves and moves[selected])
end
local function drawMoveMenu(battle)
drawMoveGrid(battle, battle.player.curMoves, battle.moveIndex)
if battle.moveSwapIndex then
local col = (battle.moveSwapIndex - 1) % 2
local row = math.floor((battle.moveSwapIndex - 1) / 2)
Font.drawCode(0xEC, col == 0 and 8 or 112, 112 + row * 16)
end
end
local function drawTextArea(battle)
if battle.phase == "messages" and (battle.current or battle.animPlaying) then
drawMessageBox(battle)
elseif battle.phase == "menu" then
drawCommandMenu(battle)
elseif battle.phase == "moveSelect" then
drawMoveMenu(battle)
elseif battle.phase == "mimicSelect" then
drawMoveGrid(battle, battle.mimicMoves, battle.mimicIndex)
else
Font.drawBox(0, 13, 38, 5)
end
end
-- Battle animations are authored in the original 160px coordinate space.
-- Shift each complete OAM frame as one rigid group between the new player
-- and enemy anchors: drawing the whole animation through both side regions
-- would duplicate any tiles overlapping the other side's source range (most
-- visibly the send-out POOF reappearing on the far right).
function WideBattle.animationOffset(sprites)
if not sprites or #sprites == 0 then return 0, 0 end
local minX, maxX = math.huge, -math.huge
for _, sprite in ipairs(sprites) do
minX = math.min(minX, sprite.x - 8)
maxX = math.max(maxX, sprite.x)
end
local center = (minX + maxX) / 2
local t = math.max(0, math.min(1, (center - 40) / 80))
return math.floor(20 + 116 * t + 0.5),
math.floor(8 * (1 - t) + 0.5)
end
local function currentAnimationSprites(battle)
if battle.animPlaying and battle.animPlayer then
local step = battle.animPlayer.steps[battle.animPlayer.stepIndex]
return step and step.sprites
end
if battle.lockedBall and battle.animPlayer then
return battle.lockedBall
end
end
local function drawAnimationLayer(battle)
local sprites = currentAnimationSprites(battle)
if not sprites or #sprites == 0 then return end
local dx, dy = WideBattle.animationOffset(sprites)
inRegion(0, 0, WideBattle.WIDTH, WideBattle.FIELD_BOTTOM, dx, dy,
function() battle:drawAnimLayer(false) end)
end
-- The whole 304x144 composition for one frame.
function WideBattle.draw(battle)
local g = love.graphics
-- The field is the display mode's paper. Under a forced-mono mode the
-- whole surface is remapped downstream (WideBattle.zones), so the field
-- goes down as DMG white and comes out of that pass as the mode's paper;
-- painting the resolved shade there would run it through the remap twice
-- and land a shade off the letterbox the renderer fills around it.
if monoMode() then
g.setColor(1, 1, 1, 1)
else
g.setColor(PaletteFX.paperShade(battle.data))
end
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
-- AskName clears the field the same way the classic layout does
if battle.blankForAskName then return end
local fx = battle.fx
local sx = (fx and fx.shakeX) or 0
local sy = (fx and fx.shakeY) or 0
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
sx = battle.frame % 4 < 2 and 2 or -2
end
local slide = (battle.introSlide or 0) * 4
-- Each side keeps its original sprite pixels and placement math: the two
-- 160x144 OAM regions are translated apart and clipped into the wider
-- battlefield rather than either monster being scaled. wideRegion tells
-- drawBattlerPic its own side window is already the clip.
battle.wideRegion = true
inRegion(0, 32, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
function() battle:drawPicsLayer(slide, 0, 0, "player", true) end)
inRegion(160, 0, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
battle.wideRegion = nil
drawHUDs(battle, slide)
drawAnimationLayer(battle)
drawTextArea(battle)
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
g.setColor(1, 1, 1, 0.85)
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
end
g.setColor(1, 1, 1, 1)
if Runtime.wantsHook("battle.overlay") then
Runtime.call("battle.overlay", function() end, battle)
end
end
-- The palette zones for the wide surface. The composition already resolves
-- species colors, paper shade and HP-bar colors itself, so the colorized
-- modes take the trueColor opt-out (`colors = false`) over the whole
-- surface; the forced-mono modes still want their whole-screen remap, and
-- get one sized to the wide surface instead of the 160x144 rectangle
-- PaletteFX.ensureZones would invent (which would leave 144 columns raw).
function WideBattle.zones()
local w, h = WideBattle.WIDTH, WideBattle.HEIGHT
if monoMode() then
-- sendColors runs the mode's own substitution (CLASSIC's pea greens,
-- the inverted permutation), exactly as it does for ensureZones' zone
return { PaletteFX.zone(PaletteFX.GRAYS, 0, 0, w / 8 - 1, h / 8 - 1) }
end
return { { colors = false, x = 0, y = 0, w = w, h = h } }
end
-- 2x2 move-grid navigation: LEFT/RIGHT cross the row, UP/DOWN the column,
-- and a direction pointing at an empty slot holds the current one.
function WideBattle.moveGridIndex(index, count, direction)
if count < 1 then return nil end
local row = math.floor((index - 1) / 2)
local col = (index - 1) % 2
if direction == "left" or direction == "right" then
local other = row * 2 + (1 - col) + 1
return other <= count and other or index
end
local otherRow = 1 - row
local other = otherRow * 2 + col + 1
return other <= count and other or index
end
local DIRECTIONS = { "left", "right", "up", "down" }
-- the slot a directional press selects, or nil when none was pressed (the
-- caller then runs its normal list navigation / A / B / SELECT handling)
function WideBattle.navigate(index, count, input)
for _, key in ipairs(DIRECTIONS) do
if input:wasPressed(key) then
return WideBattle.moveGridIndex(index, count, key)
end
end
return nil
end
return WideBattle
+81 -1
View File
@@ -25,6 +25,29 @@ local SAMPLE_RATE = ChipSynth.SAMPLE_RATE
local MUSIC_BUFFER_SAMPLES = ChipSynth.MUSIC_BUFFER_SAMPLES local MUSIC_BUFFER_SAMPLES = ChipSynth.MUSIC_BUFFER_SAMPLES
local MUSIC_BUFFER_COUNT = ChipSynth.MUSIC_BUFFER_COUNT 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
-- ---------------------------------------------------------------------------
local CHANNEL_VOLUME = {
[1] = 1, -- pulse 1
[2] = 1, -- pulse 2
[3] = 0.25, -- wave
[4] = 1, -- noise / drums
}
local CHANNEL_PITCH = {
[1] = 1, -- pulse 1
[2] = 1, -- pulse 2
[3] = 0.5, -- wave
[4] = 1, -- noise / drums
}
ChipSynth.setChannelVolumes(CHANNEL_VOLUME)
ChipSynth.setChannelPitches(CHANNEL_PITCH)
-- currentMusic: { source, gen, threaded, started, finished, engine } -- currentMusic: { source, gen, threaded, started, finished, engine }
-- threaded songs stream from the worker (engine is nil here); -- threaded songs stream from the worker (engine is nil here);
-- the fallback path owns a local engine and fills the source itself. -- the fallback path owns a local engine and fills the source itself.
@@ -152,13 +175,23 @@ function ChipAudio.playMusic(data, header, allowLoops)
musicGen = musicGen + 1 musicGen = musicGen + 1
local gen = musicGen local gen = musicGen
cmdCh:push({ cmd = "play", gen = gen, header = header, 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, currentMusic = { source = source, gen = gen, threaded = true,
started = false, finished = false } started = false, finished = false }
-- playback starts in update() once the first buffer arrives (~1 frame) -- playback starts in update() once the first buffer arrives (~1 frame)
return source return source
end 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 -- move finished buffers from the worker into the Source; start playback once
-- the first one lands -- the first one lands
local function updateThreaded() local function updateThreaded()
@@ -270,6 +303,53 @@ function ChipAudio.invalidate()
if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end
end 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 -- a stale song must not keep sounding past the flush that replaced its
-- program (20 §2 cache contract, chip music row) -- program (20 §2 cache contract, chip music row)
Assets.register(ChipAudio.invalidate) Assets.register(ChipAudio.invalidate)
+124 -47
View File
@@ -14,26 +14,97 @@ local bit = require("bit")
local ChipSynth = {} local ChipSynth = {}
local SAMPLE_RATE = 22050 local SAMPLE_RATE = 44100
local TICKS_PER_SECOND = 15360 local TICKS_PER_SECOND = 15360
local FRAME_TICKS = 256 local FRAME_TICKS = 256
local GB_CLOCK = 4194304 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 -- the synchronous fallback queues; the source keeps MUSIC_BUFFER_COUNT of them
-- (~6s) for stall tolerance (window resize, a long GC pause) -- (~6s at 44100) for stall tolerance (window resize, a long GC pause)
local MUSIC_BUFFER_SAMPLES = 4096 local MUSIC_BUFFER_SAMPLES = 8192
local MUSIC_BUFFER_COUNT = 32 local MUSIC_BUFFER_COUNT = 32
ChipSynth.SAMPLE_RATE = SAMPLE_RATE ChipSynth.SAMPLE_RATE = SAMPLE_RATE
ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES
ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT 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 = { local PITCHES = {
0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23, 0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23,
0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA, 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 WAVE_LEVEL = { [0] = 0, [1] = 1, [2] = 0.5, [3] = 0.25 }
local NOISE_DIVISORS = { local NOISE_DIVISORS = {
[0] = 8, [1] = 16, [2] = 32, [3] = 48, [0] = 8, [1] = 16, [2] = 32, [3] = 48,
@@ -41,7 +112,7 @@ local NOISE_DIVISORS = {
} }
local function snapTicks(ticks) local function snapTicks(ticks)
return math.floor((ticks * 735 + 256) / 512) return math.floor((ticks * 1470 + 256) / 512)
end end
local cachedProgramFile local cachedProgramFile
@@ -143,7 +214,7 @@ function Channel.new(engine, spec, options)
speed = 12, speed = 12,
volume = 12, volume = 12,
fade = 0, fade = 0,
duty = 0.5, duty = 2,
octave = 4, octave = 4,
waveInstrument = 0, waveInstrument = 0,
waveLevel = 1, waveLevel = 1,
@@ -317,7 +388,7 @@ function Channel:nextEvent()
target = self:frequency(bit.band(packed, 0x0F), octave), target = self:frequency(bit.band(packed, 0x0F), octave),
} }
elseif command == 0xEC then 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 elseif command == 0xED then
self.engine.tempo = self:byte() * 0x100 + self:byte() self.engine.tempo = self:byte() * 0x100 + self:byte()
elseif command == 0xEE then elseif command == 0xEE then
@@ -329,10 +400,10 @@ function Channel:nextEvent()
elseif command == 0xFC then elseif command == 0xFC then
local packed = self:byte() local packed = self:byte()
self.duty = { self.duty = {
DUTY[bit.band(bit.rshift(packed, 6), 3)], bit.band(bit.rshift(packed, 6), 3),
DUTY[bit.band(bit.rshift(packed, 4), 3)], bit.band(bit.rshift(packed, 4), 3),
DUTY[bit.band(bit.rshift(packed, 2), 3)], bit.band(bit.rshift(packed, 2), 3),
DUTY[bit.band(packed, 3)], bit.band(packed, 3),
} }
elseif command == 0xFD then elseif command == 0xFD then
self.callStack[#self.callStack + 1] = self.address + 2 self.callStack[#self.callStack + 1] = self.address + 2
@@ -423,27 +494,24 @@ function Channel:sampleNoise(parameter)
parameter = parameter or 0 parameter = parameter or 0
local divisor = NOISE_DIVISORS[bit.band(parameter, 7)] local divisor = NOISE_DIVISORS[bit.band(parameter, 7)]
local shift = bit.rshift(parameter, 4) local shift = bit.rshift(parameter, 4)
local output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1 if shift < 14 then
if shift >= 14 then return output end local pitch = channelPitch[self.hardware] or 1
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE * pitch
local width7 = bit.band(parameter, 8) ~= 0 local width7 = bit.band(parameter, 8) ~= 0
local remaining = cycles local remaining = cycles
local area = 0 while remaining > 0 do
local untilClock = 1 - self.noiseClock
while remaining > 0 do local span = math.min(remaining, untilClock)
local untilClock = 1 - self.noiseClock self.noiseClock = self.noiseClock + span
local span = math.min(remaining, untilClock) remaining = remaining - span
output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1 if self.noiseClock >= 1 - 1e-12 then
area = area + output * span self.noiseClock = 0
self.noiseClock = self.noiseClock + span self:clockNoise(width7)
remaining = remaining - span end
if self.noiseClock >= 1 - 1e-12 then
self.noiseClock = 0
self:clockNoise(width7)
end end
end end
-- LuaGB: instantaneous inverted LFSR LSB (high when bit0 == 0)
return area / cycles return bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
end end
local function sweepCalculation(register, sweep) local function sweepCalculation(register, sweep)
@@ -481,7 +549,7 @@ function Channel:sampleDrum(event, sampleIndex)
end end
local elapsed = (sampleIndex - segment.startSample) / SAMPLE_RATE local elapsed = (sampleIndex - segment.startSample) / SAMPLE_RATE
local volume = envelopeVolume(segment.volume, segment.fade, elapsed) 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 end
function Channel:sample() function Channel:sample()
@@ -498,11 +566,14 @@ function Channel:sample()
event.sample = sampleIndex + 1 event.sample = sampleIndex + 1
if event.silence then return 0 end 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( local volume = envelopeVolume(
event.volume or 0, event.fade or 0, event.elapsed) event.volume or 0, event.fade or 0, event.elapsed)
if event.noise then if event.noise then
return self:sampleNoise(event.noiseParameter) * volume / 15 * 0.35 return self:sampleNoise(event.noiseParameter) * volume / 15 * gain
end end
local register = event.register local register = event.register
@@ -527,7 +598,8 @@ function Channel:sample()
end end
end 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 if event.wave then frequency = frequency * 0.5 end
local phase = self.phase local phase = self.phase
self.phase = (phase + frequency / SAMPLE_RATE) % 1 self.phase = (phase + frequency / SAMPLE_RATE) % 1
@@ -537,13 +609,18 @@ function Channel:sample()
-- a def-local program may omit its wave table entirely -- a def-local program may omit its wave table entirely
if not wave then return 0 end if not wave then return 0 end
local index = math.min(32, math.floor(phase * 32) + 1) local index = math.min(32, math.floor(phase * 32) + 1)
return wave[index] * event.waveLevel * 0.55 return wave[index] * event.waveLevel * gain
end end
local duty = event.duty local duty = event.duty
if type(duty) == "table" then if type(duty) == "table" then
duty = duty[frame % 4 + 1] duty = duty[frame % 4 + 1]
end 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 end
local Engine = {} local Engine = {}
@@ -597,8 +674,8 @@ local function readWaves(banks, audio, engineNumber)
for byteIndex = 0, 15 do for byteIndex = 0, 15 do
local packed = romByte( local packed = romByte(
banks, spec.bank, spec.address + wave * 16 + byteIndex) banks, spec.bank, spec.address + wave * 16 + byteIndex)
values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5 values[#values + 1] = (bit.rshift(packed, 4) - 8) / 8
values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5 values[#values + 1] = (bit.band(packed, 0x0F) - 8) / 8
end end
waves[#waves + 1] = values waves[#waves + 1] = values
end end
@@ -606,8 +683,8 @@ local function readWaves(banks, audio, engineNumber)
for byteIndex = 0, 15 do for byteIndex = 0, 15 do
local packed = romByte( local packed = romByte(
banks, spec.bank, spec.address + 5 * 16 + byteIndex) banks, spec.bank, spec.address + 5 * 16 + byteIndex)
values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5 values[#values + 1] = (bit.rshift(packed, 4) - 8) / 8
values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5 values[#values + 1] = (bit.band(packed, 0x0F) - 8) / 8
end end
for _ = 1, 4 do waves[#waves + 1] = values end for _ = 1, 4 do waves[#waves + 1] = values end
return waves return waves
@@ -615,7 +692,7 @@ end
-- def-local waves are authored either as raw 0-15 nibbles (the ROM's own -- 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 -- units) or as the -1..1 samples readWaves produces; the synth wants the
-- latter -- latter (LuaGB: (nibble - 8) / 8)
local function normalizeWaves(source) local function normalizeWaves(source)
local waves = {} local waves = {}
for index, values in ipairs(source) do for index, values in ipairs(source) do
@@ -625,7 +702,7 @@ local function normalizeWaves(source)
end end
local wave = {} local wave = {}
for position, value in ipairs(values) do 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 end
waves[index] = wave waves[index] = wave
end end
@@ -690,7 +767,7 @@ end
function Engine:sample() function Engine:sample()
local value = 0 local value = 0
for _, channel in ipairs(self.channels) do value = value + channel:sample() end 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 end
function Engine:sampleStereo() 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.panLeft ~= false then left = left + value end
if not event or event.panRight ~= false then right = right + value end if not event or event.panRight ~= false then right = right + value end
end end
return math.max(-1, math.min(1, left * 0.5)), return math.max(-1, math.min(1, left / 4)),
math.max(-1, math.min(1, right * 0.5)) math.max(-1, math.min(1, right / 4))
end end
function Engine:sampleChannel(number) function Engine:sampleChannel(number)
@@ -711,7 +788,7 @@ function Engine:sampleChannel(number)
local value = channel:sample() local value = channel:sample()
if channel.number == number then selected = value end if channel.number == number then selected = value end
end end
return math.max(-1, math.min(1, selected * 0.5)) return math.max(-1, math.min(1, selected / 4))
end end
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound -- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
+60 -4
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 -- 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. -- values match what SaveData.newGame and the Oak speech used to inline.
local BOOT_DEFAULTS = { local BOOT_DEFAULTS = {
@@ -58,6 +74,12 @@ local function copy(value)
return out return out
end 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 -- Fills only what the cache is missing, so an importer that learns to
-- stamp one of these keys silently takes over from the engine. -- stamp one of these keys silently takes over from the engine.
function Data:seedDefaults() function Data:seedDefaults()
@@ -77,6 +99,7 @@ function Data:seedDefaults()
if constants.dexDigits == nil then if constants.dexDigits == nil then
constants.dexDigits = math.max(3, #tostring(constants.dexSize)) constants.dexDigits = math.max(3, #tostring(constants.dexSize))
end end
self:applyVersionedFieldData()
local boot = self.field.boot local boot = self.field.boot
if boot == nil then if boot == nil then
boot = {} boot = {}
@@ -85,6 +108,13 @@ function Data:seedDefaults()
for key, value in pairs(BOOT_DEFAULTS) do for key, value in pairs(BOOT_DEFAULTS) do
if boot[key] == nil then boot[key] = copy(value) end if boot[key] == nil then boot[key] = copy(value) end
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 -- the naming screen presets the importer already extracts but nothing
-- ever read (field.presetNames) -- ever read (field.presetNames)
if boot.namePresets == nil then if boot.namePresets == nil then
@@ -206,10 +236,15 @@ function Data:load()
(function() local n = 0 for _ in pairs(self.moves) do n = n + 1 end return n end)()) (function() local n = 0 for _ in pairs(self.moves) do n = n + 1 end return n end)())
end end
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the -- Drop every namespace the mod merge created and evict the generated modules
-- mod merge created, then re-require the generated modules so base records -- from package.loaded, so the next load() re-reads them off disk instead of
-- return to their on-disk values even where a mod edited them in place -- handing back the cached tables. Two callers:
function Data:reloadGenerated() -- * reloadGenerated below (dev hot reload)
-- * main.lua, when the launcher closes the save editor -- the editor may
-- have loaded the OTHER game's cache, and require would otherwise serve
-- those modules to a subsequent Play (see CacheFs.unmountVersion, which
-- clears the matching read-path overlay).
function Data:unloadGenerated()
local pristine = self._pristineKeys local pristine = self._pristineKeys
if pristine then if pristine then
for key in pairs(self) do for key in pairs(self) do
@@ -222,6 +257,13 @@ function Data:reloadGenerated()
for _, name in ipairs(OPTIONAL) do for _, name in ipairs(OPTIONAL) do
package.loaded["data.generated." .. name] = nil package.loaded["data.generated." .. name] = nil
end end
end
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
-- mod merge created, then re-require the generated modules so base records
-- return to their on-disk values even where a mod edited them in place
function Data:reloadGenerated()
self:unloadGenerated()
self:load() self:load()
end end
@@ -246,6 +288,20 @@ function Data:resolveText(mapLabel, textConst)
local s = self.text[entry.text] local s = self.text[entry.text]
if s then return s, entry.asm end if s then return s, entry.asm end
end end
-- A text_asm entry names its wrapper label but carries no string, because
-- the extractor cannot follow asm. A handful of those wrappers are plain
-- `text_far <label> / text_end` pairs with no logic at all -- BoulderText
-- (home/overworld_text.asm:16), MartSignText, PokeCenterSignText -- and
-- for those the extracted _Label string IS the whole behavior, so the
-- boulders and signs printed nothing at all (#318). Wrappers that really
-- do run logic have no _Label string to find, so they still fall through
-- to their hand-ported script in data/scripts/.
-- needsAsm comes back false here on purpose: showMapText's warning tells
-- the reader to go port a script, and for these there is nothing to port.
if entry.label then
local s = self.text["_" .. entry.label]
if s then return s, false end
end
return nil, entry.asm return nil, entry.asm
end end
+7 -6
View File
@@ -9,6 +9,7 @@
local Json = require("src.link.Json") local Json = require("src.link.Json")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local DiscordPresence = { local DiscordPresence = {
APP_ID = "1529183141267374262", APP_ID = "1529183141267374262",
@@ -436,7 +437,7 @@ end
local function buildActivity() local function buildActivity()
local details, activityState local details, activityState
if state.activity == "battle" then if state.activity == "battle" then
details = state.battleLabel or "In battle" details = state.battleLabel or Strings("In battle")
activityState = state.location and (state.location) or "Kanto" activityState = state.location and (state.location) or "Kanto"
elseif state.activity == "exploring" and state.location then elseif state.activity == "exploring" and state.location then
details = state.location details = state.location
@@ -550,20 +551,20 @@ local function subscribe(game)
track("battle.started", function(ev) track("battle.started", function(ev)
local battle = ev and ev.battle local battle = ev and ev.battle
local label = "In battle" local label = Strings("In battle")
if ev and ev.kind == "wild" then if ev and ev.kind == "wild" then
local name = speciesName(game, ev.species) local name = speciesName(game, ev.species)
if name then if name then
label = "Battling wild " .. name label = "Battling wild " .. name
if ev.level then label = label .. " Lv" .. tostring(ev.level) end if ev.level then label = label .. " Lv" .. tostring(ev.level) end
else else
label = "Wild battle" label = Strings("Wild battle")
end end
elseif ev and ev.kind == "trainer" then elseif ev and ev.kind == "trainer" then
local tname = battle and battle.trainer and battle.trainer.name local tname = battle and battle.trainer and battle.trainer.name
label = tname and ("Battling " .. tname) or "Trainer battle" label = tname and ("Battling " .. tname) or Strings("Trainer battle")
elseif ev and ev.kind == "link" then elseif ev and ev.kind == "link" then
label = "Link battle" label = Strings("Link battle")
end end
local mapId = state.mapId local mapId = state.mapId
or (game.save and game.save.player and game.save.player.map) or (game.save and game.save.player and game.save.player.map)
@@ -594,7 +595,7 @@ local function subscribe(game)
state.mapId = nil state.mapId = nil
setPresence({ setPresence({
activity = "menu", activity = "menu",
location = id == "IntroMovie" and "Watching the intro" or "Title screen", location = id == "IntroMovie" and "Watching the intro" or Strings("Title screen"),
clearBattle = true, clearBattle = true,
}) })
end end
+90 -9
View File
@@ -53,6 +53,9 @@ function Game:load()
require("src.render.Font").load(Data) require("src.render.Font").load(Data)
-- menu cursor/border/geometry constants; field.theme restyles them -- menu cursor/border/geometry constants; field.theme restyles them
require("src.ui.Theme").load(Data) require("src.ui.Theme").load(Data)
-- the engine's own text, after the merge so a translation mod's catalog
-- is already in Data.strings; empty on a mod-free boot and skipped
require("src.core.Strings").load(Data)
self.stack = StateStack self.stack = StateStack
StateStack:init() StateStack:init()
@@ -87,10 +90,13 @@ function Game:load()
self.save.player.x, self.save.player.y, self.save.player.facing) self.save.player.x, self.save.player.y, self.save.player.facing)
else else
local titleState = self:makeTitleState() local titleState = self:makeTitleState()
-- the copyright splash + Nidorino-vs-Gengar attract movie plays -- the copyright splash + attract movie plays before the title
-- before the title (engine/movie/splash.asm + intro.asm); the ids come -- (engine/movie/splash.asm + intro.asm; Yellow swaps in its own
-- from field.boot.screens so a total conversion owns the whole boot -- 18-scene movie, engine/movie/intro_yellow.asm); the ids come from
Screens.push(self, bootScreens(self).splash or "IntroMovie", function() -- 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) StateStack:push(titleState)
end) end)
end end
@@ -188,6 +194,15 @@ end
-- or screenshot run does not depend on whatever the player last chose. -- or screenshot run does not depend on whatever the player last chose.
function Game:logicSpeed() function Game:logicSpeed()
local GameSpeed = require("src.core.GameSpeed") local GameSpeed = require("src.core.GameSpeed")
-- Link play is always 1X on both machines, and this wins over every other
-- source including POKEPORT_SPEED. Fast-forward multiplies the logic
-- clock, so a peer at 10X burned a tournament shot clock ten times faster
-- than the opponent it is racing, and drove its own animation/message
-- queue at a different rate than the peer it is locked to. Nothing about
-- a match should depend on what either player set this to.
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
return 1
end
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
local opts = self.save and self.save.options local opts = self.save and self.save.options
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT) return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
@@ -229,25 +244,93 @@ end
-- exactly as the owning state computed it -- exactly as the owning state computed it
local function sameZones(_, zones) return zones end 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() function Game:draw()
-- the UI canvas clears transparent when the overworld's world pass -- the UI canvas clears transparent when the overworld's world pass
-- shows through beneath it; opaque full-screen states get the classic -- shows through beneath it; opaque full-screen states get the classic
-- white clear -- white clear
local base = self.stack:visibleBase() local base = self.stack:visibleBase()
local worldBelow = self.stack.states[base] == self.overworld local worldBelow = self.stack.states[base] == self.overworld
-- 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()
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) 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 -- SGB colorization: the topmost state that knows its palette owns the
-- screen (overlays like text boxes inherit from what's beneath them); -- screen (overlays like text boxes inherit from what's beneath them);
-- the overworld's world pass colors each visible map area separately -- 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 for i = #self.stack.states, 1, -1 do
local s = self.stack.states[i] local s = self.stack.states[i]
if s.sgbPalettes then if s.sgbPalettes then
zones = s:sgbPalettes(self) zones = s:sgbPalettes(self)
zoneOwner = s
break break
end end
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 -- 14's render.zones: weather/lighting overlays and custom colorization
-- recolor or add zones before the blit -- recolor or add zones before the blit
if ModRuntime.wantsHook("render.zones") then if ModRuntime.wantsHook("render.zones") then
@@ -378,9 +461,7 @@ end
-- LÖVE process ensures scripts, registries, and assets are all rebuilt from -- LÖVE process ensures scripts, registries, and assets are all rebuilt from
-- the newly selected mod state. -- the newly selected mod state.
function Game:restartWithMods() function Game:restartWithMods()
if love.event and love.event.quit then require("src.core.HostShell").restart()
love.event.quit("restart")
end
end end
function Game:keyreleased(key) function Game:keyreleased(key)
+25 -8
View File
@@ -1,12 +1,13 @@
-- Which Gen-1 game this process is running: Red (the historical default) or -- Which Gen-1 game this process is running: Red (the historical default),
-- Blue. One source of truth for everything that differs by version -- the -- Blue, or Yellow. One source of truth for everything that differs by
-- accepted ROM hash, the import manifest, where the extracted cache lives, -- version -- the accepted ROM hash, the import manifest, where the
-- and the save-file suffix -- so the importer, cache mount, SaveData, title -- extracted cache lives, and the save-file suffix -- so the importer,
-- screen and palette all agree. -- cache mount, SaveData, title screen and palette all agree.
-- --
-- Red keeps every un-suffixed path it always used (save.lua, the root cache), -- 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 -- 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 -- Zero requires, so it loads during love.conf and under plain Lua for tools
-- and tests. The active version is a process-global set once at boot from -- and tests. The active version is a process-global set once at boot from
@@ -19,6 +20,7 @@ GameVersion.VERSIONS = {
id = "red", id = "red",
label = "Red", label = "Red",
displayName = "Pokemon Red", displayName = "Pokemon Red",
launcherName = "Red", -- game-panel header in the launcher
sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a", sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
manifest = "tools/rom_manifest.json", manifest = "tools/rom_manifest.json",
cachePrefix = "", -- Red owns the cache root (backwards compatible) cachePrefix = "", -- Red owns the cache root (backwards compatible)
@@ -28,15 +30,26 @@ GameVersion.VERSIONS = {
id = "blue", id = "blue",
label = "Blue", label = "Blue",
displayName = "Pokemon Blue", displayName = "Pokemon Blue",
launcherName = "Blue",
sha1 = "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2", sha1 = "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2",
manifest = "tools/rom_manifest_blue.json", manifest = "tools/rom_manifest_blue.json",
cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated
saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp 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). -- Launcher column order.
GameVersion.ORDER = { "red", "blue" } GameVersion.ORDER = { "red", "blue", "yellow" }
GameVersion.current = "red" GameVersion.current = "red"
@@ -53,6 +66,10 @@ function GameVersion.isBlue()
return GameVersion.current == "blue" return GameVersion.current == "blue"
end end
function GameVersion.isYellow()
return GameVersion.current == "yellow"
end
-- Metadata for a version id, defaulting to the active one. -- Metadata for a version id, defaulting to the active one.
function GameVersion.info(id) function GameVersion.info(id)
return GameVersion.VERSIONS[id or GameVersion.current] 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
+1 -1
View File
@@ -144,7 +144,7 @@ function Input:step()
elseif next(sources) ~= nil then elseif next(sources) ~= nil then
self.state[btn] = true self.state[btn] = true
end end
-- sources == {}: real press fully released before this step keep up -- sources == {}: real press fully released before this step -- keep up
end end
for btn, sources in pairs(self.sources) do for btn, sources in pairs(self.sources) do
if next(sources) == nil then if next(sources) == nil 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
+138 -39
View File
@@ -16,6 +16,7 @@ local SaveSerializer = require("src.core.SaveSerializer")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Semver = require("src.mods.Semver") local Semver = require("src.mods.Semver")
local Boxes = require("src.pokemon.Boxes") local Boxes = require("src.pokemon.Boxes")
local Stats = require("src.pokemon.Stats")
local Bag = require("src.inventory.Bag") local Bag = require("src.inventory.Bag")
local Badges = require("src.inventory.Badges") local Badges = require("src.inventory.Badges")
@@ -23,10 +24,11 @@ local GameVersion = require("src.core.GameVersion")
local SaveData = {} local SaveData = {}
-- Progress files carry the game-version suffix so Red and Blue saves coexist: -- Progress files carry the game-version suffix so Red / Blue / Yellow saves
-- Red keeps save.lua / .bak / .tmp exactly as before; Blue is save_blue.lua -- coexist: Red keeps save.lua / .bak / .tmp exactly as before; Blue is
-- (+ .bak/.tmp). options.lua is deliberately shared across versions (it holds -- save_blue.lua and Yellow is save_yellow.lua (+ .bak/.tmp). options.lua is
-- global preferences and the mod enable-state, not per-playthrough data). -- deliberately shared across versions (it holds global preferences and the
-- mod enable-state, not per-playthrough data).
local OPTIONS_FILENAME = "options.lua" local OPTIONS_FILENAME = "options.lua"
-- Main / backup / staged-witness names for a version (defaults to the active -- Main / backup / staged-witness names for a version (defaults to the active
@@ -108,11 +110,14 @@ local function makePortableFs(dir)
} }
end end
local function detectPortable() -- Every folder a player might reasonably call "the game folder", best first:
if portableChecked then return portableBase end -- the packaged-app container, the folder holding the executable, then the
portableChecked = true -- source itself. Portable mode is the case where one of these holds the
portableBase = false -- marker; the list itself is just locations, marker or not, which is also
if not (love and love.filesystem) then return false end -- 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 -- Desktop only: portable mode carries the save (and, since issue #74, the
-- ROM cache) in the game folder next to the executable/source. On -- 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 -- Android/iOS the source is a read-only package with no such folder, so
@@ -120,7 +125,7 @@ local function detectPortable()
if love.system and love.system.getOS then if love.system and love.system.getOS then
local osName = love.system.getOS() local osName = love.system.getOS()
if osName ~= "Windows" and osName ~= "Linux" and osName ~= "OS X" then if osName ~= "Windows" and osName ~= "Linux" and osName ~= "OS X" then
return false return {}
end end
end end
local src = love.filesystem.getSource and love.filesystem.getSource() local src = love.filesystem.getSource and love.filesystem.getSource()
@@ -132,22 +137,40 @@ local function detectPortable()
-- Recover the folder containing the .app so a packaged app finds its -- Recover the folder containing the .app so a packaged app finds its
-- marker. On Windows/Linux the executable is not a bundle, so this is nil -- marker. On Windows/Linux the executable is not a bundle, so this is nil
-- and the plain source-base directory (next to the .exe/AppImage) is used. -- and the plain source-base directory (next to the .exe/AppImage) is used.
local function appContainer(path) local function parentDir(path)
local appPath = path and path:match("^(.*%.app)/Contents/") return path and path:match("^(.*)/[^/]+$") or nil
return appPath and appPath:match("^(.*)/[^/]+$") or nil
end end
-- Order: the .app's containing folder (packaged macOS), then the local function appContainer(path)
-- source-base directory (next to a packaged .exe/AppImage), then the return parentDir(path and path:match("^(.*%.app)/Contents/"))
-- source itself (a `love <gamedir>` run drops portable.txt in the game end
-- folder). First one holding the marker wins. Built by appending so a -- The Linux AppImage build works similarly to macOs, but the runtime mounts
-- nil (e.g. no .app in the path) never truncates the ipairs scan. -- the squashfs at a temp path, and that becomes the base directory.
-- The runtime exports the real .AppImage path in $APPIMAGE, so we get the
-- base dir from there.
local function appImageContainer()
return parentDir(os.getenv("APPIMAGE"))
end
-- 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).
-- Built by appending so a nil (e.g. no .app in the path) never truncates
-- the ipairs scan.
local candidates = {} local candidates = {}
local appDir = appContainer(src) or appContainer(sbd) local appDir = appContainer(src) or appContainer(sbd) or appImageContainer()
if appDir then candidates[#candidates + 1] = appDir end if appDir then candidates[#candidates + 1] = appDir end
if sbd then candidates[#candidates + 1] = sbd end if sbd and sbd ~= "" then candidates[#candidates + 1] = sbd end
if src then candidates[#candidates + 1] = src end if src and src ~= "" then candidates[#candidates + 1] = src end
for _, base in ipairs(candidates) do return candidates
if base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then 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 portableBase = base
break break
end end
@@ -193,6 +216,9 @@ function SaveData.defaultOptions()
textSpeed = 3, textSpeed = 3,
animations = true, animations = true,
battleStyle = "shift", battleStyle = "shift",
-- battle screen composition: og (the 160x144 original) | wide
-- (304x144, src/battle/WideBattle.lua)
battleLayout = "og",
ruleset = "gen1_faithful", ruleset = "gen1_faithful",
-- 0-7 like the GB's NR50 master volume -- 0-7 like the GB's NR50 master volume
musicVol = 7, musicVol = 7,
@@ -310,8 +336,9 @@ end
-- under options.saveSlots[version]; the active slot is also cached -- under options.saveSlots[version]; the active slot is also cached
-- process-wide (like GameVersion.current) so the hot saveNames path does -- 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 -- 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 -- use" and the flat legacy path (save.lua / save_blue.lua / save_yellow.lua)
-- keeps a brand-new install and every pre-slots caller working unchanged. -- 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 activeSlotCache = {} -- version -> slotId in use, or false when none
local slotsChecked = {} -- version -> true once resolved this process local slotsChecked = {} -- version -> true once resolved this process
@@ -323,17 +350,18 @@ local function slotNames(version, id)
end end
-- the pre-slots flat names a version always used (save.lua for Red, -- 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 function legacyNames(version)
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua" local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
return main, main .. ".bak", main .. ".tmp" return main, main .. ".bak", main .. ".tmp"
end end
-- Slot resolution is only meaningful for versions GameVersion actually knows -- Slot resolution is only meaningful for versions GameVersion actually knows
-- (red/blue). The launcher also renders a locked placeholder tab ("yellow") -- (red / blue / yellow). An unknown id has no info entry and therefore no
-- that has no info entry and therefore no saveSuffix; resolving its legacy -- saveSuffix; resolving its legacy names would index a nil info table and
-- names would index a nil info table and crash. Treat any unknown version as -- crash. Treat any unknown version as having no slots so the slot APIs
-- having no slots so the slot APIs degrade to empty/no-op instead. -- degrade to empty/no-op instead.
local function knownVersion(version) local function knownVersion(version)
return GameVersion.info(version) ~= nil return GameVersion.info(version) ~= nil
end end
@@ -442,6 +470,25 @@ function SaveData.slotSummary(save)
} }
end end
-- The absolute on-disk path of a slot's save file, for the one caller that
-- cannot go through love.filesystem: the save editor reads and writes with
-- raw io.* so it can also open a file the player dragged in from anywhere.
-- Resolves against the same root persistFs would write to -- the portable
-- game folder when portable mode is on, otherwise LOVE's save directory --
-- so Edit on a launcher save row lands on the file the game actually plays.
-- nil when neither root is available (headless tests with an injected fs).
function SaveData.slotDiskPath(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) or not slotId then return nil end
local base = SaveData.portableBaseDir()
or (love and love.filesystem and love.filesystem.getSaveDirectory
and love.filesystem.getSaveDirectory())
if not base then return nil end
local sep = package.config:sub(1, 1)
local rel = select(1, slotNames(version, slotId))
return base .. sep .. rel:gsub("/", sep)
end
-- Slots visible to the launcher: every registered slot for a version, each -- Slots visible to the launcher: every registered slot for a version, each
-- with whether it holds a save and the cheap summary above. A fresh -- with whether it holds a save and the cheap summary above. A fresh
-- install with nothing registered returns an empty array; a legacy install -- install with nothing registered returns an empty array; a legacy install
@@ -458,11 +505,44 @@ function SaveData.listSlots(version)
for _, id in ipairs(list) do for _, id in ipairs(list) do
local save = decodeSlot(fs, version, id) local save = decodeSlot(fs, version, id)
local name, meta = SaveData.slotSummary(save) local name, meta = SaveData.slotSummary(save)
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta } out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta,
label = reg.names and reg.names[id] or nil }
end end
return out return out
end end
-- Give a registered slot a custom label (#205: "a way to name save slots so
-- you can see that in the launcher"). The label lives in the options
-- registry next to list/active, never in the save file itself, so renaming
-- needs no save rewrite and an empty slot can be labeled too. The label is
-- trimmed; an empty (or whitespace-only) one clears it. Returns true, or
-- false + an error string when the id is not registered.
function SaveData.renameSlot(version, slotId, name)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
if type(slotId) ~= "string" or slotId == "" then
return false, "missing slot id"
end
local fs = persistFs(nil)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version]
if not reg or not reg.list then return false, "slot not registered" end
local found = false
for _, id in ipairs(reg.list) do
if id == slotId then found = true break end
end
if not found then return false, "slot not registered" end
local label = type(name) == "string" and name:match("^%s*(.-)%s*$") or nil
if label == "" then label = nil end
reg.names = reg.names or {}
reg.names[slotId] = label
if next(reg.names) == nil then reg.names = nil end
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
return true
end
-- Point the active slot at slotId (registering it if new) and persist the -- Point the active slot at slotId (registering it if new) and persist the
-- choice to options.lua; also update the process-global cache so the very -- choice to options.lua; also update the process-global cache so the very
-- next save/load lands in the chosen slot. -- next save/load lands in the chosen slot.
@@ -580,6 +660,7 @@ function SaveData.deleteSlot(version, slotId)
remove(fs, tmp) remove(fs, tmp)
table.remove(reg.list, idx) table.remove(reg.list, idx)
if reg.names then reg.names[slotId] = nil end
if reg.active == slotId then if reg.active == slotId then
reg.active = reg.list[1] -- may be nil when the list is now empty reg.active = reg.list[1] -- may be nil when the list is now empty
end end
@@ -826,7 +907,7 @@ end)
-- a .tmp witness before the swap, so a crash mid-write is recoverable. -- a .tmp witness before the swap, so a crash mid-write is recoverable.
function SaveData.save(data, mods) function SaveData.save(data, mods)
-- write to the file matching this save's own version, not just the active -- 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) local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version)
if data.options then if data.options then
SaveData.saveOptions(data.options) SaveData.saveOptions(data.options)
@@ -972,6 +1053,17 @@ local function scrubKnownMon(mon, data)
for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end
end end
mon.level = clamp(mon.level, 1, 100, 1) mon.level = clamp(mon.level, 1, 100, 1)
-- Box mons imported from a real .sav carry NO stat block: box_struct stops
-- before MON_LEVEL/MON_STATS, so src/save_convert/GenSave.lua decodeMon
-- only fills `stats` for party slots. Every HP-bar draw then nil-indexes
-- mon.stats: the status screen opened in the box (#233) and the party list
-- after withdrawing one (#304). The original derives them on demand
-- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means
-- every later reader (menus, battle, items, SGB bar zones, the link
-- fingerprint) sees a party-shaped mon. Runs after the level clamp above
-- so the derived stats use a sane level. A save that already has stats is
-- untouched.
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
local moves = mon.moves local moves = mon.moves
if type(moves) ~= "table" then return end if type(moves) ~= "table" then return end
local hadMoves = #moves > 0 local hadMoves = #moves > 0
@@ -1155,9 +1247,16 @@ function SaveData.defaultHeal(boot)
end end
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME -- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME
-- after jp Init; this port places CONTINUE at the NewGameWarp bedroom and -- after jp Init; this port relocates CONTINUE instead, and retargets
-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so -- LAST_MAP exits (Red's house mats) at the heal-point town so leaving the
-- leaving the house does not dump the player back at Indigo Plateau. -- house does not dump the player back at Indigo Plateau.
--
-- The landing spot is the blackout point, not the NewGameWarp bedroom:
-- HallOfFameResetEventsAndSaveScript sets wLastBlackoutMap := PALLET_TOWN,
-- so resuming puts the player outside the front door. Spawning them back
-- in the upstairs bedroom was a port-only detour (#253). Non-vanilla
-- spawns are unaffected -- defaultHeal returns boot's own start map for
-- those, which is what the bedroom branch resolved to anyway.
-- Marks postGameHomeOk so a later intentional HoF save is not relocated. -- Marks postGameHomeOk so a later intentional HoF save is not relocated.
function SaveData.applyPostGameHome(save, boot) function SaveData.applyPostGameHome(save, boot)
boot = type(boot) == "table" and boot or {} boot = type(boot) == "table" and boot or {}
@@ -1165,9 +1264,9 @@ function SaveData.applyPostGameHome(save, boot)
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y } save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y } save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y }
save.player = save.player or {} save.player = save.player or {}
save.player.map = boot.startMap or "REDS_HOUSE_2F" save.player.map = heal.map
save.player.x = boot.startX or 3 save.player.x = heal.x
save.player.y = boot.startY or 6 save.player.y = heal.y
save.player.facing = boot.startFacing or "down" save.player.facing = boot.startFacing or "down"
save.postGameHomeOk = true save.postGameHomeOk = true
return heal return heal
@@ -1209,7 +1308,7 @@ function SaveData.newGame(boot)
inventory = {}, inventory = {},
-- Vanilla Gen1 seeds one Potion in the player's item PC -- Vanilla Gen1 seeds one Potion in the player's item PC
-- (wBoxItems / players_pc.asm); existing saves keep whatever they -- (wBoxItems / players_pc.asm); existing saves keep whatever they
-- already have this only applies to New Game. -- already have -- this only applies to New Game.
pcItems = { POTION = 1 }, pcItems = { POTION = 1 },
party = {}, party = {},
box = {}, box = {},
+35
View File
@@ -192,10 +192,45 @@ local function newCrySource(data, species, def)
return newFileSource(resolved) return newFileSource(resolved)
end 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(BASE_VOLUME * volumeScale)
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 -- returns the source (nil headless) so callers that block on the cry
-- like the original's PlayCry -> WaitForSoundToFinish can poll it -- like the original's PlayCry -> WaitForSoundToFinish can poll it
function Sound.playCry(data, species) function Sound.playCry(data, species)
if not love.audio then return nil end 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 cries = data.audio and data.audio.cries
local def = cries and cries[species] local def = cries and cries[species]
if not def then return nil end if not def then return nil end
+134
View File
@@ -0,0 +1,134 @@
-- The engine's own player-facing text, made overridable.
--
-- Extracted dialogue already had a home: `Data.text`, keyed by pokered's
-- labels, which a mod reaches through `mod.content.text`. The strings the
-- engine *authors* had none. Battle messages, item results, menu labels
-- and the link-play screens were literals in Lua, so a translator could
-- reach two thirds of the game and no more (#186, #245).
--
-- Those literals stay where they are, wrapped in `S(...)`, and the English
-- source doubles as the catalog key:
--
-- self:say(S("But, it failed!"))
-- self:say(S("Wild %s\nappeared!", self.enemy.name))
--
-- Keying on the source rather than on an invented id is deliberate. It
-- keeps the English readable at the point it is used (the alternative
-- scatters a thousand `battle.it_failed` ids that have to be looked up to
-- review a diff), it needs no id registry to stay in sync, and an entry a
-- translation has not covered yet falls through to English instead of
-- rendering a raw id at the player. The cost is that editing an English
-- string orphans its translations; `tools/modkit.py translation --refresh`
-- reports those as changed keys rather than silently dropping them.
--
-- Same-source-different-meaning is the one case source keys cannot hold on
-- their own ("OFF" as a filter setting vs "OFF" as a toggle, which some
-- languages render differently). Those sites pass a context:
--
-- S("OFF", "options.musicFilter") -- key: "options.musicFilter|OFF"
--
-- A mod supplies the catalog through the `strings` registry:
--
-- mod.content.strings:override("But, it failed!", "Echec !")
--
-- With no mod loaded the catalog is empty and `S` is an identity function
-- guarded by one boolean, so a vanilla boot draws byte-identical text.
local Strings = {}
local catalog = nil -- Data.strings once a mod has put something in it
local missing = {} -- format-arity complaints, reported once each
-- Called from Game after the mod merge, and again on dev-mode hot reload.
-- Holding the table (not a copy) means a mod that registers late still
-- takes effect without a second load.
function Strings.load(data)
local t = data and data.strings
catalog = nil
if type(t) ~= "table" then return end
for _ in pairs(t) do
catalog = t
return
end
end
function Strings.active()
return catalog ~= nil
end
-- The lookup itself. `context` is optional and only disambiguates sources
-- that collide; the plain key is tried after it, so a translation that does
-- not care about the distinction can supply one entry for both.
function Strings.lookup(source, context)
if not catalog then return source end
if context then
local hit = catalog[context .. "|" .. source]
if type(hit) == "string" then return hit end
end
local hit = catalog[source]
if type(hit) == "string" then return hit end
return source
end
-- Count `%`-directives so a translation that drops or adds one is caught
-- here rather than as a mid-battle `string.format` error.
local function specifiers(s)
local n = 0
for spec in s:gmatch("%%(.)") do
if spec ~= "%" then n = n + 1 end
end
return n
end
-- S(source) -> translated source
-- S(source, ...) -> translated source, string.format'ed
-- S(source, context) -> context-disambiguated lookup, no formatting
--
-- The two-argument forms are told apart by whether the source carries any
-- format directives: a source with no `%s` cannot be formatting, so a lone
-- string second argument is a context.
function Strings.get(source, ...)
local argc = select("#", ...)
if argc == 0 then return Strings.lookup(source) end
local wants = specifiers(source)
if wants == 0 and argc == 1 and type((...)) == "string" then
return Strings.lookup(source, (...))
end
local text = Strings.lookup(source)
-- A translation with the wrong arity would raise inside string.format,
-- which in a battle means a crash the player cannot escape. Fall back to
-- the English source, which is known to match, and say so once.
if specifiers(text) ~= wants then
if not missing[source] then
missing[source] = true
require("src.core.Logger").warn(
"strings: translation of %q has %d format directives, source has %d"
.. " -- using the source", source, specifiers(text), wants)
end
text = source
end
local ok, out = pcall(string.format, text, ...)
if not ok then return source end
return out
end
-- A marker, not a lookup: returns its argument untouched.
--
-- Some templates are declared in a module-level table and formatted much
-- later (BattleState's charge-move lines, for one). Translating at the
-- declaration would freeze the English, because those tables are built at
-- require time and Strings.load has no catalog yet; the use site therefore
-- calls Strings(template, ...) and looks the source up then. That works at
-- runtime but leaves the literal invisible to the catalog generator, which
-- only sees what is spelled out at a call site. Wrapping the declaration in
-- Strings.source puts it back in the harvest while changing nothing at all
-- about when the lookup happens.
function Strings.source(text)
return text
end
setmetatable(Strings, { __call = function(_, ...) return Strings.get(...) end })
return Strings
+12 -1
View File
@@ -5,8 +5,10 @@
-- --
-- Protocol -- main thread pushes command tables onto the "chipaudio_cmd" -- Protocol -- main thread pushes command tables onto the "chipaudio_cmd"
-- channel and drains produced buffers off "chipaudio_out": -- 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 = "stop" halt production
-- cmd = "channelMix" { volumes, pitches } per-hw volume/pitch
-- cmd = "invalidate" drop the bank cache -- cmd = "invalidate" drop the bank cache
-- cmd = "quit" end the thread -- cmd = "quit" end the thread
-- out buffers are tagged with the play's `gen` so the main thread can -- out buffers are tagged with the play's `gen` so the main thread can
@@ -45,6 +47,12 @@ local function handle(cmd)
engine = nil engine = nil
outCh:clear() -- drop any buffers left from the previous song outCh:clear() -- drop any buffers left from the previous song
data = { audio = cmd.audio } 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, local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header,
{ allowLoops = cmd.allowLoops }) { allowLoops = cmd.allowLoops })
if ok then if ok then
@@ -58,6 +66,9 @@ local function handle(cmd)
engine = nil engine = nil
finished = false finished = false
outCh:clear() 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 elseif cmd.cmd == "invalidate" then
ChipSynth.invalidateBanks() ChipSynth.invalidateBanks()
elseif cmd.cmd == "quit" then elseif cmd.cmd == "quit" then
+146 -14
View File
@@ -32,11 +32,11 @@ local CacheFs = {}
local SEP = package.config:sub(1, 1) local SEP = package.config:sub(1, 1)
-- Cache-relative paths are prefixed with this before every read/write, so a -- 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 -- Blue/Yellow import lands under its GameVersion.cachePrefix (blue/, yellow/)
-- keeps the historical root. The launcher sets it per import / per readiness -- while a Red import keeps the historical root. The launcher sets it per
-- check; it stays "" for Red. Runtime *reads* (require / newImage) do NOT go -- import / per readiness check; it stays "" for Red. Runtime *reads*
-- through here -- CacheFs.mountVersion overlays the active version's subtree -- (require / newImage) do NOT go through here -- CacheFs.mountVersion overlays
-- onto the un-prefixed paths instead. -- the active version's subtree onto the un-prefixed paths instead.
CacheFs.prefix = "" CacheFs.prefix = ""
local function withPrefix(rel) local function withPrefix(rel)
@@ -75,6 +75,32 @@ local function resolveMkdir()
return mkdirFn return mkdirFn
end end
-- Lazily-resolved windowless rmdir, the mirror of resolveMkdir above:
-- function(absolutePath) or false when FFI is unavailable. Both syscalls
-- refuse a non-empty directory, so a caller has to delete the files first.
local rmdirFn = nil
local function resolveRmdir()
if rmdirFn ~= nil then return rmdirFn end
rmdirFn = false
local ok, ffi = pcall(require, "ffi")
if not ok then return rmdirFn end
if ffi.os == "Windows" then
pcall(ffi.cdef, "int RemoveDirectoryA(const char *lpPathName);")
local resolved = pcall(function() return ffi.C.RemoveDirectoryA end)
if resolved then
rmdirFn = function(path) pcall(ffi.C.RemoveDirectoryA, path) end
end
else
pcall(ffi.cdef, "int rmdir(const char *pathname);")
local resolved = pcall(function() return ffi.C.rmdir end)
if resolved then
rmdirFn = function(path) pcall(ffi.C.rmdir, path) end
end
end
return rmdirFn
end
-- Mount an external directory onto the physfs read path (appended, so the -- Mount an external directory onto the physfs read path (appended, so the
-- game's own source always wins a name clash). Returns true on success. -- game's own source always wins a name clash). Returns true on success.
-- --
@@ -100,9 +126,9 @@ local function resolveMount()
if okl and lib then if okl and lib then
local oks, fn = pcall(function() return lib.PHYSFS_mount end) local oks, fn = pcall(function() return lib.PHYSFS_mount end)
if oks and fn then if oks and fn then
physfsMountFn = function(d, append) physfsMountFn = function(d, mountPoint, append)
if append == nil then append = true end 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 return okr and ret ~= 0
end end
break break
@@ -119,7 +145,38 @@ end
local function mountReadable(dir, append) local function mountReadable(dir, append)
local fn = resolveMount() local fn = resolveMount()
if not fn then return false end 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
-- CacheFs.unmountVersion needs it: the launcher can open the save editor on
-- one game's cache and then Play the other, and an overlay left mounted
-- would win the read path for the rest of the process.
local physfsUnmountFn = nil
local function resolveUnmount()
if physfsUnmountFn ~= nil then return physfsUnmountFn end
physfsUnmountFn = false
local ok, ffi = pcall(require, "ffi")
if not ok then return physfsUnmountFn end
pcall(ffi.cdef, "int PHYSFS_unmount(const char *oldDir);")
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_unmount end)
if oks and fn then
physfsUnmountFn = function(d)
local okr, ret = pcall(fn, d)
return okr and ret ~= 0
end
break
end
end
end
return physfsUnmountFn
end end
-- The portable game folder when the cache should live there, else nil. -- The portable game folder when the cache should live there, else nil.
@@ -229,6 +286,23 @@ function CacheFs.remove(rel)
love.filesystem.remove(rel) love.filesystem.remove(rel)
end end
-- Remove a single cache-relative directory once its files are gone. Needed
-- because os.remove cannot delete a directory on Windows and
-- love.filesystem.remove never reaches outside the save directory, so the
-- portable game folder gets the same FFI-syscall treatment as its mkdir
-- (issue #74: os.execute would flash a console window per call). Used by the
-- mod installer so an uninstall leaves nothing behind (#330).
function CacheFs.removeDir(rel)
rel = withPrefix(rel)
local root = CacheFs.root()
if root then
local rmdir = resolveRmdir()
if rmdir then rmdir(realPath(root, rel)) end
return
end
love.filesystem.remove(rel)
end
-- Remove the game-folder copy of a cache subtree before a fresh import, so a -- Remove the game-folder copy of a cache subtree before a fresh import, so a
-- cache-format bump does not leave orphaned files behind. No-op when the -- cache-format bump does not leave orphaned files behind. No-op when the
-- portable cache is inactive (the save-directory copy is cleared by -- portable cache is inactive (the save-directory copy is cleared by
@@ -256,16 +330,16 @@ end
-- Overlay the active version's extracted cache onto the un-prefixed read -- Overlay the active version's extracted cache onto the un-prefixed read
-- paths, so require("data.generated.*") and love.graphics.newImage( -- paths, so require("data.generated.*") and love.graphics.newImage(
-- "assets/generated/*") resolve to that version's files. Red lives at the -- "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 -- cache root and needs nothing; non-Red versions (blue/, yellow/, …) are
-- it wins over any Red copy at the root and over the game source. Called -- *prepended* so they win over any Red copy at the root and over the game
-- once at boot, before Game:load (main.lua). Returns true when nothing was -- source. Called once at boot, before Game:load (main.lua). Returns true
-- needed or the mount succeeded. -- when nothing was needed or the mount succeeded.
function CacheFs.mountVersion(version) function CacheFs.mountVersion(version)
local prefix = require("src.core.GameVersion").cachePrefix(version) local prefix = require("src.core.GameVersion").cachePrefix(version)
if prefix == "" then return true end -- Red: already at the root 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 -- 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() local base = CacheFs.root()
if not base and love.filesystem.getSaveDirectory then if not base and love.filesystem.getSaveDirectory then
base = love.filesystem.getSaveDirectory() base = love.filesystem.getSaveDirectory()
@@ -280,4 +354,62 @@ function CacheFs.mountVersion(version)
return false return false
end 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/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.
function CacheFs.unmountVersion(version)
local prefix = require("src.core.GameVersion").cachePrefix(version)
if prefix == "" then return true end
local sub = prefix:gsub("/+$", "")
local base = CacheFs.root()
if not base and love.filesystem.getSaveDirectory then
base = love.filesystem.getSaveDirectory()
end
local done = false
local fn = resolveUnmount()
if fn and base then
done = fn(base .. SEP .. sub) or done
end
-- also drop the love.filesystem.mount fallback, which registers the folder
-- under its bare name rather than its absolute path
if love.filesystem.unmount then
done = love.filesystem.unmount(sub) or done
end
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, or the
-- mount was refused), which callers must treat as "could not look", not as
-- "nothing there". An error inside fn still unmounts before it propagates.
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
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 return CacheFs
+345 -49
View File
@@ -163,8 +163,12 @@ function RomExtractor:extractTilesets()
for pos = offset, offset + 15 do block[#block + 1] = blocksRaw[pos] end for pos = offset, offset + 15 do block[#block + 1] = blocksRaw[pos] end
blocks[#blocks + 1] = block blocks[#blocks + 1] = block
end 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( local walkable = sorted(self:readTerminated(
0, collisionPointer, 0xFF)) collBank, collisionPointer, 0xFF))
local warpPointer = self.rom:word( local warpPointer = self.rom:word(
warpPointers.bank, warpPointers.address + (index - 1) * 2) warpPointers.bank, warpPointers.address + (index - 1) * 2)
local warpTiles = unique(self:readTerminated( local warpTiles = unique(self:readTerminated(
@@ -447,14 +451,26 @@ function RomExtractor:extractSprites()
local pointer = self.rom:word(pointerTable.bank, address) local pointer = self.rom:word(pointerTable.bank, address)
local firstHalf = self.rom:byte(pointerTable.bank, address + 2) local firstHalf = self.rom:byte(pointerTable.bank, address + 2)
local bank = self.rom:byte(pointerTable.bank, address + 3) local bank = self.rom:byte(pointerTable.bank, address + 3)
local byteLength = spec.imageWidth * spec.imageHeight / 4 local width = spec.imageWidth
local frames = spec.imageHeight / 16 local height = spec.imageHeight
local byteLength = width * height / 4
local frames = height / 16
local expected = firstHalf * (frames >= 6 and 2 or 1) 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 local base = spec.imageBase
if not written[base] then if not written[base] then
self:write2bpp(self.rom:bytes(bank, pointer, byteLength), self:write2bpp(self.rom:bytes(bank, pointer, byteLength),
spec.imageWidth, spec.imageHeight, width, height,
"sprites/" .. base .. ".png", true) "sprites/" .. base .. ".png", true)
written[base] = true written[base] = true
end end
@@ -862,20 +878,24 @@ function RomExtractor:extractPalettes()
local order = self.manifest.paletteOrder local order = self.manifest.paletteOrder
local paletteTable = self:symbol("SuperPalettes") local paletteTable = self:symbol("SuperPalettes")
local function scale5(value) return round(value * 255 / 31) end local function scale5(value) return round(value * 255 / 31) end
local palettes = {} local function readTable(symbol, names)
for index, name in ipairs(order) do local out = {}
local colors = {} for index, name in ipairs(names) do
for color = 0, 3 do local colors = {}
local value = self.rom:word(paletteTable.bank, for color = 0, 3 do
paletteTable.address + (index - 1) * 8 + color * 2) local value = self.rom:word(symbol.bank,
colors[#colors + 1] = { symbol.address + (index - 1) * 8 + color * 2)
scale5(bit.band(value, 0x1F)), colors[#colors + 1] = {
scale5(bit.band(bit.rshift(value, 5), 0x1F)), scale5(bit.band(value, 0x1F)),
scale5(bit.band(bit.rshift(value, 10), 0x1F)), scale5(bit.band(bit.rshift(value, 5), 0x1F)),
} scale5(bit.band(bit.rshift(value, 10), 0x1F)),
}
end
out[name] = colors
end end
palettes[name] = colors return out
end end
local palettes = readTable(paletteTable, order)
local monsterTable = self:symbol("MonsterPalettes") local monsterTable = self:symbol("MonsterPalettes")
local monsterPalettes = {} local monsterPalettes = {}
for index, species in ipairs(self.manifest.dexOrder) do for index, species in ipairs(self.manifest.dexOrder) do
@@ -887,6 +907,11 @@ function RomExtractor:extractPalettes()
source = "ROM:SuperPalettes + MonsterPalettes", source = "ROM:SuperPalettes + MonsterPalettes",
palettes = palettes, order = order, pokemon = 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:write("palettes", data)
self:tick("Color palettes", 1, 1) self:tick("Color palettes", 1, 1)
return data return data
@@ -915,6 +940,10 @@ function RomExtractor:extractIcons()
GRASS = "assets/generated/icons/plant.png", GRASS = "assets/generated/icons/plant.png",
SNAKE = "assets/generated/icons/snake.png", SNAKE = "assets/generated/icons/snake.png",
QUADRUPED = "assets/generated/icons/quadruped.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 = { local frames = {
{ "bug", "BugIconFrame1", "BugIconFrame2" }, { "bug", "BugIconFrame1", "BugIconFrame2" },
@@ -1048,7 +1077,9 @@ function RomExtractor:extractPokemon()
local typeById = self:typesById() local typeById = self:typesById()
local names = self:symbol("MonsterNames") local names = self:symbol("MonsterNames")
local baseStats = self:symbol("BaseStats") 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 = {} local decodedNames = {}
for index = 1, #speciesOrder do for index = 1, #speciesOrder do
decodedNames[index] = self.rom:decodeText( decodedNames[index] = self.rom:decodeText(
@@ -1067,7 +1098,7 @@ function RomExtractor:extractPokemon()
local dex = assert(dexBySpecies[species], local dex = assert(dexBySpecies[species],
"missing dex number for " .. species) "missing dex number for " .. species)
local row local row
if species == "MEW" then if species == "MEW" and mewStats then
row = self.rom:bytes(mewStats.bank, mewStats.address, 28) row = self.rom:bytes(mewStats.bank, mewStats.address, 28)
else else
row = self.rom:bytes( row = self.rom:bytes(
@@ -1410,6 +1441,142 @@ function RomExtractor:extractText()
} }
end 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) function RomExtractor:raw2bpp(label, width, height, relative, options)
options = options or {} options = options or {}
local expected = width * height / 4 local expected = width * height / 4
@@ -1454,6 +1621,8 @@ function RomExtractor:extractField()
"title/copyright.png"); tick() "title/copyright.png"); tick()
self:raw2bpp("GameFreakLogoGraphics", 72, 8, self:raw2bpp("GameFreakLogoGraphics", 72, 8,
"title/gamefreak_inc.png"); tick() "title/gamefreak_inc.png"); tick()
-- Yellow fixed Pikachu title art (no-op on Red/Blue manifests).
self:extractYellowTitleArt(); tick()
local fallingStar = self:raw2bpp( local fallingStar = self:raw2bpp(
"FallingStar", 8, 8, "intro/falling_star.png", "FallingStar", 8, 8, "intro/falling_star.png",
@@ -1502,35 +1671,71 @@ function RomExtractor:extractField()
end end
self:save(star, "intro/big_star.png"); tick() self:save(star, "intro/big_star.png"); tick()
local gengar = self:symbol("FightIntroBackMon") -- Yellow has no FightIntro Gengar/Nidorino fight (pret/pokeyellow
local gengarRaw = self.rom:bytes( -- engine/movie/intro_yellow.asm); write blank placeholders so Title/
gengar.bank, gengar.address, 96 * 16) -- Intro still find the expected paths. Red/Blue keep the tilemap rip.
local gengarTiles = {} if self.symbols["FightIntroBackMon"] then
for offset = 1, #gengarRaw, 16 do local gengar = self:symbol("FightIntroBackMon")
local raw = {} local gengarRaw = self.rom:bytes(
for index = offset, offset + 15 do raw[#raw + 1] = gengarRaw[index] end gengar.bank, gengar.address, 96 * 16)
gengarTiles[#gengarTiles + 1] = ImageWriter.decode2bpp(raw, 8, 8) local gengarTiles = {}
end for offset = 1, #gengarRaw, 16 do
for number = 1, 3 do local raw = {}
local tilemap = self:symbol("GengarIntroTiles" .. number) for index = offset, offset + 15 do raw[#raw + 1] = gengarRaw[index] end
local tileIds = self.rom:bytes(tilemap.bank, tilemap.address, 49) gengarTiles[#gengarTiles + 1] = ImageWriter.decode2bpp(raw, 8, 8)
local pose = ImageWriter.blank(56, 56, 0, 0, 0, 0) end
for index, tileId in ipairs(tileIds) do for number = 1, 3 do
ImageWriter.blit(pose, gengarTiles[tileId + 1], local tilemap = self:symbol("GengarIntroTiles" .. number)
(index - 1) % 7 * 8, math.floor((index - 1) / 7) * 8) 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 end
pose = ImageWriter.matteColor0(pose)
self:save(pose, "intro/gengar_" .. number .. ".png"); tick()
end end
for number, label in ipairs({ if self.symbols["FightIntroFrontMon"] then
"FightIntroFrontMon", "FightIntroFrontMon2", "FightIntroFrontMon3", for number, label in ipairs({
}) do "FightIntroFrontMon", "FightIntroFrontMon2", "FightIntroFrontMon3",
self:raw2bpp(label, 48, 48, }) do
"intro/red_nidorino_" .. number .. ".png", self:raw2bpp(label, 48, 48,
{ transparent = true, columns = true }) "intro/red_nidorino_" .. number .. ".png",
tick() { 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 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 for number = 1, 2 do
self:writeCompressedPic( self:writeCompressedPic(
"ShrinkPic" .. number, "intro/shrink" .. number .. ".png") "ShrinkPic" .. number, "intro/shrink" .. number .. ".png")
@@ -1563,10 +1768,27 @@ function RomExtractor:extractField()
end end
self:save(symbolSheet, "slots/symbols.png"); tick() self:save(symbolSheet, "slots/symbols.png"); tick()
local emotes = ImageWriter.blank(48, 16, 1, 1, 1, 0) -- Emote sheet layout comes from manifest.field.emotionBubbles so the
for index, label in ipairs({ -- versions can differ: Red ships the three shared bubbles, Yellow adds
"ShockEmote", "QuestionEmote", "HappyEmote", -- the five Pikachu-only ones (emotion_bubbles.asm Skull/Heart/Bolt/
}) do -- 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 symbol = self:symbol(label)
local image = ImageWriter.decode2bpp( local image = ImageWriter.decode2bpp(
self.rom:bytes(symbol.bank, symbol.address, 64), 16, 16, true) self.rom:bytes(symbol.bank, symbol.address, 64), 16, 16, true)
@@ -1574,6 +1796,26 @@ function RomExtractor:extractField()
end end
self:save(emotes, "emotes.png"); tick() 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, self:raw1bpp("LedgeHoppingShadow", 8, 8,
"fx/shadow.png", true); tick() "fx/shadow.png", true); tick()
for _, spec in ipairs({ for _, spec in ipairs({
@@ -1664,7 +1906,9 @@ end
function RomExtractor:extractAudio() function RomExtractor:extractAudio()
self:beginStage("Sound programs") self:beginStage("Sound programs")
local metadata = copy(self.manifest.audio) 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 = {} local chunks = {}
for index, bank in ipairs(bankOrder) do for index, bank in ipairs(bankOrder) do
local first = Rom.offset(bank, 0x4000) + 1 local first = Rom.offset(bank, 0x4000) + 1
@@ -1683,6 +1927,7 @@ function RomExtractor:extractAudio()
for name, header in pairs(metadata.musicHeaders) do for name, header in pairs(metadata.musicHeaders) do
songs[name] = header songs[name] = header
end end
metadata.pikaCries = self:extractPikachuCries()
local cries = {} local cries = {}
local cryData = metadata.cryData local cryData = metadata.cryData
for index, species in ipairs(self.manifest.constants.speciesOrder) do for index, species in ipairs(self.manifest.constants.speciesOrder) do
@@ -1709,6 +1954,57 @@ function RomExtractor:extractAudio()
return metadata return metadata
end 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() function RomExtractor:run()
local results = {} local results = {}
results.constants = self:extractConstants() results.constants = self:extractConstants()
+803 -156
View File
File diff suppressed because it is too large Load Diff
+103 -47
View File
@@ -12,6 +12,7 @@
local Pokemon = require("src.pokemon.Pokemon") local Pokemon = require("src.pokemon.Pokemon")
local Flags = require("src.script.Flags") local Flags = require("src.script.Flags")
local Strings = require("src.core.Strings")
local ItemEffects = {} local ItemEffects = {}
@@ -46,6 +47,17 @@ ItemEffects.BALLS = BALLS
function ItemEffects.isBall(id) return BALLS[id] or false end function ItemEffects.isBall(id) return BALLS[id] or false end
function ItemEffects.isStone(id) return STONES[id] or false end function ItemEffects.isStone(id) return STONES[id] or false end
-- Does using this item take item_effects.asm's .healHP path, the one that
-- plays SFX_HEAL_HP and lengthens the party HP bar with UpdateHPBar2 before
-- the message (item_effects.asm .doneHealing)? The status-only cures branch
-- to .playStatusAilmentCuringSound instead and never touch the bar. BagMenu
-- keeps the party picker open for these so the fill has something to draw
-- on (#252).
function ItemEffects.healsHP(id)
return HEAL_AMOUNT[id] ~= nil or id == "MAX_POTION" or id == "FULL_RESTORE"
or id == "REVIVE" or id == "MAX_REVIVE"
end
-- Does this item need a party-member target? -- Does this item need a party-member target?
function ItemEffects.needsTarget(id, itemDef) function ItemEffects.needsTarget(id, itemDef)
return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION" return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION"
@@ -119,8 +131,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP" if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
or itemId == "RARE_CANDY" or itemId == "COIN_CASE" or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
or (itemDef and itemDef.machine)) then or (itemDef and itemDef.machine)) then
return "failed", { "OAK: " .. save.player.name return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!",
.. "!\nThis isn't the\ntime to use that!" } save.player.name) }
end end
if BALLS[itemId] then if BALLS[itemId] then
@@ -138,11 +150,12 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local mapId, npc = adjacentSleepingSnorlax(save, ow) local mapId, npc = adjacentSleepingSnorlax(save, ow)
if npc then if npc then
return "flute_wake", { data.text._PlayedFluteHadEffectText return "flute_wake", { data.text._PlayedFluteHadEffectText
or "{PLAYER} played the\nPOKé FLUTE." }, { mapId = mapId, npc = npc } or Strings("{PLAYER} played the\nPOKé FLUTE.") },
{ mapId = mapId, npc = npc }
end end
-- otherwise: play the tune, nothing happens (ItemUsePokeFlute's -- otherwise: play the tune, nothing happens (ItemUsePokeFlute's
-- PlayedFluteNoEffectText branch) -- PlayedFluteNoEffectText branch)
return "flute_field", { "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!" } return "flute_field", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
end end
local woke = false local woke = false
local function wake(mon) local function wake(mon)
@@ -157,24 +170,30 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- WakeUpEntireParty runs on the enemy's bench too -- WakeUpEntireParty runs on the enemy's bench too
for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end
if not woke then if not woke then
return "failed", { "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!" } return "failed", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
end end
return "flute", { ("%s played the\nPOKé FLUTE."):format(save.player.name), return "flute", { Strings("%s played the\nPOKé FLUTE.", save.player.name),
"All sleeping\nPOKéMON woke up!" } Strings("All sleeping\nPOKéMON woke up!") }
end end
-- battle-only items -- battle-only items
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC" if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
or itemId == "POKE_DOLL" then or itemId == "POKE_DOLL" then
if not battle then if not battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end end
local b = battle.player 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 if itemId == "X_ACCURACY" then
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss -- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
-- (not an accuracy stage) -- (not an accuracy stage)
b.xAccuracy = true b.xAccuracy = true
return "consumed", { ("%s's\nhits will never\nmiss!"):format(b.name) } return "consumed", { Strings("%s's\nhits will never\nmiss!", b.name) }
end end
if X_ITEMS[itemId] then if X_ITEMS[itemId] then
local stat = X_ITEMS[itemId] local stat = X_ITEMS[itemId]
@@ -183,28 +202,28 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- effect, so at +6 it is still consumed and StatModifierUpEffect -- effect, so at +6 it is still consumed and StatModifierUpEffect
-- just prints "Nothing happened!" -- just prints "Nothing happened!"
if cur >= 6 then if cur >= 6 then
return "consumed", { "Nothing happened!" } return "consumed", { Strings("Nothing happened!") }
end end
b.stages[stat] = cur + 1 b.stages[stat] = cur + 1
return "consumed", { ("%s's\n%s rose!"):format(b.name, stat:upper()) } return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) }
end end
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume -- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
-- the item, even when it is already active -- the item, even when it is already active
if itemId == "DIRE_HIT" then if itemId == "DIRE_HIT" then
b.focusEnergy = true b.focusEnergy = true
return "consumed", { ("%s's\ngetting pumped!"):format(b.name) } return "consumed", { Strings("%s's\ngetting pumped!", b.name) }
end end
if itemId == "GUARD_SPEC" then if itemId == "GUARD_SPEC" then
b.mist = true b.mist = true
return "consumed", { ("%s's\nprotected against\nstat changes!"):format(b.name) } return "consumed", { Strings("%s's\nprotected against\nstat changes!", b.name) }
end end
if itemId == "POKE_DOLL" then if itemId == "POKE_DOLL" then
if battle.kind ~= "wild" then if battle.kind ~= "wild" then
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles -- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
return "failed", { "OAK: " .. save.player.name return "failed", { Strings(
.. "!\nThis isn't the\ntime to use that!" } "OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end end
return "consumed_escape", { "The wild POKéMON\nran away!" } return "consumed_escape", { Strings("The wild POKéMON\nran away!") }
end end
end end
@@ -213,7 +232,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- restore every move with no menu. -- restore every move with no menu.
if itemId == "ETHER" or itemId == "MAX_ETHER" if itemId == "ETHER" or itemId == "MAX_ETHER"
or itemId == "ELIXER" or itemId == "MAX_ELIXER" then or itemId == "ELIXER" or itemId == "MAX_ELIXER" then
if not target then return "failed", { "It won't have\nany effect." } end if not target then return "failed", { Strings("It won't have\nany effect.") } end
local restored = false local restored = false
local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER" local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER"
local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER" local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER"
@@ -235,9 +254,21 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
restored = mv and restore(mv) or false restored = mv and restore(mv) or false
end end
if not restored then if not restored then
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
return "consumed", { ("%s's PP\nwas restored!"):format(monName(data, target)) } 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 end
local heal = HEAL_AMOUNT[itemId] local heal = HEAL_AMOUNT[itemId]
@@ -250,49 +281,57 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
target.status = nil target.status = nil
cureActiveToxic(battle, target) cureActiveToxic(battle, target)
require("src.core.Sound").play(data, "Heal_Ailment") require("src.core.Sound").play(data, "Heal_Ailment")
return "consumed", { ("%s's\nstatus returned\nto normal!"):format(monName(data, target)) } return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
end end
if not target or target.hp <= 0 or target.hp >= target.stats.hp then if not target or target.hp <= 0 or target.hp >= target.stats.hp then
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
-- wHPBarOldHP: the bar animation starts from the HP the mon had BEFORE
-- the item landed (item_effects.asm latches it with the party menu still
-- up), so latch it here and hand it back as extra.healedFrom for the
-- party-menu fill (#252)
local before = target.hp
if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
target.hp = target.stats.hp target.hp = target.stats.hp
else else
target.hp = math.min(target.stats.hp, target.hp + heal) target.hp = math.min(target.stats.hp, target.hp + heal)
end end
local msgs = { ("%s's HP\nwas restored!"):format(monName(data, target)) } local msgs = { Strings("%s's HP\nwas restored!", monName(data, target)) }
if itemId == "FULL_RESTORE" then if itemId == "FULL_RESTORE" then
target.status = nil target.status = nil
cureActiveToxic(battle, target) cureActiveToxic(battle, target)
end end
require("src.core.Sound").play(data, "Heal_HP") require("src.core.Sound").play(data, "Heal_HP")
return "consumed", msgs return "consumed", msgs, { healedFrom = before }
end end
local cures = STATUS_HEAL[itemId] local cures = STATUS_HEAL[itemId]
if cures then if cures then
if not target or not target.status or not cures[target.status] then if not target or not target.status or not cures[target.status] then
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
target.status = nil target.status = nil
cureActiveToxic(battle, target) cureActiveToxic(battle, target)
require("src.core.Sound").play(data, "Heal_Ailment") require("src.core.Sound").play(data, "Heal_Ailment")
return "consumed", { ("%s's\nstatus returned\nto normal!"):format(monName(data, target)) } return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
end end
if itemId == "REVIVE" or itemId == "MAX_REVIVE" then if itemId == "REVIVE" or itemId == "MAX_REVIVE" then
if not target or target.hp > 0 then if not target or target.hp > 0 then
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
target.status = nil target.status = nil
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
require("src.core.Sound").play(data, "Heal_HP") require("src.core.Sound").play(data, "Heal_HP")
return "consumed", { ("%s\nis revitalized!"):format(monName(data, target)) } -- a revive takes the same .healHP -> .doneHealing route, animating up
-- from the fainted mon's 0 HP (#252)
return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) },
{ healedFrom = 0 }
end end
if itemId == "RARE_CANDY" then if itemId == "RARE_CANDY" then
if not target or target.level >= 100 then if not target or target.level >= 100 then
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
local Growth = require("src.pokemon.Growth") local Growth = require("src.pokemon.Growth")
local Stats = require("src.pokemon.Stats") local Stats = require("src.pokemon.Stats")
@@ -302,56 +341,73 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local old = target.stats local old = target.stats
target.stats = Stats.calc(speciesDef, target.level, target.dvs, target.statExp) 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)) target.hp = math.min(target.stats.hp, target.hp + (target.stats.hp - old.hp))
return "consumed", { ("%s grew\nto level %d!"):format(monName(data, target), target.level) }, -- 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 } { leveledTo = target.level }
end end
if STONES[itemId] then if STONES[itemId] then
if not target then return "failed", { "It won't have\nany effect." } end 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] local speciesDef = data.pokemon[target.species]
for _, evo in ipairs(speciesDef.evolutions) do for _, evo in ipairs(speciesDef.evolutions) do
if evo.method == "ITEM" and evo.item == itemId then if evo.method == "ITEM" and evo.item == itemId then
return "consumed", nil, { evolveTo = evo.species } return "consumed", nil, { evolveTo = evo.species }
end end
end end
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
-- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin, -- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin,
-- engine/items/item_effects.asm) -- engine/items/item_effects.asm)
local vitaminStat = VITAMINS[itemId] local vitaminStat = VITAMINS[itemId]
if vitaminStat then if vitaminStat then
if not target then return "failed", { "It won't have\nany effect." } end if not target then return "failed", { Strings("It won't have\nany effect.") } end
target.statExp = target.statExp or {} target.statExp = target.statExp or {}
local cur = target.statExp[vitaminStat] or 0 local cur = target.statExp[vitaminStat] or 0
if cur >= 25600 then if cur >= 25600 then
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
target.statExp[vitaminStat] = math.min(65535, cur + 2560) target.statExp[vitaminStat] = math.min(65535, cur + 2560)
local Stats = require("src.pokemon.Stats") local Stats = require("src.pokemon.Stats")
target.stats = Stats.calc(data.pokemon[target.species], target.level, target.stats = Stats.calc(data.pokemon[target.species], target.level,
target.dvs, target.statExp) target.dvs, target.statExp)
target.hp = math.min(target.hp, target.stats.hp) target.hp = math.min(target.hp, target.stats.hp)
return "consumed", { ("%s's %s\nrose!"):format(monName(data, target), return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
vitaminStat == "hp" and "HP" or vitaminStat:upper()) } vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
end end
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu) -- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
if itemId == "PP_UP" then if itemId == "PP_UP" then
if not target then return "failed", { "It won't have\nany effect." } end if not target then return "failed", { Strings("It won't have\nany effect.") } end
local mv = target.moves[moveIndex or 1] local mv = target.moves[moveIndex or 1]
local mdef = mv and data.moves[mv.id] local mdef = mv and data.moves[mv.id]
if mdef and (mv.ppUps or 0) < 3 then if mdef and (mv.ppUps or 0) < 3 then
mv.ppUps = (mv.ppUps or 0) + 1 mv.ppUps = (mv.ppUps or 0) + 1
-- each PP UP adds maxPP/5 uses on top of the base maximum -- each PP UP adds maxPP/5 uses on top of the base maximum
mv.pp = mv.pp + math.floor(mdef.pp / 5) mv.pp = mv.pp + math.floor(mdef.pp / 5)
return "consumed", { ("%s's PP\nincreased!"):format(mdef.name) } return "consumed", { Strings("%s's PP\nincreased!", mdef.name) }
end end
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
if itemDef and itemDef.machine then if itemDef and itemDef.machine then
if not target then return "failed", { "It won't have\nany effect." } end if not target then return "failed", { Strings("It won't have\nany effect.") } end
local speciesDef = data.pokemon[target.species] local speciesDef = data.pokemon[target.species]
local ok = false local ok = false
for _, m in ipairs(speciesDef.tmhm) do for _, m in ipairs(speciesDef.tmhm) do
@@ -362,11 +418,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic -- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic
-- ItemUseNotTime/NoCyclingAllowedHere paths are silent) -- ItemUseNotTime/NoCyclingAllowedHere paths are silent)
require("src.core.Sound").play(data, "Denied") require("src.core.Sound").play(data, "Denied")
return "failed", { ("%s can't\nlearn that move!"):format(monName(data, target)) } return "failed", { Strings("%s can't\nlearn that move!", monName(data, target)) }
end end
for _, mv in ipairs(target.moves) do for _, mv in ipairs(target.moves) do
if mv.id == itemDef.machine.move then if mv.id == itemDef.machine.move then
return "failed", { "It knows that\nmove already!" } return "failed", { Strings("It knows that\nmove already!") }
end end
end end
-- HMs are never consumed; TMs are single-use -- HMs are never consumed; TMs are single-use
@@ -375,14 +431,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then if itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then
if battle then if battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end end
return "fish", itemId return "fish", itemId
end end
if itemId == "BICYCLE" then if itemId == "BICYCLE" then
if battle then if battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end end
return "bicycle" return "bicycle"
end end
@@ -392,26 +448,26 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
end end
if itemId == "TOWN_MAP" then if itemId == "TOWN_MAP" then
if battle then if battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end end
return "townmap" return "townmap"
end end
if itemId == "ITEMFINDER" then if itemId == "ITEMFINDER" then
if battle then if battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end end
return "itemfinder" return "itemfinder"
end end
if itemId == "COIN_CASE" then if itemId == "COIN_CASE" then
return "failed", { ("Coin count:\n%d"):format(save.coins or 0) } return "failed", { Strings("Coin count:\n%d", save.coins or 0) }
end end
if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250 local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
save.repelSteps = steps save.repelSteps = steps
return "consumed", { ("%s used\n%s!"):format(save.player.name, name) } return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
end end
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end end
return ItemEffects return ItemEffects
+88 -28
View File
@@ -21,6 +21,7 @@ local Logger = require("src.core.Logger")
local Protocol = require("src.link.Protocol") local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local TurnOrder = require("src.battle.TurnOrder") local TurnOrder = require("src.battle.TurnOrder")
local Strings = require("src.core.Strings")
local LinkBattle = {} local LinkBattle = {}
@@ -111,6 +112,19 @@ local function scalar(v)
return tostring(v) return tostring(v)
end end
-- A volatile slot is "off" three interchangeable ways -- absent, false, or
-- a counter sitting at 0 -- because every reader tests `if b.key then` or
-- `key > 0`. They have to hash the same, or a match ends over a difference
-- that does not exist. They routinely disagree: the menu-phase flinch
-- clear (BattleState:update) and the FIGHT-branch boundTurns mirror
-- (fightLockedAction) are written by whichever machine is sitting at ITS
-- OWN menu, and in a link battle that is a different battler on each side,
-- so one peer holds flinched=false / boundTurns=0 where the other still
-- holds nil with two identical simulations underneath.
local function off(v)
return v == nil or v == false or v == 0
end
local function stageStr(b) local function stageStr(b)
local out = {} local out = {}
for i, stat in ipairs(STAGES) do for i, stat in ipairs(STAGES) do
@@ -130,7 +144,7 @@ end
local function volStr(b) local function volStr(b)
local out = {} local out = {}
for _, key in ipairs(VOLATILE) do for _, key in ipairs(VOLATILE) do
if b[key] ~= nil then if not off(b[key]) then
out[#out + 1] = key .. "=" .. scalar(b[key]) out[#out + 1] = key .. "=" .. scalar(b[key])
end end
end end
@@ -167,6 +181,17 @@ end
local PARTS = { "actives", "volatile", "bench" } local PARTS = { "actives", "volatile", "bench" }
-- Which components are allowed to end a match. `actives` and `bench` carry
-- what decides one -- species, HP, status, stat stages, PP, the rest of the
-- party -- so a divergence there is a real split between the two
-- simulations and stays a draw. `volatile` is per-turn bookkeeping that
-- both sides recompute from the authoritative state every turn (see `off`
-- above): it can disagree for a turn without either side being wrong, and
-- when it does mean something real it lands in `actives` as damage or
-- status within a turn or two, where it is caught. Ending a match on it
-- alone cost players games they were winning, over nothing.
local FATAL_PART = { actives = true, bench = true }
-- opts: { myParty = packed, theirParty = packed, theirName, role = -- opts: { myParty = packed, theirParty = packed, theirName, role =
-- "host"/"guest", seed, verdict, strict }. Returns nil plus a reason when -- "host"/"guest", seed, verdict, strict }. Returns nil plus a reason when
-- the handshake says the two link surfaces don't match: a lockstep -- the handshake says the two link surfaces don't match: a lockstep
@@ -177,18 +202,18 @@ function LinkBattle.new(game, net, opts)
local theirName = opts.theirName or "FOE" local theirName = opts.theirName or "FOE"
if not Handshake.battleAllowed(opts.verdict) then if not Handshake.battleAllowed(opts.verdict) then
return nil, "Link battle needs\nthe same mods on\nboth games." return nil, Strings("Link battle needs\nthe same mods on\nboth games.")
end end
-- both parties pass through the same pack->unpack clamp on both -- both parties pass through the same pack->unpack clamp on both
-- machines, so the copies are identical everywhere -- machines, so the copies are identical everywhere
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel } local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p) local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p)
return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species)) return Strings("Your %s can't\nbattle on the\nother game.", tostring(p.species))
end) end)
if not myParty then return nil, myErr end if not myParty then return nil, myErr end
local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why) local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why)
return ("Their %s isn't\nin this game.\n(%s)"):format(tostring(p.species), tostring(why)) return Strings("Their %s isn't\nin this game.\n(%s)", tostring(p.species), tostring(why))
end) end)
if not theirParty then return nil, theirErr end if not theirParty then return nil, theirErr end
if #myParty == 0 or #theirParty == 0 then if #myParty == 0 or #theirParty == 0 then
@@ -232,7 +257,7 @@ function LinkBattle.new(game, net, opts)
self.enemyParty = theirParty self.enemyParty = theirParty
self.playerParty = myParty -- intro ball row uses the clamped copies self.playerParty = myParty -- intro ball row uses the clamped copies
self.opponentName = theirName self.opponentName = theirName
self.introText = ("%s wants\nto battle!"):format(theirName) self.introText = Strings("%s wants\nto battle!", theirName)
self.remoteHashes = {} self.remoteHashes = {}
self.localHashes = {} self.localHashes = {}
self.remoteParts = {} self.remoteParts = {}
@@ -292,7 +317,7 @@ function LinkBattle.new(game, net, opts)
battle = s, side = s.sides[2], battler = s.enemy, previous = previous, battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
}) })
s.enemySendingOut = true s.enemySendingOut = true
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name)) s:sayNext(Strings("%s sent\nout %s!", theirName, s.enemy.name))
s:actNext(function() s:actNext(function()
s.enemySendingOut = false s.enemySendingOut = false
s:startGrowIn(s.enemy) s:startGrowIn(s.enemy)
@@ -314,9 +339,21 @@ function LinkBattle.new(game, net, opts)
Logger.warn("link: desync turn %s component=%s (%s vs %s)", Logger.warn("link: desync turn %s component=%s (%s vs %s)",
tostring(turn), component, tostring(localH), tostring(remoteH)) tostring(turn), component, tostring(localH), tostring(remoteH))
Runtime.emit("link.desync", { turn = turn, component = component, Runtime.emit("link.desync", { turn = turn, component = component,
localHash = localH, remoteHash = remoteH }) localHash = localH, remoteHash = remoteH,
endAsDraw(s, ("Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?") fatal = true })
:format(component)) endAsDraw(s, Strings(
"Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?",
component))
end
-- a non-fatal component split: both sides log it and carry on, so the
-- match is decided by the battle rather than by bookkeeping
local function noteDrift(s, turn, component, localH, remoteH)
Logger.warn("link: %s drift on turn %s (%s vs %s) -- match continues",
component, tostring(turn), tostring(localH), tostring(remoteH))
Runtime.emit("link.desync", { turn = turn, component = component,
localHash = localH, remoteHash = remoteH,
fatal = false })
end end
-- a verified turn stays recorded: consuming it here left a finished -- a verified turn stays recorded: consuming it here left a finished
@@ -331,8 +368,11 @@ function LinkBattle.new(game, net, opts)
if mine and theirs then if mine and theirs then
for _, component in ipairs(PARTS) do for _, component in ipairs(PARTS) do
if mine[component] ~= theirs[component] then if mine[component] ~= theirs[component] then
reportDesync(s, turn, component, mine[component], theirs[component]) if FATAL_PART[component] then
return reportDesync(s, turn, component, mine[component], theirs[component])
return
end
noteDrift(s, turn, component, mine[component], theirs[component])
end end
end end
end end
@@ -349,7 +389,7 @@ function LinkBattle.new(game, net, opts)
local function resolveLockstep(s, myMsg, theirMsg) local function resolveLockstep(s, myMsg, theirMsg)
if myMsg.kind == "run" or theirMsg.kind == "run" then if myMsg.kind == "run" or theirMsg.kind == "run" then
local who = myMsg.kind == "run" and game.save.player.name or theirName local who = myMsg.kind == "run" and game.save.player.name or theirName
endAsDraw(s, ("%s ran from\nthe battle!"):format(who)) endAsDraw(s, Strings("%s ran from\nthe battle!", who))
return return
end end
s.phase = "messages" s.phase = "messages"
@@ -479,9 +519,9 @@ function LinkBattle.new(game, net, opts)
party = myParty, party = myParty,
onSwitch = function(mon) onSwitch = function(mon)
if mon == s.player.mon then if mon == s.player.mon then
s:say(("%s is\nalready out!"):format(s.player.name)) s:say(Strings("%s is\nalready out!", s.player.name))
elseif mon.hp <= 0 then elseif mon.hp <= 0 then
s:say("There's no will\nto fight!") s:say(Strings("There's no will\nto fight!"))
else else
s:resolveSwitch(mon) s:resolveSwitch(mon)
end end
@@ -491,7 +531,7 @@ function LinkBattle.new(game, net, opts)
end end
self.openItems = function(s) self.openItems = function(s)
s:say("Items can't be\nused in a link\nbattle!") s:say(Strings("Items can't be\nused in a link\nbattle!"))
s.phase = "messages" s.phase = "messages"
s.afterQueue = "menu" s.afterQueue = "menu"
end end
@@ -509,7 +549,7 @@ function LinkBattle.new(game, net, opts)
return return
end end
end end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(game.save.player.name, s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", game.save.player.name,
theirName)) theirName))
s.result = "lose" s.result = "lose"
s.afterQueue = "finish" s.afterQueue = "finish"
@@ -522,7 +562,7 @@ function LinkBattle.new(game, net, opts)
return return
end end
end end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(theirName, s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", theirName,
game.save.player.name)) game.save.player.name))
s.result = "win" s.result = "win"
s.afterQueue = "finish" s.afterQueue = "finish"
@@ -543,13 +583,13 @@ function LinkBattle.new(game, net, opts)
-- only a draw if our own simulation hasn't already decided -- only a draw if our own simulation hasn't already decided
-- (the winner's bye can arrive while we're still animating) -- (the winner's bye can arrive while we're still animating)
if not s.result then if not s.result then
endAsDraw(s, ("%s left the\nbattle."):format(theirName)) endAsDraw(s, Strings("%s left the\nbattle.", theirName))
end end
elseif msg.type == "forfeit" then elseif msg.type == "forfeit" then
-- the peer's own shot clock ran out; unlike a mutual RUN/desync -- the peer's own shot clock ran out; unlike a mutual RUN/desync
-- draw, this has a definite winner (us) -- draw, this has a definite winner (us)
if not s.result then if not s.result then
endWithResult(s, "win", ("%s ran out of\ntime!"):format(theirName)) endWithResult(s, "win", Strings("%s ran out of\ntime!", theirName))
end end
else else
-- a tournament control message (bracket_update, the next -- a tournament control message (bracket_update, the next
@@ -563,10 +603,19 @@ function LinkBattle.new(game, net, opts)
if net.closed and not s.linkEnded and not s.result then if net.closed and not s.linkEnded and not s.result then
endAsDraw(s) endAsDraw(s)
end end
-- Both early returns below skip baseUpdate, which is where the
-- presentational clock normally advances -- so tick it here, in the same
-- order baseUpdate would (fx, then the queue). Without this an
-- animation caught mid-flight froze for the whole wait: a flash stopped
-- on its inverted BGP step and repainted the UI in inverted shades, and
-- a pic part-way through a slide or grow-in stayed off screen, which is
-- the "the screen went inverted" / "a Pokemon just vanished" pair.
if s.phase == "waitRemote" then if s.phase == "waitRemote" then
s:tickFx()
return -- the other side is still choosing return -- the other side is still choosing
end end
if s.phase == "messages" and s.afterQueue == "linkNext" then if s.phase == "messages" and s.afterQueue == "linkNext" then
s:tickFx()
if not s:updateQueue() then if not s:updateQueue() then
s.afterQueue = "menu" s.afterQueue = "menu"
s.phase = "menu" s.phase = "menu"
@@ -582,7 +631,7 @@ function LinkBattle.new(game, net, opts)
if s.turnClock <= 0 then if s.turnClock <= 0 then
s.turnClockActive = false s.turnClockActive = false
send({ type = "forfeit" }) send({ type = "forfeit" })
endWithResult(s, "lose", "Time's up! You\nforfeit the match.") endWithResult(s, "lose", Strings("Time's up! You\nforfeit the match."))
end end
elseif opts.turnLimit then elseif opts.turnLimit then
s.turnClockActive = false s.turnClockActive = false
@@ -643,17 +692,16 @@ function LinkBattle.newSpectator(game, net, opts)
local guestName = opts.guestName or "GUEST" local guestName = opts.guestName or "GUEST"
if not Handshake.battleAllowed(opts.verdict) then if not Handshake.battleAllowed(opts.verdict) then
return nil, "Link battle needs\nthe same mods on\nboth games." return nil, Strings("Link battle needs\nthe same mods on\nboth games.")
end end
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel } local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p) local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p)
return ("%s's %s can't\nbattle on this\ngame."):format(hostName, tostring(p.species)) return Strings("%s's %s can't\nbattle on this\ngame.", hostName, tostring(p.species))
end) end)
if not hostParty then return nil, hostErr end if not hostParty then return nil, hostErr end
local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why) local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why)
return ("%s's %s can't\nbattle on this\ngame.\n(%s)"):format( return Strings("%s's %s can't\nbattle on this\ngame.\n(%s)", guestName, tostring(p.species), tostring(why))
guestName, tostring(p.species), tostring(why))
end) end)
if not guestParty then return nil, guestErr end if not guestParty then return nil, guestErr end
if #hostParty == 0 or #guestParty == 0 then if #hostParty == 0 or #guestParty == 0 then
@@ -672,7 +720,7 @@ function LinkBattle.newSpectator(game, net, opts)
self.enemyParty = guestParty self.enemyParty = guestParty
self.playerParty = hostParty self.playerParty = hostParty
self.opponentName = guestName self.opponentName = guestName
self.introText = ("%s vs %s!"):format(hostName, guestName) self.introText = Strings("%s vs %s!", hostName, guestName)
local function orderMove(action) local function orderMove(action)
if action and action.id then return game.data.moves[action.id] end if action and action.id then return game.data.moves[action.id] end
@@ -712,7 +760,7 @@ function LinkBattle.newSpectator(game, net, opts)
battle = s, side = s.sides[2], battler = s.enemy, previous = previous, battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
}) })
s.enemySendingOut = true s.enemySendingOut = true
s:sayNext(("%s sent\nout %s!"):format(guestName, s.enemy.name)) s:sayNext(Strings("%s sent\nout %s!", guestName, s.enemy.name))
s:actNext(function() s:actNext(function()
s.enemySendingOut = false s.enemySendingOut = false
s:startGrowIn(s.enemy) s:startGrowIn(s.enemy)
@@ -741,6 +789,13 @@ function LinkBattle.newSpectator(game, net, opts)
end end
s:act(function() s:act(function()
-- the two real players cleared their flinch flags when their move
-- menu opened; a spectator has no menu, so it does it here instead,
-- at the same point in the turn (see BattleState:clearTurnFlinches).
-- Without this a flinch survived into the next turn and ate a move
-- that landed in the real match, and the replay -- sharing the RNG
-- stream -- was watching a different battle from that point on.
s:clearTurnFlinches()
local hostAction = hostMsg.kind ~= "switch" and hostMsg.kind ~= "run" local hostAction = hostMsg.kind ~= "switch" and hostMsg.kind ~= "run"
and decodeWireAction(s, hostMsg, s.player) or nil and decodeWireAction(s, hostMsg, s.player) or nil
local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run" local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run"
@@ -789,7 +844,7 @@ function LinkBattle.newSpectator(game, net, opts)
return return
end end
end end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(hostName, guestName)) s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", hostName, guestName))
s.result = "guestWin" s.result = "guestWin"
s.afterQueue = "finish" s.afterQueue = "finish"
end end
@@ -801,7 +856,7 @@ function LinkBattle.newSpectator(game, net, opts)
return return
end end
end end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(guestName, hostName)) s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", guestName, hostName))
s.result = "hostWin" s.result = "hostWin"
s.afterQueue = "finish" s.afterQueue = "finish"
end end
@@ -836,7 +891,11 @@ function LinkBattle.newSpectator(game, net, opts)
if net.closed and not s.linkEnded and not s.result then if net.closed and not s.linkEnded and not s.result then
endSpectate(s) endSpectate(s)
end end
-- same as the real-participant loop: every path that skips baseUpdate
-- still has to advance the presentational clock, and a spectator sits in
-- waitBoth between every single turn
if s.phase == "messages" and s.afterQueue == "linkNext" then if s.phase == "messages" and s.afterQueue == "linkNext" then
s:tickFx()
if not s:updateQueue() then if not s:updateQueue() then
s.afterQueue = "waitBoth" s.afterQueue = "waitBoth"
s.phase = "waitBoth" s.phase = "waitBoth"
@@ -844,6 +903,7 @@ function LinkBattle.newSpectator(game, net, opts)
return return
end end
if s.phase == "menu" or s.phase == "waitBoth" then if s.phase == "menu" or s.phase == "waitBoth" then
s:tickFx()
return -- frozen between resolved turns; never a real decision here return -- frozen between resolved turns; never a real decision here
end end
baseUpdate(s, dt) baseUpdate(s, dt)
+91 -45
View File
@@ -11,6 +11,7 @@ local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local LinkState = {} local LinkState = {}
LinkState.__index = LinkState LinkState.__index = LinkState
@@ -72,6 +73,9 @@ end
function LinkState.new(game) function LinkState.new(game)
local self = setmetatable({}, LinkState) local self = setmetatable({}, LinkState)
self.game = game self.game = game
-- a link session runs at 1X on both machines whatever either player set
-- GAME SPEED to (see Game:logicSpeed); cleared in exitWith
game.linkSession = true
self.stage = "menu" self.stage = "menu"
self.index = 1 self.index = 1
self.addr = ipDigits(Net.lanIP()) self.addr = ipDigits(Net.lanIP())
@@ -90,13 +94,14 @@ function LinkState.newJoinOnline(game, code)
self.stage = "onlineJoining" self.stage = "onlineJoining"
else else
self.stage = "menu" -- exitWith below needs a real stage to unwind from self.stage = "menu" -- exitWith below needs a real stage to unwind from
self:exitWith("Link error:\n" .. (self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end end
return self return self
end end
function LinkState:exitWith(message, reason) function LinkState:exitWith(message, reason)
DiscordPresence.setJoinCode(nil) DiscordPresence.setJoinCode(nil)
self.game.linkSession = nil -- back to the player's own GAME SPEED
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") }) Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
if self.net then self.net:close() end if self.net then self.net:close() end
self.game.stack:pop() self.game.stack:pop()
@@ -105,6 +110,46 @@ function LinkState:exitWith(message, reason)
end end
end end
-- Online play meets strangers, so it requires vanilla on both ends
-- (Handshake.onlineAllowed). Mods merge into the shared Data registries at
-- boot and there is no unmerge, so switching them off has to go through a
-- relaunch -- but the player should not have to go find the mod manager and
-- work out which mods count. This turns every enabled mod off, records
-- them so the mod manager can put them back, and relaunches. The restart
-- is confirmed rather than silent: it drops unsaved progress.
function LinkState:offerVanillaRestart()
local game = self.game
local loader = game.mods
local mods = Handshake.mods(game)
local names = {}
for i, mod in ipairs(mods) do
if i > 2 then break end
names[#names + 1] = tostring(mod.id):upper():sub(1, 12)
end
local list = table.concat(names, ", ")
if #mods > #names then list = list .. (" +%d"):format(#mods - #names) end
local text = Strings(
"Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?", list)
self.game.linkSession = nil
Runtime.emit("link.ended", { reason = "error" })
if self.net then self.net:close() end
game.stack:pop()
game.stack:push(TextBox.new(game, text, nil, { choice = function(yes)
if not yes then return end
-- setEnabled persists the toggle itself (Loader:_saveState), so the
-- relaunch comes up vanilla and the mod manager lists them as disabled
-- for the player to switch back on afterwards
for _, mod in ipairs(mods) do
if loader and loader.setEnabled then loader:setEnabled(mod.id, false) end
end
if game.restartWithMods then
game:restartWithMods()
elseif love.event and love.event.quit then
love.event.quit("restart")
end
end }))
end
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
-- handshake v2 (D8): both peers announce engine version, api version and -- handshake v2 (D8): both peers announce engine version, api version and
-- a fingerprint of their link surface, and the verdict comes from the two -- a fingerprint of their link surface, and the verdict comes from the two
@@ -174,7 +219,7 @@ function LinkState:update(dt)
if self.net then if self.net then
self.net:update() self.net:update()
if self.net.error and not PRE_CONNECT_STAGES[self.stage] then if self.net.error and not PRE_CONNECT_STAGES[self.stage] then
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60)) self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
return return
end end
-- the peer vanished without a bye (only once the inbox is drained, -- the peer vanished without a bye (only once the inbox is drained,
@@ -183,7 +228,7 @@ function LinkState:update(dt)
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry" and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
and self.stage ~= "codeEntry" and self.stage ~= "notice" and self.stage ~= "codeEntry" and self.stage ~= "notice"
and self.stage ~= "battleRunning" then and self.stage ~= "battleRunning" then
self:exitWith("The link was\nbroken.") self:exitWith(Strings("The link was\nbroken."))
return return
end end
end end
@@ -201,7 +246,7 @@ function LinkState:update(dt)
self.index = 1 self.index = 1
elseif self.index == 2 or self.index == 3 then elseif self.index == 2 or self.index == 3 then
if not Handshake.onlineAllowed(self.game) then if not Handshake.onlineAllowed(self.game) then
self:exitWith("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS.") self:offerVanillaRestart()
return return
end end
if self.index == 2 then if self.index == 2 then
@@ -227,7 +272,7 @@ function LinkState:update(dt)
if self.net:host() then if self.net:host() then
self.stage = "hosting" self.stage = "hosting"
else else
self:exitWith("Link error:\n" .. (self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end end
else else
self.stage = "addrEntry" self.stage = "addrEntry"
@@ -246,7 +291,7 @@ function LinkState:update(dt)
if self.net:hostOnline() then if self.net:hostOnline() then
self.stage = "onlineHosting" self.stage = "onlineHosting"
else else
self:exitWith("Link error:\n" .. (self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end end
else else
self.stage = "codeEntry" self.stage = "codeEntry"
@@ -284,7 +329,7 @@ function LinkState:update(dt)
if self.net:joinOnline(nil, code) then if self.net:joinOnline(nil, code) then
self.stage = "onlineJoining" self.stage = "onlineJoining"
else else
self:exitWith("Link error:\n" .. (self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end end
end end
@@ -323,7 +368,7 @@ function LinkState:update(dt)
if self.net:join(table.concat(octets, ".")) then if self.net:join(table.concat(octets, ".")) then
self.stage = "joining" self.stage = "joining"
else else
self:exitWith("Link error:\n" .. (self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end end
end end
@@ -433,7 +478,7 @@ function LinkState:update(dt)
end end
if not battle then if not battle then
self.net:send({ type = "bye" }) self.net:send({ type = "bye" })
self:exitWith(why or "Link battle\ncan't start.", "error") self:exitWith(why or Strings("Link battle\ncan't start."), "error")
return return
end end
self.game.stack:push(battle) self.game.stack:push(battle)
@@ -491,8 +536,8 @@ function LinkState:updateTrade(input)
local t = self.trade local t = self.trade
if t.stage == "cancelled" then if t.stage == "cancelled" then
self:exitWith(t.error and ("The trade stopped:\n%s."):format(t.error) self:exitWith(t.error and Strings("The trade stopped:\n%s.", t.error)
or "The trade was\ncancelled.") or Strings("The trade was\ncancelled."))
return return
end end
if t.stage == "done" then if t.stage == "done" then
@@ -508,6 +553,7 @@ function LinkState:updateTrade(input)
if self.game.writeSave then self.game:writeSave() end if self.game.writeSave then self.game:writeSave() end
local name = received.nickname or self.game.data.pokemon[received.species].name local name = received.nickname or self.game.data.pokemon[received.species].name
Runtime.emit("link.ended", { reason = "done" }) Runtime.emit("link.ended", { reason = "done" })
self.game.linkSession = nil -- this path pops without exitWith
self.net:close() self.net:close()
self.game.stack:pop() self.game.stack:pop()
local game = self.game local game = self.game
@@ -520,7 +566,7 @@ function LinkState:updateTrade(input)
enemyOtId = received.otId, enemyOtId = received.otId,
onDone = function() onDone = function()
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name), Strings("Trade completed!\f%s received\n%s!", game.save.player.name, name),
function() function()
if evoTo then if evoTo then
-- via="TRADE": a trade evolution cannot be B-cancelled -- via="TRADE": a trade evolution cannot be B-cancelled
@@ -547,7 +593,7 @@ function LinkState:updateTrade(input)
-- the trade) -- B is dead after that, matching the A branch's own -- the trade) -- B is dead after that, matching the A branch's own
-- self.confirmed == nil guard -- self.confirmed == nil guard
self.net:send({ type = "bye" }) self.net:send({ type = "bye" })
self:exitWith("The trade was\ncancelled.") self:exitWith(Strings("The trade was\ncancelled."))
elseif t.stage == "picking" and input:wasPressed("a") then elseif t.stage == "picking" and input:wasPressed("a") then
if t:canPick(self.index) then if t:canPick(self.index) then
self.net:send(t:pick(self.index)) self.net:send(t:pick(self.index))
@@ -574,30 +620,30 @@ end
function LinkState:draw() function LinkState:draw()
if self.stage == "menu" then if self.stage == "menu" then
drawTitle("BOIS CLUB LIVE") drawTitle("BOIS CLUB LIVE")
Font.draw("LINK CABLE (LAN)", 32, 44) Font.draw(Strings("LINK CABLE (LAN)"), 32, 44)
Font.draw("ONLINE MATCH", 32, 60) Font.draw(Strings("ONLINE MATCH"), 32, 60)
Font.draw("TOURNAMENT", 32, 76) Font.draw(Strings("TOURNAMENT"), 32, 76)
Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16) Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16)
elseif self.stage == "lanMenu" then elseif self.stage == "lanMenu" then
drawTitle("LINK CABLE (LAN)") drawTitle("LINK CABLE (LAN)")
Font.draw("HOST A GAME", 32, 48) Font.draw(Strings("HOST A GAME"), 32, 48)
Font.draw("JOIN A GAME", 32, 68) Font.draw(Strings("JOIN A GAME"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
Font.draw("UDP port " .. Net.defaultPort(), 8, 128) Font.draw(Strings("UDP port %s", Net.defaultPort()), 8, 128)
elseif self.stage == "onlineMenu" then elseif self.stage == "onlineMenu" then
drawTitle("ONLINE MATCH") drawTitle("ONLINE MATCH")
Font.draw("HOST ONLINE", 32, 48) Font.draw(Strings("HOST ONLINE"), 32, 48)
Font.draw("JOIN ONLINE", 32, 68) Font.draw(Strings("JOIN ONLINE"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "onlineHosting" then elseif self.stage == "onlineHosting" then
drawTitle("HOSTING ONLINE") drawTitle("HOSTING ONLINE")
Font.draw("Tell your friend", 16, 40) Font.draw(Strings("Tell your friend"), 16, 40)
Font.draw("the code:", 16, 52) Font.draw(Strings("the code:"), 16, 52)
Font.draw(self.net.code or "??????", 32, 68) Font.draw(self.net.code or "??????", 32, 68)
Font.draw("Waiting for join...", 8, 96) Font.draw(Strings("Waiting for join..."), 8, 96)
elseif self.stage == "codeEntry" then elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE") drawTitle("ENTER CODE")
@@ -609,18 +655,18 @@ function LinkState:draw()
Font.drawCode(0xEE, x, 76) -- ▼ under the active slot Font.drawCode(0xEE, x, 76) -- ▼ under the active slot
end end
end end
Font.draw("A: connect B: back", 8, 128) Font.draw(Strings("A: connect B: back"), 8, 128)
elseif self.stage == "onlineJoining" then elseif self.stage == "onlineJoining" then
drawTitle("CONNECTING...") drawTitle("CONNECTING...")
Font.draw("Calling...", 8, 56) Font.draw(Strings("Calling..."), 8, 56)
Font.draw(self.net.target or "", 8, 72) Font.draw(self.net.target or "", 8, 72)
elseif self.stage == "hosting" then elseif self.stage == "hosting" then
drawTitle("HOSTING") drawTitle("HOSTING")
Font.draw("Friend joins at:", 16, 48) Font.draw(Strings("Friend joins at:"), 16, 48)
Font.draw(self.net.address or "?", 16, 64) Font.draw(self.net.address or "?", 16, 64)
Font.draw("Waiting for join...", 8, 96) Font.draw(Strings("Waiting for join..."), 8, 96)
elseif self.stage == "addrEntry" then elseif self.stage == "addrEntry" then
drawTitle("ENTER HOST ADDRESS") drawTitle("ENTER HOST ADDRESS")
@@ -635,34 +681,34 @@ function LinkState:draw()
for octet = 1, 3 do for octet = 1, 3 do
Font.draw(".", 16 + octet * 32 - 8, 64) Font.draw(".", 16 + octet * 32 - 8, 64)
end end
Font.draw("Port: " .. Net.defaultPort(), 16, 96) Font.draw(Strings("Port: %s", Net.defaultPort()), 16, 96)
Font.draw("A: connect B: back", 8, 128) Font.draw(Strings("A: connect B: back"), 8, 128)
elseif self.stage == "joining" then elseif self.stage == "joining" then
drawTitle("JOINING...") drawTitle("JOINING...")
Font.draw("Calling...", 8, 56) Font.draw(Strings("Calling..."), 8, 56)
Font.draw(self.net.target or "", 8, 72) Font.draw(self.net.target or "", 8, 72)
elseif self.stage == "modeSelect" then elseif self.stage == "modeSelect" then
drawTitle("CONNECTED!") drawTitle("CONNECTED!")
Font.draw("TRADE", 32, 48) Font.draw(Strings("TRADE"), 32, 48)
Font.draw("BATTLE", 32, 68) Font.draw(Strings("BATTLE"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "battleOptions" then elseif self.stage == "battleOptions" then
drawTitle("BATTLE OPTIONS") drawTitle("BATTLE OPTIONS")
Font.draw("LEVELS:", 16, 56) Font.draw(Strings("LEVELS:"), 16, 56)
Font.draw(forceLevelLabel(self.levelChoice or ANY), 88, 56) Font.draw(forceLevelLabel(self.levelChoice or ANY), 88, 56)
Font.draw("A: continue B: back", 8, 128) Font.draw(Strings("A: continue B: back"), 8, 128)
elseif self.stage == "waitMode" or self.stage == "waitHello" then elseif self.stage == "waitMode" or self.stage == "waitHello" then
drawTitle("CONNECTED!") drawTitle("CONNECTED!")
if self.stage == "waitHello" then if self.stage == "waitHello" then
Font.draw("Checking the", 16, 56) Font.draw(Strings("Checking the"), 16, 56)
Font.draw("other game...", 16, 72) Font.draw(Strings("other game..."), 16, 72)
else else
Font.draw("Waiting for the", 16, 56) Font.draw(Strings("Waiting for the"), 16, 56)
Font.draw("host to choose...", 16, 72) Font.draw(Strings("host to choose..."), 16, 72)
end end
elseif self.stage == "notice" then elseif self.stage == "notice" then
@@ -671,12 +717,12 @@ function LinkState:draw()
if i > 8 then break end -- what fits above the prompt row if i > 8 then break end -- what fits above the prompt row
Font.draw(line, 8, 24 + (i - 1) * 12) Font.draw(line, 8, 24 + (i - 1) * 12)
end end
Font.draw(self.noticeExits and "A: back" or "A: trade anyway", 8, 128) Font.draw(self.noticeExits and "A: back" or Strings("A: trade anyway"), 8, 128)
elseif self.stage == "trade" then elseif self.stage == "trade" then
drawTitle("TRADE") drawTitle("TRADE")
local t = self.trade local t = self.trade
Font.draw("YOURS", 8, 20) Font.draw(Strings("YOURS"), 8, 20)
for i, mon in ipairs(self.game.save.party) do for i, mon in ipairs(self.game.save.party) do
local def = self.game.data.pokemon[mon.species] local def = self.game.data.pokemon[mon.species]
local label = (mon.nickname or def.name):sub(1, 8) local label = (mon.nickname or def.name):sub(1, 8)
@@ -684,7 +730,7 @@ function LinkState:draw()
Font.draw(label, 16, 20 + i * 12) Font.draw(label, 16, 20 + i * 12)
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
end end
Font.draw("THEIRS", 84, 20) Font.draw(Strings("THEIRS"), 84, 20)
for i, mon in ipairs(t.theirParty or {}) do for i, mon in ipairs(t.theirParty or {}) do
local def = self.game.data.pokemon[mon.species] local def = self.game.data.pokemon[mon.species]
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12) Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
@@ -695,16 +741,16 @@ function LinkState:draw()
elseif t.stage == "waitParty" then hint = "Exchanging data..." elseif t.stage == "waitParty" then hint = "Exchanging data..."
elseif t.stage == "picking" then elseif t.stage == "picking" then
hint = t:canPick(self.index) and "Pick one to trade" hint = t:canPick(self.index) and "Pick one to trade"
or "X: not on theirs" or Strings("X: not on theirs")
elseif t.stage == "waitPick" then hint = "Waiting for them..." elseif t.stage == "waitPick" then hint = "Waiting for them..."
elseif t.stage == "confirming" then elseif t.stage == "confirming" then
hint = self.confirmed and "Waiting..." or "A: trade B: cancel" hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
end end
Font.draw(hint or "", 8, 132) Font.draw(hint or "", 8, 132)
elseif self.stage == "battleWait" or self.stage == "battleRunning" then elseif self.stage == "battleWait" or self.stage == "battleRunning" then
drawTitle("LINK BATTLE") drawTitle("LINK BATTLE")
Font.draw("Exchanging data...", 16, 64) Font.draw(Strings("Exchanging data..."), 16, 64)
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+13 -9
View File
@@ -26,6 +26,7 @@
local Json = require("src.link.Json") local Json = require("src.link.Json")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Strings = require("src.core.Strings")
local hasEnet, enet = pcall(require, "enet") local hasEnet, enet = pcall(require, "enet")
if not hasEnet then enet = nil end if not hasEnet then enet = nil end
@@ -173,7 +174,7 @@ function Net:connectTCP(addr)
tcp:settimeout(5) tcp:settimeout(5)
local ok, err = tcp:connect(host, port) local ok, err = tcp:connect(host, port)
if not ok then if not ok then
self.error = ("can't reach relay %s:%d\n(%s)"):format(host, port, tostring(err)) self.error = Strings("can't reach relay %s:%d\n(%s)", host, port, tostring(err))
return false return false
end end
tcp:settimeout(0) tcp:settimeout(0)
@@ -236,11 +237,14 @@ local function handleGenericRelayControl(self, msg)
self.paired = true self.paired = true
return true return true
elseif msg.type == "join_error" then elseif msg.type == "join_error" then
self.error = ({ self.error = Strings(({
not_found = "That code wasn't\nfound.", not_found = Strings.source("That code wasn't\nfound."),
full = "That game already\nhas two players.", full = Strings.source("That game already\nhas two players."),
expired = "That code has\nexpired.", expired = Strings.source("That code has\nexpired."),
})[msg.reason] or ("Couldn't join:\n%s"):format(tostring(msg.reason)) })[msg.reason] or "")
if self.error == "" then
self.error = Strings("Couldn't join:\n%s", tostring(msg.reason))
end
self.closed = true self.closed = true
return true return true
elseif msg.type == "peer_gone" then elseif msg.type == "peer_gone" then
@@ -328,7 +332,7 @@ function Net:update()
-- an unreachable join target surfaces as a service error -- an unreachable join target surfaces as a service error
-- (ICMP unreachable on the connected UDP socket) -- (ICMP unreachable on the connected UDP socket)
if self.mode == "joining" and not self.paired then if self.mode == "joining" and not self.paired then
self.error = ("no answer from\n%s"):format(self.target or "the host") self.error = Strings("no answer from\n%s", self.target or "the host")
else else
self.error = tostring(event) self.error = tostring(event)
end end
@@ -358,14 +362,14 @@ function Net:update()
self.closed = true self.closed = true
if not self.paired then if not self.paired then
self.error = self.error or self.error = self.error or
("no answer from\n%s"):format(self.target or "the host") Strings("no answer from\n%s", self.target or "the host")
end end
end end
end end
end end
if self.mode == "joining" and not self.paired if self.mode == "joining" and not self.paired
and self.joinDeadline and now() > self.joinDeadline then and self.joinDeadline and now() > self.joinDeadline then
self.error = ("no answer from\n%s"):format(self.target or "the host") self.error = Strings("no answer from\n%s", self.target or "the host")
self.closed = true self.closed = true
pcall(function() self.peer:disconnect_now() end) pcall(function() self.peer:disconnect_now() end)
end end
+6
View File
@@ -382,6 +382,12 @@ function TradeSession:apply(game)
Runtime.emit("pokemon.received", Runtime.emit("pokemon.received",
{ mon = received, from = "link", peerName = self.peerName }) { mon = received, from = "link", peerName = self.peerName })
self.party[self.myPick] = received 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 if game and game.save.pokedex then
game.save.pokedex.seen[received.species] = true game.save.pokedex.seen[received.species] = true
game.save.pokedex.owned[received.species] = true game.save.pokedex.owned[received.species] = true
+41 -35
View File
@@ -18,6 +18,7 @@ local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local Tournament = {} local Tournament = {}
Tournament.__index = Tournament Tournament.__index = Tournament
@@ -90,6 +91,11 @@ function Tournament.new(game)
self.settingsIndex = 1 self.settingsIndex = 1
self.roster = {} self.roster = {}
self.spectatorRoster = {} self.spectatorRoster = {}
-- everything from here to exitWith runs at 1X regardless of the GAME
-- SPEED option (see Game:logicSpeed): a tournament's shot clock counts
-- down on the logic step, so fast-forward would hand one player less
-- real time to choose than the opponent they are racing
game.linkSession = true
Sound.startLoop(game.data, MUSIC) Sound.startLoop(game.data, MUSIC)
return self return self
end end
@@ -112,6 +118,7 @@ end
function Tournament:exitWith(message) function Tournament:exitWith(message)
DiscordPresence.setJoinCode(nil) DiscordPresence.setJoinCode(nil)
self.game.linkSession = nil -- back to the player's own GAME SPEED
Sound.stopLoop(MUSIC) Sound.stopLoop(MUSIC)
Runtime.emit("link.ended", { reason = message and "error" or "bye" }) Runtime.emit("link.ended", { reason = message and "error" or "bye" })
if self.net then self.net:close() end if self.net then self.net:close() end
@@ -128,7 +135,7 @@ end
function Tournament:startHosting() function Tournament:startHosting()
self.net = Net.new() self.net = Net.new()
if not self.net:connectTCP(Net.defaultRelayAddress()) then if not self.net:connectTCP(Net.defaultRelayAddress()) then
self:exitWith("Link error:\n" .. (self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
return return
end end
local size, minL, maxL = partyStats(self.game.save.party) local size, minL, maxL = partyStats(self.game.save.party)
@@ -151,7 +158,7 @@ end
function Tournament:startJoining(code) function Tournament:startJoining(code)
self.net = Net.new() self.net = Net.new()
if not self.net:connectTCP(Net.defaultRelayAddress()) then if not self.net:connectTCP(Net.defaultRelayAddress()) then
self:exitWith("Link error:\n" .. (self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
return return
end end
local size, minL, maxL = partyStats(self.game.save.party) local size, minL, maxL = partyStats(self.game.save.party)
@@ -171,9 +178,9 @@ end
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
local JOIN_ERROR_TEXT = { local JOIN_ERROR_TEXT = {
not_found = "That code wasn't\nfound.", not_found = Strings.source("That code wasn't\nfound."),
already_started = "That tournament\nhas already begun.", already_started = Strings.source("That tournament\nhas already begun."),
expired = "That code has\nexpired.", expired = Strings.source("That code has\nexpired."),
} }
function Tournament:handleMessage(msg) function Tournament:handleMessage(msg)
@@ -198,17 +205,17 @@ function Tournament:handleMessage(msg)
end end
elseif msg.type == "tournament_host_error" then elseif msg.type == "tournament_host_error" then
if msg.reason == "party_ineligible" then if msg.reason == "party_ineligible" then
self:exitWith(("Can't host:\nneed %d Pokemon\nLv %s-%s."):format( self:exitWith(Strings("Can't host:\nneed %d Pokemon\nLv %s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
else else
self:exitWith("Couldn't host\nthat tournament.") self:exitWith(Strings("Couldn't host\nthat tournament."))
end end
elseif msg.type == "tournament_join_error" then elseif msg.type == "tournament_join_error" then
if msg.reason == "party_ineligible" then if msg.reason == "party_ineligible" then
self:exitWith(("Your party needs\n%d Pokemon, Lv\n%s-%s."):format( self:exitWith(Strings("Your party needs\n%d Pokemon, Lv\n%s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
else else
self:exitWith(JOIN_ERROR_TEXT[msg.reason] or "Couldn't join\nthat tournament.") self:exitWith(JOIN_ERROR_TEXT[msg.reason]
and Strings(JOIN_ERROR_TEXT[msg.reason])
or Strings("Couldn't join\nthat tournament."))
end end
elseif msg.type == "tournament_roster" then elseif msg.type == "tournament_roster" then
self.roster = msg.players self.roster = msg.players
@@ -281,7 +288,7 @@ function Tournament:beginMatchBattle()
-- shouldn't happen (both sides already passed the online-play mods -- shouldn't happen (both sides already passed the online-play mods
-- gate), but a mismatched engine/build is still possible -- bail out -- gate), but a mismatched engine/build is still possible -- bail out
-- of just this match rather than crash the tournament -- of just this match rather than crash the tournament
self:exitWith("Link error:\nversion mismatch\nwith opponent.") self:exitWith(Strings("Link error:\nversion mismatch\nwith opponent."))
return return
end end
self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil
@@ -351,11 +358,11 @@ function Tournament:update(dt)
self.net:update() self.net:update()
if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings" if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings"
and self.stage ~= "codeEntry" then and self.stage ~= "codeEntry" then
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60)) self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
return return
end end
if self.net.closed and self.stage ~= "done" then if self.net.closed and self.stage ~= "done" then
self:exitWith("The tournament\nconnection was\nlost.") self:exitWith(Strings("The tournament\nconnection was\nlost."))
return return
end end
end end
@@ -381,7 +388,7 @@ function Tournament:update(dt)
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts) and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts) or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
if not battle then if not battle then
self:exitWith(why or "Link battle\ncan't start.") self:exitWith(why or Strings("Link battle\ncan't start."))
return return
end end
-- anything after `party` in this same batch belongs to the -- anything after `party` in this same batch belongs to the
@@ -418,7 +425,7 @@ function Tournament:update(dt)
forceLevel = levelForWire(self.settings.forceLevel), forceLevel = levelForWire(self.settings.forceLevel),
}) })
if not battle then if not battle then
self:exitWith(why or "Can't watch this\nmatch.") self:exitWith(why or Strings("Can't watch this\nmatch."))
return return
end end
for j = #msgs, i + 1, -1 do for j = #msgs, i + 1, -1 do
@@ -541,8 +548,8 @@ local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "LEVELS", "PLA
function Tournament:draw() function Tournament:draw()
if self.stage == "menu" then if self.stage == "menu" then
drawTitle("TOURNAMENT") drawTitle("TOURNAMENT")
Font.draw("HOST", 32, 48) Font.draw(Strings("HOST"), 32, 48)
Font.draw("JOIN", 32, 68) Font.draw(Strings("JOIN"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "hostSettings" then elseif self.stage == "hostSettings" then
@@ -561,7 +568,7 @@ function Tournament:draw()
Font.draw(values[i], 96, y) Font.draw(values[i], 96, y)
if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end
end end
Font.draw("START: create", 8, 128) Font.draw(Strings("START: create"), 8, 128)
elseif self.stage == "codeEntry" then elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE") drawTitle("ENTER CODE")
@@ -573,28 +580,27 @@ function Tournament:draw()
Font.drawCode(0xEE, x, 76) Font.drawCode(0xEE, x, 76)
end end
end end
Font.draw("A: join B: back", 8, 128) Font.draw(Strings("A: join B: back"), 8, 128)
elseif self.stage == "registering" then elseif self.stage == "registering" then
drawTitle("CONNECTING...") drawTitle("CONNECTING...")
Font.draw("B: cancel", 8, 128) Font.draw(Strings("B: cancel"), 8, 128)
elseif self.stage == "bracket" or self.stage == "matchHello" elseif self.stage == "bracket" or self.stage == "matchHello"
or self.stage == "matchWaitParty" or self.stage == "spectateWait" then or self.stage == "matchWaitParty" or self.stage == "spectateWait" then
drawTitle(("TOURNAMENT %s"):format(self.code or "??????")) drawTitle(Strings("TOURNAMENT %s", self.code or "??????"))
if self.bracket then if self.bracket then
local y = 20 local y = 20
for _, round in ipairs(self.bracket.rounds) do for _, round in ipairs(self.bracket.rounds) do
Font.draw(("ROUND %d"):format(round.round), 8, y) Font.draw(Strings("ROUND %d", round.round), 8, y)
y = y + 10 y = y + 10
for _, m in ipairs(round.matches) do for _, m in ipairs(round.matches) do
local line local line
if m.bye then if m.bye then
line = ("%s (bye)"):format(m.a or m.b or "?") line = Strings("%s (bye)", m.a or m.b or "?")
else else
local mark = m.state == "live" and "*" or (m.winner and "" or "") local mark = m.state == "live" and "*" or (m.winner and "" or "")
line = ("%s%s vs %s%s"):format( line = Strings("%s%s vs %s%s", m.winner == m.a and ">" or " ", m.a or "?",
m.winner == m.a and ">" or " ", m.a or "?",
m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or "")) m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or ""))
end end
Font.draw(line, 12, y) Font.draw(line, 12, y)
@@ -604,11 +610,11 @@ function Tournament:draw()
end end
else else
if self.participating == false then if self.participating == false then
Font.draw("(organizing --", 16, 32) Font.draw(Strings("(organizing --"), 16, 32)
Font.draw("not playing)", 16, 42) Font.draw(Strings("not playing)"), 16, 42)
end end
Font.draw("Waiting for", 16, 48) Font.draw(Strings("Waiting for"), 16, 48)
Font.draw("players to join:", 16, 60) Font.draw(Strings("players to join:"), 16, 60)
local y = 60 local y = 60
for i, name in ipairs(self.roster) do for i, name in ipairs(self.roster) do
y = 60 + i * 10 y = 60 + i * 10
@@ -620,18 +626,18 @@ function Tournament:draw()
end end
end end
if self.isCreator and #self.roster >= 2 and not self.bracket then if self.isCreator and #self.roster >= 2 and not self.bracket then
Font.draw("A: START B: cancel", 8, 132) Font.draw(Strings("A: START B: cancel"), 8, 132)
else else
Font.draw("B: cancel", 8, 132) Font.draw(Strings("B: cancel"), 8, 132)
end end
elseif self.stage == "done" then elseif self.stage == "done" then
drawTitle("TOURNAMENT OVER") drawTitle("TOURNAMENT OVER")
if self.champion then if self.champion then
Font.draw(("%s is the"):format(self.champion), 16, 56) Font.draw(Strings("%s is the", self.champion), 16, 56)
Font.draw("champion!", 16, 68) Font.draw(Strings("champion!"), 16, 68)
end end
Font.draw("A: continue", 8, 128) Font.draw(Strings("A: continue"), 8, 128)
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+196 -13
View File
@@ -3,18 +3,39 @@
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real -- manifests only. The full loader (src/mods/Loader.lua) still owns the real
-- load at boot; this reads the same options.mods enable-state the loader -- load at boot; this reads the same options.mods enable-state the loader
-- writes, derives per-mod status with the pure ManagerState.resolveToggle, -- writes, derives per-mod status with the pure ManagerState.resolveToggle,
-- installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree, and -- installs a dropped/chosen .zip into a "mods/<id>/" tree, and uninstalls a
-- uninstalls a mod by removing that tree + clearing options.mods[id]. -- mod by removing that tree + clearing options.mods[id].
-- --
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and -- Where that tree lives is CacheFs's call, not love.filesystem's: a portable
-- no filesystem, so the engine tier can table-drive it; the discovery, -- install (portable.txt beside the executable) keeps its mods in the game
-- install, and uninstall paths reach for love.filesystem and SaveData. -- folder like everything else it owns, and only the OS save directory
-- otherwise (#330 -- love.filesystem.write always resolves to the save dir,
-- so the installer used to strand every mod in appdata). Reads stay on
-- love.filesystem: the portable folder is on the physfs read path either way
-- (it IS the source for a `love <gamedir>` run, and CacheFs mounts it for a
-- fused build), which is why those mods still loaded while landing in the
-- wrong place.
--
-- 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 Manifest = require("src.mods.Manifest")
local ManagerState = require("src.mods.ManagerState") local ManagerState = require("src.mods.ManagerState")
local Semver = require("src.mods.Semver") local Semver = require("src.mods.Semver")
local Version = require("src.core.Version") local Version = require("src.core.Version")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local CacheFs = require("src.import.CacheFs")
local LauncherMods = {} local LauncherMods = {}
@@ -125,6 +146,28 @@ function LauncherMods.locateRoot(paths)
return nil, "no manifest.json found in the .zip" return nil, "no manifest.json found in the .zip"
end 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
-- ------- discovery (love.filesystem) -- ------- discovery (love.filesystem)
local function decodeManifest(raw, path) local function decodeManifest(raw, path)
@@ -143,6 +186,13 @@ local function discover()
local fs = love and love.filesystem local fs = love and love.filesystem
local out = {} local out = {}
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
-- A fused portable build keeps its mods in the game folder next to the
-- executable; resolving the cache root is what mounts that folder onto the
-- physfs read path, so this is what makes those mods enumerable at all
-- (#330). A source run needs nothing (the game folder IS the source), and
-- the launcher's readiness check has usually resolved it already; the call
-- is cached and idempotent.
CacheFs.root()
if not fs.getInfo("mods") then return out end if not fs.getInfo("mods") then return out end
local seen = {} local seen = {}
for _, name in ipairs(fs.getDirectoryItems("mods")) do for _, name in ipairs(fs.getDirectoryItems("mods")) do
@@ -233,11 +283,14 @@ local function topLevelPaths(mount)
return paths return paths
end end
-- Copy the mounted archive subtree at `src` to the install path `dst`. Reads
-- come from love.filesystem (the .zip is mounted there); every write goes
-- through CacheFs so it lands in the portable game folder when portable.txt is
-- in play and in the OS save directory otherwise (#330). No explicit mkdir:
-- CacheFs.write creates the parent chain on both paths, which also means an
-- empty folder inside the .zip is simply not carried over (it holds nothing).
local function copyTree(src, dst) local function copyTree(src, dst)
local fs = love.filesystem local fs = love.filesystem
if not fs.createDirectory(dst) then
return nil, "could not create " .. dst
end
for _, name in ipairs(fs.getDirectoryItems(src)) do for _, name in ipairs(fs.getDirectoryItems(src)) do
local s = src .. "/" .. name local s = src .. "/" .. name
local d = dst .. "/" .. name local d = dst .. "/" .. name
@@ -248,13 +301,18 @@ local function copyTree(src, dst)
else else
local data = fs.read(s) local data = fs.read(s)
if data == nil then return nil, "could not read " .. name end if data == nil then return nil, "could not read " .. name end
local ok, err = fs.write(d, data) local ok, err = CacheFs.write(d, data)
if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end
end end
end end
return true return true
end end
-- Delete an installed mod subtree. Enumeration stays on love.filesystem (the
-- portable game folder is on its read path), but the deletes go through
-- CacheFs so a portable install's real files actually go away instead of
-- love.filesystem no-opping outside the save directory (#330). Directories
-- are removed after their children, since rmdir refuses a non-empty one.
local function removeTree(path) local function removeTree(path)
local fs = love.filesystem local fs = love.filesystem
local info = fs.getInfo(path) local info = fs.getInfo(path)
@@ -263,10 +321,121 @@ local function removeTree(path)
for _, child in ipairs(fs.getDirectoryItems(path)) do for _, child in ipairs(fs.getDirectoryItems(path)) do
removeTree(path .. "/" .. child) removeTree(path .. "/" .. child)
end end
CacheFs.removeDir(path)
else
CacheFs.remove(path)
end end
-- A portable install can still be carrying a pre-#330 copy in the OS save
-- directory, which is where every install used to land and which physfs
-- searches first. CacheFs only touched the game folder, so clear the
-- save-directory twin too or that copy would keep the mod alive; outside
-- portable mode this repeats the delete CacheFs just did and no-ops.
fs.remove(path) fs.remove(path)
end end
-- ------- 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 that are already the physfs source
-- are skipped: their mods/ is discoverable by definition, so anything there is
-- installed already and not a stray (this is every `love <gamedir>` dev run).
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 seen = {}
for _, folder in ipairs(SaveData_.gameFolders() or {}) do
if not seen[folder] and folder ~= source 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) -> true, id | nil, errString -- installZip(source) -> true, id | nil, errString
-- source is an external path or a love DroppedFile. The archive is validated -- 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 -- BEFORE anything is copied; every path unmounts and clears the staged temp
@@ -322,10 +491,19 @@ function LauncherMods.installZip(source)
return nil, "a mod named '" .. manifest.id .. "' is already installed" return nil, "a mod named '" .. manifest.id .. "' is already installed"
end end
fs.createDirectory("mods") -- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...);
-- the mods tree is shared by Red and Blue, so pin the prefix to the root for
-- the copy and the rollback, then hand back whatever the launcher had set
-- (an import coroutine leaves it pointed at that version -- RomImporter.lua).
-- No fs.createDirectory("mods") here any more: CacheFs.write creates the
-- parent chain in both homes, and doing it through love.filesystem would
-- only ever make the directory in the save dir (#330).
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
local copied, copyErr = copyTree(root, dest) local copied, copyErr = copyTree(root, dest)
if not copied then removeTree(dest) end
CacheFs.prefix = savedPrefix
if not copied then if not copied then
removeTree(dest)
cleanup() cleanup()
return nil, copyErr or "could not copy the mod files" return nil, copyErr or "could not copy the mod files"
end end
@@ -334,8 +512,9 @@ function LauncherMods.installZip(source)
end end
-- uninstall(id) -> true | nil, errString -- uninstall(id) -> true | nil, errString
-- Removes mods/<id>/ from the save directory and clears options.mods[id] so the -- Removes mods/<id>/ from wherever it was installed (the portable game folder
-- loader and in-game manager no longer see it. Rejects unknown / missing ids. -- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
-- so the loader and in-game manager no longer see it. Rejects missing ids.
-- Does not touch other mods' enable state. -- Does not touch other mods' enable state.
function LauncherMods.uninstall(id) function LauncherMods.uninstall(id)
if type(id) ~= "string" or id == "" then if type(id) ~= "string" or id == "" then
@@ -352,7 +531,11 @@ function LauncherMods.uninstall(id)
if not fs.getInfo(dest) then if not fs.getInfo(dest) then
return nil, "mod '" .. id .. "' is not installed" return nil, "mod '" .. id .. "' is not installed"
end end
-- same root pin as installZip: the mods tree is not version-prefixed (#330)
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
removeTree(dest) removeTree(dest)
CacheFs.prefix = savedPrefix
-- Drop the enable flag so a reinstall of the same id starts from the -- Drop the enable flag so a reinstall of the same id starts from the
-- loader's default (enabled) rather than a stale false. -- loader's default (enabled) rather than a stale false.
local options = SaveData.loadOptions() local options = SaveData.loadOptions()
+5 -1
View File
@@ -162,7 +162,11 @@ function Loader:_loadState()
if next(options.mods or {}) == nil and self.fs.getInfo if next(options.mods or {}) == nil and self.fs.getInfo
and self.fs.getInfo(MOD_STATE_FILE) then and self.fs.getInfo(MOD_STATE_FILE) then
local chunk = self.fs.load(MOD_STATE_FILE) local chunk = self.fs.load(MOD_STATE_FILE)
local ok, state = chunk and pcall(chunk) -- `chunk and pcall(chunk)` truncates to one value, so state came back nil
-- however well the chunk ran and the migration below never fired once:
-- the guard has to be a statement for pcall's second return to survive.
local ok, state = false, nil
if chunk then ok, state = pcall(chunk) end
if ok and type(state) == "table" then if ok and type(state) == "table" then
for id, disabled in pairs(state) do for id, disabled in pairs(state) do
if disabled then if disabled then
+21 -20
View File
@@ -10,6 +10,7 @@ local Semver = require("src.mods.Semver")
local Version = require("src.core.Version") local Version = require("src.core.Version")
local Theme = require("src.ui.Theme") local Theme = require("src.ui.Theme")
local OptionRows = require("src.ui.OptionRows") local OptionRows = require("src.ui.OptionRows")
local Strings = require("src.core.Strings")
local ManagerState = {} local ManagerState = {}
ManagerState.__index = ManagerState ManagerState.__index = ManagerState
@@ -269,7 +270,7 @@ function ManagerState:modRows()
end end
end end
if #rows == 0 then if #rows == 0 then
rows[1] = { header = true, label = "NO MODS INSTALLED" } rows[1] = { header = true, label = Strings("NO MODS INSTALLED") }
end end
return rows return rows
end end
@@ -281,7 +282,7 @@ function ManagerState:profileRows()
rows[#rows + 1] = { profile = p, label = p.name, rows[#rows + 1] = { profile = p, label = p.name,
glyph = opts.activeProfile == p.name and GLYPH.errored or " " } glyph = opts.activeProfile == p.name and GLYPH.errored or " " }
end end
rows[#rows + 1] = { saveAs = true, label = "SAVE CURRENT AS.." } rows[#rows + 1] = { saveAs = true, label = Strings("SAVE CURRENT AS..") }
rows[#rows + 1] = { adhoc = true, rows[#rows + 1] = { adhoc = true,
label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" } label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" }
return rows return rows
@@ -316,30 +317,30 @@ function ManagerState:detailRows(m)
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE", rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
action = function() self:beginToggle(m) end } action = function() self:beginToggle(m) end }
if self:schemaFor(m) then if self:schemaFor(m) then
rows[#rows + 1] = { label = "OPTIONS..", rows[#rows + 1] = { label = Strings("OPTIONS.."),
action = function() self:openOptions(m) end } action = function() self:openOptions(m) end }
end end
if m.permissions and #m.permissions > 0 then if m.permissions and #m.permissions > 0 then
rows[#rows + 1] = { label = "PERMISSIONS..", rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
action = function() self:goTo("permissions") end } action = function() self:goTo("permissions") end }
end end
if m.error then if m.error then
rows[#rows + 1] = { label = "VIEW ERROR..", rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
action = function() self:goTo("errors") end } action = function() self:goTo("errors") end }
end end
rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end } rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end }
return rows return rows
end end
function ManagerState:applyRows() function ManagerState:applyRows()
local rows = {} local rows = {}
rows[#rows + 1] = { label = "APPLY & RESTART", action = function() rows[#rows + 1] = { label = Strings("APPLY & RESTART"), action = function()
self:openConfirm({ "RESTART NOW?" }, function() self:restartGame() end) self:openConfirm({ "RESTART NOW?" }, function() self:restartGame() end)
end } end }
rows[#rows + 1] = { label = "DISCARD CHANGES", action = function() rows[#rows + 1] = { label = Strings("DISCARD CHANGES"), action = function()
self:discardChanges() self:discardChanges()
end } end }
rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end } rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end }
return rows return rows
end end
@@ -352,7 +353,7 @@ function ManagerState:permissionRows(m)
label = info and info.text or name } label = info and info.text or name }
end end
if #rows == 0 then if #rows == 0 then
rows[1] = { inert = true, label = "DATA & API ONLY" } rows[1] = { inert = true, label = Strings("DATA & API ONLY") }
end end
return rows return rows
end end
@@ -564,7 +565,7 @@ function ManagerState:openCascade(r, m, want)
else else
local dep = self.byId[r.alsoDisable[1]] local dep = self.byId[r.alsoDisable[1]]
lines[#lines + 1] = (dep and dep.name or r.alsoDisable[1]) .. " NEEDS THIS." lines[#lines + 1] = (dep and dep.name or r.alsoDisable[1]) .. " NEEDS THIS."
lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or "DISABLE BOTH?" lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or Strings("DISABLE BOTH?")
end end
self.overlay = { kind = "confirm", lines = lines, index = 1, self.overlay = { kind = "confirm", lines = lines, index = 1,
onYes = function() self:commitToggle(r.apply) end } onYes = function() self:commitToggle(r.apply) end }
@@ -700,7 +701,7 @@ end
function ManagerState:saveCurrentAs() function ManagerState:saveCurrentAs()
local NamingScreen = require("src.ui.NamingScreen") local NamingScreen = require("src.ui.NamingScreen")
self.game.stack:push(NamingScreen.new(self.game, { self.game.stack:push(NamingScreen.new(self.game, {
title = "PROFILE NAME?", title = Strings("PROFILE NAME?"),
maxLen = 10, maxLen = 10,
onDone = function(name) onDone = function(name)
local opts = self:optionsTable() local opts = self:optionsTable()
@@ -726,7 +727,7 @@ end
function ManagerState:renameProfile(p) function ManagerState:renameProfile(p)
local NamingScreen = require("src.ui.NamingScreen") local NamingScreen = require("src.ui.NamingScreen")
self.game.stack:push(NamingScreen.new(self.game, { self.game.stack:push(NamingScreen.new(self.game, {
title = "RENAME?", title = Strings("RENAME?"),
maxLen = 10, maxLen = 10,
default = p.name, default = p.name,
onDone = function(name) onDone = function(name)
@@ -886,7 +887,7 @@ function ManagerState:buildOptionRows(m, schema)
end } end }
end end
end end
rows[#rows + 1] = { id = "__reset", label = "RESET DEFAULTS", rows[#rows + 1] = { id = "__reset", label = Strings("RESET DEFAULTS"),
value = function() return "" end, value = function() return "" end,
activate = function() activate = function()
for _, row in ipairs(schema) do for _, row in ipairs(schema) do
@@ -1047,7 +1048,7 @@ function ManagerState:drawApply()
y = y + 1 y = y + 1
end end
if #staged == 0 then if #staged == 0 then
Font.draw(Runtime.safeMode and "SAFE MODE" or "NO CHANGES", 16, y * 8) Font.draw(Runtime.safeMode and "SAFE MODE" or Strings("NO CHANGES"), 16, y * 8)
y = y + 1 y = y + 1
end end
local rows = self:rowsForScreen() local rows = self:rowsForScreen()
@@ -1076,12 +1077,12 @@ function ManagerState:drawOverlay()
end end
if overlay.kind == "confirm" then if overlay.kind == "confirm" then
local yesY = ty + #lines + 1 local yesY = ty + #lines + 1
Font.draw("YES", 5 * 8, yesY * 8) Font.draw(Strings("YES"), 5 * 8, yesY * 8)
Font.draw("NO", 5 * 8, (yesY + 1) * 8) Font.draw(Strings("NO"), 5 * 8, (yesY + 1) * 8)
Font.drawCode(Theme.cursor, 4 * 8, Font.drawCode(Theme.cursor, 4 * 8,
(overlay.index == 1 and yesY or yesY + 1) * 8) (overlay.index == 1 and yesY or yesY + 1) * 8)
else else
Font.draw("A:OK", 5 * 8, (ty + #lines + 1) * 8) Font.draw(Strings("A:OK"), 5 * 8, (ty + #lines + 1) * 8)
end end
end end
@@ -1090,7 +1091,7 @@ function ManagerState:draw()
OptionRows.draw(self.game, self.optionRows or {}, self.cursor, OptionRows.draw(self.game, self.optionRows or {}, self.cursor,
self.scroll or 0) self.scroll or 0)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.notice or "B:DONE (NO RESTART)", 8, 136) Font.draw(self.notice or Strings("B:DONE (NO RESTART)"), 8, 136)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
if self.overlay then self:drawOverlay() end if self.overlay then self:drawOverlay() end
return return
@@ -1099,7 +1100,7 @@ function ManagerState:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(0, 0, 20, 18) Font.drawBox(0, 0, 20, 18)
Font.draw(self.banner or "MOD MANAGER", 16, 8) Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8)
if self.screen == "list" then if self.screen == "list" then
self:drawList() self:drawList()
elseif self.screen == "detail" then elseif self.screen == "detail" then
+46 -2
View File
@@ -493,6 +493,12 @@ R.maps = {
width = f.int(1), height = f.int(1), width = f.int(1), height = f.int(1),
blocks = f.list(f.int(0, 255)), blocks = f.list(f.int(0, 255)),
borderBlock = f.opt(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), warps = f.opt(f.list(f.rec{ x = f.int(0), y = f.int(0),
destMap = f.str, destWarp = f.int(0) })), destMap = f.str, destWarp = f.int(0) })),
objects = f.opt(f.list(f.any)), objects = f.opt(f.list(f.any)),
@@ -555,6 +561,11 @@ R.trainers = {
index = f.opt(f.int(0, 255)), index = f.opt(f.int(0, 255)),
-- unused vanilla classes ship without a pic, so it cannot be required -- unused vanilla classes ship without a pic, so it cannot be required
pic = f.opt(f.path), 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)), baseMoney = f.opt(f.int(0)),
parties = f.list(f.list(f.rec{ level = f.int(1), parties = f.list(f.list(f.rec{ level = f.int(1),
species = f.id("pokemon") })), species = f.id("pokemon") })),
@@ -574,6 +585,10 @@ R.sprites = {
frames = f.int(1), frames = f.int(1),
walker = f.opt(f.bool), walker = f.opt(f.bool),
trueColor = 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 })', example = 'mod.content.sprites:register("SPRITE_HERO", { image = "...", frames = 6 })',
} }
@@ -584,6 +599,17 @@ R.text = {
example = 'mod.content.text:override("_PalletTownText1", "HELLO!")', example = 'mod.content.text:override("_PalletTownText1", "HELLO!")',
} }
-- The engine's own authored text, the half of the game `text` does not
-- cover (src/core/Strings.lua). The id is the English source string, so a
-- translation supplies one entry per literal the engine draws and anything
-- it has not reached yet keeps rendering in English. Ids that collide in
-- English but not elsewhere carry a "context|source" prefix.
R.strings = {
semantics = "record", target = "strings",
value = f.str,
example = 'mod.content.strings:override("But, it failed!", "Echec !")',
}
-- the self-contained bytecode blob ChipAsm.song/sfx emit (13.1 shape 2); -- the self-contained bytecode blob ChipAsm.song/sfx emit (13.1 shape 2);
-- shared by every namespace that plays a chip program -- shared by every namespace that plays a chip program
local chipProgram = f.rec{ local chipProgram = f.rec{
@@ -918,8 +944,20 @@ R.battle_sprite_scales = {
-- 1 = native pixels; the drawn size relative to the pic's own pixels -- 1 = native pixels; the drawn size relative to the pic's own pixels
scale = f.numRange(0.25, 4.0), scale = f.numRange(0.25, 4.0),
}, },
example = 'mod.content.battle_sprite_scales:register("abra_back", ' .. example = [[
'{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })', mod.content.battle_sprite_scales:register("abra_back", {
path = "assets/generated/battle/back/abrab.png",
scale = 1.5,
})]],
notes = [[
Scales one battle pic by its asset path, overriding the species-level
`battleScaleFront`/`battleScaleBack` (see [pokemon](#pokemon)) for that
image. The only way to scale a pic that is not species-keyed, like the
player's trainer back sprite. Resolution order at draw time:
image-level, then species-level, then the defaults (1x front, 2x back).
The pic stays grounded at any scale: player feet stay flush on the
text-box top, the enemy pic stays bottom-pinned in its slot, and the
scale composes with the send-out grow animation.]],
} }
-- ------- progression -- ------- progression
@@ -1146,6 +1184,12 @@ R.field = {
name = f.opt(f.str) })), name = f.opt(f.str) })),
nest = f.opt(f.any) }, nest = f.opt(f.any) },
flyOrder = f.list(f.str), flyOrder = f.list(f.str),
-- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle
-- back pic, the catch tutorial's old man, and the front pic the intro,
-- trainer card and Hall of Fame share. Every key is optional so a
-- conversion can replace one pic and inherit the rest.
playerPics = f.rec{
back = f.opt(f.str), demoBack = f.opt(f.str), front = f.opt(f.str) },
-- the new-game and boot config a total conversion replaces -- the new-game and boot config a total conversion replaces
boot = f.rec{ boot = f.rec{
startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)), startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)),
+6 -5
View File
@@ -14,6 +14,7 @@ local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local Stats = require("src.pokemon.Stats") local Stats = require("src.pokemon.Stats")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local Evolution = {} local Evolution = {}
@@ -23,7 +24,7 @@ Evolution.METHODS = {
return trigger.kind == "levelup" and mon.level >= (evo.level or 0) return trigger.kind == "levelup" and mon.level >= (evo.level or 0)
end, end,
describe = function(evo) describe = function(evo)
return ("Level %d"):format(evo.level or 0) return Strings("Level %d", evo.level or 0)
end, end,
}, },
ITEM = { ITEM = {
@@ -139,7 +140,7 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
table.insert(mon.moves, { id = moveId, pp = mdef.pp }) table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
("%s learned\n%s!"):format(name, mdef.name), nextStep)) Strings("%s learned\n%s!", name, mdef.name), nextStep))
else else
-- LearnMoveFromLevelUp with a full moveset: the forget UI -- LearnMoveFromLevelUp with a full moveset: the forget UI
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep) Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
@@ -160,8 +161,8 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
Music.play(game.data, Music.special(game.data, "evolution")) Music.play(game.data, Music.special(game.data, "evolution"))
local oldName = mon.nickname or game.data.pokemon[mon.species].name local oldName = mon.nickname or game.data.pokemon[mon.species].name
Evolution.apply(game, mon, newSpecies, via) Evolution.apply(game, mon, newSpecies, via)
local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!") local msg = Strings("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!",
:format(oldName, oldName, game.data.pokemon[newSpecies].name) oldName, oldName, game.data.pokemon[newSpecies].name)
game.stack:push(TextBox.new(game, msg, function() game.stack:push(TextBox.new(game, msg, function()
Music.restoreMap(game.data) Music.restoreMap(game.data)
-- re-run the evolved species' level-up learn check before onDone -- re-run the evolved species' level-up learn check before onDone
@@ -186,7 +187,7 @@ end
-- After-battle hook: evolve mons that leveled this battle and still -- After-battle hook: evolve mons that leveled this battle and still
-- qualify (queued one at a time, party order). Gen1 EvolveAfterBattle -- qualify (queued one at a time, party order). Gen1 EvolveAfterBattle
-- only considers mons that gained a level during the fight a B-cancel -- only considers mons that gained a level during the fight -- a B-cancel
-- means "not this time", and the next offer waits for the next level-up -- means "not this time", and the next offer waits for the next level-up
-- (or Rare Candy / stone, which call Evolution.evolve directly). -- (or Rare Candy / stone, which call Evolution.evolve directly).
-- leveledUp is a set of party mon tables; nil/empty yields no evolutions. -- leveledUp is a set of party mon tables; nil/empty yields no evolutions.
+5 -1
View File
@@ -31,7 +31,7 @@ end
-- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare): -- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare):
-- grant learnset moves with startLevel < moveLevel <= newLevel, shifting -- grant learnset moves with startLevel < moveLevel <= newLevel, shifting
-- the oldest slot out when full. Silent no LearnMove prompts. -- the oldest slot out when full. Silent -- no LearnMove prompts.
function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel) function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel)
if not (speciesDef and speciesDef.learnset and mon) then return end if not (speciesDef and speciesDef.learnset and mon) then return end
mon.moves = mon.moves or {} mon.moves = mon.moves or {}
@@ -75,6 +75,10 @@ function Pokemon.new(data, species, level, rng)
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }, statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
stats = stats, stats = stats,
hp = stats.hp, hp = stats.hp,
-- the Gen1 catch-rate byte freezes at catch time: evolution does NOT
-- update it, so PKHeX expects a preevolution's rate on an evolved mon
-- (#206). Evolution.apply mutates in place and never touches this.
catchRate = def.catchRate,
status = nil, -- "SLP"|"PSN"|"BRN"|"FRZ"|"PAR" status = nil, -- "SLP"|"PSN"|"BRN"|"FRZ"|"PAR"
moves = moves, moves = moves,
} }
+36 -1
View File
@@ -3,8 +3,12 @@
-- patch pokemon.spriteFront / icons.bySpecies. These helpers are the -- patch pokemon.spriteFront / icons.bySpecies. These helpers are the
-- sanctioned seam: every battle pic and party icon load goes through -- sanctioned seam: every battle pic and party icon load goes through
-- pokemon.sprite / pokemon.icon, which stay live for the whole process. -- pokemon.sprite / pokemon.icon, which stay live for the whole process.
--
-- playerPath is the same seam for the player's own trainer art, whose
-- vanilla paths are data (field.playerPics) rather than a species record.
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local FieldDefaults = require("src.world.FieldDefaults")
local Sprites = {} local Sprites = {}
@@ -14,7 +18,8 @@ local function samePath(path) return path end
-- side: "front" | "back" -- side: "front" | "back"
-- opts.mon: the live mon when available (per-instance skins) -- opts.mon: the live mon when available (per-instance skins)
-- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade" -- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade"
-- | "title" | "oak" | "credits" (informational for wrappers) -- | "title" | "oak" | "credits" | "overworld" (informational
-- for wrappers)
-- Returns path, trueColor. -- Returns path, trueColor.
function Sprites.path(data, species, side, opts) function Sprites.path(data, species, side, opts)
opts = opts or {} opts = opts or {}
@@ -36,6 +41,36 @@ function Sprites.path(data, species, side, opts)
return path, ctx.trueColor and true or false return path, ctx.trueColor and true or false
end end
-- Resolve the player's own trainer pic path.
-- side: "back" (the battle intro pic) | "front" (intro / card / Hall of Fame)
-- opts.kind: "battle" | "intro" | "trainer_card" | "hof"
-- opts.demo: the catch tutorial, where the old man fights in the player's
-- place and stands in for the back pic
-- opts.battle: the live battle, for kind == "battle"
-- Returns path, trueColor.
function Sprites.playerPath(data, side, opts)
opts = opts or {}
side = side == "back" and "back" or "front"
-- one key per pic, so a conversion can replace the back and inherit the
-- rest; fieldValue folds data.field over FieldDefaults per key
local key = side == "front" and "front"
or (opts.demo and "demoBack" or "back")
local path = FieldDefaults.fieldValue(data, "playerPics", key)
local ctx = {
side = side,
kind = opts.kind or "battle",
demo = opts.demo and true or false,
battle = opts.battle,
trueColor = false,
data = data,
}
if path and Runtime.wantsHook("player.sprite") then
local hooked = Runtime.call("player.sprite", samePath, path, ctx)
if type(hooked) == "string" and hooked ~= "" then path = hooked end
end
return path, ctx.trueColor and true or false
end
-- Resolve a party-menu icon image path for `mon`. -- Resolve a party-menu icon image path for `mon`.
-- vanillaPath is the path PartyMenu already picked from icons.bySpecies / -- vanillaPath is the path PartyMenu already picked from icons.bySpecies /
-- def.icon / icons.byDex; the hook may replace it. -- def.icon / icons.byDex; the hook may replace it.
+22
View File
@@ -42,6 +42,28 @@ function Stats.calc(speciesDef, level, dvs, statExp)
return out return out
end end
-- Give a mon a stat block when it has none. A real Gen 1 box_struct is a
-- byte-for-byte PREFIX of party_struct that stops before MON_LEVEL and
-- MON_STATS (macros/ram.asm box_struct / party_struct), so mons decoded out
-- of an imported .sav arrive without one (src/save_convert/GenSave.lua
-- decodeMon, isParty = false). The original derives them on demand at
-- exactly two moments: when a box or daycare mon's status screen opens
-- (engine/pokemon/status_screen.asm:66-76, "mon is in a box or daycare" ->
-- CalcStats) and when one is moved back into the party
-- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is
-- kept (box_struct does hold it) but clamped to the recalculated maximum so
-- a tampered save cannot overfill the bar. A mon that already has stats is
-- returned untouched, so a vanilla save round-trips. #233, #304
function Stats.ensure(speciesDef, mon)
if type(mon) ~= "table" or type(mon.stats) == "table" then return mon end
if type(speciesDef) ~= "table" or type(speciesDef.baseStats) ~= "table" then
return mon
end
mon.stats = Stats.calc(speciesDef, mon.level or 1, mon.dvs or {}, mon.statExp)
mon.hp = math.max(0, math.min(tonumber(mon.hp) or mon.stats.hp, mon.stats.hp))
return mon
end
-- Battle stat stage multipliers (data/battle/stat_modifiers.asm): stages -- Battle stat stage multipliers (data/battle/stat_modifiers.asm): stages
-- -6..+6 map to N/D pairs 25/100 .. 400/100. -- -6..+6 map to N/D pairs 25/100 .. 400/100.
local STAGE_MULT = { local STAGE_MULT = {
+15 -1
View File
@@ -23,6 +23,20 @@ local FLASH_STEPS = { 1 / 3, 2 / 3, 1, 2 / 3, 1 / 3, 0,
local FLASH_HOLD = 2 -- frames per palette step local FLASH_HOLD = 2 -- frames per palette step
local FLASH_CYCLES = 3 local FLASH_CYCLES = 3
-- The screen stays black after the wipe lands. Shrink and Split ask for it
-- outright (BattleTransition_BlackScreen, then `ld c, 10 / jp DelayFrames`:
-- battle_transitions.asm:390-392 and 422-424); the other six get the same gap
-- for free, because BattleTransition_BlackScreen has already set rBGP/rOBP0/
-- rOBP1 to $ff (:168-174) while DoBattleTransitionAndInitBattleVariables
-- reloads the HUD tile patterns and clears the screen (core.asm:6152-6185),
-- InitBattleCommon decompresses the front pic (core.asm:6694-6730), and
-- SlidePlayerAndEnemySilhouettesOnScreen rebuilds the whole tilemap between
-- DisableLCD and EnableLCD (core.asm:9-49) before anything moves. This port
-- has no load to hide behind, so the hold has to be explicit (#315). 30 is a
-- frame budget for that work, not a number pokered states; retune this one
-- constant against a reference recording if it reads long or short.
local BLACK_HOLD = 30
local TILE = 8 local TILE = 8
local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles
@@ -208,7 +222,7 @@ function BattleTransition:update(dt)
self.t = 0 self.t = 0
end end
else else
if self.t >= self.wipeLen + 6 then if self.t >= self.wipeLen + BLACK_HOLD then
self.game.stack:pop() self.game.stack:pop()
if self.onDone then self.onDone() end if self.onDone then self.onDone() end
end end
+69 -20
View File
@@ -102,35 +102,84 @@ local function pageFor(code)
return nil return nil
end end
local SPACE = 0x7F
-- Segment text into glyph spans: `{ from, to, code }` byte ranges, one per
-- drawn glyph, code nil when the charmap has nothing. A span is a whole
-- charmap sequence, so a multi-byte char ("é", "♂") and an ASCII ligature
-- ("<PK>", "'d") are each one glyph.
--
-- Every caller that *measures* or *cuts* text walks these instead of bytes.
-- "POKéMON" is 8 bytes and 7 glyphs: measuring it as 8 wraps lines that fit
-- (25 vanilla lines did), and cutting at byte 8 splits the é into two
-- invalid bytes that both draw as spaces. That distinction is the whole
-- reason a non-English font can be shipped as a mod (#186, #245).
--
-- Safe before Font.load: with no charmap it falls back to UTF-8 lead-byte
-- boundaries, which is all a headless paginate needs.
function Font.split(text)
local spans = {}
local i, n = 1, #text
while i <= n do
local span
local candidates = state and state.byFirstByte[text:byte(i)]
if candidates then
for _, entry in ipairs(candidates) do
local len = #entry.seq
if text:sub(i, i + len - 1) == entry.seq then
span = { from = i, to = i + len - 1, code = entry.code }
break
end
end
end
if not span then
-- Nothing matched. Still keep a UTF-8 sequence whole, so a cut never
-- lands mid-character even for a glyph we cannot draw.
local last = i
if text:byte(i) >= 0xC0 then
local k = i + 1
while k <= n do
local b = text:byte(k)
if b < 0x80 or b > 0xBF then break end
last, k = k, k + 1
end
end
span = { from = i, to = last }
end
spans[#spans + 1] = span
i = span.to + 1
end
return spans
end
-- How many leading spans fit in `budget` pixels. Advances come from each
-- glyph's own page, so a variable-width page measures correctly.
function Font.spansFitting(spans, budget)
local used, fit = 0, 0
for _, span in ipairs(spans) do
used = used + Font.advanceOf(span.code or SPACE)
if used > budget then break end
fit = fit + 1
end
return fit
end
-- Convert a text string into a list of glyph codes. Unknown characters -- Convert a text string into a list of glyph codes. Unknown characters
-- render as space (and are reported once). -- render as space (and are reported once).
local reported = {} local reported = {}
function Font.encode(text) function Font.encode(text)
local codes = {} local codes = {}
local i = 1 for _, span in ipairs(Font.split(text)) do
while i <= #text do local code = span.code
local candidates = state.byFirstByte[text:byte(i)] if not code then
local matched = false local ch = text:sub(span.from, span.to)
if candidates then if not reported[ch] and text:byte(span.from) >= 32 then
for _, entry in ipairs(candidates) do
local n = #entry.seq
if text:sub(i, i + n - 1) == entry.seq then
codes[#codes + 1] = entry.code
i = i + n
matched = true
break
end
end
end
if not matched then
local ch = text:sub(i, i)
if not reported[ch] and ch:byte() >= 32 then
reported[ch] = true reported[ch] = true
require("src.core.Logger").warn("font: no glyph for %q", ch) require("src.core.Logger").warn("font: no glyph for %q", ch)
end end
codes[#codes + 1] = 0x7F -- space code = SPACE
i = i + 1
end end
codes[#codes + 1] = code
end end
return codes return codes
end end
+9
View File
@@ -25,7 +25,16 @@ local shader -- false = unavailable (headless / no shader support)
-- Mobile GPUs often compile this pass but present a black frame, and the -- Mobile GPUs often compile this pass but present a black frame, and the
-- level persists in options.lua -- soft-bricking the APK until a manual -- level persists in options.lua -- soft-bricking the APK until a manual
-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect. -- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect.
--
-- POKEPORT_GBCFX overrides the platform default either way ("1" force on,
-- "0" force off), same tri-state as POKEPORT_TOUCH. Handheld packs whose
-- GPU is in the same class as a phone's but whose getOS() says "Linux" set
-- it to 0 in their launcher (build-rg34xxsp.sh); it also lets a desktop
-- checkout exercise the unsupported path without stubbing love.system.
function GBCFX.isSupported() function GBCFX.isSupported()
local env = os.getenv("POKEPORT_GBCFX")
if env == "1" then return true end
if env == "0" then return false end
if not love or not love.system or not love.system.getOS then return true end if not love or not love.system or not love.system.getOS then return true end
local osName = love.system.getOS() local osName = love.system.getOS()
return osName ~= "Android" and osName ~= "iOS" return osName ~= "Android" and osName ~= "iOS"
+80 -22
View File
@@ -2,6 +2,11 @@
-- screen: pokered overlays the $62-$7F font area with the HP bar / -- screen: pokered overlays the $62-$7F font area with the HP bar /
-- status sheet (font_battle_extra -> $62) and the HUD line tiles -- status sheet (font_battle_extra -> $62) and the HUD line tiles
-- (battle_hud_1 -> $6D, battle_hud_2+3 -> $73). -- (battle_hud_1 -> $6D, battle_hud_2+3 -> $73).
--
-- The two screens do NOT use the same overlay: the status screen scatters
-- hud_2 and hud_3 instead of copying them contiguously, which is what keeps
-- its № and <ID> glyphs alive. HudTiles.tile draws the battle layout,
-- HudTiles.statusTile the status one -- see STATUS_PAGES below. #280
local Assets = require("src.render.Assets") local Assets = require("src.render.Assets")
@@ -23,32 +28,62 @@ local PAGES = {
image = "assets/generated/battle/battle_hud_3.png", base = 0x76 }, image = "assets/generated/battle/battle_hud_3.png", base = 0x76 },
} }
local tiles -- The STATUS SCREEN overlays the SAME sheets differently, and the layout
function HudTiles.tile(code, x, y, tint) -- above would break it: engine/pokemon/status_screen.asm:86-97 copies 3
if not tiles then -- tiles of hud_1 to $6D, ONE tile of hud_2 to $78 and 2 tiles of hud_3 to
tiles = {} -- $76, which leaves $70/$73/$74 as font_battle_extra's <to>, <ID> and № --
local registered = require("src.core.Data").font -- the glyphs the screen prints "№." and "<ID>№/" from
registered = registered and registered.pages or nil -- (constants/charmap.asm:69-73). The battle overlay instead copies
local function add(path, base) -- hud_2+hud_3 contiguously over $73-$78 (engine/battle/core.asm:6520/6532),
local ok, img = pcall(Assets.image, path) -- burying № under a line tile, so the status screen needs its own table.
if not ok then return end -- The line glyphs land identically either way -- $76 ─, $77 ┘, $6F the
-- halfarrow -- only the vertical bar moves ($73 in battle, $78 here). #280
local STATUS_PAGES = {
{ id = "font_battle_extra",
image = "assets/generated/battle/font_battle_extra.png", base = 0x62 },
{ id = "battle_hud_1",
image = "assets/generated/battle/battle_hud_1.png", base = 0x6D },
{ id = "battle_hud_3",
image = "assets/generated/battle/battle_hud_3.png", base = 0x76, count = 2 },
{ id = "battle_hud_2",
image = "assets/generated/battle/battle_hud_2.png", base = 0x78, count = 1 },
}
local tiles, statusTiles
-- Build one code -> {img, quad} map from a page list. `count` caps a page
-- at the number of tiles the asm actually copies (the extracted sheets all
-- carry 3 tiles; the status overlay uses fewer). A mod's registered page
-- swaps the image in either table, but only the battle table honors its
-- `base`: the status layout is the asm's own placement, and sliding hud_2
-- there would bury № again.
local function build(pages, fixedBase)
local out = {}
local registered = require("src.core.Data").font
registered = registered and registered.pages or nil
for _, page in ipairs(pages) do
local override = registered and registered[page.id]
local path, base = page.image, page.base
if override and override.image then path = override.image end
if not fixedBase and override and override.base then base = override.base end
local ok, img = pcall(Assets.image, path)
if ok then
local iw, ih = img:getDimensions() local iw, ih = img:getDimensions()
local per = iw / 8 local per = iw / 8
for i = 0, per * (ih / 8) - 1 do local count = page.count or per * (ih / 8)
tiles[base + i] = { for i = 0, count - 1 do
out[base + i] = {
img = img, img = img,
quad = love.graphics.newQuad((i % per) * 8, quad = love.graphics.newQuad((i % per) * 8,
math.floor(i / per) * 8, 8, 8, iw, ih), math.floor(i / per) * 8, 8, 8, iw, ih),
} }
end end
end end
for _, page in ipairs(PAGES) do
local override = registered and registered[page.id]
add(override and override.image or page.image,
override and override.base or page.base)
end
end end
local t = tiles[code] return out
end
local function put(t, x, y, tint)
if not t then return end if not t then return end
local r, g, b, a = love.graphics.getColor() local r, g, b, a = love.graphics.getColor()
love.graphics.setColor(tint or { 1, 1, 1, 1 }) love.graphics.setColor(tint or { 1, 1, 1, 1 })
@@ -56,9 +91,23 @@ function HudTiles.tile(code, x, y, tint)
love.graphics.setColor(r, g, b, a) love.graphics.setColor(r, g, b, a)
end end
function HudTiles.tile(code, x, y, tint)
if not tiles then tiles = build(PAGES) end
put(tiles[code], x, y, tint)
end
-- The same sheets under the status screen's overlay (STATUS_PAGES). The HP
-- bar codes $62-$6D are identical in both layouts, so drawHPBar below keeps
-- using the battle table. #280
function HudTiles.statusTile(code, x, y, tint)
if not statusTiles then statusTiles = build(STATUS_PAGES, true) end
put(statusTiles[code], x, y, tint)
end
-- lazy: the next tile() rebuilds every page from the search path -- lazy: the next tile() rebuilds every page from the search path
function HudTiles.invalidate() function HudTiles.invalidate()
tiles = nil tiles = nil
statusTiles = nil
end end
Assets.register(HudTiles.invalidate) Assets.register(HudTiles.invalidate)
@@ -78,6 +127,11 @@ end
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else -- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
-- red). -- red).
-- --
-- segments: how many 8px cells the bar spans (6, the hardware width,
-- unless a caller asks for more -- the widescreen battle layout has room
-- for a longer bar in the same tiles). The color thresholds scale with
-- it so a wider bar turns yellow and red at the same fractions of full.
--
-- grayFill (#229): when the caller will colorize this bar with an SGB -- grayFill (#229): when the caller will colorize this bar with an SGB
-- region palette (BattleState's zone pass, BATTLE_ZONES pal 0/1 = -- region palette (BattleState's zone pass, BATTLE_ZONES pal 0/1 =
-- GetHealthBarColor), leave the fill as its raw DMG shade-2 gray and skip -- GetHealthBarColor), leave the fill as its raw DMG shade-2 gray and skip
@@ -87,18 +141,22 @@ end
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has -- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
-- red channel 0, so the tint zeroes the whole bar's red and the zone's -- red channel 0, so the tint zeroes the whole bar's red and the zone's
-- red-channel-keyed shade shader then maps every pixel to color 3 = black. -- red-channel-keyed shade shader then maps every pixel to color 3 = black.
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill) function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments)
local x, y = tx * 8, ty * 8 local x, y = tx * 8, ty * 8
segments = math.max(1, math.floor(segments or 6))
HudTiles.tile(0x71, x, y) HudTiles.tile(0x71, x, y)
HudTiles.tile(0x62, x + 8, y) HudTiles.tile(0x62, x + 8, y)
local px = 0 local px = 0
if mon.stats.hp > 0 and mon.hp > 0 then if mon.stats.hp > 0 and mon.hp > 0 then
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp)) px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp))
end end
local tint local tint
if not grayFill then if not grayFill then
local PaletteFX = require("src.render.PaletteFX") local PaletteFX = require("src.render.PaletteFX")
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR" local green = math.ceil(27 * segments / 6)
local yellow = math.ceil(10 * segments / 6)
local name = px >= green and "GREENBAR"
or px >= yellow and "YELLOWBAR" or "REDBAR"
local colors = PaletteFX.pal(data, name) local colors = PaletteFX.pal(data, name)
if colors then if colors then
local c = colors[3] -- GB color 2 is the fill shade local c = colors[3] -- GB color 2 is the fill shade
@@ -108,11 +166,11 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
math.min(1, c[3] / 170), 1 } math.min(1, c[3] / 170), 1 }
end end
end end
for i = 0, 5 do for i = 0, segments - 1 do
local seg = math.min(8, math.max(0, px - i * 8)) local seg = math.min(8, math.max(0, px - i * 8))
HudTiles.tile(seg >= 8 and 0x6B or 0x63 + seg, x + 16 + i * 8, y, tint) HudTiles.tile(seg >= 8 and 0x6B or 0x63 + seg, x + 16 + i * 8, y, tint)
end end
HudTiles.tile(HudTiles.capTile(barType), x + 64, y) HudTiles.tile(HudTiles.capTile(barType), x + 16 + segments * 8, y)
end end
return HudTiles return HudTiles
+144 -34
View File
@@ -5,10 +5,11 @@
-- then drawn once per zone through a shader that remaps the four DMG -- then drawn once per zone through a shader that remaps the four DMG
-- shades to that zone's palette. -- shades to that zone's palette.
-- --
-- Port display option: COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC) -- Port display option: COLORS (OG RED / SGB / ADVANCED / OG / OG INV /
-- transforms every zone's palette at send time via effectiveColors. -- SGB INV / CLASSIC) transforms every zone's palette at send time via
-- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes -- effectiveColors. ADVANCED (the `redpp` id below) swaps the named-palette
-- (data/palettes_gbc.lua), including per-species mon colors. -- pack for pokered-gbc SuperPalettes (data/palettes_gbc.lua), including
-- per-species mon colors.
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
@@ -18,14 +19,18 @@ local shader -- false = unavailable (headless / no shader support)
local gbcPack -- false = missing; nil = not loaded yet local gbcPack -- false = missing; nil = not loaded yet
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations -- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, RED++ = -- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, ADVANCED =
-- pokered-gbc per-tile), then the DMG-shade novelty modes. -- pokered-gbc per-tile), then the DMG-shade novelty modes.
PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" } PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
-- `gbc`/`gbc_inv` keep their save-value ids for back-compat; their LABELS are -- `gbc`/`gbc_inv`/`redpp` keep their save-value ids for back-compat while
-- "SGB"/"SGB INV" because that is what the mode actually is (the old "GBC" -- their LABELS say what the mode actually is: "SGB"/"SGB INV" because the old
-- label was a misnomer -- it never was the real Game Boy Color palette). -- "GBC" label was a misnomer (it never was the real Game Boy Color palette),
-- and "ADVANCED" because `redpp` is the richest colorization of the three
-- rather than anything Red-specific -- it reads as a misnomer outright on a
-- Blue playthrough. Comments elsewhere still call it RED++, the name it has
-- carried in this file since it landed.
PaletteFX.MODE_LABELS = { PaletteFX.MODE_LABELS = {
ogred = "OG RED", gbc = "SGB", redpp = "RED++", og = "OG", ogred = "OG RED", gbc = "SGB", redpp = "ADVANCED", og = "OG",
og_inv = "OG INV", gbc_inv = "SGB INV", classic = "CLASSIC", og_inv = "OG INV", gbc_inv = "SGB INV", classic = "CLASSIC",
} }
PaletteFX.mode = "gbc" PaletteFX.mode = "gbc"
@@ -73,8 +78,11 @@ PaletteFX.GBC_OBJ_BLUE = {
-- playthrough, red otherwise. White (index 1) and black (index 4) are -- playthrough, red otherwise. White (index 1) and black (index 4) are
-- identical across versions, so callers that only touch the endpoints -- identical across versions, so callers that only touch the endpoints
-- (e.g. BattleState's zone white/black snap) need no version branch. -- (e.g. BattleState's zone white/black snap) need no version branch.
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes) and has no extracted
-- boot-ROM auto-palette here; keep the Red ramp -- never Blue's GBC_BG_BLUE.
function PaletteFX.ogBg() function PaletteFX.ogBg()
if GameVersion.isBlue() then return PaletteFX.GBC_BG_BLUE end if GameVersion.isBlue() then return PaletteFX.GBC_BG_BLUE end
if GameVersion.isYellow() then return PaletteFX.GBC_BG end
return PaletteFX.GBC_BG return PaletteFX.GBC_BG
end end
@@ -83,11 +91,32 @@ end
-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys -- version-distinct cache-group string, because SpriteRenderer.getObpImage keys
-- its baked-image cache by (image path, group): a shared group would collide a -- 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. -- Red bake with a Blue one and one version would show the other's colors.
-- Yellow: same Red OBJ green as above until Yellow-specific tables land.
function PaletteFX.ogObj() function PaletteFX.ogObj()
if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end
if GameVersion.isYellow() then return PaletteFX.GBC_OBJ, "gbcobj" end
return PaletteFX.GBC_OBJ, "gbcobj" return PaletteFX.GBC_OBJ, "gbcobj"
end end
-- The DMG object ramp every mode except OG RED bakes onto overworld sprites,
-- plus its cache group (same two-value contract as ogObj). Entry 1 is never
-- read -- SpriteRenderer.getObpImage keys OBJ color 0 to alpha, the hardware's
-- unconditional OBJ transparency -- and entries 2..4 are OBJ colors 1..3 sent
-- through rOBP0 = $D0 (home/fade.asm FadePal4 `dc 3,1,0,0`, the entry
-- LoadGBPal reads while wMapPalOffset is 0): color 1 -> DMG shade 0, color 2
-- -> shade 1, color 3 -> shade 3. Leaving the result in DMG shades is the
-- whole point: the zone shader then colors a character out of the same map
-- palette it colors the ground with, which is all the Super Game Boy can do to
-- an OBJ (#301), and the OBP0 lift is what puts Red's cap on the ROUTE
-- palette's grass green instead of its light-blue (#150).
PaletteFX.OBP0_SHADES = {
{ 255, 255, 255 }, { 255, 255, 255 }, { 170, 170, 170 }, { 0, 0, 0 },
}
function PaletteFX.dmgObj()
return PaletteFX.OBP0_SHADES, "obp0"
end
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 } local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
function PaletteFX.shader() function PaletteFX.shader()
@@ -202,32 +231,45 @@ function PaletteFX.usesGbcPack(mode)
end end
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites -- Whether the active mode bakes a per-OBJ palette onto overworld sprites
-- (the OBP bake + post-zone redraw path). OG RED and SGB both do: characters -- (the OBP bake + post-zone redraw path). OG RED alone does: the Game Boy
-- wear the GBC boot-ROM object palette (PaletteFX.ogObj -- green over Red's red -- Color boot ROM hands the game one global object palette (PaletteFX.ogObj --
-- background, pink over Blue's blue background), so the player and NPCs carry a -- green over Red's red background, pink over Blue's blue background), so on
-- fixed object color instead of tinting with whatever region palette their -- that machine the player and NPCs carry a fixed object color instead of
-- feet stand over. On real hardware a sprite is an OBJ colored by an OBJ -- tinting with whatever region palette their feet stand over. An EARLIER
-- palette (color/sprites.asm ColorOverworldSprite), distinct from the BG it -- attempt at per-sprite color baked GBC_BG (the RED background ramp) onto
-- overlaps -- so Red's cap must stay green in tall grass, not turn the ROUTE
-- palette's light-blue (issue #150: SGB region-tinting sent the cap to shade-2
-- = light-blue and the character clashed with the grass it should blend into).
-- 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. An EARLIER
-- attempt at per-sprite SGB color baked GBC_BG (the RED background ramp) onto
-- characters -- that was the "reds coloring on the player/NPCs" bug; the object -- characters -- that was the "reds coloring on the player/NPCs" bug; the object
-- palette is GBC_OBJ (green), so baking it here is the fix, not that -- palette is GBC_OBJ (green), so baking that here is the fix, not that
-- regression. RED++ colors sprites through the usesGbcPack() path in -- regression. Terrain is unaffected -- pal() below still hands SGB its per-map
-- SpriteRenderer instead. -- BG palette; only OG RED short-circuits BG to the one global red palette.
--
-- 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
-- defines ATTR_BLK / PAL_SET / PAL_TRN / MLT_REQ / CHR_TRN / PCT_TRN and
-- nothing else), so a character there wears the very palette its map does.
-- Baking GBC_OBJ over it was issue #301 ("people in SGB mode are green": the
-- boot-ROM greens sat on top of ROUTE's own greens and blues). What issue
-- #150 actually caught was the missing rOBP0 step, not a missing object
-- palette: overworld OBJs run through OBP0 = $D0 (home/fade.asm FadePal4
-- `dc 3,1,0,0`), which lifts OBJ color 1 to DMG shade 0 and color 2 to shade 1,
-- so Red's cap lands on the ROUTE palette's shade-1 GRASS GREEN and blends into
-- the grass exactly as #150's reference shot shows. Drawn with an identity
-- shade map it landed on shade 2 = light-blue instead, which is the clash #150
-- reported. SpriteRenderer bakes that OBP0 ramp (PaletteFX.dmgObj) and lets
-- the zone shader color the result. RED++ colors sprites through the
-- usesGbcPack() path in SpriteRenderer instead.
function PaletteFX.usesSpriteObp(mode) function PaletteFX.usesSpriteObp(mode)
mode = mode or PaletteFX.mode mode = mode or PaletteFX.mode
return mode == "ogred" or mode == "gbc" return mode == "ogred"
end end
-- ------- post-zone sprite redraw (GBC mode) -- ------- post-zone sprite redraw (OG RED)
-- --
-- In GBC mode the world canvas still runs through the per-map zone -- In OG RED the world canvas still runs through the whole-screen zone
-- shade-remap shader, which would corrupt an OBP-baked sprite's true-color -- shade-remap shader, which would corrupt an OBP-baked sprite's true-color
-- pixels. So SpriteRenderer draws the baked sprite into the canvas (its -- pixels. (SGB used to come through here too; it no longer bakes an object
-- palette at all, so its characters are colorized by the zone like the ground
-- they stand on and never queue a replay -- see usesSpriteObp, #301.) So SpriteRenderer draws the baked sprite into the canvas (its
-- pixels come out zone-tinted there) AND records the draw here; -- pixels come out zone-tinted there) AND records the draw here;
-- Renderer:endFrame replays the list on top of the finished zone pass, -- Renderer:endFrame replays the list on top of the finished zone pass,
-- scaled into screen space -- the GBC's OBJ-over-BG compositing, one draw -- scaled into screen space -- the GBC's OBJ-over-BG compositing, one draw
@@ -274,6 +316,9 @@ end
-- Red-derived pokered-gbc pack, so under RED++ a Blue playthrough must -- 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 -- read these from the ROM-imported table or the title ribbon stays red
-- and the Game Corner reels keep Red's pink (issue #128). -- and the Game Corner reels keep Red's pink (issue #128).
-- Yellow is intentionally NOT in BLUE_VERSIONED: skip Blue LOGO1/SLOTS*
-- recolors. When CGBBasePalettes were imported (palettes.cgbBase), Yellow
-- prefers those over SGB SuperPalettes for named zones.
local BLUE_VERSIONED = { local BLUE_VERSIONED = {
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true, LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
} }
@@ -283,6 +328,11 @@ local function romNamedPal(data, name)
return p and p.palettes and p.palettes[name] return p and p.palettes and p.palettes[name]
end end
local function yellowCgbNamedPal(data, name)
local p = data and data.palettes
return p and p.cgbBase and p.cgbBase[name]
end
-- named palette from the active pack (nil on stale builds / missing name). -- 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). -- 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 -- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
@@ -292,10 +342,16 @@ end
-- GBC_OBJ green), so this stays a BG-only hook. -- GBC_OBJ green), so this stays a BG-only hook.
function PaletteFX.pal(data, name) function PaletteFX.pal(data, name)
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end if PaletteFX.mode == "ogred" 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 if GameVersion.isBlue() and BLUE_VERSIONED[name] then
local fromRom = romNamedPal(data, name) local fromRom = romNamedPal(data, name)
if fromRom then return fromRom end if fromRom then return fromRom end
end end
if GameVersion.isYellow() then
local fromCgb = yellowCgbNamedPal(data, name)
if fromCgb then return fromCgb end
end
local p = PaletteFX.pack(data) local p = PaletteFX.pack(data)
local c = p and p.palettes[name] local c = p and p.palettes[name]
if c then return c end if c then return c end
@@ -481,7 +537,7 @@ end
function PaletteFX.spriteObp(spriteDef, seed) function PaletteFX.spriteObp(spriteDef, seed)
local pack = PaletteFX.gbcPack() local pack = PaletteFX.gbcPack()
local w = pack and pack.world 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 if not (w and src) then return nil end
local idx = tonumber(src:match("%[(%d+)%]")) local idx = tonumber(src:match("%[(%d+)%]"))
-- RedBikeSprite loads outside SpriteSheetPointerTable -- RedBikeSprite loads outside SpriteSheetPointerTable
@@ -529,6 +585,34 @@ function PaletteFX.permute(colors, map)
colors[map[2] + 1], colors[map[3] + 1] } colors[map[2] + 1], colors[map[3] + 1] }
end end
-- ------- global shade map (rBGP)
--
-- home/fade.asm's LoadGBPal writes ONE rBGP for the whole screen, indexing
-- FadePal4 - wMapPalOffset. A dark cave sets wMapPalOffset = 6
-- (home/overworld.asm's ROCK_TUNNEL_1F check; the value rides in the extracted
-- field.darkMaps.palOffset), which lands on FadePal2 = `dc 3,3,3,2`: DMG white
-- drops to shade 2 and every darker shade goes to shade 3. So the WHOLE
-- screen darkens -- the original never cuts a window of light around the
-- player (#322) -- and FLASH clears wMapPalOffset again
-- (engine/menus/start_sub_menus.asm .flash).
--
-- Renderer:beginFrame clears this every frame and the state that draws a dark
-- map re-arms it while it draws, so it can never outlive the map it belongs
-- to: a battle or a full-screen menu draws with no map beneath it and comes
-- out lit, exactly like init_battle_variables.asm's `ld [wMapPalOffset], a`
-- leaves the original.
PaletteFX.DARK_BGP = { [0] = 2, [1] = 3, [2] = 3, [3] = 3 }
local shadeMap = nil
function PaletteFX.setShadeMap(map)
shadeMap = map
end
function PaletteFX.shadeMap()
return shadeMap
end
function PaletteFX.setMode(mode) function PaletteFX.setMode(mode)
local prev = PaletteFX.mode local prev = PaletteFX.mode
local ok = false local ok = false
@@ -579,7 +663,10 @@ function PaletteFX.modeLabel(mode)
mode = mode or PaletteFX.mode mode = mode or PaletteFX.mode
-- The GBC boot-ROM mode wears the running game's name: it is red for Red and -- 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". -- blue for Blue (see ogBg), so a Blue playthrough shows "OG BLUE".
-- Yellow still uses the Red boot-ROM ramp (no Yellow table yet), so keep
-- the "OG RED" label rather than inventing an "OG YELLOW" without colors.
if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end
if mode == "ogred" and GameVersion.isYellow() then return "OG RED" end
return PaletteFX.MODE_LABELS[mode] or "GBC" return PaletteFX.MODE_LABELS[mode] or "GBC"
end end
@@ -598,19 +685,42 @@ end
-- Transform a 4-color palette for the active COLORS display mode. -- Transform a 4-color palette for the active COLORS display mode.
-- GBC and RED++ pass the zone colors through (RED++ already swapped the -- GBC and RED++ pass the zone colors through (RED++ already swapped the
-- pack in pal/monPal); OG* / CLASSIC replace; GBC INV permutes shades. -- pack in pal/monPal); OG* / CLASSIC replace; GBC INV permutes shades.
-- The "paper" shade of the active display mode: what a DMG-white background
-- pixel ends up as once colorization has run. Every palette in the SGB pack
-- carries the same off-white (255,239,255) as color 0, so this is well
-- defined for the whole screen rather than per zone. Goes through
-- PaletteFX.pal so OG RED short-circuits to the one global boot-ROM BG
-- palette and RED++ falls back to the ROM pack, then through effectiveColors
-- so the mono and inverted modes get their own paper (CLASSIC's pea green,
-- and the dark paper the inverted modes should have). Returns r, g, b in
-- 0..1, white if nothing resolves.
function PaletteFX.paperShade(data)
local base = PaletteFX.pal(data, "GREENBAR") or PaletteFX.GRAYS
local colors = PaletteFX.effectiveColors(base) or base
local c = colors and colors[1]
if not c then return 1, 1, 1 end
return c[1] / 255, c[2] / 255, c[3] / 255
end
function PaletteFX.effectiveColors(c) function PaletteFX.effectiveColors(c)
if not c then return nil end if not c then return nil end
local mode = PaletteFX.mode or "gbc" local mode = PaletteFX.mode or "gbc"
local out = c
if mode == "og" then if mode == "og" then
return PaletteFX.GRAYS out = PaletteFX.GRAYS
elseif mode == "og_inv" then elseif mode == "og_inv" then
return PaletteFX.permute(PaletteFX.GRAYS, INV_MAP) out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
elseif mode == "classic" then elseif mode == "classic" then
return PaletteFX.CLASSIC out = PaletteFX.CLASSIC
elseif mode == "gbc_inv" then elseif mode == "gbc_inv" then
return PaletteFX.permute(c, INV_MAP) out = PaletteFX.permute(c, INV_MAP)
end end
return c -- The shade map goes on LAST: rBGP is a hardware register write, so it
-- composes on top of whatever colors the display mode settled on -- a dark
-- cave has to read dark in CLASSIC's pea greens and in plain DMG grays too
-- (#322). permute() is the identity (and returns `out` itself, which the
-- mod-graphics parity check leans on) while nothing has armed one.
return PaletteFX.permute(out, shadeMap)
end end
-- send a 4-color (0-255 RGB) palette to the shade-remap shader, after -- send a 4-color (0-255 RGB) palette to the shade-remap shader, after
+47 -8
View File
@@ -15,8 +15,15 @@ local Runtime = require("src.mods.Runtime")
local Renderer = {} local Renderer = {}
-- The Game Boy surface. WIDTH/HEIGHT are the classic dimensions every
-- screen is laid out in; uiWidth/uiHeight are the surface actually
-- allocated this frame, which a state may widen through setUISize (the
-- widescreen battle layout asks for 304x144). Anything drawing a normal
-- 160x144 screen can keep reading WIDTH/HEIGHT.
Renderer.WIDTH = 160 Renderer.WIDTH = 160
Renderer.HEIGHT = 144 Renderer.HEIGHT = 144
Renderer.MAX_UI_WIDTH = 640
Renderer.MAX_UI_HEIGHT = 576
-- Whether a value is a real Canvas we can composite. Real LOVE canvases are -- Whether a value is a real Canvas we can composite. Real LOVE canvases are
-- userdata answering typeOf("Canvas"); the headless test stub fakes them as -- userdata answering typeOf("Canvas"); the headless test stub fakes them as
@@ -83,7 +90,8 @@ function Renderer:init()
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua -- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
-- (#208). Every canvas below is sized in framebuffer pixels for the same -- (#208). Every canvas below is sized in framebuffer pixels for the same
-- reason -- worldViewSize() already works in drawable pixels. -- reason -- worldViewSize() already works in drawable pixels.
self.canvas = PixelCanvas.new(self.WIDTH, self.HEIGHT, "nearest") self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
self.worldCanvas = nil self.worldCanvas = nil
self.worldActive = false self.worldActive = false
-- tilt mode only: a transparent overlay canvas the size of the world -- tilt mode only: a transparent overlay canvas the size of the world
@@ -115,7 +123,31 @@ end
-- units via / dpiX and / dpiY when drawing. -- units via / dpiX and / dpiY when drawing.
function Renderer:fitScale() function Renderer:fitScale()
local _, _, pw, ph = displayMetrics() local _, _, pw, ph = displayMetrics()
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT))) local w, h = self:uiSize()
return math.max(1, math.floor(math.min(pw / w, ph / h)))
end
-- the native-pixel UI surface in use right now
function Renderer:uiSize()
return self.uiWidth or self.WIDTH, self.uiHeight or self.HEIGHT
end
-- Ask for a UI surface of w x h native pixels; the canvas is reallocated
-- only when the size actually changes, so the classic path never rebuilds
-- it. Sizes are resolved before any state draws (Game:draw) and bounded on
-- both ends -- never smaller than the Game Boy screen every layout assumes,
-- never large enough for a bad request to allocate an unbounded canvas.
function Renderer:setUISize(w, h)
if type(w) ~= "number" or type(h) ~= "number"
or w < self.WIDTH or h < self.HEIGHT
or w > self.MAX_UI_WIDTH or h > self.MAX_UI_HEIGHT then
w, h = self.WIDTH, self.HEIGHT
end
w, h = math.floor(w), math.floor(h)
if w == self.uiWidth and h == self.uiHeight and self.canvas then return end
if self.canvas and self.canvas.release then self.canvas:release() end
self.uiWidth, self.uiHeight = w, h
self.canvas = PixelCanvas.new(w, h, "nearest")
end end
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer -- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
@@ -174,6 +206,9 @@ function Renderer:beginFrame(transparent)
-- draws this one -- draws this one
PaletteFX.clearTrueColor() PaletteFX.clearTrueColor()
PaletteFX.clearSpriteRedraws() PaletteFX.clearSpriteRedraws()
-- rBGP is a per-frame register here: the state that draws a dark map
-- re-arms it while it draws (#322), so nothing inherits last frame's
PaletteFX.setShadeMap(nil)
PaletteFX.setPass("ui") PaletteFX.setPass("ui")
love.graphics.setCanvas(self.canvas) love.graphics.setCanvas(self.canvas)
if transparent then if transparent then
@@ -440,10 +475,11 @@ function Renderer:endFrame(zones, worldZones)
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY). -- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
local Sp = self:fitScale() local Sp = self:fitScale()
local Sx, Sy = Sp / dpiX, Sp / dpiY local Sx, Sy = Sp / dpiX, Sp / dpiY
local vpw, vph = self.WIDTH * Sx, self.HEIGHT * Sy local uiw, uih = self:uiSize()
local vpw, vph = uiw * Sx, uih * Sy
-- Snap the letterbox origin to a framebuffer pixel, then convert to units. -- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpiX local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpiY local oy = math.floor((ph - uih * Sp) / 2) / dpiY
local GBCFX = require("src.render.GBCFX") local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state -- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap. -- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -480,8 +516,11 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setCanvas(present) love.graphics.setCanvas(present)
end end
-- Default letterbox is black. Battle (and any state that opts in via -- Default letterbox is black. Battle (and any state that opts in via
-- letterboxWhite) fills the voids white so the window matches the -- letterboxWhite) fills the voids with the display mode's paper shade, so
-- white battle canvas instead of showing black bars. -- the bars match the canvas they frame instead of showing black. Not a
-- literal white: the battle canvas is colorized, and in SGB mode its paper
-- is the pack's off-white (255,239,255), which a hardcoded 1,1,1 framed in
-- a visibly brighter border.
local clearR, clearG, clearB = 0, 0, 0 local clearR, clearG, clearB = 0, 0, 0
if not self.worldActive then if not self.worldActive then
local ok, Game = pcall(require, "src.core.Game") local ok, Game = pcall(require, "src.core.Game")
@@ -489,7 +528,7 @@ function Renderer:endFrame(zones, worldZones)
local base = stack and stack.visibleBase and stack:visibleBase() local base = stack and stack.visibleBase and stack:visibleBase()
local state = base and stack.states and stack.states[base] local state = base and stack.states and stack.states[base]
if state and state.letterboxWhite then if state and state.letterboxWhite then
clearR, clearG, clearB = 1, 1, 1 clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
end end
end end
love.graphics.setColor(clearR, clearG, clearB, 1) love.graphics.setColor(clearR, clearG, clearB, 1)
+26 -5
View File
@@ -18,10 +18,14 @@ local function getImage(path)
return imageCache[path] return imageCache[path]
end end
-- RED++ overworld sprite OBJ-palette recolor (color/sprites.asm -- Overworld sprite OBJ-palette recolor, baked into an ImageData like
-- ColorOverworldSprite), baked into an ImageData like BattleState's mon-pic -- BattleState's mon-pic palette bake (src/battle/BattleState.lua getImage):
-- palette bake (src/battle/BattleState.lua getImage): CPU-remap the 4 DMG -- CPU-remap the 4 DMG shades to the resolved OBP colors, cached per
-- shades to the resolved OBP colors, cached per (image path, group). -- (image path, group). Every colour mode goes through it now (#301): RED++
-- resolves real per-sprite colours (color/sprites.asm ColorOverworldSprite),
-- OG RED the one boot-ROM object palette, and everything else the plain
-- rOBP0 = $D0 shade lift (PaletteFX.dmgObj) that leaves the sprite in DMG
-- shades for the zone shader to colour.
-- --
-- Sprite sheets carry no real alpha (every pixel, including the -- Sprite sheets carry no real alpha (every pixel, including the
-- background, is opaque -- confirmed by sampling the extracted PNGs): the -- background, is opaque -- confirmed by sampling the extracted PNGs): the
@@ -110,7 +114,13 @@ function SpriteRenderer:resolveImage()
-- collide in obpCache) -- see issue #155 -- collide in obpCache) -- see issue #155
return getObpImage(self.def.image, PaletteFX.ogObj()) return getObpImage(self.def.image, PaletteFX.ogObj())
end end
return self.image -- Every other mode (SGB and the mono/inverted novelties) leaves the sprite
-- in DMG shades so the zone shader colors it out of the map's own palette,
-- but still bakes rOBP0 = $D0 in and keys OBJ color 0 to alpha -- the two
-- things a raw sheet blit cannot express (#301, #150). The sheets carry no
-- real alpha (see getObpImage), so returning self.image here would put an
-- opaque white box behind every character a pipeline textures.
return getObpImage(self.def.image, PaletteFX.dmgObj())
end end
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate -- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
@@ -152,6 +162,17 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
-- top. -- top.
image = getObpImage(self.def.image, PaletteFX.ogObj()) image = getObpImage(self.def.image, PaletteFX.ogObj())
redraw = true redraw = true
else
-- SGB and the mono/inverted modes (and OG RED's tilt upright pass, which
-- has no post-zone replay to restore a bake): the sprite stays in DMG
-- shades -- rOBP0 = $D0 baked in, OBJ color 0 keyed to alpha -- and the
-- whole-canvas zone shader colors it with the map's palette. That is the
-- only thing the Super Game Boy can do to an OBJ, since pokered never
-- sends the OBJ_TRN packet that would give sprites palettes of their own
-- (data/sgb/sgb_packets.asm defines ATTR_BLK / PAL_SET / PAL_TRN /
-- MLT_REQ / CHR_TRN / PCT_TRN and nothing else). No redraw is queued:
-- being colorized by the zone IS the point (#301).
image = getObpImage(self.def.image, PaletteFX.dmgObj())
end end
-- single-frame sprites (item balls, fossils...) have one fixed pose; -- single-frame sprites (item balls, fossils...) have one fixed pose;
-- still 3-frame sprites turn to face (the nurse at her machine, -- still 3-frame sprites turn to face (the nurse at her machine,
+51 -5
View File
@@ -27,6 +27,11 @@ local MAX_COLS = 18
-- WaitForSoundToFinish; nil headless), then auto.delay frames pass -- WaitForSoundToFinish; nil headless), then auto.delay frames pass
-- (default 3, Delay3) and the box pops itself + calls onDone. No -- (default 3, Delay3) and the box pops itself + calls onDone. No
-- blinking cursor, no Press_AB beep. -- blinking cursor, no Press_AB beep.
-- opts.auto.tick, when given, runs once per frame for as long as the box
-- is held open. It is the only per-frame hook a script has while a box is
-- up: StateStack updates the top state only, so the overworld and its
-- ScriptRunner are frozen underneath (the Pewter JIGGLYPUFF dance drives
-- its spin off it, data/scripts/story5.lua, #249).
function TextBox.new(game, text, onDone, opts) function TextBox.new(game, text, onDone, opts)
local self = setmetatable({}, TextBox) local self = setmetatable({}, TextBox)
self.game = game self.game = game
@@ -92,13 +97,28 @@ end
-- pokered ContText waits for A/B + ▼ before scrolling that line in. -- pokered ContText waits for A/B + ▼ before scrolling that line in.
function TextBox.paginate(text, maxCols) function TextBox.paginate(text, maxCols)
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS
-- maxCols is a column count, so the budget is that many vanilla 8px
-- cells. Measuring in pixels rather than columns is what lets a mod's
-- variable-advance page wrap correctly (#186).
local budget = maxCols * 8
local pages = {} local pages = {}
local contBefore = {} local contBefore = {}
-- Soft-wrap on glyph boundaries, never byte boundaries: a line is over
-- budget by what it *draws*, and the cut falls between glyphs so a
-- multi-byte char is never torn in half.
local function pushLine(lines, conts, line, wait) local function pushLine(lines, conts, line, wait)
while #line > maxCols do while true do
local cut = maxCols local spans = Font.split(line)
for i = maxCols, 1, -1 do local fit = Font.spansFitting(spans, budget)
if line:sub(i, i) == " " then cut = i break end if fit >= #spans then break end
-- a glyph wider than the whole box still has to advance by one
fit = math.max(fit, 1)
local cut = spans[fit].to
for i = fit, 1, -1 do
if line:sub(spans[i].from, spans[i].to) == " " then
cut = spans[i].to
break
end
end end
table.insert(lines, line:sub(1, cut)) table.insert(lines, line:sub(1, cut))
table.insert(conts, wait) table.insert(conts, wait)
@@ -164,9 +184,28 @@ function TextBox:update(dt)
self.autoSrc = self.auto.sound and self.auto.sound() or nil self.autoSrc = self.auto.sound and self.auto.sound() or nil
self.autoTimer = 0 self.autoTimer = 0
end end
-- auto.tick: one call per frame for as long as the box is held open,
-- run before the autoSrc gate so a tick-driven gate can clear itself
-- on the same frame. It is the only per-frame hook a script gets
-- while a box is up, since StateStack updates the top state only and
-- the overworld underneath is frozen (the Pewter JIGGLYPUFF spin,
-- #249).
if self.auto.tick then self.auto.tick() end
if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then
return -- the cry is still sounding (WaitForSoundToFinish) return -- the cry is still sounding (WaitForSoundToFinish)
end end
-- auto.wait: the pet-NPC cries (PewterNidoranHouseNidoranText,
-- ViridianNicknameHouseSpearowText) have nothing queued behind the
-- cry, so DisplayTextID's trailing WaitForTextScrollButtonPress still
-- runs once it is over -- their maps enable auto text box drawing,
-- which zeroes wDoNotWaitForButtonPressAfterDisplayingText
-- (home/window.asm AutoTextBoxDrawingCommon). Drop the auto gate and
-- hand the box to the plain A/B path, which also starts the blinking
-- arrow, instead of popping it (#247, #251).
if self.auto.wait then
self.auto = nil
return
end
self.autoTimer = self.autoTimer + 1 self.autoTimer = self.autoTimer + 1
local delay = self.auto.delay or 3 local delay = self.auto.delay or 3
-- auto.onOverlap: fired once when the delay elapses but before the -- auto.onOverlap: fired once when the delay elapses but before the
@@ -267,10 +306,17 @@ function TextBox:draw()
self.scrollPx = self.scrollPx - 2 self.scrollPx = self.scrollPx - 2
if self.scrollPx <= 0 then self.scrollPx = nil end if self.scrollPx <= 0 then self.scrollPx = nil end
end end
-- Only the retained line carries the offset: it slides up from where it
-- already sat (line2Y) to line1Y. The incoming line is drawn at its home
-- row instead, because offsetting it too put fresh glyphs 8px low -- on the
-- box's bottom border -- whenever the typewriter beat the 4-frame slide
-- (#314). The sub-tile slide is ours to begin with: ScrollTextUpOneLine
-- (home/text.asm:283) copies the rows up whole and waits 5 frames, so
-- nothing in the original is ever drawn between two rows.
local off = self.scrollPx or 0 local off = self.scrollPx or 0
local ys = { self.line1Y, self.line2Y } local ys = { self.line1Y, self.line2Y }
for i, line in ipairs(self.shown) do for i, line in ipairs(self.shown) do
local y = (ys[i] or self.line2Y) + off local y = (ys[i] or self.line2Y) + (i == 1 and off or 0)
for j, code in ipairs(line) do for j, code in ipairs(line) do
Font.drawCode(code, self.textX + (j - 1) * 8, y) Font.drawCode(code, self.textX + (j - 1) * 8, y)
end end

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