Compare commits

..

120 Commits

Author SHA1 Message Date
bryanthaboi 5ee4a3cd35 Merge pull request #1778 from bryanthaboi/dev
some fixes
2026-08-24 12:38:35 -04:00
bryanthaboi 4794ddcfdf chris (and all gen2) fix 2026-08-24 12:35:35 -04:00
bryanthaboi 6503e982db potential fix for shaders 2026-08-24 12:10:15 -04:00
github-actions 72ce1fe33e chore(ios): update app-repo.json [skip ci] 2026-08-24 11:46:51 -04:00
bryanthaboi 493fca7297 Merge pull request #1776 from bryanthaboi/dev
chris1.1 and bonus additions
2026-08-24 11:36:23 -04:00
bryanthaboi 7d5856f93b Merge pull request #1774 from thibautbus/fix/translate-pcmenu-changebox-save
Translate the PC's CHANGE BOX save flow
2026-08-24 11:33:52 -04:00
bryanthaboi ef43814efc Merge pull request #1773 from ShaneMcGovernIE/codex/launcher-navigation-performance
perf(launcher): keep MODS and MOD INDEX navigation responsive
2026-08-24 11:33:43 -04:00
bryanthaboi bed1062a5f chris 1.1 hotfix 2026-08-24 11:32:42 -04:00
Shane McGovern 190c03cfca perf(launcher): keep mod index navigation responsive 2026-08-24 15:24:10 +01:00
github-actions cec264ad81 chore(ios): update app-repo.json [skip ci] 2026-08-24 10:23:37 -04:00
bryanthaboi 041051e154 cartkit: skip the scaffold round trip when there is no engine checkout
The generated cart release workflow curls cartkit.py into RUNNER_TEMP and
runs the selftest from the cart repo, where find_repo() finds nothing and
scaffold exits non-zero, so every cart release failed at the first step.

Report failures and skips even under --quiet; the workflow passes it, so
the only output was the FAIL count with no indication of which check.
2026-08-24 10:20:27 -04:00
bryanthaboi 92fd4221a0 Merge pull request #1772 from bryanthaboi/dev
SPIDER CAR 2 delivery
2026-08-24 10:12:06 -04:00
bryanthaboi 07c828cab0 fixerino 2026-08-24 10:10:59 -04:00
bryanthaboi b89771508e Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-24 10:00:57 -04:00
bryanthaboi 2c1c2fe080 Create liblibrashader_bridge.dylib 2026-08-24 10:00:42 -04:00
bryanthaboi 6f4c59df8b Merge pull request #1771 from bryanthaboi/spidercar2
CLOSES #1483, CLOSES #1610, CLOSES #1615, CLOSES #1646, CLOSES #1649, CLOSES #1651, CLOSES #1653, CLOSES #1656, CLOSES #1683, CLOSES #1685, CLOSES #1686, CLOSES #1687, CLOSES #1688, CLOSES #1689, CLOSES #1690, CLOSES #1693, CLOSES #1694, CLOSES #1695, CLOSES #1696, CLOSES #1702, CLOSES #1704, CLOSES #1705, CLOSES #1706, CLOSES #1707, CLOSES #1708, CLOSES #1710, CLOSES #1711, CLOSES #1712, CLOSES #1713, CLOSES #1716, CLOSES #1717, CLOSES #1718, CLOSES #1719, CLOSES #1720, CLOSES #1721, CLOSES #1725, CLOSES #1732, CLOSES #1745, CLOSES #1748, CLOSES #1749, CLOSES #1751, CLOSES #1754
2026-08-24 09:58:53 -04:00
thibautbus 0d14980205 Translate the PC's CHANGE BOX save flow (PcMenu.lua)
PcMenu.lua's own CHANGE BOX save confirmation stayed in English on every
non-English build: its overwrite/saving/done prompts and its YES/NO choice
were bare literals, invisible to a translation mod's `strings` registry,
even though the overwrite/saving prompts are the exact same two cart
messages Gold's SAVE screen (SaveMenu.lua) already routes through
Strings().

SaveMenu.lua now exports OVERWRITE_PROMPT_SOURCE, SAVING_PROMPT_SOURCE and
twoLines() so PcMenu:savePrompt() can call through them instead of
duplicating the two shared prompts. The now-unused plain
OVERWRITE_PROMPT/SAVING_PROMPT tables (PcMenu's old direct-read contract)
are gone along with them, removing a second copy of the same literal text
that had to be kept in sync by hand. The CHANGE BOX confirm prompt itself
("#MON BOX, data\nwill be saved. OK?") gets its own new Strings.source()
key -- it is not shared with SaveMenu, and the port's own English text is
already a same-shape truncation of the cart's three-line
_ChangeBoxSaveText, confirmed against poke-corpus GoldSilver
en_msg.txt:4897. The done message and YES/NO now go through the exact
same Strings() keys SaveMenu.lua already uses ("%s saved\nthe game.",
"Could not save.", "YES", "NO"), so a translation covering SaveMenu's
screen needs no PcMenu-specific fork for any of those.

Added tests/engine/gen2_pcmenu_changebox_save_translation_test.lua,
mirroring gen2_save_menu_translation_test.lua's technique for PcMenu's own
CHANGE BOX flow, and updated the stale cross-file-contract regression case
in gen2_save_menu_translation_test.lua to match the new shared-export
shape instead of the old plain-table one.
2026-08-24 15:55:08 +02:00
bryanthaboi 1bd6e35931 copy rintro 2026-08-24 09:02:56 -04:00
bryanthaboi da73a4fe25 Update crystal_version_test.lua 2026-08-24 08:51:20 -04:00
bryanthaboi cd4ef14f13 Merge branch 'dev' into spidercar2 2026-08-24 08:48:24 -04:00
bryanthaboi d575a64287 Merge pull request #1734 from BoLayerDev/feature/mew-dock-occupancy-seam
Add composable map occupancy and active-block APIs
2026-08-24 08:37:27 -04:00
bryanthaboi ff826ce01e Merge pull request #1769 from MaxTomahawk/adaptive-trainers/mod-developer-mode
feat(mod-api): expose loader developer mode
2026-08-24 08:35:49 -04:00
bryanthaboi 0ab4ef2755 Merge pull request #1766 from MaxTomahawk/adaptive-trainers/field-residual-hook
feat(mod-api): expose field residual descriptors
2026-08-24 08:35:10 -04:00
bryanthaboi 6f343d9b29 Merge pull request #1746 from campavao/engine-mod-seams
engine: generic seams for mod-driven multiplayer modes
2026-08-24 08:34:33 -04:00
bryanthaboi 41790637f0 Merge pull request #1738 from thibautbus/fix/translate-gold-naming-screen
Translate Gold's naming/keyboard screen
2026-08-24 08:32:22 -04:00
bryanthaboi 4ab0ac433b Merge pull request #1736 from thibautbus/fix/gold-ttf-printthrough-invisible-text
Stop drawing invisible TTF text on Gold's palette-shaded screens
2026-08-24 08:32:13 -04:00
bryanthaboi 44c304574b Merge pull request #1727 from dlloa/upstream-cache-contract-1724
Centralize ROM cache readiness and publication
2026-08-24 08:31:50 -04:00
bryanthaboi c8b05e7aa1 Merge pull request #1722 from syybott/fix-mod-options-marker
Add opt-in marker for mod options screens
2026-08-24 08:30:25 -04:00
bryanthaboi 8c6c360ce0 Merge pull request #1715 from caorthann-celt/dev
Enable UWP HTTP requests and Skin Studio controller support
2026-08-24 08:30:09 -04:00
bryanthaboi 6b6b8f46a1 Merge pull request #1735 from thibautbus/fix/translate-gold-options-menu
Translate Gold's in-game OPTION screen
2026-08-24 08:29:21 -04:00
bryanthaboi 2d62805128 Merge pull request #1747 from 1Jamie/android-restart-perf
Refactor session management and resource cleanup
2026-08-24 08:29:10 -04:00
bryanthaboi d27c59b18d Merge pull request #1759 from thibautbus/fix/translate-gold-title-and-save-menus
Translate Gold's title menu and SAVE screen
2026-08-24 08:29:00 -04:00
bryanthaboi 1905261c5b Spider Car Unleashed
CLOSES #1483, CLOSES #1610, CLOSES #1615, CLOSES #1646, CLOSES #1649, CLOSES #1651, CLOSES #1653, CLOSES #1656, CLOSES #1683, CLOSES #1685, CLOSES #1686, CLOSES #1687, CLOSES #1688, CLOSES #1689, CLOSES #1690, CLOSES #1693, CLOSES #1694, CLOSES #1695, CLOSES #1696, CLOSES #1702, CLOSES #1704, CLOSES #1705, CLOSES #1706, CLOSES #1707, CLOSES #1708, CLOSES #1710, CLOSES #1711, CLOSES #1712, CLOSES #1713, CLOSES #1716, CLOSES #1717, CLOSES #1718, CLOSES #1719, CLOSES #1720, CLOSES #1721, CLOSES #1725, CLOSES #1732, CLOSES #1745, CLOSES #1748, CLOSES #1749, CLOSES #1751, CLOSES #1754
2026-08-24 07:52:05 -04:00
MaxTomahawk 333949ce0d fix(mods): normalize developer mode option 2026-08-24 11:13:30 +02:00
MaxTomahawk fb95dc5b6a docs(gen2): correct developer mode guidance 2026-08-24 10:56:05 +02:00
MaxTomahawk 96ed862b50 docs(mod-api): clarify developer mode sources 2026-08-24 10:51:09 +02:00
MaxTomahawk 58681fe62e feat(mod-api): expose developer mode 2026-08-24 10:38:31 +02:00
MaxTomahawk 2ad2e028d1 feat(mod-api): expose field residual descriptors 2026-08-24 09:32:34 +02:00
thibautbus 654650e3ff Translate Pokegear's AM/PM too, and warn instead of corrupting a 3rd line
An independent review flagged two gaps:

Pokegear.lua's clock card (drawClock and the card-list clock view) prints
the same AM/PM information MainMenu's clock box does, but was left as bare
literals -- so a mod translating AM/PM would now show it correctly on the
title screen and in English on the Pokegear for the exact same hour. Both
call sites now go through Strings("AM")/Strings("PM"), the same source key
MainMenu.lua already added, so no new corpus entry is needed on the
translation-mods side.

SaveMenu.lua's twoLines() only ever split on the first "\n": drawPanel's
box has room for exactly two Chrome.print calls, so a translation needing
a third line (nothing in the current fr/de/es/it overrides does, but
nothing stopped one from trying) had nowhere on screen to go, and the
extra "\n" landed as a literal newline byte drawn as glyph garbage on the
second line with no indication anything was wrong. It now warns once per
source text instead.

Added a gen2_save_menu_translation_test.lua case driving drawPanel() with a
three-line mod translation: confirms only the first two lines render, the
warning fires exactly once (not once per frame), and drawing does not
crash. Pokegear.lua has no engine-tier test suite to extend for the AM/PM
change; verified by direct inspection and a syntax check instead.
2026-08-23 21:49:06 +02:00
thibautbus fad7443f94 Route Gold's title menu and SAVE screen through Strings()
MainMenu.lua's row labels (CONTINUE/NEW GAME/OPTION/EXIT GAME), its clock
box's AM/PM half, and its CONTINUE save-summary panel (PLAYER <name>/
BADGES/POKéDEX/TIME, or NO SAVE FILE) were bare literals, invisible to a
translation mod's strings registry. SaveMenu.lua's confirm/overwrite/
saving/saved prompts, its YES/NO choice, and its own copy of the same
summary panel had the same gap. Both mirror the Gen 1 port's already-
translated equivalents (src/ui/TitleState.lua, src/ui/StartMenu.lua),
which route every one of these rows through Strings().

SaveMenu's two-line prompts (module-level OVERWRITE_PROMPT/SAVING_PROMPT,
plus the dynamically-built "%s saved\nthe game." and the confirm/failed
messages) are now single Strings()-resolved strings with an embedded "\n",
matching the Gen 1 port's own single-call convention for two-line messages,
split into the two-slot table drawPanel's fixed-position Chrome.print calls
expect only at draw time -- so a translation sees one whole sentence to
reorder, not two independently-translated fragments.

Added tests/engine/gen2_main_menu_translation_test.lua and
gen2_save_menu_translation_test.lua: drive both screens' drawPanel()/
drawSavePanel() with a mod-loaded Strings catalog and check the translated
text reaches Font.draw, plus a vanilla no-mod case proving the fallback is
unchanged. Confirmed both catch the regression: reverting either file to
its pre-fix content fails the corresponding suite (7 and 15 checks
respectively).
2026-08-23 20:57:01 +02:00
bryanthaboi f895293217 Teach custom carts about Crystal
Both features landed independently and each was green on its own, so the gap
only appears in the combination.

- tools/cartkit.py BASES did not list crystal, so cartkit rejected a Crystal
  cart as an unknown base game. The Lua validator never had this problem: it
  asks GameVersion.VERSIONS, so it accepted Crystal the moment the row existed.
- Two self-tests used the literal "crystal" as their never-a-game sentinel and
  invert now that Crystal is real: tests/engine/cart_manifest.lua and
  cartkit's own t_identity. Both re-pointed at "nonesuch", the token the rest
  of the suite already uses for this.
2026-08-23 12:21:50 -04:00
bryanthaboi ebf44f20d3 Add custom carts: named, version-pinned mod sets that play as their own game
A custom cart pairs an identity (title, shell colour, label art) with a base
game, a list of mods pinned to exact builds with their option values frozen,
a load order, and a seal. It ships no code of its own: every mod it names is
a separately published mod, which is what keeps a cart auditable before it
runs and reproducible after an author's repo disappears.

Format and storage:
- src/carts/CartManifest.lua parses and validates cart.json, canonicalises it
  for hashing and reads/writes the .g1rcart bundle. The bundle is a data-only
  serialised table read through SaveSerializer, so an imported cart can never
  execute code. Canonical strings are length-prefixed because option keys and
  values are author-controlled and could otherwise forge a record boundary and
  collide two different carts onto one hash.
- Pins name a public source: a GitHub release with its sha256, a GameBanana
  file id with its md5, or "local" for a capture that only exists on this
  install. A local pin is unpublishable by construction, which is what makes
  "build it here, publish later" possible without inventing a hash.
- Label art rides alongside the manifest rather than inside its identity, so
  re-arting a cart does not tell every player their run is out of date.
  src/core/Base64.lua decodes it; strict, with no whitespace tolerance.

Saves:
- Cart playthroughs live in the cart's own slot namespace (saves/cart_<id>/),
  so a cart's file never sits beside a vanilla one and uninstalling a cart
  never orphans a save. Every save records the cart build it was made under.

The seal:
- A sealed cart loads its pinned list, in its order, with its options, and
  nothing else. A pinned mod with no frozen options gets an empty bucket so
  unfrozen keys fall to schema defaults, identical for everyone; otherwise two
  players on one cart quietly run different games.
- A sealed cart refuses to load when a pin is missing or installed at another
  version. Playing a subset of the cart is the exact dishonesty the seal
  exists to prevent, so the refusal loads nothing at all.
- Breaking the seal is permanent, marked per save slot, and downgrades that
  playthrough to open behaviour. It cannot be cleared through any public API.

Launcher:
- A game's page carries a Custom Carts control and a picker; choosing a cart
  turns the page into that cart's page, with its own cartridge, title and save
  slots. The rail of five games never grows and a cart id never reaches
  imp.tab or imp.panelVersion.
- Loader.planCart runs before boot so a refusal is visible on the page instead
  of being discovered as an error after launch.
- Save as cart captures the enabled mods for a game and names, before the
  player confirms, every mod that could only be pinned to this install and
  whether the result can be shared at all.

Authoring:
- tools/cartkit.py scaffolds, validates, pins and packs a cart, and installs a
  release workflow. Its writer is byte-identical to the engine's serialiser.
2026-08-23 12:18:29 -04:00
bryanthaboi ca4d3d283c Add Pokemon Crystal as a sixth supported version
Crystal boots from a user-supplied ROM, imports a full cache and is playable:
copyright, the Crystal intro movie, the animated title, gender select, Oak,
and out into Johto. 122 of the cart's 169 script specials are implemented.

Import and data
- tools/make_crystal_manifest.py derives the manifest by importing
  make_gold_manifest as a library, with three additive keyword seams. Gold and
  Silver still regenerate byte-identical, which is the standing requirement for
  touching that generator.
- crystal_symbol_deltas.py and crystal_movie_symbols.py carry the symbol delta:
  Crystal renames the credits mons, splits the trainer card, Pokegear and
  pack-pal blocks by gender, and replaces the intro and title outright.
- Crystal-only manifest keys: engineFlagOrder (162 flags to Gold's 93, so the
  badge block sits one higher) and unownCharmap (the main charmap parser stops
  at the first newcharmap so the two cannot contaminate each other).

Extractor
- RomExtractorGen2 becomes three-edition. Crystal corrections: PAL_MAP_BANK
  0x13, a flat PICS_FIX pic bank, audio bank 0x5e, the mapSongs id-100 hole,
  seven NPC trades, a TradeTexts stride of 8, the five Crystal tileset anim
  steps with per-row degrade, and the column-major trainer card portraits.
- New: animated front sprites (frames, bitmasks, play and idle scripts), the
  Battle Tower roster, Kris assets, Mobile System GB art, and the Crystal
  intro and title via src/import/CrystalMovie.lua.

Engine
- GameVersion gains engine(id) and fixes(id). Gold and Silver keep their
  original bugs where the bug is not hardware dependent; Crystal gets the fixes
  Crystal shipped: Lucky Number boxes 10-14, surfing onto an NPC, and the
  Reflect and Light Screen defence overflow.
- Crystal story: Suicune and Eusine, Celebi behind the GS Ball flag, the Ruins
  of Alph chambers, Buena, the Move Tutor, the Poke Seer, and the Battle Tower
  including the wInBattleTowerBattle badge-boost guard.
- Kris and the gender flag, animated fronts in battle and the summary screen,
  and mon caught data.

Verification
- Every extracted asset is pixel-compared against pret's own source PNGs.
- Gold caches are byte-identical before and after, file for file.
- New Crystal suites plus a T2 Gen 2 tier; the full suite passes.
2026-08-23 12:10:28 -04:00
1jamie 5807b8d836 Refactor session management and resource cleanup
- Replaced `teardownMountedSession` and `flushEditorPackageLoaded` with a unified `SessionLifecycle` approach for managing session transitions and resource cleanup.
- Implemented `SessionLifecycle.endEditorSession` and `SessionLifecycle.endGameSession` to streamline the teardown process for editor and game sessions.
- Introduced `Assets.releaseSession` to handle GPU resource release at session end, ensuring efficient memory management.
- Updated `Game` and `Game2` reset methods to include world and canvas resource releases.
- Enhanced `MapLoader` with a new `releaseAll` method for eager GPU cache cleanup.
- Added tests to verify the new session lifecycle functionality and resource management.

This commit improves the stability and performance of in-process transitions, particularly during editor and game session changes.

further improves and addresses #1662 specifically around android gc pressure.
2026-08-23 10:46:19 -05:00
DESKTOP-8SRFDDM\cam95 b550db3abb rfc: guard the new seams and write RFC 0014
Route B in CONTRIBUTING-mods.md asks an event/hook change for five things.
This adds the two that were missing and fixes what the third turned up.

link.battle_ended built its payload unconditionally. Route B is explicit
that a new event must not allocate when nothing wants it, and every other
emit in the engine already guards -- Runtime.wants now gates this one too,
so an unsubscribed build runs the branch exactly as it did.

world.talk was handing Runtime.call a closure built fresh on every A press,
purely to have a fallthrough to pass. It is a file-local now, so an unhooked
press allocates nothing it did not allocate before.

The RFC covers motivation, the API delta with call sites, migration (nothing
changes for existing mods), and verification. The backward-compatibility
statement is in it: every item is a new name or a new optional argument, and
example_mew_starter -- api 1, category = "GAMEPLAY", whole-species copy --
still loads, which run_modkit proves on every run.

No registry or schema field is added, so gen_registry_docs has nothing to
emit for this change. Running it does show pre-existing drift in
docs/modding/reference/registries.md (timeFishGroups, an objects refinement)
from earlier Schemas.lua edits that were never regenerated; that is not this
branch's to carry, so it is left alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 10:35:12 -05:00
DESKTOP-8SRFDDM\cam95 01c5eb2189 tests, docs: cover world.talk and link.battle_ended
gate_meta_coverage asks every extension point for a unit test through the
public mod API, a no-mod parity test, and docs. The seams commit shipped the
call sites and owed the rest.

Both cases drive the real engine path, and both assert the unhooked build
first, so they fail if the seam is deleted and if it changes vanilla
behaviour. world_talk stands an object on the faced cell: with no mod the A
press reaches talkTo, with a mod that owns the object it does not, and an
object the mod ignores still falls through. link_battle_ended parks a
session at the end of a battle and checks the event carries the result, the
role, and both party copies.

The parity side needed nothing. gate_hooks and gate_events walk the live
catalog, so both seams were covered structurally as soon as the call sites
existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 10:19:06 -05:00
DESKTOP-8SRFDDM\cam95 dd0f0e0892 engine: seams for mod-driven world actors and link sessions
Five small additions. None of them mention the mod that prompted them.

WorldAPI handles gain stepNow, canStep, placeAt, isMoving and setPassable.
scriptMove queues onto OverworldState.scriptMoves, and a non-empty
scriptMoves is how the overworld knows a cutscene is running, so it gates
handleInput -- an actor animated that way freezes the player's controls for
as long as it walks. Right for Oak marching to his lab, wrong for anything
moving on its own schedule. stepNow sets the same per-tile state without the
queue. It skips the collision check on purpose: the caller is replaying a
move that was decided somewhere else, and re-judging it here would let the
two copies disagree about where the actor is. canStep is there for callers
that do want the map's opinion.

OverworldController raises world.talk around the NPC talk path. An object
from spawnNpc has no TEXT_* id, so the vanilla path has nothing to say for
one; a mod that owns the object wraps this and does not call next.

LinkState.newFromSession adopts a transport that is already paired, so a
mode can tunnel a battle through a connection it already has rather than
opening a second one. The hello and fingerprint exchange still runs. When
the battle ends, link.battle_ended carries the result and both lockstep
party copies -- cable rules leave the real party alone, so that is the only
place the damage exists.

Game:startNewGame(opts) is the title's NEW GAME closure made callable, with
opts.intro=false to land straight in the world. CodeEntry.new takes an
optional {length=, charset=} so the same widget can enter an address or a
room code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 10:18:41 -05:00
Bo Layer d17f0725d8 fix(mod-api): validate active block storage 2026-08-23 03:20:43 -06:00
Bo Layer 3b7fc42858 test(mod-api): gate Mew dock seams 2026-08-23 03:20:23 -06:00
Bo Layer c9221daa90 fix(mod-api): fail closed on malformed map blocks 2026-08-23 03:19:19 -06:00
Bo Layer 0147e3d87b feat(mod-api): add composable map occupancy seam 2026-08-23 03:17:27 -06:00
Caorthann fb2cebd54f Add controller support to Skin Studio 2026-08-23 09:16:44 +01:00
Caorthann cb504e5328 Update UWP LÖVE backend for HTTP requests 2026-08-23 09:16:44 +01:00
david bdbdbf2ed6 Centralize ROM cache publication contract 2026-08-22 18:11:30 -07:00
github-actions 7ae469603c chore(ios): update app-repo.json [skip ci] 2026-08-22 19:22:00 -04:00
bryanthaboi 77e189bc2e Merge pull request #1726 from bryanthaboi/dev 2026-08-22 19:13:03 -04:00
bryanthaboi e88f2ef060 Merge pull request #1723 from 1Jamie/bullet-train-anim-launcher-lc-yellow-pallet-g1-link-flow 2026-08-22 18:37:09 -04:00
1jamie 16eefcde68 Implement session teardown and resource management for in-process transitions
- Introduced `teardownMountedSession` to handle cleanup of mounted versions, generated data, and mod states during transitions between the editor and launcher.
- Added `flushEditorPackageLoaded` to evict save-editor modules from `package.loaded` dynamically, ensuring a clean state for subsequent sessions.
- Implemented `Game:reset` and `Game2:reset` methods to clear session-specific fields, allowing for a fresh start when returning to the launcher.
- Enhanced `Renderer` and `TileRenderer` to release GPU resources immediately, preventing memory leaks during rapid transitions.
- Updated `MagnetTrainRide` to support OAM priority overlays and manage background rendering with new shader functionality.

This commit improves the stability and performance of the application during in-process transitions, particularly on Android.

fixes Yellow color palette for title screen

fixes #1662 #1643 #1536 and finishes fixing #1597
2026-08-22 17:08:14 -05:00
syybott 6d55da0ed2 Add mod options screen marker 2026-08-22 16:39:11 -05:00
thibautbus 745374d8ae Cover the naming screen translation fix with a targeted test
Drives NamingScreen:drawPanel() with a mod-loaded Strings catalog and
checks the translated text reaches Font.draw for the prompt, a
keyboard cell, the lower/END bottom-row labels, and both lines of the
nickname header (checking the mod's word order actually took, not just
the mon name splicing in), plus a vanilla no-mod case proving the
fallback is unchanged. Reverting src/ui/gen2/NamingScreen.lua to its
pre-fix content fails 6 of the 9 checks, reproducing the reported gap
exactly.
2026-08-22 20:26:08 +02:00
thibautbus 3470e3c4ae Translate Gold's naming/keyboard screen
src/ui/gen2/NamingScreen.lua had zero Strings() calls: every prompt
(YOUR NAME?/RIVAL'S NAME?/MOTHER'S NAME?/BOX NAME?/NICKNAME?), the
on-screen keyboard's own letters, and the lower/UPPER/DEL/END bottom
row were bare literals, invisible to a translation mod's `strings`
registry (reported against a real Gold build, gen1recomp#1642). The
Gen 1 naming screen (src/ui/NamingScreen.lua) already routes its title
and every keyboard cell through Strings(); this mirrors that.

NamingScreen.TYPES and the bottom-row label tables are built once at
require time, before any mod's Strings.load has a catalog to answer
from, so their literals are wrapped in Strings.source -- an identity
marker that keeps the catalog generator's harvest of them -- and
drawPanel resolves them live through plain Strings() calls, the same
split used for Gold's OPTION screen and PR #1450's clock screens.

The nickname header ("<MON>'S" then "NICKNAME?", two lines because
Chrome.printThrough draws one row at a time) folds the mon name into
the first line's own format string, Strings("%s'S", self.monName),
rather than translating just the "'S" suffix: a language whose
possessive is not a bare suffix appended to the name can restructure
that whole line instead of being stuck splicing one on.
2026-08-22 20:25:52 +02:00
thibautbus f76a8b2f96 Cover the invisible-TTF-text fix with a targeted test
No real shader runs headless (love_stub does not stub newShader), so
this cannot check a rendered pixel. Font.encode/drawCode/advanceOf/
width are replaced with fakes that hand printThrough a fixed list of
glyph codes, so what is checked is the two things that decide the
outcome: which glyphs skip the shade-remap shader, and what colour is
active when each one draws -- for an all-tile string, an all-TTF one,
a string that mixes both (proving the switch is per glyph, not per
call), an inverted call, and DMG mode's own resolved palette.

Reverting src/ui/gen2/Chrome.lua to its pre-fix content fails every
TTF-mode check. A per-string version of the fix (Font.ttfActive()
checked once for the whole call instead of per glyph) also fails the
mixed-string case here, since it would flatten the naming screen's
tile-based <PK>/<MN> cells to a plain ink tint whenever a TTF is
loaded at all, losing their shader-remapped colour even though they
never draw from the TTF.
2026-08-22 20:10:27 +02:00
thibautbus 24a7ff86d4 Stop drawing invisible TTF text on Gold's palette-shaded screens
Chrome.printThrough (src/ui/gen2/Chrome.lua) runs a string through the
GbcPalette shade-remap shader whenever a palette is given, so a screen
whose background is not white -- the naming/keyboard screen, Diploma,
Pokegear, Credits -- gets its text colour resolved from the same
palette as everything else on it. The shader recovers a shade by
reading the RED CHANNEL of an already-rasterized 2bpp tile pixel
(src/render/GbcPalette.lua's SHADER_SOURCE), which assumes the texture
underneath is one of the four flat GB shades.

A TTF glyph is not: LÖVE's font rasterizer stores glyph coverage as
alpha over a plain white texture and lets the current tint carry the
ink colour, so that same red-channel read always comes back 1.0 --
shade 0 -- painting every TTF character the exact colour of the paper
rect printThrough had just drawn behind it. Since a mod loads a TTF
through src/core/Strings.lua's font registry precisely so a
translation is not boxed into a handful of tile-page glyphs, every
translation mod that ships one hit this: reported against a real Gold
build where the naming screen's on-screen keyboard, Diploma and the
Pokegear all went blank the moment the mod's TTF took over rendering
(gen1recomp#1642). Gen 1 has no such shader, which is why the same mod
draws fine there.

A TTF glyph carries no discrete shade to recover in the first place,
so skip the shader for one and tint it with the palette's own ink
colour directly -- the same shade-3 entry the shader's `rgb = pal3`
branch would have mapped a black tile pixel to.

The switch is per GLYPH rather than per string: a TTF-mod build still
keeps multi-byte charmap sequences (the naming screen's own <PK>/<MN>
cells, the 'd/'l/'s ligatures) and anything a mod names in ttf.tiles on
their ROM tiles (src/render/Font.lua's Font.split), so one call can mix
both kinds of glyph, and a tile glyph drawn alongside a TTF one still
needs the shader to pick up a mod's COLOR/rBGP settings correctly.
2026-08-22 20:10:13 +02:00
thibautbus c2e1d810f9 Cover the Gold OPTION screen translation fix with a targeted test
Drives OptionsMenu:drawPanel() with a mod-loaded Strings catalog and
checks the translated text reaches Font.draw for a cart-original row
(label and its display value), a port-added row's label, and CANCEL,
plus a vanilla no-mod case proving the fallback is unchanged. Reverting
src/ui/gen2/OptionsMenu.lua to its pre-fix content fails 4 of the 9
checks, reproducing the reported bug exactly.
2026-08-22 19:15:43 +02:00
thibautbus 6d6ccb5778 Translate Gold's in-game OPTION screen
src/ui/gen2/OptionsMenu.lua had zero Strings() calls: every row label
and every cart-original display value (FAST/MID/SLOW, ON/OFF,
SHIFT/SET, MONO/STEREO, the PRINT contrast ladder, :TYPE) was a bare
literal baked into the module-level ROWS table, invisible to a
translation mod's `strings` registry (reported against a real Gold
build, gen1recomp#1642). The Gen 1 OPTION screen (src/ui/OptionsMenu.lua)
already routes every one of its equivalent rows through Strings().

ROWS is built once at require time, before any mod's Strings.load has
a catalog to answer from, so wrapping its literals directly in
Strings() would freeze the English (src/core/Strings.lua's own note on
this). Labels and cart-original display strings are wrapped in
Strings.source instead -- an identity marker that keeps the catalog
generator's harvest of them, without changing when the lookup runs --
and drawPanel resolves them live through plain Strings() calls, the
same split PR #1450 used for the clock and day-of-week screens.

Also routed the two port-added rows (SCREEN POS, VIBRATION) and the
touch pad ON/OFF row through Strings() at their value call sites,
matching how the Gen 1 screen already treats the same shared
ScreenPosition.label/TouchControls.hapticLabel helpers -- both return
raw, untranslated text on their own. Left MUSIC VOL/SFX VOL's OFF/level
readout and the MUSIC FILTER ladder (OFF/1X/2X/3X) untranslated, since
that mirrors the Gen 1 screen's own established choice for the same
two helpers.
2026-08-22 19:15:34 +02:00
bryanthaboi d54f9a02e0 Merge pull request #1680 from colsonrice/feat/gen2-rom-text 2026-08-22 11:08:18 -04:00
Colson Rice e094b73536 Give Gen 2 the ROM text table Gen 1 has had all along
Gold and Silver had no label-keyed string table at all.  The manifests
carried no text section, RomExtractorGen2 had no extractText, and
game.data.text was never assigned, so every call through
src/core/RomText.lua fell back to the literal written beside it.  The only
Gen 2 text the cache held was the script text in data/generated/text.lua,
keyed by bank:address for the overworld VM, which nothing can look a battle
line up in.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

RFC: docs/rfcs/0012-gen2-exp-award-announce.md
Docs: docs/mod-api-gen2-compat.md gains the reading and the residual
      omitted-argument difference beside the existing payload note.
Tests: tests/gen2_exp_share_test.lua grows the two Route B tests -- the
       no-mod parity case and the seam driven through hooks:wrap -- and
       its 23 existing checks are unchanged.
2026-08-21 18:07:36 -03:00
bryanthaboi 087a275189 skins 2026-08-21 16:36:55 -04:00
github-actions 63b31d234c chore(ios): update app-repo.json [skip ci] 2026-08-21 15:11:23 -04:00
bryanthaboi 44f4680b24 Merge pull request #1652 from bryanthaboi/dev
skin studio fix
2026-08-21 15:01:51 -04:00
bryanthaboi 06e06e305b skin studio fix 2026-08-21 14:38:54 -04:00
github-actions 464fb47756 chore(ios): update app-repo.json [skip ci] 2026-08-21 14:21:21 -04:00
bryanthaboi fe3746fd1a Merge pull request #1650 from bryanthaboi/dev
big freaking junk off
2026-08-21 14:12:38 -04:00
bryanthaboi 0e407dca7a Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-21 14:03:27 -04:00
bryanthaboi b0ff1552f2 skins fixerino 2026-08-21 14:03:25 -04:00
bryanthaboi d191aaa34d Merge pull request #1616 from HighDrexler/fix/large-required-imports-v2
Fix/large required imports v2
2026-08-21 09:59:17 -04:00
bryanthaboi a1a70540b8 Merge pull request #1645 from MaxTomahawk/adaptive-trainers/charge-required-hook
feat(mod-api): expose charge decision hook
2026-08-21 09:19:17 -04:00
bryanthaboi 5c1837b1eb maybe smoother mobile scroll on installed mods 2026-08-21 09:17:06 -04:00
MaxTomahawk bb0f156497 test(mod-api): cover Gold charge continuations 2026-08-21 14:52:11 +02:00
MaxTomahawk 4b0496bad1 feat(mod-api): expose charge decision hook 2026-08-21 14:40:51 +02:00
bryanthaboi a56add6d17 screen position fixes for gen 2 2026-08-21 08:30:44 -04:00
bryanthaboi 48b39c8519 CLOSES #1570 2026-08-21 08:23:21 -04:00
HighDrexler decaa006b2 test: lock large imports to streaming path after confirmation 2026-08-21 08:01:00 -04:00
HighDrexler c0bd4a35be test(mods): fix streamed import rewind assertion 2026-08-21 07:33:24 -04:00
bryanthaboi 8c0d0ace4d CLOSES #1367, CLOSES #1585, CLOSES #1611, CLOSES #1612, CLOSES #1636, CLOSES #1638, CLOSES #1641 2026-08-21 07:14:47 -04:00
bryanthaboi c11c762f15 Merge pull request #1634 from 1Jamie/game-corner-extraction-fix 2026-08-21 05:20:28 -04:00
HighDrexler c465f58006 fix(mods): harden streamed import cleanup 2026-08-20 23:46:02 -04:00
1jamie 780246c4f6 feat: add deinterleave function and enhance graphics extraction for Slots and Card Flip
Implemented a new deinterleave function in ImageWriter to restore row-major order for PNGs. Updated RomExtractorGen2 to utilize this function for extracting Slot Machine graphics, and added new functions for padding and writing graphics sheets. Updated the ROM manifests to include necessary symbols for Slots and Card Flip assets.
2026-08-20 20:40:37 -05:00
1jamie 5714555847 feat: enhance Goldenrod Game Corner graphics extraction
Added functions to write raw graphics assets for the Slot Machine and Card Flip games, ensuring that the necessary files are generated when the corresponding symbols are present in the ROM. Updated the manifest to include required symbols for these assets, preventing fallback to labelled cells when graphics are missing.
2026-08-20 20:23:39 -05:00
github-actions 738f7317d7 chore(ios): update app-repo.json [skip ci] 2026-08-20 20:51:52 -04:00
bryanthaboi 5e514335c1 Merge pull request #1632 from bryanthaboi/dev
saves and saves
2026-08-20 20:40:07 -04:00
1jamie f5b8b6c85f feat: implement Diploma screen visual assets and add version-specific blink logic to TownMap
Fix for #1595 #1597 #1589 #1613 and maybe 1520.
2026-08-20 20:37:51 -04:00
bryanthaboi db25c14dfb save sync update 2026-08-20 20:36:23 -04:00
bryanthaboi 90163a3ff2 CLOSES #1619 2026-08-20 20:07:10 -04:00
github-actions f63707c45b chore(ios): update app-repo.json [skip ci] 2026-08-20 18:46:51 -04:00
bryanthaboi 7756fdc3cb Merge pull request #1622 from bryanthaboi/dev
fix android because goia broke it
2026-08-20 18:37:33 -04:00
bryanthaboi 4bdb9435a4 fix android because goia broke it 2026-08-20 18:36:29 -04:00
github-actions 5cbde96177 chore(ios): update app-repo.json [skip ci] 2026-08-20 17:44:02 -04:00
HighDrexler 911e11a372 test(mods): document and cover streamed import access 2026-08-20 17:14:32 -04:00
github-actions[bot] 2d28d18bf6 fix(mods): support large required imports end-to-end 2026-08-20 21:10:01 +00:00
HighDrexler 0b70d6c535 chore: stage prepared large-import PR patch 2026-08-20 17:09:40 -04:00
456 changed files with 93043 additions and 3800 deletions
+21
View File
@@ -468,3 +468,24 @@ jobs:
if [ "$found" = "0" ]; then
echo "no committed mods to lint"
fi
luacheck:
name: engine lint (luacheck)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: install luacheck
run: |
set -e
sudo apt-get update
sudo apt-get install -y lua5.4 liblua5.4-dev luarocks
sudo luarocks install luacheck || sudo apt-get install -y lua-check
luacheck --version
- name: luacheck gate (undefined globals, unreachable code)
run: ./scripts/lint.sh --gate
- name: luacheck full report (advisory)
continue-on-error: true
run: ./scripts/lint.sh
+5
View File
@@ -23,6 +23,10 @@ read_globals = {
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
-- it; without this, every `table.unpack` reads as an undefined field.
table = { fields = { "unpack" } },
"POKEPORT_DISPLAY_COMPANION",
"POKEPORT_EDITOR_MODE",
"rawlen",
package = { fields = { "searchers" } },
}
-- Vendored/native trees and the test suites have their own conventions.
@@ -30,6 +34,7 @@ exclude_files = {
"mobile/",
"tests/",
"tools/save-editor/",
"tools/save_convert/vendor/",
}
ignore = {
+16 -14
View File
@@ -140,8 +140,8 @@ ship text.
### 4. `games` (and the legacy `gen2compat`)
Pokemon Gold and Silver are Gen 2, and they run their own battle engine,
overworld, script VM and save format. The mod API is shared across both
Pokemon Gold, Silver and Crystal are Gen 2, and they run their own battle
engine, overworld, script VM and save format. The mod API is shared across both
generations (same hook names, same event names, same registry names) but Gen 2
cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
@@ -150,19 +150,20 @@ cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
```
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`,
`"silver"`), a generation (`"gen1"`, `"gen2"`) or `"all"`;
`"silver"`, `"crystal"`), a generation (`"gen1"`, `"gen2"`) or `"all"`;
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
restates the game list. `python3 tools/modkit.py scaffold my_mod --games
gen1,gen2` writes the key for you. The mod still installs to one directory,
`mods/<id>/`, shared by every game -- targeting is declared, never filed.
restates the game list, which is why `"gen2"` covers Crystal as well as Gold
and Silver. `python3 tools/modkit.py scaffold my_mod --games gen1,gen2` writes
the key for you. The mod still installs to one directory, `mods/<id>/`, shared
by every game -- targeting is declared, never filed.
Absent means Gen 1 only, which is what every mod written before the key existed
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
already ran on. On a Gold or Silver boot a mod claiming no Gen 2 game is not
loaded at all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why,
because a mod that half-applies reads as a broken mod. Claim Gen 2 once you
have actually run your mod on Gold or Silver.
already ran on. On a Gen 2 boot a mod claiming no Gen 2 game is not loaded at
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually
run your mod on Gold, Silver or Crystal.
Every token is enforced, per game: the loader gates on the same
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]`
@@ -172,10 +173,11 @@ manifest with neither key still covers every Gen 1 game, so nothing written
before the key existed changes behavior; list both generations or say `"all"`
when you mean everywhere.
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
and Silver today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1,
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
writes with a report, and which hooks and events are still to come.
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold,
Silver and Crystal today (40 of the 46 registries, 40 event and 44 hook names
shared with Gen 1, and 24 Gen 2-only ones), which registries have no Gen 2 home
and drop their writes with a report, and which hooks and events are still to
come.
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
Gen 1 mod, and it is the one to start from.
+16 -12
View File
@@ -56,14 +56,16 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
[![Watch the latest update video](https://img.youtube.com/vi/yi7LkWQPKKM/maxresdefault.jpg)](https://youtu.be/yi7LkWQPKKM)
This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold, or
Silver ROM is the only game content input.
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold,
Silver, or Crystal ROM is the only game content input.
The ROM is verified, used during import, and then released from memory. It is
not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again. Red, Blue, Yellow, Gold, and Silver can all be
imported side by side. Gold and Silver are Gen 2 Phase 1 (import + launcher;
see `docs/gold-phase1.md`): the Gen 2 engine is still under construction.
do not ask for the ROM again. Red, Blue, Yellow, Gold, Silver, and Crystal can
all be imported side by side. Gold, Silver, and Crystal are Gen 2 Phase 1
(import + launcher; see `docs/gold-phase1.md`): the Gen 2 engine is still under
construction, and Crystal is the newest of the three, so the launcher lists it
as Crystal (Beta).
## Quick Start
@@ -71,14 +73,17 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` /
`.gbc` file or drop it onto the window. Import takes a few seconds and the
game starts automatically.
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, and Silver (2 MiB)
ROMs are accepted. The importer verifies SHA-1 before creating any game data:
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, Silver, and Crystal
(2 MiB) ROMs are accepted. The importer verifies SHA-1 before creating any
game data:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
- Silver: `49b163f7e57702bc939d642a18f591de55d92dae`
- Crystal (1.0): `f4cd194bdee0d04ca4eac29e09b8e4e9d818c133`
- Crystal (1.1): `f2f52230b536214ef7c9924f483392993e226cfb`
The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact
@@ -122,24 +127,23 @@ supported out of the box.
| `2` | Cycle COLORS |
| `3` | Cycle TILT (free-roam overworld) |
| `4` | Cycle ZOOM through every level (free-roam overworld) |
| `5` | Cycle GBC FX |
| `F1` | Save |
| `F2` | Load |
| `F10` | Open / close the mod manager |
COLORS, TILT, ZOOM, GBC FX, GAME SPEED, and VOID FILL are also in the
COLORS, TILT, ZOOM, SHADER FX, GAME SPEED, and VOID FILL are also in the
Options menu and persist in `options.lua`.
### Low-end devices
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX),
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt),
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
normal desktops → HIGH, unchanged). It only scales presentation; the
fixed-step game logic is identical on every tier, and a lower tier hides
your tilt/zoom/GBC-FX preferences without forgetting them. Details in
your tilt/zoom preferences without forgetting them. Details in
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
### Rulesets
@@ -219,7 +223,7 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
| Option | Effect |
| --- | --- |
| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold` and `silver` too, or just `r` / `b` / `y` / `g` / `s`) |
| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold`, `silver` and `crystal` too, or just `r` / `b` / `y` / `g` / `s` / `c`) |
| `--slot=2` | load that save slot; takes a slot number or a slot id |
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
+1 -1
View File
@@ -134,7 +134,7 @@ mkdir -p "$GAME_SRC"
main.lua conf.lua src libs data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$WORK/game-payload.zip" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
+3 -13
View File
@@ -92,7 +92,7 @@ mkdir -p "$GAME_SRC"
main.lua conf.lua src libs data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
printf '%s\n' "$payload_list" \
@@ -102,6 +102,8 @@ printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|| fail "payload is missing tools/rom_manifest_gold.json"
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_silver.json" \
|| fail "payload is missing tools/rom_manifest_silver.json"
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_crystal.json" \
|| fail "payload is missing tools/rom_manifest_crystal.json"
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
rm -f "$WORK/game-payload.zip"
@@ -228,11 +230,6 @@ 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
@@ -323,13 +320,6 @@ Native LÖVE 11.5 port of gen1recomp for Anbernic RG34XXSP on
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
+6
View File
@@ -66,6 +66,12 @@ function love.conf(t)
t.modules.audio = not companion
t.modules.joystick = not companion
t.modules.physics = false
-- love.sensor exposes raw accelerometer/gyroscope data (love.sensor.getData),
-- independent of t.accelerometerjoystick below (which instead maps the
-- accelerometer onto joystick axes and stays off -- see that flag's
-- comment for why). Explicit here so a future effect (e.g. a tilt-driven
-- reflective-screen look) has sensor data available without reviving #468.
t.modules.sensor = true
-- love.system is not loaded during love.conf; love._os is set by the
-- engine before conf runs (LÖVE 11.x / 11.5).
+17 -7
View File
@@ -36,19 +36,29 @@ M.POKEMON_FAN_CLUB = {
{ "clear_flag", "EVENT_SEEL_FAN_BOAST" }, -- 8
},
-- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm): the
-- PIKACHU itself, just a flavor line (its cry isn't playable in the
-- port's talk pipeline, so it's dropped like other cry-only lines).
-- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm:71): PrintText,
-- then ld a, PIKACHU / call PlayCry (:76) / call WaitForSoundToFinish.
TEXT_POKEMONFANCLUB_PIKACHU = {
{ "show_text", "_PokemonFanClubPikachuText" },
{ "play_cry", "PIKACHU", true }, -- 1 PlayCry (#1649)
{ "show_text", "_PokemonFanClubPikachuText" }, -- 2 PrintText
},
-- PokemonFanClubSeelText (scripts/PokemonFanClub.asm): the SEEL
-- itself, flavor line only.
-- PokemonFanClubSeelText (scripts/PokemonFanClub.asm:84): the same
-- shape with SEEL, PlayCry at :89.
TEXT_POKEMONFANCLUB_SEEL = {
{ "show_text", "_PokemonFanClubSeelText" },
{ "play_cry", "SEEL", true }, -- 1 PlayCry (#1649)
{ "show_text", "_PokemonFanClubSeelText" }, -- 2 PrintText
},
},
}
-- pokeyellow/scripts/PokemonFanClub.asm:149 PokemonFanClubClefairyText: Yellow's
-- pet is a CLEFAIRY on its own TEXT_POKEMONFANCLUB_CLEFAIRY, PlayCry at :154.
if require("src.core.GameVersion").isYellow() then
M.POKEMON_FAN_CLUB.talk.TEXT_POKEMONFANCLUB_CLEFAIRY = {
{ "play_cry", "CLEFAIRY", true }, -- 1 PlayCry
{ "show_text", "_PokemonFanClubClefairyText" }, -- 2 PrintText
}
end
return M
+3 -2
View File
@@ -1,10 +1,11 @@
-- pokered/scripts/SSAnne1FRooms.asm: SSAnne1FRoomsWigglytuffText
-- text_far _SSAnne1FRoomsWigglytuffText; then ld a, WIGGLYTUFF / call PlayCry (cosmetic cry sound, not ported)
-- pokered/scripts/SSAnne1FRooms.asm:66 SSAnne1FRoomsWigglytuffText
-- text_far _SSAnne1FRoomsWigglytuffText, then ld a, WIGGLYTUFF / call PlayCry (:70)
return {
SS_ANNE_1F_ROOMS = {
talk = {
TEXT_SSANNE1FROOMS_WIGGLYTUFF = {
{"face_player"},
{"play_cry", "WIGGLYTUFF", true}, -- 1 PlayCry (#1687)
{"show_text", "_SSAnne1FRoomsWigglytuffText"},
},
},
+3 -4
View File
@@ -1,12 +1,11 @@
-- pokered/scripts/SSAnneB1FRooms.asm: SSAnneB1FRoomsMachokeText
-- text_far _SSAnneB1FRoomsMachokeText, then `ld a, MACHOKE / call PlayCry`
-- (cry playback has no equivalent Commands.lua verb in this port, so only
-- the flavor text is ported).
-- pokered/scripts/SSAnneB1FRooms.asm:82 SSAnneB1FRoomsMachokeText
-- text_far _SSAnneB1FRoomsMachokeText, then ld a, MACHOKE / call PlayCry (:86)
return {
SS_ANNE_B1F_ROOMS = {
talk = {
TEXT_SSANNEB1FROOMS_MACHOKE = {
{ "face_player" },
{ "play_cry", "MACHOKE", true }, -- 1 PlayCry (#1687)
{ "show_text", "_SSAnneB1FRoomsMachokeText" },
},
},
+4 -3
View File
@@ -18,10 +18,11 @@ return {
-- VermilionCityMachopText: cries out, then follows up with a
-- second line about stomping the land flat.
-- (pokered/scripts/VermilionCity.asm)
-- (pokered/scripts/VermilionCity.asm:224, PlayCry at :228)
TEXT_VERMILIONCITY_MACHOP = {
{ "show_text", "_VermilionCityMachopText" }, -- 1
{ "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 2
{ "play_cry", "MACHOP", true }, -- 1 PlayCry (#1649)
{ "show_text", "_VermilionCityMachopText" }, -- 2
{ "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 3
},
},
},
@@ -1,14 +1,12 @@
-- pokered/scripts/VermilionPidgeyHouse.asm: VermilionPidgeyHousePidgeyText
-- text_far _VermilionPidgeyHousePidgeyText, then text_asm plays the PIDGEY
-- cry (ld a, PIDGEY / call PlayCry / call WaitForSoundToFinish) before
-- TextScriptEnd. This port has no cry-playback command, so only the
-- flavor line is ported.
-- pokered/scripts/VermilionPidgeyHouse.asm:15 VermilionPidgeyHousePidgeyText
-- text_far, then ld a, PIDGEY / call PlayCry (:19) / call WaitForSoundToFinish
return {
VERMILION_PIDGEY_HOUSE = {
talk = {
TEXT_VERMILIONPIDGEYHOUSE_PIDGEY = {
{"face_player"},
{"play_cry", "PIDGEY", true}, -- 1 PlayCry (#1649)
{"show_text", "_VermilionPidgeyHousePidgeyText"},
},
},
+1 -1
View File
@@ -111,7 +111,7 @@ local function joinPrompt(game, ow, done)
local t = game.data.text
local back = function(text)
game.stack:push(TextBox.new(game, text, function()
ow:scriptMove(ow.player, "down", 1, done)
ow:scriptMove(ow.player, "down", 1, done, { collide = true })
end))
end
game.stack:push(TextBox.new(game,
+39 -25
View File
@@ -127,7 +127,7 @@ M.VIRIDIAN_CITY = {
game.stack:push(TextBox.new(game,
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
or "You can't go\nthrough here!\fThis is private\nproperty!",
function() ow:scriptMove(ow.player, "down", 1) end))
function() ow:scriptMove(ow.player, "down", 1, nil, { collide = true }) end))
return true
end,
}
@@ -356,7 +356,7 @@ M.VERMILION_CITY = {
if shipLeft then
game.stack:push(TextBox.new(game,
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
function() ow:scriptMove(ow.player, "up", 1) end))
function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
return true
end
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
@@ -377,26 +377,39 @@ M.VERMILION_CITY = {
ask .. "\f"
.. (t._VermilionCitySailor1YouNeedATicketText
or "You need a ticket\nto get aboard."),
function() ow:scriptMove(ow.player, "up", 1) end))
function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
return true
end,
talk = {
-- the sailor guarding the dock gangway (VermilionCitySailor1Text):
-- flashing the ticket just lets you through -- he never hides, and
-- once the ship has sailed he only reports it gone
TEXT_VERMILIONCITY_SAILOR1 = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
{ "jump_if_true", 11 }, -- 3
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
{ "check_item", "S_S_TICKET" }, -- 5
{ "jump_if_false", 9 }, -- 6
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
{ "jump", 12 }, -- 8
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 9
{ "jump", 12 }, -- 10
{ "show_text", "_VermilionCitySailor1ShipSetSailText" }, -- 11
},
-- scripts/VermilionCity.asm:158 (#1651)
TEXT_VERMILIONCITY_SAILOR1 = function(game, ow, npc, done)
local Flags = require("src.script.Flags")
local TextBox = require("src.render.TextBox")
local t = game.data.text
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
game.stack:push(TextBox.new(game,
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
done))
return
end
-- scripts/VermilionCity.asm:195
local p = ow and ow.player
if not p or p.facing == "right"
or (p.cellX == 19 and (p.cellY == 29 or p.cellY == 31)) then
game.stack:push(TextBox.new(game,
t._VermilionCitySailor1WelcomeToSSAnneText
or "Welcome to S.S.\nANNE!", done))
return
end
local ask = t._VermilionCitySailor1DoYouHaveATicketText
or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?"
local tail = ((game.save.inventory.S_S_TICKET or 0) > 0)
and (t._VermilionCitySailor1FlashedTicketText
or "{PLAYER} flashed\nthe S.S.TICKET!")
or (t._VermilionCitySailor1YouNeedATicketText
or "You need a ticket\nto get aboard.")
game.stack:push(TextBox.new(game, ask .. "\f" .. tail, done))
end,
},
}
@@ -405,13 +418,14 @@ M.SS_ANNE_2F = {
TEXT_SSANNE2F_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 2
{ "jump_if_true", 9 }, -- 3
{ "jump_if_true", 9 }, -- 3 (beaten: silent)
{ "show_text", "_SSAnne2FRivalText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
{ "jump_if_false", 10 }, -- 6
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
{ "jump", 10 }, -- 9 (already beaten: silent)
-- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers
-- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688)
{ "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 6
{ "jump_if_false", 9 }, -- 7
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8
},
},
}
+25 -2
View File
@@ -414,7 +414,7 @@ local function saffronGate(guardText, triggers, horizontal)
game.stack:push(TextBox.new(game,
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
function()
ow:scriptMove(ow.player, back, 1)
ow:scriptMove(ow.player, back, 1, nil, { collide = true })
end))
return true
end,
@@ -731,12 +731,35 @@ M.MT_MOON_B2F = {
local function museumClerk(game, ow, done, onDecline)
local TextBox = require("src.render.TextBox")
local t = game.data.text or {}
local p = ow and ow.player
-- scripts/Museum1F.asm:45 (#1690)
if p and ((p.cellY == 4 and p.cellX == 13)
or (p.cellY == 3 and p.cellX == 12)) then
game.stack:push(TextBox.new(game,
t._Museum1FScientist1DoYouKnowWhatAmberIsText
or "You can't sneak\nin the back way!\fOh, whatever!\nDo you know what\vAMBER is?",
nil, { choice = function(yes)
game.stack:push(TextBox.new(game, yes
and (t._Museum1FScientist1TheresALabSomewhereText
or "There's a lab\nsomewhere trying\vto resurrect\vancient POKéMON\vfrom AMBER.")
or (t._Museum1FScientist1AmberIsFossilizedTreeSapText
or "AMBER is fossil-\nized tree sap."), done))
end }))
return
end
if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
game.stack:push(TextBox.new(game,
t._Museum1FScientist1TakePlentyOfTimeText
or "Take your time,\nand enjoy it all!", done))
return
end
-- scripts/Museum1F.asm:58
if p and p.cellY ~= 4 then
game.stack:push(TextBox.new(game,
t._Museum1FScientist1GoToOtherSideText
or "Please go to the\nother side!", done))
return
end
-- scripts/Museum1F.asm:72
local money = function() return game.save.money end
game.stack:push(TextBox.new(game,
@@ -769,7 +792,7 @@ M.MUSEUM_1F = {
if y == 4 and (x == 9 or x == 10)
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
museumClerk(game, ow, nil, function()
ow:scriptMove(ow.player, "down", 1)
ow:scriptMove(ow.player, "down", 1, nil, { collide = true })
end)
return true
end
+75 -80
View File
@@ -2,6 +2,8 @@
-- ghost, elevators, the Game Corner coins/prizes, the SS Anne departure
-- and the Hall of Fame record. Each cites its pokered source.
local Runtime = require("src.mods.Runtime")
local M = {}
-- -------------------------------------------------------------------
@@ -9,20 +11,30 @@ local M = {}
-- FuchsiaGoodRodHouse.asm, Route12SuperRodHouse.asm)
-- -------------------------------------------------------------------
local function rodGiver(askText, receivedText, afterText, rodItem, flag)
return {
{ "face_player" }, -- 1
{ "check_flag", flag }, -- 2
{ "jump_if_true", 9 }, -- 3
{ "ask", askText }, -- 4
{ "jump_if_false", 10 }, -- 5
-- refusedText is the .ThatsSoDisappointingText tail on NO; followText is
-- the second half of the received chain (scripts/VermilionOldRodHouse.asm:45)
local function rodGiver(askText, receivedText, afterText, rodItem, flag,
refusedText, followText)
local rows = {
{ "face_player" },
{ "check_flag", flag },
{ "jump_if_true", "already_got" },
{ "ask", askText },
{ "jump_if_false", "refused" },
-- give-then-print like the three rod-house scripts (GiveItem fills
-- wStringBuffer; the received texts read OLD/GOOD/SUPER ROD from it)
{ "give_item", rodItem, 1, false }, -- 6
{ "show_text", receivedText }, -- 7
{ "set_flag", flag }, -- 8
{ "jump", 10 }, -- 9 is below
{ "give_item", rodItem, 1, false },
{ "set_flag", flag },
{ "show_text", receivedText },
}
if followText then rows[#rows + 1] = { "show_text", followText } end
rows[#rows + 1] = { "jump", "end" }
rows[#rows + 1] = { "label", "refused" }
rows[#rows + 1] = { "show_text", refusedText }
rows[#rows + 1] = { "jump", "end" }
rows[#rows + 1] = { "label", "already_got" }
rows[#rows + 1] = { "show_text", afterText }
return rows
end
M.VERMILION_OLD_ROD_HOUSE = {
@@ -31,11 +43,11 @@ M.VERMILION_OLD_ROD_HOUSE = {
"_VermilionOldRodHouseFishingGuruDoYouLikeToFishText",
"_VermilionOldRodHouseFishingGuruTakeThisText",
"_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText",
"OLD_ROD", "EVENT_GOT_OLD_ROD"),
"OLD_ROD", "EVENT_GOT_OLD_ROD",
"_VermilionOldRodHouseFishingGuruThatsSoDisappointingText",
"_VermilionOldRodHouseFishingGuruFishingIsAWayOfLifeText"),
},
}
M.VERMILION_OLD_ROD_HOUSE.talk.TEXT_VERMILIONOLDRODHOUSE_FISHING_GURU[9] =
{ "show_text", "_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText" }
M.FUCHSIA_GOOD_ROD_HOUSE = {
talk = {
@@ -43,11 +55,10 @@ M.FUCHSIA_GOOD_ROD_HOUSE = {
"_FuchsiaGoodRodHouseFishingGuruText",
"_FuchsiaGoodRodHouseFishingGuruReceivedGoodRodText",
"_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText",
"GOOD_ROD", "EVENT_GOT_GOOD_ROD"),
"GOOD_ROD", "EVENT_GOT_GOOD_ROD",
"_FuchsiaGoodRodHouseFishingGuruThatsSoDisappointingText"),
},
}
M.FUCHSIA_GOOD_ROD_HOUSE.talk.TEXT_FUCHSIAGOODRODHOUSE_FISHING_GURU[9] =
{ "show_text", "_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText" }
M.ROUTE_12_SUPER_ROD_HOUSE = {
talk = {
@@ -55,11 +66,11 @@ M.ROUTE_12_SUPER_ROD_HOUSE = {
"_Route12SuperRodHouseFishingGuruDoYouLikeToFishText",
"_Route12SuperRodHouseFishingGuruReceivedSuperRodText",
"_Route12SuperRodHouseFishingGuruTryFishingText",
"SUPER_ROD", "EVENT_GOT_SUPER_ROD"),
"SUPER_ROD", "EVENT_GOT_SUPER_ROD",
"_Route12SuperRodHouseFishingGuruThatsDisappointingText",
"_Route12SuperRodHouseFishingGuruFishingWayOfLifeText"),
},
}
M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
{ "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" }
-- -------------------------------------------------------------------
-- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm
@@ -176,7 +187,7 @@ M.POKEMON_TOWER_6F = {
-- .did_not_defeat: one simulated step right, off the trigger,
-- so fleeing does not leave you standing on a cell that
-- immediately re-fires.
ow:scriptMove(ow.player, "right", 1)
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
end
ow:afterBattle(result, battle)
end
@@ -893,81 +904,65 @@ local DOCK_SHIP_BLOCKS = {
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
}
-- her four hull columns bow-to-stern (upper-half / lower-half block ids)
-- and the open-water ids of the rows she sits in
local DOCK_SHIP_COLUMNS = {
{ bx = 5, top = 4, bottom = 8 },
{ bx = 6, top = 5, bottom = 9 },
{ bx = 7, top = 6, bottom = 10 },
{ bx = 8, top = 7, bottom = 11 },
}
local DOCK_WATER_TOP, DOCK_WATER_BOTTOM = 1, 13
M.VERMILION_DOCK = {
onEnter = function(game, ow)
local Flags = require("src.script.Flags")
local f = game.save.flags
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
-- the ship is long gone: erase her right away, and anyone who
-- still lands here is sent back out past the guard
-- still lands here is sent back out past the guard unless a mod
-- explicitly permits this occupied map state. This hook surrounds
-- only the ejection decision; map-script registration and dispatch
-- stay unchanged, and the departed ship remains erased.
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
ow.map:setBlock(b.bx, b.by, b.water)
end
ow.map.renderer:rebuild()
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._VermilionCitySailor1ShipSetSailText
or "The ship set sail.", function()
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
end))
local occupancyAllowed = false
if Runtime.wantsHook("map.occupancy_allowed") then
local player = ow.player or {}
occupancyAllowed = Runtime.call("map.occupancy_allowed",
function() return false end, game, {
mapId = "VERMILION_DOCK",
reason = "ss_anne_departed",
gameVersion = game.save and game.save.version,
x = player.cellX,
y = player.cellY,
}) == true
end
if not occupancyAllowed then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._VermilionCitySailor1ShipSetSailText
or "The ship set sail.", function()
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
end))
end
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
-- Music_Surfing plays for the sail-away cutscene, smoke puffs
-- drift off the funnel, the horn blows, the ship is erased to
-- open water, and the player is walked off the dock into the
-- city past the guard (VermilionCity's
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
-- triggers the departure (wDestinationWarpID == 1 in pokered)
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
local Music = require("src.core.Music")
Music.stop()
Music.play(game.data, "Music_Surfing")
local function puff(n, cx)
if n <= 0 then return end
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
end
puff(3, 15)
-- scripts/VermilionDock.asm:182-203
local rows = {}
local function setBlock(bx, by, block)
if bx < 1 or bx > 8 then return end
rows[#rows + 1] = { "replace_block", bx, by, block }
end
rows[#rows + 1] = { "wait", 120 }
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
-- .shift_columns_up slides her tile columns west behind a mid-frame
-- rSCX split; with no split scroll here she sails one block per beat
-- and the water closes in astern (#360)
for step = 1, 8 do
for _, col in ipairs(DOCK_SHIP_COLUMNS) do
setBlock(col.bx - step, 1, col.top)
setBlock(col.bx - step, 2, col.bottom)
end
setBlock(9 - step, 1, DOCK_WATER_TOP)
setBlock(9 - step, 2, DOCK_WATER_BOTTOM)
rows[#rows + 1] = { "wait", 20 }
end
-- the second horn as she clears the dock, then EraseSSAnne's 120
-- frames before the walk out
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
rows[#rows + 1] = { "wait", 120 }
rows[#rows + 1] = { "move_player", "up", 2 }
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
-- cutscene, and VERMILION_CITY's own theme has to take over as the
-- player crosses in (EnterMap's PlayDefaultMusic)
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
rows[#rows + 1] = { "move_player", "up", 2 }
ow:queueScript(rows)
ow:queueScript({
-- scripts/VermilionDock.asm:50 zeroes the player image index and
-- :77 freezes sprite updates, so he faces DOWN throughout (#1689)
{ "face_player_dir", "down" },
{ "wait", 120 },
{ "play_sound", "SS_Anne_Horn" },
-- scripts/VermilionDock.asm:80 .shift_columns_up
{ "ss_anne_departs" },
-- scripts/VermilionDock.asm:205 VermilionDock_EraseSSAnne
{ "play_sound", "SS_Anne_Horn" },
{ "wait", 120 },
{ "move_player", "up", 2 },
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
-- cutscene, and VERMILION_CITY's own theme has to take over as the
-- player crosses in (EnterMap's PlayDefaultMusic)
{ "warp", "VERMILION_CITY", 18, 31, "up" },
{ "move_player", "up", 2 },
})
end
end,
}
+1 -1
View File
@@ -734,7 +734,7 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text[dontRunText] or "Don't run away!", function()
ow:scriptMove(ow.player, "up", 1)
ow:scriptMove(ow.player, "up", 1, nil, { collide = true })
end))
return true
end,
+11 -8
View File
@@ -240,7 +240,7 @@ local function stepGate(opts)
push(game, text(game)[opts.text] or opts.fallback, function()
ow.player.facing = opts.push
if not ow:checkLedgeHop(opts.push) then
ow:scriptMove(ow.player, opts.push, 1)
ow:scriptMove(ow.player, opts.push, 1, nil, { collide = true })
end
end)
return true
@@ -820,7 +820,8 @@ M.PEWTER_POKECENTER = {
-- on the west-side cells and walks you back
local function bikeGateGuard(coords, stopText, explainText)
return function(game, ow, x, y)
if game.save.inventory.BICYCLE then return false end
local bike = game.save.inventory.BICYCLE
if bike and bike ~= 0 then return false end
if not inCoords(coords, x, y) then return false end
-- walk the player up to the tile beside the counter, no further:
-- (matchedY - closestY) tiles, 0 when already next to it
@@ -842,10 +843,10 @@ local function bikeGateGuard(coords, stopText, explainText)
-- (PlayerMovingRightScript). Without it the player was left
-- parked beside the guard's counter with no way past. #518
local function shoveRight()
ow:scriptMove(ow.player, "right", 1)
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
end
if dist > 0 then
ow:scriptMove(ow.player, "up", dist, shoveRight)
ow:scriptMove(ow.player, "up", dist, shoveRight, { collide = true })
else
shoveRight()
end
@@ -921,10 +922,12 @@ M.SS_ANNE_2F = {
{ "move_npc_to", 2, 36, onLeft and 7 or 8 }, -- 2
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
{ "show_text", "_SSAnne2FRivalText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
{ "jump_if_false", 13 }, -- 6
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
-- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers
-- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688)
{ "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 6
{ "jump_if_false", 13 }, -- 7
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
{ "play_music", "Music_MeetRival", { start = "rival" } }, -- 10
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 11
-10
View File
@@ -55,16 +55,6 @@ In-game controls use the normal PortMaster / SDL pad map, rebindable under
## 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.
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
to AUTO, which reads this device as an ARM Linux handheld and resolves to
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
+27 -9
View File
@@ -24,7 +24,7 @@ The short version, for an author deciding what to write:
merged.** The write is taken, dropped, and named once per mod in the same
error feed the mod manager shows -- in both directions, so a Red boot writing
to `decorations` is told exactly as a Gold boot writing to `map_scripts` is.
- **40 event names and 43 hook names have a call site in both generations**, so
- **40 event names and 44 hook names have a call site in both generations**, so
one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua`
reads those names back out of the source and fails if a site is renamed or
deleted on either side, and fails again if a new shared site appears without
@@ -55,10 +55,10 @@ The short version, for an author deciding what to write:
```
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
`"gold"`, `"silver"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or
`"all"`. `src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER`
and `GameVersion.generation`, so nothing anywhere restates the game list.
`"gen2"` now expands to both Gold and Silver.
`"gold"`, `"silver"`, `"crystal"`), generations (`"gen1"`, `"gen2"`,
case-insensitive) or `"all"`. `src/mods/ModTargets.lua` resolves the tokens off
`GameVersion.ORDER` and `GameVersion.generation`, so nothing anywhere restates
the game list. `"gen2"` now expands to Gold, Silver and Crystal.
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
and **derives** `manifest.gen2compat` from them, which is the one field the
loader's gate reads.
@@ -469,8 +469,11 @@ is warned once per name and the rest of the list still runs. The engine's own
Gen 1 verbs are **not** seeded on Gold: a row-list verb handed Gold's ctx would
find no runner on it, so `data.commands` under Gen 2 is the mod verbs alone.
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`, exports.**
Generation-agnostic; nothing to adapt.
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`,
`mod.developer`, exports.** Generation-agnostic; nothing to adapt.
`mod.developer` is the same fixed boot-time boolean on both generations and is
available while the entry chunk runs. Gold does not gain Gen 1's developer
console or F5 hot-reload hotkey; the field reports the loader's mode only.
**`mod.world`.** Same method set, resolved against Gold's world
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
@@ -539,7 +542,8 @@ gains a field instead of the name gaining a prefix.
`battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`,
`battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`,
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
`battle.crit`, `battle.accuracy`, `battle.charge_required`,
`battle.turn_order`,
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
`battle.catch_exp`, `battle.bottom_ui_visible`,
@@ -549,6 +553,17 @@ gains a field instead of the name gaining a prefix.
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
`ctx.battle.data` instead gets nil on Gold.
`battle.exp_award`'s `ctx.applyShare(mon, split, announce)` reads its third
argument on both generations: truthy prints the mon's GainedText, falsy pays
it silently, so one mod source can print a single summary line for a
party-wide award instead of a box per recipient. Gold honours it **only when
it is passed**, by argument count -- `applyShare(mon, split)` was written
against a seam that always announced on Gold and keeps announcing there,
while `applyShare(mon, split, nil)` is silent on both. Pass the argument
explicitly and the two generations agree; omit it and Gen 1 stays silent
where Gold speaks. Only the line is affected: the exp, the stat exp,
`battle.exp_gained`, the level-up line, learned moves and the forget prompt
happen either way.
- *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
`pokemon.caught` once the mon is in the party or the box, and
@@ -561,7 +576,7 @@ gains a field instead of the name gaining a prefix.
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
-- the logic tick before the pad is read, a pointer the touch overlay gets
first refusal on, the palette zone list handed to the present pass, the
composed frame before GBCFX, the letterbox, and the finished playfield rect
composed frame before ShaderFX, the letterbox, and the finished playfield rect
-- and carries the same payload.
`render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
@@ -771,6 +786,9 @@ name and the existing payload, plus fields where Gen 2 genuinely carries more
The list is much shorter than it was. What is outstanding, in descending value:
- `battle.field_residual`: the first guarded call site is in Gen 1 end-of-round
processing. Gold already has a native weather/between-turn pipeline but does
not yet expose the shared data-only descriptor hook.
- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
`src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred
preparation boundary or a battle-local player-party view. Gen 1 mods can use
+260 -10
View File
@@ -122,21 +122,40 @@ Each object requires a stable `id`, a display `name`, a destination `file`
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
`description` gives players dump or region guidance in the import panel.
`size` declares the exact canonical byte length; `max_size` declares a smaller
per-import ceiling when an exact size is not appropriate. Every import also
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its
filesystem reports an invalid size.
per-import ceiling when an exact size is not appropriate. The engine hard limit
is 2 GiB. Imports above 128 MiB receive an explicit free-space confirmation and
use the launcher's streaming large-file path rather than being materialized as
one Lua string.
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
strips a recognized 512-byte copier header, converts the bytes to canonical
big-endian `.z64` order, and then checks MD5. The canonical bytes are written
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
mod: the launcher never scans or copies another mod's imported files merely
because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem
because its manifest names the same digest. Small sources can still be read
with the existing scoped `mod:read` API, for example
`mod:read("baseroms/stadium2.z64")`. For large sources, prefer the bounded
`mod.imports` facade described below; no host path or new general filesystem
permission is exposed. Missing `required_imports` block the mod before its
entry chunk runs; missing `optional_imports` remain visible in the same
launcher panel but do not block loading.
#### Bounded access to validated imports
A loaded mod can address only ids declared by its own `required_imports` or
`optional_imports` arrays:
```lua
local info, err = mod.imports:info("stadium2")
local header, err = mod.imports:read("stadium2", 0, 4096)
```
`read` uses zero-based offsets and is capped at 8 MiB per call. The engine
rechecks the stored import before exposing it, seeks into the engine-owned
copy, and never gives the mod a host path or file handle. This is intended for
large source formats whose table/index can be parsed with small reads before
selectively reading the payloads a transform actually needs.
MD5 here identifies a known dump because ROM databases commonly publish it;
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
@@ -209,6 +228,66 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
read-only snapshots; mods choose which layers to render. Red and Gold expose
the same contract while applying their own object and event visibility rules.
### Active Gen 1 block checks
Red, Blue, and Yellow expose
`mod.world:activeBlockAt(mapId, blockX, blockY)`. It returns the numeric block
ID at one zero-based block coordinate only when `mapId` is the active map.
The value is a scalar snapshot: changing it cannot change the map. This lets a
mod compare a small runtime map signature before it applies a lawful authored
replacement, without reading the mutable map or ROM cache through engine
internals.
The method fails closed. Before an overworld exists it returns
`nil, "no overworld"`; for a different active map it returns
`nil, "map is not active"`; non-numeric, non-finite, or fractional coordinates
return `nil, "invalid block coordinates"`; and negative or out-of-range
coordinates return `nil, "block coordinates out of bounds"`. An unavailable
or malformed active block returns `nil, "block unavailable"`. The caller must
require every expected cell to match before changing presentation. This method
is Gen 1-only; Gold callers receive no parity promise for it.
The same unavailable result covers missing or sparse active block storage and
an accessor result that does not match its validated active block slot.
### Conditional map occupancy
`map.occupancy_allowed` is a narrow Gen 1 hook around a map script's vanilla
decision to eject the player from an otherwise valid loaded map. Its first
call site is the post-departure `VERMILION_DOCK` branch. The ship has already
been erased when the hook runs, and the hook does not replace or suppress any
base or peer map handler.
The wrapper receives `(next, game, context)`. The dock context is a copied
`{ mapId = "VERMILION_DOCK", reason = "ss_anne_departed", gameVersion, x, y }`
record. Vanilla returns `false`. Return exactly `true` to allow the player to
remain; every other value denies occupancy and preserves the normal message
and warp. A composable wrapper calls downstream first and only adds its own
permission:
```lua
mod.hooks:wrap("map.occupancy_allowed", function(next, game, ctx)
local allowed = next(game, ctx)
local mine = ctx.mapId == "VERMILION_DOCK"
and ctx.reason == "ss_anne_departed"
and myPublicEligibilityCheck(game)
return allowed == true or mine == true
end)
```
With no wrapper, the hook allocates no context and vanilla behavior is
unchanged. A throwing wrapper is isolated by the normal hook bus. A nil,
string, number, table, or other malformed final answer fails closed. Disabling
or uninstalling the permitting mod therefore restores vanilla ejection without
changing the S.S. Anne story flag or restoring the ship.
Normal hook-chain ownership applies: a wrapper that does not call `next`
intentionally owns the final answer and does not run lower-priority wrappers.
Permission wrappers must call `next` as shown above to compose. A noncompliant
wrapper that returns false without calling `next` safely denies occupancy and
can suppress downstream permission by this standard rule. A malformed answer
also fails closed and cannot force occupancy.
## Party ordering
Companion UIs and alternate party screens can call
@@ -246,6 +325,80 @@ Red exposes FLY separately because it requires a destination picker:
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
Fly town list. Gold does not expose these two methods yet.
## Self-driven world actors
`mod.world:spawnNpc()` returns a handle whose `scriptMove` queues onto the
overworld's scripted-movement list. A non-empty list is how the overworld
knows a cutscene is running, so it gates player input for as long as the
actor walks -- right for Oak marching to his lab, wrong for an actor that
moves on its own schedule (a networked player's ghost, an ambient walker).
Five handle methods drive one without that lockout:
```lua
local ghost = mod.world:spawnNpc({ map = "ROUTE_1", x = 5, y = 7,
sprite = "SPRITE_RED" })
if ghost:canStep("up") then ghost:stepNow("up") end -- one tile, now
if not ghost:isMoving() then ghost:placeAt(9, 3, "down") end -- snap, no walk
ghost:setPassable(true) -- walk-through
```
`stepNow(dir)` sets the same per-tile state `scriptMove` does, minus the
queue. It deliberately does **not** check collision: a caller replaying a
move that was already decided elsewhere (validated on a peer's machine, or
authored) would let the two copies disagree about where the actor is if this
re-judged it. Ask `canStep(dir)` first when you do want the map's opinion.
`placeAt(x, y, facing)` snaps with no animation and clears any step in
flight, for a warp arrival or a resync too far gone to walk off.
`isMoving()` lets a driver pace itself instead of stomping a move already
running. `setPassable(flag)` is the flag `Collision.occupied` skips (the
engine's own user is Yellow's companion Pikachu); a passable object still
draws and can still be talked to.
An object spawned this way carries no `TEXT_*` id, so the vanilla talk path
has nothing to say for it. The **`world.talk`** hook is the A press on an
object, raised before the map's text tables get it:
```lua
mod.hooks:wrap("world.talk", function(next, ow, target)
if mine(target) then
say(target) -- the mod answers for an object it owns
return -- ...by not calling next()
end
return next(ow, target) -- everything else falls through unchanged
end)
```
With no subscriber the A press reaches `talkTo` exactly as before. An object
mid-step raises no hook, matching the vanilla gate.
## Adopting an already-paired link session
`LinkState.newFromSession(game, transport, mode, isHost, opts)` starts a link
session on a transport that is *already* paired, skipping the address/code
entry UI while keeping the hello and fingerprint compatibility exchange
intact. `transport` is anything `Session` accepts, which is what lets a mode
tunnel a battle through its own connection rather than opening a second one.
When the battle finishes, **`link.battle_ended`** reports the outcome:
```lua
mod.events:on("link.battle_ended", function(ev)
-- ev = { result, myParty, theirParty, peerName, role }
end)
```
The party copies are the point. Cable rules leave the real party untouched,
so a mode built on link battles -- a tournament ladder, a battle royale --
has no other way to learn what the fight cost, and by the time the state
unwinds the battle object is gone. `role` is `"host"` or `"guest"`.
Two smaller pieces support the same shape of mode. `Game:startNewGame(opts)`
is the title screen's NEW GAME closure made callable, with `opts.intro =
false` to land straight in the world -- a mode that hands out its own starting
state has no use for Oak's speech. `CodeEntry.new` takes an optional
`{ length = , charset = }`, so the slot-scrub widget that enters a link code
can also carry a room code or an address.
## Read-only battle snapshots
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
@@ -441,6 +594,28 @@ default** (1x front, 2x back).
ball-to-pic grow multiplies your scale through each stage, so a rescaled
mon still grows into place from the ball, grounded the whole way.
## Installation-scoped generated cache
Generated data derived from a validated user source often belongs to the mod
installation rather than to one Pokémon save. `mod.cache` is that namespace:
```lua
local ok, err = mod.cache:write("extract/v1/arena.bin", encodedArena)
local bytes, err = mod.cache:read("extract/v1/arena.bin")
local info = mod.cache:info("extract/v1/arena.bin")
mod.cache:delete("extract/v1/arena.bin")
```
The physical root is engine-owned (`mod_cache/<mod-id>/`) and never exposed to
the mod. Keys are safe relative paths and a single write is capped at 64 MiB.
The cache does not rewind with checkpoints and is not scoped to game version,
slot, or playthrough. The mod owns its generated format, fingerprints, rebuild
policy, and completion marker; the engine treats the bytes as opaque data.
Use `mod.storage` instead when the data belongs to one playthrough. Use
`mod.cache` when it is a reproducible installation artifact that can be rebuilt
from a declared user source.
## Durable tool storage and runtime checkpoints
`mod.save` remains the right place for state that should travel with the next
@@ -633,17 +808,51 @@ the selected indices. Mods remain responsible for selection policy and should
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
contract and compatibility guarantees.
Both battle engines expose the guarded `battle.charge_required` hook when a
charge-capable move is selected for its initial turn and the active ruleset
would otherwise charge it. The wrapper receives `(next, ctx)`, where `ctx` is
`{ battle, user, target, move, charge = true, isCalled }`. Return `false` to
skip only that initial charge and continue through the ordinary move pipeline;
call `next(ctx)` to keep it. The hook does not run for the release turn or when
the active ruleset already skips charging (for example, Gold Solarbeam in
sun). PP use, accuracy, damage, animation, and secondary effects remain owned
by the engine. With no subscriber, the vanilla decision runs without building
the hook context.
## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass
`--developer` on the command line:
On a Gen 1 boot, developer mode unlocks the in-game console and hot-reload
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass `--developer`
on the command line:
```sh
love . --developer
```
While developer mode is active:
The mod loader independently derives a matching boolean for every sandboxed
entry chunk as `mod.developer`. It is available while the entry file is
loading, so a mod can keep diagnostic commands, screens, and verbose tracing
out of player builds:
```lua
if mod.developer then
mod.commands:register("my_mod:diagnostics", function(ctx)
-- open or print this mod's diagnostic view
end)
end
```
`mod.developer` is a plain boolean snapshot for this boot. It grants no
permission and exposes neither the process environment nor the loader. In a
normal player boot it is `false`; `POKEPORT_DEV=1` and `--developer` make it
`true`. On Gen 1 those inputs separately enable the console and hot-reload
hotkeys. The headless loader's `opts.dev` test seam changes only the loader
signal and diagnostics; it does not enable the game's console or hot reload.
Gold exposes the same `mod.developer` boolean but does not implement the Gen 1
console or hotkeys. Use a mod option for player-facing feature toggles rather
than treating developer mode as configuration.
While developer mode is active on Gen 1:
- `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`,
`data` and `mods` in scope. Press `` ` `` again to close it.
@@ -754,7 +963,7 @@ contract, so a mod does not need a desktop-specific rendering path.
`render.output_enabled` and `render.output` are the later, whole-window seam
for mods that need the engine's normal composite rather than its separate
layers. It runs after registered present pipelines and before GBCFX,
layers. It runs after registered present pipelines and before ShaderFX,
`render.hud`, and touch controls. A mod wraps both hooks: the first returns
`true` only while output ownership is needed, and the second receives
`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`,
@@ -805,6 +1014,47 @@ which stay unconditional and are never visible to a subscriber.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
## Battle field residual hook
`battle.field_residual` lets a Gen 1 battle-rule mod request end-of-round
damage without mutating live battlers. It is guarded and runs after vanilla
status residuals, before field-token expiry and `battle.turn_ended`. The
wrapper receives `(next, context)`, calls `next(context)` for the existing
descriptor list, and appends data-only rows:
```lua
mod.hooks:wrap("battle.field_residual", function(next, context)
local rows = next(context)
rows[#rows + 1] = {
side = "enemy", amount = 7,
message = context.battlers.enemy.name .. " is buffeted!",
}
return rows
end)
```
`context.field` is a detached, data-only view with the same
`{ weather, tokens }` shape that battle checkpoints capture; it does not expose
`field.sides` or any live battler aliases. The projection recursively retains
raw tables and finite numbers, strings, and booleans under scalar keys. It
strips metatables and omits functions, userdata, threads, unsupported keys, and
cyclic edges. Consequently a wrapper cannot obtain or invoke an engine callback
even if a live field token uses one internally, and changing any nested view
value cannot change live field state. `context.battlers.player` and `.enemy` are
detached `{ side, name, hp, maxHp, types, vanished }` views, and `context.turn`
is the current turn number. A descriptor accepts `side`
(`player` or `enemy`), a positive, finite integer number `amount`, and an
optional string `message`.
Numeric strings and invalid rows are ignored; damage is clamped to current HP.
The engine retains HP-bar, faint, experience, and replacement authority. If
both active battlers take terminal residual damage together and the player has
no healthy reserve, this hook batch queues only the player faint authority and
resolves as a blackout loss without an enemy-faint EXP award or replacement.
That precedence is local to accepted rows from this hook; native faint paths
are unchanged when no hook is active. Hook callbacks remain process-local;
checkpoints serialize only field data. Gold does not yet raise this hook; its
native weather pipeline is documented in `docs/mod-api-gen2-compat.md`.
## Process-lifecycle hooks
These exist so a platform-specific launcher integration (a native shell
+5
View File
@@ -14,9 +14,14 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Screen position setting** (center, upper, top) shared across all games, for clamp-on controllers that cover the lower screen
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
* **Pokédex diploma and printer image exports**
* **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed
* **Custom carts**, a named mod set saved from the mods tab and picked from a game's page, with its own shell colour, label art, save slots and export file
* **Install required mods**, one press on a cart that will not start, fetching every pinned mod at the pinned version and refusing any archive whose hash is not the one the cart recorded
* **Browse carts in Find mods**, a Mods / Carts switch on the same community index, searched and filtered by base game, installing the cart file straight into that game's cart list
## Gen 2 Specifics
* **Pokémon Silver** as an importable, launcher-selectable version alongside Gold
* **Pokémon Crystal** as an importable, launcher-selectable version alongside Gold and Silver
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
* **Followers** for mods, plus Gen 2-only registries and hooks
+3 -2
View File
@@ -176,7 +176,7 @@ something the filesystem encodes.
| token | means |
| --- | --- |
| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"` | that one game (a version id from `GameVersion.ORDER`) |
| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"`, `"crystal"` | that one game (a version id from `GameVersion.ORDER`) |
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
| `"all"` | every game this engine has |
@@ -768,7 +768,8 @@ the same:
So: read the log for coverage problems, and the manager for load problems.
`POKEPORT_IDENTITY=<name>` sandboxes the save directory if you want a clean
profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload.
profile to test in. `POKEPORT_DEV=1` makes `mod.developer` true for loader-gated
diagnostics; Gold does not add Gen 1's console or `F5` hot reload.
## What this guide does not promise
@@ -0,0 +1,141 @@
# RFC 0008 — Streamed mod imports and installation-scoped generated cache
## Motivation
`required_imports`/`optional_imports` can now describe files up to 2 GiB, but
the existing launcher and public mod API still assume imported bytes are small:
* the Windows desktop picker stages a selected required import through a fixed
`%TEMP%/pokeport_required_import.bin` path before validation;
* the fallback import path materializes the selected file as one Lua string;
* after validation a mod can only use `mod:read("baseroms/...")`, which also
materializes the whole file;
* `mod.storage` is intentionally scoped to one Pokémon playthrough, so it is
not an appropriate home for a one-time generated asset cache shared by every
save using the same installed mod.
This makes optical-disc-sized user sources impractical even though the manifest
schema already accepts them. A failed temporary staging copy can also turn a
valid large source into a smaller temporary file and produce a misleading
"wrong file size" rejection.
A mod should be able to consume its own already-validated source incrementally
and compile derived runtime data once, without receiving a host path or general
filesystem access.
## Decision being extended
This extends the same legal/sandbox direction as **D11 asset transforms**
(`src/mods/AssetTransform.lua`): mods distribute recipes and derive bytes from
user-owned sources rather than shipping ROM-derived data. It also follows the
**D14 parity-gate** contract referenced by `tests/harness.lua` and
`tests/engine/gate_meta_coverage.lua` (the `21-testing-and-ci` plan): additive
extension points ship public-API coverage, no-mod parity coverage, and docs in
the same change.
The historical D11 plan document is referenced by source comments but is not
present in the current repository tree; this RFC is the checked-in design
record for the new surface.
## Exact API delta
No manifest field changes. Existing `required_imports` and `optional_imports`
remain the declaration/validation authority.
Two additive facades are added to the `mod` object.
### `mod.imports`
```lua
local info, err = mod.imports:info("source_id")
local bytes, err = mod.imports:read("source_id", offset, length)
```
* `source_id` must name an import declared by the calling mod.
* the import is rechecked through `RequiredImports.validateStored` before it is
exposed, so missing, replaced, or invalid optional imports are not readable;
* `offset` and `length` are zero-based byte coordinates;
* one read is capped at 8 MiB;
* no host path or file handle is returned;
* production reads seek into the engine-owned stored copy instead of reading
the whole source.
`info()` returns declaration metadata plus stored size. It does not expose a
host path.
### `mod.cache`
```lua
mod.cache:write("extract/v1/model.bin", bytes)
local bytes = mod.cache:read("extract/v1/model.bin")
local info = mod.cache:info("extract/v1/model.bin")
mod.cache:delete("extract/v1/model.bin")
```
The cache is rooted at `mod_cache/<mod-id>/`, follows the engine persistence
backend, and is independent of game version, launcher slot, and playthrough.
Paths are checked with `SafePath`; `..`, absolute paths, drive paths, and other
escapes remain unavailable. A single cache write is capped at 64 MiB so large
generated datasets are naturally split into independently replaceable files.
The engine does not interpret cache bytes. Mods own generated-format versioning,
fingerprints, transactional completion markers, and rebuild policy.
## Launcher/import transport delta
For large raw required imports:
1. desktop pickers return the original selected path instead of staging it
through a fixed temporary file;
2. the engine opens that source itself;
3. bytes are copied directly to the existing engine-owned
`mods/<id>/baseroms/<file>` destination in 4 MiB chunks;
4. MD5 is updated incrementally during the copy;
5. the normal size/MD5 validation receipt is written only after the complete
destination passes validation;
6. partial destinations are removed on short reads, write failure, size
mismatch, or digest mismatch.
N64 imports stay on the existing canonicalization path because byte-order and
copier-header normalization require transformation rather than a raw copy.
If a validation receipt for an already-stored large raw import is missing, the
engine rebuilds it with streaming MD5 rather than a whole-file read.
## Backward compatibility / migration
**Existing mods do nothing.** This is additive.
* manifest v1/v2 fields are unchanged;
* `mod:read`, `mod.storage`, registries, events, hooks, and legacy compatibility
retain their existing behavior;
* small required imports retain the existing in-memory validation path;
* N64 imports retain canonicalization and existing accepted byte orders;
* a mod that never touches `mod.imports` or `mod.cache` creates no new cache
files and observes no new behavior.
The mod API integer is not bumped because no existing member changes meaning or
shape.
## Security and legal posture
The launcher remains the authority that validates user-supplied bytes. The new
facade narrows access rather than widening it: a mod can read only ids declared
in its own manifest, only after validation, and only in bounded ranges. It does
not receive host paths, `io`, or a raw filesystem handle.
`mod.cache` is writable only beneath the calling mod's generated-cache root.
Nothing in this RFC permits packaged ROM-derived bytes; `modkit lint/pack`
continue to enforce the existing legal posture.
## Parity guarantee
The change ships with:
* a no-mod/API-v1 parity test proving an empty load and an existing v1-style
`mod:read` load do not create cache data or change the old surface;
* a public mod-API test that reaches `mod.imports` and `mod.cache` through a
real `Loader` load, including bounded reads, undeclared/missing imports,
cache isolation, and traversal rejection;
* incremental MD5 vectors and a large-import streaming regression test;
* the existing engine suite, required-import suite, and mod lint gates.
+92
View File
@@ -0,0 +1,92 @@
# RFC 0011: Charge-required battle hook
## Status
Proposed.
## Motivation
A battle-mechanics mod can change damage through `battle.damage` and register
move effects, but it cannot conditionally skip the first turn of an existing
charge move. In Gen 1, the engine decides and stores the charge continuation
before any public effect callback can run. Reaching into `user.charging`,
`user.chargeReady`, or generation-specific volatile state is private,
checkpoint-fragile, and would require a mod to duplicate move-pipeline policy.
Weather is the immediate example: a portable sun rule needs Solarbeam to
resolve on selection while leaving Fly, Dig, PP use, hit resolution, animation,
and secondary effects to the engine. The capability is generic and useful to
other ruleset and move-mechanics mods.
## Decision and plan extended
This implements **D-AT-002: charge-stage policy remains mod authority through a
generic guarded engine decision seam**. The consuming design is tracked in the
Adaptive Trainers implementation plan,
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
Task 8. The delta follows the additive, guarded hook convention documented by
Route B in `CONTRIBUTING-mods.md`; it contains no weather, move-id, trainer, or
Adaptive Trainers policy.
## Exact API delta
Both the Gen 1 and Gen 2 battle engines add this guarded hook:
```lua
mod.hooks:wrap("battle.charge_required", function(next, ctx)
-- ctx = {
-- battle = live battle controller,
-- user = attacking battler,
-- target = defending battler,
-- move = merged move record,
-- charge = true,
-- isCalled = false,
-- }
if should_resolve_now(ctx) then return false end
return next(ctx)
end)
```
The call site is the initial-use charge decision, after announcement and PP
handling but before charge state, invulnerability, charge animation, or charge
text is created. It runs only when the active engine rules would otherwise
require a charge. It does not run on the release turn. Returning exactly
`false` skips that initial charge and continues through the engine-owned move
pipeline. Any other downstream return preserves the charge. `isCalled` is true
when Metronome or Mirror Move selected the move.
Gold keeps its native sun decision first, so Solarbeam in native sun already
requires no charge and does not invoke the hook. Gen 1 link battles use the
shared Gen 1 move pipeline and therefore receive the same seam; normal link
mod-compatibility rules continue to govern deterministic peers.
The hot path first calls `Runtime.wantsHook("battle.charge_required")`. With no
subscriber, no hook payload table is allocated and the existing branch runs
unchanged.
## Migration and compatibility
Existing mods change nothing. The hook name and payload are additive. With no
wrapper installed, Red, Blue, Yellow, Gold, and Silver retain their previous
charge state, PP use, text, animation, accuracy, damage, and native weather
behavior. Existing charge-move data and effect records require no migration.
A mod adopting the seam should call `next(ctx)` unless it deliberately wants to
skip this charge. It should not mutate private charge fields or re-run the move.
## Verification
- `tests/engine/battle_charge_required.lua` exercises the real Gen 1 and Gen 2
engines through a sandboxed public mod, including false-to-skip, next-to-keep,
release-turn behavior, called-move PP semantics, shared payload shape, and
native Gold sun behavior.
- The same test proves no-mod charge/release parity and replaces
`Runtime.call` with a sentinel behind a false `Runtime.wantsHook` guard.
- `tests/engine/gate_hooks.lua` discovers the new catalog name and proves empty
chains preserve vanilla values and allocation behavior.
- `tests/engine/gate_gen2_mod_api.lua` requires a guarded site in both
generations and keeps the compatibility reference list complete.
## Deprecation etiquette
Nothing is removed, renamed, superseded, or deprecated.
+140
View File
@@ -0,0 +1,140 @@
# RFC 0012: `applyShare`'s announce argument on Gen 2
## Status
Proposed.
## Motivation
`battle.exp_award` hands a mod `ctx.applyShare(mon, split, announce)` on both
generations. On Gen 1 the third argument decides whether the mon's GainedText
box is printed (`src/battle/BattleState.lua`, `if announce then`), which is how
a mod that pays the whole party prints **one** summary line instead of a box
per recipient.
Gold accepts the argument and ignores it, as its own comment above the hook
call says. So the same mod source, running the same code, prints one line on
Red and six on Gold — one for the participant plus one for every bench mon it
paid.
This is not hypothetical. The [Exp Share](https://github.com/ShaneMcGovernIE/exp_share)
mod declares `"games": ["gen1", "gen2"]` and its description promises "a single
shared-exp line instead of one message per Pokemon". It passes `true` for the
fighters and `nil` for the bench, exactly as the Gen 1 seam asks. On Gold every
one of those `nil` calls announces anyway, so a five-mon party turns every KO
into six boxes to click through.
There is no mod-side fix. The announcement is emitted inside
`Battle:giveExperiencePass`, behind no hook, and a mod cannot ask for silence
because the argument that means "quietly" is discarded. The only workaround is
to intercept the battle's event queue afterwards and delete the boxes, which is
what a mod written for this had to do — a mod reaching into engine internals to
undo something the public seam should never have done.
## Decision and plan extended
This does not add a seam. It finishes one: `battle.exp_award` is documented as
"the same hook `BattleState:awardExp` calls on Gen 1 and with the same ctx",
and `docs/mod-api-gen2-compat.md` lists it among the hooks shared with Gen 1.
The third `applyShare` argument is the one part of that ctx whose meaning did
not survive the crossing, so the promise the catalog already makes is what this
change delivers.
The delta follows Route B's additive, guarded convention in
`CONTRIBUTING-mods.md`: nothing is renamed, nothing is removed, and no mod that
exists today changes behaviour.
## Exact API delta
`ctx.applyShare(mon, split, announce)` on Gen 2 now reads `announce`:
| Call | Gen 1 | Gen 2 before | Gen 2 after |
|---|---|---|---|
| `applyShare(mon, split)` | silent | announces | **announces** (unchanged) |
| `applyShare(mon, split, nil)` | silent | announces | **silent** |
| `applyShare(mon, split, false)` | silent | announces | **silent** |
| `applyShare(mon, split, true)` | announces | announces | announces |
| `applyShare(mon, split, "expAll")` | announces | announces | announces |
The argument is honoured **only when it is actually passed**, decided by
argument count rather than by value:
```lua
local function applyShare(mon, split, ...)
local announce = ...
local silent = select("#", ...) > 0 and not announce
...
end
```
`select("#", ...)` counts an explicit `nil`, so `applyShare(mon, split)` and
`applyShare(mon, split, nil)` are distinguishable — and they have to be, because
the first is a Gen 2-era call written against a seam that always announced, and
the second is a deliberate "pay this one quietly".
Only the `{ kind = "experience" }` event is affected. A silent award is still a
whole award: the exp, the stat exp, the `battle.exp_gained` event, the
`grew to level` line, learned moves and the interactive forget-a-move prompt all
happen exactly as before, in the same order.
Internally `Battle:giveExperiencePass` takes a sixth parameter, `silent`. It
defaults to announcing, so both of the cart's own passes are untouched.
## Migration and compatibility
**Existing mods change nothing.** A Gen 2 mod calling `applyShare(mon, split)`
gets the behaviour it was written against. A Gen 1 mod is untouched: no Gen 1
file is modified. The v1 surface — `content.X:register/override/get`,
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and
`pokemon.before_give` — is not involved; `mods/example_mew_starter` neither
calls this seam nor loads differently.
A mod that wants parity passes the argument explicitly, which is what the Gen 1
seam has always documented. Exp Share already does, and needs no edit to get
its own README's behaviour on Gold.
One residual difference is deliberate and now documented rather than silent: an
**omitted** third argument still means "silent" on Gen 1 and "announce" on
Gold. Closing that would change what an existing Gen 2 mod prints, which Route
B rejects. Passing the argument makes the two generations agree, so the rule an
author needs is one sentence: *say what you mean and both games do the same
thing.*
## Verification
- `tests/gen2_exp_share_test.lua` grows two sections and 14 checks, and the 23
checks it already had are unchanged — which is itself the vanilla-parity
evidence for this file.
- **The no-mod test.** With nothing subscribed to `battle.exp_award`, a solo
participant still prints one line and the EXP.SHARE double pass still
prints both. The hot path is unchanged: `Runtime.wantsHook` still guards
the ctx allocation, and `vanillaAward` never passes `silent`.
- **The mod-API test.** The seam is driven through `hooks:wrap` on a real
`Runtime.install`ed bus, not by calling internals: the omitted argument
announces, an explicit `nil` and an explicit `false` are silent, a truthy
value (including Gen 1's `"expAll"`) announces, the exp and stat exp paid
are identical either way, and no `experience` event leaks into the queue on
a silent pass.
- `tests/engine/gate_gen2_mod_api.lua` (943 checks),
`tests/engine/gate_hooks.lua` (493) and `tests/engine/gate_events.lua` (529)
pass unchanged; `battle.exp_award` was already in the shared catalog, so no
gate list moves.
- `tests/gen2_battle_test.lua` (690), `gen2_battle_end_test.lua` (32),
`gen2_battle_items_test.lua` (99), `gen2_badge_boosts_test.lua` (34) and
`gen2_battle_loss_test.lua` (15) pass unchanged.
## Docs with the change
`docs/mod-api-gen2-compat.md` gains the `applyShare` reading beside the
existing `battle.low_health_alarm` payload note, in the same section that lists
`battle.exp_award` as shared — including the argument-count rule and the
residual difference above.
No registry or schema field changes, so `src/mods/Schemas.lua` is untouched and
`tools/gen_registry_docs.lua` has nothing new to emit.
## Deprecation etiquette
Nothing is removed, renamed, superseded or deprecated. The two-argument call is
not deprecated either — it keeps its current Gen 2 meaning permanently, and the
docs name the explicit form as the one that behaves the same on both games.
@@ -0,0 +1,142 @@
# RFC 0013: Conditional map occupancy and active-block reads
## Status
Proposed.
## Motivation
The Gen 1 Vermilion Dock script correctly ejects a player who enters after the
S.S. Anne has departed. A content mod can add a city-side route back to that
empty harbor, but it cannot preserve the dock visit: replacing the complete
dock handler would discard vanilla behavior and peer handlers, while an added
handler cannot cancel the base handler's ejection.
A mod that changes one active map block also needs to prove that it is looking
at the expected Red, Blue, or Yellow layout before it acts. `mapOverview()` is
intentionally presentation-oriented and does not expose block identity.
Requiring internal `Map` state or generated ROM data would cross the public mod
boundary and make a wrong-version edit difficult to fail closed.
## Decision and plan extended
This extends Route B in `CONTRIBUTING-mods.md`: new behavior is additive,
ordinary hook composition remains the authority, an empty hook chain is a
provable no-op, and mods receive copied or scalar data rather than mutable
engine state. It supports the approved Mew-under-the-truck implementation plan
without adding any Mew-specific rule, asset, flag, or content to the engine.
## Exact API delta
### `map.occupancy_allowed`
The post-departure `VERMILION_DOCK` script calls this hook after it replaces the
ship blocks with water and immediately before it would display the departure
message and warp the player to Vermilion City.
A wrapper has this shape:
```lua
function(next, game, context) -> boolean
```
The context is a new table with these fields:
| Field | Meaning |
|---|---|
| `mapId` | `"VERMILION_DOCK"` at this call site |
| `reason` | Stable reason key `"ss_anne_departed"` |
| `gameVersion` | Active save version (`red`, `blue`, or `yellow`) when present |
| `x`, `y` | Current player cell coordinates when present |
Vanilla returns `false`. The player remains only when the final chain result is
exactly `true`. Absent, throwing, or malformed wrappers therefore preserve
ejection. A wrapper composes by calling `next(game, context)` and returning
true when either downstream or its own narrow rule permits occupancy. The hook
does not replace `MapScripts` registration, merging, or dispatch, and does not
change the departure flag or reconstruct the ship.
As with every wrapper hook, a callback that does not call `next` intentionally
owns the final answer and does not run lower-priority callbacks. Permission
wrappers must call downstream to compose. A false, non-forwarding wrapper
safely denies occupancy and can suppress downstream permission by this normal
rule. A malformed final result also fails closed and cannot permit occupancy.
The call is guarded by `Runtime.wantsHook`, so an empty chain allocates no
context and follows the prior branch exactly.
### `WorldAPI:activeBlockAt`
Gen 1's public `mod.world` facade adds:
```lua
activeBlockAt(mapId, blockX, blockY) -> blockId
| nil, reason
```
`mapId` must equal the active map ID. Coordinates are finite, integral,
zero-based block coordinates. A successful result is a numeric scalar copied
from the active runtime map. The method never returns the map's mutable block
array and never writes game or save state.
Failure reasons are stable:
| Condition | Reason |
|---|---|
| No active overworld map | `no overworld` |
| `mapId` differs from the active map | `map is not active` |
| Coordinate has the wrong type, is non-finite, or is fractional | `invalid block coordinates` |
| Coordinate is negative or outside the active map | `block coordinates out of bounds` |
| Active block data is absent or malformed | `block unavailable` |
The block slot and the active map accessor must both contain the same valid
nonnegative integer. Missing or sparse storage and inconsistent accessor data
return `block unavailable`.
Requiring the expected map ID and rejecting all ambiguous input lets a mod
compare every cell in its version-specific signature before it calls an
existing mutation API. Red, Blue, and Yellow each use their own loaded map
data. Gold does not gain this method in this RFC.
## Migration and compatibility
Existing mods change nothing. No hook, event, registry, manifest field, save
field, map handler, or WorldAPI method is removed or renamed. With no hook
subscriber the departed-dock behavior is unchanged. Existing callers cannot
invoke the new WorldAPI method accidentally.
The occupancy answer is not persisted by the engine. Disabling or uninstalling
a mod removes its wrapper through normal owner cleanup, so a later dock entry
uses vanilla ejection. The engine writes no new save state and neither API
returns or serializes ROM or save data.
## Verification requirements
The parity gate must prove:
- vanilla departed-dock message and warp remain with no subscriber;
- one permission wrapper can allow occupancy without removing the ship-erasure
work or any map handler;
- multiple cooperative wrappers preserve downstream permission;
- absent, throwing, nil, false, malformed, and non-forwarding hook answers fail
closed according to the normal wrapper-chain rule;
- Red, Blue, and Yellow contexts keep their version identity separate;
- disabling or removing the owner restores vanilla behavior without a save
migration;
- `activeBlockAt` accepts only the active map and valid in-range integral
coordinates, returns a scalar, and never mutates map or save state; and
- the test fixtures and resulting changes contain no ROM or save payload.
The hook must be driven through a real public `hooks:wrap` chain. The block API
must be exercised through a public `WorldAPI` instance. Tests must not replace
the production map handler with a test-only implementation.
## Docs with the change
`docs/modding.md` documents both contracts, their failure behavior, the
cooperative wrapper pattern, and the Gen 1-only block method. No registry or
schema changes occur, so generated registry documentation is unchanged.
## Deprecation etiquette
Nothing is removed, superseded, or deprecated.
@@ -0,0 +1,158 @@
# RFC 0014: Mod-driven world actors and adopted link sessions
## Status
Proposed.
## Motivation
A mod can already spawn a runtime object with `mod.world:spawnNpc` and can
already start a link battle. It cannot make either of them behave like
something the mod itself owns.
Three walls, each of which stops a mode rather than inconveniencing it:
**An actor cannot move on its own schedule.** The only public way to animate a
spawned object is `Handle:scriptMove`, which queues onto
`OverworldState.scriptMoves`. A non-empty `scriptMoves` is how the overworld
knows a cutscene is running -- `handleInput` gates on it -- so anything
animated that way freezes the player's controls for as long as it walks. That
is correct for Oak marching into his lab and wrong for an ambient walker or a
networked player's ghost, which move continuously and must not lock anyone out.
**A spawned object cannot answer the A press.** Talking is resolved from the
map's text tables by `TEXT_*` id. A runtime object has no id, so the vanilla
path has nothing to say for it, and the mod that created it has no way to say
anything either.
**A link battle cannot ride a connection the mod already has.** `LinkState`
owns pairing, so a mode that already has a socket to its peer must either open
a second connection for the battle or reimplement lockstep. And when the
battle ends, cable rules leave the real party untouched, so the damage exists
only in the battle's own copies -- which are gone by the time the state
unwinds. A mode built on link battles cannot learn what the fight cost.
The immediate consumer is an overworld multiplayer mode, but nothing here is
specific to it: the first two are wanted by any mod with an actor that moves
itself, and the third by any mode that runs battles over its own transport --
a tournament ladder, a draft, a gauntlet.
## The decision it extends
This adds nothing to the compatibility surface's shape; it extends the
**additive, guarded seam convention** that Route B in `CONTRIBUTING-mods.md`
documents, and is gated by the parity guarantee `tests/engine/gate_meta_coverage.lua`
enforces ("21-testing-and-ci: a parity gate for every extension point"; M14).
There is no in-repo D-number registry to amend; the consuming design lives
outside this repository, as it did for RFC 0011.
## Exact API delta
### New hook: `world.talk`
```lua
mod.hooks:wrap("world.talk", function(next, ow, target)
-- ow = the OverworldState raising it
-- target = the object on the faced cell
if mine(target) then
say(target)
return -- the mod answered; the text path is skipped
end
return next(ow, target) -- anything else falls through unchanged
end)
```
Call site: `OverworldState:interact`, on the branch that has already resolved
an object on the faced cell (including across a counter) and confirmed it is
not mid-step and not the Pikachu follower. It runs before the map's text
tables are consulted. With no subscriber, `Runtime.call` invokes the vanilla
fallthrough, which is a file-local function rather than a per-press closure,
so an unhooked A press allocates nothing it did not allocate before.
### New event: `link.battle_ended`
```lua
mod.events:on("link.battle_ended", function(ev)
-- ev = {
-- result = "win" | "lose" | "draw" | "ended",
-- myParty = lockstep copy of our party,
-- theirParty = lockstep copy of theirs,
-- peerName = string,
-- role = "host" | "guest",
-- }
end)
```
Call site: `LinkState:update`, in the `battleRunning` stage, once the battle
state has been popped and before `exitWith` unwinds the link stack. Guarded by
`Runtime.wants("link.battle_ended")`, so with no subscriber no payload table
is allocated and the branch runs exactly as before.
The party copies are the point of the event. They are the lockstep records the
battle actually fought with, which is where the damage lives under cable rules.
### New `WorldAPI` handle methods
```lua
handle:stepNow(dir) --> true when the step started
handle:canStep(dir) --> would stepNow land somewhere legal?
handle:placeAt(x, y, dir) --> snap, no animation, clears a step in flight
handle:isMoving() --> true while a step is animating
handle:setPassable(flag) --> may the player walk through this object?
```
`stepNow` sets the same per-tile state `scriptMove` does, without the queue and
therefore without the input lockout. It does **not** consult collision: the
intended caller is replaying a move already decided elsewhere (validated on a
peer, or authored), and re-judging it locally would let two copies of the same
actor disagree about where it is. `canStep` is the separate opinion for callers
that want it. `setPassable` sets the flag `Collision.occupied` already honours
for Yellow's companion Pikachu; a passable object still draws and still talks.
### Two supporting changes
`Game:startNewGame(opts)` -- the title screen's NEW GAME closure, made callable,
with `opts.intro = false` to land directly in the world. A mode that issues its
own starting state has no use for the intro cutscene.
`CodeEntry.new(shape)` -- accepts an optional `{ length =, charset = }`, so the
slot-scrub widget that enters a link code can also carry a room code or a
host:port address. Called with no argument it is byte-for-byte the previous
widget.
## Migration and compatibility
**Existing mods change nothing.** Every item above is a new name or a new
optional argument; no existing name, payload, signature, or default changes.
The v1 surface is unaffected: `content.X:register/override/get`, `events:on`,
`hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and
`pokemon.before_give` all behave as before. `mods/example_mew_starter` -- api 1,
`category = "GAMEPLAY"`, whole-species copy -- loads unchanged, which
`tests/run_modkit.lua` proves on every run.
With no subscriber to either seam, Red, Blue, Yellow, Gold and Silver behave
exactly as they did: the A press reaches `talkTo`, the link battle unwinds
without building an event payload, and no handle method is reachable unless a
mod calls it.
## Verification
- `tests/modkit/cases/world_talk.lua` drives the real `OverworldState:interact`
path. It asserts the unhooked build first (the A press reaches `talkTo`),
then that a mod owning the object suppresses the text path by not calling
`next`, that an object the mod ignores still falls through, and that an
object mid-step raises no hook at all.
- `tests/modkit/cases/link_battle_ended.lua` drives the real `LinkState:update`
path. It asserts that with nothing subscribed the event is not wanted (so no
payload is built) and the battle still unwinds, then that a subscriber
receives the result, the role, the peer name and both party copies including
the damage the real party never took, from both sides of the cable.
- `tests/engine/gate_hooks.lua` and `tests/engine/gate_events.lua` walk the live
catalog, so both seams are parity-gated structurally; `gate_meta_coverage`
passes 208/208 with both names covered and no DEBT entry added.
## Deprecation etiquette
Nothing is removed, renamed, superseded or deprecated.
+123
View File
@@ -0,0 +1,123 @@
# RFC 0016: Engine-owned field residual descriptors
## Status
Proposed.
## Motivation
Battle-rule mods can keep deterministic data-only state in the public battle
field and observe `battle.turn_ended`, but that event fires after the engine's
residual and faint pipeline. A listener cannot safely deal end-of-round field
damage: directly changing live HP bypasses bar drains, faint messages,
experience, replacements, double-faint resolution, and checkpoint continuation.
Putting callbacks into `battle.field` is also rejected by the checkpoint
serializer, correctly, because executable state is not save-safe.
## Decision and plan extended
This implements **D-AT-004: public engine-owned field residual execution**, the
consuming decision required by Adaptive Trainers capability `ENGINE-FIELD-
RESIDUALS`. The plan is
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
Task 8. The engine delta defines only a generic end-of-round extension point;
it contains no weather names, immunities, damage formula, trainer identity, or
Adaptive Trainers policy.
## Exact API delta
Add the guarded Gen 1 hook:
```lua
mod.hooks:wrap("battle.field_residual", function(next, context)
local rows = next(context)
rows[#rows + 1] = {
side = "enemy",
amount = 7,
message = context.battlers.enemy.name .. " is buffeted!",
}
return rows
end)
```
The hook runs once during an undecided battle's end-of-round processing, after
vanilla status residuals and before field/side token expiry and
`battle.turn_ended`. With no subscriber, the guarded site builds no context and
changes nothing. Vanilla contributes an empty list.
`context` is `{ field, battlers, turn }`. `field` is a strictly data-only view
with the same `{ weather, tokens }` shape captured by battle checkpoints. Its
recursive projection retains raw tables and finite numbers, strings, and
booleans under scalar keys. It strips metatables and omits functions, userdata,
threads, unsupported keys, and cyclic edges. Thus it exposes neither
`field.sides` nor a graph or executable callback back into live engine state.
`battlers.player` and `battlers.enemy` are detached snapshots with `{ side,
name, hp, maxHp, types, vanished }`. Changing either detached view cannot
change the live battle. `turn` is the current Gen 1 turn counter.
A result row is `{ side = "player"|"enemy", amount =
positive_finite_integer_number, message = optional_string }`. Numeric strings,
zero, negatives, fractions, NaN, infinities, malformed sides, and non-string
messages fail closed. Damage is clamped to current HP. The engine owns
mutation, HP-bar drain rows, and its existing faint pipeline. It applies every
accepted row before scheduling newly fainted battlers. If this hook batch
terminally faints the player with no healthy reserve, it queues only the player
faint authority, so descriptor order cannot race a blackout against an enemy
EXP/replacement path. Otherwise it schedules newly fainted battlers in fixed
player/enemy order. This precedence is scoped to this hook response; native
faint paths, including `enemyMonFainted`, keep their existing no-hook behavior.
Wrappers compose by calling `next(context)` and appending their own rows.
Callbacks are not part of the descriptor contract and hook functions are never
stored in the battle.
Gold already owns native weather and a generation-specific between-turn order;
this first additive call site is Gen 1-only. A future Gold site must keep the
same context and descriptor contract and choose its native ordering explicitly.
## Migration and compatibility
Existing mods change nothing. No hook name or payload changes. With no wrapper,
Gen 1 performs the same residual, token, event, and faint work as before,
including native simultaneous-faint resolution, and allocates no context. Gen
2 is unchanged. Existing and new checkpoints keep
serializing only the data stored in `battle.field`; hook callbacks remain
process-local loader state and are never serialized.
The v1 surface remains unchanged: `content.X:register/override/get`,
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields, and
`pokemon.before_give` keep their existing behavior.
## Verification
- The catalog hook parity gate proves null and live-empty buses return the
vanilla list unchanged.
- A sandboxed fixture mod exercises the seam through `mod.hooks`, verifies the
detached checkpoint-shaped context, applies damage, and reaches the engine
faint pipeline.
- Engine validation tests cover strict number validation (including numeric
strings, NaN, infinities, zero, and negatives), nested mutation isolation,
omission of functions/userdata/threads/cycles and metatables from the public
field projection, optional messages, non-table results, clamping, and
settled-battle suppression.
- Both descriptor orders are driven through queue completion for a simultaneous
terminal residual. Each proves player blackout loss with no EXP event,
enemy replacement, or replacement UI.
- A disabled-bus sentinel proves the guard performs no `Runtime.call` or field
context construction. An ordering probe proves the enabled hook runs after
vanilla status residuals and before token expiry and `battle.turn_ended`.
- A no-hook native regression proves a simultaneous zero-HP state still enters
the pre-existing enemy-faint EXP and win authority outside this hook batch.
- Capture/restore/capture evidence proves checkpointed field state round-trips
while the enabled process-local hook remains installed and callable.
## Docs with the change
`docs/modding.md` documents the Gen 1 timing, detached payload, descriptor
validation, simultaneous-terminal result, and checkpoint boundary.
`docs/mod-api-gen2-compat.md` records that Gold does not yet expose the hook.
No registry or schema changes are involved, so generated registry docs do not
change.
## Deprecation etiquette
Nothing is deprecated. The hook is additive.
+102
View File
@@ -0,0 +1,102 @@
# RFC 0017: Public mod developer-mode signal
## Status
Proposed.
## Motivation
The loader already derives a boot-time developer-mode flag for its permission
diagnostics and headless test seam. Gen 1's `Game` independently derives a
similarly sourced flag for its console and hot reload. A sandboxed mod cannot
read either one. `mod.commands` can register a diagnostic command but cannot
say whether the current boot is a developer boot. `mod.exports` only publishes
values to other mods. `game.ready` fires after entry registration and carries
only the game. `mod.options` is player configuration, not engine mode, and
`mod.log` logs unconditionally. The pre-sandbox compatibility
`os.getenv("POKEPORT_DEV")` deliberately returns `nil`, because the process
environment is hidden from mods.
The concrete consumer is **Adaptive Trainers**. Its approved Chapter 30 and
Phase H require trainer, boss, Rival, and League diagnostic views plus
seed-label tracing to exist only when `POKEPORT_DEV` is active. Without a
public signal, the mod must either ship those registrations in production,
misuse a player option, or import loader/Logger internals. All three violate
the approved observability boundary or the sandbox/public-API policy.
## Decision and plan extended
This implements **D-AT-005: diagnostics and seed tracing are admitted only by
the engine's developer-mode decision**. The consuming design is tracked in the
Adaptive Trainers implementation plan,
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
Task 9. The engine delta is generic and contains no trainer, balancing,
diagnostic-layout, seed-label, or Adaptive Trainers policy.
## Exact API delta
Every sandboxed mod object adds one field:
```lua
mod.developer -- boolean
```
The loader copies its existing `dev` decision into this field before invoking
the mod's entry chunk. It is therefore available for load-time registration:
```lua
if mod.developer then
mod.commands:register("my_mod:diagnostics", diagnostics_command)
end
```
The value is a plain boolean snapshot, not a loader reference or environment
facade. `false` is the normal player-build answer. `POKEPORT_DEV=1` and the
`--developer` command-line path make the loader flag true; on Gen 1 those inputs
separately make `Game`'s own developer flag true for its console and hot
reload. The loader's existing injected `opts.dev` test seam changes only the
loader flag and diagnostics, not `Game` or its hotkeys. It grants no permission
and does not expose environment variables. The answer is fixed for the life of
that loader; changing a field on a mod's own table cannot change engine mode.
The field is generation-independent and has identical semantics on Red, Blue,
Yellow, Gold, and Silver. Gold and Silver do not gain Gen 1's developer console
or hot-reload hotkeys from this field.
## Migration and compatibility
Existing mods change nothing. `mod.developer` is additive, requires no
permission, and does not bump the integer mod API. Existing API-v1 and API-v2
entry chunks receive one extra scalar field and retain all prior fields and
methods unchanged. No name is removed or shadowed.
With no mods installed, `Loader:_api` is never called, so the delta allocates no
mod object and changes no data, save, options, event, hook, command, or file.
With mods installed in a normal boot, the new field is `false` unless an author
explicitly reads it. Existing registration and logging behavior is unchanged.
An adopting mod should gate developer-only registrations and verbose logging
directly on `mod.developer`. Player-facing behavior belongs behind
`mod.options`, not this signal.
## Verification
- `tests/engine/mod_developer_mode_test.lua` loads a real sandboxed mod through
the public SDK with developer mode both on and off. It proves the boolean is
available during entry execution and that the same source registers its
diagnostic command only for the developer load. It also covers the
command-line global path and a Gen 2 load.
- `tests/engine/mod_developer_mode_parity_test.lua` is the separate no-mod
parity suite. It proves both developer answers discover no mods, create no
files, and leave injected vanilla data unchanged. It also loads an unchanged
API-v1 probe and verifies identity, `mod:read`, exports, and options behavior.
- The full ROM-free engine and modkit tiers remain the compatibility proof for
`content.X:register/override/get`, `events:on`, `hooks:wrap`, `mod.log`,
`mod:read`, manifest v1 fields, and `pokemon.before_give`.
No registry or schema changes are involved, so generated registry documentation
is unaffected.
## Deprecation etiquette
Nothing is removed, renamed, superseded, or deprecated.
+673
View File
@@ -0,0 +1,673 @@
# ShaderFX: runtime slang shader presets
ShaderFX plays real libretro `.slangp` shader presets over the finished frame.
It replaced `src/render/GBCFX.lua`, a hand-ported fixed four-level effect, with
a picker: any preset the player drops in a folder, or any preset pulled from
the RetroArch buildbot, can be selected and run. Engine:
`src/render/ShaderFX.lua` (discovery, download, translation call, pass-graph
runtime, render entry point), `src/render/ShaderFixup.lua` (GLSL rewrites),
`src/render/ShaderSourcePatches.lua` (pre-translation source patches),
`src/core/Sensors.lua` (accelerometer and gyroscope), `src/ui/ShaderFXScreen.lua`
(the picker), `src/ui/ShaderFXParamsScreen.lua` (per-preset parameter editor),
`tools/shaderfx-bridge/` (the Rust translator). Call sites:
`src/render/Renderer.lua` (Gen 1) and `src/core/Game2.lua` (Gen 2), both at the
end of the frame. Drivers: `tests/drivers/gold_shaderfx_zoom_sizing_test.lua`,
`tests/drivers/gold_shaderfx_menu_black_crop_test.lua`.
This is first-party engine code, not a mod. It calls the native translator
directly through `ffi.load`, with no `Sandbox.lua`, no `native` permission
declaration, and no mod boundary. Mods do not get that, and the distinction is
deliberate: the upstream maintainer will not accept `native` in mods.
## What a player sees
**OPTIONS** carries two rows, `SHADER FX` and `SHADER FX 2`. Each opens the
same pushed list screen (`ShaderFXScreen`) on a different slot. The list is
`OFF`, then every `.slangp` found on disk, then a permanent `DOWNLOAD SHADERS`
action row at the bottom.
A preset that has never been translated draws muted with a `CONVERT` hint on
the right. `A` on that row translates it in place and stays open: converting is
a preparation step, not a selection. `A` on a converted row activates it,
persists the choice, and closes. `SELECT` on a converted row opens
`ShaderFXParamsScreen`, which lists every `#pragma parameter` the preset
declares and lets the player step each one (`A` wraps, Left/Right clamp,
`SELECT` resets one row, `START` resets all behind a confirm).
Presets live in a plain OS folder, `shaders/` under the portable base directory
when running portable (`SaveData.portableBaseDir()`, the SD-card convention the
Anbernic pack uses, see `docs/anbernic-rg34xxsp.md`) and otherwise under LOVE's
save directory. `ShaderFX.list()` scans it recursively, so a shader pack keeps
whatever nested layout it shipped with. These are real filesystem paths rather
than `love.filesystem` virtual paths on purpose: the native translator does
plain `std::fs` reads and knows nothing about LOVE's mounts, and neither do the
LUT loads.
Persisted state, all in `save.options`:
| Key | Meaning |
| --- | --- |
| `shaderfx` | main slot's preset name, or absent for OFF |
| `shaderfxSecondary` | secondary slot's preset name |
| `shaderfxParams[name][paramId]` | one preset's edited pragma values |
`POKEPORT_SHADERFX=<name>` activates a preset in the main slot for scratch
harnesses that never call `ShaderFX.applyOptions`. It is a stand-in that
predates the real OPTIONS row; `applyOptions` marks itself as having run so the
env var can never later override a real player choice, including a real choice
of OFF.
One quiet behavior worth knowing about: if a slot wants a real preset while
PERFORMANCE is still on AUTO, and AUTO would resolve to a tier that caps
ShaderFX off, `ShaderFX.applyOptions` pins PERFORMANCE to `HIGH`. Without it
the saved choice was force-deactivated a few lines later on every boot, which
looks identical to "the setting does not save" from the player's side. On
Android and iOS that is the common case, because AUTO always resolves to
`balanced` there. It never touches an already-explicit performance choice and
never un-escalates.
## The five stages
| Stage | Owner |
| --- | --- |
| Fetch | `ShaderFX.list`, `ShaderFX.downloadPresets`, `ShaderFX.installDownloaded` |
| Translate | `tools/shaderfx-bridge/` via `ShaderFX.translate` |
| Fixup | `src/render/ShaderFixup.lua` |
| Cache | `ShaderFX.convert` writes, `ShaderFX.load` reads |
| Run | `ShaderFX.runChain` / `runPass` / `ShaderFX.render` |
### Fetch
Two acquisition paths, and they land in the same place. A player can copy a
shader pack into `shaders/` by hand, or press `DOWNLOAD SHADERS`, which fetches
`https://buildbot.libretro.com/assets/frontend/shaders_slang.zip` (the same
~54 MB archive RetroArch's own "Update Shaders" entry pulls) through
`src/net/Fetch.lua`, the curl-on-a-`love.thread` transport the self-updater and
mod index already use.
Downloaded presets are *not* pre-converted. The buildbot is RetroArch's asset
mirror and has no notion of this project's cache format, so a downloaded preset
goes through the same `CONVERT` row a hand-copied one does. Every platform
ships both convert and use; there is no asymmetry to work around.
Repeat downloads are conditional. The zip itself is deleted right after
extraction, so what is cached instead is the buildbot's own ETag
(`shaderfx_buildbot.etag`), replayed as `If-None-Match`. The server answers a
match with 304 and no body, and curl writes no file at all in that case, so
`installDownloaded(notModified)` short-circuits before touching the filesystem
and reports "already up to date" rather than "FAILED".
The interesting part is what gets extracted. `handheld/` is not self-contained:
its 78 presets carry 101 references that escape the folder (color-mod LUTs
into `../shaders/color/`, console-border helpers into `../../reshade/`, shared
motion-blur and misc helpers, a shared `stock.slang`) across roughly 40 of
them. Extracting `handheld/` alone silently breaks about a third of its own
list; extracting the whole zip drags in ~5600 files of CRT, arcade and console
content nobody asked for. So `extractClosure` walks the real file-level
dependency closure in Lua before a single file is copied, the same
`#reference`-closure idea librashader applies internally, done up front because
deciding what to copy has to happen before the translator ever sees these
files. Against this zip that is 207 files and about 9.5 MB with zero broken
references. The closure seeds from `KEPT_PRESETS`, a curated shortlist rather
than all 78, after most of `color-mod/` and `console-border/` turned out either
irrelevant (color-only, no LCD effect) or broken for this project. That list
is a temporary trim pending wider testing and is expected to change.
Two mechanical details in that walk that are easy to get wrong a second time.
`extractRefs` tries a quoted `key = "path"` match per line first, with an
unrestricted `[^"]+` capture, because the closing quote is an unambiguous
delimiter and real packs ship paths with spaces and parentheses in them
(`"shaders/handheld/color-mod/Game Boy (Color).slang"`); only a line with no
quoted match falls back to a conservative character class, since an unquoted
path has no delimiter to trust past. And `love.filesystem.write` does not
create intermediate directories, so each destination directory is created once
before anything is written into it; without that, every file under a subfolder
`handheld/` never had before was silently dropped while flat writes succeeded.
Cleanup is equally literal: `love.filesystem.unmount()` takes the archive path
originally passed to `mount()`, not the mountpoint. Called with the mountpoint
it returns false, leaves the zip's handle open, and the following `remove()`
silently fails too, so every download used to leave 54 MB on disk forever.
### Translate: the native bridge
`tools/shaderfx-bridge/` is a small Rust crate (`spike`) that builds a cdylib
named `librashader_bridge`. It wraps `librashader-presets`,
`librashader-preprocess` and `librashader-reflect` behind a two-function C ABI:
```
char* librashader_translate_preset(const char* preset_path, int es);
void librashader_free_string(char* s);
```
It returns a JSON `TranslateResult`: `pass_count`, a `passes` array (each with
its emitted `vertex`/`fragment` GLSL, `filter`, `wrap_mode`, `scale_x`/
`scale_y`, its own `#pragma parameter` declarations, a classified `samplers`
list and a classified `size_uniforms` list), the preset's `textures` (LUTs,
with resolved absolute paths and filter/wrap settings), its
`parameter_overrides`, and an `error` field.
**What the bridge is not.** No librashader runtime backend is linked in, for
any API: no GL, Vulkan, D3D or Metal crate is a dependency, and no live
graphics context is ever touched. It is the translation step only. LOVE still
owns every draw call, every canvas and every shader object. The bridge hands
back text and metadata and nothing else.
**When it is called.** Only from `ShaderFX.convert()`. Translation is ahead of
time, not just in time. `ShaderFX.load()`, `ShaderFX.activate()`, boot-time
reactivation of a saved choice and every frame of `ShaderFX.render()` read the
cached artifact and never call the library. An entry with no cached artifact
fails `activate()` loudly instead of silently live-translating.
The classification is done with librashader's real semantics resolution rather
than name matching on the Lua side, and that matters for correctness, not just
tidiness. Sampler classification uses `ShaderSemantics::create_pass_semantics`
plus the `TextureSemanticMap` lookup (explicit alias and LUT-name entries
first, then the built-in `Source`/`Original`/`OriginalHistoryN`/`PassOutputN`/
`PassFeedbackN` conventions), so a pass reachable only by its real `.slangp`
alias resolves. The per-pass convenience API only registers the alias of the
pass being compiled, so the bridge mirrors upstream's `insert_pass_semantics`
loop and builds a preset-wide alias map first; without that,
`ds-hybrid-scalefx.slangp`'s pass 2 sampling `scalefx_pass0` by alias, with no
`PassOutput1`-shaped name anywhere, can never resolve. Size-uniform
classification runs the same resolution over the pass's `uniform_semantics`
map, which also covers shapes (`PassFeedbackSizeN`, `UserSizeN`) that no
present preset uses but that the convention allows.
The `es` flag picks the emitted dialect: 1 for GLSL ES 1.00 (mobile, LOVE's ES
dialect), 0 for GLSL 1.20 (LOVE's desktop dialect). `ShaderFX` picks it from
`love.system.getOS()`, and the same function decides which dialect
`validateShader` is asked about at run time, so the two always agree. Convert
and render always happen on the same device; **artifacts are not portable
across platforms**.
One class of fixup has to happen in the bridge rather than in `ShaderFixup.lua`,
on the raw `.slang` text before SPIR-V compilation: `textureSize`,
`texelFetchOffset` and `textureOffset` are all ES 3.00+ only, and spirv-cross
refuses to *emit* them for an ES 1.00 target, failing the whole pass with
`UnsupportedSpirv("textureSize is not supported in ESSL 100.")`. No GLSL text
is ever produced for a later pass to patch, so `rewrite_essl100_gaps` rewrites
the source first, and only for the ES target:
- `textureSize(Tex, lod)` becomes a literal `ivec2(w, h)` when `Tex` is one of
the preset's declared static textures, with dimensions read straight out of
each PNG's IHDR chunk. A texture that is not one of those, or a non-literal
`lod`, is left alone so it fails as loudly as before instead of guessing.
- `texelFetchOffset` and `textureOffset` become ordinary `texture()` calls at
the equivalent texel-centre UV, using the texture's own `<Tex>Size.zw`
reciprocal-size uniform. The containing block instance (`params`, `global`,
whatever) is discovered by scanning the source's own uniform block bodies,
never assumed. When a pass samples another pass purely through these calls it
may never declare that `<Tex>Size` uniform at all, so one is injected first,
before any byte offsets are computed.
That last rewrite has an honest limit: `texture()` honours the sampler's wrap
mode at out-of-range coordinates, which is not necessarily identical to
`texelFetch`'s implementation-defined out-of-bounds behavior. Any edge-of-image
discrepancy for passes whose offsets can leave the image is unmeasured.
**Building it.** `cargo build --release` inside `tools/shaderfx-bridge/`.
`ShaderFX` looks for the library, most specific first: the
`LIBRASHADER_BRIDGE_DLL` environment variable, the source directory,
`<source>/tools/shaderfx-bridge/target/release/`, the save directory, and
finally the bare name handed to the system loader. Per-OS names are
`librashader_bridge.dll` (Windows), `liblibrashader_bridge.dylib` or
`librashader_bridge.dylib` (macOS), and `liblibrashader_bridge.so` or
`librashader_bridge.so` (Linux and Android). Android resolves the bare name
because the `.so` ships as an ordinary `jniLibs` entry, so `dlopen` finds it
without a path. The desktop path is still a developer build sitting in cargo's
output directory; nothing packages it next to a shipped game yet.
`ShaderFX.canConvert()` reports whether the library resolved on this machine,
and `ShaderFX.bridgeError()` says why not. Activating an already-converted
preset never needs any of this.
### Fixup
`ShaderFixup.lua` mechanically rewrites the emitted GLSL into something LOVE
will accept. librashader emits a standalone `void main()` /`gl_FragData[0]` /
`gl_Position` shape (translated Vulkan GLSL); LOVE requires the `effect()` and
`position()` convention and refuses a raw `main()`-shaped source outright. This
is a targeted rewriter, not a GLSL parser, and every rule below exists because a
real preset in the corpus failed without it. This list is the least guessable
part of the whole feature.
**`#version` line.** Stripped; LOVE prepends its own.
**Array constructors.** SPIR-V Cross emits ES 3.0 array-constructor syntax
(`const float _17[5] = float[](0.0, 1.0, ...)`) for compile-time array
literals, which validation rejects with "arrayed constructor: not supported for
this version". GLSL ES 1.00 has no array-constructor syntax at all. There are
three real shapes in the corpus and each is handled: a `const` global (declared
without an initializer at global scope, with per-element assignments relocated
to the top of `main()`), a non-const local declaration with initializer
(rewritten in place, since a function body can hold assignment statements where
the literal was), and a bare reassignment of an array declared elsewhere (also
in place). Splitting the element list needs `splitTopLevelCommas`, because a
naive comma split breaks on any element containing its own parentheses
(`vec2(-1.0, 0.0)`), and the outer capture needs `%b()` rather than a
`[^%)]-` class for the same reason: the class stops at the first inner `)`, the
whole match fails, and the literal passes through completely untouched.
**Whole-array copies.** `float param_1[7] = coeffs;` is how SPIR-V Cross clones
a function-parameter array before passing it on, since GLSL array arguments are
by value. ES 1.00 has no whole-array assignment either, so it becomes a bare
declaration plus an element-by-element copy. The size is known from the
declaration, so no comma splitting is involved. This one only became reachable
once the other array shapes stopped masking it in the same file.
**Integer modulo.** ES 1.00 has no `%` operator, and SPIR-V Cross emits it
anyway for an upstream integer-modulo op. `%` in GLSL is only defined for
integer operands, so routing through float `mod()` and back is exact for the
non-negative operands this shader family uses (rotation and orientation enum
indices). Four patterns are tried in order (paren/paren, paren/bare,
bare/paren, bare/bare) because SPIR-V Cross fully parenthesizes a compound
operand and leaves a simple one bare, and the balanced form must be tried
before a plain identifier can partially match. Seen live on
`authentic_gbc`'s subpixel-rotation math.
**Precision.** SPIR-V Cross hardcodes an unguarded `precision highp float;` /
`precision highp int;` pair with no toggle. That is removed and replaced with
the same guard the old hand-written `DotMatrix` port used: claim `highp` only
where `GL_FRAGMENT_PRECISION_HIGH` says the driver actually offers fragment
highp, and fall through to the stage default otherwise.
**Struct flattening.** This is the big one. LOVE's `Shader:send` cannot address
a member of a custom struct-typed uniform: neither an `INSTANCE.member` dot path
nor sending the whole struct as a table works, both raise "Shader uniform '...'
does not exist." librashader emits every pass's `#pragma parameter`s and size
uniforms as exactly that kind of struct, so as shipped the output is unusable
from LOVE, not merely inefficient. `flattenStruct` deletes the struct and its
instance uniform and re-declares the members at top level. Scalar members are
*packed* four at a time into synthetic `uniform vec4 LIBRA_PACKED_N;` slots,
because GLSL ES 1.00 guarantees only 16 fragment uniform *vectors* and every
scalar costs a whole one; `gb-pass4`'s pass 0 alone has 14 scalars, already over
budget unpacked. Non-scalar members keep their own uniform, since packing an
existing `vec4` saves nothing. Every `instance.member` reference is rewritten to
`LIBRA_PACKED_N.x` (or `.y`/`.z`/`.w`), and a member whose original declared
type was `int` or `bool` gets an explicit cast back on every read, since a
packed slot only stores floats. Vertex and fragment declare identically ordered
structs for the same parameters, so packing both with the same prefix assigns
the same slot and component to the same parameter in both stages, and one
`shader:send` reaches whichever stage uses it.
Two ordering constraints inside that function are load bearing and look
arbitrary from the outside. Packing must walk the members in the struct's own
declaration order, because that order is what keeps the scalars in one
contiguous run; an earlier version sorted them longest-name-first before
packing and produced six packed vec4s instead of four on `gb-pass4`, blowing the
budget. Substitution, separately, must go longest-name-first, so a replacement
can never land as a substring inside a still-pending member name that shares a
prefix. The two orders are separate copies of the list for exactly that reason.
`Fixup.packValues` turns a flat `{name = value}` table back into the
`{uniform = value_or_vec4}` shape the packed shader expects, per the manifest
`flattenStruct` returned. `Fixup.countUniformSlots` counts declared uniform
slots against that same 16-vector budget, samplers excluded. Its pattern uses
`[%w_]+` rather than `%w+` because Lua's `%w` does not include underscore
unlike regex `\w`, and every generated name here is full of underscores.
**UBO blocks.** `LIBRA_UBO_FRAGMENT` and `LIBRA_UBO_VERTEX` have the identical
problem and are flattened the same way, under a distinct `LIBRA_UBO_PACKED_`
prefix so their groups cannot collide with the push block's numbering. `MVP` is
the one special member: it is substituted directly to `transform_projection`
instead of becoming a uniform, because "multiply the incoming vertex by it" is
exactly what LOVE's `transform_projection` already is on a full-screen draw, and
nothing would ever supply a value for it. An earlier version assumed the UBO
block only ever carried `MVP` and deleted the whole declaration after
substituting it. That is true only for presets that declare their parameters in
a push-constant block; presets that use a UBO instead (many real handheld and
console-border presets do) had every other member reference left dangling, and
the driver then read `INSTANCE.PAR` as a swizzle, which is where the "undeclared
identifier" and "unknown swizzle selection" errors on real Android hardware came
from.
**Fragment entry point.** `void main()` becomes LOVE's `effect()` signature.
The parameter list is qualified by an `EFFECT_PREC` define rather than a literal
precision, because LOVE forward-declares `effect()`'s prototype under its own
header's precision default before this source runs, which can mismatch whatever
the precision guard above raises the default to. `Fixup.PREC_HEADS` holds the
two variants (`mediump`, then unqualified) and the caller tries them in order
against `validateShader`, taking the first that passes.
**Fragment output.** `gl_FragData` does not exist in LOVE's `effect()`
convention, so every `gl_FragData[0]` occurrence is rewritten to a local
`gbFragColor`, declared at the top of the function, with a single `return`
appended before the closing brace. Rewriting *every* occurrence regardless of
the operator that follows is necessary, not just symmetric with the vertex side:
an earlier assign-then-return pair assumed one write at the very end of
`main()`, which holds for most presets but not for ones like `ds-hybrid-sabr`
that write once with `=` and later accumulate with `+=`. The `+=` statement
passed through unconverted and collided with LOVE's own `gl_FragColor` write
("Cannot use both gl_FragColor and gl_FragData"). A bare early `return;` is
rewritten to `return gbFragColor;`, which holds the value assigned just before
it on every real shape seen.
**Vertex entry point.** `void main()` becomes `position(mat4
transform_projection, vec4 vertex_position)`, the source's own `attribute`
redeclarations of `Position`/`TexCoord` are dropped since LOVE supplies them,
`gl_Position = X;` becomes `gbClipPos = X;` (named to share no substring with
`Position`, or the next step would mangle it), and a `return gbClipPos;` is
appended. The `Position` and `TexCoord` substitutions are frontier-matched
whole identifiers (`%f[%w]...%f[%W]`), not blind substring replacements: real
presets declare their own unrelated locals such as `vec2 vTexCoord;`, and a
blind `gsub` turned that declaration into the invalid `vec2 vVertexTexCoord.xy;`
(`dot.slangp` pass 0, a real driver "unexpected DOT" error).
### Cache
`ShaderFX.convert(entry)` is the only path that calls the bridge. It runs
`ShaderSourcePatches.apply` first, translates, then serializes the decoded
result to `ShaderFX.artifactPath(entry)`: the source `.slangp`'s own absolute
path with the extension swapped to `.lua`, so the artifact sits next to the
preset it came from. The file is a plain `return { ... }` chunk written by
`serializeLua`, which handles the string/number/boolean/nested-table shape
`Json.decode` produces. Array detection walks every key rather than trusting
`#t`, since `#t` counts a trailing nil as absent and a sparse table can pass a
naive length check by accident.
`ShaderFX.load(entry)` `loadfile`s that chunk and builds a chain state. On
failure it says "convert this preset first" rather than falling back to a live
translation.
**AOT rather than JIT** is the whole point of this stage. Translation is a rare,
explicit, user-initiated action whose result is stable for a given preset and
dialect, so paying for it once and writing the answer to disk keeps `ffi.load`
and the native call off every activation, every boot and every frame. The cost
is a staleness gap: `ShaderFX.isConverted()` is a plain "does the artifact file
exist" check with no version or content stamp, and there is no explicit
"reconvert" action in the UI. A preset converted by an older build never picks
up a later translator fix on its own. This was seen on a real device, where
`sunlight_shimmer.slangp`'s `Accelerometer` uniform never reached the shader
because that device's cache predated the fix while `pixel_transparency`'s
happened to be fresher. Two places compensate by reconverting unconditionally:
`ShaderFXScreen`'s explicit selection of an already-converted row, and
`ShaderFX.applyOptions` on every boot and options save. Both are human-paced,
CPU-only work with no GPU compile, and neither is on the per-frame path.
`ShaderSourcePatches.lua` sits just before translation and patches the raw
`.slang`/`.inc` files *on disk*, because only librashader's own preset parser,
reading the real files, discovers `#pragma parameter` lines and struct members;
nothing downstream can add one. Patches are small, explicit, per-preset literal
find/replace pairs (plain `find`, not `gsub`, since GLSL source is full of Lua
pattern magic), re-applied idempotently on every convert so a buildbot
re-download that replaces the upstream file wholesale does not quietly undo
them. **The patch table ships empty on purpose and nothing registers one.** Its
original use case, wiring gyroscope yaw into `sunlight_shimmer.slangp` as new
`PT_YAW_*` pragma parameters, was reverted precisely because of the staleness
gap above: a new pragma can only reach an artifact that gets reconverted, and at
the time nothing forced one. The mechanism is kept for a future preset that
genuinely needs a new declaration, but an already-wired engine-side channel is
preferred whenever one exists.
### Run: the pass graph
`ShaderFX.activate(slot, entry, paramOverrides)` loads the artifact, layers the
player's edited parameters over the artifact's own defaults, loads the preset's
LUTs once, and snapshots the accelerometer rest pose. `ShaderFX.render` then
runs the chain each frame.
`newChainState` builds one instance of chain-local state per loaded preset,
never module-global, so switching presets cannot leak a previous preset's
canvases or dimensions. `ALL_DEFAULTS` is built in layers: each pass's declared
`initial`, then the preset's own `parameter_overrides`, then (in `activate`) the
player's `shaderfxParams` edits.
Sizes resolve through `resolveScale`, which handles all four slang scale types
(`absolute`, `viewport`, `source`, `original`) against the viewport, the pass's
input dimensions and the original frame. Size uniforms are packed as
`{w, h, 1/w, 1/h}`, the slang convention.
`runPass` caches two things per `(state, pass index)`. The **shader** is
compiled once for the state's lifetime, along with the fragment manifest it was
compiled against, since a pass's GLSL depends only on the preset and never on
per-frame input. The **canvas** is reallocated only when the pass's resolved
size actually changes, a window resize or a different preset. Every harness this
runtime was ported from ran the chain once and quit, so allocating a fresh
canvas and compiling a fresh shader on every call was invisible there. On a real
per-frame render path it is one GPU allocation per pass per frame, and a shader
recompile on top. The same discipline applies to the crop canvas in
`cropToGbSource`, which is called once per frame and whose size grows with the
world canvas as the player zooms out; leaving it uncached was a real cost that
scaled with zoom level even with a single preset active.
Sampler binding is by semantic, from the bridge's classification, never by a
hardcoded per-preset name check: `Source` is the previous pass's output (or the
input frame for pass 0), `Original` and `OriginalHistory` are the input frame,
`PassOutput` indexes an earlier pass's canvas, and `User` resolves a LUT by the
real name the translation reported. A sampler that resolves to nothing asserts
rather than drawing garbage.
**LUTs** are loaded once per `activate`. `ShaderFX.loadImageFromPath` reads the
bytes with plain `io.open` and goes through `love.data.newByteData` and
`love.image.newImageData`, because a preset's texture paths are arbitrary
absolute OS paths outside any LOVE mount and `love.graphics.newImage` refuses
those outright ("Could not open file ... Does not exist") even when the file is
real. Wrap modes are mapped from librashader's names to LOVE's
(`clamp_to_border` to `clampzero`, `clamp_to_edge` to `clamp`, `repeat`,
`mirrored_repeat` to `mirroredrepeat`). A LUT that fails to load is logged and
left nil; the fail-loud point is the sampler assertion in `runPass` that
actually needed it, not the loader.
**History ring.** `OriginalHistoryN` currently resolves to a steady state: every
history slot reads the current frame, both for the sampler binding and for the
size uniform. Real per-frame history rotation has been proven out in a desktop
harness but is not wired into this path.
**Feedback.** `PassFeedback` is not implemented. A `PassFeedback` or `User` size
uniform raises an explicit "not yet supported" error, and a `PassFeedback`
sampler resolves to nothing and trips the binding assertion. No preset in the
shipped shortlist uses it.
**Blending.** Every pass draws with `replace`, and the chain's final pass uses
`replace, premultiplied`. Intermediate canvases are `nearest` filtered.
`ShaderFX.render(canvas, rect, source, dpiX, dpiY)` is the entry point
`Renderer:endFrame` and `Game2` call. `canvas` is the finished window-sized
composite (world, UI, and any post-process pipeline that already ran); `rect` is
this frame's real playfield rectangle in physical framebuffer pixels and
`source` is the real pixel size of the content it frames. The sequence is: crop
`rect` out of the composite, run whichever slots are active over that crop, draw
the untouched composite, then stretch the chain output back over `rect`. UI and
letterbox bars outside the playfield pass through untouched. If the chain throws,
the frame still shows the unprocessed composite; a broken preset degrades to
"shader off", never to a crash or a blank frame.
Three details in that path are non-obvious:
- **DPI.** `love.graphics.newCanvas` and `draw` work in LOVE's DPI-aware
logical units, not raw pixels, so the viewport handed to the pass graph and
the final draw-back position are both converted from `rect`'s physical pixels
first. On a `dpiscale = 1` desktop the two are numerically identical and the
bug is invisible; at dpiscale 3 on real Android hardware the chain output
rendered about three times too large and at a pixel-valued offset in unit
space.
- **Draw color.** `cropToGbSource` sets `setColor(1, 1, 1, 1)` explicitly. The
caller can leave the draw color dirty (a menu's black text leaves it at
`(0,0,0,x)`), the crop draw multiplies the canvas texels by the active color,
and `push("all")` saves state for `pop()` without resetting it. That was the
root cause of the Gen 2 blank-menu bug, confirmed on a desktop repro where
`getColor()` read `0,0,0,1` here exactly when a menu was on the stack.
`flushBatch()` on the line above is cheap insurance against a read-after-write
ordering hazard between this draw and whatever last rendered into the canvas;
it was never confirmed to fix anything on its own.
- **The final blit stretches.** A slang chain's last pass is not required to
land on the viewport size, and most presets (21 of the 78 in the corpus)
declare their last pass `scale_type = "source"` and stay at native Game Boy
resolution, relying on the frontend's blit exactly as RetroArch does.
Requiring an exact size match here used to skip the draw outright for every
such preset on every frame, which is a silent total no-op rather than a sizing
quirk. The stretch uses the last-run chain's own final-pass `filter` to pick
nearest or linear.
### What this engine feeds shaders that a libretro core does not
A stock libretro core hands its frontend a raw framebuffer and a frame count.
This engine has more context available and passes some of it through.
| Context | How it reaches the shader |
| --- | --- |
| Playfield rect and true source size | `rect`/`source` per frame from `Renderer:endFrame` or `Game2`, so the chain sees real on-screen geometry at any survey zoom or Faithful Ratio state rather than a fixed 160x144 assumption that then gets stretched |
| Blit scale | `rect.scale`, the crisp integer scale the composite was built at, used to derive the crop's own draw scale |
| SGB zone coloring and palette | Baked into the input frame. `PaletteFX` zone passes run before the composite reaches ShaderFX, so a preset shades an already-zone-tinted image |
| Performance tier | `chainRenderScale()` reads `Performance.CAPS[tier].shaderfx`, a chain-resolution multiplier; the viewport and the cropped source both shrink by it and the final blit upscales |
| Accelerometer | `Sensors.read("accelerometer")`, bound to the `Accelerometer` unique semantic |
| Gyroscope | `Sensors.read("gyroscope")`, bound to `Gyroscope`, plus the integrated yaw twist below |
Two motion semantics are deliberately pinned rather than guessed. `Rotation` is
bound to 0 because librashader's own documentation is explicit that it is
`retroarch_get_rotation()`, the *content's* requested rotation (a vertically
oriented arcade core, say), not device orientation. Nothing here ever rotates
Game Boy content, so 0 is the correct answer, not a placeholder.
`AccelerometerRest` is bound to `{0, 0, 0}`: it is librashader's "reading at
rest" calibration reference, no preset in the corpus reads it, and a fixed
placeholder beats an invented value.
`src/core/Sensors.lua` is what makes the two real motion semantics work.
`love.sensor` does not exist in LOVE 11.5, the version this project ships, on
any platform including Android; it is a LOVE 12 addition. The working path is
raw FFI into the SDL2 that LOVE already links, the same technique
`src/core/Orientation.lua` uses, opening the first `SDL_SENSOR_ACCEL` or
`SDL_SENSOR_GYRO` device via `SDL_NumSensors`/`SDL_SensorGetDeviceType`/
`SDL_SensorOpen`. The `love.sensor` path is kept above it and simply stops being
dead code after a future LOVE 12 upgrade. Loading order matters:
`ffi.load("SDL2")` first, needed on desktop where SDL2 is a separate DLL, then
bare `ffi.C`, needed on Android where love-android links SDL2 statically into
`libmain.so` and there is no `libSDL2.so` for `ffi.load` to find by name. A
device with no sensor is probed once and then permanently reports zeros, so a
desktop run does not pay for it every frame.
SDL keeps sensor readings in the device's fixed chassis frame regardless of
screen orientation, so `rotateForScreen` remaps x and y into
"as currently displayed" terms using `SDL_GetDisplayOrientation`. That
compensation is mobile-only: a desktop monitor is legitimately and permanently
"landscape" to that query, which says something about the monitor's shape and
nothing about how a player is holding anything.
The accelerometer path in `sizeTable` does three things to the raw reading
before it becomes a uniform, all of them driven by real on-device data:
1. **Rest-pose subtraction.** `activate()` snapshots whatever pose the player is
actually holding the device in and every later reading is measured relative
to that, rather than to an assumed idealized vertical. The shipped tilt maths
(`pt_base.inc`'s `getOrientedTilt`) was authored assuming gravity sits almost
entirely on one axis at rest; a natural, comfortable hold already puts 56 to
66 percent of gravity's magnitude on the axis the shader reads as tilt, so
the effect sat near-saturated all the time instead of starting near neutral.
2. **Axis swap.** The tilt maths assumes a device resting flat, with gravity
dominant on Z, the one axis it never reads. This engine's rest pose is
upright portrait, where Y is gravity-dominant, so y and z are swapped to put
gravity back on the ignored axis.
3. **Denominator stabilization.** `getOrientedTilt` normalizes by the full
vector's magnitude. Before calibration that magnitude was a stable ~9.8 that
quietly damped tilt and noise alike by the same factor; calibration correctly
zeroes x and y at neutral but also shrinks the magnitude near rest, and real
logs showed it swinging between 0.65 and 11.4 second to second on ordinary
hand jitter, which reads as wildly bouncing. A fixed constant is re-injected
on the ignored axis to keep the denominator stable, but only when there is a
genuine live reading to calibrate against. An all-zero raw read is
`Sensors.lua`'s explicit "no hardware at all" sentinel, never a real value on
Earth, and injecting into that case would make the shader believe it had
sensor data and silently replace its own static fallback with fake motion.
Yaw is a separate mechanism. A raw gyroscope reading is angular velocity, not an
angle, so it only becomes a usable on-screen offset by integrating over time,
and only the per-slot state persists frame to frame to do that. `updateYawTwist`
is deliberately a decaying spring rather than a true integrated heading:
gyro-only integration drifts without a magnetometer to correct it, so this
settles back toward neutral and stays bounded by construction. It is folded onto
the accelerometer's x component before the shader's own normalize and clamp,
because that is the only already-compiled channel the stock upstream maths
reads, and reaching an already-converted artifact with no reconvert was worth
the tradeoff that the twist reads as an added simulated tilt rather than a
cleanly separate motion. It applies to `sunlight_shimmer.slangp` only, the one
preset in the shortlist with a twist-reactive channel. `YAW_GAIN = 0.6` and a
clamp of +/-2 were tuned against real device data (a moderate real yaw turn
peaks around 1.5 to 1.7 rad/s); a much larger gain was tried on-device and
looked worse, because overshooting a comfortable range reads worse than being
subtle. Retune in small steps with real device checks, not big jumps.
## Two slots
`ShaderFX.SLOTS` is `{"main", "secondary"}` and `ShaderFX.OPTION_KEY` maps each
to its save key. The slots are activated and persisted independently, and the
same `ShaderFXScreen` serves both, opened with the slot as its argument. Pragma
parameter edits are keyed by *preset name*, not by slot, because a preset's
values are a property of the preset the same way its cached artifact is;
editing them re-activates every slot currently showing that preset and persists
for the next load in either.
When both slots are active, `render` runs main's chain first and hands its
finished output to secondary as secondary's own input frame, along with its
dimensions, so a secondary preset that scales off its input sees main's real
output size rather than the original crop. Either slot alone behaves exactly as
a single-preset path; neither active is a plain passthrough.
**This is not how RetroArch composes multiple presets.** RetroArch merges
presets into a *single* pass list through `#reference` and `Append`, producing
one pass graph with one shared semantics map, where a later pass can reference
an earlier one's output by alias and the whole thing resolves as one unit. Two
slots here are two independent librashader chains run back to back, which is
what stacking two separate preset chains would give you, not what merging them
gives you. Presets that assume merged semantics will not behave the same way.
## Test seams
`ShaderFX` exposes a few fields purely so a headless harness can assert on real
per-frame values without taking a screenshot: `_lastRect` and `_lastSource`
(the rect and source dimensions a caller handed in), `_lastCrop` (the exact crop
canvas, which the later unconditional draw-back would otherwise mask),
`_lastYawTwist` and `_lastAccelPacked` (the integrated twist and the values that
actually reached the packed uniform, per slot). `Sensors.setOverride`,
`Sensors.clearOverride` and `Sensors.setOrientationOverride` inject synthetic
readings on a machine with no hardware.
## Limitations
None of these are theoretical.
- **Tested on very little real hardware.** Essentially one Android phone, one
desktop, and the automated harnesses. Anything about how a preset actually
looks or performs elsewhere is unverified.
- **No performance tier is actually tuned.** The chain-resolution multiplier in
`Performance.CAPS` is a working mechanism, but every tier that permits
ShaderFX at all sets it to 1.0. Nothing runs at reduced chain resolution
today. Picking a real value for weak hardware needs a device this project does
not have.
- **The dual-slot design does not match RetroArch.** See above. Two chains in
sequence is not one merged pass list.
- **`OriginalHistoryN` is a steady state.** Real per-frame history rotation
falls back to "every slot is the current frame" in the live render path, and
is unverified there.
- **`PassFeedback` is unimplemented.** Its size uniform raises an explicit
error and its sampler trips an assertion.
- **`ShaderFXScreen` has a known text-overlap bug on long preset names.**
`ListMenu`'s `fitLabel` truncation covers the ordinary case, but a long enough
player-supplied filename still collides with the row's right-hand hint.
- **`ShaderSourcePatches` ships with an empty patch table and nothing uses it.**
Intentional, for the reason given above, but it means the mechanism has no
live coverage.
- **Cached artifacts have no staleness detection.** Existence is the only check.
The two unconditional reconvert points paper over it; anything that does not
go through them can be running a stale translation.
- **Artifacts are per-device.** The GLSL dialect is baked in at convert time.
Copying a converted preset folder between a phone and a desktop copies a
wrong artifact along with it.
- **The bundled bridge is only as good as the build machine.**
`scripts/build.sh` bundles the cdylib for mac, win and linux via
`bundle_shader_bridge`, building it with cargo when a prebuilt one is not
supplied through `SHADERFX_BRIDGE`. A build host without cargo produces a
package that can run converted presets but cannot CONVERT new ones, and says
so rather than failing. Android ships the `.so` via `jniLibs`.
- **The buildbot shortlist is a temporary trim.** `KEPT_PRESETS` reflects one
manual pass over `handheld/` and is expected to change, most likely to shrink.
- **Tilt direction is unverified.** Which way forward and back rocking moves the
effect was never confirmed on a device; if it feels backwards the fix is a
sign flip on the swapped axis, not a deeper bug. Likewise, whether the
landscape rotation compensation matches real RetroArch is genuinely unknown:
RetroArch's Android input driver computes a screen rotation but does not
visibly apply it to the accelerometer values that reach shader uniforms, so
this project's compensation may be an improvement over upstream rather than a
match to it.
- **`texelFetch` wrap behavior at image edges may differ.** The ES 1.00 rewrite
in the bridge turns those calls into `texture()`, which honours the sampler's
wrap mode out of range where `texelFetch`'s out-of-bounds behavior is
implementation defined. Unmeasured.
+64 -25
View File
@@ -1,11 +1,11 @@
# Touch skins and the Skin Studio
A **skin** replaces the on-screen controls wholesale: a bezel image, a
control layout, and the rectangle the Game Boy screen is drawn into. Engine:
control layout, and a screen-placement anchor. Engine:
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
(draw and input), `src/render/Renderer.lua` (the screen viewport),
(draw and input), `src/render/Renderer.lua` (screen placement),
`src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export),
`src/ui/SkinStudio.lua` (the desktop editor). Tests:
`src/ui/SkinStudio.lua` (the responsive skin editor). Tests:
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
`tests/engine/skin_studio_ux.lua`,
`tests/engine/skin_studio_image_import.lua`,
@@ -13,8 +13,10 @@ control layout, and the rectangle the Game Boy screen is drawn into. Engine:
`tests/engine/launcher_skins_tab.lua`,
`tests/engine/launcher_skins_ux.lua`.
Skins are picked in the launcher's **Skins** tab, which also imports them and
opens the studio. `options.touchControls.skin` holds the folder name.
The launcher's **Skins** tab imports skins, shows the enabled skin, exports it,
and is the one place that turns skin use off. **My Skins** holds the visual
grid, pagination, edit, delete and per-skin export actions.
`options.touchControls.skin` holds the folder name.
## Formats
@@ -30,7 +32,7 @@ as-is. Supported keys:
| `overlays` | page count |
| `overlayN_name` | page name, the target of `next_target` |
| `overlayN_overlay` | bezel image |
| `overlayN_full_screen` | stretch the page to the window |
| `overlayN_full_screen` | cover the window with the page without deforming its artwork |
| `overlayN_rect` | page placement, default `0,0,1,1` |
| `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen |
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
@@ -98,8 +100,8 @@ corners fire two directions. `screens[1].outputFrame` (or the legacy
`gameScreenFrame`) becomes the screen cutout. A portrait page with neither
keeps `mappingSize` as the overlay aspect, sits at the bottom of the
window, and puts the Game Boy picture in the leftover space above -- the
usual GBA4iOS controller-deck layout. Pages that name a screen rect still
stretch to the window the way Delta does. Host functions map to
usual GBA4iOS controller-deck layout. Pages that name a screen rect fit the
game into it. Host functions map to
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
@@ -135,7 +137,7 @@ to decoration and never captures a touch.
As an extension to the format, `key:<name>` presses any keyboard key, which is
how a skin button reaches a mod hotkey.
## The screen viewport
## Screen placement
`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy
screen keeps its whole-pixel scale and letterboxes inside that rect rather than
@@ -146,6 +148,17 @@ that lets a widescreen bezel take the filling survey-zoom world view instead.
A viewport also implies the faithful-ratio lock. Without it the world pass
expands to fill the cutout and you get more map instead of a Game Boy screen.
Zoom still steps around that hole: OUT shows more map inside it, IN enlarges
the world, and the start menu stays at the hole's fit scale instead of
shrinking with the map.
An image-backed portrait overlay that has no explicit vertical anchor is treated
as a controller deck: it is contained without deformation and pinned to the
bottom on taller screens. The spare space belongs to the game above it.
When a skin is active, **SCREEN POS** reads **SKIN**: placement comes from the
skin rather than the normal Center / Upper / Top setting.
Border art often ships with a transparent hole and no `viewport` key. **Detect
screen from bezel** in the studio measures the hole out of the art's alpha
channel and writes the rect.
@@ -153,10 +166,8 @@ channel and writes the rect.
## Bezels versus pads
A skin whose active page binds nothing is a frame rather than a pad: a TV
surround, a handheld shell, a Super Game Boy border. Those draw on **desktop**
as well, where the touch overlay itself does not, and a gamepad does not hide
them. Anything that binds a button still follows the usual mobile /
`POKEPORT_TOUCH` rule.
surround, a handheld shell, a Super Game Boy border. Selected skins draw on
**desktop** as well as mobile; a gamepad does not hide them.
## Installing
@@ -190,9 +201,32 @@ shipping branding.
## The studio
Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open
that skin. Desktop only: the launcher does not offer it on Android or iOS,
because it wants a mouse, typed coordinates and room for an inspector.
Launcher, Skins tab, **My Skins** opens the Studio library on desktop and
mobile. **My Skins** is the only visual grid: real bezel previews plus create
and import actions, with each card owning Edit, Export and (for installed
skins) Delete. Choosing Edit opens a separate, canvas-first editor; the old
New/Load workspace controls are deliberately not duplicated inside that editor.
The editor keeps its canvas unobstructed and puts the contextual actions in a
compact lower tray: add/control binding, button and bezel artwork, pages,
screen placement, freeform/10:9 screen shape and deletion. **Screen** opens
cutout, bezel-hole detect, **Detect this screen**, and the canvas presets.
**Detect this screen** (also on the tray) sets the mock device to the live
window size so a phone skin is authored at that phone's form factor rather
than a generic 1080x1920 16:9. **Zoom ** shrinks the mock device inside the
workspace so the screen hole can be dragged larger than the bezel while the
handles stay grabable; **Fit** restores contain. The mouse wheel over the
canvas, and `-` / `=` / `0` on a keyboard, do the same. Touches select, drag and resize the
same controls that a mouse edits on desktop.
My Skins and the editor chrome sit inside the platform safe area (notch,
status bar, home indicator), the same inset the launcher uses. The mock
device still represents the full window, because a skin covers the whole
screen at play time.
The launchers **Turn skins off** button clears the selected skin and disables
skin use. With no skin enabled, mobile falls back to the built-in pad; that pad
is not itself a skin card.
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at
phone proportions on a desktop monitor.
@@ -200,6 +234,7 @@ phone proportions on a desktop monitor.
| Preset | Size |
| --- | --- |
| Phone portrait / landscape | 1080x1920, 1920x1080 |
| This screen | the live window, so a phone is authored at its own height |
| Tablet portrait / landscape | 1536x2048, 2048x1536 |
| Steam Deck | 1280x800 |
| Desktop 1080p | 1920x1080 |
@@ -216,13 +251,16 @@ and of the page itself when it comes within a few pixels, and the guide it
snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be
typed to the coordinate its art was drawn at. **Back** and **Front** move the
selection through the draw order. Bind, hitbox shape, hit reach and idle and
pressed images are per control; the bezel, the pages and the screen cutout are
per page. The cutout is itself a draggable element with a 10:9 lock.
pressed images are per control; the bezel, the pages and the screen anchor are
per page. The SCREEN anchor is itself draggable and resizable; its default
shape is freeform, with an optional 10:9 lock.
**Bind** opens a grid of every bind the engine understands: the eight Game Boy
buttons, the diagonal pairs, every hotkey, a few `key:` entries, and
decoration. The COMBINE chips at the top toggle one part at a time, which is
how a pipe bind like `left|down` is built without typing it.
buttons, the diagonal pairs, every hotkey, desktop hotkeys and decoration.
The desktop section exposes `-` / `=`, `1` through `5`, `F1`, `F2` and `F10`
as `key:` controls, so a mobile button invokes the exact same game path as
its desktop shortcut. The COMBINE chips at the top toggle one part at a time,
which is how a pipe bind like `left|down` is built without typing it.
**Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or
`u` / shift+`u` without a keyboard modifier). The stack holds the last 50
@@ -247,12 +285,13 @@ the **Import** button there and beside each row opens the host file picker (`src
zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in
the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the
window does the same for whichever slot was last touched. A new bezel does not
move the screen cutout: press **Detect screen from bezel** to measure it out of
move the screen anchor: press **Detect screen from bezel** to measure it out of
the art's alpha.
**Testing.** **Test** makes the canvas live: clicking presses real Game Boy
buttons and the footer reports what is held. **Play** saves the skin, selects
it, and boots the game with it.
**Testing.** **Test** renders a game-composition preview behind the live overlay:
the 160x144 picture letterboxes inside the screen cutout, matching gameplay.
Clicking presses real Game Boy buttons and the footer reports what is held.
**Play** saves the skin, selects it, and boots the game with it.
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
skin names, so the folder stands alone. **Export** offers three formats, and
-1
View File
@@ -183,7 +183,6 @@ hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
| ----- | -------------- | ------------------- |
| Select + **A** | `2` | COLORS |
| Select + **B** | `3` | TILT |
| Select + **Y** | `5` | GBC FX |
| Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) |
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
+4 -4
View File
@@ -22,8 +22,8 @@ Player install (what to download, title override) stays in
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver/` then that game's SAVE FILES → **Import save** (Gold / Silver cart `.sav` not supported yet) |
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver/` (pull after **Export save**; Gold / Silver cart `.sav` not supported yet) |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver\|crystal/` then that game's SAVE FILES → **Import save** (Gen 2 cart `.sav` not supported yet, on Gold, Silver or Crystal) |
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver\|crystal/` (pull after **Export save**; Gen 2 cart `.sav` not supported yet, on Gold, Silver or Crystal) |
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
| Lua error log | `lua-error.log` in the save dir |
@@ -54,8 +54,8 @@ macOS, not a Mac-only requirement.
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
(or copy NRO / `game.love` for loose).
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
`imports/saves/<red|blue|yellow|gold|silver>/`, or
`exports/<red|blue|yellow|gold|silver>/`
`imports/saves/<red|blue|yellow|gold|silver|crystal>/`, or
`exports/<red|blue|yellow|gold|silver|crystal>/`
path the launcher prints.
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
+6 -3
View File
@@ -126,9 +126,12 @@ bundled game, in that case.
persistent launcher control. Android downloads the release APK, verifies
its SHA-256 entry from `sha256sums.txt`, then invokes Android's Package
Installer. The installer asks the user for consent and enforces package,
version-code, and signing-certificate compatibility. iOS links the
sideload repository for a re-sideload; Xbox, desktop, and PortMaster builds
link their correctly named full package. Switch keeps its native OTA flow.
version-code, and signing-certificate compatibility. A legacy APK without
the installer bridge links its full package for one manual bootstrap
update, including when its downloaded payload already reports the latest
engine version. iOS links the sideload repository for a re-sideload; Xbox,
desktop, and PortMaster builds link their correctly named full package.
Switch keeps its native OTA flow.
## Known limitations
+72 -86
View File
@@ -24,10 +24,10 @@ local GameViewport = require("src.render.GameViewport")
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
do
local defaultErrorHandler = love.errorhandler
local defaultErrorHandler = love.errorhandler or love.errhand
function love.errorhandler(msg)
local hint = SwitchDiagnostics.logLuaError(msg)
if hint and type(msg) == "string" then
local ok, hint = pcall(SwitchDiagnostics.logLuaError, msg)
if ok and hint and type(msg) == "string" then
msg = msg .. "\n\n" .. hint
end
if defaultErrorHandler then
@@ -82,6 +82,11 @@ end
local editorHost, editorVersion, editorWindow
local closeEditor -- forward declaration: openEditor hands it to the editor
-- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted
-- version session (save editor or game). closeEditor and returnToLauncher
-- both go through SessionLifecycle so neither path forgets a singleton.
local SessionLifecycle = require("src.core.SessionLifecycle")
-- 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
@@ -157,9 +162,7 @@ local function openEditor(version, slotId)
local okReq, appOrErr = pcall(require, "App")
if not okReq then
editorMode = false
if version then
require("src.import.CacheFs").unmountVersion(version)
end
SessionLifecycle.endEditorSession({ version = version, app = nil })
restoreWindow()
Importer = editorHost
editorHost = nil
@@ -179,10 +182,7 @@ local function openEditor(version, slotId)
editorMode = false
if EditorApp.unload then pcall(EditorApp.unload) end
EditorApp = nil
if version then
require("src.import.CacheFs").unmountVersion(version)
require("src.core.Data"):unloadGenerated()
end
SessionLifecycle.endEditorSession({ version = version, app = nil })
restoreWindow()
Importer = editorHost
editorHost = nil
@@ -197,21 +197,14 @@ 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.
-- with this one's data. Also reset Runtime / Assets / LegacyCompat so the
-- next Edit or Play does not inherit the editor's dead mod loader.
function closeEditor()
local version = editorVersion
local app = EditorApp
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
for k in pairs(package.loaded) do
if type(k) == "string" and (k:find("save%-editor") or k == "App" or k == "Kit" or k == "State" or k == "Catalog" or k == "SaveIO" or k == "Ops" or k == "MonOps" or k == "ItemOps" or k == "PadInput" or k == "Gen" or k == "Theme") then
package.loaded[k] = nil
end
end
SessionLifecycle.endEditorSession({ version = version, app = app })
editorVersion = nil
restoreWindow()
Importer = editorHost
@@ -294,14 +287,16 @@ end
local function makeLauncher()
local RomImporter = require("src.import.RomImporter")
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
return RomImporter.new(function(version)
return RomImporter.new(function(version, cartId)
Importer = nil
bootGame(version)
bootGame(version, cartId)
end, {
launcher = true,
forceImport = forceImport,
onEditSave = openEditor,
onEditTouchControls = openTouchControlsEditor,
-- Skin Studio owns a touch-first layout as well as the desktop workspace.
-- Keep the compatibility predicate so external hosts using it still work.
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
and openSkinStudio or nil,
})
@@ -310,45 +305,19 @@ end
local function returnToLauncher()
if not Game then return end
pcall(function() require("src.core.Music").stop() end)
pcall(function() require("src.core.Sound").stop() end)
if package.loaded["src.core.ChipAudio"] then
pcall(package.loaded["src.core.ChipAudio"].shutdown)
end
if package.loaded["src.core.DiscordPresence"] then
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
end
if package.loaded["src.core.gen2.Clock"] then
pcall(package.loaded["src.core.gen2.Clock"].shutdown)
end
if package.loaded["src.net.Gen1Tls"] then
pcall(package.loaded["src.net.Gen1Tls"].shutdown)
end
if love.audio and love.audio.stop then
pcall(love.audio.stop)
end
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
local GameVersion = require("src.core.GameVersion")
local currentVersion = GameVersion.get()
if currentVersion then
require("src.import.CacheFs").unmountVersion(currentVersion)
end
require("src.core.Data"):unloadGenerated()
local Runtime = require("src.mods.Runtime")
if Runtime.reset then
Runtime.reset()
end
SessionLifecycle.endGameSession(Game)
Game = nil
autopilot = nil
driverCo = nil
-- Leave the cart's scope behind: the launcher's own settings and slots are
-- the base game's, not the cart's. The speed ladder is cart state too, so
-- a 1x/2x cart must not pin the launcher or the next game.
require("src.core.SaveData").setCart(nil)
require("src.core.GameSpeed").setAllowed(nil)
local Input = require("src.core.Input")
local TouchControls = require("src.core.TouchControls")
Input:reset()
TouchControls:reset()
SessionLifecycle.endMountedSession(currentVersion)
require("src.core.Orientation").applyOptions(
require("src.core.SaveData").loadOptions())
@@ -364,7 +333,7 @@ local function returnToLauncher()
Importer = makeLauncher()
end
function bootGame(version)
function bootGame(version, cartId)
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
-- Set the active version and overlay its extracted cache BEFORE anything
@@ -377,6 +346,25 @@ function bootGame(version)
-- (Blue/Yellow/Gold caches live under blue/ / yellow/ / gold/).
CacheFs.prefix = GameVersion.cachePrefix()
CacheFs.mountVersion(GameVersion.get())
local cartHash, cartSpeeds, cartOptions
if cartId then
local ok, cart, hash = pcall(function()
return require("src.carts.CartStore").get(cartId)
end)
if ok and cart then
cartHash, cartSpeeds, cartOptions = hash, cart.speeds, cart.options
else
cartId = nil
end
end
local SaveData = require("src.core.SaveData")
SaveData.setCart(cartId, cartHash)
-- The author's settings land in the cart's own scope the first time only;
-- after that the player owns them.
if cartOptions then SaveData.seedCartOptions(cartOptions) end
-- A cart may narrow or pin the speed ladder; nil restores the full one.
require("src.core.GameSpeed").setAllowed(cartSpeeds)
if cartId then SaveData.adoptCartSeal(cartId) end
-- NX: always write nx-asset-probe.log so Yellow/Blue art failures are
-- diagnosable from the SD without enabling switch-debug.txt.
pcall(function()
@@ -692,7 +680,7 @@ function love.gamepadpressed(joystick, button)
end
return
end
if Studio then return end
if Studio then return Studio.gamepadpressed(joystick, button) end
if Importer then return Importer:gamepadpressed(joystick, button) end
if not Game then return end
Game:gamepadpressed(joystick, button)
@@ -712,7 +700,7 @@ function love.gamepadreleased(joystick, button)
end
return
end
if Studio then return end
if Studio then return Studio.gamepadreleased(joystick, button) end
if Importer then return Importer:gamepadreleased(joystick, button) end
if not Game then return end
Game:gamepadreleased(joystick, button)
@@ -732,7 +720,7 @@ function love.gamepadaxis(joystick, axis, value)
end
return
end
if Studio then return end
if Studio then return Studio.gamepadaxis(joystick, axis, value) end
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
if not Game then return end
Game:gamepadaxis(joystick, axis, value)
@@ -752,7 +740,7 @@ function love.joystickpressed(joystick, button)
end
return
end
if Studio then return end
if Studio then return Studio.joystickpressed(joystick, button) end
if Importer then return Importer:joystickpressed(joystick, button) end
if not Game then return end
Game:joystickpressed(joystick, button)
@@ -772,7 +760,7 @@ function love.joystickreleased(joystick, button)
end
return
end
if Studio then return end
if Studio then return Studio.joystickreleased(joystick, button) end
if Importer then return Importer:joystickreleased(joystick, button) end
if not Game then return end
Game:joystickreleased(joystick, button)
@@ -792,7 +780,7 @@ function love.joystickaxis(joystick, axis, value)
end
return
end
if Studio then return end
if Studio then return Studio.joystickaxis(joystick, axis, value) end
if Importer then return Importer:joystickaxis(joystick, axis, value) end
if not Game then return end
Game:joystickaxis(joystick, axis, value)
@@ -812,7 +800,7 @@ function love.joystickhat(joystick, hat, direction)
end
return
end
if Studio then return end
if Studio then return Studio.joystickhat(joystick, hat, direction) end
if Importer then return Importer:joystickhat(joystick, hat, direction) end
if not Game then return end
Game:joystickhat(joystick, hat, direction)
@@ -877,6 +865,8 @@ love.handlers = love.handlers or {}
function love.handlers.audiosuspend()
local ChipAudio = package.loaded["src.core.ChipAudio"]
if ChipAudio then pcall(ChipAudio.setSuspended, true) end
local Sound = package.loaded["src.core.Sound"]
if Sound then pcall(Sound.onDeviceReset) end
end
function love.handlers.audioreset()
@@ -929,7 +919,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
if love.system.getOS() == "iOS" then return end
return TouchEditor.touchpressed(id, x, y)
end
if Studio then return end
if Studio then return Studio.touchpressed(id, x, y) end
if Importer then
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
-- polled inside the view; the istouch filter on mousepressed still drops
@@ -946,7 +936,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
if love.system.getOS() == "iOS" then return end
return TouchEditor.touchmoved(id, x, y)
end
if Studio then return end
if Studio then return Studio.touchmoved(id, x, y) end
if Importer then
return Importer:touchmoved(id, x, y, dx, dy, pressure)
end
@@ -960,7 +950,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
if love.system.getOS() == "iOS" then return end
return TouchEditor.touchreleased(id, x, y)
end
if Studio then return end
if Studio then return Studio.touchreleased(id, x, y) end
if Importer then
return Importer:touchreleased(id, x, y, dx, dy, pressure)
end
@@ -1013,7 +1003,12 @@ function love.mousepressed(x, y, button, istouch)
if love.system.getOS() == "Android" then return end
return TouchEditor.mousepressed(x, y, button)
end
if Studio then return Studio.mousepressed(x, y, button) end
if Studio then
-- Mobile LÖVE sends both a touch event and an `istouch` mouse twin.
-- Studio consumes the real finger stream above, so discard the twin.
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
return Studio.mousepressed(x, y, button)
end
if Importer then
-- love.touchpressed already forwards the primary touch into FlexLove for
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
@@ -1048,7 +1043,10 @@ function love.mousereleased(x, y, button, istouch)
if love.system.getOS() == "Android" then return end
return TouchEditor.mousereleased(x, y, button)
end
if Studio then return Studio.mousereleased(x, y, button) end
if Studio then
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
return Studio.mousereleased(x, y, button)
end
if Importer then return end
if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button)
@@ -1066,7 +1064,10 @@ function love.mousemoved(x, y, dx, dy, istouch)
if love.system.getOS() == "Android" then return end
return TouchEditor.mousemoved(x, y)
end
if Studio then return Studio.mousemoved(x, y) end
if Studio then
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
return Studio.mousemoved(x, y)
end
if editorMode or Importer then return end
if mouseTouch then
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
@@ -1138,22 +1139,7 @@ function love.quit()
pcall(function()
require("src.core.DiscordPresence").shutdown()
end)
-- LOVE waits for every live love.thread before the process exits, and both
-- background workers idle in a loop that only a "quit" command breaks, so
-- without this the process outlived the window and the next launch re-entered
-- the dead one instead of starting fresh (#339)
if package.loaded["src.core.ChipAudio"] then
pcall(package.loaded["src.core.ChipAudio"].shutdown)
end
if package.loaded["src.update.Check"] then
pcall(package.loaded["src.update.Check"].shutdown)
end
-- The launcher's fetch pool is the same story: its workers idle in
-- Channel:demand(), which never returns on its own, so a launcher that ever
-- touched the network would hang the process on exit (#339's shape again).
if package.loaded["src.net.Fetch"] then
pcall(package.loaded["src.net.Fetch"].shutdown)
end
SessionLifecycle.endProcess()
end
function love.filedropped(file)
@@ -30,6 +30,10 @@
<!-- Low latency audio -->
<uses-feature android:name="android.hardware.audio.low_latency" android:required="false" />
<uses-feature android:name="android.hardware.audio.pro" android:required="false" />
<!-- love.sensor (accelerometer). No runtime permission needed on Android;
this is a Play Store filtering hint only, so required=false like the
other optional hardware features above. -->
<uses-feature android:name="android.hardware.sensor.accelerometer" android:required="false" />
<application
android:allowBackup="true"
@@ -54,7 +58,7 @@
<activity
android:name="org.love2d.android.GameActivity"
android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection|colorMode"
android:label="${NAME}"
android:launchMode="singleTask"
android:screenOrientation="${ORIENTATION}"
@@ -71,7 +75,7 @@
</activity>
<activity
android:name="org.love2d.android.GameActivity$SecondaryActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection|colorMode"
android:excludeFromRecents="true"
android:exported="false"
android:launchMode="singleTask"
+1 -2
View File
@@ -63,8 +63,7 @@ android {
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
minifyEnabled false
}
debug {
ndk {
+70
View File
@@ -12,6 +12,76 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.2.22",
"date": "2026-08-24",
"size": 14060365,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.22/gen1recomp++-0.2.22-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @ShaneMcGovernIE\n- @thibautbus"
},
{
"version": "0.2.21",
"date": "2026-08-24",
"size": 14058979,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.21/gen1recomp++-0.2.21-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1483 (Pokémon Gold) Missing HM/TM fanfare when receiving them\n- #1610 Wrong text when choosing Pokemon to trade\n- #1615 Fishing Guru dialogue issue\n- #1640 Sync doesnt work on XBOX build\n- #1646 Cashier dialogue + sound effects\n- #1649 Pokemon cries missing in Vermilion City\n- #1651 Ticket taker asks for ticket too much\n- #1653 Wrap animation is too fast and has no screen shake\n- #1656 Map visible in between end of fight & evolution\n- #1683 Music not changing when talking to trainer\n- #1685 Cancel option missing in item menu\n- #1686 Issues with teaching TMs & HMs\n- #1687 Pokemon cries missing on S.S. Anne\n- #1688 S.S. Anne rival dialogue\n- #1689 S.S. Anne departing cutscene broken\n- #1690 Wrong dialogue when taking back entrance to museum\n- #1693 (Gold) Issues not properly fixed\n- #1694 (Gold) Arrow when seeing items in the bag should be red, not black\n- #1695 (Gold) Wrong layout in TMs/HMs pocket\n- #1696 (Gold) Caught pokémon don't get healed when automatically sent to the PC\n- #1697 Standardize identification of mod-created options/settings screens\n- #1702 Lt. Surge's door opening isn't synced with the sound effect\n- #1704 Ledge jumping on the bike doesn't slow you down\n- #1705 The bike shouldn't have Use/Toss options\n- #1706 (Gold) Cursor wraps around the edge in the battle interface\n- #1707 (Gold) Pokémon sometimes wake up in the same turn they were put to sleep\n- #1708 (Gold) Missing rod when fishing\n- #1710 (Gold) You shouldn't be able to change boxes by pressing LEFT or RIGHT\n- #1711 (Gold) Missing animation for FLY in the overworld\n- #1712 (Gold) Wrong cursor in the map when using FLY\n- #1713 (Gold) Low framerate when jumping off ledges\n- #1716 (Gold) Player can go through a whirlpool even without having the HM\n- #1717 (Gold) Missing animation and SFX when using whirlpool in the overworld\n- #1718 (Gold) Can't pause in the Cycling Road while moving down\n- #1719 (Gold) Pokémon given by NPCs not being registered in the pokédex\n- #1720 Critical hit on multi-hit moves repeats the message\n- #1721 Rival not visible while talking if you lose first fight\n- #1724 Centralize ROM cache readiness and publication in an engine-owned contract\n- #1725 (Gold) Text not spaced nor centered correctly for secondary actions in the bag\n- #1732 Cursor doesn't stay on last used item\n- #1745 Viewing items makes ingame menu disappear\n- #1748 (Gold) Overworld pokémon sprite are stactic\n- #1749 (Gold) You shouldn't be able to surf on Cycling Road\n- #1751 (Gold) Synthesis and Morning Sun always recover 50% of max HP\n- #1754 Leaving building with bike speed\n- #1756 No cursor or gamepad controls in skin menu, locked out on Xbox\n\n## Contributors\n\n- @1Jamie\n- @BoLayerDev\n- @bryanthaboi\n- @campavao\n- @caorthann-celt\n- @dlloa\n- @syybott\n- @thibautbus\n- MaxTomahawk"
},
{
"version": "0.2.20",
"date": "2026-08-22",
"size": 13812226,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.20/gen1recomp++-0.2.20-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1536 Magnet Train Cutscene visual bugs\n- #1597 Link cable looks broken\n- #1643 Pikachu + Title uses an incorrect Colour Palette\n- #1662 The game glitches after exiting, returning to the menu, and choosing another one.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @colsonrice"
},
{
"version": "0.2.19",
"date": "2026-08-22",
"size": 13778435,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.19/gen1recomp++-0.2.19-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1274 Choose ROM imports a different version than the one selected when several dumps are pending (Linux, no file picker)\n- #1484 (Pokémon Gold) Bill's PC doesn't show equipped items + missing orange filter on pokémon sprite\n- #1486 (Pokémon Gold) Can't deposit key items\n- #1513 Experience share issue different case from #1464\n- #1517 Game Crash Opening Stats\n- #1548 Colisión\n- #1556 (Gold) Missing SFX for a lot of actions\n- #1564 Bide's storing turn is missing the screen shake\n- #1567 TM/HM case not in order\n- #1663 transition pops the top state, destroying anything pushed from a midpoint callback\n- #1664 gold bide spends pp every turn instead of only the turn it is selected\n- #1665 gold has no bide lock-in, so a mid-bide switch of move strands the counter\n- #1666 sleep talk is inert in gold: EFFECT_SLEEP_TALK has no move effect entry\n- #1667 gold: a fainted party mon keeps its exp participant credit and halves the survivor's exp\n- #1668 summary page 2 formats a move's pp with no numeric guard\n- #1670 gold tm/hm pocket capacity is an invented 64 where the cart has 57 slots\n- #1672 bill's pc move screen is missing the box name arrows\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @colsonrice\n- @jramiresbrito"
},
{
"version": "0.2.18",
"date": "2026-08-21",
"size": 13772120,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.18/gen1recomp++-0.2.18-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.17",
"date": "2026-08-21",
"size": 13771903,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.17/gen1recomp++-0.2.17-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.16",
"date": "2026-08-21",
"size": 13769300,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.16/gen1recomp++-0.2.16-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1367 Android auto scrolling down bug\n- #1570 Pokemon Gold isn't noticing my controller inputs on...ANY controller I own, despite all of them working in the other games, and in the main launcher\n- #1585 Power-saving mode or dark mode closes the game on Android.\n- #1611 bug: keyboard gets stuck IOS\n- #1612 Fold 7 infinite scrolling mouse.\n- #1636 Game freezes when trainer battle starts\n- #1638 Game screen orientation on smartphone\n- #1641 [Gold] [Android] PKMN Evolutions sequence freezes the Game \n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @HighDrexler\n- MaxTomahawk"
},
{
"version": "0.2.15",
"date": "2026-08-21",
"size": 13753007,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.15/gen1recomp++-0.2.15-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1589 Player sprite still blinking on the Town Map\n- #1595 Pokedex completion certificate is not displaying correctly\n- #1597 Link cable looks broken\n- #1613 Visual differences in Pokemon trades\n- #1619 Save Menu cutting off and Put into another place\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
},
{
"version": "0.2.14",
"date": "2026-08-20",
"size": 13749372,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.14/gen1recomp++-0.2.14-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.13",
"date": "2026-08-20",
"size": 13749377,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.13/gen1recomp++-0.2.13-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1474 (Pokémon Gold) Pokédex mode not being saved\n- #1475 (Pokémon Gold) Vital Throw attacking before the opponent\n- #1477 (Pokémon Gold) Map when using fly keeps the arrow from the pokégear\n- #1478 (Pokémon Gold) Wrong move layout in battles\n- #1479 [Gold] tiles broken at Route 28 and Mt Silver\n- #1482 (Pokémon Gold) Nothing happens when trying to use Coin Case\n- #1488 Pokemon EXP calculation after trading went back to issue #984\n- #1510 Title Screen Transition is Missing\n- #1511 Broken Trainer Rival Name Layout\n- #1512 Gen 2 Post battle interactions don't exist\n- #1514 Health damage timing\n- #1516 Move learning timing\n- #1521 Different Item Menu\n- #1522 Wrong Save Game Layout\n- #1545 Pikachu not sliding out when switching Pokemon.\n- #1557 (Gold) Impossible to get TM 03 in Celadon Mansion at night\n- #1558 (Gold) Missing colors and symbol in stats screen\n- #1563 Player mon pic cuts away instead of shrinking when recalled\n- #1565 Thrash doesn't lock in when the first use misses\n- #1566 Center PC missing the PKMN LEAGUE entry after the Hall of Fame\n- #1569 Goldenrod Gift Spearow Bugged\n- #1577 Thrash has no animation past the first turn\n- #1578 Hitting yourself in confusion animation missing\n- #1579 Missing dialogue for Cerulean City Rocket\n- #1594 Pokemon menu closing too soon when using rare candy\n- #1596 Evolution dialogue & missing jingle\n- #1606 Misty dialogue issue\n- #1608 \"There's no will to fight!\" message issues\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.12",
"date": "2026-08-20",
+1 -1
View File
@@ -1,6 +1,6 @@
# LÖVE UWP binaries
These x64 UWP Release binaries were built from [`caorthann-celt/love-xbox-uwp`](https://github.com/caorthann-celt/love-xbox-uwp) at commit `cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb`.
These x64 UWP Release binaries were built from [`caorthann-celt/love-xbox-uwp`](https://github.com/caorthann-celt/love-xbox-uwp) at commit `3f51bf0be5f3f86e5934da3893caba2b817f82c1`.
The build uses LÖVE 11.5, LuaJIT, SDL2, and ANGLE. Keep the DLLs and import libraries together; they are one binary interface.
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -7,7 +7,7 @@
"love": {
"version": "11.5",
"repository": "https://github.com/caorthann-celt/love-xbox-uwp.git",
"commit": "cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb"
"commit": "3f51bf0be5f3f86e5934da3893caba2b817f82c1"
},
"luajit": {
"repository": "https://github.com/LuaJIT/LuaJIT.git",
@@ -99,7 +99,7 @@
},
{
"path": "love/bin/love.dll",
"sha256": "A3622828014F03DDB502D2A7807DC8F94432172595BE03508099BD66937FD45F"
"sha256": "E2CE334817636AA9EFF97E181FB5C33C506971F2AC7C653F2CA751B5CDE2FCE6"
},
{
"path": "love/bin/lua51.dll",
@@ -111,7 +111,7 @@
},
{
"path": "love/lib/lovestatic.lib",
"sha256": "15FB3A9CB3C3CDAB8B33DE5B1D1F8D8FF6CE9719E9AA7BC2CF3D7B51CDCE0C98"
"sha256": "3794690ED3F44DB6F0C83BBC1673979922627929E852E7668C9911DE1F72B972"
},
{
"path": "love/lib/lua51.lib",
+33 -3
View File
@@ -79,7 +79,7 @@ else
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
fi
# Materialize the listing once and grep the file: piping unzip straight into
@@ -101,7 +101,8 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
src/ui/kit/Kit.lua \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json; do
tools/rom_manifest_silver.json \
tools/rom_manifest_crystal.json; do
grep -qxF "$required" "$LOVE_LISTING" \
|| fail "game.love is missing $required"
done
@@ -167,6 +168,31 @@ make_ico() { # $1 = output .ico path
}
# --------------------------------------------------------------- macOS
# ShaderFX's librashader bridge. Only the CONVERT action needs it, so a build
# without it still runs presets that were converted elsewhere.
# SHADERFX_BRIDGE points at a prebuilt library; otherwise cargo builds it.
bundle_shader_bridge() {
local dest="$1" name="$2"
local src="${SHADERFX_BRIDGE:-}"
local crate="$ROOT/tools/shaderfx-bridge"
if [ -z "$src" ] && [ -f "$crate/target/release/$name" ]; then
src="$crate/target/release/$name"
fi
if [ -z "$src" ] && command -v cargo >/dev/null 2>&1; then
say "building the ShaderFX bridge with cargo"
if (cd "$crate" && cargo build --release >/dev/null 2>&1); then
src="$crate/target/release/$name"
fi
fi
if [ -n "$src" ] && [ -f "$src" ]; then
mkdir -p "$dest"
cp "$src" "$dest/$name"
say "bundled $name for SHADER FX preset conversion"
else
warn "$name not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE or install cargo)"
fi
}
build_mac() {
say "building macOS app"
local love_app="${LOVE_APP:-/Applications/love.app}"
@@ -179,6 +205,7 @@ build_mac() {
# drop any bundled placeholder .love and fuse ours in
find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete
cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love"
bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib"
local plist="$out_app/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \
@@ -292,9 +319,11 @@ build_win() {
cp "$tls_dll" "$out_dir/gen1tls.dll"
say "bundled gen1tls.dll for Windows TLS (wss://)"
else
warn "gen1tls.dll not found Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)"
warn "gen1tls.dll not found: Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)"
fi
bundle_shader_bridge "$out_dir" "librashader_bridge.dll"
# The exe's icon lives in love.exe's PE resources, so it must be patched
# BEFORE the .love is appended: peresed rewrites the whole file and would
# drop the fused bytes. peresed (pipx install pe_tools) has no .ico input,
@@ -390,6 +419,7 @@ build_linux() {
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
cp "$LOVE_FILE" "$appdir/game.love"
bundle_shader_bridge "$appdir" "liblibrashader_bridge.so"
# Replace LÖVE's own desktop entry rather than keeping it: it says
# Name=LÖVE / Icon=love, which is what appimaged, app menus and file
+81 -7
View File
@@ -2,11 +2,22 @@
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
#
# Usage: scripts/build_android.sh [--version X.Y.Z] [--release] [--package-only]
# [--test-application-id ID]
#
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
# --release build the production-signed release APK (requires the
# GEN1RECOMP_ANDROID_* signing environment variables)
# --package-only zip game.love + apply branding; skip gradle
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
# --release build the production-signed release APK (requires the
# GEN1RECOMP_ANDROID_* signing environment variables)
# --package-only zip game.love + apply branding; skip gradle
# --test-application-id ID install under a distinct application id (e.g.
# com.theboisclub.pokemonred.shaderfxtest) so a
# test build installs side by side with a real
# played copy instead of overwriting it. App name
# gets a " (test)" suffix so it's distinguishable
# in the launcher too. Without this flag,
# apply_android_branding always writes back the
# real shipping identity, which previously had to
# be restored by hand in gradle.properties after
# every test build.
#
# Prerequisites:
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
@@ -34,10 +45,13 @@ GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}"
SILVER_MANIFEST_RELATIVE="tools/rom_manifest_silver.json"
SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_silver.json}"
CRYSTAL_MANIFEST_RELATIVE="tools/rom_manifest_crystal.json"
CRYSTAL_MANIFEST_URL="${CRYSTAL_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_crystal.json}"
VERSION=""
PACKAGE_ONLY=false
RELEASE=false
TEST_APPLICATION_ID=""
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
@@ -48,11 +62,12 @@ while [ $# -gt 0 ]; do
--version) VERSION="$2"; shift ;;
--package-only) PACKAGE_ONLY=true ;;
--release) RELEASE=true ;;
--test-application-id) TEST_APPLICATION_ID="$2"; shift ;;
-h|--help)
sed -n '2,20p' "$0"
sed -n '2,28p' "$0"
exit 0
;;
*) fail "unknown argument: $1 (try --version X.Y.Z, --release, or --package-only)" ;;
*) fail "unknown argument: $1 (try --version X.Y.Z, --release, --package-only, or --test-application-id ID)" ;;
esac
shift
done
@@ -84,6 +99,14 @@ if $RELEASE; then
|| fail "Android signing keystore does not exist: $GEN1RECOMP_ANDROID_KEYSTORE"
fi
if [ -n "$TEST_APPLICATION_ID" ]; then
if ! printf '%s' "$TEST_APPLICATION_ID" | grep -Eq '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$'; then
fail "invalid --test-application-id '$TEST_APPLICATION_ID' (expected a dotted package id, e.g. com.theboisclub.pokemonred.shaderfxtest)"
fi
APPLICATION_ID="$TEST_APPLICATION_ID"
APP_NAME="$APP_NAME (test)"
fi
# --------------------------------------------------------------- preconditions
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
fail "love-android not found at mobile/android/.
@@ -246,6 +269,54 @@ ensure_silver_manifest() {
fail "Silver import manifest is unavailable. Git recovery failed and could not download $SILVER_MANIFEST_URL"
}
crystal_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") ==
"f4cd194bdee0d04ca4eac29e09b8e4e9d818c133" else 1)
PY
}
ensure_crystal_manifest() {
local manifest="$ROOT/$CRYSTAL_MANIFEST_RELATIVE"
local staged
staged="$(mktemp)"
if crystal_manifest_is_valid "$manifest"; then
rm -f "$staged"
return
fi
warn "Crystal import manifest is missing or invalid; recovering it before packaging"
if git -C "$ROOT" show "HEAD:$CRYSTAL_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \
&& crystal_manifest_is_valid "$staged"; then
mkdir -p "$(dirname "$manifest")"
mv "$staged" "$manifest"
say "restored Crystal 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" "$CRYSTAL_MANIFEST_URL" \
&& crystal_manifest_is_valid "$staged"; then
mkdir -p "$(dirname "$manifest")"
mv "$staged" "$manifest"
say "downloaded Crystal import manifest from the project repository"
return
fi
rm -f "$staged"
fail "Crystal import manifest is unavailable. Git recovery failed and could not download $CRYSTAL_MANIFEST_URL"
}
# --------------------------------------------------------------- branding
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
# Manifest still gets permission trims. Re-applied every build so refreshing
@@ -312,6 +383,7 @@ pack_game_love() {
ensure_yellow_manifest
ensure_gold_manifest
ensure_silver_manifest
ensure_crystal_manifest
mkdir -p "$EMBED_ASSETS"
rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save
@@ -326,7 +398,7 @@ pack_game_love() {
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
# List once and match against the captured text: piping unzip straight into
@@ -350,6 +422,8 @@ pack_game_love() {
|| fail "game.love is missing the Gold ROM import manifest"
grep -qx 'tools/rom_manifest_silver.json' <<< "$archive_entries" \
|| fail "game.love is missing the Silver ROM import manifest"
grep -qx 'tools/rom_manifest_crystal.json' <<< "$archive_entries" \
|| fail "game.love is missing the Crystal ROM import manifest"
# This gate exists because the launcher's UI toolkit once lived outside
# src/ (libs/flexlove) and was added to scripts/build.sh's payload and to
# no other packager, so Android and iOS built an APK/IPA whose launcher
+21 -2
View File
@@ -7,7 +7,8 @@
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
# cosmetic categories the codebase lives with, so what prints is worth a look.
#
# scripts/lint.sh lint src/
# scripts/lint.sh full advisory report over every shipped tree
# scripts/lint.sh --gate only the codes CI blocks on (0xx, 1xx, 511)
# scripts/lint.sh src tools lint specific paths
#
# Install once with: luarocks install luacheck
@@ -15,9 +16,27 @@
set -uo pipefail
cd "$(dirname "$0")/.."
DEFAULT_PATHS=(main.lua conf.lua src data/scripts mods tools)
GATE=0
if [ "${1:-}" = "--gate" ]; then
GATE=1
shift
fi
if ! command -v luacheck >/dev/null 2>&1; then
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
exit 2
fi
luacheck "${@:-src}"
if [ "$#" -gt 0 ]; then
PATHS=("$@")
else
PATHS=("${DEFAULT_PATHS[@]}")
fi
if [ "$GATE" = "1" ]; then
exec luacheck "${PATHS[@]}" -q --codes --only 0 1 511
fi
luacheck "${PATHS[@]}"
+2
View File
@@ -368,6 +368,8 @@ grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \
|| fail "game.love is missing tools/rom_manifest_gold.json"
grep -qxF "tools/rom_manifest_silver.json" "$WORK/love-listing.txt" \
|| fail "game.love is missing tools/rom_manifest_silver.json"
grep -qxF "tools/rom_manifest_crystal.json" "$WORK/love-listing.txt" \
|| fail "game.love is missing tools/rom_manifest_crystal.json"
# The .desktop's Icon= resolves against the AppDir root by basename, and
# .DirIcon is what appimaged and file-manager thumbnailers read.
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
@@ -171,5 +171,7 @@ grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|| fail "shared payload is missing tools/rom_manifest_gold.json"
grep -qxF "tools/rom_manifest_silver.json" "$temp_dir/love-listing.txt" \
|| fail "shared payload is missing tools/rom_manifest_silver.json"
grep -qxF "tools/rom_manifest_crystal.json" "$temp_dir/love-listing.txt" \
|| fail "shared payload is missing tools/rom_manifest_crystal.json"
say "Linux arm64 self-test passed"
+2 -1
View File
@@ -50,7 +50,7 @@ rm -f "$OUTPUT"
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
@@ -96,6 +96,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
tools/rom_manifest_crystal.json \
src/ui/kit/Kit.lua \
src/import/LauncherView.lua; do
grep -qxF "$required" "$LISTING" \
+2 -1
View File
@@ -19,7 +19,8 @@ if [ ! -f "$ROOT/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/gold/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/silver/data/generated/maps.lua" ]; then
&& [ ! -f "$ROOT/silver/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/crystal/data/generated/maps.lua" ]; then
fail "generated data missing, run scripts/setup.sh first"
fi
+10 -4
View File
@@ -71,15 +71,15 @@ First install or update (same steps):
your saves, imported ROMs, mods, and options. Re-extracting only
replaces the NRO(s) and these help files.
3. Launch with title override (hold R on HOME, open any title → hbmenu).
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc into:
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver/Crystal .gbc into:
switch/gen1recomp/pokemon-love2d/imports/
then use Scan again in the launcher if needed.
Inboxes (drop files here via MTP / SD / FTP):
imports/ — ROM .gb / .gbc
imports/mods/ — community mod .zip
imports/saves/red|blue|yellow|gold|silver/ raw .sav import (Gold/Silver cart .sav not yet)
exports/red|blue|yellow|gold|silver/ pull after Export save (Gold/Silver not yet)
imports/saves/red|blue|yellow|gold|silver|crystal/ - raw .sav import (Gen 2 cart .sav not yet)
exports/red|blue|yellow|gold|silver|crystal/ - pull after Export save (Gen 2 not yet)
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
EOF
@@ -92,7 +92,7 @@ write_readme() {
}
write_readme "$SAVE_ROOT/imports/README.txt" \
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc here, then Scan again in the launcher."
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver/Crystal .gbc here, then Scan again in the launcher."
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
"Put community mod .zip files here, then MODS → Scan again."
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
@@ -105,6 +105,8 @@ write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \
"Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
write_readme "$SAVE_ROOT/imports/saves/silver/README.txt" \
"Silver cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
write_readme "$SAVE_ROOT/imports/saves/crystal/README.txt" \
"Crystal cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
write_readme "$SAVE_ROOT/exports/red/README.txt" \
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
@@ -115,6 +117,8 @@ write_readme "$SAVE_ROOT/exports/gold/README.txt" \
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
write_readme "$SAVE_ROOT/exports/silver/README.txt" \
"Silver cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
write_readme "$SAVE_ROOT/exports/crystal/README.txt" \
"Crystal cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
rm -f "$OUT_ZIP"
(
@@ -145,11 +149,13 @@ REQUIRED=(
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/crystal/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/crystal/README.txt"
)
for rel in "${REQUIRED[@]}"; do
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
+3 -1
View File
@@ -272,11 +272,13 @@ for rel in \
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/crystal/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/crystal/README.txt"
do
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
done
+68 -5
View File
@@ -5,8 +5,8 @@
# them fails. The tier split is what makes that possible: T1/T2/T4 need
# nothing but the committed fixture dataset, so they run anywhere --
# including CI, which has no ROM. T3 asserts Pokemon Red facts and needs
# data/generated/, so it is skipped automatically when the ROM has never
# been imported rather than failing the run.
# data/generated/ or an imported Red cache, so it is skipped automatically
# when neither exists rather than failing the run.
#
# scripts/test.sh every tier this checkout can run
# scripts/test.sh --quick skip the slow content tier
@@ -16,6 +16,13 @@
#
# LUA overrides the interpreter (luajit here; CI installs lua5.4 too, but
# the engine targets LuaJIT/5.1 semantics so luajit is the default).
#
# POKEPORT_TEST_CACHES points at one LOVE identity holding an imported cache
# per version (red/ blue/ yellow/ gold/ silver/ crystal/); each is exported as
# RED_CACHE .. CRYSTAL_CACHE for the suites that read one. Build it with:
# POKEPORT_IDENTITY=pokeport-test-caches POKEPORT_VERSION=<version> \
# POKEPORT_IMPORT_ONLY=1 POKEPORT_IMPORT_ROM="<rom>" love .
# An explicit RED_CACHE/GOLD_CACHE/... in the environment always wins.
set -uo pipefail
@@ -32,7 +39,7 @@ for arg in "$@"; do
--bless) BLESS=1 ;;
--bless-shots) SHOTS=1; BLESS=1 ;;
--quick) QUICK=1 ;;
--help|-h) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
--help|-h) sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown option: $arg" >&2; exit 2 ;;
esac
done
@@ -49,6 +56,36 @@ fi
# save-editor suite pins that to the default identity.
SANDBOX_IDENTITY="ci-$$"
# Per-version caches. POKEPORT_TEST_CACHES names one LOVE identity holding an
# imported cache per version (red/ blue/ yellow/ gold/ silver/ crystal/).
CACHE_IDENTITY=${POKEPORT_TEST_CACHE_IDENTITY:-pokeport-test-caches}
if [ -n "${POKEPORT_TEST_CACHES:-}" ]; then
CACHE_ROOT="$POKEPORT_TEST_CACHES"
elif [ -d "$HOME/Library/Application Support/LOVE/$CACHE_IDENTITY" ]; then
CACHE_ROOT="$HOME/Library/Application Support/LOVE/$CACHE_IDENTITY"
else
CACHE_ROOT="$HOME/.local/share/love/$CACHE_IDENTITY"
fi
# Only a cache with the importer's completion marker is offered: a half-written
# one would fail suites that are meant to self-skip.
adopt_cache() {
local var="$1" dir="$CACHE_ROOT/$2"
[ -z "${!var:-}" ] || return 0
[ -f "$dir/rom-cache.complete" ] || return 0
export "$var=$dir"
echo " $var=$dir"
}
echo ""
echo "-- per-version caches under $CACHE_ROOT"
adopt_cache RED_CACHE red
adopt_cache BLUE_CACHE blue
adopt_cache YELLOW_CACHE yellow
adopt_cache GOLD_CACHE gold
adopt_cache SILVER_CACHE silver
adopt_cache CRYSTAL_CACHE crystal
FAILED=()
run_tier() {
local label="$1"; shift
@@ -66,8 +103,18 @@ run_tier() {
# ------- ROM-free tiers: these are what CI runs
if command -v luacheck >/dev/null 2>&1; then
run_tier "T0 luacheck gate (undefined globals, unreachable code)" \
./scripts/lint.sh --gate
else
echo ""
echo "-- T0 luacheck gate: skipped (no luacheck on PATH --"
echo " luarocks install luacheck; CI installs and gates on it regardless)"
fi
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
run_tier "T0 Crystal manifest + specials coverage" "$LUA" tests/crystal_import_test.lua
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
@@ -78,6 +125,9 @@ run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_
run_tier "T0 NX Gold cache load (maps.lua prefix)" "$LUA" tests/engine/cache_fs_gold_nx_load_test.lua
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
# Gen 2 / Crystal: ROM-free (own fixtures, or a self-skip on a missing cache),
# so it runs here rather than behind the Red content gate below.
run_tier "T2 Gen 2 / Crystal suites" "$LUA" tests/run_gen2.lua
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
run_tier "T4 title checkpoint cold restart" \
bash tests/integration/title_checkpoint_cold_start.sh
@@ -124,7 +174,19 @@ run_content_behavior() {
return 1
}
# The Red dataset is the source tree's when tools/build_data.py wrote one, and
# otherwise the imported Red cache, through Data:load's POKEPORT_DATA_DIR hook.
HAVE_RED_DATA=0
if [ -f data/generated/maps.lua ]; then
HAVE_RED_DATA=1
elif [ -n "${RED_CACHE:-}" ] && [ -f "$RED_CACHE/data/generated/maps.lua" ]; then
HAVE_RED_DATA=1
export POKEPORT_DATA_DIR="$RED_CACHE/data/generated"
echo ""
echo "-- T3 content: reading Red from $POKEPORT_DATA_DIR"
fi
if [ "$HAVE_RED_DATA" = "1" ]; then
if [ "$QUICK" = "1" ]; then
echo ""
echo "-- T3 content: skipped (--quick)"
@@ -154,8 +216,9 @@ if [ -f data/generated/maps.lua ]; then
fi
else
echo ""
echo "-- T3 content + run_link_tests: skipped (no data/generated/ --"
echo " import a ROM to run them; the modded-link cases ran in T4)"
echo "-- T3 content + run_link_tests: skipped (no data/generated/ and no"
echo " RED_CACHE -- import a ROM to run them; the modded-link cases ran"
echo " in T4 and the Gen 2 suites in T2)"
fi
# ------- golden screenshots: needs love + a display
+11
View File
@@ -348,6 +348,12 @@ local EMITTERS = {
SE_PETALS_FALLING = function() return fallingObjectSteps(20, PETAL_TILE, "e4") end,
}
-- home/copy2.asm:62 CopyVideoData -- 8 tiles a frame plus the tail frame
local function tilesetLoadFrames(data, ts)
local sheet = data and data.tilesheets and data.tilesheets[ts or 0]
return math.floor((sheet and sheet.tiles or 79) / 8) + 1
end
-- One OAM entry for tile `t` of a frame block anchored at base coord `bc`,
-- with the subanimation transform applied (DrawFrameBlock).
local function placeTile(transform, bc, t, tileset)
@@ -456,6 +462,11 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
local obp0Flip = false
for _, row in ipairs(anim.seq) do
-- engine/battle/animations.asm:252 LoadMoveAnimationTiles, before the
-- row's sound and its first frame block (#1653)
if not row.effect then
emit(tilesetLoadFrames(self.data, row.tileset))
end
-- PlayAnimation/PlaySubanimation: each row's sound byte is a move id
-- whose MoveSoundTable entry (sfx + pitch/tempo modifiers) plays as
-- the row starts (GetMoveSound)
+158 -7
View File
@@ -538,6 +538,7 @@ end
local function makeBattler(data, mon, isPlayer, save)
local def = data.pokemon[mon.species]
require("src.pokemon.Stats").ensure(def, mon)
local badgeBoosts = data.constants and data.constants.badgeBoosts
local badges = nil
if isPlayer and save then
@@ -637,6 +638,9 @@ local function newBattle(game)
-- wPartyAndBillsPCSavedMenuItem, so entering a battle drops the party
-- cursor the field menu has been carrying (src/ui/PartyMenu.lua). #768
game.partyMenuSavedIndex = nil
-- the same run covers wBagSavedMenuItem, and wListScrollOffset follows it
-- (init_battle_variables.asm:7-12) #1732
game.bagSavedMenuItem, game.bagListScrollOffset = nil, nil
self.data = game.data
-- ruleset from the merged registry (the requires above are the same
-- records on a mod-free boot); an unknown save value falls back to the
@@ -1917,6 +1921,8 @@ function BattleState:exit()
-- end_of_battle.asm clears wPartyAndBillsPCSavedMenuItem as well, so the
-- field party menu comes back on slot 1 after a battle. #768
self.game.partyMenuSavedIndex = nil
-- and wBagSavedMenuItem / wListScrollOffset (end_of_battle.asm:57-62) #1732
self.game.bagSavedMenuItem, self.game.bagListScrollOffset = nil, nil
-- Free this battle's own GPU objects now rather than waiting on a GC
-- finalizer: the two full-screen wavy-effect canvases (colorMode) and
-- the AnimPlayer's per-instance tilesheet images/quads. The shared
@@ -1941,6 +1947,7 @@ local function clearTrapping(battler)
battler.trappingTurns = nil
battler.trapMove = nil
battler.trapDamage = nil
battler.trapHitSfx = nil
end
-- SendOutMon (core.asm:1733-1735) clears both battle cursors, though the
@@ -2791,6 +2798,111 @@ function BattleState:queueResidual(b, opp)
end
end
local function fieldBattlerView(battler, side)
local types = {}
for index, typeId in ipairs(battler.curTypes or {}) do
types[index] = typeId
end
local mon = battler.mon or {}
return {
side = side,
name = battler.name,
hp = tonumber(mon.hp) or 0,
maxHp = tonumber(mon.stats and mon.stats.hp) or tonumber(mon.hp) or 0,
types = types,
vanished = battler.invulnerable and true or false,
}
end
local function publicScalar(value)
local kind = type(value)
if kind == "string" or kind == "boolean" then return value, true end
if kind == "number" and value == value
and value < math.huge and value > -math.huge then
return value, true
end
return nil, false
end
local function publicDataCopy(value, visiting)
local scalar, ok = publicScalar(value)
if ok then return scalar, true end
if type(value) ~= "table" then return nil, false end
visiting = visiting or {}
if visiting[value] then return nil, false end
visiting[value] = true
local copy = {}
for key, child in next, value do
local copiedKey, keyOk = publicScalar(key)
local copiedChild, childOk = publicDataCopy(child, visiting)
if keyOk and childOk then copy[copiedKey] = copiedChild end
end
visiting[value] = nil
return copy, true
end
local function checkpointFieldView(field)
field = field or {}
local view = publicDataCopy({
weather = field.weather,
tokens = field.tokens or {},
})
return view
end
-- Public field residuals are data-only requests. Mods can inspect a detached
-- checkpoint-shaped field view and detached battler views, but only the engine
-- mutates HP, animates the bar, or enters the faint pipeline.
function BattleState:applyFieldResiduals()
if not Runtime.wantsHook("battle.field_residual") then return end
local views = {
player = fieldBattlerView(self.player, "player"),
enemy = fieldBattlerView(self.enemy, "enemy"),
}
local rows = Runtime.call("battle.field_residual", function() return {} end, {
field = checkpointFieldView(self.field),
battlers = views,
turn = self.turnCount or 0,
})
if type(rows) ~= "table" then return end
local fainted = {}
for _, row in ipairs(rows) do
local battler = type(row) == "table" and row.side == "player"
and self.player or type(row) == "table" and row.side == "enemy"
and self.enemy or nil
local amount = type(row) == "table" and row.amount or nil
if battler and battler.mon.hp > 0 and type(amount) == "number"
and amount > 0
and amount < math.huge and amount == math.floor(amount)
and (row.message == nil or type(row.message) == "string") then
amount = math.min(amount, battler.mon.hp)
if type(row.message) == "string" and row.message ~= "" then
self:sayNext(row.message)
end
battler.mon.hp = battler.mon.hp - amount
self:drainNext(battler, battler.mon.hp)
if battler.mon.hp <= 0 then fainted[battler] = true end
end
end
-- A terminal player faint owns a simultaneous field-residual batch. Queue
-- only that authority so its blackout cannot race an enemy EXP/replacement
-- path from the same hook response. Native faint paths remain untouched.
if fainted[self.player]
and not Party.firstHealthy(self:playerPartyView()) then
self:onFaint(self.player)
return
end
-- Otherwise resolve the two sides in engine order after every accepted
-- descriptor has landed. Descriptor order must not decide resolution.
for _, battler in ipairs({ self.player, self.enemy }) do
if fainted[battler] then self:onFaint(battler) end
end
end
function BattleState:endOfTurn()
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual
@@ -2844,6 +2956,7 @@ function BattleState:endOfTurn()
b.trappingTurns = nil
end
end
self:applyFieldResiduals()
self:tickTokens()
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
end
@@ -3980,7 +4093,17 @@ function BattleState:performMove(user, target, moveInst, isCalled)
-- record (chargeText) and the invulnerability from semiInvulnerable,
-- falling back to the id tables (Fly AND Dig go semi-invulnerable:
-- ChargeEffect sets INVULNERABLE for both)
if record and record.charge and not releasing then
local chargeRequired = record and record.charge ~= nil and not releasing
if chargeRequired and Runtime.wantsHook("battle.charge_required") then
local required = Runtime.call("battle.charge_required", function(c)
return c.charge
end, {
battle = self, user = user, target = target, move = move,
charge = true, isCalled = isCalled or false,
})
chargeRequired = required ~= false
end
if chargeRequired then
self:cancelMoveAnim()
user.charging = moveInst
user.chargeReady = true
@@ -4059,12 +4182,15 @@ function BattleState:continueTrapping(user, target)
-- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText
-- then jumps to GetPlayerAnimationType, so the trapping move's full
-- animation replays each locked turn (same damage, animation shown).
-- Mirror performMove's anim row (BattleState.lua ~1307), gated on the
-- OPTIONS animation toggle.
if user.trapMove and self:animationsOn() then
-- Mirror performMove's anim row (BattleState.lua ~1307), with the
-- applying-attack shake GetPlayerAnimationType picks (core.asm:3159 /
-- :5555 -- a trapping move's effect is nonzero, so type 5 / 2) (#1653).
if user.trapMove then
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert,
{ anim = user.trapMove, attackerIsPlayer = user.isPlayer })
{ anim = user.trapMove, attackerIsPlayer = user.isPlayer,
hit = { animType = user.isPlayer and 5 or 2,
sfx = user.trapHitSfx } })
end
-- the counter can sit at 0 until the END of the turn: the trapping
-- bit is only cleared by CheckNumAttacksLeft (core.asm:439/467)
@@ -4239,7 +4365,7 @@ function BattleState:awardExp()
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
self.enemy.mon.level, self.kind == "trainer",
split, traded)
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
-- Track level-ups for EvolveAfterBattle (BattleState:finish ->
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
-- without this gate it re-triggers after every later fight (#213).
if #levels > 0 then
@@ -4616,6 +4742,14 @@ function BattleState:playerMonFainted()
-- Exception: the Oak's Lab starter rival (HandlePlayerBlackOut).
if not nextMon and self.result ~= "lose" then
if self.oppClass == "OPP_RIVAL1" then
-- HandlePlayerBlackOut (core.asm:1139-1146): ClearScreenArea, the pic
-- scroll-in and DelayFrames 40 all run before Rival1WinText (#1721)
self:actNext(function()
self.showEnemyTrainer = self.trainerPic ~= nil
if self.showEnemyTrainer then self:slidePic("foe", 64, 16, 2) end
end)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 64 })
local TextBox = require("src.render.TextBox")
local raw = (self.data.text and self.data.text._Rival1WinText)
or Strings("{RIVAL}: Yeah! Am\nI great or what?")
@@ -5205,6 +5339,14 @@ function BattleState:finish()
self.phase = "messages"
return
end
-- EndOfBattle runs EvolutionAfterBattle on the battle screen, before the
-- GBPalWhiteOut back to the map (end_of_battle.asm:42-45) (#1656, #213)
if not self.evolutionsChecked then
self.evolutionsChecked = true
require("src.pokemon.Evolution").checkParty(self.game,
function() self:finish() end, self.leveledUp)
return
end
-- Invariant: a battle can never hand the overworld a party with nothing
-- healthy in it -- except the Oak's Lab starter rival, where pret skips
-- the blackout and OaksLabRivalEndBattleScript HealParty's immediately.
@@ -5245,6 +5387,13 @@ function BattleState:finish()
local result = self.result or "run"
local onFinish = self.onFinish
if result == "lose" then
-- .battleOccurred skips the faint check in OAKS_LAB and re-enters the map
-- through MapEntryAfterBattle (home/overworld.asm:343-352) (#1721)
if BattleState.isOaksLabStarterRival(self) then
self.game.stack:push(require("src.render.Transition").battleReturn(
self.game, function() if onFinish then onFinish(result) end end))
return
end
-- the blackout path warps to the heal point with its own transition
if onFinish then onFinish(result) end
return
@@ -6109,8 +6258,10 @@ function BattleState:drawHUDs(slide)
self:drawBallRow(self:playerPartyView(), 88, 80, 8)
end
local hidePlayer = self.safari or self.demo
-- RemoveFaintedPlayerMon clears the player HUD (core.asm:1024-1026) and
-- nothing redraws it until the next SendOutMon (#1721)
if showStatus and self.player and not hidePlayer and not self.showPlayerBack
and slide == 0 then
and slide == 0 and not self.player.fainted then
-- player HUD (DrawPlayerHUDAndHPBar): name (10,7), <LV>+level
-- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with
-- the tick at (18,10) and the triangle at (9,11)
+12 -6
View File
@@ -260,6 +260,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
local totalDealt = 0
local landed, brokeSub = 0, false
local critPending, ohkoPending = info.crit, info.ohko
for h = 1, hits do
if target.mon.hp <= 0 then break end
local hitRow
@@ -280,12 +281,16 @@ function EffectRegistry.runDamaging(battle, ctx, record)
totalDealt = totalDealt + dealt
landed = h
if dealt > 0 then hitRow.hit = hitFx end
-- PrintCriticalOHKOText + DisplayEffectiveness run inside the
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
-- every strike -- damage was only rolled once
if info.crit then battle:sayNext(romText(battle.data, "_CriticalHitText", "Critical hit!")) end
if info.ohko then battle:sayNext(romText(battle.data, "_OHKOText", "One-hit KO!")) end
-- PrintCriticalOHKOText zeroes wCriticalHitOrOHKO after printing
-- (core.asm:3809-3811); DisplayEffectiveness re-reads its own flag (#1720)
if critPending then
battle:sayNext(romText(battle.data, "_CriticalHitText", "Critical hit!"))
critPending = false
end
if ohkoPending then
battle:sayNext(romText(battle.data, "_OHKOText", "One-hit KO!"))
ohkoPending = false
end
-- PrintCriticalOHKOText closes with `ld c, 20 / jp DelayFrames` at its
-- .done label (core.asm:3812-3814) -- and the no-crit path jumps to that
-- same label (:3799), so this hold is paid on EVERY landed hit, not just
@@ -323,6 +328,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- post-damage effect bookkeeping (recoil/drain/trap/thrash/...)
ctx.rawDamage, ctx.totalDealt = dmg, totalDealt
ctx.brokeSub, ctx.hits = brokeSub, hits
ctx.hitSfx = hitSfx
if record and record.afterDamage then
record.afterDamage(ctx, totalDealt)
elseif moveInst.struggle then
+5 -1
View File
@@ -574,6 +574,8 @@ MoveEffects.full = {
local r = ctx.rng(0, 7)
user.trappingTurns = ({ 1, 1, 1, 2, 2, 2, 3, 4 })[r + 1]
user.trapDamage = ctx.rawDamage
-- PlayApplyingAttackSound (animations.asm:2639) replays it each turn
user.trapHitSfx = ctx.hitSfx
-- remember the move so its animation can replay on each locked
-- continuation (core.asm:3554-3566 -> GetPlayerAnimationType)
user.trapMove = ctx.move.id
@@ -645,7 +647,9 @@ MoveEffects.full = {
user.bideTurns = ctx.rng(2, 3)
user.bideDamage = 0
ctx.battle:cancelMoveAnim()
ctx.anim(user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM")
ctx.battle:animBeforeMove(
user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM",
user.isPlayer)
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
end,
},
+3 -1
View File
@@ -161,8 +161,10 @@ local function drawHUDs(battle, slide)
-- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside
-- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in
-- drawCommandMenu below like the classic layout's (#540).
-- RemoveFaintedPlayerMon clears the player HUD (core.asm:1024-1026) (#1721)
if showStatus and not battle.safari and battle.player and not battle.demo
and not battle.showPlayerBack and slide == 0 then
and not battle.showPlayerBack and slide == 0
and not battle.player.fainted then
drawStatusPanel(battle, battle.player, 184, 56, true)
end
end
+262 -48
View File
@@ -16,7 +16,7 @@
--
-- Status handling follows Gen 2's rules rather than Gen 1's: burn is 1/8 max HP
-- (not 1/16) and halves physical Attack, poison is 1/8, and sleep counts down
-- from 1-7 turns.
-- from 2-7 turns.
local Damage = require("src.battle.gen2.Damage")
local Ai = require("src.battle.gen2.Ai")
@@ -166,6 +166,9 @@ Battle.SUBSTATUS_ITEMS = {
-- be run from or Roared away.
Battle.BATTLETYPE_FORCESHINY = 7
Battle.BATTLETYPE_TRAP = 9
-- ../pokecrystal/constants/battle_constants.asm:102-103, Crystal-only appends
Battle.BATTLETYPE_CELEBI = 11
Battle.BATTLETYPE_SUICUNE = 12
-- LostBattle's .canlose arm (engine/battle/core.asm:2766): the only battle
-- type whose loss still prints the trainer's own line instead of a whiteout.
Battle.BATTLETYPE_CANLOSE = 1
@@ -247,6 +250,13 @@ function Battle.new(opts)
-- (BATTLETYPE_FISH is the one condition LureBallMultiplier reads), and the
-- FORCESHINY / TRAP no-escape rules will hang off the same field.
self.battleType = opts.battleType
-- wInBattleTowerBattle (../pokecrystal/constants/ram_constants.asm:38), set
-- around the Tower's own StartBattle (engine/events/battle_tower/
-- battle_tower.asm:220-223) and cleared again at :253-254.
self.inBattleTowerBattle = opts.battleTower and true or false
-- wTimeOfDay: only BattleCommand_TimeBasedHealContinue reads it in battle
-- (engine/battle/effect_commands.asm:6401-6404).
self.timeOfDay = opts.timeOfDay
self.events = {}
self.turn = 0
self.over = false
@@ -765,6 +775,9 @@ end
function Battle:battleStat(mon, key)
local value = (mon.stats or {})[key] or 1
if mon ~= self.player then return value end
-- BadgeStatBoosts' second early return (engine/battle/core.asm:6786-6788):
-- adventure badges do not follow the player into the standardised Tower.
if self.inBattleTowerBattle then return value end
local badge = Battle.BADGE_STAT_BOOSTS[key]
if badge and self:hasBadge("badges", badge) then
return Battle.boostStat(value)
@@ -783,6 +796,8 @@ end
-- boosts the damage. Each type appears once, so this is a plain scan.
function Battle:badgeTypeBoost(attacker, moveType)
if attacker ~= self.player or not moveType then return false end
-- DoBadgeTypeBoosts' own tower guard (engine/battle/misc.asm:152-154).
if self.inBattleTowerBattle then return false end
for _, row in ipairs(Battle.BADGE_TYPE_BOOSTS) do
if row.type == moveType then
return self:hasBadge(row.store, row.badge)
@@ -903,8 +918,16 @@ function Battle:movePriority(moveId)
return (def and Battle.PRIORITY[def.effect]) or 0
end
-- engine/battle/effect_commands.asm:192-197 (enemy twin :383-390)
Battle.SLEEP_BYPASS_MOVES = { SNORE = true, SLEEP_TALK = true }
-- Can this mon act? Returns true, or false plus the message the cart prints.
function Battle:canAct(mon)
-- `moveId` is wCurPlayerMove / wCurEnemyMove (effect_commands.asm:193).
local function clearBide(state)
state.bideTurns, state.bideStored, state.bideMove = nil, nil, nil
end
local function checkTurn(self, mon, moveId)
local name = self:monName(mon)
-- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads
-- it first, clears it, prints MustRechargeText and jumps to EndTurn, so a mon
@@ -925,7 +948,11 @@ function Battle:canAct(mon)
local beforeMove = record and record.beforeMove
if beforeMove
and (record.beforeMovePriority or 0) > Battle.VOLATILE_PRIORITY then
return beforeMove(self, mon, name) and true or false
-- engine/battle/effect_commands.asm:188-200
local bypass = mon.status == "sleep" and Battle.SLEEP_BYPASS_MOVES[moveId]
local acted = beforeMove(self, mon, name) and true or false
if acted or not bypass then return acted end
beforeMove = nil
end
-- SUBSTATUS_FLINCHED, read and cleared right after the freeze check
-- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the
@@ -959,6 +986,14 @@ function Battle:canAct(mon)
return true
end
-- CantMove (engine/battle/effect_commands.asm:344-353) clears BIDE on every
-- arm of CheckPlayerTurn / CheckEnemyTurn that spends the turn.
function Battle:canAct(mon, moveId)
local acted = checkTurn(self, mon, moveId)
if not acted then clearBide(self:volatile(mon)) end
return acted
end
-- STRUGGLE, the move a mon with nothing left to spend falls back to
-- (engine/battle/core.asm `.CheckPlayerHasUsableMoves` for the player and
-- `.struggle` for the enemy). It lives in the move table like any other move
@@ -1234,10 +1269,15 @@ function Battle:dealDamage(attacker, defender, damage, opts)
if opts.critical then
self:emit({ kind = "message", text = "A critical hit!" })
end
-- SuperEffectiveText / NotVeryEffectiveText (data/text/battle.asm:603,608).
-- The cart breaks both across the box's two lines and hyphenates "super-"
-- to do it, and the not-very line ends on the single ellipsis glyph Gold's
-- charmap carries at $75, not three periods.
if opts.effectiveness and opts.effectiveness > 10 then
self:emit({ kind = "message", text = "It's super effective!" })
self:emit({ kind = "message", text = Strings("It's super-\neffective!") })
elseif opts.effectiveness and opts.effectiveness < 10 then
self:emit({ kind = "message", text = "It's not very effective..." })
self:emit({ kind = "message",
text = Strings("It's not very\neffective…") })
end
if endured then
self:emit({ kind = "message",
@@ -1389,9 +1429,18 @@ function Battle:useMove(attacker, defender, moveId)
-- free of PP and obedience in exactly the same way.
local rolling = state.rolloutLock == moveId
if not (charging or rampaging or rolling) then
-- engine/battle/effect_commands.asm:977-979, data/moves/effects.asm:795-800,
-- engine/battle/move_effects/bide.asm:62-68
local biding = def.effect == "EFFECT_BIDE" and state.bideTurns ~= nil
-- engine/battle/effect_commands.asm:6222-6234, :949-951
local called = (self.copyDepth or 0) > 0
if not (charging or rampaging or rolling or biding or called) then
if move and (move.pp or 0) <= 0 then
self:emit({ kind = "message", text = "No PP left for this move!" })
-- BattleText_TheresNoPPLeftForThisMove (data/text/battle.asm:315).
self:emit({ kind = "message",
text = Strings("There's no PP left\nfor this move!") })
return
end
if move then move.pp = (move.pp or 1) - 1 end
@@ -1471,9 +1520,38 @@ function Battle:useMove(attacker, defender, moveId)
return
end
-- engine/battle/move_effects/sleep_talk.asm:2, :16-19, :61
if def.effect == "EFFECT_SLEEP_TALK" then
local picked
if attacker.status == "sleep" and (self.copyDepth or 0) == 0 then
-- engine/battle/move_effects/sleep_talk.asm:40-44, :117-141
local pool = {}
for _, own in ipairs(attacker.moves or {}) do
local ownDef = self:moveDef(own.id)
local effect = ownDef and ownDef.effect
if own.id ~= moveId and not self:moveDisabled(attacker, own.id)
and not Effects.CHARGE[effect] and effect ~= "EFFECT_BIDE" then
pool[#pool + 1] = own.id
end
end
if #pool > 0 then picked = pool[rand(self.random, #pool) + 1] end
end
if not picked then
self:markMissed()
self:emit({ kind = "message", text = "But it failed!" })
return
end
state.lastMove = nil
self.copyDepth = (self.copyDepth or 0) + 1
self:useMove(attacker, defender, picked)
self.copyDepth = self.copyDepth - 1
return
end
-- Everything past here counts as "the user's last move" for Mirror Move,
-- Encore and Disable.
state.lastMove = moveId
-- Encore and Disable. A called move skips the write
-- (engine/battle/used_move_text.asm:30-36).
if (self.copyDepth or 0) == 0 then state.lastMove = moveId end
state.turnsTaken = (state.turnsTaken or 0) + 1
state.usedMoves = state.usedMoves or {}
local seen = false
@@ -1494,6 +1572,15 @@ function Battle:useMove(attacker, defender, moveId)
if def.effect == "EFFECT_SOLARBEAM" and self.weather == "sun" then
charge = nil
end
if charge and not charging and Runtime.wantsHook("battle.charge_required") then
local required = Runtime.call("battle.charge_required", function(c)
return c.charge
end, {
battle = self, user = attacker, target = defender, move = def,
charge = true, isCalled = (self.copyDepth or 0) > 0,
})
if required == false then charge = nil end
end
if charge and not charging then
state.chargeMove = moveId
state.vanished = charge.vanish or nil
@@ -1507,6 +1594,13 @@ function Battle:useMove(attacker, defender, moveId)
return
end
-- BattleCommand_Snore (engine/battle/move_effects/snore.asm:1-9)
if def.effect == "EFFECT_SNORE" and attacker.status ~= "sleep" then
self:markMissed()
self:emit({ kind = "message", text = "But it failed!" })
return
end
-- Counter and Mirror Coat answer what the user took this turn, at double,
-- and fail outright when nothing of the right kind landed.
local counterKind = Effects.COUNTER[def.effect]
@@ -1738,8 +1832,12 @@ function Battle:useMove(attacker, defender, moveId)
landed = landed + 1
end
if landed > 1 then
self:emit({ kind = "message",
text = ("Hit %d time(s)!"):format(landed) })
-- PlayerHitTimesText / EnemyHitTimesText (data/text/battle.asm:749,755)
-- are "Hit @ times!". Gen 2 has no singular form of this line, so the
-- plural stands even at one hit rather than the "(s)" this printed.
-- Gen 1 already says it this way (src/battle/EffectRegistry.lua,
-- _HitXTimesText).
self:emit({ kind = "message", text = Strings("Hit %d times!", landed) })
end
-- move_effects/pay_day.asm:13
@@ -1959,8 +2057,11 @@ Battle.MOVE_EFFECTS.EFFECT_PERISH_SONG = function(self)
if mine.perish and theirs.perish then return fail(self) end
if not mine.perish then mine.perish = Effects.PERISH_TURNS end
if not theirs.perish then theirs.perish = Effects.PERISH_TURNS end
-- StartPerishText (data/text/battle.asm:986). What shipped here was a
-- sentence no cart prints; the Gen 2 line names both sides and counts in
-- digits.
self:emit({ kind = "message",
text = "All POKéMON hearing the song will faint in three turns!" })
text = Strings("Both POKéMON will\nfaint in 3 turns!") })
end
-- BattleCommand_Encore: 3-6 turns locked into the move the target last used.
@@ -2090,6 +2191,11 @@ Battle.MOVE_EFFECTS.EFFECT_SPIKES = function(self, attacker, defender)
local side = self:sideOf(defender)
if self.spikes[side] then return fail(self) end
self.spikes[side] = true
-- SpikesText (data/text/battle.asm:974) is three rows, the third scrolled
-- (`cont`) and carrying <TARGET>. The battle message path has no `cont`:
-- src/ui/gen2/BattleState.lua sets self.message straight from the event and
-- printMessage cuts past two rows, so the cart's line cannot be told here
-- yet without the name being dropped on screen. Left as it stands.
self:emit({ kind = "message", text = "Spikes were scattered all around!" })
end
@@ -2124,6 +2230,8 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
if not state.bideTurns then
state.bideTurns = Effects.bideTurns(self.random)
state.bideStored = 0
-- engine/battle/core.asm:574-576
state.bideMove = moveId
self:emit({ kind = "message",
text = self:monName(attacker) .. " is storing energy!" })
return
@@ -2135,7 +2243,7 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
return
end
local damage = Effects.bideDamage(state.bideStored)
state.bideTurns, state.bideStored = nil, nil
state.bideTurns, state.bideStored, state.bideMove = nil, nil, nil
self:emit({ kind = "message",
text = self:monName(attacker) .. " unleashed energy!" })
if damage <= 0 then return fail(self) end
@@ -2325,7 +2433,9 @@ Battle.MOVE_EFFECTS.EFFECT_BEAT_UP = function(self, attacker, defender, def)
{ move = def, moveId = def and def.id })
landed = landed + 1
end
self:emit({ kind = "message", text = ("Hit %d time(s)!"):format(landed) })
-- BattleCommand_EndLoop prints the same line for Beat Up, and Beat Up can
-- land exactly once, which is the case the cart still prints as "times".
self:emit({ kind = "message", text = Strings("Hit %d times!", landed) })
end
-- BattleCommand_Heal (effect_commands.asm:5986): Recover and Rest are both
@@ -2362,7 +2472,7 @@ end
-- BattleCommand_TimeBasedHealContinue (effect_commands.asm:6374) answers the
-- same two lines BattleCommand_Heal does: HPIsFullText (:6447) and
-- RegainedHealthText (:6440).
for effect in pairs(Effects.SUN_HEAL) do
for effect, wants in pairs(Effects.SUN_HEAL) do
Battle.MOVE_EFFECTS[effect] = function(self, attacker)
local maxHp = attacker.maxHp or (attacker.stats and attacker.stats.hp) or 1
if (attacker.hp or 0) >= maxHp then
@@ -2371,7 +2481,9 @@ for effect in pairs(Effects.SUN_HEAL) do
text = self:monName(attacker) .. "'s HP is full!" })
return
end
local fraction = Effects.weatherHealFraction(self.weather)
-- effect_commands.asm:6396-6417, the time of day and the weather (#1751).
local fraction = Effects.timeBasedHealFraction(self.weather, wants,
self.timeOfDay)
self:heal(attacker, math.max(1, math.floor(maxHp * fraction)))
self:emit({ kind = "message",
text = self:monName(attacker) .. " regained health!" })
@@ -2408,6 +2520,8 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker)
self.enemyIndex = target
self.enemy = party[target]
self.enemy.volatile = carried
-- engine/battle/move_effects/baton_pass.asm:59
self:resetParticipants()
end
local sent = side == "player" and self.player or self.enemy
self:emit({ kind = "send", side = side, mon = sent,
@@ -2670,6 +2784,8 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
self.enemyIndex = pick
self.enemy = incoming
self.stages.enemy = Battle.newStages()
-- ForceEnemySwitch (engine/battle/core.asm:2937)
self:resetParticipants()
end
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
hp = incoming.hp or 0, status = incoming.status or false,
@@ -2814,9 +2930,12 @@ Battle.STATUSES = {
id = "sleep", label = "SLP", hudLabel = "SLP", healClass = "slp",
inflictText = " fell asleep!",
catchBonus = 10, catchBonusIntended = 10,
-- BattleCommand_Sleep rolls a 3-bit value, retried until nonzero.
-- BattleCommand_SleepTarget's .random_loop rerolls 0 and SLP_MASK before
-- `inc a`, so sleep opens at 2 (effect_commands.asm:3591-3598, #1707).
-- Crystal masks the roll to %011 in the Battle Tower, capping it at 4
-- (../pokecrystal/engine/battle/effect_commands.asm:3609-3613).
onInflict = function(battle, mon)
mon.statusTurns = rand(battle.random, 7) + 1
mon.statusTurns = rand(battle.random, battle.inBattleTowerBattle and 3 or 6) + 2
end,
beforeMovePriority = 40,
beforeMove = function(battle, mon, name)
@@ -3079,6 +3198,12 @@ end
-- Faint bookkeeping and experience. Returns true when the battle ended.
function Battle:resolveFaints()
-- engine/battle/core.asm:2551-2556, :7116-7130, :3033-3037
if (self.player.hp or 0) <= 0 and self.participantsCleared ~= self.player then
self.participantsCleared = self.player
if self.playerIndex then self.participants[self.playerIndex] = nil end
end
if (self.enemy.hp or 0) <= 0 then
self:emit({ kind = "faint", side = "enemy",
text = (self.wild and "Wild " or "") .. self:monName(self.enemy)
@@ -3263,7 +3388,16 @@ end
-- `count` is the pass's own divisor -- the participant count for the first
-- pass, the holder count for the EXP.SHARE pass -- and `halved` is whether
-- any Share holder taxed the whole pool.
function Battle:giveExperiencePass(loser, def, recipients, count, halved)
--
-- `silent` suppresses only the GainedText line. It exists for the
-- battle.exp_award seam below, where a mod paying the bench wants one summary
-- line rather than a box per mon; the cart's own two passes never pass it, so
-- vanilla prints exactly what it always did. Everything else about the pass
-- -- the exp, the stat exp, battle.exp_gained, "grew to level", learned moves
-- and the forget prompt -- is unaffected, because a silent award is still an
-- award.
function Battle:giveExperiencePass(loser, def, recipients, count, halved,
silent)
for _, index in ipairs(recipients) do
local mon = self.party[index]
if mon and (mon.hp or 0) > 0 and not mon.isEgg then
@@ -3314,10 +3448,12 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
index = index,
})
end
self:emit({ kind = "experience", index = index, amount = amount,
-- BoostedExpPointsText, keyed on the traded arm alone.
text = self:monName(mon) .. " gained "
.. (traded and "a boosted " or "") .. amount .. " EXP. Points!" })
if not silent then
self:emit({ kind = "experience", index = index, amount = amount,
-- BoostedExpPointsText, keyed on the traded arm alone.
text = self:monName(mon) .. " gained "
.. (traded and "a boosted " or "") .. amount .. " EXP. Points!" })
end
if result.levels > 0 then
-- "level up happiness mod", the cart's own comment, sitting right
-- after the stat recalc and before the "grew to level" text. It fires
@@ -3332,7 +3468,9 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
local moveDef = self:moveDef(moveId)
local moveName = (moveDef and moveDef.name) or moveId
if ok then
-- data/text/common_3.asm:119
self:emit({ kind = "message",
sfx = "Sfx_DexFanfare5079", waitSfx = true,
text = self:monName(mon) .. " learned " .. moveName .. "!" })
elseif reason == "full" then
-- LearnMove's full-moveset arm calls ForgetMove, which asks with
@@ -3411,22 +3549,38 @@ function Battle:awardExperience(loser)
-- battle.exp_award, the same hook BattleState:awardExp calls on Gen 1 and
-- with the same ctx: the participant COUNT, the live participants, and an
-- applyShare(mon, split) a mod can call to pay one mon its own share. The
-- third applyShare argument is Gen 1's EXP.ALL announcement variant; Gen 2
-- has no EXP.ALL (the EXP.SHARE pass below is its replacement), so it is
-- accepted and ignored rather than changing what is printed. `recipients`,
-- `holders` and `halved` are the Gen 2 additions.
-- applyShare(mon, split, announce) a mod can call to pay one mon its own
-- share. `recipients`, `holders` and `halved` are the Gen 2 additions.
--
-- `announce` is Gen 1's third argument (src/battle/BattleState.lua
-- applyShare) and means the same thing here: truthy prints the mon's
-- GainedText, falsy pays it silently. That is what lets one mod source
-- print ONE summary line for a party-wide award on both generations instead
-- of a box per mon -- which is what the Exp Share mod documents and could
-- not do on Gold, because this argument used to be accepted and ignored.
--
-- It is honoured only when it is actually PASSED, by argument count rather
-- than by value. A Gen 2-era mod calling applyShare(mon, split) was written
-- against a seam that always announced and keeps announcing; a caller that
-- passes the argument -- including an explicit nil, which is what a "pay
-- this one quietly" call looks like -- gets Gen 1's reading. So no existing
-- mod changes behaviour, and a mod that opts in gets parity.
if Runtime.wantsHook("battle.exp_award") then
local alive = {}
for _, index in ipairs(participants) do
local mon = self.party[index]
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
end
local function applyShare(mon, split)
local function applyShare(mon, split, ...)
local announce = ...
-- select("#") counts an explicit nil; `announce == nil` alone could not
-- tell applyShare(mon, split) from applyShare(mon, split, nil), and
-- those two have to mean different things here.
local silent = select("#", ...) > 0 and not announce
for index, candidate in ipairs(self.party) do
if candidate == mon then
return self:giveExperiencePass(loser, def, { index },
math.max(1, split or 1), halved)
math.max(1, split or 1), halved, silent)
end
end
end
@@ -3441,8 +3595,7 @@ function Battle:awardExperience(loser)
-- GiveExperiencePoints .done falls through ResetBattleParticipants into
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
self.participants = {}
if self.playerIndex then self.participants[self.playerIndex] = true end
self:resetParticipants()
end
-- The answer to a `choose-forget`: drop the move in `slot` and put the
@@ -3463,7 +3616,9 @@ function Battle:resolveForget(index, slot, entry, moveName)
end
self:emit({ kind = "message",
text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" })
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
self:emit({ kind = "message",
sfx = "Sfx_DexFanfare5079", waitSfx = true,
text = self:monName(mon) .. " learned "
.. (moveName or (entry and entry.id) or "?") .. "!" })
-- The forget path writes the slot itself rather than going through
@@ -3502,6 +3657,13 @@ function Battle:switchLocked()
return self:volatile(self.enemy).trapsTarget == true
end
-- ResetBattleParticipants falls through into AddBattleParticipant
-- (engine/battle/core.asm:3033 and :3037).
function Battle:resetParticipants()
self.participants = {}
if self.playerIndex then self.participants[self.playerIndex] = true end
end
-- EnemySwitch's shift arm zeroes both participant bitfields before PlayerSwitch
-- (engine/battle/core.asm:2959-2961).
function Battle:shiftSwitch(index)
@@ -3524,6 +3686,7 @@ function Battle:switch(index)
-- A mon that comes back (a REVIVE, or a second battle) has to be able to
-- announce its own faint again; see resolveFaints.
self.faintAnnounced = nil
self.participantsCleared = nil
-- ForcePlayerMonChoice has been answered, so the next faint may ask again.
self.pendingSwitch = nil
self.player = mon
@@ -3761,19 +3924,41 @@ function Battle:lockedInMove(mon)
return nil
end
-- ParsePlayerAction's bide arm (engine/battle/core.asm:569-576), enemy twin
-- at :5650
function Battle:fightLockedMove(mon)
local state = self:volatile(mon)
if state.bideTurns then return state.bideMove end
return nil
end
-- engine/battle/core.asm:627-629
function Battle:cancelBide(mon)
clearBide(self:volatile(mon))
end
-- Encore forces the move; Disable forbids one. Both are read by the screen
-- (to grey out the move list) and by the enemy's own choice below.
-- engine/battle/core.asm:561-566
local function encoredMove(state, mon)
if not state.encore then return nil end
for _, move in ipairs(mon.moves or {}) do
if move.id == state.encore and (move.pp or 0) > 0 then
return state.encore
end
end
state.encore, state.encoreTurns = nil, nil
return nil
end
function Battle:forcedMove(mon)
local locked = self:lockedInMove(mon)
if locked then return locked end
local state = self:volatile(mon)
if not state.encore then return nil end
for _, move in ipairs(mon.moves or {}) do
if move.id == state.encore and (move.pp or 0) > 0 then return state.encore end
end
-- Encore ends early when the move runs out of PP.
state.encore, state.encoreTurns = nil, nil
return nil
-- ParsePlayerAction reads SUBSTATUS_ENCORED ahead of the bide arm
-- (engine/battle/core.asm:561-566).
local encored = encoredMove(self:volatile(mon), mon)
if encored then return encored end
return self:fightLockedMove(mon)
end
function Battle:moveDisabled(mon, moveId)
@@ -3787,8 +3972,9 @@ function Battle:usableMoves(mon)
-- .CheckPlayerHasUsableMoves (core.asm:533-556), so a Rollout or a rampage
-- that spent its last PP on the opening turn keeps running: no later turn
-- of the lock spends any. Encore is not in this exemption -- forcedMove
-- ends it the moment the encored move runs dry.
local locked = self:lockedInMove(mon)
-- ends it the moment the encored move runs dry. Bide is exempt too:
-- .CheckPlayerHasUsableMoves lives inside MoveSelectionScreen (core.asm:5058).
local locked = self:lockedInMove(mon) or self:fightLockedMove(mon)
local out = {}
for _, move in ipairs(mon.moves or {}) do
local ok = (move.pp or 0) > 0 and not self:moveDisabled(mon, move.id)
@@ -3799,6 +3985,16 @@ function Battle:usableMoves(mon)
return out
end
-- ../pokecrystal/engine/battle/core.asm:3687-3694 refuses TRAP, CELEBI,
-- FORCESHINY and SUICUNE; pokegold's :3476-3479 has only the first and third.
function Battle:noEscapeBattleType()
local t = self.battleType
return t == Battle.BATTLETYPE_FORCESHINY
or t == Battle.BATTLETYPE_TRAP
or t == Battle.BATTLETYPE_CELEBI
or t == Battle.BATTLETYPE_SUICUNE
end
-- Running: Gen 2's odds (engine/battle/core.asm TryToRunAwayFromBattle) are
-- based on the speed ratio and how many times you have tried this battle.
-- Trainers never let you run.
@@ -3812,8 +4008,7 @@ function Battle:tryRun(pSpd)
-- BATTLETYPE_FORCESHINY jump straight to .cant_escape, ahead of the
-- trainer check and any speed math. Without this, running from the Red
-- Gyarados returned a WIN to the script and forfeited the one-shot shiny.
if self.battleType == Battle.BATTLETYPE_FORCESHINY
or self.battleType == Battle.BATTLETYPE_TRAP then
if self:noEscapeBattleType() then
self:emit({ kind = "message", text = "Can't escape!" })
self.runRefused = true
return false
@@ -3990,6 +4185,8 @@ function Battle:enemyTrySwitchOrItem()
.. self:monName(outgoing) .. "!" })
self.enemyIndex = target
self.enemy = self.enemyParty[target]
-- AI_Switch (engine/battle/ai/items.asm:697)
self:resetParticipants()
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
-- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so
-- the mon coming IN starts from an empty area -- the same pair of clears
@@ -4080,9 +4277,16 @@ function Battle:vanillaEnemyMove()
-- move answers "TYPHLOSION's attack missed!", so Hitmonlee cannot be damaged
-- by anything, at any level. Fifteen straight attempts at the Elite Four
-- died there, and no amount of grinding could ever have got past it.
local charged = self:volatile(self.enemy).chargeMove
local enemyState = self:volatile(self.enemy)
local charged = enemyState.chargeMove
if charged then return charged end
-- engine/battle/core.asm:5524-5533: the encore arm runs ahead of
-- CheckEnemyLockedIn (:5650).
local encored = encoredMove(enemyState, self.enemy)
if encored then return encored end
if enemyState.bideTurns then return enemyState.bideMove end
-- Encore and Disable narrow the pool before the AI ever scores it.
local moves = self:usableMoves(self.enemy)
if #moves == 0 then
@@ -4176,6 +4380,7 @@ local function runTurn(self, action)
-- (engine/battle/core.asm:5035-5038), which reopens the 2x2 menu with the
-- turn unspent -- so a refused RUN never bought the enemy a free attack.
if self.runRefused then return self:takeEvents() end
self:cancelBide(self.player)
action = { kind = "skip" }
end
@@ -4191,6 +4396,9 @@ local function runTurn(self, action)
if action.kind == "item" and Battle.X_ITEMS[action.item] then
Happiness.change(self.player, "USEDXITEM")
end
-- engine/battle/core.asm:572-573 into :627-629; a switch takes :570-571
-- instead and keeps the store.
if action.kind == "item" then self:cancelBide(self.player) end
-- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides
-- to rotate or drink a potion spends its whole turn on it.
@@ -4239,7 +4447,6 @@ local function runTurn(self, action)
local function playerAttack()
if action.kind ~= "move" then return end
if not self:canAct(self.player) then return end
local move = action.move
-- An encored mon has no choice, whatever the menu said.
local forced = self:forcedMove(self.player)
@@ -4259,13 +4466,20 @@ local function runTurn(self, action)
-- player's own mon spends the rest of the battle underground.
local stored = self:volatile(self.player).chargeMove
if stored then move = stored end
-- engine/battle/core.asm:558-598 settles wCurPlayerMove before
-- engine/battle/effect_commands.asm:193 reads it.
if not self:canAct(self.player, move) then return end
-- CheckPlayerLockedIn quits before .CheckPlayerHasUsableMoves and before
-- checkobedience, so a locked Rollout or Thrash is exempt from the
-- Struggle substitution and the obedience roll the same way the second
-- half of a charge move is.
local charging = self:volatile(self.player).chargeMove == move
or self:lockedInMove(self.player) == move
if not charging and not self:hasUsableMoves(self.player) then
-- engine/battle/core.asm:5058, and data/moves/effects.asm:796 keeps
-- `checkobedience`.
local bideLocked = self:fightLockedMove(self.player) == move
if not charging and not bideLocked
and not self:hasUsableMoves(self.player) then
self:emit({ kind = "message",
text = self:monName(self.player) .. " has no moves left!" })
move = Battle.STRUGGLE
@@ -4303,7 +4517,7 @@ local function runTurn(self, action)
-- against a trainer, could not be escaped either.
enemyMoveId = Battle.STRUGGLE
end
if not self:canAct(self.enemy) then return end
if not self:canAct(self.enemy, enemyMoveId) then return end
-- CheckEnemyTurn's disabled arm (engine/battle/effect_commands.asm:562-574):
-- the AI chose before the player's Disable landed, so the turn is spent here.
if self:moveDisabled(self.enemy, enemyMoveId) then
+42
View File
@@ -25,6 +25,7 @@
-- names src/battle/BattleState.lua raises on Gen 1, with the same argument
-- order and the same payload keys (docs/mod-api-gen2-compat.md).
local Runtime = require("src.mods.Runtime")
local Mon = require("src.battle.gen2.Mon")
local Catching = {}
@@ -337,6 +338,47 @@ function Catching.statusBonus(status, opts)
return bonuses[status] or 0
end
-- constants/landmark_constants.asm:24, the fallback when no cache is passed.
Catching.LANDMARK_NATIONAL_PARK = 19
local function landmarkIndex(opts, id, fallback)
local data = opts and (opts.data or (opts.battle and opts.battle.data))
local rows = (opts and opts.landmarks)
or (data and data.gen2Landmarks and data.gen2Landmarks.landmarks)
local row = rows and rows[id]
return (row and row.index) or fallback
end
-- GetWorldMapLocation with the POKECENTER_2F backup-map swap
-- (engine/pokemon/caught_data.asm:177-193) and the Bug Contest override (:73-81).
function Catching.caughtLandmark(opts)
opts = opts or {}
if opts.bugContest then
return landmarkIndex(opts, "LANDMARK_NATIONAL_PARK",
Catching.LANDMARK_NATIONAL_PARK)
end
if opts.landmark then return opts.landmark end
local map = opts.map
if map and map.id == "POKECENTER_2F" and opts.backupMap then
map = opts.backupMap
end
return (map and map.landmark) or 0
end
-- SetCaughtData (engine/pokemon/caught_data.asm:163-199); no-op off Crystal.
function Catching.stampCaughtData(mon, opts)
opts = opts or {}
if not Mon.hasCaughtData(opts.version) then return mon end
local save = opts.save or (opts.battle and opts.battle.save)
return Mon.setCaughtData(mon, {
level = opts.level or (type(mon) == "table" and mon.level) or 0,
timeOfDay = opts.timeOfDay,
landmark = Catching.caughtLandmark(opts),
playerGender = opts.playerGender
or (save and save.player and save.player.gender),
})
end
-- Does the ball catch? Returns caught and the final rate (wFinalCatchRate).
-- A rate of 255 or a Master Ball is certain.
--
+30 -1
View File
@@ -21,8 +21,13 @@
-- per-move as in Gen 4: type ids below FIRE are physical. type_chart.lua's
-- records carry that as `category`.
local GameVersion = require("src.core.GameVersion")
local Damage = {}
-- ../pokecrystal/constants/battle_constants.asm:78
Damage.MAX_STAT_VALUE = 999
-- data/battle/critical_hit_chances.asm, as "1 in N".
Damage.CRITICAL_CHANCES = { [0] = 15, 8, 4, 3, 2, 2, 2 }
@@ -60,7 +65,23 @@ end
function Damage.applyStage(value, stage)
local numerator, denominator = Damage.stageMultiplier(stage)
local out = math.floor(value * numerator / denominator)
return math.max(1, out)
-- ../pokecrystal/engine/battle/core.asm:6739
return math.max(1, math.min(Damage.MAX_STAT_VALUE, out))
end
-- TruncateHL_BC: ../pokegold/engine/battle/effect_commands.asm:2625 runs one
-- pass, ../pokecrystal/engine/battle/effect_commands.asm:2644 loops it.
function Damage.truncateStats(attack, defense, fixed)
local a = math.max(0, math.floor(attack or 0))
local d = math.max(0, math.floor(defense or 0))
while a > 255 or d > 255 do
d = math.floor(d / 4)
if d == 0 then d = 1 end
a = math.floor(a / 4)
if a == 0 then a = 1 end
if not fixed then break end
end
return a % 256, d % 256
end
-- Is this move physical? `types` is type_chart.lua's `types` table.
@@ -169,6 +190,8 @@ end
-- random(n) -- 0..n-1, for the variation roll
-- screen -- Reflect/Light Screen active on the defender
-- defenseHalved -- EFFECT_SELFDESTRUCT's srl c
-- reflectOverflowFixed -- override GameVersion.fixes()'s TruncateHL_BC
-- answer; ../pokecrystal/engine/battle/effect_commands.asm:2644
--
-- Returns damage, info where info carries the pieces a battle message needs:
-- effectiveness (x10), critical, physical, variation.
@@ -204,6 +227,12 @@ function Damage.calc(opts)
defense = defense * 2
end
-- PlayerAttackDamage hands DamageCalc one-byte stats
-- (../pokecrystal/engine/battle/effect_commands.asm:2604).
local fixed = opts.reflectOverflowFixed
if fixed == nil then fixed = GameVersion.fixes().reflectOverflow == true end
attack, defense = Damage.truncateStats(attack, defense, fixed)
-- BattleCommand_DamageCalc (effect_commands.asm:2905-2913): Selfdestruct and
-- Explosion halve the defence, never below 1.
if opts.defenseHalved then defense = math.max(1, math.floor(defense / 2)) end
+30 -11
View File
@@ -267,7 +267,7 @@ Effects.MAGNITUDE_POWER = {
-- power and the magnitude number the text prints.
--
-- `random` is BattleRandom (0..n-1). If none is supplied, roll via love.math
-- / math.random never hard-code 0 (that always yields Magnitude 4).
-- / math.random -- never hard-code 0 (that always yields Magnitude 4).
function Effects.magnitudePower(random)
local roll
if type(random) == "function" then
@@ -356,19 +356,38 @@ function Effects.sandstormHits(types)
return true
end
-- Morning Sun / Synthesis / Moonlight heal a HALF normally, and the weather
-- shifts that one step either way: x2 in sun, /2 in rain or sandstorm
-- (BattleCommand_Heal's .Weather block walks a multiplier index).
-- BattleCommand_TimeBasedHealContinue's .Multipliers
-- (engine/battle/effect_commands.asm:6450-6454).
Effects.HEAL_MULTIPLIERS = { 1 / 8, 1 / 4, 1 / 2, 1 }
-- wTimeOfDay (constants/ram_constants.asm:134-139): MORN_F 0, DAY_F 1,
-- NITE_F 2, DARKNESS_F 3.
Effects.TIME_OF_DAY_ID = { MORN = 0, DAY = 1, NITE = 2, DARK = 3 }
-- Morning Sun / Synthesis / Moonlight and the wTimeOfDay each one wants
-- (engine/battle/effect_commands.asm:6362-6371).
Effects.SUN_HEAL = {
EFFECT_MORNING_SUN = true,
EFFECT_SYNTHESIS = true,
EFFECT_MOONLIGHT = true,
EFFECT_MORNING_SUN = 0,
EFFECT_SYNTHESIS = 1,
EFFECT_MOONLIGHT = 2,
}
function Effects.weatherHealFraction(weather)
if weather == "sun" then return 2 / 3 end
if weather == "rain" or weather == "sandstorm" then return 1 / 4 end
return 1 / 2
function Effects.timeOfDayIndex(timeOfDay)
if type(timeOfDay) == "number" then return math.floor(timeOfDay) end
return Effects.TIME_OF_DAY_ID[timeOfDay]
end
-- engine/battle/effect_commands.asm:6388-6417: the index opens at a half, the
-- wrong time of day steps it down, sun steps it up, rain and sandstorm down.
function Effects.timeBasedHealFraction(weather, wants, timeOfDay)
local index = 3
local now = Effects.timeOfDayIndex(timeOfDay)
if wants ~= nil and now ~= nil and now ~= wants then index = index - 1 end
if weather then
index = index + 1
if weather ~= "sun" then index = index - 2 end
end
return Effects.HEAL_MULTIPLIERS[math.max(1, math.min(4, index))]
end
-- ---------------------------------------------------------------- Perish Song
+102 -1
View File
@@ -17,6 +17,7 @@
-- with a sign bit on the n^2 term. pokemon.lua carries those five numbers per
-- GROWTH_* so this needs no hardcoded table.
local GameVersion = require("src.core.GameVersion")
local Unown = require("src.core.gen2.Unown")
-- The mod event bus. pokemon.level_up and pokemon.move_learned are the SAME
-- names src/battle/Experience.lua and src/battle/BattleState.lua raise on
@@ -32,6 +33,102 @@ Mon.PARTY_SIZE = 6
-- DVs are 0..15 each; Attack's low bit pair also decides gender and shininess.
Mon.MAX_DV = 15
-- MON_CAUGHTDATA's two packed bytes and their masks --
-- constants/pokemon_data_constants.asm:93-99, :120-130.
Mon.CAUGHT_TIME_MASK = 0xc0
Mon.CAUGHT_LEVEL_MASK = 0x3f
Mon.CAUGHT_GENDER_MASK = 0x80
Mon.CAUGHT_LOCATION_MASK = 0x7f
Mon.CAUGHT_EGG_LEVEL = 1
-- constants/landmark_constants.asm:111-113
Mon.LANDMARK_EVENT = 0x7f
Mon.LANDMARK_GIFT = 0x7e
-- Gold spends the same word on `rb_skip 2` and has no SetCaughtData --
-- pokegold constants/pokemon_data_constants.asm:93.
function Mon.hasCaughtData(version)
return GameVersion.engine(version) == "crystal"
end
-- wTimeOfDay is only MORN / DAY / NITE (engine/rtc/rtc.asm:48-55), stored
-- `inc a`'d so 0 stays free -- engine/pokemon/caught_data.asm:169-172.
local CAUGHT_TIME = { MORN = 1, DAY = 2, NITE = 3, DARK = 3 }
function Mon.caughtTimeOf(timeOfDay)
if type(timeOfDay) == "string" then return CAUGHT_TIME[timeOfDay] or 0 end
if type(timeOfDay) ~= "number" then return 0 end
local id = math.floor(timeOfDay)
if id < 0 or id > 3 then return 0 end
if id > 2 then id = 2 end
return id + 1
end
-- wPlayerGender's bit 0 is PLAYERGENDER_FEMALE_F (constants/ram_constants.asm:177).
local CAUGHT_GENDER = {
girl = "girl", female = "girl", boy = "boy", male = "boy",
}
function Mon.caughtGenderOf(gender)
if gender == true then return "girl" end
if gender == false then return "boy" end
if type(gender) ~= "string" then return nil end
return CAUGHT_GENDER[gender:lower()]
end
local function landmarkByte(landmark)
if type(landmark) ~= "number" then return 0 end
return math.floor(landmark) % 0x80
end
-- SetBoxmonOrEggmonCaughtData (engine/pokemon/caught_data.asm:168-199); the
-- egg path hands CAUGHT_EGG_LEVEL in as `level` (:239-242).
function Mon.setCaughtData(mon, opts)
if type(mon) ~= "table" then return mon end
opts = opts or {}
mon.caughtTime = Mon.caughtTimeOf(opts.timeOfDay)
mon.caughtLevel = math.max(0, math.floor(opts.level or mon.level or 0))
mon.caughtLocation = landmarkByte(opts.landmark)
mon.caughtByGender = Mon.caughtGenderOf(opts.playerGender) or "boy"
return mon
end
-- CAUGHT_BY_UNKNOWN / GIRL / BOY, the code SetGiftMonCaughtData takes in `b`
-- (constants/pokemon_data_constants.asm:126-128).
Mon.CAUGHT_BY = { unknown = 0, girl = 1, boy = 2 }
-- SetGiftMonCaughtData (engine/pokemon/caught_data.asm:226-233): `rrc b / or
-- LANDMARK_GIFT` puts CAUGHT_BY_BOY on $7f, LANDMARK_EVENT, not on a gender bit.
function Mon.setGiftCaughtData(mon, caughtBy)
if type(mon) ~= "table" then return mon end
local code = Mon.CAUGHT_BY[tostring(caughtBy):lower()] or 0
local rotated = math.floor(code / 2) + (code % 2) * Mon.CAUGHT_GENDER_MASK
local unpacked = Mon.unpackCaughtData(0, Mon.LANDMARK_GIFT + rotated)
mon.caughtTime, mon.caughtLevel = 0, 0
mon.caughtLocation = unpacked.caughtLocation
mon.caughtByGender = unpacked.caughtByGender
return mon
end
-- The packed pair, as engine/pokemon/caught_data.asm:169-199 stores it.
function Mon.packCaughtData(mon)
mon = type(mon) == "table" and mon or {}
local time = math.floor(tonumber(mon.caughtTime) or 0) % 4
local level = math.floor(tonumber(mon.caughtLevel) or 0) % 0x40
local location = landmarkByte(tonumber(mon.caughtLocation) or 0)
local female = Mon.caughtGenderOf(mon.caughtByGender) == "girl"
return time * 0x40 + level, (female and Mon.CAUGHT_GENDER_MASK or 0) + location
end
function Mon.unpackCaughtData(byte0, byte1)
byte0, byte1 = math.floor(byte0 or 0) % 256, math.floor(byte1 or 0) % 256
return {
caughtTime = math.floor(byte0 / 0x40),
caughtLevel = byte0 % 0x40,
caughtLocation = byte1 % 0x80,
caughtByGender = (byte1 >= Mon.CAUGHT_GENDER_MASK) and "girl" or "boy",
}
end
local function rand(a, b)
if love and love.math and love.math.random then
return love.math.random(a, b)
@@ -362,7 +459,11 @@ function Mon.new(data, species, level, opts)
status = nil,
-- 70 for a caught mon, 120 for a gift/hatched one.
happiness = opts.happiness or 70,
caughtLevel = level,
caughtLevel = opts.caughtLevel or level,
-- MON_CAUGHTDATA, absent on Gold -- constants/pokemon_data_constants.asm:93-99.
caughtTime = opts.caughtTime,
caughtLocation = opts.caughtLocation,
caughtByGender = opts.caughtByGender,
-- shiny.roll / gender.roll get the species and level as context; opts.shiny
-- still wins, because a FORCED shiny battle (Red Gyarados) is the cart
-- overriding the roll rather than a roll to be hooked.
+501
View File
@@ -0,0 +1,501 @@
local Base64 = require("src.core.Base64")
local GameVersion = require("src.core.GameVersion")
local SafePath = require("src.mods.SafePath")
local SaveSerializer = require("src.core.SaveSerializer")
local Semver = require("src.mods.Semver")
local StreamMD5 = require("src.mods.StreamMD5")
local CartManifest = {}
CartManifest.SCHEMA = 1
CartManifest.EXT = ".g1rcart"
CartManifest.FORMAT = "g1rcart"
CartManifest.DIR = "carts"
-- sealed: the pinned set runs exactly as pinned. sealed+: the same fixed set,
-- but the player may switch any pinned mod on or off. open: additive.
CartManifest.SEALS = { sealed = true, ["sealed+"] = true, open = true }
-- Shell and label finishes the launcher cartridge can render.
CartManifest.FINISHES = {
sparkle = true, holo = true, ["sparkle+holo"] = true,
}
CartManifest.SOURCES = { github = true, gamebanana = true, ["local"] = true }
CartManifest.ART_ENCODINGS = { base64 = true }
CartManifest.PNG_SIGNATURE = "\137PNG\r\n\26\10"
CartManifest.MAX_ID = 64
CartManifest.MAX_TITLE = 48
CartManifest.MAX_AUTHOR = 64
CartManifest.MAX_SUMMARY = 120
CartManifest.MAX_LABEL = 128
CartManifest.MAX_LABEL_ART = 1024 * 1024
CartManifest.MAX_MODS = 64
CartManifest.MAX_OPTIONS = 64
CartManifest.MAX_OPTION_KEY = 64
CartManifest.MAX_OPTION_TEXT = 256
local function trim(text)
return text:match("^%s*(.-)%s*$")
end
local function isId(value)
return type(value) == "string" and value ~= "" and #value <= CartManifest.MAX_ID
and value:match("^[%w_%-]+$") ~= nil
end
local function isRepo(value)
if type(value) ~= "string" then return false end
local owner, name = value:match("^([%w%._%-]+)/([%w%._%-]+)$")
return owner ~= nil and name ~= nil
end
local function isHex(value, width)
return type(value) == "string" and #value == width
and value:match("^[0-9a-f]+$") ~= nil
end
local function isCount(value)
return type(value) == "number" and value > 0 and value % 1 == 0
end
local function isSemver(value)
return type(value) == "string" and Semver.parse(value) ~= nil
end
local function parseOptions(raw, label)
if raw == nil then return nil end
if type(raw) ~= "table" then
return nil, label .. " options must be a table"
end
local keys = {}
for key in pairs(raw) do
if type(key) ~= "string" then
return nil, label .. " option keys must be strings"
end
if key == "" or #key > CartManifest.MAX_OPTION_KEY then
return nil, ("%s option keys must be 1 to %d characters")
:format(label, CartManifest.MAX_OPTION_KEY)
end
keys[#keys + 1] = key
end
if #keys > CartManifest.MAX_OPTIONS then
return nil, ("%s carries more than %d options")
:format(label, CartManifest.MAX_OPTIONS)
end
local out = {}
for _, key in ipairs(keys) do
local value = raw[key]
local kind = type(value)
if kind == "string" then
if #value > CartManifest.MAX_OPTION_TEXT then
return nil, ("%s option %q must be %d characters or fewer")
:format(label, key, CartManifest.MAX_OPTION_TEXT)
end
elseif kind ~= "number" and kind ~= "boolean" then
return nil, ("%s option %q must be a string, number or boolean")
:format(label, key)
end
out[key] = value
end
return out
end
local function parseMod(raw, index, seen)
local label = ("cart mod #%d"):format(index)
if type(raw) ~= "table" then return nil, label .. " must be a table" end
if not isId(raw.id) then
return nil, ("%s id must be 1 to %d characters of letters, numbers, _ or -")
:format(label, CartManifest.MAX_ID)
end
label = ("cart mod %q"):format(raw.id)
if seen[raw.id] then return nil, label .. " is pinned twice" end
seen[raw.id] = true
local source = raw.source
if type(source) ~= "string" or not CartManifest.SOURCES[source] then
return nil, label .. " source must be github, gamebanana or local"
end
local entry = { id = raw.id, source = source }
if source == "github" then
if not isRepo(raw.repo) then
return nil, label .. " repo must be owner/name"
end
if not isSemver(raw.version) then
return nil, label .. " version must be a semantic version"
end
if not isHex(raw.sha256, 64) then
return nil, label .. " sha256 must be 64 lowercase hex characters"
end
entry.repo = raw.repo
entry.version = trim(raw.version)
entry.sha256 = raw.sha256
elseif source == "local" then
if not isSemver(raw.version) then
return nil, label .. " version must be a semantic version"
end
entry.version = trim(raw.version)
else
if not isCount(raw.mod) then
return nil, label .. " mod must be a positive integer"
end
if not isCount(raw.file) then
return nil, label .. " file must be a positive integer"
end
if not isHex(raw.md5, 32) then
return nil, label .. " md5 must be 32 lowercase hex characters"
end
entry.mod = raw.mod
entry.file = raw.file
entry.md5 = raw.md5
end
if raw.enabled ~= nil and type(raw.enabled) ~= "boolean" then
return nil, label .. " enabled must be true or false"
end
-- Only a declared OFF is carried: an absent or explicit true is the default,
-- so a cart that says nothing serializes exactly as it did before this field.
if raw.enabled == false then entry.enabled = false end
local options, err = parseOptions(raw.options, label)
if err then return nil, err end
entry.options = options
return entry
end
-- A pin the cart ships switched off is installed and configured but not run.
function CartManifest.modEnabled(entry)
return type(entry) == "table" and entry.enabled ~= false
end
local function parseOrder(raw, mods)
local out = {}
if raw == nil then
for i, entry in ipairs(mods) do out[i] = entry.id end
return out
end
if type(raw) ~= "table" then return nil, "cart load_order must be an array" end
if #raw ~= #mods then
return nil, "cart load_order must list every pinned mod exactly once"
end
local pinned, seen = {}, {}
for _, entry in ipairs(mods) do pinned[entry.id] = true end
for i = 1, #raw do
local id = raw[i]
if type(id) ~= "string" or not pinned[id] then
return nil, ("cart load_order names %s, which the cart does not pin")
:format(tostring(id))
end
if seen[id] then
return nil, ("cart load_order names %s twice"):format(id)
end
seen[id] = true
out[i] = id
end
return out
end
function CartManifest.parse(tbl)
if type(tbl) ~= "table" then return nil, "cart must be a table" end
if not isId(tbl.id) then
return nil, ("cart id must be 1 to %d characters of letters, numbers, _ or -")
:format(CartManifest.MAX_ID)
end
if type(tbl.title) ~= "string" then return nil, "cart title is required" end
local title = trim(tbl.title)
if title == "" or #title > CartManifest.MAX_TITLE then
return nil, ("cart title must be 1 to %d characters"):format(CartManifest.MAX_TITLE)
end
if not isSemver(tbl.version) then
return nil, "cart version must be a semantic version"
end
if type(tbl.author) ~= "string" then return nil, "cart author is required" end
local author = trim(tbl.author)
if author == "" or #author > CartManifest.MAX_AUTHOR then
return nil, ("cart author must be 1 to %d characters"):format(CartManifest.MAX_AUTHOR)
end
local repo = nil
if tbl.repo ~= nil then
if not isRepo(tbl.repo) then return nil, "cart repo must be owner/name" end
repo = tbl.repo
end
local summary = nil
if tbl.summary ~= nil then
if type(tbl.summary) ~= "string" then
return nil, "cart summary must be a string"
end
summary = trim(tbl.summary)
if #summary > CartManifest.MAX_SUMMARY then
return nil, ("cart summary must be %d characters or fewer")
:format(CartManifest.MAX_SUMMARY)
end
end
local shell = type(tbl.shell) == "string" and tbl.shell:match("^#(%x%x%x%x%x%x)$")
if not shell then return nil, "cart shell must be a #RRGGBB colour" end
shell = "#" .. shell:lower()
local label = nil
if tbl.label ~= nil then
if type(tbl.label) ~= "string" or #tbl.label > CartManifest.MAX_LABEL then
return nil, ("cart label must be a path of %d characters or fewer")
:format(CartManifest.MAX_LABEL)
end
label = SafePath.safe(tbl.label)
if not label then return nil, "cart label must stay inside the cart" end
end
if type(tbl.base) ~= "string" or not GameVersion.VERSIONS[tbl.base] then
return nil, "cart base must name a game this engine knows"
end
local engine = nil
if tbl.engine ~= nil then
if type(tbl.engine) ~= "string" or trim(tbl.engine) == "" then
return nil, "cart engine must be a non-empty version range"
end
engine = trim(tbl.engine)
end
local speeds
if tbl.speeds ~= nil then
if type(tbl.speeds) ~= "table" or #tbl.speeds == 0 then
return nil, "cart speeds must be a non-empty array of multipliers"
end
local GameSpeed = require("src.core.GameSpeed")
local valid, seen = {}, {}
for _, want in ipairs(GameSpeed.LEVELS) do
for _, have in ipairs(tbl.speeds) do
if have == want and not seen[want] then
seen[want] = true
valid[#valid + 1] = want
end
end
end
if #valid ~= #tbl.speeds then
return nil, "cart speeds must all be GameSpeed levels"
end
speeds = valid
end
local finish = tbl.finish
if finish ~= nil then
if type(finish) ~= "string" or not CartManifest.FINISHES[finish] then
return nil, "cart finish must be sparkle, holo or sparkle+holo"
end
end
local seal = tbl.seal
if seal == nil then seal = "sealed" end
if type(seal) ~= "string" or not CartManifest.SEALS[seal] then
return nil, "cart seal must be sealed, sealed+ or open"
end
-- The author's preferred game settings, seeded once per cart
-- (SaveData.CART_OPTION_KEYS); unknown keys are kept but ignored.
local cartOptions, cartOptErr = parseOptions(tbl.options, "cart")
if cartOptErr then return nil, cartOptErr end
if type(tbl.mods) ~= "table" then return nil, "cart mods must be an array" end
local count = #tbl.mods
if count < 1 or count > CartManifest.MAX_MODS then
return nil, ("cart must pin 1 to %d mods"):format(CartManifest.MAX_MODS)
end
local mods, seen = {}, {}
for i = 1, count do
local entry, err = parseMod(tbl.mods[i], i, seen)
if not entry then return nil, err end
mods[i] = entry
end
local order, orderErr = parseOrder(tbl.load_order, mods)
if not order then return nil, orderErr end
return {
id = tbl.id,
title = title,
version = trim(tbl.version),
author = author,
repo = repo,
summary = summary,
shell = shell,
finish = finish,
speeds = speeds,
label = label,
base = tbl.base,
engine = engine,
seal = seal,
options = cartOptions,
mods = mods,
load_order = order,
}
end
function CartManifest.parseLabelArt(raw)
if raw == nil then return nil, "cart carries no label art" end
if type(raw) ~= "table" then return nil, "cart label art must be a table" end
if type(raw.encoding) ~= "string" or not CartManifest.ART_ENCODINGS[raw.encoding] then
return nil, "cart label art encoding must be base64"
end
if type(raw.data) ~= "string" or raw.data == "" then
return nil, "cart label art data must be a base64 string"
end
local tooBig = ("cart label art must be %d bytes or fewer")
:format(CartManifest.MAX_LABEL_ART)
if #raw.data > math.ceil(CartManifest.MAX_LABEL_ART / 3) * 4 then
return nil, tooBig
end
local bytes, err = Base64.decode(raw.data)
if not bytes then return nil, "cart label art " .. err end
if #bytes > CartManifest.MAX_LABEL_ART then return nil, tooBig end
if not isCount(raw.bytes) or raw.bytes ~= #bytes then
return nil, ("cart label art declares %s bytes but decodes to %d")
:format(tostring(raw.bytes), #bytes)
end
if bytes:sub(1, #CartManifest.PNG_SIGNATURE) ~= CartManifest.PNG_SIGNATURE then
return nil, "cart label art must be a PNG"
end
local name = nil
if raw.name ~= nil then
if type(raw.name) ~= "string" or #raw.name > CartManifest.MAX_LABEL then
return nil, ("cart label art name must be a path of %d characters or fewer")
:format(CartManifest.MAX_LABEL)
end
name = SafePath.safe(raw.name)
if not name then return nil, "cart label art name must stay inside the cart" end
end
return { name = name, encoding = raw.encoding, bytes = raw.bytes, data = raw.data },
nil, bytes
end
function CartManifest.labelArtBytes(cart)
if type(cart) ~= "table" then return nil, "cart must be a table" end
local art, err, bytes = CartManifest.parseLabelArt(cart.labelArt)
if not art then return nil, err end
return bytes, art.name
end
function CartManifest.publishable(cart)
if type(cart) ~= "table" or type(cart.mods) ~= "table" then
return false, "a cart must be parsed before it can be published"
end
local unpinned = {}
for _, entry in ipairs(cart.mods) do
if type(entry) == "table" and entry.source == "local" then
unpinned[#unpinned + 1] = tostring(entry.id)
end
end
if #unpinned == 0 then return true end
table.sort(unpinned)
return false, ("%s %s pinned to this install only, so nobody else can fetch %s: publish needs a repo and an archive hash for %s")
:format(table.concat(unpinned, ", "),
#unpinned == 1 and "is" or "are",
#unpinned == 1 and "it" or "them",
#unpinned == 1 and "it" or "each")
end
local function number(value)
return ("%.17g"):format(value)
end
local function writeText(out, prefix, text)
out[#out + 1] = ("%s%d:%s"):format(prefix, #text, text)
end
local function writeValue(out, value)
local kind = type(value)
if kind == "number" then
out[#out + 1] = "#" .. number(value)
elseif kind == "boolean" then
out[#out + 1] = value and "T" or "F"
elseif kind == "table" then
-- Length-prefixed so a list can never collide with another field's text.
out[#out + 1] = "*" .. tostring(#value)
for _, item in ipairs(value) do writeValue(out, item) end
else
writeText(out, "$", tostring(value))
end
end
local function writeField(out, name, value)
if value == nil then return end
writeText(out, ".", name)
writeValue(out, value)
end
local CART_FIELDS = { "author", "base", "engine", "finish", "id", "label",
"repo", "seal", "shell", "speeds", "summary", "title",
"version" }
local MOD_FIELDS = { "enabled", "file", "id", "md5", "mod", "repo", "sha256",
"source", "version" }
local function writeOptions(out, marker, options)
out[#out + 1] = marker
local keys = {}
for key in pairs(options or {}) do keys[#keys + 1] = key end
table.sort(keys)
for _, key in ipairs(keys) do writeField(out, key, options[key]) end
end
function CartManifest.canonical(cart)
local out = { "[cart]" }
for _, field in ipairs(CART_FIELDS) do writeField(out, field, cart[field]) end
-- Absent when the cart ships no settings, so a cart written before this
-- field hashes byte for byte as it did.
if cart.options ~= nil then writeOptions(out, "[cart_options]", cart.options) end
out[#out + 1] = "[mods]"
for _, entry in ipairs(cart.mods or {}) do
writeText(out, "@", tostring(entry.id))
for _, field in ipairs(MOD_FIELDS) do writeField(out, field, entry[field]) end
writeOptions(out, "[options]", entry.options)
end
out[#out + 1] = "[order]"
for _, id in ipairs(cart.load_order or {}) do writeText(out, "@", tostring(id)) end
return table.concat(out)
end
function CartManifest.hash(cart)
return StreamMD5.new():update(CartManifest.canonical(cart)):final()
end
function CartManifest.encode(cart)
return SaveSerializer.encode({
format = CartManifest.FORMAT,
formatVersion = CartManifest.SCHEMA,
labelArt = CartManifest.parseLabelArt(cart.labelArt),
-- Every field parse() keeps: a name missing here is a field the file
-- round trip silently drops, as finish and speeds were.
cart = { id = cart.id, title = cart.title, version = cart.version,
author = cart.author, repo = cart.repo, summary = cart.summary,
shell = cart.shell, finish = cart.finish, speeds = cart.speeds,
label = cart.label, base = cart.base,
engine = cart.engine, seal = cart.seal, options = cart.options,
mods = cart.mods, load_order = cart.load_order },
})
end
function CartManifest.decode(str)
if type(str) ~= "string" or str == "" then return nil, "EMPTY FILE" end
local data = SaveSerializer.decode(str)
if type(data) ~= "table" then return nil, "BAD FILE" end
if data.format ~= CartManifest.FORMAT then return nil, "NOT A CART" end
if data.formatVersion ~= CartManifest.SCHEMA then
return nil, ("unknown cart schema %s"):format(tostring(data.formatVersion))
end
local cart, err = CartManifest.parse(data.cart)
if not cart then return nil, err end
cart.labelArt = CartManifest.parseLabelArt(data.labelArt)
return cart
end
return CartManifest
+355
View File
@@ -0,0 +1,355 @@
local CartManifest = require("src.carts.CartManifest")
local SaveData = require("src.core.SaveData")
local Semver = require("src.mods.Semver")
local CartStore = {}
CartStore.DIR = CartManifest.DIR
CartStore.EXT = CartManifest.EXT
CartStore.OPTIONS_KEY = "carts"
CartStore.UNPINNED_VERSION = "0.0.0"
local RECORD_FIELDS = { "id", "title", "version", "author", "base", "seal",
"shell", "finish", "speeds", "summary", "hash", "file" }
local function fsOr(fs)
return fs or (love and love.filesystem) or nil
end
local function safeId(id)
return type(id) == "string" and id ~= "" and #id <= CartManifest.MAX_ID
and id:match("^[%w_%-]+$") ~= nil
end
local function fileFor(id)
return CartStore.DIR .. "/" .. id .. CartStore.EXT
end
CartStore.fileFor = fileFor
local function readOptions(fs)
local ok, opts = pcall(SaveData.loadOptions, fs)
if not ok or type(opts) ~= "table" then return {} end
return opts
end
local function writeOptions(opts, fs)
local ok = pcall(SaveData.saveOptions, opts, fs)
return ok and true or false
end
local function registry(opts)
local reg = opts[CartStore.OPTIONS_KEY]
return type(reg) == "table" and reg or nil
end
local function ensureRegistry(opts)
if type(opts[CartStore.OPTIONS_KEY]) ~= "table" then
opts[CartStore.OPTIONS_KEY] = {}
end
return opts[CartStore.OPTIONS_KEY]
end
local function recordFor(cart, hash)
return { id = cart.id, title = cart.title, version = cart.version,
author = cart.author, base = cart.base, seal = cart.seal,
shell = cart.shell, finish = cart.finish, speeds = cart.speeds,
summary = cart.summary,
hash = hash, file = fileFor(cart.id) }
end
local function sameValue(a, b)
if type(a) == "table" and type(b) == "table" then
if #a ~= #b then return false end
for i = 1, #a do
if a[i] ~= b[i] then return false end
end
return true
end
return a == b
end
local function sameRecord(a, b)
if type(a) ~= "table" or type(b) ~= "table" then return false end
for _, field in ipairs(RECORD_FIELDS) do
if not sameValue(a[field], b[field]) then return false end
end
return true
end
local function entryFor(cart, hash)
return { id = cart.id, title = cart.title, version = cart.version,
author = cart.author, base = cart.base, seal = cart.seal,
shell = cart.shell, finish = cart.finish, speeds = cart.speeds,
summary = cart.summary, label = cart.label,
cart = cart, cartHash = hash, file = fileFor(cart.id) }
end
local function readCart(fs, id)
if not safeId(id) then return nil, "unknown cart id" end
if not (fs and fs.read) then return nil, "NO FILESYSTEM" end
local path = fileFor(id)
if fs.getInfo and not fs.getInfo(path) then
return nil, ("cart %q is not installed"):format(id)
end
local body = fs.read(path)
if type(body) ~= "string" or body == "" then
return nil, ("cart %q is not installed"):format(id)
end
local ok, cart, err = pcall(CartManifest.decode, body)
if not ok then return nil, "BAD CART" end
if not cart then return nil, err or "BAD CART" end
if cart.id ~= id then
return nil, ("cart file %s names %q"):format(path, tostring(cart.id))
end
local hashed, hash = pcall(CartManifest.hash, cart)
if not hashed then return nil, "BAD CART" end
return cart, hash
end
local function strayIds(fs, seen)
local out = {}
if not (fs and fs.getDirectoryItems) then return out end
if fs.getInfo and not fs.getInfo(CartStore.DIR) then return out end
local ok, items = pcall(fs.getDirectoryItems, CartStore.DIR)
if not ok then return out end
for _, name in ipairs(items or {}) do
if type(name) == "string" and name:sub(-#CartStore.EXT) == CartStore.EXT then
local id = name:sub(1, #name - #CartStore.EXT)
if safeId(id) and not seen[id] then
seen[id] = true
out[#out + 1] = id
end
end
end
table.sort(out)
return out
end
function CartStore.index(fs)
local opts = readOptions(fsOr(fs))
local reg = registry(opts) or {}
local out = {}
for id, record in pairs(reg) do
if safeId(id) and type(record) == "table" then
local row = { id = id, file = fileFor(id) }
for _, field in ipairs(RECORD_FIELDS) do
if record[field] ~= nil then row[field] = record[field] end
end
row.id, row.title = id, record.title or id
out[#out + 1] = row
end
end
table.sort(out, function(a, b)
local at, bt = tostring(a.title):lower(), tostring(b.title):lower()
if at ~= bt then return at < bt end
return a.id < b.id
end)
return out
end
function CartStore.list(fs)
fs = fsOr(fs)
local rows = {}
if not fs then return rows end
local opts = readOptions(fs)
local reg = registry(opts) or {}
local ids, seen = {}, {}
for id in pairs(reg) do
if safeId(id) and not seen[id] then
seen[id] = true
ids[#ids + 1] = id
end
end
table.sort(ids)
for _, id in ipairs(strayIds(fs, seen)) do ids[#ids + 1] = id end
for _, id in ipairs(ids) do
local cart, hash = readCart(fs, id)
if cart then rows[#rows + 1] = entryFor(cart, hash) end
end
table.sort(rows, function(a, b)
local at, bt = tostring(a.title):lower(), tostring(b.title):lower()
if at ~= bt then return at < bt end
return a.id < b.id
end)
local healed, changed = {}, false
for _, row in ipairs(rows) do
healed[row.id] = recordFor(row.cart, row.cartHash)
if not sameRecord(reg[row.id], healed[row.id]) then changed = true end
end
for id in pairs(reg) do
if healed[id] == nil then changed = true end
end
if changed then
opts[CartStore.OPTIONS_KEY] = healed
writeOptions(opts, fs)
end
return rows
end
function CartStore.listFor(version, fs)
local out = {}
for _, row in ipairs(CartStore.list(fs)) do
if row.base == version then out[#out + 1] = row end
end
return out
end
function CartStore.get(id, fs)
return readCart(fsOr(fs), id)
end
function CartStore.labelArt(id, fs)
local cart, err = readCart(fsOr(fs), id)
if not cart then return nil, err end
return CartManifest.labelArtBytes(cart)
end
function CartStore.export(id, fs)
local cart, err = readCart(fsOr(fs), id)
if not cart then return nil, err end
return CartManifest.encode(cart), CartManifest.hash(cart)
end
function CartStore.install(bytes, fs)
fs = fsOr(fs)
if not (fs and fs.write) then return nil, "NO FILESYSTEM" end
local ok, cart, err = pcall(CartManifest.decode, bytes)
if not ok then return nil, "BAD CART" end
if not cart then return nil, err or "BAD CART" end
local existing = readCart(fs, cart.id)
if existing then
local order = Semver.compare(cart.version, existing.version)
if order and order < 0 then
return nil, ("%s %s is older than the installed %s")
:format(cart.title, cart.version, existing.version)
end
end
if fs.createDirectory then fs.createDirectory(CartStore.DIR) end
local wrote, writeErr = fs.write(fileFor(cart.id), CartManifest.encode(cart))
if not wrote then return nil, tostring(writeErr) end
local hash = CartManifest.hash(cart)
local opts = readOptions(fs)
ensureRegistry(opts)[cart.id] = recordFor(cart, hash)
writeOptions(opts, fs)
return cart, hash
end
function CartStore.uninstall(id, fs)
fs = fsOr(fs)
if not safeId(id) then return nil, "unknown cart id" end
if not fs then return nil, "NO FILESYSTEM" end
local path = fileFor(id)
local present = not fs.getInfo or fs.getInfo(path) ~= nil
if present and fs.read and fs.read(path) == nil then present = false end
local opts = readOptions(fs)
local reg = registry(opts)
local known = reg ~= nil and reg[id] ~= nil
if not present and not known then
return nil, ("cart %q is not installed"):format(id)
end
if present and fs.remove then fs.remove(path) end
if known then
reg[id] = nil
writeOptions(opts, fs)
end
return true
end
local function manifestOf(row)
return type(row.manifest) == "table" and row.manifest or nil
end
local function textOf(...)
for i = 1, select("#", ...) do
local value = select(i, ...)
if type(value) == "string" and value ~= "" then return value end
end
return nil
end
local function pinRepo(row)
local m = manifestOf(row)
return textOf(row.github, m and m.github)
end
local function pinHash(row)
local m = manifestOf(row)
local hash = textOf(row.sha256, row.archiveSha256,
m and m.sha256, m and m.archiveSha256)
if hash and #hash == 64 and hash:match("^[0-9a-f]+$") then return hash end
return nil
end
local function frozenOptions(bucket)
if type(bucket) ~= "table" then return nil end
local out, any = {}, false
for key, value in pairs(bucket) do
local kind = type(value)
if type(key) == "string" and key ~= ""
and (kind == "string" or kind == "number" or kind == "boolean") then
out[key] = value
any = true
end
end
return any and out or nil
end
local function pinReason(repo, version, semver, hash)
local why = {}
if not repo then why[#why + 1] = "no GitHub repo is recorded" end
if not semver then
why[#why + 1] = ("version %s is not a semantic version, so it pins as %s")
:format(version and ("%q"):format(version) or "is missing",
CartStore.UNPINNED_VERSION)
end
if repo and semver and not hash then
why[#why + 1] = "no archive hash is known yet"
end
return table.concat(why, " and ")
end
function CartStore.capture(identity, available, modOptions)
if type(identity) ~= "table" then return nil, "cart identity is required" end
local mods, order, unresolved = {}, {}, {}
for _, row in ipairs(available or {}) do
if type(row) == "table" and safeId(row.id) then
local m = manifestOf(row)
local version = textOf(row.version, m and m.version)
local semver = version and Semver.parse(version) and version or nil
local repo = pinRepo(row)
local hash = pinHash(row)
local entry
if repo and semver and hash then
entry = { id = row.id, source = "github", repo = repo,
version = semver, sha256 = hash }
else
entry = { id = row.id, source = "local",
version = semver or CartStore.UNPINNED_VERSION }
unresolved[#unresolved + 1] = {
id = row.id,
name = textOf(row.name, m and m.name) or row.id,
version = version,
reason = pinReason(repo, version, semver, hash),
}
end
-- A switched-off mod is pinned OFF rather than dropped, so the cart
-- still ships it with the author's settings.
if not row.enabled then entry.enabled = false end
entry.options = frozenOptions(modOptions and modOptions[row.id])
mods[#mods + 1] = entry
order[#order + 1] = row.id
end
end
local cart, err = CartManifest.parse({
id = identity.id, title = identity.title, version = identity.version,
author = identity.author, repo = identity.repo, summary = identity.summary,
shell = identity.shell, finish = identity.finish, speeds = identity.speeds,
label = identity.label, base = identity.base,
engine = identity.engine, seal = identity.seal, options = identity.options,
mods = mods, load_order = order,
})
if not cart then return nil, err end
return cart, unresolved
end
return CartStore
+75
View File
@@ -0,0 +1,75 @@
local Base64 = {}
local ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local PAD = 61
local ENC, DEC = {}, {}
for i = 0, 63 do
local c = ALPHABET:sub(i + 1, i + 1)
ENC[i] = c
DEC[c:byte()] = i
end
local floor = math.floor
local char = string.char
local concat = table.concat
function Base64.encode(bytes)
if type(bytes) ~= "string" then return nil, "base64 input must be a string" end
local out, n, i = {}, #bytes, 1
while i + 2 <= n do
local a, b, c = bytes:byte(i, i + 2)
local word = a * 65536 + b * 256 + c
out[#out + 1] = ENC[floor(word / 262144)] .. ENC[floor(word / 4096) % 64]
.. ENC[floor(word / 64) % 64] .. ENC[word % 64]
i = i + 3
end
local rest = n - i + 1
if rest == 1 then
local a = bytes:byte(i)
out[#out + 1] = ENC[floor(a / 4)] .. ENC[(a % 4) * 16] .. "=="
elseif rest == 2 then
local a, b = bytes:byte(i, i + 1)
local word = a * 256 + b
out[#out + 1] = ENC[floor(word / 1024)] .. ENC[floor(word / 16) % 64]
.. ENC[(word % 16) * 4] .. "="
end
return concat(out)
end
function Base64.decode(text)
if type(text) ~= "string" then return nil, "base64 input must be a string" end
local n = #text
if n % 4 ~= 0 then return nil, "base64 length must be a multiple of four" end
if n == 0 then return "" end
local out = {}
local last = n - 3
for i = 1, n, 4 do
local b1, b2, b3, b4 = text:byte(i, i + 3)
local v1, v2 = DEC[b1], DEC[b2]
if not v1 or not v2 then
return nil, "base64 holds a character outside the alphabet"
end
if i == last and b3 == PAD then
if b4 ~= PAD then return nil, "base64 padding is malformed" end
if v2 % 16 ~= 0 then return nil, "base64 padding carries data bits" end
out[#out + 1] = char(v1 * 4 + floor(v2 / 16))
elseif i == last and b4 == PAD then
local v3 = DEC[b3]
if not v3 then return nil, "base64 holds a character outside the alphabet" end
if v3 % 4 ~= 0 then return nil, "base64 padding carries data bits" end
local word = v1 * 1024 + v2 * 16 + floor(v3 / 4)
out[#out + 1] = char(floor(word / 256), word % 256)
else
local v3, v4 = DEC[b3], DEC[b4]
if not v3 or not v4 then
return nil, "base64 holds a character outside the alphabet"
end
local word = v1 * 262144 + v2 * 4096 + v3 * 64 + v4
out[#out + 1] = char(floor(word / 65536), floor(word / 256) % 256, word % 256)
end
end
return concat(out)
end
return Base64
+3 -1
View File
@@ -371,7 +371,7 @@ function ChipAudio.shutdown()
if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end
if worker then pcall(function() worker:wait() end) end
worker, cmdCh, outCh = nil, nil, nil
workerReady = false
workerReady = nil
end
function ChipAudio.currentSource()
@@ -498,6 +498,8 @@ end
-- program (20 §2 cache contract, chip music row)
Assets.register(ChipAudio.invalidate)
require("src.core.SessionLifecycle").registerProcessShutdown(ChipAudio.shutdown)
-- ---------------------------------------------------------------------------
-- one-shot effects (SFX, cries, low-health alarm): synchronous static Sources
-- ---------------------------------------------------------------------------
+1
View File
@@ -1147,6 +1147,7 @@ function Engine:drumInstrumentGen2(kit, pitch)
ticks = ticks + duration
end
end
extendDrumEnvelope(segments)
self.noiseInstruments[key] = segments
return segments
end
+73 -41
View File
@@ -147,29 +147,42 @@ function Game:bootConfig()
return boot
end
-- NEW GAME, as a call: a fresh skeleton (through save.new_game, so a mod
-- can reshape it), the overworld at the skeleton's spawn, and the intro
-- screen on top. The title menu's NEW GAME row is this; a mod that starts a
-- game on its own terms -- a match, a challenge mode -- calls it directly.
--
-- opts.intro = false skips the newGame screen (Oak's speech) so the player
-- lands straight in the world; the skeleton then has to carry a name and a
-- party, which is the caller's job via save.new_game.
function Game:startNewGame(opts)
local OverworldState = require("src.world.OverworldController")
while self.stack:top() do self.stack:pop() end
-- New Game keeps the standalone options.lua preferences
self.sessionStartedAt = os.time()
self.save = SaveData.newGame(self:bootConfig())
-- no bucket carry-over: mod state from an abandoned session must
-- not leak into a fresh slot; mods seed via save.created instead
self:adoptSave(self.save)
ModRuntime.emit("save.created", { save = self.save })
self:applyOptions(self.save.options)
self.stack:push(OverworldState, self.save.player.map,
self.save.player.x, self.save.player.y,
self.save.player.facing,
{ via = "boot", freshBoot = true })
if not (opts and opts.intro == false) then
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
function() end)
end
end
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
-- and by the START-menu QUIT confirmation
function Game:makeTitleState()
local OverworldState = require("src.world.OverworldController")
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
local title = factory.new(self, {
onNewGame = function()
while self.stack:top() do self.stack:pop() end
-- New Game keeps the standalone options.lua preferences
self.sessionStartedAt = os.time()
self.save = SaveData.newGame(self:bootConfig())
-- no bucket carry-over: mod state from an abandoned session must
-- not leak into a fresh slot; mods seed via save.created instead
self:adoptSave(self.save)
ModRuntime.emit("save.created", { save = self.save })
self:applyOptions(self.save.options)
self.stack:push(OverworldState, self.save.player.map,
self.save.player.x, self.save.player.y,
self.save.player.facing,
{ via = "boot", freshBoot = true })
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
function() end)
end,
onNewGame = function() self:startNewGame() end,
onContinue = function()
local loaded, recovered = SaveData.load()
if loaded then
@@ -817,14 +830,6 @@ function Game:keypressed(key)
self:writeOptions()
end
return
elseif key == "5" then
-- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on
-- desktop. Mobile refuses the present shader (issue #136).
local GBCFX = require("src.render.GBCFX")
if not GBCFX.isSupported() then return end
self.save.options.gbcfx = GBCFX.cycle()
self:writeOptions()
return
end
-- Mod render pipelines claim their hotkeys last, so one can never shadow
-- an engine display key however a mod declares it (12 §rendering
@@ -919,16 +924,7 @@ function Game:gamepadaxis(joystick, axis, value)
Input:gamepadaxis(joystick, axis, value)
end
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
-- generic joystick path anyway: any sensor-style device that still reaches us
-- has gravity pinning an axis past the deadzone, which would hide the touch
-- overlay every instant and steer the player by tilt through the axis-1/2
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
-- never as "* Accelerometer".
local function isAccelerometer(joystick)
local name = joystick and joystick.getName and joystick:getName()
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
end
local isAccelerometer = GamepadMap.isAccelerometer
-- BindingsMenu's raw-stick capture rides the same top-state routing as the
-- keyboard and gamepad paths (#632). Only a stick SDL does not recognize
@@ -1267,8 +1263,10 @@ function Game:applyOptions(opts)
require("src.render.Pipelines").applyOptions(opts)
require("src.render.Zoom").applyOptions(opts)
require("src.render.TileRenderer").applyOptions(opts)
-- returns true when a persisted GBC FX level was cleared on mobile
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
-- returns true when a persisted preset name no longer resolves (deleted
-- from the drop-in folder, or failed to (re)translate) and had to be
-- cleared back to OFF -- "sanitized, please persist" contract
local shaderfxCleared = require("src.render.ShaderFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts)
-- Android orientation lock (#592); no-op everywhere else
require("src.core.Orientation").applyOptions(opts)
@@ -1283,12 +1281,12 @@ function Game:applyOptions(opts)
-- tier. Every heavy feature was just applied from the stored options
-- above; here we clamp the *live* state down for a weaker device without
-- rewriting what the player saved, so raising the tier later restores
-- their exact TILT / GBC FX / ZOOM / MAX FPS choices. A HIGH tier (the
-- their exact TILT / ZOOM / MAX FPS choices. A HIGH tier (the
-- default on a normal desktop, and every options.lua predating this
-- option) clamps nothing, so it is a no-op for the common case.
local caps = require("src.core.Performance").applyOptions(opts)
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end
if not caps.shaderfx then require("src.render.ShaderFX").deactivate() end
local Zoom = require("src.render.Zoom")
Zoom.allowSurvey = caps.survey
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
@@ -1298,8 +1296,7 @@ function Game:applyOptions(opts)
end
Input:applyBindings(opts.bindings)
TouchControls:applyOptions(opts)
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
if gbcCleared then self:writeOptions() end
if shaderfxCleared then self:writeOptions() end
end
function Game:restoreSave(loaded, recovered, opts)
@@ -1390,4 +1387,39 @@ function Game:restoreCheckpointBattle(battle)
if battle.resumeCheckpoint then battle:resumeCheckpoint() end
end
-- Drop every session-owned field so the next Game:load() starts clean when
-- the process returns to the launcher in-place (Android / intent_game).
-- main.lua must not guess field names: new systems (Game.network, …) are
-- cleared automatically because only functions (methods) are kept.
function Game:reset()
if self.stack and self.stack.clear then
pcall(function() self.stack:clear() end)
end
if self.world and self.world.release then
pcall(function() self.world:release() end)
end
if self._canvases then
for _, canvas in pairs(self._canvases) do
if canvas and canvas.release then pcall(canvas.release, canvas) end
end
end
if self.renderer and self.renderer.releaseCanvases then
pcall(function() self.renderer:releaseCanvases() end)
end
local keys = {}
for key, value in pairs(self) do
if type(value) ~= "function" then
if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then
if type(value) == "table" and value.release then
pcall(value.release, value)
end
end
keys[#keys + 1] = key
end
end
for _, key in ipairs(keys) do
self[key] = nil
end
end
return Game
+286 -83
View File
@@ -18,6 +18,8 @@ local Chrome = require("src.ui.gen2.Chrome")
local Clock = require("src.core.gen2.Clock")
local FixedStep = require("src.core.FixedStep")
local Font = require("src.render.Font")
local GamepadMap = require("src.core.GamepadMap")
local GameVersion = require("src.core.GameVersion")
local Input = require("src.core.Input")
local Music = require("src.core.Music")
local Save = require("src.core.gen2.Save")
@@ -57,20 +59,6 @@ Game2.__index = Game2
local function noop() end
for _, name in ipairs({
"joystickpressed", "joystickreleased", "joystickaxis", "joystickhat",
"joystickadded",
}) do
Game2[name] = noop
end
-- Not a noop, because the overlay has to come back on its own: a player who
-- unplugs the only controller would otherwise have to tap a blind screen to
-- get the pad back (src/core/Game.lua:869 does the same).
function Game2:joystickremoved()
TouchControls:joystickremoved()
end
-- THE FRAME AND INPUT SEAMS.
--
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
@@ -301,8 +289,6 @@ function Game2:continueGame(save)
self:startWorld()
-- After the adopt and after the world is standing, which is where Gen 1
-- emits it (src/core/Game.lua:1127, once the stack has been rebuilt).
-- `meta` stays absent on a Gold save, which stamps no meta block; modsDiff
-- is derived from it and so comes back empty rather than missing.
if modsDiff then
local notice = SaveData.modsDiffNotice(modsDiff, save.meta)
if notice then require("src.core.Logger").warn("%s", notice) end
@@ -367,24 +353,36 @@ function Game2:showTitle()
})
end
-- ../pokegold/engine/movie/intro.asm:1 GoldSilverIntro, and Crystal's own
-- program at ../pokecrystal/engine/movie/intro.asm:1 CrystalIntro.
function Game2:showIntro()
self.stack:clear()
self.phase = "boot"
Screens.push(self, "Gen2GoldSilverIntro", {
local id = (GameVersion.engine() == "crystal")
and "Gen2CrystalIntro" or "Gen2GoldSilverIntro"
Screens.push(self, id, {
onDone = function()
self:showTitle()
end,
})
end
-- ../pokegold/engine/menus/intro_menu.asm:848-851 IntroSequence, and Crystal's
-- at ../pokecrystal/engine/menus/intro_menu.asm:964-967: a skip means the title.
function Game2:showGameFreak()
self.stack:clear()
self.phase = "boot"
Screens.push(self, "Gen2GameFreakPresents", {
local id = (GameVersion.engine() == "crystal")
and "Gen2CrystalSplash" or "Gen2GameFreakPresents"
Screens.push(self, id, {
title = self.titleData or {},
oakSpeech = self.oakSpeechData or {},
onDone = function()
self:showIntro()
onDone = function(skipped)
if skipped then
self:showTitle()
else
self:showIntro()
end
end,
})
end
@@ -496,8 +494,10 @@ function Game2:learnMoveOn(mon, moveId, onDone)
if onDone then onDone(learned) end
end
if ok then
-- data/text/common_3.asm:119
return self:say(("%s learned\n%s!"):format(name, moveName),
function() finish(true) end)
function() finish(true) end,
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
end
if reason ~= "full" then return finish(false) end
local askForget, pickMove, askStop
@@ -552,9 +552,11 @@ function Game2:learnMoveOn(mon, moveId, onDone)
-- The slot is written here rather than through Mon.learnMove, so
-- pokemon.move_learned is raised here too.
ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
self:say(("1, 2 and… Poof!\f%s forgot\n%s.\fAnd…\f%s learned\n%s!")
:format(name, oldName, name, moveName),
function() finish(true) end)
function() finish(true) end,
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
end,
})
end
@@ -719,7 +721,9 @@ function Game2:usePartyItem(itemId)
})
elseif action == "candy" then
self:consumeItem(itemId)
self:say(result.text, function() self:afterRareCandy(mon, result) end)
-- data/text/common_1.asm:86
self:say(result.text, function() self:afterRareCandy(mon, result) end,
result.sfx and TextBox.soundOpts(self, result.sfx) or nil)
else
self:consumeItem(itemId)
self:say(result.text)
@@ -778,8 +782,10 @@ function Game2:useSelectItem()
local name = (items[itemId] and items[itemId].name) or itemId
self:say(Strings("{PLAYER} used the\n%s.", name))
elseif outcome == "trophy_sent" then
-- data/text/common_3.asm:372
self:say(Strings(
"There was a trophy\ninside!\fThe trophy was\nsent home."))
"There was a trophy\ninside!\fThe trophy was\nsent home."),
nil, TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
end
-- Anything else (a fishing bite, the ITEMFINDER's queued script) already
-- drives its own presentation off World:step -- nothing left to print here.
@@ -791,9 +797,9 @@ end
-- onDone that popped again ate the state UNDER the box: dismissing a message
-- over the PACK closed the PACK with it, and over an empty overworld stack it
-- was a silent extra pop.
function Game2:say(text, onDone)
function Game2:say(text, onDone, opts)
local TextBox = require("src.render.TextBox")
self.stack:push(TextBox.new(self, text, onDone))
self.stack:push(TextBox.new(self, text, onDone, opts))
end
-- The landmark the player is standing in, for the Pokegear map's marker.
@@ -948,6 +954,12 @@ function Game2:load()
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
self.data.gen2Text = loadGenerated("data/generated/text.lua")
-- The engine's own strings, keyed by the disassembly's label. gen2Text
-- above is the script text and is keyed by bank:address for the overworld
-- VM, so the two are different tables and both are loaded. This one is
-- what src/core/RomText.lua reads, which is why it lands on `text`: that
-- helper is shared with Gen 1 and looks up data.text[label].
self.data.text = loadGenerated("data/generated/rom_text.lua") or {}
-- data/generated/events.lua: the side tables a script command NAMES rather
-- than carries -- the phone book, the in-game trades, the elevator's floor
-- labels, the decoration descriptions. Keyed for World's own `eventTables`
@@ -1094,18 +1106,26 @@ function Game2:load()
-- The play clock only runs in the overworld, the way wGameTimerPaused is
-- set while the intro menu is up.
Save.tickPlayTime(self.save)
-- START and SELECT are read only at the tail of OWPlayerInput, which
-- PlayerEvents never reaches while a script is running or the player is
-- mid-step (World:acceptsMenuInput transcribes the three gates). A press
-- that arrives under one of them is dropped, not queued -- and the frame
-- still runs, so world:step must not be skipped on the swallowed press.
if self.input:wasPressed("start") and self.world:acceptsMenuInput() then
self:openStartMenu()
return
end
if self.input:wasPressed("select") and self.world:acceptsMenuInput() then
self:useSelectItem()
return
-- MAPEVENTS_OFF skips GetJoypad for the whole of a step, so the hJoyDown
-- mirror is frozen -- events.asm:190-198, :211-227 (#525, #1718)
local accepts = self.world:acceptsMenuInput()
local latch = self.joyLatch
if accepts then
self.joyLatch = nil
if self.input:wasPressed("start")
or (latch and latch.start and self.input:isDown("start")) then
self:openStartMenu()
return
end
if self.input:wasPressed("select")
or (latch and latch.select and self.input:isDown("select")) then
self:useSelectItem()
return
end
else
if not latch then latch = {}; self.joyLatch = latch end
if self.input:wasPressed("start") then latch.start = true end
if self.input:wasPressed("select") then latch.select = true end
end
self.world:pollInput(self.input)
if self.input:wasPressed("a") then
@@ -1157,8 +1177,8 @@ end
-- The screen-pixels-per-GB-pixel scale the post passes need so their grid and
-- shadow offsets stay window-size independent. Always the plain letterbox
-- fit, never the survey zoom: GBC FX is simulating the PANEL the picture is
-- being shown on, and the panel does not resize when the player zooms the map
-- fit, never the survey zoom: SHADER FX is simulating the PANEL the picture
-- is being shown on, and the panel does not resize when the player zooms the map
-- -- Gen 1 hands the same pass its `Renderer:fitScale()` for that reason
-- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to
-- one screen pixel a cell out at survey range.
@@ -1355,25 +1375,29 @@ end
-- Gold's frame, and then the passes that run over it.
--
-- The Gen 1 path gets these for free because everything it draws goes through
-- src/render/Renderer.lua, which owns a present canvas and calls GBCFX there.
-- Gold draws straight to the screen instead, which is why its GBC FX row used
-- to change a number and nothing else: nothing ever presented a canvas for the
-- shader to read. So compose into one here when a pass wants it, and skip the
-- canvas entirely when none does -- the common case, and one less full-screen
-- blit than the old path would have paid.
-- src/render/Renderer.lua, which owns a present canvas and calls ShaderFX
-- there. Gold draws straight to the screen instead, which is why its SHADER
-- FX row used to change a number and nothing else (back when it was GBC FX):
-- nothing ever presented a canvas for the shader to read. So compose into
-- one here when a pass wants it, and skip the canvas entirely when none does
-- -- the common case, and one less full-screen blit than the old path would
-- have paid.
--
-- CLASSIC runs first and GBC FX second, matching the Gen 1 order: the palette
-- IS the picture, and the screen effects are simulating the panel that picture
-- is being shown on. Mod post-processes fold in between the two, where
-- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid
-- is then drawn over, rather than something that smears the grid itself.
-- CLASSIC runs first and SHADER FX second, matching the Gen 1 order: the
-- palette IS the picture, and the screen effects are simulating the panel
-- that picture is being shown on. Mod post-processes fold in between the
-- two, where Renderer.lua:1058 folds them -- a blur or a colour grade is
-- what the LCD grid is then drawn over, rather than something that smears
-- the grid itself.
function Game2:drawViewportFrame()
local G = love.graphics
local w, h = GameViewport.dimensions()
local GBCFX = require("src.render.GBCFX")
local ShaderFX = require("src.render.ShaderFX")
local GbcPalette = require("src.render.GbcPalette")
local Pipelines = require("src.render.Pipelines")
local fx = GBCFX.active()
-- Same dispatch src/render/Renderer.lua:1185 already uses for Gen 1
-- (ShaderFX replaced GBCFX's slot; GBCFX.lua itself is removed).
local shaderfx = ShaderFX.active()
-- render.zones, at the instant Gen 1 raises it: the palette list is settled
-- and the blit has not happened yet. Gen 1's list is the SGB packet zones
@@ -1396,14 +1420,14 @@ function Game2:drawViewportFrame()
local zoned = type(zones) == "table" and zones[1] ~= nil
-- A present canvas is paid for only when something reads it: the zone pass,
-- GBC FX, a mod post-process, render.compose, or an enabled render.output
-- SHADER FX, a mod post-process, render.compose, or an enabled render.output
-- subscriber. With none of them the frame draws straight to the screen
-- exactly as it always did.
local composing = ModRuntime.wantsHook("render.compose")
local hasOutputHook = ModRuntime.wantsHook("render.output")
and ModRuntime.call("render.output_enabled", function() return false end) == true
local scene = nil
if zoned or fx or composing or Pipelines.wantsPresent() or hasOutputHook then
if zoned or shaderfx or composing or Pipelines.wantsPresent() or hasOutputHook then
scene = self:presentCanvas(1, w, h)
end
if not scene then
@@ -1431,11 +1455,11 @@ function Game2:drawViewportFrame()
end
-- The zone pass has to land in a texture whenever anything still reads one
-- after it: GBC FX and a post-process both sample the tinted image, not the
-- untinted one. On its own the tint rides the final blit and no second
-- after it: SHADER FX and a post-process both sample the tinted image, not
-- the untinted one. On its own the tint rides the final blit and no second
-- canvas is paid for.
local source = scene
local reread = fx or Pipelines.wantsPresent() or hasOutputHook
local reread = shaderfx or Pipelines.wantsPresent() or hasOutputHook
if zoned and reread then
local tinted = self:presentCanvas(2, w, h)
if tinted then
@@ -1455,7 +1479,7 @@ function Game2:drawViewportFrame()
-- Post-process pipelines run over the finished composite and before GBC
-- FX. Each hands back a canvas; with none registered this returns `source`
-- unchanged and the frame is byte-identical (Renderer.lua:1058).
local scale, ox, oy, dpi = self:frameFit(w, h)
local scale, ox, oy, dpi, pw, ph = self:frameFit(w, h)
source = Pipelines.present(source, { width = w, height = h, scale = scale,
dpi = dpi, dpiX = dpi, dpiY = dpi }) or source
local outputHandled = hasOutputHook
@@ -1469,8 +1493,30 @@ function Game2:drawViewportFrame()
if not outputHandled then
local cx, cy, cw, ch = Playfield.cutout(w, h)
if cx then G.setScissor(cx, cy, cw, ch) end
if fx then
GBCFX.present(source, self:pixelScale(w, h))
if shaderfx then
-- rect is physical framebuffer pixels and source is the un-scaled
-- size, matching Renderer.lua's fxRectPx / fxSrc contract.
-- A live overworld draws edge to edge at World:zoomScale, so the
-- faithful 160*scale box would leave the rest of the map unshaded.
local rect, srcW, srcH
if self.frameWorldActive and self.world then
local s = self.world:zoomScale() * dpi
srcW = self.world.viewW or 160
srcH = self.world.viewH or 144
local rw, rh = srcW * s, srcH * s
rect = {
x = math.floor((pw - rw) / 2), y = math.floor((ph - rh) / 2),
w = rw, h = rh, scale = s,
}
else
srcW, srcH = 160, 144
rect = {
x = ox * dpi, y = oy * dpi,
w = 160 * scale * dpi, h = 144 * scale * dpi,
scale = scale * dpi,
}
end
ShaderFX.render(source, rect, { w = srcW, h = srcH }, dpi, dpi)
else
G.setColor(1, 1, 1, 1)
G.draw(source, 0, 0)
@@ -1514,6 +1560,28 @@ function Game2:drawContained(w, h)
if not ok then error(err, 0) end
end
-- BATTLE BG (#1709): the surround around the centred GB screen, taken from
-- whichever state in the stack owns a battle.
function Game2:paintBattleSurround(w, h)
local states = self.stack and self.stack.states or {}
local mode
for i = #states, 1, -1 do
local state = states[i]
if state and state.bgMode then mode = state:bgMode() break end
end
if mode ~= "black" then return end
local G = love.graphics
local scale = Chrome.fitScale(w, h)
local ox, oy = Chrome.fitOrigin(w, h, scale)
local pw, ph = 160 * scale, 144 * scale
G.setColor(0, 0, 0, 1)
if oy > 0 then G.rectangle("fill", 0, 0, w, oy) end
if oy + ph < h then G.rectangle("fill", 0, oy + ph, w, h - oy - ph) end
if ox > 0 then G.rectangle("fill", 0, oy, ox, ph) end
if ox + pw < w then G.rectangle("fill", ox + pw, oy, w - ox - pw, ph) end
G.setColor(1, 1, 1, 1)
end
function Game2:drawScene(w, h)
local G = love.graphics
-- render.compose reads this after the scene is drawn; the plain overworld
@@ -1583,6 +1651,7 @@ function Game2:drawScene(w, h)
and base.drawWidescreen and base)
if wide then
wide:drawWidescreen(w, h)
self:paintBattleSurround(w, h)
self:letterbox(w, h, false)
if wide ~= top then
-- The pushed box blits at the same integer fit the widescreen layer
@@ -1687,7 +1756,6 @@ end
-- F1/F2 write / reload the save 1 GAME SPEED
-- - = zoom one step out / in 2 COLOR
-- 4 cycle ZOOM 3 TILT (mnemonic: 3D)
-- 5 GBC FX
--
-- `2` is COLOR here rather than Gen 1's COLORS. The Gen 1 row cycles SGB
-- palette packs, which a CGB-native game has no use for; what it cycles here
@@ -1722,13 +1790,6 @@ function Game2:hotkey(key)
options.tilt = Tilt.cycle()
persist()
return true
elseif key == "5" then
local GBCFX = require("src.render.GBCFX")
if GBCFX.isSupported() then
options.gbcfx = GBCFX.cycle()
persist()
end
return true
end
if not (self.world and self.world.map) then
return self:pipelineHotkey(key, options, persist)
@@ -1963,12 +2024,17 @@ end
-- Push the saved display options into the modules that own them. Called
-- whenever the options table changes hands (boot, CONTINUE, the OPTION
-- screen), so a reload comes back at the zoom, tilt and GBC FX the player left.
-- screen), so a reload comes back at the zoom, tilt and SHADER FX the player
-- left.
function Game2:applyOptions()
local options = self.options or {}
Music.applyOptions(options)
require("src.core.Sound").applyOptions(options)
require("src.render.Zoom").applyOptions(options)
local Zoom = require("src.render.Zoom")
Zoom.applyOptions(options)
local caps = require("src.core.Performance").applyOptions(options)
Zoom.allowSurvey = caps.survey
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
require("src.render.Tilt").applyOptions(options)
require("src.render.GbcPalette").applyOptions(options)
-- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame.
@@ -1986,13 +2052,37 @@ function Game2:applyOptions()
require("src.core.ScreenPosition").applyOptions(options)
require("src.core.FrameCap").applyOptions(options)
require("src.world.gen2.BorderFill").applyOptions(options)
local GBCFX = require("src.render.GBCFX")
if GBCFX.applyOptions(options) and self.save then
-- applyOptions returns true when it had to clear an unsupported level.
-- returns true when a persisted preset name no longer resolves (deleted
-- from the drop-in folder, or failed to (re)translate) and had to be
-- cleared back to OFF -- src\core\Game.lua:1215 mirrors this call for
-- Gen 1 (SHADER FX reaches Gen 2 too)
local shaderfxCleared = require("src.render.ShaderFX").applyOptions(options)
-- Scale the optional presentation extras to the device's performance
-- tier, same clamp src/core/Game.lua:1222-1230 applies for Gen 1 -- see
-- that site's comment for the full rationale.
local caps = require("src.core.Performance").applyOptions(options)
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
if not caps.shaderfx then require("src.render.ShaderFX").deactivate() end
local Zoom = require("src.render.Zoom")
Zoom.allowSurvey = caps.survey
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
if caps.fpsMax then
local FrameCap = require("src.core.FrameCap")
if FrameCap.current > caps.fpsMax then FrameCap.apply(caps.fpsMax) end
end
if shaderfxCleared and self.save then
-- applyOptions returns true when it had to clear an unresolved preset.
self.save.options = options
end
end
function Game2:_cycleSpeed(dir)
local GameSpeed = require("src.core.GameSpeed")
self.options.speed = GameSpeed.cycle(self.options.speed, dir)
if self.save then self.save.options = self.options end
self:persistOptions()
end
-- `back` -- SDL's name for the small left-hand menu button: Xbox VIEW, the PS
-- CREATE/SHARE beside the touchpad, the Switch MINUS -- is SELECT, and has been
-- since src/core/GamepadMap.lua's DEFAULT_GAMEPAD_BINDINGS was written
@@ -2003,27 +2093,49 @@ end
-- the PACK's move-item, the party menu's reorder and half the soft-reset chord
-- (A+B+SELECT+START) were all unreachable from a pad, and pressing the button
-- to find out killed the process. It reaches Input like every other button now.
function Game2:gamepadpressed(_joystick, button)
function Game2:gamepadpressed(joystick, button)
-- a controller is being used: the touch overlay steps aside until the next
-- screen touch (mobile only; a no-op elsewhere)
TouchControls:noteGamepad()
-- The shoulders cycle GAME SPEED, as they do in the Gen 1 path.
if button == "rightshoulder" or button == "leftshoulder" then
local GameSpeed = require("src.core.GameSpeed")
local dir = button == "rightshoulder" and 1 or -1
self.options.speed = GameSpeed.cycle(self.options.speed, dir)
if self.save then self.save.options = self.options end
self:persistOptions()
local selectHeld = Input:isDown("select")
if not selectHeld and joystick and joystick.isGamepadDown then
local ok, down = pcall(function()
return joystick:isGamepadDown("back")
end)
selectHeld = ok and down == true
end
-- shoulders and triggers cycle GAME SPEED, as in src/core/Game.lua:881
if not selectHeld then
if button == "rightshoulder" or button == "righttrigger" then
self:_cycleSpeed(1)
return
elseif button == "leftshoulder" or button == "lefttrigger" then
self:_cycleSpeed(-1)
return
end
end
local top = self.stack and self.stack:top()
if top and top.onGamepadPressed then
top:onGamepadPressed(button)
return
end
if selectHeld then
local digit = GamepadMap.displayChordDigit(button)
if digit then
self:keypressed(digit)
return
end
end
-- START opens the start menu in the overworld; it used to quit, from before
-- there was a menu to open.
Input:gamepadpressed(_joystick, button)
Input:gamepadpressed(joystick, button)
end
function Game2:gamepadreleased(joystick, button)
Input:gamepadreleased(joystick, button)
local top = self.stack and self.stack:top()
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
end
function Game2:gamepadaxis(joystick, axis, value)
@@ -2032,4 +2144,95 @@ function Game2:gamepadaxis(joystick, axis, value)
Input:gamepadaxis(joystick, axis, value)
end
-- The raw joystick road, same bodies as src/core/Game.lua:935 (#620, #632, #1570).
local function isRawStick(joystick)
return not (joystick and joystick.isGamepad and joystick:isGamepad())
end
function Game2:joystickpressed(joystick, button)
if GamepadMap.isAccelerometer(joystick) then return end
TouchControls:noteGamepad()
local top = self.stack and self.stack:top()
if isRawStick(joystick) and top and top.onJoystickPressed then
top:onJoystickPressed(button)
return
end
Input:joystickpressed(joystick, button)
end
function Game2:joystickreleased(joystick, button)
if GamepadMap.isAccelerometer(joystick) then return end
Input:joystickreleased(joystick, button)
local top = self.stack and self.stack:top()
if isRawStick(joystick) and top and top.onJoystickReleased then
top:onJoystickReleased(button)
end
end
function Game2:joystickaxis(joystick, axis, value)
if GamepadMap.isAccelerometer(joystick) then return end
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
Input:joystickaxis(joystick, axis, value)
end
function Game2:joystickhat(joystick, hat, direction)
if GamepadMap.isAccelerometer(joystick) then return end
if direction ~= "c" then TouchControls:noteGamepad() end
Input:joystickhat(joystick, hat, direction)
end
-- src/core/Game.lua:1015 (#799)
function Game2:recoverInput()
Input:reset()
Input:reconcile()
TouchControls:reset()
if self.mods and self.mods.releaseModInput then self.mods:releaseModInput() end
self:cancelPointers()
end
function Game2:joystickadded()
self:recoverInput()
end
-- The overlay comes back on its own when the last pad is unplugged
-- (src/core/Game.lua:1044).
function Game2:joystickremoved()
self:recoverInput()
TouchControls:joystickremoved()
end
-- In-process return-to-launcher (Android / intent_game): drop session fields
-- so a later Game2.new() + load is not sharing a live stack or mod loader.
-- Methods live on the class table; pairs(self) only sees instance state.
function Game2:reset()
if self.stack and self.stack.clear then
pcall(function() self.stack:clear() end)
end
if self.world and self.world.release then
pcall(function() self.world:release() end)
end
if self._canvases then
for _, canvas in pairs(self._canvases) do
if canvas and canvas.release then pcall(canvas.release, canvas) end
end
end
if self.renderer and self.renderer.releaseCanvases then
pcall(function() self.renderer:releaseCanvases() end)
end
local keys = {}
for key, value in pairs(self) do
if type(value) ~= "function" then
if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then
if type(value) == "table" and value.release then
pcall(value.release, value)
end
end
keys[#keys + 1] = key
end
end
for _, key in ipairs(keys) do
self[key] = nil
end
end
return Game2
+31 -4
View File
@@ -29,11 +29,38 @@ end
-- nearest valid level for an arbitrary value (a hand-edited options.lua or
-- a --speed argument), so a bad number degrades to something sane
-- A cart may narrow the levels a player can reach (CartManifest's `speeds`).
-- nil restores the full ladder; a one-entry list pins the speed outright.
local allowed
function GameSpeed.setAllowed(levels)
if type(levels) ~= "table" or #levels == 0 then allowed = nil; return end
local valid, seen = {}, {}
for _, want in ipairs(GameSpeed.LEVELS) do
for _, have in ipairs(levels) do
if have == want and not seen[want] then
seen[want] = true
valid[#valid + 1] = want
end
end
end
allowed = (#valid > 0) and valid or nil
end
function GameSpeed.allowed()
return allowed or GameSpeed.LEVELS
end
function GameSpeed.isLocked()
return allowed ~= nil and #allowed <= 1
end
function GameSpeed.clamp(v)
v = tonumber(v)
if not v then return GameSpeed.DEFAULT end
local best, bestDiff = GameSpeed.DEFAULT, math.huge
for _, level in ipairs(GameSpeed.LEVELS) do
local levels = GameSpeed.allowed()
if not v then return levels[1] or GameSpeed.DEFAULT end
local best, bestDiff = levels[1] or GameSpeed.DEFAULT, math.huge
for _, level in ipairs(levels) do
local diff = math.abs(level - v)
if diff < bestDiff then best, bestDiff = level, diff end
end
@@ -42,7 +69,7 @@ end
-- cycle to the next/previous level, wrapping (the options row idiom)
function GameSpeed.cycle(v, dir)
local levels = GameSpeed.LEVELS
local levels = GameSpeed.allowed()
local cur = 1
for i, level in ipairs(levels) do
if level == GameSpeed.clamp(v) then cur = i break end
+75 -12
View File
@@ -1,6 +1,6 @@
-- Which game this process is running: Red (the historical default), Blue,
-- Yellow, Gold, or Silver. One source of truth for everything that differs by
-- version -- the accepted ROM hash, the import manifest, where the
-- Yellow, Gold, Silver, or Crystal. One source of truth for everything that
-- differs by version -- the accepted ROM hash, the import manifest, where the
-- extracted cache lives, and the save-file suffix -- so the importer,
-- cache mount, SaveData, title screen and palette all agree.
--
@@ -8,8 +8,9 @@
-- saves are untouched, but its extracted cache lives under red/ like Blue,
-- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once
-- by CacheFs.migrateLegacyRedCache. All supported versions can be imported
-- and selected side by side. Gold and Silver are Gen 2 (see
-- docs/gold-phase1.md).
-- and selected side by side. Gold, Silver and Crystal are Gen 2 (see
-- docs/gold-phase1.md); `generation` splits Gen 1 from Gen 2 and `engine`
-- splits Gold/Silver from Crystal within Gen 2.
--
-- 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
@@ -27,6 +28,8 @@ GameVersion.VERSIONS = {
manifest = "tools/rom_manifest.json",
cachePrefix = "red/", -- red/data/generated, red/assets/generated (#899)
saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp
-- Absent reads as "gen1" (GameVersion.engine)
engine = "gen1",
},
blue = {
id = "blue",
@@ -37,6 +40,7 @@ GameVersion.VERSIONS = {
manifest = "tools/rom_manifest_blue.json",
cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated
saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp
engine = "gen1",
},
yellow = {
id = "yellow",
@@ -47,6 +51,7 @@ GameVersion.VERSIONS = {
manifest = "tools/rom_manifest_yellow.json",
cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated
saveSuffix = "_yellow", -- save_yellow.lua / .bak / .tmp
engine = "gen1",
},
-- Gen 2, Phase 1 (docs/gold-phase1.md): a 2 MiB cart, twice the size of
-- the Gen 1 ROMs above, imported through RomExtractorGen2 instead of
@@ -55,15 +60,14 @@ GameVersion.VERSIONS = {
id = "gold",
label = "Gold",
displayName = "Pokemon Gold",
-- Still Gen 2 Phase work; the launcher panel / Play button say Beta so
-- players do not treat it like the shipped Gen 1 columns.
launcherName = "Gold (Beta)",
launcherName = "Gold",
sha1 = "d8b8a3600a465308c9953dfa04f0081c05bdcb94",
manifest = "tools/rom_manifest_gold.json",
cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated
saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp
-- Absent reads as 1 (GameVersion.generation)
generation = 2,
engine = "gs",
},
-- Gold's engine with edition-selected data; the manifest is derived from
-- Gold's by tools/make_silver_manifest.py.
@@ -71,17 +75,46 @@ GameVersion.VERSIONS = {
id = "silver",
label = "Silver",
displayName = "Pokemon Silver",
launcherName = "Silver (Beta)",
launcherName = "Silver",
sha1 = "49b163f7e57702bc939d642a18f591de55d92dae",
manifest = "tools/rom_manifest_silver.json",
cachePrefix = "silver/", -- silver/data/generated, silver/assets/generated
saveSuffix = "_silver", -- save_silver.lua / .bak / .tmp
generation = 2,
engine = "gs",
},
crystal = {
id = "crystal",
label = "Crystal",
displayName = "Pokemon Crystal",
-- Still Gen 2 Phase work; the launcher panel / Play button say Beta so
-- players do not treat it like the shipped Gold and Silver columns.
launcherName = "Crystal (Beta)",
sha1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133",
manifest = "tools/rom_manifest_crystal.json",
cachePrefix = "crystal/", -- crystal/data/generated, crystal/assets/generated
saveSuffix = "_crystal", -- save_crystal.lua / .bak / .tmp
generation = 2,
engine = "crystal",
revisions = {
{ sha1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", label = "1.0" },
{ sha1 = "f2f52230b536214ef7c9924f483392993e226cfb", label = "1.1" },
},
fixes = {
-- pokegold/docs/bugs_and_glitches.md:61
luckyNumberBoxes = true,
-- pokegold/docs/bugs_and_glitches.md:88
surfOntoNpc = true,
-- pokecrystal/engine/battle/effect_commands.asm:2614
reflectOverflow = true,
},
},
}
-- Launcher column order.
GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver" }
local NO_FIXES = {}
-- Launcher column order. Append only (src/mods/ModProfile.lua encodes by index).
GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal" }
GameVersion.current = "red"
@@ -115,6 +148,17 @@ function GameVersion.generation(id)
return GameVersion.info(id).generation or 1
end
-- "gen1" | "gs" | "crystal": lineage within a generation.
function GameVersion.engine(id)
return GameVersion.info(id).engine or "gen1"
end
-- Cart bugs a version FIXED, by fix name; an absent row reads {} and stays bugged.
function GameVersion.fixes(id)
local info = GameVersion.info(id)
return (info and info.fixes) or NO_FIXES
end
-- Metadata for a version id, defaulting to the active one.
function GameVersion.info(id)
return GameVersion.VERSIONS[id or GameVersion.current]
@@ -128,10 +172,29 @@ function GameVersion.cachePrefix(id)
return GameVersion.info(id).cachePrefix
end
function GameVersion.revisions(id)
local info = GameVersion.info(id)
return info.revisions or { { sha1 = info.sha1 } }
end
function GameVersion.acceptsSha1(id, sha1)
for _, revision in ipairs(GameVersion.revisions(id)) do
if revision.sha1 == sha1 then return true end
end
return false
end
function GameVersion.revisionLabel(id, sha1)
for _, revision in ipairs(GameVersion.revisions(id)) do
if revision.sha1 == sha1 then return revision.label end
end
return nil
end
-- The version a ROM belongs to, by its SHA-1, or nil for an unknown ROM.
function GameVersion.forSha1(sha1)
for id, info in pairs(GameVersion.VERSIONS) do
if info.sha1 == sha1 then return id end
for id in pairs(GameVersion.VERSIONS) do
if GameVersion.acceptsSha1(id, sha1) then return id end
end
return nil
end
+11
View File
@@ -106,6 +106,17 @@ function GamepadMap.ignoreRawForJoystick(joystick)
return ok and isPad == true
end
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
-- generic joystick path anyway: any sensor-style device that still reaches us
-- has gravity pinning an axis past the deadzone, which would hide the touch
-- overlay every instant and steer the player by tilt through the axis-1/2
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
-- never as "* Accelerometer".
function GamepadMap.isAccelerometer(joystick)
local name = joystick and joystick.getName and joystick:getName()
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
end
function GamepadMap.mapRawButton(index)
if nxActive() then
local nx = GamepadMap.NX_RAW_BUTTON_BINDINGS[index]
+24 -1
View File
@@ -376,7 +376,19 @@ end
-- notice a quit command until curl returns. With the launcher's default 300s
-- ceiling, closing the window during a mod download hung the process for
-- minutes. Callers on the interactive fetch pool pass something short.
function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
--
-- `etagPath` (optional, absolute host path) turns this into a conditional
-- GET: curl sends `If-None-Match` from whatever ETag is already saved there
-- (`--etag-compare`) and overwrites it with the response's own ETag on
-- success (`--etag-save`), same file for both so a first call with no prior
-- ETag degrades to a plain unconditional download. A server that replies 304
-- Not Modified is reported as a third return value (`notModified`), and --
-- confirmed empirically against the real buildbot.libretro.com endpoint
-- (TrueFX/etag-cache-repro/) before this was wired in here -- curl does NOT
-- write `absPath` at all in that case (not even an empty file), matching
-- HTTP: a 304 carries no body. A caller must not treat a missing file as an
-- error when `notModified` comes back true.
function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPath)
if type(url) ~= "string" or url == "" then return nil, "missing url" end
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
userAgent = userAgent or "gen1recomp"
@@ -388,6 +400,10 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
if accept then
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
end
if type(etagPath) == "string" and etagPath ~= "" then
cmd = cmd .. "--etag-compare " .. HostShell.quote(etagPath) .. " "
.. "--etag-save " .. HostShell.quote(etagPath) .. " "
end
cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " "
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1"
@@ -400,6 +416,9 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
-- status is here purely so the failure can NAME itself: "download failed"
-- with no URL and no code is the report this whole change exists to fix.
local body, status, noise = splitCurlOutput(readOk and out or "")
if status == 304 then
return true, nil, true
end
if status and (status < 200 or status >= 300) then
return nil, fetchError(url, status, body)
end
@@ -411,6 +430,10 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
if not haveBridge() then
return nil, "no network transport on this platform"
end
-- The Android/iOS bridge has no conditional-GET support -- etagPath is
-- silently ignored here, so a downloaded preset re-fetches in full every
-- time on those platforms. No transport-level ETag/If-None-Match hook
-- exists on the bridge to hang one off, and it's low-priority for now.
local ok, done = pcall(love.system.httpDownload, url, absPath, userAgent, accept)
if ok and done then return true end
return nil, "download failed for " .. url
+6 -1
View File
@@ -281,6 +281,7 @@ end
function Input:joystickpressed(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
noteCapture(self, "joy", "pressed", button)
local btn = self.joyBindings[button]
if btn then press(self, btn, "joy:" .. button) end
@@ -288,6 +289,7 @@ end
function Input:joystickreleased(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
noteCapture(self, "joy", "released", button)
local btn = self.joyBindings[button]
if btn then release(self, btn, "joy:" .. button) end
@@ -330,6 +332,7 @@ end
function Input:joystickaxis(joystick, axis, value)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
if axis == 1 then
self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then
@@ -343,6 +346,7 @@ end
-- directions on top of a direction rebind.
function Input:joystickhat(joystick, hat, direction)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
local source = "hat:" .. hat
for _, btn in ipairs(self.hatDirs[hat] or {}) do
release(self, btn, source)
@@ -380,7 +384,8 @@ function Input:reconcile()
local ok, joysticks = pcall(js.getJoysticks)
if not ok or type(joysticks) ~= "table" then return end
for _, j in ipairs(joysticks) do
if GamepadMap.ignoreRawForJoystick(j) then
if GamepadMap.isAccelerometer(j) then
elseif GamepadMap.ignoreRawForJoystick(j) then
-- SDL-recognized pad: buttons + left stick, the gamepad surfaces
if j.isGamepadDown then
for button, btn in pairs(self.padBindings) do
+1
View File
@@ -40,6 +40,7 @@ local function normalizeVersion(v)
y = "yellow", yellow = "yellow",
g = "gold", gold = "gold",
s = "silver", silver = "silver",
c = "crystal", crystal = "crystal",
}
v = alias[v] or v
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
+79 -23
View File
@@ -1,4 +1,4 @@
-- Screen orientation lock, Android only (#592, #716).
-- Screen orientation lock, Android and iOS (#592, #716, #1638).
--
-- Persisted as options.orientation: "auto" | "portrait" | "landscape" |
-- "reverseLandscape". The lock travels through SDL_HINT_ORIENTATIONS:
@@ -10,16 +10,12 @@
-- rotation lock"; LANDSCAPE allows both landscapes (SENSOR_LANDSCAPE ->
-- USER_LANDSCAPE); REVERSE LANDSCAPE is SDL's LandscapeRight alone.
--
-- SDL only re-reads the hint when the window is created or its resizable
-- flag changes (SDL_androidwindow.c: Android_CreateWindow /
-- Android_SetWindowResizable both call Android_JNI_SetOrientation). LOVE
-- 11.5 exposes neither hints nor a resizable setter, so apply() goes through
-- the FFI to SDL's C API: set the hint, then pulse the window's resizable
-- flag off and back on -- each edge makes the Android backend recompute the
-- requested orientation, so a change from the launcher or the OPTION menu
-- takes hold immediately, and the flag ends where it started (conf.lua sets
-- resizable on mobile). Everything is pcall-guarded: desktop, iOS (the
-- Info.plist governs there) and headless stubs make this a no-op.
-- Android only re-reads the hint at window creation or on a resizable-flag
-- change (SDL_androidwindow.c), and SDL_SetWindowResizable early-returns on
-- a fullscreen window (SDL_video.c:2237) -- which LOVE's Android window
-- always is -- so the hint never reached a running activity (#1638).
-- apply() sets the hint for a later window, then goes over JNI for the live
-- one. iOS needs only the hint. Desktop and headless stubs no-op.
local Orientation = {}
@@ -58,6 +54,11 @@ function Orientation.isAndroid()
return love.system.getOS() == "Android"
end
function Orientation.isIOS()
if not love or not love.system or not love.system.getOS then return false end
return love.system.getOS() == "iOS"
end
function Orientation.cycle(mode, dir)
local cur, idx = Orientation.normalize(mode), 1
for i, m in ipairs(Orientation.MODES) do
@@ -67,6 +68,15 @@ function Orientation.cycle(mode, dir)
return Orientation.MODES[(idx - 1 + (dir or 1)) % n + 1]
end
-- ActivityInfo constants, what setOrientationBis lands on per hint after
-- GameActivity's *_SENSOR -> *_USER remap (#716).
local REQUESTED = {
auto = 13,
portrait = 1,
landscape = 11,
reverseLandscape = 8,
}
-- The SDL2 C API this module needs. cdef errors on redefinition, so run it
-- once and remember whether it took; ffi itself may be absent (plain Lua
-- test interpreters), hence the pcall'd require.
@@ -77,33 +87,79 @@ local function sdlFfi()
if cdefOk == nil then
cdefOk = pcall(ffi.cdef, [[
typedef struct SDL_Window SDL_Window;
typedef union { int32_t i; int64_t pad; } love_jvalue;
int SDL_SetHint(const char *name, const char *value);
SDL_Window *SDL_GL_GetCurrentWindow(void);
void SDL_SetWindowResizable(SDL_Window *window, int resizable);
void *SDL_AndroidGetJNIEnv(void);
void *SDL_AndroidGetActivity(void);
]])
end
if not cdefOk then return nil end
return ffi
end
-- Push the mode into the live activity. Returns true when the hint reached
-- SDL (the symbols resolved), false on any non-Android / stubbed platform.
-- Slot numbers in JNINativeInterface (jni.h).
local JNI_EXCEPTION_CLEAR = 17
local JNI_DELETE_LOCAL_REF = 23
local JNI_GET_OBJECT_CLASS = 31
local JNI_GET_METHOD_ID = 33
local JNI_CALL_VOID_METHOD_A = 63
-- What Android_JNI_SetOrientation reaches, called directly: the hint path
-- cannot re-run on a live fullscreen window (SDL_video.c:2237).
local function setRequestedOrientation(ffi, requested)
local env = ffi.C.SDL_AndroidGetJNIEnv()
if env == nil then return false end
local activity = ffi.C.SDL_AndroidGetActivity()
if activity == nil then return false end
local fns = ffi.cast("void***", env)[0]
local getObjectClass = ffi.cast("void *(*)(void *, void *)", fns[JNI_GET_OBJECT_CLASS])
local getMethodID = ffi.cast(
"void *(*)(void *, void *, const char *, const char *)", fns[JNI_GET_METHOD_ID])
local callVoidMethodA = ffi.cast(
"void (*)(void *, void *, void *, love_jvalue *)", fns[JNI_CALL_VOID_METHOD_A])
local deleteLocalRef = ffi.cast("void (*)(void *, void *)", fns[JNI_DELETE_LOCAL_REF])
local exceptionClear = ffi.cast("void (*)(void *)", fns[JNI_EXCEPTION_CLEAR])
local ok = false
local cls = getObjectClass(env, activity)
if cls ~= nil then
local mid = getMethodID(env, cls, "setRequestedOrientation", "(I)V")
if mid ~= nil then
local args = ffi.new("love_jvalue[1]")
args[0].pad = 0
args[0].i = requested
callVoidMethodA(env, activity, mid, args)
ok = true
end
exceptionClear(env)
deleteLocalRef(env, cls)
end
deleteLocalRef(env, activity)
return ok
end
-- Returns true only when the request actually landed, never unconditionally
-- as it once did (#1638).
function Orientation.apply(mode)
if not Orientation.isAndroid() then return false end
local android = Orientation.isAndroid()
if not (android or Orientation.isIOS()) then return false end
local ffi = sdlFfi()
if not ffi then return false end
mode = Orientation.normalize(mode)
local ok = pcall(function()
-- "SDL_IOS_ORIENTATIONS" is SDL_HINT_ORIENTATIONS's name (SDL_hints.h);
-- despite the IOS in the string, the Android backend reads it too.
local ok, reached = pcall(function()
-- SDL_HINT_ORIENTATIONS is "SDL_IOS_ORIENTATIONS" in the SDL2 Android
-- ships and "SDL_ORIENTATIONS" in the SDL3 the iOS app links; each
-- engine ignores the other's key.
ffi.C.SDL_SetHint("SDL_IOS_ORIENTATIONS", HINTS[mode])
local win = ffi.C.SDL_GL_GetCurrentWindow()
if win ~= nil then
ffi.C.SDL_SetWindowResizable(win, 0)
ffi.C.SDL_SetWindowResizable(win, 1)
end
ffi.C.SDL_SetHint("SDL_ORIENTATIONS", HINTS[mode])
-- On iOS the hint is the lock: UIKit re-asks on every rotation
-- (SDL_uikitviewcontroller.m supportedInterfaceOrientations).
if not android then return true end
return setRequestedOrientation(ffi, REQUESTED[mode])
end)
return ok
return ok and reached == true
end
function Orientation.applyOptions(opts)
+34 -14
View File
@@ -3,9 +3,12 @@
--
-- The heavy extras are all things the Game Boy never had and this port
-- adds on top: the whole-screen 3D TILT (transforms the entire map as a
-- ground plane), the GBC FX post-process shader (a fullscreen pass), and
-- survey ZOOM (zooming out renders the connected neighbor maps -- a lot of
-- extra overdraw). A hard FPS ceiling caps present cost on top of that.
-- ground plane), survey ZOOM (zooming out renders the connected
-- neighbor maps -- a lot of extra overdraw), and SHADER FX (a live-
-- translated libretro slang-shader chain, reasoned -- not yet measured --
-- to be heavier per-frame than GBCFX.lua's old fixed shader it replaced).
-- A hard FPS ceiling caps present cost on
-- top of that.
-- None of this touches game logic, which is fixed-step off dt
-- (src/core/FixedStep.lua), so every tier plays identically; they differ
-- only in how much optional eye-candy the renderer is allowed to do.
@@ -13,14 +16,15 @@
-- The tier is persisted as save.options.performance:
-- auto pick a default from the device (see detect())
-- high everything on -- the historical behavior
-- balanced no TILT, no GBC FX (kept: survey zoom, colors, uncapped FPS)
-- low no TILT, no GBC FX, no survey zoom, FPS capped
-- balanced no TILT, no SHADER FX (kept: survey zoom, colors, uncapped FPS)
-- low no TILT, no survey zoom, no SHADER FX, FPS capped
--
-- AUTO only chooses the *default* for the current device; every tier is
-- selectable in OPTIONS, so a heuristic that guesses wrong is one row away
-- from being overridden. The clamps are applied live in Game:applyOptions
-- against the stored options without rewriting them, so raising the tier
-- restores exactly the TILT / GBC FX / ZOOM / FPS the player had.
-- (Gen 1) / Game2:applyOptions (Gen 2) against the stored options without
-- rewriting them, so raising the tier restores exactly the TILT / SHADER FX
-- / ZOOM / FPS the player had. Wired into both generations.
--
-- Zero requires: loads during love.conf and under plain Lua for tools and
-- tests, the same way src/core/GameVersion.lua does.
@@ -40,10 +44,24 @@ Performance.LABELS = {
-- What each concrete tier permits. `auto` is resolved to one of these
-- before caps are read, so it has no row here. fpsMax = false means no
-- extra ceiling (the player's own MAX FPS still applies).
--
-- `shaderfx` is `false` (fully off) or a positive number: an internal
-- chain-resolution multiplier (1.0 = native, e.g. 0.5 = render the whole
-- SHADER FX pass chain at half resolution, then stretch up for display --
-- src/render/ShaderFX.lua's ShaderFX.render already stretch-blits its
-- final output to the real display rect regardless of the chain's own
-- internal size, so shrinking the chain's working
-- resolution costs nothing extra at that final blit). Every existing
-- truthy/falsy call site (`if not caps.shaderfx then ... end`) still works
-- unchanged: `false` stays falsy, any positive number stays truthy -- only
-- `ShaderFX.render` itself reads the actual number. No tier is
-- set below 1.0 yet -- tuning the actual per-tier value for real low-end
-- hardware needs a device this project doesn't have to hand right now; this
-- is the mechanism, not the tuning.
Performance.CAPS = {
high = { tilt = true, gbcfx = true, survey = true, fpsMax = false },
balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false },
low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 },
high = { tilt = true, survey = true, shaderfx = 1.0, fpsMax = false },
balanced = { tilt = false, survey = true, shaderfx = false, fpsMax = false },
low = { tilt = false, survey = false, shaderfx = false, fpsMax = 60 },
}
-- Live resolved tier (never "auto"); Game:applyOptions sets it and the
@@ -86,12 +104,14 @@ function Performance.detect()
local cores = processorCount()
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
-- already ships a build for): the weakest target here.
if isArm and os ~= "Android" and os ~= "iOS" then
-- already ships a build for): the weakest target here. Desktop ARM
-- (Apple Silicon "OS X", Windows-on-ARM) is not a handheld — those
-- used to resolve AUTO → LOW, which stripped survey zoom-out from
-- OPTIONS so the ZOOM row only offered IN.
if isArm and os == "Linux" then
return "low"
end
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
-- balanced additionally drops the 3D tilt, the heaviest remaining extra.
-- Phones and tablets: balanced drops the 3D tilt, the heaviest extra.
if os == "Android" or os == "iOS" then
return "balanced"
end
+544 -125
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -12,10 +12,12 @@ function ScreenPosition.normalize(v)
end
function ScreenPosition.label(v)
if ScreenPosition.skinActive() then return "SKIN" end
return LABELS[ScreenPosition.normalize(v)]
end
function ScreenPosition.cycle(v, dir)
if ScreenPosition.skinActive() then return ScreenPosition.normalize(v) end
v = ScreenPosition.normalize(v)
local modes = ScreenPosition.MODES
local cur = 1
@@ -44,6 +46,9 @@ end
function ScreenPosition.skinActive(w, h)
local ok, TouchSkin = pcall(require, "src.core.TouchSkin")
if not ok or type(TouchSkin.viewport) ~= "function" then return false end
if (not w or not h) and love and love.graphics and love.graphics.getDimensions then
w, h = love.graphics.getDimensions()
end
local okv, x = pcall(TouchSkin.viewport, w, h)
return okv and x ~= nil
end
+178
View File
@@ -0,0 +1,178 @@
-- Accelerometer and gyroscope reads for the tilt-driven shader presets, plus
-- screen-rotation compensation. love.sensor does not exist in LOVE 11.5 on any
-- platform, so the working path is raw FFI into the SDL2 LOVE already links,
-- the same technique src/core/Orientation.lua uses. What is and is not verified
-- on real hardware is written down in docs/shaderfx.md.
local Sensors = {}
-- Test seams: a no-device harness injects synthetic readings.
local overrides = {}
local orientationOverride = nil
function Sensors.setOverride(kind, x, y, z)
overrides[kind] = { x, y, z }
end
function Sensors.clearOverride(kind)
overrides[kind] = nil
end
function Sensors.clearAllOverrides()
overrides = {}
orientationOverride = nil
end
-- "landscape" | "landscapeFlipped" | "portrait" | "portraitFlipped" | nil
-- (nil = ask SDL for real). Test seam for rotateForScreen below.
function Sensors.setOrientationOverride(o)
orientationOverride = o
end
-- ---- love.sensor path (kept for a future LOVE 12 upgrade; always nil today).
local function safeHasSensor(kind)
if not love or not love.sensor then return false end
local ok, has = pcall(love.sensor.hasSensor, kind)
return ok and has == true
end
local function loveSensorRead(kind)
if not safeHasSensor(kind) then return nil end
local ok, x, y, z = pcall(love.sensor.getData, kind)
if not ok then return nil end
return x or 0, y or 0, z or 0
end
-- ---- raw SDL2 FFI path (the one that actually works on LÖVE 11.5).
local SDL_SENSOR_ACCEL, SDL_SENSOR_GYRO = 1, 2
local SDL_INIT_SENSOR = 0x00008000
local KIND_TO_SDL_TYPE = { accelerometer = SDL_SENSOR_ACCEL, gyroscope = SDL_SENSOR_GYRO }
local sdlCdefOk = nil
local sdlSensorHandles = {} -- kind -> SDL_Sensor* (nil once confirmed absent)
local sdlSensorTried = {}
-- ffi.load("SDL2") first, needed on desktop where SDL2 is a separate library,
-- then bare ffi.C, needed on Android where love-android links SDL2 statically
-- into libmain.so and there is no standalone libSDL2.so to attach to by name
-- (Orientation.lua's own sdlFfi resolves the same way on real hardware, #592/#716).
local sdlLib = nil
local function sdlFfi()
local okFfi, ffi = pcall(require, "ffi")
if not okFfi then return nil end
if sdlCdefOk == nil then
sdlCdefOk = pcall(ffi.cdef, [[
typedef struct SDL_Window SDL_Window;
typedef struct SDL_Sensor SDL_Sensor;
int SDL_InitSubSystem(unsigned int flags);
int SDL_NumSensors(void);
int SDL_SensorGetDeviceType(int device_index);
SDL_Sensor *SDL_SensorOpen(int device_index);
void SDL_SensorUpdate(void);
int SDL_SensorGetData(SDL_Sensor *sensor, float *data, int num_values);
SDL_Window *SDL_GL_GetCurrentWindow(void);
int SDL_GetWindowDisplayIndex(SDL_Window *window);
int SDL_GetDisplayOrientation(int displayIndex);
]])
if sdlCdefOk then
local okLoad, lib = pcall(ffi.load, "SDL2")
sdlLib = okLoad and lib or ffi.C
end
end
if not sdlCdefOk or not sdlLib then return nil end
return sdlLib
end
-- Opens and caches once, and returns nil permanently once a real attempt finds
-- none, so a no-hardware desktop run pays this cost once rather than per frame.
local function sdlSensorHandle(lib, kind)
if sdlSensorTried[kind] then return sdlSensorHandles[kind] end
sdlSensorTried[kind] = true
local wantType = KIND_TO_SDL_TYPE[kind]
if not wantType then return nil end
local okInit = pcall(lib.SDL_InitSubSystem, SDL_INIT_SENSOR)
if not okInit then return nil end
local okCount, count = pcall(lib.SDL_NumSensors)
if not okCount or count <= 0 then return nil end
for i = 0, count - 1 do
local okType, t = pcall(lib.SDL_SensorGetDeviceType, i)
if okType and t == wantType then
local okOpen, handle = pcall(lib.SDL_SensorOpen, i)
if okOpen and handle ~= nil then
sdlSensorHandles[kind] = handle
return handle
end
end
end
return nil
end
local function sdlSensorRead(kind)
local lib = sdlFfi()
if not lib then return nil end
local handle = sdlSensorHandle(lib, kind)
if not handle then return nil end
pcall(lib.SDL_SensorUpdate)
local data = require("ffi").new("float[3]")
local okGet, n = pcall(lib.SDL_SensorGetData, handle, data, 3)
if not okGet or n ~= 0 then return nil end
return data[0], data[1], data[2]
end
-- ---- screen-rotation compensation (x/y only; z is perpendicular to the screen
-- and unaffected by an in-plane rotation). Mobile-only: a desktop monitor is
-- legitimately, permanently "landscape" to SDL_GetDisplayOrientation.
local function isMobile()
if not love or not love.system or not love.system.getOS then return false end
local os = love.system.getOS()
return os == "Android" or os == "iOS"
end
local function sdlOrientation()
if not isMobile() then return nil end
local lib = sdlFfi()
if not lib then return nil end
local okWin, win = pcall(lib.SDL_GL_GetCurrentWindow)
if not okWin or win == nil then return nil end
local okIdx, idx = pcall(lib.SDL_GetWindowDisplayIndex, win)
if not okIdx or idx < 0 then return nil end
local okOr, o = pcall(lib.SDL_GetDisplayOrientation, idx)
if not okOr then return nil end
-- SDL_DisplayOrientation: 0=UNKNOWN, 1=LANDSCAPE, 2=LANDSCAPE_FLIPPED,
-- 3=PORTRAIT, 4=PORTRAIT_FLIPPED.
if o == 1 then return "landscape"
elseif o == 2 then return "landscapeFlipped"
elseif o == 3 then return "portrait"
elseif o == 4 then return "portraitFlipped"
else return nil end
end
-- Remaps raw device-frame (x,y) into "as currently displayed" (x,y). portrait,
-- and unknown/undetectable (desktop always lands here), is a no-op.
local function rotateForScreen(x, y)
local o = orientationOverride or sdlOrientation()
if o == "landscape" then return -y, x
elseif o == "landscapeFlipped" then return y, -x
elseif o == "portraitFlipped" then return -x, -y
else return x, y end
end
-- Returns x, y, z for "accelerometer" | "gyroscope". {0,0,0} when overridden
-- that way, no hardware/module is available, or an underlying call errors.
-- x/y are rotated to be screen-relative; z passes through unchanged.
function Sensors.read(kind)
local override = overrides[kind]
if override then
local x, y = rotateForScreen(override[1], override[2])
return x, y, override[3]
end
local x, y, z = loveSensorRead(kind)
if not x then x, y, z = sdlSensorRead(kind) end
if not x then return 0, 0, 0 end
x, y = rotateForScreen(x, y)
return x, y, z
end
return Sensors
+101
View File
@@ -0,0 +1,101 @@
-- Central session lifecycle orchestrator. Subsystems register teardown hooks
-- at module load (Assets release/invalidate bus, process shutdown below); the
-- host only calls phase entry points.
--
-- Three tiers:
-- mount endMountedSession — GPU release + CacheFs/Data/Runtime/Assets
-- game endGameSession — audio + game:reset; workers stay alive
-- process endProcess — worker shutdown on real app exit (love.quit)
--
-- Hot reload (Assets.flush / installLoader) stays invalidate-only forever.
local SessionLifecycle = {}
local processShutdowns = {}
function SessionLifecycle.registerProcessShutdown(fn)
processShutdowns[#processShutdowns + 1] = fn
end
-- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted
-- version session (save editor or game). GPU release runs before soft
-- invalidate via installLoader(nil).
function SessionLifecycle.endMountedSession(version)
local Assets = require("src.render.Assets")
if Assets.releaseSession then Assets.releaseSession() end
if version then
require("src.import.CacheFs").unmountVersion(version)
end
require("src.core.Data"):unloadGenerated()
local Runtime = require("src.mods.Runtime")
if Runtime.reset then Runtime.reset() end
if Assets.installLoader then Assets.installLoader(nil) end
local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat")
if okCompat and LegacyCompat.reset then LegacyCompat.reset() end
end
-- Evict every save-editor module from package.loaded without a hardcoded
-- panel whitelist. Flat require names (App, Party, …) resolve under
-- tools/save-editor/; path-style keys may also appear.
local function flushEditorPackageLoaded()
local fs = love and love.filesystem
local function isEditorFlat(name)
if not (fs and fs.getInfo) then return false end
if name:find("[./]") then return false end
return fs.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil
or fs.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil
end
for k in pairs(package.loaded) do
if type(k) == "string"
and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then
package.loaded[k] = nil
end
end
end
function SessionLifecycle.endEditorSession(opts)
opts = opts or {}
if opts.app and opts.app.unload then pcall(opts.app.unload) end
flushEditorPackageLoaded()
if opts.version then
SessionLifecycle.endMountedSession(opts.version)
end
end
-- EXIT GAME / intent_game before dropping Game. Stops audio and resets the
-- live game instance so map/GPU holders are gone before endMountedSession.
function SessionLifecycle.endGameSession(game)
pcall(function() require("src.core.Music").stop() end)
pcall(function() require("src.core.Sound").stop() end)
if package.loaded["src.core.ChipAudio"] then
pcall(package.loaded["src.core.ChipAudio"].shutdown)
end
if package.loaded["src.core.DiscordPresence"] then
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
end
if package.loaded["src.core.gen2.Clock"] then
pcall(package.loaded["src.core.gen2.Clock"].shutdown)
end
if package.loaded["src.net.Gen1Tls"] then
pcall(package.loaded["src.net.Gen1Tls"].shutdown)
end
if love.audio and love.audio.stop then
pcall(love.audio.stop)
end
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
if game and game.reset then
pcall(function() game:reset() end)
end
local Input = require("src.core.Input")
local TouchControls = require("src.core.TouchControls")
Input:reset()
TouchControls:reset()
end
function SessionLifecycle.endProcess()
for _, fn in ipairs(processShutdowns) do pcall(fn) end
end
return SessionLifecycle
+16
View File
@@ -154,8 +154,14 @@ local function newSfxSource(data, key, def, pitch, tempo, plain)
return newFileSource(def)
end
local function deviceSuspended()
local ChipAudio = package.loaded["src.core.ChipAudio"]
return ChipAudio ~= nil and ChipAudio.isSuspended()
end
local function playPath(data, key, def, pitch, tempo, plain)
if not love.audio or not def then return nil end
if deviceSuspended() then return nil end
local src = cache[key]
if src == false then return nil end -- known bad, already logged
if not src then
@@ -357,6 +363,14 @@ function Sound.sfxBusy()
return true
end
-- WaitSFX (home/audio.asm), the drain above GiveItemScript's `specialsound`
-- (engine/overworld/scripting.asm:445), so ch5-ch8 are free for it (#1483).
function Sound.waitSfxDone()
if not curSfx then return end
pcall(curSfx.src.stop, curSfx.src)
curSfx = nil
end
local function startSfx(data, name, def)
local src = playPath(data, name, def)
if not src then return end
@@ -602,6 +616,7 @@ end
-- cache carries no clips (Red/Blue) or headless.
function Sound.playPikaCry(data, n)
if not love.audio then return nil end
if deviceSuspended() 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))
@@ -633,6 +648,7 @@ end
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
function Sound.playCry(data, species, pikaClip)
if not love.audio then return nil end
if deviceSuspended() then return nil end
-- Yellow voices every Pikachu cry with the PCM clips (the chip cry is
-- never used for the species there). Which clip is a property of the
-- call site in the original -- every caller of PlayPikachuSoundClip sets
+18 -6
View File
@@ -293,7 +293,9 @@ function TouchControls:applyOptions(opts)
-- launcher editor round-trips through config() (#806)
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
TouchSkin.setOverlayLive(self.active)
self:selectSkin(cfg.skin)
-- Off means off everywhere: do not leave a hidden selected skin behind to
-- influence renderer placement on desktop or with a controller attached.
self:selectSkin(cfg.enabled and cfg.skin or nil)
self.layouts = cfg.layouts
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
@@ -335,7 +337,10 @@ function TouchControls:visible()
local art = TouchSkin.active ~= nil or self.img ~= nil
if self.preview then return art end
if self.enabled == false or not art then return false end
if TouchSkin.active and TouchSkin.decorativeOnly() then return true end
-- A selected skin is also a desktop/TV bezel. Input remains gated in
-- touchpressed, but the artwork must not disappear when a controller is
-- connected or the platform is not touch-first.
if TouchSkin.active then return true end
return self.active and not self.controllerHidden
end
@@ -778,12 +783,19 @@ local function drawIcon(img, zone, pressed, alphaMul)
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
end
local function drawStretched(img, x, y, w, h, alpha)
local function drawCovered(img, x, y, w, h, alpha)
if not img or alpha <= 0 then return end
local iw, ih = img:getWidth(), img:getHeight()
if iw <= 0 or ih <= 0 then return end
-- Cover the assigned box with one uniform scale and crop the excess. The
-- old independent X/Y scale made portrait art visibly squash on wide
-- displays (and vice versa).
local s = math.max(w / iw, h / ih)
local dw, dh = iw * s, ih * s
love.graphics.setColor(1, 1, 1, math.min(1, alpha))
love.graphics.draw(img, x, y, 0, w / iw, h / ih)
love.graphics.setScissor(x, y, w, h)
love.graphics.draw(img, x + (w - dw) * 0.5, y + (h - dh) * 0.5, 0, s, s)
love.graphics.setScissor()
end
function TouchControls:drawSkin(alphaMul)
@@ -795,7 +807,7 @@ function TouchControls:drawSkin(alphaMul)
love.graphics.push("all")
love.graphics.origin()
drawStretched(page.image, bx, by, bw, bh, opacity)
drawCovered(page.image, bx, by, bw, bh, opacity)
local pressed = {}
for _, touch in pairs(self.touches or {}) do
@@ -810,7 +822,7 @@ function TouchControls:drawSkin(alphaMul)
TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy)
local alpha = opacity
if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end
drawStretched(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
drawCovered(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
end
end
+69 -9
View File
@@ -294,7 +294,6 @@ function TouchSkin.parse(text)
page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0),
w = num(vp[3], 1), h = num(vp[4], 1) }
page.viewportFill = toBool(kv[p .. "_viewport_fill"])
page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
end
page.pixelCoords = not page.normalized
@@ -447,7 +446,6 @@ function TouchSkin.parseNative(text)
page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0),
w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) }
page.viewportFill = raw.viewport.fill == true
page.viewportExpand = raw.viewport.expand == true
end
for _, c in ipairs(raw.controls or {}) do
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
@@ -526,7 +524,6 @@ function TouchSkin.toNative(skin)
x = page.viewport.x, y = page.viewport.y,
w = page.viewport.w, h = page.viewport.h,
fill = page.viewportFill or nil,
expand = page.viewportExpand or nil,
}
end
for _, ctl in ipairs(page.controls or {}) do
@@ -636,6 +633,19 @@ local function applyPixelScale(page)
return true
end
-- An overlay image is its own design canvas. Older RetroArch cfg files often
-- omit `aspect_ratio`; reading the dimensions here keeps that legacy art and
-- all of its normalized controls on the same uniform scale.
function TouchSkin.applyImageAspect(page)
if not page or page.aspectFromCfg or not page.image
or not page.image.getDimensions then return false end
local iw, ih = page.image:getDimensions()
if not iw or not ih or iw <= 0 or ih <= 0 then return false end
page.aspect = iw / ih
page.aspectFromImage = true
return true
end
function TouchSkin.load(root, id)
local cfgPath, format, prefix = findConfig(root)
if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end
@@ -665,6 +675,7 @@ function TouchSkin.load(root, id)
elseif page.pdfPath then
rasterizePdfPage(page, root)
end
TouchSkin.applyImageAspect(page)
if not applyPixelScale(page) then
return nil, "could not read " .. tostring(page.imagePath)
.. ", which " .. page.name .. " measures its coordinates against"
@@ -771,6 +782,28 @@ function TouchSkin.find(id)
return nil
end
-- Remove only a user-installed skin. Bundled skins are shipped with the
-- game and intentionally have no delete affordance.
function TouchSkin.remove(id)
local entry = TouchSkin.find(id)
if not entry then return nil, "no skin " .. tostring(id) end
if entry.source ~= "user" then return nil, "bundled skins cannot be deleted" end
if not (love and love.filesystem and love.filesystem.remove) then
return nil, "no writable filesystem"
end
local function removeTree(path)
if isDir(path) and love.filesystem.getDirectoryItems then
for _, name in ipairs(love.filesystem.getDirectoryItems(path)) do
local ok, err = removeTree(path .. "/" .. name)
if not ok then return nil, err end
end
end
local ok, err = love.filesystem.remove(path)
return ok and true or nil, err
end
return removeTree(entry.archive or (TouchSkin.USER_ROOT .. "/" .. entry.id))
end
function TouchSkin.assetPaths(skin)
local out, seen = {}, {}
local function add(rel)
@@ -886,7 +919,6 @@ function TouchSkin.toRetroArchConfig(skin)
if page.viewport then
out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport)
if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end
if page.viewportExpand then out[#out + 1] = p .. "_viewport_expand = true" end
end
local controls = {}
for _, ctl in ipairs(page.controls or {}) do
@@ -1284,7 +1316,7 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
-- full_screen means "relative to the window, not the game viewport".
-- When the cfg also names an aspect_ratio, that window is then fitted
-- to the overlay's design aspect so buttons do not stretch. #1503
local fit = ((not page.fullScreen) or page.aspectFromCfg)
local fit = ((not page.fullScreen) or page.aspectFromCfg or page.aspectFromImage)
and page.aspect and page.aspect > 0 and h > 0
if fit then
local displayAspect = w / h
@@ -1306,6 +1338,11 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
by = oy + extra
elseif anchor == "top" then
by = oy
elseif page.aspect < 1 then
-- A portrait bezel with controls is a controller deck. On an
-- unusually tall display, pin the deck to the lower edge and leave
-- the additional room for the game above it.
by = oy + extra
else
by = oy + extra * 0.5
end
@@ -1359,8 +1396,10 @@ function TouchSkin.decorativeOnly()
end
function TouchSkin.drawable()
if not TouchSkin.active then return false end
return TouchSkin.overlayLive or TouchSkin.decorativeOnly()
-- A selected skin is a presentation choice, not a mobile-only input mode.
-- Its artwork and screen placement therefore belong on every platform;
-- `overlayLive` still controls whether touch input is available.
return TouchSkin.active ~= nil
end
function TouchSkin.hasViewport()
@@ -1391,6 +1430,16 @@ local function remainderBox(ox, oy, w, h, bx, by, bw, bh)
return best[1], best[2], best[3], best[4]
end
-- The deck box pinned to the lower edge (see pageBox) leaves room above it
-- that belongs to the game, so a screen rect flush with the top of the deck
-- grows into it instead of showing a black band.
local function deckHeadroom(y, vh, by, bh, oy, h)
if by <= oy + 0.5 then return y, vh end
if by + bh < oy + h - 0.5 then return y, vh end
if y - by > math.max(2, bh * 0.01) then return y, vh end
return oy, vh + (y - oy)
end
function TouchSkin.pageViewport(page, w, h, ox, oy)
if not page then return nil end
ox, oy = ox or 0, oy or 0
@@ -1400,16 +1449,27 @@ function TouchSkin.pageViewport(page, w, h, ox, oy)
local x, y = bx + v.x * bw, by + v.y * bh
local vw, vh = v.w * bw, v.h * bh
if vw <= 0 or vh <= 0 then return nil end
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
y, vh = deckHeadroom(y, vh, by, bh, oy, h)
return x, y, vw, vh, page.viewportFill == true
end
if page.screenFit == "remainder" then
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
if not x then return nil end
return x, y, vw, vh, false, false
return x, y, vw, vh, false
end
return nil
end
-- Centre of the page's screen cutout. The renderer fits the 160x144
-- picture into that rect; this helper is for the studio preview.
function TouchSkin.screenCenter(w, h, ox, oy, page)
page = page or TouchSkin.page()
if not page then return nil end
local x, y, vw, vh = TouchSkin.pageViewport(page, w, h, ox, oy)
if not x then return nil end
return x + vw * 0.5, y + vh * 0.5
end
function TouchSkin.viewport(w, h, ox, oy)
local page = TouchSkin.page()
if not page or not TouchSkin.drawable() then return nil end
+503
View File
@@ -0,0 +1,503 @@
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1 and
-- ../pokecrystal/engine/events/battle_tower/rules.asm:27.
local Breeding = require("src.core.gen2.Breeding")
local HallOfFame = require("src.core.gen2.HallOfFame")
local Mon = require("src.battle.gen2.Mon")
local Save = require("src.core.gen2.Save")
local BattleTower = {}
-- ../pokecrystal/constants/battle_tower_constants.asm:1-7
BattleTower.PARTY_LENGTH = 3
BattleTower.STREAK_LENGTH = 7
BattleTower.NUM_UNIQUE_MON = 21
BattleTower.NUM_UNIQUE_TRAINERS = 70
BattleTower.TRAINERDATALENGTH = 36
-- ../pokecrystal/constants/battle_tower_constants.asm:47 GS_BALL_AVAILABLE
BattleTower.GS_BALL_AVAILABLE = 0x0b
-- ../pokecrystal/constants/battle_tower_constants.asm:55-61
BattleTower.NO_CHALLENGE = 0
BattleTower.SAVED_AND_LEFT = 1
BattleTower.CHALLENGE_IN_PROGRESS = 2
BattleTower.WON_CHALLENGE = 3
BattleTower.RECEIVED_REWARD = 4
-- ../pokecrystal/constants/battle_tower_constants.asm:63-67
BattleTower.REWARD_QUANTITY = 5
BattleTower.MIN_REWARD = "HP_UP"
BattleTower.MAX_REWARD = "CALCIUM"
BattleTower.SKIPPED_REWARD = "LUCKY_PUNCH"
BattleTower.FALLBACK_REWARD = "POTION"
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1492 bit 0,
-- :978-1008 bit 1 of sBattleTowerSaveFileFlags.
BattleTower.SAVEFILE_REGISTERED = 1
BattleTower.SAVEFILE_EXPLANATION = 2
-- ../pokecrystal/constants/battle_tower_constants.asm:11-43
BattleTower.ACTIONS = {
CHECK_EXPLANATION_READ = 0,
SET_EXPLANATION_READ = 1,
GET_CHALLENGE_STATE = 2,
SAVE_AND_QUIT = 3,
CHALLENGECANCELED = 4,
ACTION_05 = 5,
ACTION_06 = 6,
SAVELEVELGROUP = 7,
LOADLEVELGROUP = 8,
CHECKSAVEFILEISYOURS = 9,
ACTION_0A = 10,
GSBALL = 11,
ACTION_0C = 12,
ACTION_0D = 13,
EGGTICKET = 14,
ACTION_0F = 15,
ACTION_10 = 16,
ACTION_11 = 17,
ACTION_12 = 18,
ACTION_13 = 19,
ACTION_14 = 20,
ACTION_15 = 21,
ACTION_16 = 22,
ACTION_17 = 23,
LEVEL_CHECK = 24,
UBERS_CHECK = 25,
RESETDATA = 26,
GIVEREWARD = 27,
ACTION_1C = 28,
ACTION_1D = 29,
CHOOSEREWARD = 30,
SAVEOPTIONS = 31,
}
BattleTower.NUM_ACTIONS = 32
-- ../pokecrystal/ram/wram.asm:1703 wNrOfBeatenBattleTowerTrainers, which
-- ../pokecrystal/maps/BattleTowerBattleRoom.asm:38 reads back with `readmem`.
BattleTower.WRAM_NR_BEATEN = 0xcf64
-- ../pokecrystal/constants/battle_tower_constants.asm:49-53 `battletowertext`.
BattleTower.TEXT_INTRO = 1
BattleTower.TEXT_WIN = 2
BattleTower.TEXT_LOSS = 3
-- ../pokecrystal/engine/events/battle_tower/rules.asm:39-55, in the order
-- BattleTower_ExecuteJumptable prints them.
BattleTower.RULE_HEADER_TEXT = "_ExcuseMeYoureNotReadyText"
BattleTower.RULE_FAIL_TEXTS = {
"_OnlyThreeMonMayBeEnteredText",
"_TheMonMustAllBeDifferentKindsText",
"_TheMonMustNotHoldTheSameItemsText",
"_YouCantTakeAnEggText",
}
-- ../pokecrystal/engine/events/battle_tower/rules.asm:61-68
BattleTower.RULE_TAIL_TEXT = "_BattleTowerReturnWhenReadyText"
-- ../pokecrystal/engine/events/battle_tower/rules.asm:28-31 wStringBuffer2.
BattleTower.RULE_PARTY_COUNT_TEXT = "3"
-- ../pokecrystal/mobile/mobile_46.asm:3936-3958 BattleTower_UbersCheck.
BattleTower.UBERS = {
MEWTWO = true, MEW = true, LUGIA = true, HO_OH = true, CELEBI = true,
}
BattleTower.UBER_MIN_LEVEL = 70
-- ../pokecrystal/mobile/mobile_46.asm:3869-3887 Strings_L10ToL100 and
-- Strings_Ll0ToL40.
BattleTower.MAX_LEVEL_GROUP = 10
BattleTower.PRE_HOF_LEVEL_GROUPS = 4
local function counter(value)
return math.max(0, math.floor(tonumber(value) or 0))
end
-- ../pokecrystal/ram/sram.asm:147-172, on top of Save.battleTowerState.
function BattleTower.state(save)
local tower = Save.battleTowerState(save)
-- ../pokecrystal/ram/sram.asm:156 sBTChoiceOfLevelGroup
tower.levelGroup = counter(tower.levelGroup)
-- ../pokecrystal/ram/sram.asm:159 sBattleTowerSaveFileFlags
tower.saveFileFlags = counter(tower.saveFileFlags) % 256
-- ../pokecrystal/ram/sram.asm:158 sBTTrainers, $ff for an unused slot
if type(tower.trainers) ~= "table" then tower.trainers = {} end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1449-1469 s5_aa8d
if tower.reentry == nil then tower.reentry = false end
return tower
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1483 and
-- :984-989: `and 1` / `and 2`, so the answer is the MASKED byte, not a boolean.
function BattleTower.saveFileFlag(save, mask)
local tower = BattleTower.state(save)
return (math.floor(tower.saveFileFlags / mask) % 2 == 1) and mask or 0
end
function BattleTower.setSaveFileFlag(save, mask)
local tower = BattleTower.state(save)
if math.floor(tower.saveFileFlags / mask) % 2 == 0 then
tower.saveFileFlags = tower.saveFileFlags + mask
end
return tower.saveFileFlags
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:890-904
function BattleTower.resetTrainers(save)
local tower = BattleTower.state(save)
tower.trainers = {}
tower.streak = 0
return tower
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1016-1022
function BattleTower.setChallengeState(save, state)
local tower = BattleTower.state(save)
tower.challenge = counter(state)
return tower.challenge
end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:206-211
function BattleTower.partyCountOk(party)
return #party == BattleTower.PARTY_LENGTH
end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:218-277
-- CheckPartyValueIsUnique: eggs are skipped on both sides and a zero value
-- never collides, because `ld a, [hl] / and a / jr z, .next` drops it first.
local function valuesUnique(party, get)
local count = #party
for i = 1, count - 1 do
local mon = party[i]
if not Breeding.isEgg(mon) then
local value = get(mon)
if value ~= nil and value ~= 0 then
for j = i + 1, count do
local other = party[j]
if not Breeding.isEgg(other) and get(other) == value then
return false
end
end
end
end
end
return true
end
local function speciesOf(mon) return mon and mon.species or nil end
local function itemOf(mon) return mon and mon.item or nil end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:213-216
function BattleTower.speciesUnique(party)
return valuesUnique(party, speciesOf)
end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:279-282
function BattleTower.itemsUnique(party)
return valuesUnique(party, itemOf)
end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:284-299
function BattleTower.partyHasEgg(party)
for _, mon in ipairs(party) do
if Breeding.isEgg(mon) then return true end
end
return false
end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:27-37 and :94-103: every
-- check runs, the first failure prints the header first, and a tail line
-- follows any failure at all. Returns the text labels in printing order.
function BattleTower.checkRules(party)
party = party or {}
local failed = {
not BattleTower.partyCountOk(party),
not BattleTower.speciesUnique(party),
not BattleTower.itemsUnique(party),
BattleTower.partyHasEgg(party),
}
local lines, any = {}, false
for index = 1, #BattleTower.RULE_FAIL_TEXTS do
if failed[index] then
if not any then
any = true
lines[#lines + 1] = BattleTower.RULE_HEADER_TEXT
end
lines[#lines + 1] = BattleTower.RULE_FAIL_TEXTS[index]
end
end
if any then lines[#lines + 1] = BattleTower.RULE_TAIL_TEXT end
return lines, any
end
-- ../pokecrystal/mobile/mobile_46.asm:1156-1166: the Hall of Fame flag is what
-- opens rooms above L40, and the last row is always CANCEL.
function BattleTower.levelGroupCount(save)
if HallOfFame.hasEntered(save) then return BattleTower.MAX_LEVEL_GROUP end
return BattleTower.PRE_HOF_LEVEL_GROUPS
end
function BattleTower.levelGroupRows(save)
local rows = {}
for group = 1, BattleTower.levelGroupCount(save) do
rows[group] = { group = group, level = group * 10 }
end
return rows
end
-- ../pokecrystal/mobile/mobile_46.asm:1264-1267 `dec a / and $fe / srl a`, the
-- BATTLE ROOM pair the hallway walks to (../pokecrystal/maps/BattleTowerHallway.asm:40-47).
function BattleTower.roomOf(group)
return math.floor((math.max(1, counter(group)) - 1) / 2)
end
-- ../pokecrystal/mobile/mobile_46.asm:3892-3934 BattleTower_LevelCheck
function BattleTower.levelCheck(party, group)
local cap = counter(group) * 10
for _, mon in ipairs(party or {}) do
if (tonumber(mon.level) or 0) > cap then return true end
end
return false
end
-- ../pokecrystal/mobile/mobile_46.asm:3936-3989 BattleTower_UbersCheck: below
-- the L70 rooms an uber under L70 is refused, and its name goes in wcd49.
function BattleTower.ubersCheck(party, group)
if counter(group) >= BattleTower.UBER_MIN_LEVEL / 10 then return nil end
for _, mon in ipairs(party or {}) do
if BattleTower.UBERS[mon.species]
and (tonumber(mon.level) or 0) < BattleTower.UBER_MIN_LEVEL then
return mon.species
end
end
return nil
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:955-976: a
-- `maskbits` roll over HP_UP..CALCIUM that folds the overshoot back to the
-- bottom of the range and rerolls LUCKY_PUNCH.
function BattleTower.rollReward(order, random)
if type(order) ~= "table" then return nil end
local low, high
for index, name in pairs(order) do
if name == BattleTower.MIN_REWARD then low = index end
if name == BattleTower.MAX_REWARD then high = index end
end
if not (low and high) then return nil end
local span = high - low + 1
local mask = 1
while mask < span do mask = mask * 2 end
for _ = 1, 64 do
local roll = random(mask) % mask
if roll >= span then roll = roll - span end
local item = order[low + roll]
if item ~= BattleTower.SKIPPED_REWARD then return item end
end
return order[low]
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:906-933: five of
-- the reward only fit when the ITEM pocket has a free slot, or already holds
-- the reward with room for five more. Otherwise the desk hands over a POTION,
-- which the script reads as "your PACK is stuffed full".
function BattleTower.rewardFits(slots, capacity, held)
if counter(slots) < counter(capacity) then return true end
if held == nil then return false end
return counter(held) < 99 - BattleTower.REWARD_QUANTITY + 1
end
-- The opponent draw, all of it ../pokecrystal/engine/events/battle_tower/
-- load_trainer.asm, over RomExtractorGen2's Crystal-only `battleTower` block.
-- data/generated/trainers.lua `battleTower`, absent on Gold and Silver.
function BattleTower.roster(data)
local trainers = data and (data.gen2Trainers or data.trainers)
local roster = trainers and trainers.battleTower
if type(roster) ~= "table" then return nil end
if type(roster.trainers) ~= "table" or type(roster.groups) ~= "table" then
return nil
end
return roster
end
-- ../pokecrystal/ram/sram.asm:162-173 sBTMonOfTrainers: the last team's three
-- species and the one before that's, which the draw refuses to repeat.
function BattleTower.prevTeams(save)
local tower = BattleTower.state(save)
local teams = tower.prevTeams
if type(teams) ~= "table" then
teams = {}
tower.prevTeams = teams
end
if type(teams.prev) ~= "table" then teams.prev = {} end
if type(teams.prevPrev) ~= "table" then teams.prevPrev = {} end
return teams
end
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:104-105 reads the
-- room back as `ld a, [wBTChoiceOfLvlGroup] / dec a`, so group 0 indexes
-- BEFORE the table; battle_tower.asm:1129-1141 is what can only save 1..10.
function BattleTower.opponentGroup(save, roster)
local groups = (roster and roster.levelGroups) or BattleTower.MAX_LEVEL_GROUP
local group = counter(BattleTower.state(save).levelGroup)
if group < 1 then return 1 end
if group > groups then return groups end
return group
end
-- load_trainer.asm:24-38 and :101-166 reroll until the roll passes; one draw
-- from the survivors is the same distribution and cannot spin. An empty
-- survivor set is where the cart's own loop would hang, so it draws unfiltered.
local function drawFiltered(count, random, accept)
local pool = {}
for index = 1, count do
if accept(index) then pool[#pool + 1] = index end
end
if #pool == 0 then return random(count) end
return pool[random(#pool)]
end
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:22-60. The roll
-- is refused while it names anybody already in sBTTrainers, and the winner is
-- written into the slot sNrOfBeatenBattleTowerTrainers points at.
function BattleTower.chooseTrainer(save, roster, random)
local tower = BattleTower.state(save)
-- :29-37, the ceiling read out of the cart: Crystal 1.0 masks with
-- BATTLETOWER_NUM_UNIQUE_MON and can only ever draw the first 21 rows.
local ceiling = counter(roster.sampleTrainers)
if ceiling < 1 or ceiling > #roster.trainers then ceiling = #roster.trainers end
local seen = {}
for slot = 1, BattleTower.STREAK_LENGTH do
local held = tonumber(tower.trainers[slot])
if held then seen[held] = true end
end
local index = drawFiltered(ceiling, random, function(row)
return not seen[row - 1]
end)
tower.trainers[math.min(counter(tower.streak), BattleTower.STREAK_LENGTH - 1)
+ 1] = index - 1
return roster.trainers[index]
end
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:94-208. Three
-- draws out of the chosen level group, each refusing a species this team
-- already holds, an ITEM this team already holds, and any species from the
-- last two teams. wBT_OTTrainer was zero-filled and its three item slots set
-- to $ff first (:7-17), which is why an unfilled slot collides with nothing.
function BattleTower.chooseTeam(save, roster, group, random)
local rows = roster.groups[group] or {}
local teams = BattleTower.prevTeams(save)
local picked, species, items = {}, {}, {}
for _ = 1, BattleTower.PARTY_LENGTH do
local index = drawFiltered(#rows, random, function(row)
local mon = rows[row]
if not mon then return false end
if species[mon.species] then return false end
if mon.item ~= nil and items[mon.item] then return false end
for _, seen in ipairs(teams.prev) do
if seen == mon.species then return false end
end
for _, seen in ipairs(teams.prevPrev) do
if seen == mon.species then return false end
end
return true
end)
local mon = rows[index]
if not mon then break end
picked[#picked + 1] = mon
species[mon.species] = true
if mon.item ~= nil then items[mon.item] = true end
end
-- :195-206, after all three: this team becomes sBTMonPrevTrainer and the
-- one it displaces becomes sBTMonPrevPrevTrainer.
local prev = {}
for slot, mon in ipairs(picked) do prev[slot] = mon.species end
teams.prevPrev = teams.prev
teams.prev = prev
return picked
end
-- The whole of `special LoadOpponentTrainerAndPokemon`, as one record. The
-- cart keeps it in wBT_OTTrainer, which is WRAM bank 3 and is NOT saved --
-- only the sBTTrainers slot and the two previous teams this walk writes are.
function BattleTower.drawOpponent(data, save, random)
local roster = BattleTower.roster(data)
if not roster then return nil end
local group = BattleTower.opponentGroup(save, roster)
local trainer = BattleTower.chooseTrainer(save, roster, random)
if not trainer then return nil end
local rows = BattleTower.chooseTeam(save, roster, group, random)
return {
index = trainer.index,
name = trainer.name,
class = trainer.class,
classId = trainer.classId,
sprite = roster.classSprites and roster.classSprites[trainer.classId],
group = group,
rows = rows,
}
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:549-570
-- CopyBTTrainer_FromBT_OT_TowBT_OTTemp, which is the first thing
-- ReadBTTrainerParty does: the challenge is now in progress and the streak
-- counter steps BEFORE the battle, which is why beating the seventh opponent
-- leaves the counter on BATTLETOWER_STREAK_LENGTH.
function BattleTower.beginBattle(save)
local tower = BattleTower.state(save)
tower.challenge = BattleTower.CHALLENGE_IN_PROGRESS
tower.streak = counter(tower.streak) + 1
return tower.streak
end
-- ReadBTTrainerParty's .otpartymon_loop (battle_tower.asm:349-376) copies the
-- whole party_struct into wOTPartyMon, so nothing here is rolled: the stored
-- stats go on top of Mon.new's, which agree with them anyway.
-- The ROM nicknames never show: load_trainer.asm:171-190 overwrites each one
-- with GetPokemonName, so the rows carry none.
function BattleTower.battleParty(data, rows)
local party = {}
for _, row in ipairs(rows or {}) do
local moves = nil
if row.moves and #row.moves > 0 then
moves = {}
for slot, id in ipairs(row.moves) do
local def = data and data.moves and data.moves[id]
local max = (def and def.pp) or 0
moves[slot] = {
id = id,
pp = (row.pp and row.pp[slot]) or max,
maxPp = max,
}
end
end
local dvs = row.dvs or {}
local statExp = row.statExp or {}
local mon = Mon.new(data, row.species, row.level, {
moves = moves,
item = row.item,
dvs = { attack = dvs.attack, defense = dvs.defense,
speed = dvs.speed, special = dvs.special },
statExp = { hp = statExp.hp, attack = statExp.attack,
defense = statExp.defense, speed = statExp.speed,
special = statExp.special },
happiness = row.happiness,
})
if mon then
if row.stats then
mon.stats = {
hp = row.stats.hp, attack = row.stats.attack,
defense = row.stats.defense, speed = row.stats.speed,
specialAttack = row.stats.specialAttack,
specialDefense = row.stats.specialDefense,
}
end
mon.maxHp = row.maxHp or mon.maxHp
mon.hp = row.hp or mon.maxHp
mon.experience = row.experience or mon.experience
party[#party + 1] = mon
end
end
return party
end
return BattleTower
+12 -1
View File
@@ -109,6 +109,17 @@ function Boxes.restorePP(mon)
end
end
-- box_struct has no MON_STATUS and no MON_HP (macros/ram.asm:7-26), so
-- CalcTempmonStats refills a BOXMON from MAXHP (engine/pokemon/tempmon.asm:56-83).
function Boxes.enterBox(mon)
if not mon then return mon end
Boxes.restorePP(mon)
mon.status = nil
mon.statusTurns = nil
mon.hp = mon.isEgg and 0 or (mon.maxHp or mon.hp)
return mon
end
function Boxes.deposit(save, partyIndex, boxIndex)
local ok, reason = Boxes.canDeposit(save, partyIndex, boxIndex)
if not ok then return false, reason end
@@ -122,7 +133,7 @@ function Boxes.deposit(save, partyIndex, boxIndex)
box[#box + 1] = mon
-- SendGetMonIntoFromBox's PC_DEPOSIT arm ends in RestorePPOfDepositedPokemon
-- (engine/pokemon/move_mon.asm:633-635, :696-700).
Boxes.restorePP(mon)
Boxes.enterBox(mon)
return true, mon
end
+20 -1
View File
@@ -883,6 +883,11 @@ function Breeding.withdraw(data, save, which)
rebuilt.status = nil
for _, move in ipairs(rebuilt.moves) do move.pp = move.maxPp end
rebuilt.caughtLevel = mon.caughtLevel or rebuilt.caughtLevel
-- RetrieveBreedmon copies the stored struct back whole, MON_CAUGHTDATA
-- included -- engine/pokemon/move_mon.asm:805.
rebuilt.caughtTime = mon.caughtTime
rebuilt.caughtLocation = mon.caughtLocation
rebuilt.caughtByGender = mon.caughtByGender
rebuilt.ot, rebuilt.otId = mon.ot, mon.otId
-- CalcExpAtLevel, which is the experience loss.
rebuilt.experience = Mon.experienceForLevel(growthOf(data, def), newLevel)
@@ -1077,7 +1082,9 @@ end
-- Returns the new record plus a table of side effects the caller owes:
-- { species = , togepi = bool } -- SetSeenAndCaughtMon, and the
-- EVENT_TOGEPI_HATCHED flag the ASM sets by hand for exactly one species.
function Breeding.hatch(data, save, index, nickname)
-- `where` is the hatch site for SetEggMonCaughtData: landmark, timeOfDay and
-- playerGender -- engine/pokemon/breeding.asm:228.
function Breeding.hatch(data, save, index, nickname, where)
local party = (save and save.party) or {}
local egg = party[index]
if not Breeding.isEgg(egg) then return nil end
@@ -1097,6 +1104,18 @@ function Breeding.hatch(data, save, index, nickname)
hatched.ot = egg.ot or (save.player and save.player.name)
hatched.otId = egg.otId or (save.player and save.player.id)
hatched.caughtLevel = egg.level or Breeding.EGG_LEVEL
-- SetEggMonCaughtData swaps wCurPartyLevel for CAUGHT_EGG_LEVEL around the
-- shared setter -- engine/pokemon/caught_data.asm:235-246.
if Mon.hasCaughtData(save and save.version) then
where = where or {}
Mon.setCaughtData(hatched, {
level = Mon.CAUGHT_EGG_LEVEL,
timeOfDay = where.timeOfDay,
landmark = where.landmark,
playerGender = where.playerGender
or (save.player and save.player.gender),
})
end
party[index] = hatched
Breeding.markPokedex(save, egg.species)
+3 -1
View File
@@ -803,7 +803,9 @@ function BugContest.collectCaughtMon(save, partySize, boxes)
end
boxes = boxes or require("src.core.gen2.Boxes")
local box = boxes.box(save, save.currentBox or 1)
if box then box[#box + 1] = mon end
-- .TryAddToBox copies BOXMON_STRUCT_LENGTH and tails into
-- RestorePPOfDepositedPokemon (engine/pokemon/caught_nickname.asm:72-90).
if box then box[#box + 1] = boxes.enterBox(mon) end
return BugContest.BOXED_MON, mon
end
+6
View File
@@ -357,6 +357,7 @@ Evolution.MON_FIELDS = {
experience = true, dvs = true, stats = true, hp = true, maxHp = true,
types = true, moves = true, item = true, status = true, happiness = true,
caughtLevel = true, shiny = true, gender = true,
caughtTime = true, caughtLocation = true, caughtByGender = true,
}
-- Turn `mon` into `entry.into`. Returns the NEW record; the caller writes it
@@ -410,6 +411,11 @@ function Evolution.apply(data, mon, entry)
evolved.experience = mon.experience
evolved.status = mon.status
evolved.caughtLevel = mon.caughtLevel
-- Nothing between GetBaseData and the PARTYMON_STRUCT_LENGTH copy back
-- touches MON_CAUGHTDATA -- engine/pokemon/evolve.asm:291-293.
evolved.caughtTime = mon.caughtTime
evolved.caughtLocation = mon.caughtLocation
evolved.caughtByGender = mon.caughtByGender
-- Anything a future field adds to a party record (mail, pokerus) rides along
-- rather than being silently dropped. Only fields Mon.new does NOT own may
-- be carried: copying `nickname` back would undo
+22 -1
View File
@@ -86,8 +86,11 @@ Happiness.EVENT = {
ENERGYROOT = 16, -- 10
REVIVALHERB = 17, -- 11
GROOMING = 18, -- 12
-- Crystal only; Gold's enum stops at GROOMING
-- (pokegold constants/pokemon_data_constants.asm:205).
GAINLEVELATHOME = 19, -- 13
}
Happiness.NUM_EVENTS = 18
Happiness.NUM_EVENTS = 19
-- data/events/happiness_changes.asm, transcribed row for row. The three
-- columns are the three tiers below, in order.
@@ -110,6 +113,7 @@ Happiness.CHANGES = {
{ -10, -10, -15 }, -- 10 Used Energy Root (bitter)
{ -15, -15, -20 }, -- 11 Used Revival Herb (bitter)
{ 3, 3, 1 }, -- 12 Grooming
{ 10, 6, 4 }, -- 13 Gained a level where it was caught (Crystal)
}
-- Which of HappinessChanges' three columns a CURRENT value reads. The cart
@@ -174,6 +178,23 @@ function Happiness.change(mon, event)
return value
end
-- LevelUpHappinessMod: the caught location masked with CAUGHT_LOCATION_MASK
-- against the current landmark -- engine/pokemon/level_up_happiness.asm:1-20.
function Happiness.levelUpEvent(mon, landmark)
local caught = type(mon) == "table" and tonumber(mon.caughtLocation) or nil
landmark = tonumber(landmark)
if not (caught and landmark) then return "GAINLEVEL" end
if math.floor(caught) % 0x80 ~= math.floor(landmark) % 0x80 then
return "GAINLEVEL"
end
return "GAINLEVELATHOME"
end
-- The `callfar ChangeHappiness` that follows -- level_up_happiness.asm:19.
function Happiness.levelUp(mon, landmark)
return Happiness.change(mon, Happiness.levelUpEvent(mon, landmark))
end
-- The same event across a party, which is how the Gym Leader award is written
-- out longhand in engine/battle/core.asm InitEnemyTrainer:
--
+2
View File
@@ -240,6 +240,8 @@ local function rareCandy(mon, data)
used = true,
level = newLevel,
learned = learned,
-- data/text/common_1.asm:86
sfx = "Sfx_DexFanfare5079",
text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel),
}
end
+128 -18
View File
@@ -1,5 +1,5 @@
-- The two pieces of Gen 2 world state that override where a wild mon comes
-- from: the three roaming legendaries, and swarms.
-- from: the roaming legendaries, and swarms.
--
-- Both live in one module because they are the same KIND of thing -- a
-- persistent record that sits in front of a map's own encounter table -- and
@@ -26,6 +26,7 @@
-- pair InitRoamMons never writes and BattleEnd_HandleRoamMons writes when a
-- beast is caught or beaten -- is nil.
local GameVersion = require("src.core.GameVersion")
local Mon = require("src.battle.gen2.Mon")
local Runtime = require("src.mods.Runtime")
@@ -37,7 +38,7 @@ local Roamers = {}
-- map connection or a door triggers) and JumpRoamMons (the scatter a fly or a
-- teleport triggers) -- so a tracker mod sees every hop.
--
-- index the roamer slot, 1 Raikou / 2 Entei / 3 Suicune
-- index the roamer slot, 1 Raikou / 2 Entei / 3 Suicune (Gold only)
-- slot that roamer record, already carrying the new map
-- species the beast's species id
-- from the map id it left
@@ -68,9 +69,31 @@ Roamers.SPECIES = {
{ species = "ENTEI", level = 40, map = "ROUTE_37" },
{ species = "SUICUNE", level = 40, map = "ROUTE_38" },
}
Roamers.COUNT = 3
Roamers.LEVEL = 40
-- pokecrystal/engine/overworld/wildmons.asm:493 seeds Raikou and Entei only,
-- so the roster is read out of the cache when the cache carries one.
local function startMapFor(species)
for _, row in ipairs(Roamers.SPECIES) do
if row.species == species then return row.map end
end
return nil
end
function Roamers.roster(encounters)
local extracted = encounters and encounters.roamMons
if type(extracted) ~= "table" or #extracted == 0 then return Roamers.SPECIES end
local rows = {}
for _, row in ipairs(extracted) do
rows[#rows + 1] = {
species = row.species,
level = row.level or Roamers.LEVEL,
map = row.map or startMapFor(row.species),
}
end
return rows
end
-- data/wild/roammon_maps.asm, entry for entry and in order. The order matters
-- twice over: `.Update` picks a connection by a two-bit index into the list, so
-- shuffling one row changes which route a beast walks to, and JumpRoamMon picks
@@ -185,7 +208,7 @@ end
-- The save record
--------------------------------------------------------------------------
--
-- save.roamers is a three-slot array in the order above, written first by the
-- save.roamers is a slot array in the roster order above, written first by the
-- InitRoamMons special (src/script/gen2/Specials.lua) when the Burned Tower
-- basement script fires. Each slot:
--
@@ -215,13 +238,15 @@ function Roamers.slot(save, index)
end
-- InitRoamMons. Safe to call twice: the Burned Tower script is behind a scene
-- flag, but a re-init would hand the player three fresh beasts, so this only
-- writes when there is nothing there.
-- flag, but a re-init would hand the player fresh beasts, so this only writes
-- when there is nothing there.
function Roamers.init(save, opts)
if type(save) ~= "table" then return nil end
if save.roamers and not (opts and opts.force) then return save.roamers end
local encounters = opts and (opts.encounters
or (opts.data and opts.data.gen2Encounters))
local list = {}
for _, row in ipairs(Roamers.SPECIES) do
for _, row in ipairs(Roamers.roster(encounters)) do
list[#list + 1] = {
species = row.species,
level = row.level,
@@ -413,7 +438,23 @@ end
-- -- the roaming battle is one attack long unless it is trapped. The other
-- two lists are the same routine's 50% and 10% gates and live here so the
-- battle engine has one place to read them from.
Roamers.ALWAYS_FLEE = { RAIKOU = true, ENTEI = true, SUICUNE = true }
-- pokegold/data/wild/flee_mons.asm:34 lists Suicune; pokecrystal's:34 ends the
-- list at Entei, which is what makes maps/TinTower1F.asm:119 catchable.
Roamers.ALWAYS_FLEE_BY_ENGINE = {
gs = { RAIKOU = true, ENTEI = true, SUICUNE = true },
crystal = { RAIKOU = true, ENTEI = true },
}
function Roamers.alwaysFleeMons(versionId)
return Roamers.ALWAYS_FLEE_BY_ENGINE[GameVersion.engine(versionId)]
or Roamers.ALWAYS_FLEE_BY_ENGINE.gs
end
-- src/battle/gen2/Battle.lua:4070 reads AlwaysFleeMons by species name.
Roamers.ALWAYS_FLEE = setmetatable({}, {
__index = function(_, species) return Roamers.alwaysFleeMons()[species] end,
})
Roamers.OFTEN_FLEE = {
CUBONE = true, ARTICUNO = true, ZAPDOS = true, MOLTRES = true,
QUAGSIRE = true, DELIBIRD = true, PHANPY = true, TEDDIURSA = true,
@@ -433,29 +474,79 @@ local Swarm = {}
Roamers.Swarm = Swarm
-- The state, on the save:
-- save.swarmMap wSwarmMapGroup / wSwarmMapNumber, as a map id
-- save.swarmMaps the stored map pairs, keyed by swarm kind
-- save.swarmMap the Gold single pair, kept as the legacy alias
-- save.dailyFlags.swarm DAILYFLAGS1_SWARM_F
-- save.dailyFlags.fishingSwarm wFishingSwarmFlag (FISHSWARM_* 0/1/2)
-- save.dailyResetDay the day wDailyResetTimer was last restarted
--
-- src/world/gen2/World.lua:setSwarm and the ActivateFishingSwarm special
-- already write the first three; this module is where they are READ and where
-- they expire.
-- already write the flags and the alias; this module is where they are READ
-- and where they expire.
-- constants/script_constants.asm, ActivateFishingSwarm setval arguments.
Swarm.FISH_NONE = 0
Swarm.FISH_QWILFISH = 1
Swarm.FISH_REMORAID = 2
-- pokecrystal/constants/script_constants.asm:256-257 -- the kind byte Crystal
-- puts in front of a `swarm` map, choosing between its two stored pairs.
Swarm.KIND_ORDER = { "DUNSPARCE", "YANMA" }
Swarm.KINDS = { [0] = "DUNSPARCE", [1] = "YANMA" }
Swarm.DEFAULT_KIND = "DUNSPARCE"
local KIND_NAMES = { DUNSPARCE = true, YANMA = true }
local function kindName(kind)
if type(kind) == "number" then return Swarm.KINDS[kind] or Swarm.DEFAULT_KIND end
if KIND_NAMES[kind] then return kind end
return Swarm.DEFAULT_KIND
end
-- pokegold/engine/events/specials.asm:288-293 has the one pair, so a Gold save
-- (and every save written before this record was keyed) folds onto that key.
local function goldShape(maps)
return maps.YANMA == nil
end
function Swarm.maps(save)
if type(save) ~= "table" then return nil end
local maps = save.swarmMaps
if type(maps) ~= "table" then
maps = {}
save.swarmMaps = maps
end
if save.swarmMap ~= nil and goldShape(maps) then
maps[Swarm.DEFAULT_KIND] = save.swarmMap
end
return maps
end
local function anyMap(save)
if type(save) ~= "table" then return false end
if save.swarmMap ~= nil then return true end
local maps = save.swarmMaps
if type(maps) ~= "table" then return false end
for _, name in ipairs(Swarm.KIND_ORDER) do
if maps[name] ~= nil then return true end
end
return false
end
-- StoreSwarmMapIndices, which FALLS THROUGH into SetSwarmFlag: one command
-- writes the map pair AND the daily flag. A port that stored only the map
-- would leave the Dunsparce call live for the rest of the game, because
-- CheckSwarmFlag answers off the flag and clears the pair itself.
function Swarm.set(save, mapId)
--
-- pokecrystal/engine/events/specials.asm:290-306 picks the pair off c instead.
function Swarm.set(save, mapId, kind)
if type(save) ~= "table" then return false end
save.dailyFlags = save.dailyFlags or {}
save.dailyFlags.swarm = true
save.swarmMap = mapId
local name = kindName(kind)
local maps = Swarm.maps(save)
maps[name] = mapId
if name == Swarm.DEFAULT_KIND then save.swarmMap = mapId end
return true
end
@@ -475,9 +566,22 @@ function Swarm.active(save)
and save.dailyFlags.swarm == true
end
function Swarm.mapId(save)
function Swarm.mapId(save, kind)
if not Swarm.active(save) then return nil end
return save.swarmMap
local maps = Swarm.maps(save)
return maps and maps[kindName(kind)] or nil
end
-- pokecrystal/engine/overworld/wildmons.asm:414-451 tests Dunsparce first and
-- falls through to Yanma, so a map both are on answers Dunsparce.
function Swarm.onMap(save, mapId)
if mapId == nil or not Swarm.active(save) then return nil end
local maps = Swarm.maps(save)
if not maps then return nil end
for _, name in ipairs(Swarm.KIND_ORDER) do
if maps[name] == mapId then return name end
end
return nil
end
function Swarm.fishing(save)
@@ -493,6 +597,12 @@ function Swarm.check(save)
if type(save) ~= "table" then return 1 end
if Swarm.active(save) then return 0 end
if save.dailyFlags then save.dailyFlags.fishingSwarm = nil end
-- pokecrystal/engine/overworld/time.asm:103-112 zeroes wSwarmFlags whole,
-- which strands both of Crystal's pairs on the one daily tick.
local maps = save.swarmMaps
if type(maps) == "table" then
for _, name in ipairs(Swarm.KIND_ORDER) do maps[name] = nil end
end
save.swarmMap = nil
return 1
end
@@ -518,10 +628,10 @@ end
-- rather than needing its own timer. Returns true when the swarm ended on
-- this call.
function Swarm.timeEvents(save, day)
local hadMap = anyMap(save)
local reset = Swarm.checkDailyReset(save, day)
local hadMap = save and save.swarmMap ~= nil
Swarm.check(save)
return reset and hadMap and (save.swarmMap == nil)
return reset and hadMap and not anyMap(save)
end
-- _SwarmWildmonCheck: the swarm table is searched BEFORE the Johto/Kanto one,
@@ -538,7 +648,7 @@ end
-- lookup here falls through to the map's own list.
function Swarm.entry(save, encounters, mapId, kind)
if not encounters then return nil end
if Swarm.mapId(save) ~= mapId then return nil end
if not Swarm.onMap(save, mapId) then return nil end
local table_ = (kind == "water") and encounters.swarmWater
or encounters.swarmGrass
return table_ and table_[mapId] or nil
+90 -7
View File
@@ -133,10 +133,33 @@ local function fs()
return love.filesystem
end
-- The blank-name fallback is the first PlayerNameArray row, which differs
-- per edition -- data/player_names.asm:12-23.
function Save.defaultPlayerName(version)
return (version or GameVersion.get()) == "silver" and "SILVER" or "GOLD"
-- The first PlayerNameArray row -- pokegold data/player_names.asm:12-22, and
-- Crystal's gender-split arrays at data/player_names.asm:12-16, :31-35.
Save.DEFAULT_PLAYER_NAMES = {
gold = "GOLD",
silver = "SILVER",
crystal = "CHRIS",
}
-- FemalePlayerNameArray's first row, the `.Kris` dname NamePlayer falls back to
-- (data/player_names.asm:31-32, engine/menus/intro_menu.asm:768-781).
Save.DEFAULT_PLAYER_NAMES_FEMALE = {
crystal = "KRIS",
}
-- wPlayerGender as this save spells it (constants/ram_constants.asm:176-177).
function Save.isFemale(save)
local player = type(save) == "table" and save.player
return (player and player.gender) == "female"
end
function Save.defaultPlayerName(version, gender)
version = version or GameVersion.get()
if gender == "female" then
local female = Save.DEFAULT_PLAYER_NAMES_FEMALE[version]
if female then return female end
end
return Save.DEFAULT_PLAYER_NAMES[version] or "GOLD"
end
-- A fresh Gen 2 save. `opts` carries what the intro collected: player name,
@@ -148,10 +171,13 @@ function Save.newGame(opts)
version = GameVersion.get(),
generation = 2,
player = {
name = opts.playerName or Save.defaultPlayerName(),
name = opts.playerName
or Save.defaultPlayerName(nil, opts.gender or "male"),
-- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd
-- (engine/menus/intro_menu.asm:41-49).
id = opts.trainerId or rand(0, 65535),
-- InitCrystalData zeroes wPlayerGender before InitGender is even offered
-- (engine/menus/init_gender.asm:1-6).
gender = opts.gender or "male",
money = 3000,
coins = 0,
@@ -272,9 +298,11 @@ Save.DEFAULT_OPTIONS = {
-- uses (src/core/SaveData.lua) and they drive the same shared modules, so
-- a player's display and speed choices mean the same thing in both games.
speed = 1, -- GameSpeed.LEVELS multiplier, logic only
-- graphics performance tier: auto | high | balanced | low. "auto" is the
-- Gen 1 save's own default (src/core/SaveData.lua); same key, same module.
performance = "auto",
zoom = 0, -- Zoom offset from the window's fit scale
tilt = 0, -- Tilt.LEVELS degrees, 0 = off
gbcfx = 0, -- GBCFX ladder, 0 = off
-- COLOR: GbcPalette.MODES. "gbc" is the cart's own palettes and the
-- default -- this is a Game Boy Color game, so colour is ON out of the box
-- and the other two rungs are the deliberate step DOWN to a grey or green
@@ -283,6 +311,8 @@ Save.DEFAULT_OPTIONS = {
color = "gbc",
videoMode = "windowed",
fpsCap = 60,
-- BATTLE BG (#1709): white | black, the surround around the battle screen.
battleBg = "white",
-- VOID FILL: fade | water | trees | black. fade is each map header's own
-- border block with the dissolve across a boundary (#1418).
voidFill = "fade",
@@ -379,6 +409,49 @@ local function normalizePokerus(mons)
end
end
-- ../pokecrystal/ram/sram.asm:140 sGSBallFlag. nil is the cleared byte.
Save.GS_BALL_STATES = { have = true, given = true, used = true }
local function counter(value)
return math.max(0, math.floor(tonumber(value) or 0))
end
-- ../pokecrystal/ram/sram.asm:138 "SRAM Crystal Data", created on demand the
-- way Mail.state creates sPartyMail, so a Crystal handler can index freely.
function Save.crystalState(save)
local crystal = save.crystal or {}
save.crystal = crystal
if crystal.celebiCaught == nil then crystal.celebiCaught = false end
crystal.beasts = crystal.beasts or {}
-- ../pokecrystal/ram/wram.asm:3342 wBuenasPassword, :3343 wBlueCardBalance.
local buena = crystal.buenaPassword or {}
crystal.buenaPassword = buena
buena.prizesToday = counter(buena.prizesToday)
buena.streak = counter(buena.streak)
-- ../pokecrystal/engine/events/move_tutor.asm:1 MoveTutor.
crystal.moveTutor = crystal.moveTutor or {}
if crystal.moveTutor.used == nil then crystal.moveTutor.used = false end
-- ../pokecrystal/ram/wram.asm:3445 wUnlockedUnowns.
crystal.unownWords = crystal.unownWords or {}
return crystal
end
-- ../pokecrystal/ram/sram.asm:147 "SRAM Battle Tower". `reward` stays nil
-- until one is won, which is sBattleTowerReward's zero byte (:160).
function Save.battleTowerState(save)
local tower = save.battleTower or {}
save.battleTower = tower
-- ../pokecrystal/ram/sram.asm:155 sNrOfBeatenBattleTowerTrainers.
tower.streak = counter(tower.streak)
tower.best = counter(tower.best)
-- ../pokecrystal/ram/sram.asm:150 sBattleTowerChallengeState: 0 normal, 2 tower.
tower.challenge = counter(tower.challenge)
-- ../pokecrystal/ram/sram.asm:162 sBTMonOfTrainers.
tower.prevTeams = tower.prevTeams or {}
if tower.inChallenge == nil then tower.inChallenge = false end
return tower
end
-- Fill in anything a save (or an older save) is missing, so callers can index
-- freely. Runs on both newGame and load.
function Save.normalize(save)
@@ -390,7 +463,11 @@ function Save.normalize(save)
end
save.generation = 2
save.player = save.player or {}
save.player.name = save.player.name or Save.defaultPlayerName(save.version)
-- _ResetWRAM leaves wPlayerGender at 0, PLAYERGENDER_MALE, and Gold never
-- writes it -- constants/ram_constants.asm:177.
save.player.gender = save.player.gender or "male"
save.player.name = save.player.name
or Save.defaultPlayerName(save.version, save.player.gender)
save.player.id = save.player.id or rand(0, 65535)
save.player.money = math.max(0, math.min(save.player.money or 0, Save.MAX_MONEY))
save.player.coins = math.max(0, math.min(save.player.coins or 0, Save.MAX_COINS))
@@ -462,6 +539,12 @@ function Save.normalize(save)
save.playTime = save.playTime
or { hours = 0, minutes = 0, seconds = 0, frames = 0 }
save.rtc = save.rtc or {}
-- ../pokecrystal/ram/sram.asm:138,147: both regions are Crystal's own, so a
-- Gold or Silver file never grows either key.
if GameVersion.engine(save.version) == "crystal" then
Save.crystalState(save)
Save.battleTowerState(save)
end
-- HallOfFame.record fills in the count and the roster list, and trims a
-- roster that a corrupt file grew past NUM_HOF_TEAMS -- the same guard the
-- party gets below, for the same reason.
+3 -2
View File
@@ -249,12 +249,13 @@ function SwitchDiagnostics.probeAssets(version)
end
-- Shallow listing so we can see if the extract tree exists at all.
local roots = { "yellow", "blue", "gold", "silver", "assets",
local roots = { "yellow", "blue", "gold", "silver", "crystal", "assets",
"yellow/assets/generated",
"yellow/assets/generated/sprites", "blue/assets/generated/sprites",
"gold/assets/generated", "gold/assets/generated/sprites",
"gold/data/generated", "silver/assets/generated",
"silver/data/generated" }
"silver/data/generated", "crystal/assets/generated",
"crystal/data/generated" }
for _, dir in ipairs(roots) do
local info = filesystem.getInfo(dir)
if info and info.type == "directory" and filesystem.getDirectoryItems then
+255
View File
@@ -0,0 +1,255 @@
-- Engine-owned contract for generated ROM caches.
--
-- A cache is playable only when its versioned marker matches the ROM and every
-- required output for that version exists. Extraction writers may differ by
-- platform, but they must publish through this contract so partial staging
-- cannot look ready to the runtime.
local GameVersion = require("src.core.GameVersion")
local CacheContract = {}
CacheContract.FORMAT = "rom-cache-v10:"
CacheContract.MARKER_PATH = "rom-cache.complete"
CacheContract.REQUIRED_FILES = {
"data/generated/constants.lua",
"data/generated/maps.lua",
"data/generated/text.lua",
"data/generated/field.lua",
"data/generated/battle_anims.lua",
"assets/generated/title/pokemon_logo.png",
"assets/generated/fonts/font.png",
"assets/generated/battle/front/pikachu.png",
"assets/generated/battle/anims/move_anim_0.png",
"assets/generated/battle/anims/move_anim_1.png",
"assets/generated/audio/programs.bin",
"assets/generated/trade/game_boy.png",
}
CacheContract.VERSION_REQUIRED_FILES = {
yellow = {
"assets/generated/battle/trainers/jessie_james.png",
"assets/generated/battle/profoakb.png",
"assets/generated/pikachu/pikapic_1.png",
},
}
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = {
gold = {
"data/generated/constants.lua",
"data/generated/maps.lua",
"data/generated/roofs.lua",
"data/generated/sprites.lua",
"data/generated/scripts.lua",
"data/generated/text.lua",
-- The engine's label-keyed strings are separate from Gen 2 script text.
-- Caches made before RomExtractorGen2:extractText must be rebuilt so
-- src/core/RomText.lua does not silently fall back to built-in wording.
"data/generated/rom_text.lua",
"data/generated/pokemon.lua",
"data/generated/tilesets.lua",
"data/generated/audio.lua",
"data/generated/marts.lua",
"assets/generated/fonts/font.png",
"assets/generated/fonts/frames.png",
"assets/generated/title/pokemon_logo.png",
"assets/generated/title/title_screen.png",
"assets/generated/title/hooh.png",
"assets/generated/title/hooh_5.png",
"assets/generated/title/clouds.png",
"assets/generated/title/copyright_splash.png",
"data/generated/oak_speech.lua",
"assets/generated/intro/oak.png",
"assets/generated/intro/cal.png",
"assets/generated/tilesets/johto.png",
"assets/generated/tilesets/roofs/new_bark.png",
"assets/generated/sprites/chris.png",
"assets/generated/battle/front/chikorita.png",
"assets/generated/battle/front/pikachu.png",
"assets/generated/battle/front/marill.png",
"assets/generated/battle/trainers/falkner.png",
"assets/generated/battle/hud/balls.png",
"assets/generated/audio/programs.bin",
"assets/generated/slots/gold_slots_1.png",
"assets/generated/card_flip/card_flip_1.png",
"assets/generated/pc/mail_item.png",
-- engine/events/fishing_gfx.asm:23
"assets/generated/emotes/fishing.png",
},
crystal = {
"data/generated/constants.lua",
"data/generated/maps.lua",
"data/generated/roofs.lua",
"data/generated/sprites.lua",
"data/generated/scripts.lua",
"data/generated/text.lua",
"data/generated/rom_text.lua",
"data/generated/pokemon.lua",
"data/generated/encounters.lua",
"data/generated/tilesets.lua",
"data/generated/landmarks.lua",
"data/generated/audio.lua",
"data/generated/marts.lua",
"data/generated/oak_speech.lua",
"data/generated/title.lua",
"data/generated/intro.lua",
"assets/generated/fonts/font.png",
"assets/generated/fonts/frames.png",
"assets/generated/title/crystal_logo.png",
"assets/generated/title/crystal_wordmark.png",
"assets/generated/title/crystal_suicune.png",
"assets/generated/title/copyright_splash.png",
"assets/generated/splash/ditto.png",
"assets/generated/intro/chris.png",
"assets/generated/intro/kris.png",
"assets/generated/intro/suicune_run_sprites.png",
"assets/generated/intro/unowns_tiles.png",
"assets/generated/intro/oak.png",
"assets/generated/tilesets/johto.png",
"assets/generated/tilesets/roofs/new_bark.png",
"assets/generated/sprites/chris.png",
"assets/generated/sprites/kris.png",
"assets/generated/battle/front/chikorita.png",
"assets/generated/battle/front/wooper.png",
"assets/generated/battle/front/pikachu.png",
"assets/generated/battle/trainers/falkner.png",
"assets/generated/battle/hud/balls.png",
"assets/generated/audio/programs.bin",
"assets/generated/slots/gold_slots_1.png",
"assets/generated/card_flip/card_flip_1.png",
"assets/generated/pc/mail_item.png",
"assets/generated/trainer_card/card_f.png",
"data/generated/mobile_gfx.lua",
"assets/generated/battle/player_back_female.png",
"assets/generated/battle/trainers/kris.png",
"assets/generated/battle/trainers/chris.png",
-- ../pokecrystal/engine/events/fishing_gfx.asm:38-42
"assets/generated/emotes/fishing.png",
},
}
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.silver =
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.gold
function CacheContract.requiredFilesFor(version)
local override = CacheContract.VERSION_REQUIRED_FILES_OVERRIDE[version]
if override then return override, true end
return CacheContract.REQUIRED_FILES, false
end
function CacheContract.markerFor(version, sha1)
return CacheContract.FORMAT .. (sha1 or GameVersion.info(version).sha1)
end
function CacheContract.markerMatches(version, marker)
for _, revision in ipairs(GameVersion.revisions(version)) do
if marker == CacheContract.markerFor(version, revision.sha1) then return true end
end
return false
end
-- Keep the process-global CacheFs prefix isolated even when a filesystem
-- adapter raises while probing or publishing. The real CacheFs methods
-- return errors, but this also makes the contract safe for platform adapters
-- that surface I/O failures as Lua errors.
local function withVersionPrefix(version, fs, action)
local saved = fs.prefix
fs.prefix = GameVersion.cachePrefix(version)
local ok, first, second = pcall(action)
fs.prefix = saved
if not ok then return false, first end
return true, first, second
end
function CacheContract.allRequiredFilesExist(version, fs)
fs = fs or require("src.import.CacheFs")
local ok, complete, missing = withVersionPrefix(version, fs, function()
local required, isOverride = CacheContract.requiredFilesFor(version)
local missingPath
for _, path in ipairs(required) do
if not fs.exists(path) then missingPath = path; break end
end
if not missingPath and not isOverride then
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
if not fs.exists(path) then missingPath = path; break end
end
end
return missingPath == nil, missingPath
end)
if not ok then return false, complete end
return complete, missing
end
function CacheContract.readMarker(version, fs)
fs = fs or require("src.import.CacheFs")
local ok, marker, readError = withVersionPrefix(version, fs, function()
return fs.read(CacheContract.MARKER_PATH)
end)
if not ok then return nil, marker end
-- LÖVE may return contents plus a byte count; only a nil contents result
-- makes the auxiliary value an error. CacheFs' portable reader returns
-- just the contents, so this remains adapter-neutral.
if marker == nil then return nil, readError end
return marker
end
function CacheContract.isReady(version, fs)
fs = fs or require("src.import.CacheFs")
if CacheContract.sourceTreeHasData(version) then return true end
local marker, readError = CacheContract.readMarker(version, fs)
if readError or not CacheContract.markerMatches(version, marker) then return false end
return CacheContract.allRequiredFilesExist(version, fs)
end
function CacheContract.publish(version, fs, sha1)
fs = fs or require("src.import.CacheFs")
local complete, missing = CacheContract.allRequiredFilesExist(version, fs)
if not complete then
-- A caller may be retrying over a partially replaced cache. Do not
-- leave its old marker advertising readiness after this failed check.
local removed, removeError = withVersionPrefix(version, fs, function()
if not fs.remove then
error("cache filesystem cannot remove the completion marker")
end
return fs.remove(CacheContract.MARKER_PATH)
end)
if not removed then
return false, "cache is incomplete; missing " .. tostring(missing)
.. "; could not remove completion marker: " .. tostring(removeError)
end
return false, "cache is incomplete; missing " .. tostring(missing)
end
local changed, ok, err = withVersionPrefix(version, fs, function()
return fs.write(CacheContract.MARKER_PATH, CacheContract.markerFor(version, sha1))
end)
if not changed then return false, tostring(ok) end
return ok, err
end
function CacheContract.sourceTreeHasData(version)
if not (love and love.filesystem and love.filesystem.getInfo
and love.filesystem.getRealDirectory and love.filesystem.getSource) then
return false
end
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
local required, isOverride = CacheContract.requiredFilesFor(version)
local source = love.filesystem.getSource()
for _, path in ipairs(required) do
local fullPath = prefix .. path
if love.filesystem.getInfo(fullPath, "file") == nil
or love.filesystem.getRealDirectory(fullPath) ~= source then
return false
end
end
if not isOverride then
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
local fullPath = prefix .. path
if love.filesystem.getInfo(fullPath, "file") == nil
or love.filesystem.getRealDirectory(fullPath) ~= source then
return false
end
end
end
return true
end
return CacheContract
+59 -8
View File
@@ -287,6 +287,43 @@ function CacheFs.write(rel, data)
return love.filesystem.write(rel, data)
end
-- Open a cache-relative file for streaming replacement. The returned handle
-- has write(bytes) and close() methods and follows the same portable/save-dir
-- routing as CacheFs.write without forcing the caller to hold the whole file
-- in one Lua string.
function CacheFs.openWrite(rel)
rel = withPrefix(rel)
local root = CacheFs.root()
if root then
ensureParents(root, rel)
local f, err = io.open(realPath(root, rel), "wb")
if not f then return nil, err end
return {
write = function(_, data)
local ok, writeErr = f:write(data)
if not ok then return nil, writeErr end
return true
end,
close = function() f:close() end,
}
end
if not (love and love.filesystem and love.filesystem.newFile) then
return nil, "streaming cache writes are unavailable"
end
local parent = rel:match("^(.*)/[^/]+$")
if parent and not love.filesystem.createDirectory(parent) then
local info = love.filesystem.getInfo(parent)
local reason = info and ("a " .. info.type .. " already exists there")
or "unknown reason"
return nil, "could not create " .. parent .. ": " .. reason
end
local file, makeErr = love.filesystem.newFile(rel)
if not file then return nil, makeErr or "could not create cache file" end
local ok, openErr = file:open("w")
if not ok then return nil, openErr or "could not open cache file" end
return file
end
-- read cache-relative `rel`; returns the bytes or nil
function CacheFs.read(rel)
rel = withPrefix(rel)
@@ -577,14 +614,18 @@ function CacheFs.mountVersion(version)
return true
end
-- Undo mountVersion. A process normally mounts exactly one version and then
-- boots it, but the launcher can open the save editor on one game's save,
-- close it, and press Play on another: with the first version's subtree
-- still prepended, the other's require("data.generated.*") and generated
-- art would silently resolve to the first 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.
-- Undo mountVersion in LIFO order relative to mountVersion: generated-tree
-- overlays first (assets, then data -- reverse of mountGeneratedTrees), then
-- the version folder. PHYSFS resolves by stack order; peeling the wrong
-- layer first can leave another version's generated files winning a name.
--
-- A process normally mounts exactly one version and then boots it, but the
-- launcher can open the save editor on one game's save, close it, and press
-- Play on another: with the first version's subtree still prepended, the
-- other's require("data.generated.*") and generated art would silently
-- resolve to the first 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.
function CacheFs.unmountVersion(version)
@@ -596,6 +637,16 @@ function CacheFs.unmountVersion(version)
base = love.filesystem.getSaveDirectory()
end
local done = false
-- LIFO vs mountGeneratedTrees: assets/generated, then data/generated.
if love.filesystem and love.filesystem.unmount then
local generated = {
prefix .. "assets/generated",
prefix .. "data/generated",
}
for _, src in ipairs(generated) do
done = love.filesystem.unmount(src) or done
end
end
local fn = resolveUnmount()
if fn and base then
done = fn(base .. SEP .. sub) or done
+439
View File
@@ -0,0 +1,439 @@
-- Crystal's intro movie and title screen extraction (pokecrystal
-- engine/movie/intro.asm:1678-1777, engine/movie/title.asm:364-374).
-- Called from RomExtractorGen2's extractIntro/extractTitle stages when
-- edition == "crystal"; reads through the passed-in extractor and never
-- mutates it.
local ImageWriter = require("src.import.ImageWriter")
local CrystalMovie = {}
local TILE_BYTES = 16
local SHEET_TILES = 16
-- vBGMap tilemaps and attrmaps are one full 32x32 map
-- (engine/movie/intro.asm:1611-1628 decompresses $40 tiles = 1024 bytes).
local MAP_BYTES = 1024
local function padTiles(raw, count)
local length = count * TILE_BYTES
while #raw > length do table.remove(raw) end
while #raw < length do raw[#raw + 1] = 0 end
return raw
end
-- Lay `count` tiles of `source` (from tile `from`, 0-based) over `target`
-- starting at tile `at` (0-based).
local function overlayTiles(target, source, at, from, count)
for offset = 1, count * TILE_BYTES do
target[at * TILE_BYTES + offset] = source[from * TILE_BYTES + offset] or 0
end
return target
end
local function blankTiles(count)
local out = {}
for index = 1, count * TILE_BYTES do out[index] = 0 end
return out
end
-- Sheets are 16 tiles per row so tile id N resolves to (N % 16, N / 16),
-- the same layout data/sprite_anims/oam.asm:120-136 assumes.
local function writeSheet(self, raw, count, relative)
padTiles(raw, count)
local rows = math.ceil(count / SHEET_TILES)
padTiles(raw, rows * SHEET_TILES)
self:write2bpp(raw, SHEET_TILES * 8, rows * 8, relative, true)
return "assets/generated/" .. relative
end
local function readMap(self, label)
local bytes = self:decompressLz3Symbol(label)
while #bytes > MAP_BYTES do table.remove(bytes) end
while #bytes < MAP_BYTES do bytes[#bytes + 1] = 0 end
return bytes
end
-- 16 palettes: 8 BG then 8 OBJ (engine/movie/intro.asm:123-130 copies
-- `16 palettes` over wBGPals1, which wOBPals1 follows).
local function readPalettes(self, label)
local symbol = self:symbol(label)
local flat = self:colors(symbol.bank, symbol.address, 64)
local bg, obj = {}, {}
for pal = 0, 7 do
local a, b = {}, {}
for slot = 1, 4 do
a[slot] = flat[pal * 4 + slot]
b[slot] = flat[(pal + 8) * 4 + slot]
end
bg[pal + 1], obj[pal + 1] = a, b
end
return { bg = bg, obj = obj }
end
local function readColors(self, label, count)
local symbol = self:symbol(label)
return self:colors(symbol.bank, symbol.address, count)
end
function CrystalMovie.extractIntro(self)
self:beginStage("Intro movie")
local out = {
generation = 2,
layout = "crystal",
source = "ROM:CrystalIntro (engine/movie/intro.asm)",
}
local unowns = padTiles(self:decompressLz3Symbol("IntroUnownsGFX"), 128)
local grassSym = self:symbol("IntroGrass4GFX")
local grass4 = self.rom:bytes(grassSym.bank, grassSym.address, TILE_BYTES)
local unownsPath = writeSheet(self, unowns, 128, "intro/unowns_tiles.png")
local pulsePath = writeSheet(self,
self:decompressLz3Symbol("IntroPulseGFX"), 16, "intro/pulse_sprites.png")
local backgroundPath = writeSheet(self,
self:decompressLz3Symbol("IntroBackgroundGFX"), 128,
"intro/background_tiles.png")
local runPath = writeSheet(self,
self:decompressLz3Symbol("IntroSuicuneRunGFX"), 192,
"intro/suicune_run_sprites.png")
local pichuPath = writeSheet(self,
self:decompressLz3Symbol("IntroPichuWooperGFX"), 128,
"intro/pichu_wooper_sprites.png")
self:tick("Intro movie", 1, 4)
-- IntroScene15: jump BG at vTiles2 plus IntroGrass4GFX at vTiles1 tile 0,
-- i.e. BG id $80 (engine/movie/intro.asm:744-753).
local jump = blankTiles(256)
overlayTiles(jump, self:decompressLz3Symbol("IntroSuicuneJumpGFX"), 0, 0, 128)
overlayTiles(jump, grass4, 0x80, 0, 1)
local jumpPath = writeSheet(self, jump, 256, "intro/suicune_jump_tiles.png")
-- The same grass tile is the whole of the SUICUNE_AWAY object's sheet
-- (data/sprite_anims/oam.asm:136 base $80; engine/movie/intro.asm:750-753).
local unownBack = blankTiles(144)
overlayTiles(unownBack, self:decompressLz3Symbol("IntroUnownBackGFX"), 0, 0, 48)
overlayTiles(unownBack, grass4, 0x80, 0, 1)
local unownBackPath = writeSheet(self, unownBack, 144,
"intro/unown_back_sprites.png")
-- IntroScene17 loads 255 tiles from vTiles1, so BG id $80 is close tile 0
-- and ids wrap through $00-$7e (engine/movie/intro.asm:825-828).
local closeRaw = padTiles(self:decompressLz3Symbol("IntroSuicuneCloseGFX"), 255)
local close = blankTiles(256)
for id = 0, 255 do
local from = (id + 128) % 256
if from < 255 then overlayTiles(close, closeRaw, id, from, 1) end
end
local closePath = writeSheet(self, close, 256, "intro/suicune_close_tiles.png")
-- IntroScene19: suicune_back at vTiles2, the Unown ring at vTiles1, and
-- grass over vTiles1 tile $7f = BG id $ff (engine/movie/intro.asm:890-901).
local back = blankTiles(256)
overlayTiles(back, self:decompressLz3Symbol("IntroSuicuneBackGFX"), 0, 0, 128)
overlayTiles(back, unowns, 128, 0, 128)
overlayTiles(back, grass4, 0xff, 0, 1)
local backPath = writeSheet(self, back, 256, "intro/suicune_back_tiles.png")
local crystalUnownsPath = writeSheet(self,
self:decompressLz3Symbol("IntroCrystalUnownsGFX"), 32,
"intro/crystal_unowns_tiles.png")
-- Intro_RustleGrass swaps 4 tiles at vTiles2 tile $09 through grass1/2/3
-- (engine/movie/intro.asm:1521-1547); one 16-tile row, frame f at f*4.
local grassStrip = blankTiles(16)
for frame, label in ipairs({ "IntroGrass1GFX", "IntroGrass2GFX",
"IntroGrass3GFX" }) do
local sym = self:symbol(label)
overlayTiles(grassStrip,
self.rom:bytes(sym.bank, sym.address, 4 * TILE_BYTES), (frame - 1) * 4,
0, 4)
end
out.grassFrames = writeSheet(self, grassStrip, 16, "intro/grass_anim.png")
self:tick("Intro movie", 2, 4)
local unownPals = readPalettes(self, "IntroUnownsPalette")
local backgroundPals = readPalettes(self, "IntroBackgroundPalette")
local suicunePals = readPalettes(self, "IntroSuicunePalette")
local closePals = readPalettes(self, "IntroSuicuneClosePalette")
local crystalUnownsPals = readPalettes(self, "IntroCrystalUnownsPalette")
local function act(tiles, sprites, tilemapLabel, attrmapLabel, palettes)
return {
tiles = tiles,
sprites = sprites,
tilemap = readMap(self, tilemapLabel),
attrmap = readMap(self, attrmapLabel),
palettes = palettes,
}
end
out.acts = {
unownA = act(unownsPath, pulsePath,
"IntroUnownATilemap", "IntroUnownAAttrmap", unownPals),
unownHI = act(unownsPath, pulsePath,
"IntroUnownHITilemap", "IntroUnownHIAttrmap", unownPals),
unowns = act(unownsPath, pulsePath,
"IntroUnownsTilemap", "IntroUnownsAttrmap", unownPals),
background = act(backgroundPath, runPath,
"IntroBackgroundTilemap", "IntroBackgroundAttrmap", backgroundPals),
suicuneJump = act(jumpPath, unownBackPath,
"IntroSuicuneJumpTilemap", "IntroSuicuneJumpAttrmap", suicunePals),
suicuneClose = act(closePath, nil,
"IntroSuicuneCloseTilemap", "IntroSuicuneCloseAttrmap", closePals),
suicuneBack = act(backPath, unownBackPath,
"IntroSuicuneBackTilemap", "IntroSuicuneBackAttrmap", suicunePals),
crystalUnowns = act(crystalUnownsPath, nil,
"IntroCrystalUnownsTilemap", "IntroCrystalUnownsAttrmap",
crystalUnownsPals),
}
-- IntroScene7/10 load pichu_wooper into VRAM bank 1
-- (engine/movie/intro.asm:340-348); OAM_BANK1 picks this sheet.
out.acts.background.sprites1 = pichuPath
self:tick("Intro movie", 3, 4)
out.fades = {
toWhite = {},
unownAppear = readColors(self, "Intro_Scene20_AppearUnown.pal1", 4),
unownAppear2 = readColors(self, "Intro_Scene20_AppearUnown.pal2", 4),
wordFast = readColors(self, "Intro_FadeUnownWordPals.FastFadePalettes", 16),
wordSlow = readColors(self, "Intro_FadeUnownWordPals.SlowFadePalettes", 16),
}
-- Intro_Scene24_ApplyPaletteFade steps 8 rows of one palette each
-- (engine/movie/intro.asm:1155-1189).
local fade = readColors(self, "Intro_Scene24_ApplyPaletteFade.FadePals", 32)
for row = 0, 7 do
local pal = {}
for slot = 1, 4 do pal[slot] = fade[row * 4 + slot] end
out.fades.toWhite[row + 1] = pal
end
self:write("intro", out)
self:tick("Intro movie", 4, 4)
return out
end
--------------------------------------------------------------------------
local function shadeOf(r)
if r > 0.9 then return 0 end
if r > 0.5 then return 1 end
if r > 0.2 then return 2 end
return 3
end
local function tilesFrom2bpp(raw)
local tiles = {}
for offset = 1, #raw - (#raw % TILE_BYTES), TILE_BYTES do
local one = {}
for index = offset, offset + TILE_BYTES - 1 do one[#one + 1] = raw[index] end
tiles[#tiles + 1] = ImageWriter.decode2bpp(one, 8, 8, true)
end
return tiles
end
local function blitTile(target, tile, tx, ty)
if not tile then return end
for y = 0, 7 do
for x = 0, 7 do
local r, g, b, a = tile:getPixel(x, y)
if a ~= 0 then target:setPixel(tx + x, ty + y, r, g, b, a) end
end
end
end
local function colorize(image, palFor)
local w, h = image:getDimensions()
local out = ImageWriter.blank(w, h, 0, 0, 0, 0)
for y = 0, h - 1 do
for x = 0, w - 1 do
local r, _, _, a = image:getPixel(x, y)
if a ~= 0 then
local pal = palFor(x, y)
local c = pal[shadeOf(r) + 1] or pal[4] or { 0, 0, 0 }
out:setPixel(x, y, c[1] / 255, c[2] / 255, c[3] / 255, 1)
end
end
end
return out
end
-- engine/movie/splash.asm:20-30 sets SCGB_GAMEFREAK_LOGO before Copyright, so
-- the card runs on gfx/sgb/predef.pal:79 PREDEFPAL_GAMEFREAK_LOGO_BG.
local COPYRIGHT_TILES = 29
local COPYRIGHT_BG = {
{ 0, 0, 0 }, { 66, 90, 90 }, { 173, 173, 173 }, { 255, 255, 255 },
}
-- data/copyright.asm at hlcoord 2, 7 (engine/menus/intro_menu.asm:1315-1326).
local COPYRIGHT_LINES = {
{ 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66,
0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c },
{ 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66,
0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72, 0x7a, 0x7b, 0x7c },
{ 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66,
0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c },
}
local function extractCopyright(self)
local symbol = self:symbol("CopyrightGFX")
local raw = self.rom:bytes(symbol.bank, symbol.address,
COPYRIGHT_TILES * TILE_BYTES)
self:write2bpp(raw, COPYRIGHT_TILES * 8, 8, "title/copyright.png")
local painted = {}
for index, tile in ipairs(tilesFrom2bpp(raw)) do
painted[index] = colorize(tile, function() return COPYRIGHT_BG end)
end
local backdrop = COPYRIGHT_BG[1]
local splash = ImageWriter.blank(160, 144,
backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1)
for row, line in ipairs(COPYRIGHT_LINES) do
for column, id in ipairs(line) do
blitTile(splash, painted[id - 0x60 + 1], (1 + column) * 8, (6 + row) * 8)
end
end
self:save(splash, "title/copyright_splash.png")
end
function CrystalMovie.extractTitle(self)
self:beginStage("Title screen")
-- TitleLogoGFX decompresses to vTiles1, so BG id $80 is logo tile 0 and
-- ids $00-$1b wrap on past it (engine/movie/title.asm:91-94).
local logoTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleLogoGFX"))
-- TitleSuicuneGFX fills vTiles4-vTiles5: bank-1 ids $80-$ff then $00-$7f
-- (engine/movie/title.asm:24-27).
local suicuneTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleSuicuneGFX"))
local gemTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleCrystalGFX"))
local pals = readPalettes(self, "TitleScreenPalettes")
self:tick("Title screen", 1, 5)
-- Attribute regions from _TitleScreen's ByteFills
-- (engine/movie/title.asm:39-85): logo gradient rows, the version strip,
-- pal 7 on the window copyright line, pal 0 everywhere else.
local function bgPalAt(col, row)
if row == 9 and col >= 5 and col <= 15 then return 1 end
if row >= 3 and row <= 4 then return 2 end
if row == 5 then return 3 end
if row == 6 then return 4 end
if row == 7 then return 5 end
if row >= 8 and row <= 9 then return 6 end
if row == 17 then return 7 end
return 0
end
local shade = ImageWriter.blank(160, 144, 0, 0, 0, 0)
-- DrawTitleGraphic lays 7 rows of 20 running tiles from d=$80
-- (engine/movie/title.asm:107-112,275-302).
for row = 0, 6 do
for col = 0, 19 do
blitTile(shade, logoTiles[row * 20 + col + 1], col * 8, (row + 3) * 8)
end
end
-- Copyright line: 13 tiles from d=$c on the window's row 0, shown at
-- hWY = $88 (engine/movie/title.asm:114-119; engine/menus/intro_menu.asm:1121-1122).
for index = 0, 12 do
blitTile(shade, logoTiles[128 + 12 + index + 1], (3 + index) * 8, 17 * 8)
end
local colored = colorize(shade, function(x, y)
return pals.bg[bgPalAt(math.floor(x / 8), math.floor(y / 8)) + 1]
end)
self:save(colored, "title/crystal_screen.png")
self:save(shade, "title/crystal_screen_gray.png")
local logo = ImageWriter.blank(160, 56, 0, 0, 0, 0)
ImageWriter.blit(logo, colored, 0, 0, 0, 24, 160, 56)
self:save(logo, "title/crystal_logo.png")
local wordmark = ImageWriter.blank(88, 8, 0, 0, 0, 0)
ImageWriter.blit(wordmark, colored, 0, 0, 40, 72, 88, 8)
self:save(wordmark, "title/crystal_wordmark.png")
self:tick("Title screen", 2, 5)
-- LoadSuicuneFrame: 6 rows of 8 tiles at hlcoord 6,12, row stride 16;
-- frame bases $80/$88/$00/$08 (engine/movie/title.asm:245-273).
local suicunePaths, suicuneGrayPaths = {}, {}
for index, base in ipairs({ 0x00, 0x08, 0x80, 0x88 }) do
local frame = ImageWriter.blank(64, 48, 0, 0, 0, 0)
for row = 0, 5 do
for col = 0, 7 do
blitTile(frame, suicuneTiles[base + row * 16 + col + 1],
col * 8, row * 8)
end
end
local tinted = colorize(frame, function() return pals.bg[1] end)
local rel = ("title/crystal_suicune_%d.png"):format(index)
self:save(tinted, rel)
suicunePaths[index] = "assets/generated/" .. rel
local grayRel = ("title/crystal_suicune_%d_gray.png"):format(index)
self:save(frame, grayRel)
suicuneGrayPaths[index] = "assets/generated/" .. grayRel
if index == 1 then self:save(tinted, "title/crystal_suicune.png") end
end
self:tick("Title screen", 3, 5)
-- InitializeBackground: five 48x16 strips of six 8x16 OBJs, consecutive
-- tile pairs, OBJ pal 0, OAM_PRIO (engine/movie/title.asm:304-338).
local gem = ImageWriter.blank(48, 80, 0, 0, 0, 0)
for strip = 0, 4 do
for slot = 0, 5 do
local tile = strip * 12 + slot * 2
blitTile(gem, gemTiles[tile + 1], slot * 8, strip * 16)
blitTile(gem, gemTiles[tile + 2], slot * 8, strip * 16 + 8)
end
end
local gemTinted = colorize(gem, function() return pals.obj[1] end)
self:save(gemTinted, "title/crystal_gem.png")
self:save(gem, "title/crystal_gem_gray.png")
extractCopyright(self)
self:tick("Title screen", 4, 5)
local function unit(color) return { color[1] / 255, color[2] / 255, color[3] / 255 } end
local data = {
generation = 2,
layout = "crystal_title",
source = "ROM:TitleSuicuneGFX + TitleLogoGFX + TitleCrystalGFX"
.. " + TitleScreenPalettes + CopyrightGFX",
screen = "assets/generated/title/crystal_screen.png",
screenGray = "assets/generated/title/crystal_screen_gray.png",
image = "assets/generated/title/crystal_logo.png",
wordmark = "assets/generated/title/crystal_wordmark.png",
suicune = "assets/generated/title/crystal_suicune.png",
suicuneFrames = suicunePaths,
suicuneFramesGray = suicuneGrayPaths,
-- hlcoord 6, 12 (engine/movie/title.asm:252) in screen pixels.
suicuneX = 48,
suicuneY = 96,
-- SuicuneFrameIterator advances every 8 frames (engine/movie/title.asm:217-243).
suicuneEvery = 8,
gem = "assets/generated/title/crystal_gem.png",
gemGray = "assets/generated/title/crystal_gem_gray.png",
copyright = "assets/generated/title/copyright.png",
copyrightSplash = "assets/generated/title/copyright_splash.png",
copyrightBackdrop = unit(COPYRIGHT_BG[1]),
copyrightInk = unit(COPYRIGHT_BG[4]),
-- Strip 0 starts at OAM (64, -$22) and stops at OAM y 22, i.e. screen
-- (56, 6) (engine/movie/title.asm:306-311,340-362).
gemX = 56,
gemY = 6,
gemFromY = -50,
gemStep = 2,
-- TitleScreenEntrance: hSCX from +112 to 0 by 4 with alternating-line
-- signage over the logo's 80 lines; the copyright window sits at
-- hWY = $88 only after it lands (engine/menus/intro_menu.asm:1078-1111).
entrance = { scx = 112, step = 4, lines = 80, hideBelow = 136 },
entranceSfx = "Sfx_TitleScreenEntrance",
-- TitleScreenTimer (engine/menus/intro_menu.asm:1125-1136).
timeoutFrames = 73 * 60 + 36,
sky = unit(pals.bg[1][1]),
below = unit(pals.bg[1][1]),
palettes = { bg = pals.bg, obj = pals.obj },
}
self:write("title", data)
self:tick("Title screen", 5, 5)
return data
end
return CrystalMovie
+2 -2
View File
@@ -13,7 +13,7 @@ require("love.math")
require("love.system")
require("love.timer")
local version, prefix, romData, progressName, resultName = ...
local version, prefix, romData, progressName, resultName, romSha1 = ...
local progressChannel = love.thread.getChannel(progressName)
local resultChannel = love.thread.getChannel(resultName)
@@ -44,7 +44,7 @@ local ok, err = pcall(function()
current = current, stageTotal = stageTotal,
})
end
end)
end, romSha1)
extractor:run()
end)
+20
View File
@@ -125,6 +125,26 @@ function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile)
return out
end
-- Inverse of pret tools/gfx --interleave (pokecrystal tools/gfx.c).
-- Build-time interleave stores each vertical 8x16 pair as consecutive 8x8
-- tiles for OBJ mode; this restores row-major sheet order for PNGs.
function ImageWriter.deinterleave(raw, width, bytesPerTile)
bytesPerTile = bytesPerTile or 16
local widthTiles = width / 8
local numTiles = #raw / bytesPerTile
local out = {}
for i = 0, numTiles - 1 do
local row = math.floor(i / widthTiles)
local src = i * 2 - (row % 2 == 1
and widthTiles * (row + 1) - 1
or widthTiles * row)
for offset = 1, bytesPerTile do
out[i * bytesPerTile + offset] = raw[src * bytesPerTile + offset]
end
end
return out
end
function ImageWriter.save(image, path)
local ok, fileData = pcall(image.encode, image, "png")
if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end

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