Compare commits

...

108 Commits

Author SHA1 Message Date
bryanthaboi e3a215e081 Merge pull request #1052 from bryanthaboi/dev
revisions and buggies
2026-08-10 15:05:40 -04:00
bryanthaboi 7fa758602a Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-10 14:59:06 -04:00
bryanthaboi bb979911c8 Update common.sh 2026-08-10 14:59:04 -04:00
bryanthaboi f225f8a6e7 Merge pull request #918 from KikiManjaro/pok-nickname-changer-in-save
Add nickname editing to the save editor's mon inspector
2026-08-10 14:32:14 -04:00
bryanthaboi ee891fb8fa Merge pull request #993 from MaxTomahawk/feat/checkpoint-restore-event
feat(mods): signal verified checkpoint restores
2026-08-10 14:31:45 -04:00
bryanthaboi 9d73ff4110 yellow copyright diff 2026-08-10 14:27:00 -04:00
bryanthaboi 0b7ecf1599 Merge pull request #985 from steve1337/issue_#969_linux_app_image
[#969] Fixes linux AppImage
2026-08-10 14:21:57 -04:00
bryanthaboi aa45f05c63 Merge pull request #1038 from dlloa/upstream-pr/per-category-game-speed
Per-category GAME SPEED (overworld/battle/menu) + core.logic_speed hook
2026-08-10 14:15:59 -04:00
bryanthaboi 9234f12ed0 Merge remote-tracking branch 'origin/dev' into fix1038
# Conflicts:
#	docs/modding.md
2026-08-10 14:14:39 -04:00
bryanthaboi 26e0bd227e Merge pull request #1037 from dlloa/upstream-pr/platform-lifecycle-hooks
Add core.update / core.quit_to_launcher platform lifecycle hooks
2026-08-10 14:14:17 -04:00
bryanthaboi 64ce66a619 Merge pull request #1023 from AverageConsumer/feature/battle-ui-visibility
Mod API: expose battle UI visibility hooks
2026-08-10 14:12:57 -04:00
bryanthaboi 9302bbcbf3 Merge branch 'fix1037' into fix1038
# Conflicts:
#	docs/modding.md
2026-08-10 14:12:21 -04:00
bryanthaboi 1855f838ef Merge remote-tracking branch 'origin/dev' into fix1037
# Conflicts:
#	docs/modding.md
2026-08-10 14:11:45 -04:00
bryanthaboi 11af06a752 Merge remote-tracking branch 'origin/dev' into fix1023
# Conflicts:
#	docs/modding.md
2026-08-10 14:10:52 -04:00
bryanthaboi 05668591ef Merge pull request #965 from crusty/feat/worldapi-start-wild-battle
Add mod.world:startWildBattle
2026-08-10 14:09:28 -04:00
bryanthaboi e74beec32d Merge pull request #970 from swuff-star/modded-evo-item
add modding support for custom item effects
2026-08-10 14:09:23 -04:00
bryanthaboi 3efb2c9a6c Merge pull request #996 from thibautbus/fix/modkit-dump-dataset-utf8-decode
Fix: tools/modkit.py crashes on Windows when dumped text isn't representable in the system codepage
2026-08-10 14:09:19 -04:00
bryanthaboi d3af63e013 Merge pull request #1020 from ArmstrongThomas/agent/variable-size-overworld-sprites
Support variable-size anchored overworld sprites
2026-08-10 14:09:13 -04:00
bryanthaboi ca6bee64b9 Merge pull request #1042 from thibautbus/fix/android-ttf-dpi
Fix custom TTF rendering on Android
2026-08-10 14:09:07 -04:00
bryanthaboi b52d183dba Merge pull request #1024 from AverageConsumer/feature/semantic-pc-list-kinds
Mod API: expose semantic PC list kinds
2026-08-10 14:09:01 -04:00
bryanthaboi 23e1877a62 Merge pull request #1025 from AverageConsumer/feature/map-overview-detail-markers
Mod API: add detailed map overview data and POIs
2026-08-10 14:08:58 -04:00
bryanthaboi d8736fe33e Merge pull request #1015 from ShaneMcGovernIE/shanemcgovernie-fix-substitute-fainting
Fix Substitute exact-quarter-HP trainer softlock
2026-08-10 14:08:53 -04:00
bryanthaboi a74365a4fd Merge pull request #1043 from ShaneMcGovernIE/shanemcgovernie-fix-rival-collision
Fix champion rival walk-out route
2026-08-10 14:08:49 -04:00
bryanthaboi 1c2163bb5f Merge pull request #1046 from ShaneMcGovernIE/shanemcgovernie-fix-encounter-rate-grace-period
Fix wild encounter grace period
2026-08-10 14:08:46 -04:00
bryanthaboi 12c2677dc2 title screen issues, audio issues, and replacing gf c 2026-08-10 14:00:26 -04:00
Shane McGovern ae1b59ce7f fix: restore wild encounter grace period
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-10 12:15:18 +01:00
Shane McGovern 02c4de897d Fix champion rival walk-out route
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-10 10:59:06 +01:00
thibautbus 3c6242717e Use 1x TTF rasterizer on Android 2026-08-10 08:43:46 +02:00
david 3c71afb9fa Per-category GAME SPEED: overworld/battle/menu + core.logic_speed hook (RFC 0007)
GameSpeed is a single fast-forward multiplier applied uniformly to the
whole logic clock -- overworld walking, menu navigation and battle turns
all scale together. A player who wants 4X battles but 1X overworld (so a
cutscene or NPC dialogue doesn't blur past) has no way to get both.

Splits save.options.speed into speedOverworld/speedBattle/speedMenu, each
cycling independently, with an automatic migration so an existing save's
speed choice carries over. Game.speedCategoryInStack resolves which
category is active by walking the state stack (the same idiom
wideBattleInStack/fillScaleInStack already use), so a menu opened mid-
battle inherits battle speed rather than resetting to whatever "menu"
defaults to. Adds a new core.logic_speed hook so a mod can read or
override the resolved multiplier for the current frame regardless of
which category produced it, sitting after the link-play and run-argument
overrides so neither is a seam a mod can defeat.

RFC 0007 status: Proposed.
2026-08-09 20:13:21 -07:00
david e79107c644 Add core.update/core.quit_to_launcher platform lifecycle hooks (RFC 0006)
A platform-specific launcher wrapper (a native shell embedding this engine,
owning its own UI around the game window) needs to pause the simulation
while its own UI is on top, live-reload options it wrote outside any Lua
UI, and veto main.lua's "closing the window returns to the Lua launcher"
behavior when it owns that job itself. Implementing this by hand-patching
main.lua's love.update/love.quit directly ties every such integration to
editing the one file every other engine change also touches, guaranteeing
merge conflicts. No existing hook covers "should the per-frame simulation
step run" or "should closing the window return to the Lua launcher."

Adds two generic, additive hooks (src/core/PlatformHooks.lua): core.update
and core.quit_to_launcher, replacing what would otherwise be inline
main.lua special-casing. Also adds Manifest.force_enable_env, letting a
mod that cannot function disabled on the one build where its env var is
set (a platform-bridge mod bundled only with that build) re-enable itself
regardless of a saved disable.

RFC 0006 status: Proposed.
2026-08-09 20:05:06 -07:00
steve1337 0afce96c8d [#969] Fixes linux AppImage
StartupWMClass matches what SDL reports: AppRun execs bin/love, so the
window's WM_CLASS / Wayland app_id is "love" and without this the taskbar
entry never resolves back to this desktop file (no name, no icon).
2026-08-09 22:52:38 +07:00
AverageConsumer d881997acd feat(mods): add detailed map overview POIs 2026-08-09 15:57:17 +02:00
AverageConsumer 8906133b93 feat(ui): expose semantic PC list kinds 2026-08-09 15:49:28 +02:00
AverageConsumer b9e3c4a689 feat(mods): expose battle UI visibility hooks 2026-08-09 15:46:33 +02:00
Thomas Armstrong 81f18e244d Support variable-size anchored overworld sprites 2026-08-09 04:10:39 -04:00
Shane McGovern ff91481018 Fix Substitute zero-HP boundary
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-09 00:17:51 +01:00
thibautbus fdb3184c71 Fix Windows crash: decode LuaJIT dump output as UTF-8, not the locale codepage
subprocess.run(..., capture_output=True, text=True) with no explicit
encoding falls back to locale.getpreferredencoding(False) -- the OS
default codepage. On Windows that's a legacy single-byte codepage
(e.g. cp1252), never UTF-8. When the LuaJIT dump contains a byte with
no mapping in that codepage, subprocess's internal _readerthread
crashes with an uncaught UnicodeDecodeError in a background thread; the
thread dies silently and the caller gets back stdout=None instead of a
string, crashing one line later with
AttributeError: 'NoneType' object has no attribute 'splitlines'.

Concretely, the Yellow-side imported dataset contains:
  "_ColosseumHeightText" -> "...6’8” tall!"
The right double quotation mark (U+201D) encodes in UTF-8 as E2 80 9D;
0x9D has no defined character in cp1252, so decoding as cp1252 fails
outright. Verified against the real imported dataset: the Red/Blue-only
dump has zero bytes outside cp1252's defined range; the Yellow dump has
exactly one, at this row.

UTF-8 is the actual encoding these dumps are produced in -- the driver
Lua sources are read/written as UTF-8 throughout this file, and LuaJIT
writes those source strings' bytes back out verbatim -- so passing
encoding="utf-8" explicitly at the three affected call sites
(run_loader, check_data_dump, dump_dataset) is a no-op on platforms
whose default codepage is already UTF-8 (Linux/macOS) and a correctness
fix on Windows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 16:14:07 +02:00
MaxTomahawk aa3b2a18ec feat(mods): signal verified checkpoint restores 2026-08-08 13:28:56 +02:00
Jakub Lisicki d498016a47 Merge remote-tracking branch 'origin/dev' into feat/worldapi-start-wild-battle 2026-08-08 12:42:10 +02:00
Jakub Lisicki 492e0344b5 Fold the startWildBattle coverage into mod_world_tests
mod_world_tests is already the mod.world suite and already the T3 tier,
with the same off-the-world refusal pattern the standalone file was
duplicating. Reuses its liveWorld fixture instead of standing up a
second one.
2026-08-08 12:35:06 +02:00
Jakub Lisicki a710c64909 Refuse re-entrant, fractional-level and no-party wild battles
overworld() resolves the world from under the stack, so a call from a
battle hook stacked a second battle over the live one -- on a loss its
afterBattle blacked out and warped with the outer battle still up.

newWild marks the species SEEN before it reports an empty party, so a
refused call still wrote the Pokedex; test the party before building it.

tonumber accepts 5.5, which Pokemon.new writes straight into the stat
calc and the exp curve.
2026-08-08 12:23:05 +02:00
bryanthaboi 943ba5dcbf Merge pull request #991 from AverageConsumer/codex/mod-world-map-tiles
Mod API: expose visual tile data in map overviews
2026-08-08 06:16:24 -04:00
AverageConsumer 371edd1a02 feat(mods): expose map tile shading 2026-08-08 11:59:57 +02:00
Jakub Lisicki 9fc6ba5b01 Add mod.world:startWildBattle
Starting a wild encounter had no supported entry point, so mods built a
BattleState and pushed it themselves -- silently losing onFinish (and
with it evolutions and blackout-on-loss) and pushBattle (entry wipe,
battle theme). Neither failure raises.

Also covers awardExp -> leveledUp -> afterBattle -> checkParty, which
parity_trainer_evolution_order stubs BattleState out of.
2026-08-08 11:58:10 +02:00
bryanthaboi 983bea61aa Merge pull request #986 from MaxTomahawk/feat/mod-battle-checkpoints
feat: add persistent battle safe-point checkpoints
2026-08-08 05:46:34 -04:00
bryanthaboi ab74f7adf7 Merge pull request #974 from hernan0078/mods/spanish-ui
mods: a Spanish translation of the app's own text
2026-08-08 05:45:29 -04:00
bryanthaboi bdd0bf45bd Merge pull request #973 from hernan0078/options/cancel-through-strings
OPTIONS: route CANCEL through Strings so a translation mod can reach it
2026-08-08 05:44:34 -04:00
bryanthaboi 4b950fc23d Merge pull request #972 from hernan0078/ios/stadium-picker-kind
iOS picker: a "stadium" kind, and a way to ask which kinds exist
2026-08-08 05:44:22 -04:00
bryanthaboi af183d95ad Merge pull request #962 from AverageConsumer/feature/mod-world-map-overview
Mod API: expose a read-only active map overview
2026-08-08 05:42:55 -04:00
bryanthaboi 1d4d5d2337 Merge pull request #940 from Yukitty/patch-title_player
Allow TitleState player overrides
2026-08-08 05:42:34 -04:00
bryanthaboi 653b72d86a Merge pull request #925 from Bortlesboat/multiplayer-session-foundation
Add a reusable multiplayer session layer
2026-08-08 05:42:23 -04:00
MaxTomahawk 12b6ef6350 test: prove complete overworld progress fidelity 2026-08-08 09:53:41 +02:00
MaxTomahawk c979685a1f test: cover switched battle party fidelity 2026-08-08 09:53:41 +02:00
MaxTomahawk 5b1d0261ff test: prove public battle checkpoint roundtrip 2026-08-08 09:53:41 +02:00
MaxTomahawk 5bffc89a2f docs: specify battle checkpoint extension 2026-08-08 09:53:41 +02:00
MaxTomahawk 2ce612d5b1 test: prove deterministic battle checkpoint fidelity 2026-08-08 09:53:41 +02:00
MaxTomahawk 6999e5aecf feat: preserve overworld checkpoint RNG 2026-08-08 09:53:41 +02:00
MaxTomahawk 44a7910c69 feat: reconstruct standard battle continuations 2026-08-08 09:53:41 +02:00
MaxTomahawk 85c6fde443 feat: reconstruct battle checkpoints from data 2026-08-08 09:53:41 +02:00
MaxTomahawk 67b6dcc293 feat: capture data-only battle checkpoints 2026-08-08 09:53:41 +02:00
MaxTomahawk 24d9f279ec feat: expose settled battle checkpoint boundary 2026-08-08 09:53:41 +02:00
hernan 83f93c27a1 mods: a Spanish translation of the app's own text
A LANGUAGE-profile mod filling the `strings` registry -- the seam #791 opened
up, used for the thing it was opened for. 566 keys: the launcher, OPTIONS,
the save-slot and profile screens, the mod manager, the naming screen, and
the battle menu.

WHAT IT DOES NOT TOUCH is the cartridge. Dialogue, species, items, moves and
places all come out of the player's ROM and stay exactly as they are, so an
English cartridge is still an English adventure with Spanish menus around
it. The lang/ tables for those ship empty on purpose rather than absent:
they are where a full translation would go, and an empty value falls through
to English, so anyone continuing this can fill one row at a time and the
game stays playable throughout.

THE FONT IS THE CONSTRAINT, and it decided the wording. The 8x8 charmap has
no N-tilde, no accented vowels and no inverted punctuation -- the sole
exception in the whole atlas is the small e-acute of POKeMON. So every value
on an 8x8 surface is plain A-Z: DISENO COMBATE, MESETA ANIL, SEGURO? OTRA
VEZ. Not a spelling preference; a glyph that is missing renders as a hole,
which is how the first cut of this shipped "ESPA OL" to a phone. The
launcher draws with a real font and keeps proper Spanish, inverted marks and
all -- the split is by surface, not by taste.

Adding the glyphs to the atlas would let the 8x8 side read properly too, and
lang/font.lua and lang/charmap.lua are already the place that would hook
into. I have not done it here: it is a separate change with its own taste
questions, and it should not ride in on a catalog.

Tested end to end on iOS and macOS.
2026-08-07 22:16:06 -04:00
hernan d756b7cd43 OPTIONS: route CANCEL through Strings so a translation mod can reach it
CANCEL is appended after the `ui.options.rows` hook, deliberately -- that is
what stops a mod from orphaning the exit. But it also means no translation
mod can ever see it: there is no row for one to rewrite, and the hook has
already run by the time it is added.

The result is that a fully translated OPTIONS menu has exactly one English
word left on it, and it is the way out. I hit this with a Spanish catalog
where every row translated and the exit did not.

One call, matching how every other label on this screen is already built.
Nothing changes without a catalog loaded: Strings is an identity function
until a mod supplies one.

Follows the same reasoning as #791.
2026-08-07 22:14:29 -04:00
hernan 1e6f504a84 iOS picker: a "stadium" kind, and a way to ask which kinds exist
Two things, the second of which is the reason the first is safe.

A NEW KIND. pickFile("stadium") opens the document picker for a Nintendo 64
cartridge and lands it as picked_stadium.z64. The caller I wrote it for is
the Dramatic Shape voxel mod, which builds Pokemon Stadium battle models out
of the player's own cartridge -- on desktop it opens a dialog for that, and
on iOS it could only print a sandbox path to a screen and ask the player to
put a file somewhere they cannot reach from a phone.

Its own filename, not picked_rom.gb, because that is the name the Game Boy
importer watches: a 32 MB N64 ROM landing there is deleted and then reported
to the player as a broken cartridge.

WHICH IS ALSO WHAT AN UNKNOWN KIND USED TO DO. The switch's default case
treated anything it did not recognise as a Game Boy ROM, so a caller asking
for a kind the build predates lost the player's file -- the worst available
answer to "I have not heard of that one". Unknown kinds are refused now.

That refusal is invisible on its own: pickFile returns false, which is also
what "the picker would not open" returns, and a mod cannot tell them apart.
So the host says what it knows -- love.system.pickFileKinds() returns
"rom,mod,sav,stadium", or nil where there is no bridge. A caller asks first
and keeps whatever fallback it had; the voxel mod shows its folder note
again, which is what it did before any picker existed.

Tested on an iPhone 17 Pro: a Stadium cartridge imports from the picker and
the models build.
2026-08-07 22:13:50 -04:00
Hanan Rodebaugh abb4f3f9f0 better mod item registry 2026-08-07 20:24:11 -04:00
Yukita Mayako 5b0209a7ce feat(TitleState): add field.title.player 2026-08-07 14:28:25 -04:00
AverageConsumer c1e0685e97 feat(mods): expose read-only map overview 2026-08-07 20:20:57 +02:00
Bortlesboat 3c57b28c6d ci: retrigger cancelled checks 2026-08-07 13:16:06 -04:00
bryanthaboi cab62ff7b3 Merge pull request #903 from martin2844/martin2844/fix-blue-rom-setup
Fix Blue ROM source setup
2026-08-07 12:50:26 -04:00
bryanthaboi 5b6dfed359 Merge pull request #959 from MaxTomahawk/feat/reproducible-mod-packages
fix: make modkit packages reproducible with SOURCE_DATE_EPOCH
2026-08-07 12:43:50 -04:00
bryanthaboi 91a85a065b Merge pull request #927 from AverageConsumer/feature/second-screen-touch
Mod API: expose Android second-screen touch input
2026-08-07 12:41:48 -04:00
bryanthaboi 32e2c51365 Merge pull request #957 from ShaneMcGovernIE/shanemcgovernie-issue-916-trainer-sprite-at-end-of-fly-animation-9e5f77
Hide trainer sprite through the Fly/Dig warp fade (#916)
2026-08-07 12:41:05 -04:00
bryanthaboi 1992fdf398 Merge pull request #955 from ShaneMcGovernIE/shanemcgovernie-issue-945-cannot-edit-trainer-class-s-battle-theme-ad701b
Honor trainer battleTheme override (fixes #945)
2026-08-07 12:40:55 -04:00
bryanthaboi 59ac27971a Merge pull request #953 from ShaneMcGovernIE/shanemcgovernie-fix-reset-settings-bugs
saveOptions three-way merge: stop partial writes dropping launcher keys (#932)
2026-08-07 12:39:56 -04:00
bryanthaboi cd0ace2a39 Merge pull request #952 from MaxTomahawk/feat/mod-state-checkpoints
Add playthrough-scoped storage and stable overworld checkpoints
2026-08-07 12:39:32 -04:00
bryanthaboi 274c728e77 Merge pull request #950 from jherediagu/fix/translation-readiness
Translation-readiness: route the last hardcoded labels through the Strings catalog.
2026-08-07 12:36:47 -04:00
bryanthaboi 8547127f54 Merge pull request #948 from ShaneMcGovernIE/shanemcgovernie-issue-915-wrong-sprite-used-in-opening-2f6ea5
Fix Yellow Oak speech showing Nidorino instead of Pikachu (#915)
2026-08-07 12:36:37 -04:00
bryanthaboi 45d799aa98 Merge pull request #946 from ShaneMcGovernIE/shanemcgovernie-issue-931-mod-update-checks-fail-594ba9
Fix mod update checks failing on non-JSON responses (#931)
2026-08-07 12:36:25 -04:00
bryanthaboi 660c90626d Merge pull request #937 from Yukitty/patch-oak_speech_shrink
feat(OakSpeech): use field.playerSprites.walk in shrink step
2026-08-07 12:36:14 -04:00
AverageConsumer d9dc42895f chore: retrigger CI 2026-08-07 18:10:22 +02:00
MaxTomahawk 02fd21bcdb fix: honor source date in mod packages 2026-08-07 17:54:06 +02:00
MaxTomahawk af00d6ad14 feat: expose storage engine compatibility context 2026-08-07 17:14:42 +02:00
Shane McGovern 0d4a518a08 saveOptions three-way merge: stop partial writes dropping launcher keys (#932)
options.lua is a whole-file rewrite, so a caller handing saveOptions a
partial table (only the keys it changed) silently dropped every key it did
not mention: launcher-only keys like lastVersion, and keys the launcher set
(battleBg, tilt) all fell back to defaults.

saveOptions now reads the on-disk file first and folds caller-absent
values underneath before mergeOptions backfills defaults.  A table holding
every defaultOptions key is a full snapshot and stays authoritative, so the
fold is inert for all in-repo writers (every one passes loadOptions-ed
tables) and cannot resurrect the bindings/activeProfile deletions the
RESET REBINDS and mod-manager paths make on full tables.

Adds a regression suite (options_partial_write_bug932.lua) pinning the
merge, and updates the #828 suite's partial-write assertion, which now
expects lastVersion to survive a delta write.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-07 14:55:55 +01:00
MaxTomahawk 05b43ca258 feat: include engine version in checkpoints 2026-08-07 15:21:16 +02:00
MaxTomahawk 9d6ea845d7 fix: reject invalid checkpoint content 2026-08-07 15:09:16 +02:00
MaxTomahawk bbca4e8e39 docs: specify mod storage and checkpoint APIs 2026-08-07 15:05:43 +02:00
MaxTomahawk 49954ec4ad fix: allocate playthrough identity only on demand 2026-08-07 15:01:18 +02:00
MaxTomahawk 6e94625f2a feat: expose stable overworld checkpoints to mods 2026-08-07 14:49:45 +02:00
MaxTomahawk 0399ad040f feat: add playthrough-scoped mod storage 2026-08-07 14:43:04 +02:00
MaxTomahawk 726ed1102e feat: add opaque playthrough identity 2026-08-07 14:43:04 +02:00
Juan Heredia 5bc7d203a5 Route the last hardcoded UI labels through the Strings catalog
Wrap the battle stat box, the PC quantity footer and the dex metric
labels in Strings() (SummaryMenu -- and MoveEffects since #811 --
already do this); give the battle menu a 'battle' lookup context so a
translation can shorten FIGHT/ITEM/RUN independently of the pause menu;
align the metric dex rows with the imperial ones and make the No.
prefix translatable; make the title menu's recolor zone follow Menu's
auto-grown width; honor the declared-but-unread boot.title seam,
drawing an explicit versionRibbon as one centered piece. With an empty
catalog every path is pixel-identical to vanilla.
2026-08-07 13:59:01 +02:00
Shane McGovern ce204aaf16 CLOSES #916: hide trainer sprite through the Fly/Dig warp fade
After the Fly departure animation finished (bird off-screen) and during
Dig/teleport, the trainer sprite popped back in standing at the old cell
for the whole 32-frame black fade-out before the transition.  The
player-hide guard only held while a departure animation was live:
flyAnim went nil the instant path2 completed and the teleportOut
countdown cleared the spin fields at 0, but startWarpTo's Transition
(not isOpaque) keeps the overworld drawing beneath the veil, and the
arrival animation is not armed until setMap's midpoint.

Add a playerHidden flag on OverworldState that bridges the gap:
- set when each departure completes (flyAnim path2 / teleportOut hit 0),
  immediately before the warp starts;
- cleared in startWarpTo's Transition enter callback, synchronously
  after setMap and before the arrival arms flyArrive / spinDrop, so the
  player is never drawable mid-fade and never bare on the landing frame;
- folded into both player-draw guards.

ROM-free regression test (tests/engine/warp_sprite_hidden_bug916.lua)
drives the REAL Transition + setMap headlessly for Dig and Fly and
asserts zero fade frames leave the player drawable bare (would have
observed 31/32 gap frames before the fix).  Runs in the CI headless T2
tier.

Dig spin timing/lift and the black fade color are left as-is (fade is
intentional per #607).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-07 11:14:20 +01:00
Shane McGovern ed8a89c5ce Honor trainer battleTheme override (fixes #945)
trainers.battleTheme validated and merged onto the trainer record but was
never read: battle music came solely from data.audio.battle[kind] where
kind is computeMusicKind()'s final/gym/trainer/wild.  Route both battle-
theme start sites through a single choke point:

- BattleState:playBattleTheme() cues Music.playBattle with the override
  (self.trainer.battleTheme via battleTheme()), defaulting to the kind
  when unset, so vanilla fights and #782's non-gym Giovanni are unchanged.
- BattleState:enter() and OverworldController:pushBattle() both call it.
- Music.playBattle gains an optional 4th song arg that overrides the kind
  default, and real call sites now populate the music.select trainerId.
- Victory jingles stay kind-based: a custom battle theme has no derivable
  win-variant.

New ROM-free T2 suite tests/engine/trainer_battle_theme_bug945.lua covers
mod load, override resolution, the choke point, and the nil-override
parity gate.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-07 10:53:42 +01:00
Shane McGovern 66e7d9432e Fix Yellow Oak speech showing Nidorino instead of Pikachu (#915)
Yellow's field.oakSpeech manifest carried only shrink frames, so
OakSpeech.lua's `oakGfx.demoSpecies or "NIDORINO"` fallback fired and
the opening speech showed Nidorino's sprite and cry instead of the
player's Pikachu.

- Stamp demoSpecies "PIKACHU" in the Yellow import manifest (source of
  truth for fresh ROM imports and developer builds).
- Stamp it in make_yellow_manifest.py so regeneration keeps the value.
- Repair stale Yellow caches in Data:applyVersionedFieldData() with a
  fill-if-absent block, matching the #617 oldManBattle RATTATA pattern.
- Add parity test (manifest carries PIKACHU; stale cache filled;
  pre-stamped value left alone).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-07 10:51:50 +01:00
Shane McGovern d59a9522ee Fix mod update checks failing on non-JSON responses (#931)
The mod update and Find Mods feeds feed the raw HTTP body straight to
Json.decode. When the endpoint hands back something that is not JSON
(an HTML error page, a proxy/captive prompt, or a plain-text outage
message like "Exceeded secondary rate limit" -- usually still HTTP 200),
the decoder's "unexpected character 'E'" assert escaped through the
pcall and became the error message, blaming the parser instead of the
response.

Add Json.describeUnexpected() as a pre-decode content-type guard: it
returns nil for body shapes the endpoints actually publish (JSON object
or array) and otherwise a short message naming what the server sent
(HTML page / plain text / empty, with a preview). Wire it into
ModUpdate.parseReleases and ModIndex.parse, so both the sync and async
update-check paths surface the real answer instead of the parse error.
HTTP status was already checked upstream by HostShell.httpGet (non-2xx
becomes "HTTP <code> from <url> (...)"); this closes the remaining
"2xx but not JSON" gap everywhere, including bridge platforms that
expose no status or headers.

Add regression tests for plain-text, HTML, and empty bodies; strengthen
the ModIndex HTML soft-fail test.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-07 10:49:10 +01:00
Yukita Mayako f1661b62b3 feat(OakSpeech): use field.playerSprites.walk in shrink step 2026-08-07 00:28:24 -04:00
AverageConsumer a98562f46b feat(android): expose secondary-screen touch events 2026-08-06 22:56:13 +02:00
Andrew Barnes 365bd24740 Document multiplayer session boundary 2026-08-06 16:50:30 -04:00
Andrew Barnes d50ea4d067 Route tournaments through multiplayer sessions 2026-08-06 16:50:30 -04:00
Andrew Barnes 52c96437fa Route link play through multiplayer sessions 2026-08-06 16:50:29 -04:00
Andrew Barnes 2a7c8ec81b Harden multiplayer session terminal handling 2026-08-06 16:50:29 -04:00
Andrew Barnes bd6c606303 Add multiplayer session FIFO facade 2026-08-06 16:50:28 -04:00
bryanthaboi 112120e8fe Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-06 14:36:03 -04:00
bryanthaboi 00f6e3a7b4 CLOSES #889 2026-08-06 14:36:01 -04:00
kikimanjaro 9cc72f951b Add nickname editing to the save editor's mon inspector
The mon inspector now has a NICKNAME section: a text field with Set / Clear, commit-on-Enter and discard-on-Escape.

Input is gated at the field instead of refused at commit. Only glyphs the game
font can actually draw AND that round-trip through a .sav are accepted, capped
at the naming screen's 10 glyphs, so a bad keystroke or paste never appears and
Set always succeeds. Chars like "@" (the Gen1 string terminator), "#" and the
dakuten kana have codec entries but no font tile so they are blocked rather than silently mangled.
2026-08-06 18:52:24 +02:00
github-actions 0f484682d8 chore(ios): update app-repo.json [skip ci] 2026-08-06 10:41:34 -04:00
Martin 493caee715 Merge dev and fix headless ROM builder dependency 2026-08-06 16:22:34 +02:00
Martin 0ae29744c8 Fix Blue ROM source setup 2026-08-06 14:06:55 +02:00
145 changed files with 10315 additions and 573 deletions
+3
View File
@@ -383,6 +383,9 @@ jobs:
- name: install luajit
run: sudo apt-get update && sudo apt-get install -y luajit
- name: install Pillow
run: python3 -m pip install --upgrade pillow
- name: interpreter version
run: luajit -v
+10 -15
View File
@@ -1067,23 +1067,18 @@ local championsRoomRivalScript = {
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement
-- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the
-- screen just fading on the spot (#704). The entrance walk leaves the
-- player at (4,3) and both north-wall warps sit on row 0, so the original
-- only ever spends three of those simulated steps -- CheckWarpsNoCollision
-- takes the HALL_OF_FAME warp the moment the walk lands on (4,0) and the
-- trailing UP/LEFT are dropped. Scripted steps ignore collision here just
-- as they do in the original (CollisionCheckOnLand skips its checks while
-- wSimulatedJoypadStatesIndex is non-zero), so stepping through the
-- rival's cell at (4,2) is the ported behavior, not a clip. Re-reported
-- as a clip in #847 and re-checked against home/overworld.asm
-- CollisionCheckOnLand, which is still the authority: do not "fix" it.
{ "move_player", "up", 3 }, -- 26
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement.
-- The player walks out after Oak instead of the screen just fading on the
-- spot (#704). Route one tile right before walking north so the player
-- reaches the north-wall HALL_OF_FAME warp without sharing the rival's
-- (4,2) cell. The original simulated movement bypasses entity collision,
-- but this scene should not visibly walk through the defeated rival.
{ "move_player", "right", 1 }, -- 26
{ "move_player", "up", 3 }, -- 27
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 27
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 28
{ "set_field", "pendingHallOfFame", true }, -- 28
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 29
}
M.CHAMPIONS_ROOM = {
+1 -1
View File
@@ -38,7 +38,7 @@ the same core data and graphics into the source tree for verification.
| | `src/core/SaveData.lua` | Lua-serialized save in the LÖVE save dir |
| render | `src/render/Renderer.lua` | 160x144 canvas, integer nearest scaling |
| | `src/render/TileRenderer.lua` | one SpriteBatch per map (8x8 quads) + border-block ring |
| | `src/render/SpriteRenderer.lua` | 6-frame walker sheets, flipped right facing |
| | `src/render/SpriteRenderer.lua` | variable-size anchored sprite sheets, 6-frame walkers and flipped right facing |
| | `src/render/Font.lua` | glyph rendering via charmap (greedy longest match) |
| | `src/render/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page |
| | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades |
+195 -4
View File
@@ -35,6 +35,16 @@ An edited vanilla map becomes a `mod.content.maps:patch` carrying only the
fields that moved; a new map becomes a `:register`. See
`docs/new-features.md` and the extension's own README.
## Read-only map overviews
`mod.world:mapOverview()` returns collision `rows` at map-cell resolution,
optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
4x resolution. Visual rows contain Game Boy shades from `"0"` (lightest) to
`"3"` (darkest); their matching width and height fields describe the grid.
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for
`warp`, visible `item`, and untaken `hidden` locations. All fields are
read-only snapshots; mods choose which layers to render.
## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it
@@ -100,6 +110,44 @@ Three rules worth knowing:
Returning `nil` from `drawWorld` is a normal answer meaning "not this
frame"; the engine draws the vanilla world instead.
## Variable-size overworld sprites
The `sprites` registry keeps the vanilla 16x16 grounded walker as its default,
but a mod can describe any frame rectangle and anchor for player characters,
NPCs, followers, mounts, vehicles, bosses, or other field actors:
```lua
mod.content.sprites:register("SPRITE_COMPANION", {
image = "mods/example/companion.png", -- one frame per row
frames = 6,
walker = true,
frameWidth = 32,
frameHeight = 32,
anchorX = 16, -- frame-relative bottom-center anchor
anchorY = 32,
})
```
`frameWidth` and `frameHeight` are sheet pixels. `anchorX` and `anchorY` are
measured from each frame's top-left; when omitted they default to the frame's
horizontal center and bottom edge, so a larger sprite grows upward while its
feet stay on the same world cell. Omitting all four fields is exactly the
vanilla 16x16 placement. The normal player/NPC/follower draw paths consume
these values automatically, including horizontal flips and the fishing pose.
Custom render pipelines can use the same geometry without reproducing the
pose rules:
```lua
local geometry = sprite:getPoseGeometry(facing, walkPhase, stepFlip)
-- geometry.quad, .x/.y/.width/.height, .anchorX/.anchorY, .mirror
local originX, originY = sprite:getScreenOrigin(px, py, camX, camY)
```
`getFrameGeometry(frame)` is the corresponding accessor for a specific
zero-based sheet frame. Both accessors return fresh tables and share the
renderers frame selection and mirror conventions.
## Battle sprite scaling
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
@@ -140,6 +188,90 @@ 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.
## Durable tool storage and runtime checkpoints
`mod.save` remains the right place for state that should travel with the next
normal Pokémon SAVE. Tools that need independently written, larger data-only
records can use `mod.storage`; the engine scopes every logical key by game
version, opaque playthrough identity, and mod id, and routes it through the same
standard or portable persistence backend as saves:
```lua
local context, code, message = mod.storage:context(game)
local ok, code, message = mod.storage:write(game, "history/quick/q0001", {
format = 1, createdAt = os.time(), payload = { money = 3000 },
})
local value, code, message = mod.storage:read(game, "history/quick/q0001")
local keys, code, message = mod.storage:list(game, "history/quick")
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
```
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
version is compatibility metadata; physical launcher-slot and path identity stays
private.
Values must be tables containing serializable data only. Keys are conservative
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
handles are never exposed. Writes are staged and decode-verified, reads recover
from a valid staged/backup generation, and methods return structured errors for
normal data or I/O failures. The playthrough identity is allocated lazily on the
first storage/checkpoint call, so an unused API changes no save bytes.
`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state:
```lua
local capability = mod.checkpoints:inspect(game)
if capability.canCapture then
local checkpoint, code, message = mod.checkpoints:capture(game)
-- Store the detached data-only checkpoint through mod.storage.
end
local ok, code, message = mod.checkpoints:restore(game, checkpoint)
```
Checkpoint format 1 supports settled overworld control and proven battle
player-decision safe points. Battle checkpoints are limited to ordinary
single-player wild/trainer origins with no suspended script; link, Safari,
ghost, demo, scripted, animation, message, queue, and forced-action phases fail
closed. New checkpoints preserve gameplay RNG, while legacy overworld records
without RNG remain loadable. Capture excludes global options and runtime
objects. Restore validates format, game/playthrough identity, content,
coordinates, battle relationships, continuation, and RNG before mutation;
preserves current options; suppresses normal map-entry/save-load/intro side
effects; verifies a recapture; and rolls back runtime plus RNG in memory if
reconstruction fails. Callers that need crash recovery should durably capture
their own recovery checkpoint before restore.
Checkpoint ownership follows the persistence model rather than mod identity:
- canonical `game.save` progress, including every mod's `save.modData` /
`mod.save` bucket and data-only fields added to saved Pokémon, rewinds;
- global and per-mod options remain at their current values;
- independently written `mod.storage` records do not rewind; and
- mod-owned runtime objects, references, and caches are never serialized.
Successful restore emits `checkpoint.restored` only after reconstruction and
differential recapture have committed. Mods that cache rewound progress or hold
references to reconstructed runtime objects can re-read their own public state
and rebuild at that point:
```lua
mod.events:on("checkpoint.restored", function(ev)
-- ev.kind is "overworld" or "battle"; ev.game is fully reconstructed.
cachedQuestStage = mod.save:get("quest_stage", 0)
rebuildRuntimeFor(ev.game, ev.kind)
end)
```
The event is not emitted for validation failure, failed reconstruction, or a
successful rollback. Its payload contains no checkpoint data or other mod's
private state. A mod that deliberately stores progress-coupled truth in
`mod.storage` must version and reconcile that relationship itself; the engine
cannot distinguish it safely from independent history, configuration, or cache
data.
See RFC 0003, RFC 0004, and RFC 0005 for exact contracts and error codes.
## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload
@@ -221,10 +353,11 @@ the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
palette-correct blit of either canvas into an arbitrary screen rect, and the
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`)
for driving a second physical display. This is what lets a mod lay the two
passes out as two stacked Game Boy screens, or push one onto a second screen,
without the engine knowing the layout.
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `pollTouch()` /
`setEnabled`) for driving a second physical display. `pollTouch()` returns the
oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
or push one onto a second screen, without the engine knowing the layout.
`screen.render_visible` receives `(next, state)` while the main screen is being
composed. Return `false` to omit that state from drawing, opacity selection and
@@ -233,5 +366,63 @@ update and input ownership, so a mod can mirror a native menu on another
display without reimplementing it. The default is `true`. Treat the wrapper as
a pure predicate: the renderer may ask it more than once per frame.
Scrollable list states expose `state.kind` for use with this hook. Generic
lists fall back to their title; PC lists use stable, localization-independent
identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
`pc_box_change`, `pc_item_withdraw`, `pc_item_deposit`, and `pc_item_toss`.
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
control the battle text/menu layer and the HP/status panels. Both receive
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
Pushed text boxes also pass through `battle.bottom_ui_visible`; a wrapper that
only owns battle presentation should return `false` only for its active battle
or text-box state.
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever
category `Game.speedCategoryInStack` says is active right now. A mod may
call `next(game)` and return its result to pass that resolution through, or
return a different number outright to override it for that frame (a bot mod
forcing 1X for one route segment, say, regardless of the category or saved
option). The result is clamped to the nearest valid `GameSpeed.LEVELS` entry
regardless of what a subscriber returns, so a bad value (0, negative, `nil`)
cannot destabilize the fixed-step accumulator. This hook runs *after* link
play's 1X lock and the `--speed`/equivalent run-argument override, both of
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.
## Process-lifecycle hooks
These exist so a platform-specific launcher integration (a native shell
that embeds this engine and wraps its window in platform UI) can live
entirely in a mod instead of hand-patching `main.lua`, which every other
engine change also touches.
`core.update` receives `(next, game, dt)` once per frame from
`love.update`. Vanilla behavior is `game:update(dt)`, unconditionally. A
mod may skip calling `next(game, dt)` to pause the simulation for that
frame (e.g. while a native settings sheet is on top), and may run
additional per-frame polling before or after that call regardless of
whether it calls `next` -- useful for one-shot flags that must be observed
every frame even while paused.
`core.quit_to_launcher` receives `(next)` once from `love.quit()`. `next()`
returns the engine's own decision for whether closing the window should
return to the Lua launcher instead of exiting; a mod may return `false`
outright, without ever calling `next`, to veto that and let the process
really quit -- for a platform host that owns its own "return to launcher"
UI and would otherwise get looped straight back into the game it just
quit.
A manifest may also declare `force_enable_env`, an environment variable
name that re-enables the mod regardless of a saved disable in
`options.mods` when that variable is set to `"1"`. This is for a mod that
cannot function disabled on the one build where its env var is set (a
platform-bridge mod bundled only with that build's launcher, for example).
Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call`
already falls straight through to the vanilla function when no mod has
wrapped the name, at negligible cost.
+114
View File
@@ -0,0 +1,114 @@
# RFC 0003 — Add a reusable multiplayer session layer
## Status
Proposed. Engine: `Session.lua`, `Net.lua`, `LinkState.lua`,
`Tournament.lua`. Tests: `link_session.lua`.
## Motivation
Link play and tournaments currently own transport lifecycle details and
temporarily remove and reinsert packets in `Net.inbox` when a handshake or
battle starts. That makes packet ownership fragile and gives a future
shared-world mode no stable host/guest-aware boundary to reuse.
The engine needs one small layer that preserves today's wire protocol while
owning received-packet order and terminal cleanup. Pokémon, battle, tournament,
save, and overworld rules remain outside that layer.
## The decision it extends
Extends the existing split between `Net` (backend setup, framing, and relay
controls), `Handshake`/`Protocol` (mode payloads), and the states that
interpret those payloads. It does not replace any of those components.
## The exact API delta
Backward-compatible and internal-only.
### `Session.new(transport, options)`
Wraps one successfully configured Net-compatible transport. `options.role`
is exactly `"host"` or `"guest"`; `options.kind` is a non-empty
local label such as `"link"` or `"tournament"`. Role and kind are
immutable session metadata selected locally and are never inferred from peer
packets.
The facade forwards the narrow fields current consumers need:
`paired`, `code`, `address`, `target`,
`error`, and `closed`.
It forwards valid outbound tables unchanged through `send(message)`.
### Receive and lifecycle methods
- `update()` pumps the transport, validates decoded inbound values, and
appends accepted messages to a private FIFO.
- `pollOne()` removes the oldest queued message.
- `poll()` removes every queued message in order.
- `take(type)` removes the first queued message with that type without
disturbing any other message.
- `hasPending()` reports whether the FIFO is non-empty.
- `getRole()`, `getKind()`, `getStatus()`, and
`getFailure()` expose local metadata and lifecycle.
- `close()` closes the underlying transport once and is safe to repeat.
Statuses are `connecting`, `paired`, `draining`, `closed`,
and `failed`. A transport close or failure becomes `draining` while
accepted packets remain queued. The terminal `closed`/`error`
compatibility projection appears only after that FIFO drains, so a last packet
travelling with a disconnect remains observable.
An inbound value is structurally valid only when it is a table with a string
`type`. Invalid decoded values end the session with a protocol failure.
Unknown but structurally valid types remain queued for the owning mode; the
session does not contain a packet allowlist.
## Authority direction
A later `WorldSession` may compose this facade. In that mode the host
will own the world snapshot, map state, NPC state, event results, and shared
progression. A guest will bring a trainer identity plus their Pokémon party,
inventory, and other explicitly selected profile snapshot.
Guest profile data and commands will be untrusted input. The host must validate
them and must authorize every world mutation before rebroadcasting the result.
The concrete snapshot schema, command vocabulary, conflict rules, and
persistence policy require a separate RFC and are not introduced here.
## Compatibility and security
No packet envelope, message name, payload shape, framing rule, relay protocol,
save schema, or engine protocol version changes. Existing valid outbound
messages encode exactly as before, and existing link and tournament screens
keep their current player-facing behavior.
The layer does not authenticate players or encrypt traffic. Existing LAN and
relay access assumptions remain unchanged; knowledge of a join address or code
still grants the same access it grants today. Authentication, reconnect
identity, rate limits, and abuse controls remain future protocol decisions.
## Migration note for players, mods, and peers
**Nothing.** `LinkState` and `Tournament` adopt the facade
internally. Existing peers receive the same messages, mods gain no new API, and
players do not migrate saves or settings.
## Parity tests
- **ROM-free facade:** constructor validation, immutable role/kind, unchanged
send shape, FIFO ordering, typed retrieval, unknown typed packets, draining,
terminal failure latching, protected transport calls, and decoded-value
rejection.
- **Existing modes:** source guards prohibit direct inbox mutation; headless
module loads and the complete engine tier cover both migrated states.
- **ROM-backed link play:** run the existing link driver when generated ROM
data is available; the normal quick suite remains the required baseline.
## Deprecation etiquette and non-goals
Nothing deprecated. This RFC adds an internal facade and removes no transport
method.
It does not add shared-world packets, co-op screens, a remote actor, save
transfer, server persistence, matchmaking, reconnect, or a protocol-version
bump. Those changes require the world-specific layer and its own review.
+124
View File
@@ -0,0 +1,124 @@
# RFC 0003 — Playthrough-scoped mod storage
## Status
Proposed. Engine: `SaveData.lua`, `SaveSerializer.lua`, `Storage.lua`,
`Loader.lua`. Tests: `playthrough_identity.lua`, `storage.lua`, the existing
save-slot and mod-save suites.
## Motivation
`mod.save` intentionally lives inside the normal progress record. That is the
right home for quest state, but not for independent tool data such as replay
captures, checkpoint histories, or recovery records: writing it would require a
normal Pokémon SAVE, and storing copies of progress beneath `save.modData` would
recursively embed the save that contains them.
Mods also cannot safely infer which launcher slot or portable filesystem backs
the active playthrough. Direct filesystem access would expose private paths and
make isolation dependent on engine implementation details.
## The decision it extends
Extends the per-mod persistence contract documented in `docs/modding.md` and the
wiki's Save Model. `mod.save` and `mod.options` keep their existing behavior.
## The exact API delta
Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage`
facade to the calling mod id. Mods receive logical keys and decoded values, never
filesystem handles or physical paths.
### Lazy opaque playthrough identity
`SaveData.ensurePlaythroughId(save[, fs]) -> id | nil` allocates an opaque
32-hex-character identity without consuming gameplay RNG. It is called only when
`mod.storage` or `mod.checkpoints` first needs a scope; New Game, ordinary SAVE,
and ordinary load remain byte-compatible when no caller uses either API.
The id is stored in `save.meta.playthroughId` after allocation. Until the next
ordinary SAVE writes it into progress, a mapping in `options.lua` keeps legacy
saves stable by game version and active launcher slot (or the legacy flat-save
scope). A newly created playthrough never adopts the previous playthrough's
mapping for that slot.
`SaveData.persistenceFs([fs])` is engine-only routing used by the storage
implementation. It follows the same standard/portable backend as progress and
honors injected test filesystems; it is not exposed on the mod object.
### `mod.storage:context(game)`
Returns:
```lua
{ engineVersion = "0.9.0", gameVersion = "red", playthroughId = "..." }
```
or `nil, code, message`. `engineVersion` is warning-grade compatibility metadata;
the context intentionally omits launcher slot ids and paths.
### `mod.storage:write(game, key, value)`
Accepts a data-only table and returns `true`, or
`false, code, message`. Keys are nonempty slash-separated segments containing
letters, digits, underscore, or dash. Empty segments, leading/trailing slash,
`.`/`..`, and other characters are rejected.
The engine encodes deterministically, stages and decodes a `.tmp` witness,
preserves the previous valid generation, writes and decodes the main record,
then rolls the verified bytes to `.bak`. A failed stage or replacement leaves a
verified prior generation readable.
### `mod.storage:read(game, key)`
Returns a freshly decoded table, or `nil, code, message`. It tries main, staged,
then backup data. A valid staged/backup value is returned and promoted
best-effort; corrupt bytes are never executed.
### `mod.storage:list(game[, prefix])`
Returns sorted logical keys beneath a valid prefix, an exact key when the prefix
names one, or `nil, code, message`. Physical witness filenames are hidden.
### `mod.storage:delete(game, key)`
Deletes only that key's main, backup, and staged witnesses. Returns `true`, or
`false, code, message`.
### Scope and errors
Physical records are scoped as:
`persistence root / mod_storage / game version / playthrough id / mod id`
Stable error codes are `not_in_playthrough`, `storage_unavailable`,
`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and
`not_found`. Ordinary data and I/O failures are return values, not callback-
terminating errors.
The restricted serializer's recursive writer runs outside LuaJIT traces. A
1,000-process GC stress regression found compiled recursion could intermittently
drop a newly inserted nested identity entry and produce undecodable bytes; save
encoding is infrequent and I/O-bound, so interpreter execution is the safe
boundary.
## Migration note for existing mods
**Nothing.** No API is removed, no manifest field changes, and no storage path or
playthrough id is created unless a mod invokes `mod.storage` or
`mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path.
## Parity tests
- **No-mod:** New Game plus ordinary save/load creates no identity or storage
file; the existing save-slot and mod-save suites remain green.
- **Engine identity:** lazy allocation, save/load preservation, stable legacy
mapping, fresh-playthrough replacement, and version/slot isolation.
- **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip,
deterministic listing, key rejection, mod/game/playthrough isolation,
corrupt-main recovery, failure retention, exact delete, and no-mod no-write.
## Deprecation etiquette
Nothing deprecated. The additions are one bound public facade and engine-private
persistence/identity helpers.
+138
View File
@@ -0,0 +1,138 @@
# RFC 0004 — Stable runtime checkpoints for mods
## Status
Proposed. Engine: `Checkpoint.lua`, `Game.lua`, `OverworldController.lua`,
`Loader.lua`. Tests: `checkpoints.lua`, existing world and engine suites.
## Motivation
Mods can observe world events and request semantic actions, but no supported API
can capture canonical progress at a proven-safe runtime boundary or reconstruct
the overworld without replaying map-entry scripts. Reaching into the state stack,
controller, ScriptRunner, or save restore internals would bind distributable mods
to private objects and can duplicate story side effects.
The engine is the only component that can authoritatively decide whether the
runtime is settled and rebuild its controller objects. A generic checkpoint seam
lets tools store data-only records while keeping those responsibilities private.
## The decision it extends
Extends the public world/tool surfaces in `docs/modding.md`. It does not change
`mod.world`, normal CONTINUE, vanilla SAVE, or save lifecycle hooks/events.
## The exact API delta
Backward-compatible, additive-only. `Loader:_api` binds `mod.checkpoints`; mods
never receive `Game`, StateStack, controller, coroutine, renderer, or filesystem
internals inside a checkpoint.
### `mod.checkpoints:inspect(game)`
Returns a capability record. Stable overworld control returns:
```lua
{ canCapture = true, canRestore = true, kind = "overworld" }
```
A refusal returns the same booleans as `false` plus `kind`, `reason`, and a
player-readable `message`. Format-1 supports only an overworld whose controller
is topmost, player movement has settled on a tile, and no transition, foreground
or parallel ScriptRunner, queued script, scripted move, engagement, emote,
teleport, field animation, or similar partial controller mutation is active.
Refusal reasons are `not_in_playthrough`, `not_overworld`, `screen_busy`,
`transition_busy`, `script_busy`, `animation_busy`, and `movement_busy`.
Identity allocation is lazy and happens only after an active topmost overworld
has been established.
### `mod.checkpoints:capture(game)`
Returns a detached data-only format-1 checkpoint, or
`nil, code, message`:
```lua
{
format = 1,
kind = "overworld",
identity = {
engineVersion = "...", gameVersion = "red", playthroughId = "...",
},
save = { -- canonical dynamic progress, excluding global options },
runtime = { overworld = {
map = "PALLET_TOWN", x = 5, y = 6,
facing = "down", surfing = false,
} },
}
```
`engineVersion` is metadata for caller compatibility warnings; the engine does
not reject patch/minor mismatches on restore. Capture deep-copies through the restricted serializer before and after
`OverworldController:captureSave` synchronizes live map, tile, facing, and surf
state. It excludes `save.options`, functions, userdata, threads, metatables as
behavior, controller instances, and static content registries. Failure code
`capture_failed` covers non-data progress and synchronization errors.
### `mod.checkpoints:restore(game, checkpoint)`
Returns `true`, or `false, code, message`. Before mutation it requires the current
runtime to be capturable and validates a detached copy of the complete record:
format, kind, internal identity consistency, current game/playthrough identity,
map availability, integral in-bounds tile, facing, surfing, and synchronized save
position.
Validation codes are `invalid_checkpoint`, `invalid_content`, `unsupported_format`,
`unsupported_runtime_kind`, `wrong_game`, `wrong_playthrough`, `invalid_map`, and
`invalid_position`, in addition to the capability refusal reasons.
The canonical save validator runs against the detached record. Unlike ordinary
CONTINUE, a checkpoint never accepts a quarantine, remap, reclaim, clamp, or
repair: any such content change returns `invalid_content` before live mutation.
The engine captures an in-memory rollback checkpoint, preserves current global
options, then reconstructs semantic overworld state through
`Game:restoreCheckpointSave`. Checkpoint entry suppresses normal map exit/entry
events, `onEnter` scripts, forced-movement/current checks, and last-map rewrites;
it does not emit normal `save.loading`/`save.loaded` lifecycle events. After
reconstruction, the engine recaptures and byte-compares normalized data. A failed
apply rolls back and returns `restore_failed`; failure of that rollback returns
`rollback_failed`. Only after a successful comparison does the engine emit
`checkpoint.restored` with `{ game = game, kind = "overworld" }`. Validation
failure, failed apply, and successful rollback emit nothing.
Durable recovery remains a caller responsibility: in-memory rollback handles a
runtime exception, not process termination.
## Runtime boundary and future kinds
This RFC's original Level A contract intentionally rejects battles, menus,
transitions, animations, and suspended/queued scripts. RFC 0005 subsequently
adds a separately inventoried `battle` kind with deterministic RNG and
differential reconstruction tests; it does not broaden script or arbitrary-frame
support implied here.
## Migration note for existing mods
**Nothing required.** No existing hook, save, controller, or world action changes
when `mod.checkpoints` is unused. The reconstruction path is called only by a
successful public restore after validation. Mods whose runtime caches derive from
rewound `game.save` or `mod.save` state may optionally subscribe to
`checkpoint.restored` and rebuild from their own public state.
## Parity tests
- **No-mod:** the complete ROM-free engine suite and existing world behavior stay
green; ordinary New Game/save/load allocates no checkpoint identity.
- **Public Mod API:** a real API-2 entry chunk proves stable inspection and every
unsafe refusal, detached data-only capture, exact map/tile/facing/surf sync,
`A -> mutate B -> restore A -> recapture A2` equality across representative
progress, settings preservation, compatibility rejection without mutation,
map-side-effect suppression, injected reconstruction rollback, mod-owned
metadata and `mod.save` rewind, independent `mod.storage`/options preservation,
and success-only runtime-cache reconciliation through `checkpoint.restored`.
## Deprecation etiquette
Nothing deprecated. This adds one public facade and a checkpoint-only semantic
reconstruction route.
@@ -0,0 +1,140 @@
# RFC 0005 — Persistent battle safe-point checkpoints
## Status
Proposed. Extends RFC 0004. Engine: `BattleCheckpoint.lua`, `Checkpoint.lua`,
`Game.lua`, `BattleState.lua`, and `OverworldController.lua`. Tests:
`battle_checkpoint_*.lua`, `checkpoints.lua`, and the existing no-mod suites.
## Motivation
RFC 0004 lets a tool capture and reconstruct settled overworld progress without
private engine access. A battle is a different runtime: its queue can hold Lua
functions and UI factories, its controller contains renderer objects and live
references, completion is currently an `onFinish` closure, and scripted battles
resume a suspended `ScriptRunner` coroutine. Copying the controller would create
a record that is neither data-only nor process-independent.
The engine can instead expose a narrow semantic safe point. This gives all mods
the strongest persistent battle checkpoint the current architecture can prove,
without claiming mid-animation or suspended-script support.
## API delta
No new facade is added. The existing additive `mod.checkpoints` API gains a
second format-1 runtime kind.
### Capability
`mod.checkpoints:inspect(game)` returns this only when an ordinary single-player
wild or trainer battle is settled at the player command menu:
```lua
{ canCapture = true, canRestore = true, kind = "battle" }
```
The action/message queue, waits, UI, animations, HP/status presentation, and
faint processing must be settled. The player must actually control the menu.
The underlying overworld must have no running/queued script or scripted move,
and the battle must carry an engine-owned semantic continuation descriptor.
Additional refusal codes are `battle_phase_busy`, `battle_origin_unsupported`,
`battle_variant_unsupported`, and `link_battle_unsupported`. Link, Safari,
ghost, old-man/demo, fishing, static-object, script-suspended, and mod-created
closure continuations remain rejected.
### Capture
A battle checkpoint remains detached and data-only:
```lua
{
format = 1,
kind = "battle",
identity = { engineVersion = "...", gameVersion = "red",
playthroughId = "..." },
save = { -- canonical dynamic progress, excluding global options },
runtime = {
overworld = { map = "ROUTE_1", x = 7, y = 8,
facing = "left", surfing = false },
battle = { -- normalized semantic model and continuation },
},
rng = { love = "..." },
}
```
The model carries player/enemy roster indices, dynamic enemy Pokémon, turn and
escape state, HP/PP/status/stages/volatiles, participants, level-up tracking,
trainer AI state, battle ruleset identity, side/field extension data, and
normalized pointer relationships such as multi-turn move slots and Mimic
restoration entries. Definitions, sprites, canvases, queues, callbacks, and
controller objects are reconstructed or excluded.
Callback-bearing battle extension tokens fail with `battle_extension_unsafe`;
invalid live reference relationships fail with `battle_state_invalid`. Nothing
is silently stripped.
New overworld checkpoints also carry the LÖVE gameplay RNG state. Legacy
format-1 overworld checkpoints without `rng` remain loadable and leave the
current stream untouched.
### Restore
Battle restore validates the detached save, map, content references, ruleset,
roster indices, move references, continuation identity, and RNG before live
mutation. The engine then:
1. reconstructs the saved overworld return point without entry side effects;
2. creates a fresh `BattleState` from current content registries;
3. applies the normalized battle model and rebuilds object-reference relations;
4. binds an engine-owned wild/trainer completion continuation;
5. installs the battle directly at the settled menu without replaying its intro;
6. restores the RNG after reconstruction has finished; and
7. recaptures and compares the complete checkpoint; and
8. emits `checkpoint.restored` with `{ game = game, kind = "battle" }` after the
comparison succeeds.
The pre-operation checkpoint is the transaction rollback. A failed post-install
RNG restore is covered: both battle runtime and RNG are reconstructed back to
their original values. Validation failure, failed reconstruction, and successful
rollback emit no checkpoint lifecycle event.
## Continuation decision
Ordinary random wild battles resume through `OverworldState:afterBattle`.
Ordinary trainer battles use a descriptor containing map id, stable NPC id,
trainer class/party, and optional header event; a win reapplies the same defeated
flag, event, reward, and `afterBattle` path. Reconstructed overworld input and
NPC freeze state are normalized instead of reviving the old closure.
`Commands.start_battle` is deliberately unsupported: its completion closure
mutates script context and resumes a coroutine whose program counter and Lua
stack cannot be serialized. Existing script rejection remains the correct safe
contract until a separate semantic ScriptRunner checkpoint RFC exists.
## Migration note
**Existing mods require no changes.** The facade and format number are unchanged;
the new kind, RNG field, and success-only lifecycle event are additive.
Overworld-only callers may continue to filter `capability.kind`. Mods with derived
runtime caches may rebuild them from restored public state when the event fires.
No-mod behavior is unchanged when checkpoints are unused.
## Verification
- settled/unsafe boundary and every variant refusal;
- data-only wild and trainer capture, including callback-bearing extension
rejection;
- process-independent controller and continuation reconstruction;
- exact differential recapture for wild and trainer states;
- HP, PP, status/stages/volatiles, AI layer, participants, enemy roster,
multi-turn move references, and Mimic restore pointers;
- exact damage, critical, accuracy, random AI, escape, next encounter, and next
raw RNG result after reload;
- corrupt content/continuation rejection before mutation;
- injected post-install failure with full runtime and RNG rollback;
- mod-added Pokémon metadata and `mod.save` rewind while independent
`mod.storage` and options remain current;
- exactly one post-verification `checkpoint.restored` event and none on failure;
- legacy overworld checkpoint compatibility;
- complete ROM-free engine and public mod-API suites.
+107
View File
@@ -0,0 +1,107 @@
# RFC 0006 — Generic process-lifecycle hooks for platform launcher integrations
## Status
Proposed. Engine: `PlatformHooks.lua` (new), `main.lua`, `Manifest.lua`,
`Loader.lua`. Tests: `tests/modkit/cases/platform_lifecycle_hooks.lua`,
`tests/mod_loader_tests.lua`, `tests/mod_manifest_tests.lua`.
## Motivation
A platform-specific launcher wrapper -- a native shell that embeds this
engine and owns its own UI around the game window (a mobile app shell,
say, presenting its own settings/import/save screens and only handing
control to the LÖVE window once play starts) needs three things no
current hook covers:
1. Pause the simulation while its own UI is on top of the game window.
2. Live-reload options it wrote from outside any Lua UI.
3. Veto `main.lua`'s "closing the window returns to the Lua launcher"
behavior when the platform shell owns that job itself -- without this,
a shell that re-fronts its own launcher UI on quit gets looped straight
back into `HostShell.restart()`'s in-process reboot instead.
Implementing this by hand-patching `main.lua`'s `love.update`/`love.quit`
directly ties every such integration to editing the one file every other
engine change also touches, guaranteeing merge conflicts for any second
platform integration (or any unrelated engine PR landing around the same
time). No existing hook covers "should the per-frame simulation step run"
or "should closing the window return to the Lua launcher."
## The decision it extends
No prior D-number. Extends the hook-contract section of `docs/modding.md`
alongside `input.step`, `render.hud`, `screen.render_visible`, etc.
## The exact API delta
Backward-compatible, additive-only.
### `core.update`
New hook, `(game, dt) -> nil` through the public wrapper signature
`(next, game, dt)`, called once per frame from `love.update` via
`src/core/PlatformHooks.lua`'s `PlatformHooks.update(game, dt)`. Vanilla
behavior (used when no mod claims the hook) is `game:update(dt)`,
unconditionally -- identical to `love.update`'s behavior before this hook
existed. A subscriber may skip calling `next(game, dt)` to pause the
simulation for that frame, or do additional per-frame work before/after
calling it regardless of whether it calls `next`.
### `core.quit_to_launcher`
New hook, `() -> boolean` through the public wrapper signature `(next)`,
called once from `love.quit()` via
`PlatformHooks.quitToLauncher(vanilla)`. `vanilla` is the pre-existing
non-platform-specific decision (`Game and not Importer and not
quitToLauncher and not scripted and not launchedIntoGame`). A subscriber
may return `false` outright to veto returning to the Lua launcher (without
ever calling `next`, so the vanilla condition is never evaluated), or call
`next()` and return its result to pass the vanilla decision through
unchanged.
Neither hook is guarded by `Runtime.wantsHook` -- both fire unconditionally
every call, matching the existing `input.step` precedent
(`src/core/Game.lua`), since `Hooks:call` already fast-paths to a bare
`vanilla(...)` call when no mod has wrapped the name.
### `Manifest.force_enable_env`
New optional manifest field, a bare env-var name. `Loader:load` re-enables
a mod carrying this field whenever that variable is set to `"1"`,
regardless of a saved disable in `options.mods`. This exists for exactly
the mod class this RFC is for: a platform-bridge mod that ships only with
one build and cannot function disabled there, but must still behave like
every other mod (a manifest opt-in, not an engine special case) on every
build that doesn't set its variable.
## Migration note for existing mods
**Nothing.** With no subscriber, `love.update` still calls `Game:update(dt)`
unconditionally every frame and `love.quit()`'s restart-to-launcher
decision is exactly the pre-existing condition -- bit-identical to today's
behavior on every platform where no mod wraps either hook. A manifest with
no `force_enable_env` field behaves exactly as before.
## Parity tests
- **No-mod:** `core.update`'s vanilla runs exactly once per call with the
hook chain empty; `core.quit_to_launcher`'s vanilla return value passes
through unchanged. Both hooks are picked up automatically by the
catalog-driven no-mod gate (`tests/engine/gate_hooks.lua`, which scans
for `Runtime.call("...")` call sites), so neither needs a dedicated
no-mod test file.
- **Mod-API:** `tests/modkit/cases/platform_lifecycle_hooks.lua` proves,
through a fixture mod loaded via the public loader (not the engine's
internals), that a subscriber can skip the vanilla update call (pause),
run extra per-frame polling regardless of pause state, and veto the
quit-to-launcher decision without the vanilla condition ever running.
- `tests/mod_loader_tests.lua` and `tests/mod_manifest_tests.lua` cover
`force_enable_env`: a matching env var re-enables a mod saved as
disabled, and an unset one leaves the saved disable alone.
## Deprecation etiquette
Nothing deprecated. These are two additive hooks and one additive manifest
field; `main.lua`'s only footprint is one `require` and two call sites
into `src/core/PlatformHooks.lua`.
+212
View File
@@ -0,0 +1,212 @@
# RFC 0007 — Per-category GAME SPEED and the `core.logic_speed` hook
## Status
Proposed. Engine: `GameSpeed.lua`, `Game.lua`, `BattleState.lua`,
`OptionsMenu.lua`, `SaveData.lua`, `LauncherSettings.lua`. Tests:
`tests/engine/game_speed_categories_test.lua`,
`tests/engine/gate_hooks.lua` (structural, automatic), `tests/run_tests.lua`
(OptionsMenu row walk), `tests/mod_ui_tests.lua` (row id/order).
## Motivation
`GameSpeed` (`src/core/GameSpeed.lua`) is a single fast-forward multiplier
applied uniformly to the whole logic clock in `Game:logicSpeed()` /
`Game:update()` -- overworld walking, menu navigation and battle turns all
scale together. A player who wants 4X battles (grinding, a long gym fight)
but 1X overworld (so a scripted cutscene or NPC dialogue doesn't blur past)
has no way to get both; the one GAME SPEED row is a single ladder that
applies everywhere at once.
This needs to be an engine change, not a mod: there is no per-frame seam a
mod can use to swap the multiplier mid-step, and no public event granular
enough to say "which category is active" (`screen.pushed`/`screen.popped`
and `battle.started`/`battle.ended` are the closest and are not enough --
see Decisions below). The engine's own speed resolution has to become
category-aware.
A category-aware speed resolution is also the general seam a
platform-launcher integration or automation tool needs to read or override
the effective multiplier for a given frame without caring which category
produced it -- this RFC's `core.logic_speed` hook is written for that case
alongside the player-facing Options rows.
## The decision it extends
No prior D-number. Extends `GameSpeed.lua`'s multiplier ladder (unchanged)
with per-category resolution.
## The exact API delta
Backward-compatible except for one save-data field rename, which ships with
an automatic migration (see below) -- nothing in the public mod API (hooks,
events, registries, `mod.*`) is renamed or removed.
### `save.options`: `speed` -> `speedOverworld` / `speedBattle` / `speedMenu`
`GameSpeed.CATEGORIES = { "overworld", "battle", "menu" }` is the new list
of categories, and `GameSpeed.optionKey(category)` maps a category to its
`save.options` field name (`"overworld"` -> `"speedOverworld"`, etc.).
`GameSpeed.LEVELS`, `.DEFAULT`, `.levelLabel`, `.clamp` and `.cycle` are
unchanged -- the ladder and its behavior are exactly what they were, just
applied three times instead of once.
`SaveData.defaultOptions()` drops `speed = 1` and adds `speedOverworld = 1`,
`speedBattle = 1`, `speedMenu = 1`. `SaveData.mergeOptions()` migrates: a
loaded options table that still has `speed` and none of the three new
fields seeds all three from it, so an existing player's fast-forward
preference carries over instead of two of the three categories silently
resetting to 1X. `speed` is dropped on the way out (not carried forward),
so a re-save never re-triggers the migration.
### `Game.speedCategoryInStack(stack)`
New static helper, `(stack) -> "battle" | "overworld" | "menu"`. Walks the
whole state stack top-down -- the same idiom `Game.wideBattleInStack` and
`Game.fillScaleInStack` already use -- looking for `state.isBattle` (new
marker, `BattleState.isBattle = true`, covering every battle: wild,
trainer, link, safari, the old-man demo) or `state.isOverworld` (existing
marker, `OverworldController`'s `OverworldState.isOverworld = true`). The
first match wins; a state with neither marker (a menu, a text box, a
naming screen, a cutscene) is transparent to the walk and falls through to
whatever is under it. Nothing in the stack matching either falls back to
`"menu"`.
### `Game:logicSpeed()` / `Game:_resolveLogicSpeed()`
`Game:_resolveLogicSpeed()` is new: it resolves `Game.speedCategoryInStack`
against the live stack, maps the category to its `save.options` key via
`GameSpeed.optionKey`, and returns `GameSpeed.clamp` of that option (or
`GameSpeed.DEFAULT`). This is the exact category-resolution logic the new
hook wraps.
`Game:logicSpeed()` keeps its existing early returns -- link play forces
`1`, a run-argument speed override wins over the saved option -- unchanged,
and in the same order, before ever calling the hook. Only once neither
applies does it call the `core.logic_speed` hook.
### `Game:_cycleSpeed(dir)`
The keyboard hotkey and the gamepad shoulders/triggers that used to cycle
the single `speed` option now cycle whichever category
`Game.speedCategoryInStack` says is active: pressing the hotkey during a
battle speeds up just the battle, on the overworld just the walk, in a menu
just the menu. This is the natural per-category answer for a control that
used to have one option to reach and now has three -- see Decisions below
for why this reading was chosen over, say, always cycling `overworld`.
### `core.logic_speed`
New hook, `(game) -> number` through the public wrapper signature
`(next, game)`, called once per `Game:logicSpeed()` (i.e. once per frame).
Vanilla behavior (used when no mod claims the hook) is
`Game:_resolveLogicSpeed()` -- exactly the category resolution above,
nothing else. A subscriber may call `next(game)` and return its result to
pass the vanilla multiplier through, or return a different number outright
to override it for that frame (e.g. a bot mod forcing `1` during one route
segment regardless of what category or option is active).
This intentionally sits *after* the link and speed-override checks in
`Game:logicSpeed()`, not around them: link play staying locked to 1X "no
matter what either player set this to" is exactly the invariant that would
break if a mod's hook could override it, and the run-argument override
exists so a bot/screenshot run's speed does not depend on a mod any more
than on the player's saved option. Both stay unconditional early returns a
mod never sees.
Not guarded by `Runtime.wantsHook`: `Hooks:call` already fast-paths to a
bare `vanilla(...)` call when no mod has wrapped the name, and this hook
fires every frame regardless.
## Decisions on the issue's open questions
**1. Overlays on top of another category's state (a party menu, a choice
box, a naming screen opened mid-battle or mid-overworld).** Resolved by
making the category a property of stack *position*, not of the overlay's
own type: an overlay with no `isBattle`/`isOverworld` marker is transparent
to `Game.speedCategoryInStack`'s walk and inherits whatever is under it. A
party swap opened mid-battle reads as `"battle"`; a bag opened while
walking reads as `"overworld"`. This was chosen over giving every UI state
its own fixed category (which would make a fast-forwarded battle visibly
stutter back to 1X every time its party menu opens) because it matches
what the player is actually doing moment to moment, and it reuses a
pattern the codebase already leans on for exactly this "menus opened over
X should behave like X" class of problem (`Game.fillScaleInStack`,
`Game.wideBattleInStack`).
**2. Cutscenes/scripts.** No fourth category. A scripted sequence runs
through the owning state's own machinery -- the overworld's script runner
or a battle's message queue -- rather than pushing a state of its own, so
it is already covered by decision 1: it inherits whatever category the
state driving it resolves to. A cutscene state that genuinely has nothing
under it (a pre-game intro) falls to `"menu"`, the default for anything
that is not battle or overworld gameplay -- consistent with those being
pre-game presentation, not something a player is likely to want scaled
differently from menu navigation.
**3. Category granularity (splitting "menu" further).** Deferred. Start
with the three named here; `GameSpeed.CATEGORIES` and `GameSpeed.optionKey`
are written so adding a fourth later (a Pokédex/Bag category, say) is one
entry plus one new `save.options` field, not a resolution-logic rewrite.
No current request motivates it.
**4. The GAME SPEED hotkey/shoulder buttons, once "the" speed is three
things.** `Game:_cycleSpeed` now cycles whichever category is currently
active (`Game.speedCategoryInStack`), rather than, say, always cycling
`overworld` or requiring a modifier key to pick a category. A single
physical control that means "speed up whatever I'm looking at right now"
is the reading that needs no new UI and matches what a player pressing it
mid-battle almost certainly wants.
## Migration note for existing mods
**Nothing**, for the mod API surface: `content.X:register/override/get`,
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields are
untouched, and `GameSpeed.LEVELS`/`.DEFAULT`/`.levelLabel`/`.clamp`/`.cycle`
keep their exact signatures and behavior.
**One save-data field**, for anything that read `save.options.speed`
directly (not a formal registry/hook surface, but worth naming): it is
superseded by `speedOverworld`/`speedBattle`/`speedMenu`, migrated
automatically on load (see above) so a save from before this RFC keeps its
player's chosen speed. A mod reading `save.options.speed` after this change
sees `nil` (the key is dropped on migration, not kept as a stale alias) and
should read the per-category fields, or hook `core.logic_speed` to observe
the resolved multiplier directly regardless of which category produced it.
## Parity tests
- **No-mod:** `core.logic_speed` needs no dedicated no-mod test file --
`tests/engine/gate_hooks.lua` walks the live hook catalog (which scans
`src` for `Runtime.call("...")` call sites), so the new
`Runtime.call("core.logic_speed", ...)` site is picked up and gated
automatically: vanilla runs exactly once with an empty hook chain, an
unsubscribed-but-live bus passes values and multiple returns through
unchanged, and `Runtime.wantsHook` reads `false`.
- **Mod-API:** `tests/engine/game_speed_categories_test.lua` exercises the
hook through the public API (`Hooks.new()` + `bus:wrap("core.logic_speed",
...)` + `Runtime.call`, the same idiom other hooks' tests use) -- a
subscriber can read the vanilla category resolution via `next(game)` and
can override it outright -- plus direct coverage of
`Game.speedCategoryInStack` (battle-on-top, overworld-on-top, an overlay
inheriting each, an empty/unmatched stack falling to `"menu"`) and
`Game:logicSpeed()`'s precedence (link forces 1X over all three
categories and over a hook override; the run-argument override wins over
the category resolution).
- `tests/run_tests.lua`'s OptionsMenu walk exercises the three new rows
(OVERWORLD SPEED / BATTLE SPEED / MENU SPEED) cycling and wrapping
independently, in place of the old single GAME SPEED row.
- `tests/mod_ui_tests.lua`'s row-id/order check and hardcoded row-index
activations (MODS, CONTROLS) are updated for the two extra rows.
- A link-play driver should set all three per-category speeds high before
asserting `game:logicSpeed()` reads `1` during a real link session,
proving the lock wins over every category at once, not just whichever
one happens to be active.
## Deprecation etiquette
Nothing deprecated in the mod-facing hook/event/registry catalog -- this
adds one hook, additive. The `save.options.speed` field is superseded with
an automatic migration rather than a deprecation notice, since it was never
a registered mod-API surface (no schema entry, no registry) -- the same
treatment any other `save.options` field would get if it needed reshaping.
+16 -3
View File
@@ -13,6 +13,7 @@ local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE =
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
local LaunchOptions = require("src.core.LaunchOptions")
local NxDisplay = require("src.core.NxDisplay")
local PlatformHooks = require("src.core.PlatformHooks")
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
do
@@ -426,7 +427,11 @@ function love.update(dt)
end
return
end
Game:update(dt)
-- Mods may wrap or veto the per-frame simulation step (pause it, react
-- to external platform state, etc.) -- see docs/modding.md's core.update
-- entry. Vanilla behavior (used when no mod claims the hook) is just
-- Game:update(dt), unconditionally, exactly as before this hook existed.
PlatformHooks.update(Game, dt)
end
function love.draw()
@@ -819,8 +824,16 @@ function love.quit()
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or os.getenv("POKEPORT_IMPORT_ROM")
-- #887: a shortcut session (--game / POKEPORT_GAME) has no launcher to go
-- back to and the restart would re-read the shortcut, so it exits instead.
if Game and not Importer and not quitToLauncher and not scripted
and not launchedIntoGame then
--
-- A platform launcher that owns "return to launcher" itself (see
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
-- claims the hook) is exactly the condition below.
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
return Game and not Importer and not quitToLauncher and not scripted
and not launchedIntoGame
end)
if wouldReturnToLauncher then
quitToLauncher = true
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
-- once, so the restart really does land in the launcher (#887). A failed
@@ -1003,4 +1003,33 @@ void love_android_secondary_enable(int on)
env->DeleteLocalRef(activity);
}
extern "C" __attribute__((visibility("default")))
const char *love_android_poll_secondary_touch()
{
static thread_local std::string event;
event.clear();
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "pollSecondaryDisplayTouch",
"()Ljava/lang/String;");
if (!method)
env->ExceptionClear();
else
{
jstring value = (jstring) env->CallStaticObjectMethod(activity, method);
if (value)
{
const char *utf = env->GetStringUTFChars(value, nullptr);
if (utf)
{
event = utf;
env->ReleaseStringUTFChars(value, utf);
}
env->DeleteLocalRef(value);
}
}
env->DeleteLocalRef(activity);
return event.empty() ? nullptr : event.c_str();
}
#endif // LOVE_ANDROID
@@ -1325,6 +1325,9 @@ public class GameActivity extends SDLActivity {
// in src/jni/love/src/common/android.cpp.
private static volatile SecondaryPresentation secondaryPresentation;
private static volatile boolean secondaryEnabled = false;
private static final int MAX_SECONDARY_TOUCHES = 32;
private static final java.util.ArrayDeque<String> secondaryTouches =
new java.util.ArrayDeque<>();
@Keep
public static void setSecondaryEnabled(final boolean on) {
@@ -1377,6 +1380,7 @@ public class GameActivity extends SDLActivity {
private static void teardownSecondaryDisplay() {
SecondaryPresentation p = secondaryPresentation;
secondaryPresentation = null;
synchronized (secondaryTouches) { secondaryTouches.clear(); }
if (p != null) {
try { p.dismiss(); } catch (Throwable t) {}
}
@@ -1395,6 +1399,13 @@ public class GameActivity extends SDLActivity {
}
}
@Keep
public static String pollSecondaryDisplayTouch() {
synchronized (secondaryTouches) {
return secondaryTouches.pollFirst();
}
}
private static class SecondaryPresentation extends android.app.Presentation {
private final FrameView frameView;
@@ -1461,6 +1472,7 @@ public class GameActivity extends SDLActivity {
private final android.graphics.Paint paint = new android.graphics.Paint();
private final Object lock = new Object();
private int fw, fh;
private int activePointer = -1;
FrameView(Context context) {
super(context);
@@ -1482,6 +1494,52 @@ public class GameActivity extends SDLActivity {
postInvalidate();
}
private void enqueueTouch(String event) {
synchronized (secondaryTouches) {
if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) {
secondaryTouches.clear();
secondaryTouches.addLast("cancel,0,0");
} else {
secondaryTouches.addLast(event);
}
}
}
private int logicalX(float x) {
return Math.min(fw - 1, Math.max(0,
(int) ((x - dst.left) * fw / dst.width())));
}
private int logicalY(float y) {
return Math.min(fh - 1, Math.max(0,
(int) ((y - dst.top) * fh / dst.height())));
}
@Override
public boolean onTouchEvent(android.view.MotionEvent event) {
synchronized (lock) {
int action = event.getActionMasked();
if (action == android.view.MotionEvent.ACTION_DOWN && fw > 0
&& dst.contains((int) event.getX(), (int) event.getY())) {
activePointer = event.getPointerId(0);
enqueueTouch("down," + logicalX(event.getX()) + ","
+ logicalY(event.getY()));
} else if (action == android.view.MotionEvent.ACTION_UP
&& activePointer >= 0) {
int index = event.findPointerIndex(activePointer);
if (index >= 0 && fw > 0) {
enqueueTouch("up," + logicalX(event.getX(index)) + ","
+ logicalY(event.getY(index)));
}
activePointer = -1;
} else if (action == android.view.MotionEvent.ACTION_CANCEL) {
activePointer = -1;
enqueueTouch("cancel,0,0");
}
}
return true;
}
@Override
protected void onDraw(android.graphics.Canvas canvas) {
synchronized (lock) {
+7
View File
@@ -12,6 +12,13 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.1.75",
"date": "2026-08-06",
"size": 9574086,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.75/gen1recomp-0.1.75-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #826 \"Super effective\" and \"not very effective\" SFX are reversed\n- #848 Roms not showing up\n- #876 Remote mod download is unavailable on this platform\n- #899 Importing Pokémon red leads to the contents extracted without a folder called 'red'\n- #902 Audio is wrong!\n\n## Contributors\n\n- @andrewqsantos\n- @bryanthaboi\n- @caorthann-celt\n- @johnjohto\n- @ShaneMcGovernIE"
},
{
"version": "0.1.74",
"date": "2026-08-06",
+34 -1
View File
@@ -85,11 +85,30 @@ public final class GRPickerBridge: NSObject {
types = [.zip]
case "sav":
destName = "picked_save.sav"
default:
// A Nintendo 64 cartridge, for mods that build assets out of one --
// the voxel mod's Pokemon Stadium battle models are the caller this
// was added for. Its own filename on purpose: an N64 ROM landing on
// picked_rom.gb is swept up by the Game Boy importer, deleted, and
// reported to the player as a broken cartridge.
case "stadium":
destName = "picked_stadium.z64"
for ext in ["z64", "n64", "v64"] {
if let t = UTType(filenameExtension: ext) { types.append(t) }
}
case "rom", "":
destName = "picked_rom.gb"
for ext in ["gb", "gbc"] {
if let t = UTType(filenameExtension: ext) { types.append(t) }
}
// An unknown kind is REFUSED rather than treated as a Game Boy ROM.
//
// It used to fall through to picked_rom.gb, so a caller asking for a
// kind this build had never heard of got its file deleted and
// reported as a broken cartridge -- the worst possible answer to
// "I do not know that one". Returning false lets the caller find out
// and offer its own fallback.
default:
return false
}
// .gb/.gbc/.sav resolve to dynamic UTTypes on most devices; offering
// .data as well keeps every real file selectable. The importer
@@ -107,6 +126,20 @@ public final class GRPickerBridge: NSObject {
return present(picker, with: delegate)
}
// Which kinds presentPicker understands, comma separated.
//
// So a CALLER can ask before it calls. A mod that wants a kind this build
// predates cannot otherwise tell "refused" from "the picker would not
// open", and guessing wrong used to cost the player their ROM (see the
// default case above). Asking first turns that into a fallback the caller
// chooses rather than a file it loses.
//
// Kept beside the switch it describes, because the two drifting apart is
// the only way this can lie.
@objc public static func supportedPickerKinds() -> NSString {
return "rom,mod,sav,stadium" as NSString
}
@objc(presentExportWithName:saveDir:)
public static func presentExport(name: UnsafePointer<CChar>?,
saveDir: UnsafePointer<CChar>?) -> Bool {
+44
View File
@@ -84,6 +84,49 @@ int w_pickFile(lua_State *L)
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
}
// love.system.pickFileKinds() -> "rom,mod,sav,stadium", or nil off iOS.
//
// So a caller can ask what this build's picker understands BEFORE opening it.
// An unknown kind is refused (GRPickerBridge), and a refusal looks exactly
// like a picker that would not open -- so a caller with a fallback worth
// showing needs to know which it is facing. A mod that guesses instead has
// no way back: before the refusal landed, an unrecognised kind wrote
// picked_rom.gb and the ROM importer deleted it.
//
// nil where there is no bridge at all, which reads the same as "no kinds".
int w_pickFileKinds(lua_State *L)
{
Class cls = objc_getClass("GRPickerBridge");
if (cls == nullptr)
{
lua_pushnil(L);
return 1;
}
// Fetched through the runtime: wrap_System.cpp is compiled as C++ rather
// than Objective-C++, so no Foundation type may be NAMED here -- writing
// `NSString` alone breaks the whole translation unit. objc_msgSend is a
// plain C entry point and `id` comes from objc/runtime.h, so the string
// is asked for its UTF8 bytes without ever being typed.
typedef id (*GRObj)(Class, SEL);
id kinds = ((GRObj)objc_msgSend)(cls,
sel_registerName("supportedPickerKinds"));
if (kinds == nullptr)
{
lua_pushnil(L);
return 1;
}
typedef const char *(*GRUTF8)(id, SEL);
const char *bytes = ((GRUTF8)objc_msgSend)(kinds,
sel_registerName("UTF8String"));
if (bytes == nullptr || bytes[0] == '\0')
{
lua_pushnil(L);
return 1;
}
lua_pushstring(L, bytes);
return 1;
}
int w_createFile(lua_State *L)
{
const char *name = luaL_optstring(L, 1, "export.sav");
@@ -101,6 +144,7 @@ int w_syncHealthSteps(lua_State *L)
WRAP_REGISTRATION = """#ifdef LOVE_IOS
{ "pickFile", w_pickFile },
{ "pickFileKinds", w_pickFileKinds },
{ "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload },
+27
View File
@@ -0,0 +1,27 @@
# spanish_ui
A Espanol translation of the game.
Generated with `python3 tools/modkit.py translation spanish_ui`. See
`TRANSLATING.md` for how to work on it.
## Status
Nothing is translated yet: 601 strings are waiting in `lang/`.
| Catalog | Entries |
|---|---|
| `lang/dialogue.lua` | 6 |
| `lang/strings.lua` | 577 |
| `lang/species_names.lua` | 3 |
| `lang/move_names.lua` | 4 |
| `lang/item_names.lua` | 5 |
| `lang/trainer_names.lua` | 1 |
| `lang/status_labels.lua` | 5 |
## Layout
- `manifest.json` - identity and the engine version range
- `main.lua` - registers whatever is filled in and skips whatever is not
- `lang/` - the catalogs; this is the whole job
- `assets/font/` - your glyph sheet
+111
View File
@@ -0,0 +1,111 @@
# Translating into Espanol
Everything the player can read is one of two kinds of string, and they live
in different places for a reason.
| lang/ file | What it is | Key |
|---|---|---|
| `dialogue.lua` | Every line of extracted script text | the original label, e.g. `_PalletTownText1` |
| `strings.lua` | Text the engine itself writes: battle messages, menus, link play | the English source string |
| `species.lua` `moves.lua` `items.lua` `trainers.lua` | Names | the vanilla id |
| `statuses.lua` | `PSN`, `BRN`, ... as they appear in the HUD | the status id |
| `font.lua` `charmap.lua` | Your glyph sheet and what draws what | see below |
| `naming.lua` | The letter grid for entering names | - |
Fill in a value and it takes effect. Leave it `""` and that string stays in
English, so the game is playable at every point along the way.
## Where the English is
The catalogs hold keys and *your* text, never the original English. The
English lives next door, in `spanish_ui-worksheet/`, one tab-separated file per
catalog:
```
"_AbandonLearningText" "Abandon learning\n{RAM:wStringBuffer}?"
```
That directory is deliberately outside the mod. Extracted script text and
the vanilla names are ROM content, and `modkit pack` zips everything under
the mod directory, so a worksheet kept inside would end up in your release
whatever a `.gitignore` said. Keep it beside the mod, never in it.
`lang/strings.lua` is the exception: those sources are the engine's own Lua
rather than anything out of the ROM, so there the key *is* the English and
you can translate straight from it.
## Start with the font, not the text
The engine draws from **glyph pages**: an image of 8x8 cells plus a charmap
saying which byte sequence draws which cell. The vanilla pages sit at `$60`
and `$80`. Anything from `0x100` up is free, so a new alphabet is added
rather than swapped in:
```lua
-- lang/font.lua
return {
spanish_ui = {
image = "assets/font/spanish_ui.png",
base = 0x100, -- first code this page owns
glyphsPerRow = 16,
-- advance = 8, -- set this if your glyphs are not 8px wide
},
}
```
```lua
-- lang/charmap.lua: sequence -> code, in the same order as the sheet
return {
["A"] = 0x100,
["B"] = 0x101,
}
```
The sheet is a plain PNG, 16 glyphs to a row by default, each cell 8x8,
black on white like `assets/generated/font.png`. Codes run left to right,
top to bottom from `base`.
Sequences are matched **longest first**, so a multi-byte character and a
multi-character ligature both work and neither shadows the other:
```lua
["\u{3042}"] = 0x120, -- one 3-byte character, one glyph
["ch"] = 0x121, -- two ASCII letters, one glyph
```
## Line length is counted in glyphs
The dialogue box fits 18 glyphs a line, not 18 bytes. A 3-byte character
costs one column, and the engine will never cut a character in half. Your
own `\n` line breaks are respected exactly as written, so break lines where
they read best rather than where they fit English.
If your glyphs are not 8px wide, set `advance` on the page and the box
re-measures.
## Format directives must survive
Some sources carry `%s` or `%d`:
```lua
["Wild %s\nappeared!"] = "...",
```
Keep every directive, in a count that matches. Word order is yours to
change; the engine substitutes in the order the directives appear, so if
your language needs the name last, write the sentence with the `%s` last.
A translation whose directive count does not match the English is refused
at runtime and the English is drawn instead, with a line in the log saying
so - it will not crash a battle.
## Checking your work
```sh
python3 tools/modkit.py validate spanish_ui --base imported
python3 tools/modkit.py translation spanish_ui --refresh # pick up new engine strings
POKEPORT_DEV=1 scripts/run.sh # F5 hot-reloads lang/
```
`--refresh` rewrites the catalogs from the current engine, keeping every
translation you have already written and reporting what changed. Run it
after pulling a new engine version.
+11
View File
@@ -0,0 +1,11 @@
Put your glyph sheet here.
A page is a PNG of 8x8 cells, 16 per row by default, black on white. Codes
run left to right and top to bottom starting at the page's `base`, so the
first cell is `base`, the second `base + 1`, and so on.
`assets/generated/font.png` in the player's cache is the vanilla sheet at
the same scale; open it alongside yours to match weight and baseline.
Declare the sheet in `lang/font.lua` and map sequences to codes in
`lang/charmap.lua`.
+10
View File
@@ -0,0 +1,10 @@
-- Which byte sequence draws which glyph code.
--
-- Sequences are matched longest-first, so a multi-byte character and a
-- multi-character ligature both work: "ch" can be one glyph even though
-- "c" is also mapped. Codes here must land inside a page declared in
-- lang/font.lua.
return {
-- ["A"] = 0x100,
-- ["B"] = 0x101,
}
+12
View File
@@ -0,0 +1,12 @@
-- Script text
--
-- Keyed by the original text label. The English is in the comment.
return {
["_FixMartText"] = "",
["_FixRouteTrainerAfterText"] = "",
["_FixRouteTrainerBattleText"] = "",
["_FixRouteTrainerEndText"] = "",
["_FixTownGreeterText"] = "",
["_FixTownSignText"] = "",
}
+13
View File
@@ -0,0 +1,13 @@
-- Glyph pages this translation adds. Delete the entry if the vanilla
-- alphabet already covers your language.
--
-- base is the first glyph code the page owns. 0x100 and up is free space
-- above the vanilla $60/$80 pages, so this adds an alphabet rather than
-- replacing one. Set `advance` if your glyphs are not 8px wide.
return {
-- spanish_ui = {
-- image = "assets/font/spanish_ui.png",
-- base = 0x100,
-- glyphsPerRow = 16,
-- },
}
+11
View File
@@ -0,0 +1,11 @@
-- Item names
--
-- Item names for Espanol.
return {
["FIX_BADGE_1"] = "",
["FIX_BADGE_2"] = "",
["FIX_BALL"] = "",
["FIX_POTION"] = "",
["FIX_TM"] = "",
}
+10
View File
@@ -0,0 +1,10 @@
-- Move names
--
-- Move names for Espanol.
return {
["FIX_CUT"] = "",
["FIX_EMBERISH"] = "",
["FIX_SCRATCH"] = "",
["FIX_TACKLE"] = "",
}
+41
View File
@@ -0,0 +1,41 @@
-- The naming screen's letter grid. Return an empty table to keep the
-- English alphabet.
--
-- Each entry is a row of cells; a cell is whatever sequence your charmap
-- maps, so a multi-byte character is one cell. The row holding a single
-- "lower case" / "UPPER CASE" cell is the case switch, and the cell
-- spelled "ED" is the confirm.
--
-- The screen is 160x144 and NamingScreen draws cell `c` of row `r` at
-- (c * 16, 32 + r * 16), so the grid is capped at **9 columns and 6 rows**:
-- a 10th column lands at x=160 and a 7th row at y=144, both off screen.
-- That leaves 44 usable cells, exactly what vanilla uses, so Spanish
-- letters have to displace something rather than being added.
--
-- What gives way is vanilla's `× ( ) : ; [ ]` row. Those are legal in a
-- Gen-1 nickname but nobody reaches for them, whereas Ñ is not optional in
-- Spanish -- and here it sits in its alphabetical place after N, which is
-- where a Spanish speaker will look for it. Space, <PK> and <MN> are kept.
--
-- These glyphs exist in the Spanish cartridge's font ($CA Ñ, $BF Á, $C7 É,
-- $C9 Í, $CC Ó, $CE Ú, $C2 Ü and their lowercase). On an English ROM they
-- do not, so main.lua checks the running game's charmap first and keeps the
-- English grid rather than drawing blank cells.
return {
upper = {
{ "A", "B", "C", "D", "E", "F", "G", "H", "I" },
{ "J", "K", "L", "M", "N", "Ñ", "O", "P", "Q" },
{ "R", "S", "T", "U", "V", "W", "X", "Y", "Z" },
{ "Á", "É", "Í", "Ó", "Ú", "Ü", " ", "<PK>", "<MN>" },
{ "-", "?", "!", "", "", "/", ".", ",", "ED" },
{ "lower case" },
},
lower = {
{ "a", "b", "c", "d", "e", "f", "g", "h", "i" },
{ "j", "k", "l", "m", "n", "ñ", "o", "p", "q" },
{ "r", "s", "t", "u", "v", "w", "x", "y", "z" },
{ "á", "é", "í", "ó", "ú", "ü", " ", "<PK>", "<MN>" },
{ "-", "?", "!", "", "", "/", ".", ",", "ED" },
{ "UPPER CASE" },
},
}
+9
View File
@@ -0,0 +1,9 @@
-- Species names
--
-- Species names for Espanol.
return {
["FIXMON_A"] = "",
["FIXMON_B"] = "",
["FIXMON_C"] = "",
}
+11
View File
@@ -0,0 +1,11 @@
-- Status labels
--
-- Short enough for the battle HUD: the vanilla ones are three glyphs.
return {
["BRN"] = "",
["FRZ"] = "",
["PAR"] = "",
["PSN"] = "",
["SLP"] = "",
}
+584
View File
@@ -0,0 +1,584 @@
-- Engine text
--
-- Keyed by the English source, which is also what draws if you leave
-- an entry empty. Keep any %s / %d directives.
return {
["%s\nflew up high!"] = "¡%s\nvoló muy alto!",
["%s\ndug a hole!"] = "¡%s\ncavó un hoyo!",
["%s\nmade a whirlwind!"] = "¡%s\ncreó un torbellino!",
["%s\ntook in sunlight!"] = "¡%s\nabsorbió luz!",
["%s\nlowered its head!"] = "¡%s\nbajó la cabeza!",
["%s\nis glowing!"] = "¡%s\nestá brillando!",
["The hooked\n%s\nattacked!"] = "¡El %s\nenganchado atacó!",
["Wild %s\nappeared!"] = "¡Un %s\nsalvaje apareció!",
["%s wants\nto fight!"] = "¡%s\nquiere luchar!",
["The GHOST\nappeared!"] = "¡Apareció el\nFANTASMA!",
["Go! %s!"] = "¡Ve, %s!",
["Do it! %s!"] = "¡Hazlo, %s!",
["Get'm! %s!"] = "¡A por él, %s!",
["The enemy's weak!\nGet'm! %s!"] = "¡Está débil!\n¡A por él, %s!",
["%s is out of\nuseable POKéMON!"] = "¡%s no tiene\nPOKéMON útiles!",
["%s blacked\nout!"] = "¡%s se\ndebilitó!",
["%s sent\nout %s!"] = "¡%s envió\na %s!",
["PA: You're out of\nSAFARI BALLs!\nGame over!"] = "AV: ¡No te quedan\nSAFARI BALLs!\n¡Fin del juego!",
["%s is too\nscared to move!"] = "¡%s tiene\ndemasiado miedo!",
["%s has no\nmoves left!"] = "¡%s no tiene\nmovimientos!",
["The move is\ndisabled!"] = "¡El movimiento\nestá anulado!",
["No PP left for\nthis move!"] = "¡No quedan PP para\neste movimiento!",
["But, it failed!"] = "¡Pero falló!",
["%s\nlearned\n%s!"] = "¡%s\naprendió\n%s!",
["POKé BALL"] = "POKé BALL",
["%s used\nPOKé BALL!"] = "¡%s usó\nPOKé BALL!",
["All right!\n%s was\ncaught!"] = "¡Bien!\n¡%s fue\ncapturado!",
["GHOST: Get out...\nGet out..."] = "FANTASMA: Fuera...\nFuera...",
["%s with-\ndrew %s!"] = "¡%s retiró\na %s!",
["%s\nmust recharge!"] = "¡%s debe\nrecargarse!",
["%s\nis fast asleep!"] = "¡%s está\nprofundamente dormido!",
["%s\nis confused!"] = "¡%s está\nconfuso!",
["%s\nwoke up!"] = "¡%s se\ndespertó!",
["%s\nis frozen solid!"] = "¡%s está\ncongelado!",
["%s\ncan't move!"] = "¡%s no\npuede moverse!",
["%s\nflinched!"] = "¡%s se\namedrentó!",
["It hurt itself in\nits confusion!"] = "¡Se hirió a sí\nmismo por confusión!",
["%s\nused %s!"] = "¡%s usó\n%s!",
["%s\nis charging up!"] = "¡%s está\ncargando energía!",
["%s's\nattack missed!"] = "¡El ataque de %s\nfalló!",
["%s's\nattack continues!"] = "¡El ataque de %s\ncontinúa!",
["%s\nis storing energy!"] = "¡%s está\nacumulando energía!",
["%s\nunleashed energy!"] = "¡%s liberó\nsu energía!",
["%s's\nSUBSTITUTE broke!"] = "¡El SUSTITUTO de\n%s se rompió!",
["The SUBSTITUTE\ntook damage for\n%s!"] = "¡El SUSTITUTO\nrecibió el daño\nde %s!",
["%s's\nRAGE is building!"] = "¡La FURIA de %s\nva creciendo!",
["%s\nfainted!"] = "¡%s se\ndebilitó!",
["%s gained\n%d EXP. Points!"] = "¡%s ganó\n%d P. EXP.!",
["%s gained\nwith EXP.ALL,\v%d EXP. Points!"] = "¡%s ganó\ncon EXP.TODOS,\v%d P. EXP.!",
["%s gained\na boosted\v%d EXP. Points!"] = "¡%s ganó\nun extra de\v%d P. EXP.!",
["%s grew\nto level %d!"] = "¡%s subió\nal nivel %d!",
["%s is\nabout to use"] = "%s va a usar",
["%s!"] = "¡%s!",
["Will %s\nchange POKéMON?"] = "¿%s va a\ncambiar de POKéMON?",
["%s defeated\n%s!"] = "¡%s venció\na %s!",
["%s got ¥%d\nfor winning!"] = "¡%s ganó\n¥%d!",
["%s learned\n%s!"] = "¡%s aprendió\n%s!",
["{RIVAL}: Yeah! Am\nI great or what?"] = "{RIVAL}: ¡Sí! ¿Soy\ngenial o qué?",
["Use next POKéMON?"] = "¿Sacar al siguiente?",
["Got away safely!"] = "¡Escapaste!",
["Can't escape!"] = "¡No puedes escapar!",
["There's no will\nto fight!"] = "¡No hay ganas de\nluchar!",
["%s used\nSAFARI BALL!"] = "¡%s usó\nSAFARI BALL!",
["%s threw some\nBAIT."] = "%s echó\nCEBO.",
["%s threw a\nROCK."] = "%s tiró una\nPIEDRA.",
["Wild %s\nis eating!"] = "¡El %s\nsalvaje come!",
["Wild %s\nis angry!"] = "¡El %s\nsalvaje se enfadó!",
["Wild %s\nran!"] = "¡El %s\nsalvaje huyó!",
["No! There's no\nrunning from a\vtrainer battle!"] = "¡No! ¡No puedes\nhuir de un combate\vcontra un entrenador!",
["You missed the\nPOKéMON!"] = "¡Fallaste el tiro!",
["Darn! The POKéMON\nbroke free!"] = "¡Vaya! ¡El POKéMON\nse escapó!",
["Aww! It appeared\nto be caught!"] = "¡Oh! ¡Parecía que\nestaba capturado!",
["Shoot! It was so\nclose too!"] = "¡Vaya! ¡Estuvo\nmuy cerca!",
["Do you want to\ngive a nickname\nto %s?"] = "¿Quieres poner un\nmote a\n%s?",
["NICKNAME?"] = "MOTE?",
["New POKéDEX data\nwill be added for\n%s!"] = "¡Se añadirán datos\nnuevos a la POKéDEX\nde %s!",
["someone's PC"] = "el PC de alguien",
["%s was\ntransferred to\n%s!"] = "¡%s fue\ntransferido a\n%s!",
["But every BOX\nis full!"] = "¡Pero todas las\nCAJAS están llenas!",
["%s used\n%s!"] = "¡%s usó\n%s!",
["The trainer\nblocked the BALL!"] = "¡El entrenador\nbloqueó la BALL!",
["Don't be a thief!"] = "¡No seas ladrón!",
["It dodged the\nthrown BALL!"] = "¡Esquivó la BALL!",
["This POKéMON\ncan't be caught!"] = "¡Este POKéMON no\nse puede capturar!",
["%s is\nalready out!"] = "¡%s ya\nestá fuera!",
["%s picked up\n¥%d!"] = "¡%s recogió\n¥%d!",
["FIGHT"] = "LUCHAR",
["ITEM"] = "OBJETO",
["RUN"] = "HUIR",
["BALLx"] = "BALLx",
["BAIT"] = "CEBO",
["THROW ROCK"] = "TIRAR PIEDRA",
["disabled!"] = "¡anulado!",
["TYPE/"] = "TIPO/",
["It doesn't affect\n%s!"] = "¡No afecta a\n%s!",
["Critical hit!"] = "¡Golpe crítico!",
["One-hit KO!"] = "¡KO en un golpe!",
["It's super\neffective!"] = "¡Es muy eficaz!",
["It's not very\neffective..."] = "¡No es muy\neficaz...",
["Hit the enemy\n%d times!"] = "¡Golpeó al enemigo\n%d veces!",
["Hit %d times!"] = "¡Golpeó %d veces!",
["%s's\nhit with recoil!"] = "¡%s sufrió\nel retroceso!",
["%s is\nprotected by MIST!"] = "¡%s está\nprotegido por NIEBLA!",
["Nothing happened!"] = "¡No pasó nada!",
["%s's\n%s\ngreatly rose!"] = "¡%s\nmejoró mucho su\n%s!",
["%s's\n%s rose!"] = "¡%s mejoró\nsu %s!",
["%s's\n%s fell!"] = "¡%s bajó\nsu %s!",
["%s's\n%s\ngreatly fell!"] = "¡%s\nbajó mucho su\n%s!",
["Fire defrosted\n%s!"] = "¡El fuego descongeló\na %s!",
["%s\nbecame confused!"] = "¡%s se\nconfundió!",
["%s\nwas seeded!"] = "¡%s recibió\nla DRENADORA!",
["%s\nstarted sleeping!"] = "¡%s se\nquedó dormido!",
["%s\nregained health!"] = "¡%s recuperó\nsalud!",
["%s's\nprotected against\nspecial attacks!"] = "¡%s está\nprotegido de los\nataques especiales!",
["%s\ngained armor!"] = "¡%s ganó\narmadura!",
["%s's\nshrouded in mist!"] = "¡%s se\ncubrió de niebla!",
["%s's\ngetting pumped!"] = "¡%s se\nestá animando!",
["All STATUS changes\nare eliminated!"] = "¡Los cambios de\nESTADO desaparecen!",
["%s\nhas a SUBSTITUTE!"] = "¡%s tiene\nun SUSTITUTO!",
["Too weak to make\na SUBSTITUTE!"] = "¡Muy débil para\nhacer un SUSTITUTO!",
["It created a\nSUBSTITUTE!"] = "¡Creó un SUSTITUTO!",
["Converted type to\n%s's!"] = "¡Cambió su tipo al\nde %s!",
["%s\ntransformed into\n%s!"] = "¡%s se\ntransformó en\n%s!",
["%s's\n%s was\ndisabled!"] = "¡El %s\nde %s\nfue anulado!",
["No effect!"] = "¡Sin efecto!",
["Sucked health from\n%s!"] = "¡Absorbió salud de\n%s!",
["%s's\ndream was eaten!"] = "¡Devoró el sueño\nde %s!",
["%s\nkept going and\ncrashed!"] = "¡%s siguió\nadelante y se\nestrelló!",
["Coins scattered\neverywhere!"] = "¡Las monedas se\ndesparramaron!",
["%s\nran away scared!"] = "¡%s huyó\nasustado!",
["%s\nwas blown away!"] = "¡%s salió\nvolando!",
["%s\nran from battle!"] = "¡%s huyó\ndel combate!",
["It didn't affect\n%s!"] = "¡No afectó a\n%s!",
["%s\nis unaffected!"] = "¡%s no se\nvio afectado!",
["The MIRROR MOVE\nfailed!"] = "¡El MOVIMIENTO\nESPEJO falló!",
["%s\nfell asleep!"] = "¡%s se\nquedó dormido!",
["%s\nwas frozen solid!"] = "¡%s se\ncongeló!",
["%s's\nhurt by poison!"] = "¡El veneno hiere a\n%s!",
["%s's\nbadly poisoned!"] = "¡%s está\ngravemente envenenado!",
["%s\nwas poisoned!"] = "¡%s fue\nenvenenado!",
["%s's\nhurt by the burn!"] = "¡La quemadura hiere\na %s!",
["%s\nwas burned!"] = "¡%s se\nquemó!",
["%s's\nfully paralyzed!"] = "¡%s está\ntotalmente paralizado!",
["%s's\nparalyzed! It may\nnot attack!"] = "¡%s está\nparalizado! ¡Puede\nque no ataque!",
["%s's\ndisabled no more!"] = "¡%s ya no\nestá anulado!",
["%s\nsnapped out of\nconfusion!"] = "¡%s salió\nde su confusión!",
["LEECH SEED saps\n%s!"] = "¡La DRENADORA\nabsorbe a %s!",
["%s\nwas afflicted\nby %s!"] = "¡%s sufre\n%s!",
["%s's\nprotected against\nstat changes!"] = "¡%s está\nprotegido de los\ncambios de estado!",
["What will"] = "¿Qué va a hacer",
[" do?"] = "?",
["You can't get off\nhere."] = "No puedes bajarte\naquí.",
["%s got off\nthe BICYCLE."] = "%s se bajó\nde la BICICLETA.",
["%s got on\nthe BICYCLE!"] = "¡%s se subió\na la BICICLETA!",
["No cycling\nallowed here."] = "No se puede montar\naquí.",
["No good! It's not\neven near water."] = "¡No sirve! No hay\nagua cerca.",
["OAK: %s!\nThis isn't the\ntime to use that!"] = "OAK: ¡%s!\n¡No es momento\nde usar eso!",
["The TOWN MAP is\nunreadable here."] = "El MAPA PUEBLO no\nse puede leer aquí.",
["Yes! ITEMFINDER\nindicates there's\nan item nearby."] = "¡Sí! El BUSCAOBJ.\nindica que hay algo\ncerca.",
["Nope! ITEMFINDER\nisn't responding."] = "¡No! El BUSCAOBJ.\nno responde.",
["Booted up a TM!"] = "¡Se activó una MT!",
["It contained\n%s!"] = "¡Contenía\n%s!",
["USE"] = "USAR",
["TOSS"] = "TIRAR",
["That's too impor-\ntant to toss!"] = "¡Es demasiado\nimportante!",
["Threw away\n%s."] = "Tiraste\n%s.",
["PRESS A BUTTON"] = "PULSA UN BOTON",
["ESC TO CANCEL"] = "ESC PARA CANCELAR",
["%s :L%d"] = "%s :N%d",
["STATS"] = "DATOS",
["CANCEL"] = "CANCELAR",
["What? There are\nno POKéMON here!"] = "¿Qué? ¡Aquí no hay\nningún POKéMON!",
["You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."] = "No puedes llevar\nmás POKéMON.\fGuarda alguno\nprimero.",
["BOX %d (WITHDRAW)"] = "CAJA %d (RETIRAR)",
["%s is\ntaken out.\vGot %s."] = "Retirado\n%s.\vRecibes %s.",
["You can't deposit\nthe last POKéMON!"] = "¡No puedes guardar\nel último POKéMON!",
["Oops! This Box is\nfull of POKéMON."] = "¡Uups! Esta CAJA\nestá llena.",
["You need at least\none POKéMON!"] = "¡Necesitas al menos\nun POKéMON!",
["BOX %d is full!"] = "¡La CAJA %d está\nllena!",
["%s was\nstored in Box %s."] = "%s se\nguardó en la CAJA %s.",
["BOX %d (RELEASE)"] = "CAJA %d (SOLTAR)",
["Once released,\n%s is\ngone forever. OK?"] = "Si lo sueltas,\n%s se\nirá para siempre. ¿OK?",
["%s was\nreleased outside.\fBye %s!"] = "%s fue\nliberado.\f¡Adiós, %s!",
["%sBOX %2d"] = "%sCAJA %2d",
["When you change a\nPOKéMON BOX, data\nwill be saved. OK?"] = "Al cambiar de CAJA\nse guardarán los\ndatos. ¿OK?",
["What?"] = "¿Qué?",
["BOX No."] = "CAJA No.",
["BOX No.%d"] = "CAJA No.%d",
["Empty."] = "Vacía.",
[":L%d No.%03d"] = ":N%d No.%03d",
["Printed BOX %d!\fSaved as\n%s\vin the save\nfolder."] = "¡CAJA %d impresa!\fGuardada como\n%s\ven la carpeta de\nguardado.",
["Printer error!\n%s"] = "¡Error de impresión!\n%s",
["WITHDRAW <PK><MN>"] = "RETIRAR <PK><MN>",
["DEPOSIT <PK><MN>"] = "GUARDAR <PK><MN>",
["RELEASE <PK><MN>"] = "SOLTAR <PK><MN>",
["CHANGE BOX"] = "CAMBIAR CAJA",
["PRINT BOX"] = "IMPRIMIR CAJA",
["SEE YA!"] = "HASTA LUEGO!",
["YES"] = "SI",
["NO"] = "NO",
["GAME FREAK"] = "",
["Nintendo"] = "",
["Creatures inc."] = "",
["GAME FREAK inc."] = "",
["T H E E N D"] = "F I N",
["HT %d%02d″"] = "AL %d%02d″",
["WT %.1flb"] = "PE %.1flb",
["Data unknown."] = "Datos desconocidos.",
["<Diploma>"] = "<Diploma>",
["Player"] = "Jugador",
["Huh? %s\nstopped evolving!"] = "¿Eh? ¡%s\ndejó de evolucionar!",
["Congratulations!\nYour %s\nevolved into\n%s!"] = "¡Enhorabuena!\n¡Tu %s\nevolucionó a\n%s!",
["evolving!"] = "evolucionando",
["POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"] = "POKéDEX Vistos:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Capturados:{NUM:wDexRatingNumMonsOwned, 1, 3}",
["POKéDEX Rating{COLON}"] = "Nota POKéDEX{COLON}",
["Keep it up!"] = "¡Sigue así!",
["LEVEL/"] = "NIVEL/",
["TYPE1/"] = "TIPO1/",
["TYPE2/"] = "TIPO2/",
["HALL OF FAME"] = "SALON DE LA FAMA",
["PLAY TIME"] = "TIEMPO",
["MONEY"] = "DINERO",
["bois club games"] = "bois club games",
["GENGAR VS NIDORINO"] = "",
["bois club"] = "bois club",
["Nothing here."] = "Aquí no hay nada.",
["%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f"] = "¡%s está\nintentando aprender\v%s!\f¡Pero %s\nno puede aprender\vmás de 4!\f",
["Delete an older\nmove to make room\vfor %s?"] = "¿Borrar un movi-\nmiento antiguo para\vaprender %s?",
["HM techniques\ncan't be deleted!"] = "¡Los movimientos MO\nno se pueden borrar!",
["Abandon learning\n%s?"] = "¿Dejar de aprender\n%s?",
["1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!"] = "¡1, 2 y... plaf!\f¡%s olvidó\n%s!\f¡Y...\f%s aprendió\n%s!",
["%s\ndid not learn\v%s!"] = "¡%s no\naprendió\v%s!",
["Which move should"] = "¿Qué movimiento",
["be forgotten?"] = "hay que olvidar?",
["YOUR NAME?"] = "TU NOMBRE?",
["NEW NAME"] = "NUEVO NOMBRE",
["Hello there!\nWelcome to the\vworld of POKéMON!\fMy name is OAK!\nPeople call me\vthe POKéMON PROF!"] = "¡Hola!\n¡Bienvenido al\vmundo POKéMON!\fMe llamo OAK.\nMe llaman el\vPROF. POKéMON.",
["This world is\ninhabited by\vcreatures called\vPOKéMON!"] = "¡Este mundo está\nhabitado por unas\vcriaturas llamadas\vPOKéMON!",
["\fFor some people,\nPOKéMON are\vpets. Others use\vthem for fights.\fMyself...\fI study POKéMON\nas a profession."] = "\fPara algunos, los\nPOKéMON son masco-\vtas. Otros luchan\vcon ellos.\fYo...\fEstudio los POKéMON\ncomo profesión.",
["{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!"] = "¡{PLAYER}!\f¡Tu propia leyenda\nPOKéMON está a\vpunto de comenzar!\f¡Un mundo de sueños\ny aventuras con\vPOKéMON te espera!\v¡Vamos!",
["First, what is\nyour name?"] = "¿Cómo te llamas?",
["This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?"] = "Este es mi nieto.\nHa sido tu rival\vdesde que erais\vbebés.\f...Mmm, ¿cómo se\nllamaba?",
["HIS NAME?"] = "SU NOMBRE?",
["_OakSpeechText2A"] = "",
["TEXT SPEED"] = "VEL TEXTO",
["BATTLE ANIMATION"] = "ANIMACIONES",
["OFF"] = "NO",
["ON"] = "SI",
["BATTLE STYLE"] = "ESTILO COMBATE",
["SET"] = "FIJO",
["SHIFT"] = "CAMBIO",
["BATTLE LAYOUT"] = "DISENO COMBATE",
["WIDE"] = "ANCHO",
["OG"] = "OG",
["RULESET"] = "REGLAS",
["MUSIC VOL"] = "VOL MUSICA",
["SFX VOL"] = "VOL SONIDO",
["PIKACHU VOL"] = "VOL PIKACHU",
["MUSIC FILTER"] = "FILTRO MUSICA",
["COLORS"] = "COLORES",
["TILT"] = "INCLINACION",
["GBC FX"] = "EFECTO GBC",
["ZOOM"] = "ZOOM",
["VOID FILL"] = "RELLENO VACIO",
["VIDEO MODE"] = "MODO VIDEO",
["MAX FPS"] = "FPS MAXIMO",
["GAME SPEED"] = "VELOCIDAD JUEGO",
["MODS"] = "MODS",
["%d INSTALLED"] = "%d INSTALADOS",
["CONTROLS"] = "CONTROLES",
["TOUCH PAD"] = "CONTROL TACTIL",
["SURE? AGAIN"] = "SEGURO? OTRA VEZ",
["AUTO HIDE PAD"] = "OCULTAR AUTO",
["A blinding FLASH\nlights the area!"] = "¡Un DESTELLO\nilumina la zona!",
["No SURFing here!"] = "¡Aquí no se puede\nSURFEAR!",
["Nothing to CUT!"] = "¡Nada que CORTAR!",
["{RAM:wNameBuffer} used\nSTRENGTH."] = "{RAM:wNameBuffer} usó\nFUERZA.",
["{RAM:wNameBuffer} can\nmove boulders."] = "{RAM:wNameBuffer} puede\nmover rocas.",
["It won't have\nany effect."] = "No tendrá ningún\nefecto.",
["%s's HP\nwas restored!"] = "¡Los PS de %s\nse recuperaron!",
["SWITCH"] = "CAMBIAR",
["FLY"] = "VUELO",
["FLASH"] = "DESTELLO",
["CUT"] = "CORTE",
["SURF"] = "SURF",
["STRENGTH"] = "FUERZA",
["SOFTBOILED"] = "HUEVO SUERTE",
["TELEPORT"] = "TELETRANSPORTE",
["DIG"] = "EXCAVAR",
["Use TM on which\nPOKéMON?"] = "¿Usar la MT en qué\nPOKéMON?",
["Bring out which\nPOKéMON?"] = "¿Qué POKéMON\nquieres sacar?",
["Choose a POKéMON."] = "Elige un POKéMON.",
["No POKéMON!"] = "¡Ningún POKéMON!",
["ABLE"] = "PUEDE",
["NOT ABLE"] = "NO PUEDE",
["FNT"] = "DEB",
["Move to where?"] = "¿Mover a dónde?",
["Use on which one?"] = "¿Usar en cuál?",
["You can't carry\nany more items."] = "No puedes llevar\nmás objetos.",
["Withdrew\n%s."] = "Retirado\n%s.",
["No room left to\nstore items."] = "No queda sitio para\nguardar objetos.",
["%s was\nstored via PC."] = "%s se\nguardó en el PC.",
["Toss %s?"] = "¿Tirar %s?",
["Threw away %s."] = "Tiraste %s.",
["WITHDRAW ITEM"] = "RETIRAR OBJETO",
["DEPOSIT ITEM"] = "GUARDAR OBJETO",
["TOSS ITEM"] = "TIRAR OBJETO",
["LOG OFF"] = "SALIR",
["SEEN %d OWNED %d"] = "VISTOS %d CAPT. %d",
["DATA"] = "DATOS",
["CRY"] = "VOZ",
["AREA"] = "ZONA",
["PRNT"] = "IMPR",
["Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder."] = "¡Datos de %s\nimpresos!\fGuardado como\n%s\ven la carpeta de\nguardado.",
["QUIT"] = "SALIR",
["%s (%s)"] = "%s (%s)",
["%s x%d"] = "%s x%d",
["%s to box %d"] = "%s a caja %d",
["LOAD REPORT"] = "CARGAR PARTIDA",
["A:CONTINUE"] = "A:CONTINUAR",
["You don't have\nenough money."] = "No tienes dinero\nsuficiente.",
["%s?\nThat will be\n¥%d. OK?"] = "¿%s?\nSon ¥%d.\n¿OK?",
["Here you are!\nThank you!"] = "¡Aquí tienes!\n¡Gracias!",
["I can't put a\nprice on that."] = "No puedo ponerle\nprecio a eso.",
["I can pay you\n¥%d for that."] = "Te doy ¥%d\npor eso.",
["BUY"] = "COMPRAR",
["SELL"] = "VENDER",
["%s lined up!\nScored %d coins!"] = "¡%s alineados!\n¡%d fichas!",
["Darn!\nRan out of coins!"] = "¡Vaya!\n¡Sin fichas!",
["Not enough\ncoins!"] = "¡Fichas\ninsuficientes!",
["SLOT MACHINE"] = "MAQUINA TRAGAPERRAS",
["COINS %4d"] = "FICHAS %4d",
["POKéDEX"] = "POKéDEX",
["POKéMON"] = "POKéMON",
["SAVE"] = "GUARDAR",
["PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d"] = "JUGADOR %s\nMEDALLAS %d\nPOKéDEX %3d\nTIEMPO %6d:%02d",
["\fWould you like to\nSAVE the game?"] = "\f¿Quieres GUARDAR\nla partida?",
["Now saving..."] = "Guardando...",
["%s saved\nthe game!"] = "¡%s guardó\nla partida!",
["OPTION"] = "OPCION",
["LINK"] = "LINK",
["RETURN TO MAIN\nMENU?"] = "¿VOLVER AL MENU\nPRINCIPAL?",
["BALL"] = "BALL",
["STATUS/"] = "ESTADO/",
["OT/"] = "EO/",
["EXP POINTS"] = "P. EXP.",
["LEVEL UP"] = "SUBE NIVEL",
["PP"] = "PP",
["SCORE %d"] = "PUNTOS %d",
["New record!"] = "¡Nuevo récord!",
["HI %d"] = "MAX %d",
["A: done"] = "A: listo",
["PLAYER"] = "JUGADOR",
["BADGES"] = "MEDALLAS",
["TIME"] = "TIEMPO",
["CONTINUE"] = "CONTINUAR",
["NEW GAME"] = "NUEVA PARTIDA",
["EXIT GAME"] = "SALIR DEL JUEGO",
["POKéMON RED"] = "",
["2026 bois club games"] = "",
["OT/%s"] = "EO/%s",
["NAME/%s"] = "NOMBRE/%s",
["In battle"] = "En combate",
["Wild battle"] = "Combate salvaje",
["Trainer battle"] = "Combate entrenador",
["Link battle"] = "Combate link",
["Title screen"] = "Pantalla de título",
["Level %d"] = "Nivel %d",
["What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!"] = "¿Qué?\n¡%s está\nevolucionando!\f¡Enhorabuena!\n¡Tu %s\nevolucionó a\n%s!",
["Not even a nibble!"] = "¡Ni un mordisco!",
["Oh!\nIt's a bite!"] = "¡Oh!\n¡Ha picado!",
["It's a sculpture\nof DIGLETT."] = "Es una escultura\nde DIGLETT.",
["Crammed full of\nPOKéMON books!"] = "¡Repleto de libros\nsobre POKéMON!",
["There's a slew of\nPOKéMON stuff!"] = "¡Hay un montón de\ncosas POKéMON!",
["An elevator!"] = "¡Un ascensor!",
["INDIGO PLATEAU"] = "MESETA ANIL",
["POKéMON LEAGUE HQ"] = "SEDE DE LA LIGA\nPOKéMON",
["You can't carry\nany more items!"] = "¡No puedes llevar\nmás objetos!",
["%s found\n%s!"] = "¡%s encontró\n%s!",
["%s found\n%d coins!"] = "¡%s encontró\n%d fichas!",
["OUT OF ORDER\nThis is broken."] = "FUERA DE SERVICIO\nEsto está roto.",
["OUT TO LUNCH\nThis is reserved."] = "CERRADO POR COMIDA\nEsto está reservado.",
["Someone's keys!\nThey'll be back."] = "¡Las llaves de\nalguien! Volverá.",
["A COIN CASE is\nrequired!"] = "¡Se necesita un\nMONEDERO!",
["You don't have\nany coins!"] = "¡No tienes fichas!",
["{RAM}\nPOKéMON GYM\nLEADER: {RAM}"] = "{RAM}\nGIMNASIO POKéMON\nLIDER: {RAM}",
["Nope, there's\nonly trash here."] = "No, aquí solo hay\nbasura.",
["Darn! It needs a\nCARD KEY!"] = "¡Vaya! ¡Necesita\nuna LLAVE MAGNET.!",
["Bingo!"] = "¡Bingo!",
["\nThe CARD KEY\nopened the door!"] = "\n¡La LLAVE MAGNET.\nabrió la puerta!",
["Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!"] = "¡Hay un interruptor\nbajo la basura!\f¡Se abrió el 1er\ncierre eléctrico!",
["The 2nd electric\nlock opened!\fThe motorized door\nopened!"] = "¡Se abrió el 2o\ncierre eléctrico!\f¡La puerta se\nabrió!",
["Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!"] = "¡No! Aquí solo hay\nbasura.\f¡Los cierres se\nreiniciaron!",
["TELEPORTER is\ndisplayed on the\nPC monitor."] = "El TELETRANSPORTE\naparece en el\nmonitor del PC.",
["{PLAYER} initiated\nTELEPORTER's Cell\nSeparator!"] = "¡{PLAYER} activó el\nSeparador de Células\ndel TELETRANSPORTE!",
["BILL's favorite\nPOKéMON list!"] = "¡La lista de POKéMON\nfavoritos de BILL!",
["{PLAYER} got on\n{RAM:wNameBuffer}!"] = "¡{PLAYER} se subió\na {RAM:wNameBuffer}!",
["{RAM:wNameBuffer} hacked\naway with CUT!"] = "¡{RAM:wNameBuffer} cortó\ncon CORTE!",
["Gyaoo!"] = "¡Gyaoo!",
["Hi there!\nMay I help you?"] = "¡Hola!\n¿Puedo ayudarte?",
["SOMEONE'S PC"] = "EL PC DE ALGUIEN",
["PROF.OAK's PC"] = "EL PC DEL PROF.OAK",
["POKéDEX comp-\nletion is:\f{NUM:hDexRatingNumMonsSeen} POKéMON seen\n{NUM:hDexRatingNumMonsOwned} POKéMON owned\fPROF.OAK's\nRating:"] = "La POKéDEX está\nasí:\f{NUM:hDexRatingNumMonsSeen} POKéMON vistos\n{NUM:hDexRatingNumMonsOwned} POKéMON capturados\fNota del\nPROF.OAK:",
["We hope to see\nyou again!"] = "¡Esperamos verte\nde nuevo!",
["Welcome to our\nPOKéMON CENTER!"] = "¡Bienvenido a\nnuestro CENTRO\nPOKéMON!",
["Shall we heal your\nPOKéMON?"] = "¿Curamos a tus\nPOKéMON?",
["OK. We'll need\nyour POKéMON."] = "Bien. Necesitamos\ntus POKéMON.",
["Your POKéMON are\nfighting fit!"] = "¡Tus POKéMON están\nen plena forma!",
["Welcome to the\nCable Club!"] = "¡Bienvenido al Club\nde Cable!",
["We're making\npreparations.\vPlease wait."] = "Estamos preparando\ntodo.\vEspera un momento.",
["Please apply here.\fBefore opening\nthe link, we have\vto save the game."] = "Solicítalo aquí.\fAntes de abrir el\nlink hay que\vguardar la partida.",
["Please come\nagain!"] = "¡Vuelve pronto!",
["I like shorts!\nThey're comfy and\neasy to wear!"] = "¡Me gustan los\npantalones cortos!\n¡Son cómodos!",
["%s received\nthe %s!"] = "¡%s recibió\nel %s!",
["%s received\n%s!"] = "¡%s recibió\n%s!",
["REPEL's effect\nwore off."] = "El efecto del REPEL\nse ha pasado.",
["Go right ahead!"] = "¡Adelante!",
["You don't have the\nBOULDERBADGE yet!"] = "¡Aún no tienes la\nMEDALLA ROCA!",
["Oh! That is the\n{RAM}!"] = "¡Oh! ¡Eso es el\n{RAM}!",
["You don't have the\n{RAM} yet!"] = "¡Aún no tienes el\n{RAM}!",
["You need a\nBICYCLE for the\nCycling Road!"] = "¡Necesitas una\nBICICLETA para el\nCarril Bici!",
["The boulder fell\nthrough the hole!"] = "¡La roca cayó por\nel agujero!",
["PA: Ding-dong!\nTime's up!"] = "AV: ¡Ding-dong!\n¡Se acabó el tiempo!",
["PA: Your SAFARI\nGAME is over!"] = "AV: ¡Tu JUEGO\nSAFARI ha terminado!",
["PA: You're out of\nSAFARI BALLs!"] = "AV: ¡No te quedan\nSAFARI BALLs!",
["{PLAYER} got\n%s!"] = "¡{PLAYER} consiguió\n%s!",
["There's no more\nroom for POKéMON!\v%s was\vsent to POKéMON\vBOX %s on PC!"] = "¡No hay sitio para\nmás POKéMON!\v¡%s fue\venviado a la CAJA\vPOKéMON %s del PC!",
["contribution is not a table"] = "",
[" [%s %s.%s]"] = " [%s %s.%s]",
["Link battle needs\nthe same mods on\nboth games."] = "El combate link\nnecesita los mismos\nmods en los dos\njuegos.",
["Your %s can't\nbattle on the\nother game."] = "Tu %s no puede\nluchar en el otro\njuego.",
["Their %s isn't\nin this game.\n(%s)"] = "Su %s no está\nen este juego.\n(%s)",
["%s wants\nto battle!"] = "¡%s quiere\nluchar!",
["Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?"] = "¡Link desincroni-\nzado!\n%s difiere.\f¿Están los dos\njuegos con los\nmismos mods?",
["%s ran from\nthe battle!"] = "¡%s huyó del\ncombate!",
["Items can't be\nused in a link\nbattle!"] = "¡No se pueden usar\nobjetos en un\ncombate link!",
["%s is out of\nPOKéMON!\f%s wins!"] = "¡%s no tiene\nPOKéMON!\f¡%s gana!",
["%s left the\nbattle."] = "%s dejó el\ncombate.",
["%s ran out of\ntime!"] = "¡%s se quedó\nsin tiempo!",
["Time's up! You\nforfeit the match."] = "¡Se acabó el tiempo!\nPierdes el combate.",
["%s's %s can't\nbattle on this\ngame."] = "El %s de %s\nno puede luchar en\neste juego.",
["%s's %s can't\nbattle on this\ngame.\n(%s)"] = "El %s de %s\nno puede luchar en\neste juego.\n(%s)",
["%s vs %s!"] = "¡%s contra %s!",
["Link error:\n%s"] = "Error de link:\n%s",
["Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?"] = "El juego en línea\nva sin mods para\nlos dos jugadores.\f¿Desactivar %s\ny reiniciar?",
["The link was\nbroken."] = "Se ha perdido el\nlink.",
["Link battle\ncan't start."] = "El combate link no\npuede empezar.",
["The trade stopped:\n%s."] = "El intercambio se\ndetuvo:\n%s.",
["The trade was\ncancelled."] = "El intercambio se\nha cancelado.",
["Trade completed!\f%s received\n%s!"] = "¡Intercambio hecho!\f¡%s recibió\n%s!",
["LINK CABLE (LAN)"] = "CABLE LINK (LAN)",
["ONLINE MATCH"] = "PARTIDA EN LINEA",
["TOURNAMENT"] = "TORNEO",
["HOST A GAME"] = "CREAR PARTIDA",
["JOIN A GAME"] = "UNIRSE A PARTIDA",
["UDP port %s"] = "Puerto UDP %s",
["HOST ONLINE"] = "CREAR EN LINEA",
["JOIN ONLINE"] = "UNIRSE EN LINEA",
["Tell your friend"] = "Dile a tu amigo",
["the code:"] = "el código:",
["Waiting for join..."] = "Esperando...",
["A: connect B: back"] = "A: conectar B: atrás",
["Calling..."] = "Llamando...",
["Friend joins at:"] = "Tu amigo entra en:",
["Port: %s"] = "Puerto: %s",
["TRADE"] = "INTERCAMBIO",
["BATTLE"] = "COMBATE",
["LEVELS:"] = "NIVELES:",
["A: continue B: back"] = "A: seguir B: atrás",
["Checking the"] = "Comprobando el",
["other game..."] = "otro juego...",
["Waiting for the"] = "Esperando a que",
["host to choose..."] = "el anfitrión elija...",
["A: trade anyway"] = "A: intercambiar igual",
["YOURS"] = "TUYO",
["THEIRS"] = "SUYO",
["X: not on theirs"] = "X: no en el suyo",
["A: trade B: cancel"] = "A: cambiar B: cancelar",
["Exchanging data..."] = "Intercambiando...",
["can't reach relay %s:%d\n(%s)"] = "",
["That code wasn't\nfound."] = "Ese código no se\nha encontrado.",
["That game already\nhas two players."] = "Esa partida ya\ntiene dos jugadores.",
["That code has\nexpired."] = "Ese código ha\ncaducado.",
["Couldn't join:\n%s"] = "No se pudo unir:\n%s",
["no answer from\n%s"] = "",
["That tournament\nhas already begun."] = "Ese torneo ya ha\nempezado.",
["Can't host:\nneed %d Pokemon\nLv %s-%s."] = "No puedes crearlo:\nnecesitas %d Pokemon\nNv %s-%s.",
["Couldn't host\nthat tournament."] = "No se pudo crear\nese torneo.",
["Your party needs\n%d Pokemon, Lv\n%s-%s."] = "Tu equipo necesita\n%d Pokemon, Nv\n%s-%s.",
["Couldn't join\nthat tournament."] = "No se pudo unir a\nese torneo.",
["Link error:\nversion mismatch\nwith opponent."] = "Error de link:\nversión distinta a\nla del rival.",
["The tournament\nconnection was\nlost."] = "Se perdió la\nconexión del torneo.",
["Can't watch this\nmatch."] = "No se puede ver\neste combate.",
["HOST"] = "CREAR",
["JOIN"] = "UNIRSE",
["START: create"] = "START: crear",
["A: join B: back"] = "A: unirse B: atrás",
["B: cancel"] = "B: cancelar",
["TOURNAMENT %s"] = "TORNEO %s",
["ROUND %d"] = "RONDA %d",
["%s (bye)"] = "%s (pasa)",
["%s%s vs %s%s"] = "%s%s contra %s%s",
["(organizing --"] = "(organizando --",
["not playing)"] = "no juega)",
["Waiting for"] = "Esperando a que",
["players to join:"] = "entren jugadores:",
["A: START B: cancel"] = "A: START B: cancelar",
["%s is the"] = "¡%s es el",
["champion!"] = "campeón!",
["A: continue"] = "A: continuar",
["{PLAYER} played the\nPOKé FLUTE."] = "{PLAYER} tocó la\nFLAUTA POKé.",
["Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!"] = "Tocaste la FLAUTA\nPOKé.\f¡Qué melodía tan\npegadiza!",
["%s played the\nPOKé FLUTE."] = "%s tocó la\nFLAUTA POKé.",
["All sleeping\nPOKéMON woke up!"] = "¡Todos los POKéMON\ndormidos despertaron!",
["%s's\nhits will never\nmiss!"] = "¡Los golpes de %s\nnunca fallarán!",
["The wild POKéMON\nran away!"] = "¡El POKéMON salvaje\nhuyó!",
["%s's PP\nwas restored!"] = "¡Los PP de %s\nse recuperaron!",
["%s's\nstatus returned\nto normal!"] = "¡El estado de %s\nvolvió a la\nnormalidad!",
["%s\nis revitalized!"] = "¡%s se ha\nrevitalizado!",
["%s\nis refusing!"] = "¡%s se\nniega!",
["%s's %s\nrose!"] = "¡El %s de %s\nsubió!",
["%s's PP\nincreased!"] = "¡Los PP de %s\naumentaron!",
["%s can't\nlearn that move!"] = "¡%s no puede\naprender ese\nmovimiento!",
["It knows that\nmove already!"] = "¡Ya conoce ese\nmovimiento!",
["Coin count:\n%d"] = "Fichas:\n%d",
["NO MODS INSTALLED"] = "NO HAY MODS",
["SAVE CURRENT AS.."] = "GUARDAR ACTUAL..",
["OPTIONS.."] = "OPCIONES..",
["PERMISSIONS.."] = "PERMISOS..",
["VIEW ERROR.."] = "VER ERROR..",
["BACK"] = "ATRAS",
["APPLY & RESTART"] = "APLICAR Y REINICIAR",
["DISCARD CHANGES"] = "DESCARTAR CAMBIOS",
["DATA & API ONLY"] = "SOLO DATOS Y API",
["DISABLE BOTH?"] = "DESACTIVAR AMBOS?",
["PROFILE NAME?"] = "NOMBRE DEL PERFIL?",
["RENAME?"] = "RENOMBRAR?",
["RESET DEFAULTS"] = "VALORES POR DEFECTO",
["NO CHANGES"] = "SIN CAMBIOS",
["A:OK"] = "A:OK",
["B:DONE (NO RESTART)"] = "B:LISTO (SIN REINICIAR)",
["MOD MANAGER"] = "GESTOR DE MODS",
["Choose a mod .zip"] = "Elige un .zip de mod",
["Choose a .sav save file"] = "Elige un archivo .sav",
["An update is available"] = "Hay una actualización",
["Name save slot"] = "Nombra la ranura",
["Enter to save - Esc to cancel - empty clears"] = "Enter para guardar - Esc para cancelar - vacío la borra",
["Add a mod index"] = "Añadir un índice de mods",
["Paste the index URL, or its owner/repo."] = "Pega la URL del índice, o su owner/repo.",
["Enter to add - Esc to cancel"] = "Enter para añadir - Esc para cancelar",
["Import a ROM to play"] = "Importa una ROM para jugar",
["RED"] = "ROJO",
["BLUE"] = "AZUL",
["YELLOW"] = "AMARILLO",
["FIND MODS"] = "BUSCAR MODS",
["%d of 3 ready"] = "%d de 3 listos",
["Or drop the .gb/.gbc file here."] = "O arrastra aquí el archivo .gb/.gbc.",
["ROM imported"] = "ROM importada",
["That ROM could not be imported."] = "No se pudo importar esa ROM.",
["Open folder"] = "Abrir carpeta",
["%d badges - %s - %d caught"] = "%d medallas - %s - %d capturados",
["%d of %d enabled"] = "%d de %d activados",
["Or drop a mod .zip onto the window."] = "O arrastra un .zip de mod a la ventana.",
["No mods installed - drop a mod .zip here to add one."] = "No hay mods - arrastra aquí un .zip para añadir uno.",
["Refreshed - %d mods listed"] = "Actualizado - %d mods listados",
["Added %s"] = "Añadido %s",
["Index removed"] = "Índice eliminado",
["Downloading %s..."] = "Descargando %s...",
["Installed %s %s"] = "Instalado %s %s",
["%d mods listed"] = "%d mods listados",
["%d of %d mods"] = "%d de %d mods",
["Mods here are listed, not reviewed - read the source and trust the author."] = "Los mods aquí se listan, no se revisan - lee el código y confía en el autor.",
["No mod index added"] = "No hay índice de mods",
["Add an index to browse mods. An index is a published list; paste its URL or its owner/repo."] = "Añade un índice para explorar mods. Un índice es una lista publicada; pega su URL o su owner/repo.",
["Search mods"] = "Buscar mods",
["This index lists no mods yet."] = "Este índice aún no lista mods.",
["No mods match that search."] = "Ningún mod coincide con esa búsqueda.",
}
+7
View File
@@ -0,0 +1,7 @@
-- Trainer class names
--
-- Trainer class names for Espanol.
return {
["OPP_FIX_YOUNGSTER"] = "",
}
+127
View File
@@ -0,0 +1,127 @@
-- spanish_ui: a translation of the game into Espanol.
--
-- Nothing here is translated yet. Every table under lang/ starts with
-- empty strings; fill one in and it takes effect on the next boot, and
-- anything still empty keeps rendering in English. That means a
-- half-finished translation is always playable, so you can ship early and
-- fill the long tail in later.
--
-- Read TRANSLATING.md before the first edit; the font is the part people
-- get wrong.
return function(mod)
-- mod:read is the supported way into your own directory; the catalogs are
-- plain Lua tables, so read and run them rather than require()ing them.
local function catalog(name)
local rel = "lang/" .. name .. ".lua"
local body = mod:read(rel)
if not body then return {} end
local chunk, err = loadstring(body, rel)
if not chunk then
mod.log:warn("%s has a syntax error: %s", rel, tostring(err))
return {}
end
local ok, table_ = pcall(chunk)
if not ok or type(table_) ~= "table" then
mod.log:warn("%s did not return a table: %s", rel, tostring(table_))
return {}
end
return table_
end
-- An empty value means "not translated yet", never "translate to blank".
local function each(name, apply)
local n = 0
for key, value in pairs(catalog(name)) do
if type(value) == "string" and value ~= "" then
apply(key, value)
n = n + 1
end
end
return n
end
-- ---- glyphs -------------------------------------------------------
-- Register the sheet BEFORE anything asks for a glyph on it. base is
-- the first code the page owns; 0x100 and up is free space above the
-- vanilla pages, so a new alphabet never collides with them.
for id, page in pairs(catalog("font")) do
mod.content.font:register(id, page)
end
-- charmap: which byte sequence draws which code
for seq, code in pairs(catalog("charmap")) do
mod.content.font:register("charmap:" .. seq, { seq = seq, code = code })
end
-- ---- text ---------------------------------------------------------
local counts = {}
counts.dialogue = each("dialogue", function(id, value)
mod.content.text:override(id, value)
end)
counts.strings = each("strings", function(source, value)
mod.content.strings:override(source, value)
end)
counts.species = each("species_names", function(id, value)
mod.content.pokemon:patch(id, { name = value })
end)
counts.moves = each("move_names", function(id, value)
mod.content.moves:patch(id, { name = value })
end)
counts.items = each("item_names", function(id, value)
mod.content.items:patch(id, { name = value })
end)
counts.trainers = each("trainer_names", function(id, value)
mod.content.trainers:patch(id, { name = value })
end)
counts.statuses = each("status_labels", function(id, value)
mod.content.statuses:patch(id, { label = value })
end)
-- ---- name entry ---------------------------------------------------
-- The naming screen's letter grid. Leave lang/naming.lua returning nil
-- to keep the English alphabet.
local grid = catalog("naming")
if grid.upper then
-- Only offer the accented cells when the running cartridge can actually
-- draw them. A Spanish ROM has Ñ and the accented vowels in its font
-- and the manifest maps them; an English one does not, and an
-- unmappable cell renders blank -- a naming screen with six empty keys
-- is worse than an English one. So check the charmap and fall back.
local function drawable(cells, ctx)
local font = ((ctx.game or {}).data or {}).font
local charmap = font and font.charmap
if not charmap then return false end
local have = {}
for _, entry in ipairs(charmap) do have[entry.seq] = true end
for _, row in ipairs(cells) do
for _, cell in ipairs(row) do
-- Only the non-ASCII cells are at risk; A-Z and punctuation are
-- on every page.
if cell:byte(1) and cell:byte(1) > 127 and not have[cell] then
return false
end
end
end
return true
end
local warned = false
mod.hooks:on("ui.naming.grid", function(base, ctx)
local want = ctx.lower and grid.lower or grid.upper
if not want then return base end
if not drawable(want, ctx) then
if not warned then
warned = true
mod.log:info("naming grid: this ROM has no accented glyphs, "
.. "keeping the English alphabet")
end
return base
end
return want
end)
end
mod.events:on("game.ready", function()
local total = 0
for _, n in pairs(counts) do total = total + n end
mod.log:info("Espanol: %d strings translated", total)
end)
end
+17
View File
@@ -0,0 +1,17 @@
{
"id": "spanish_ui",
"name": "Espanol (interfaz)",
"version": "0.1.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"game_version": ">=0.0.0-dev <1.0.0",
"category": "LANGUAGE",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"incompatible": [],
"experimental": false,
"description": "Spanish for the app's own settings and menus. The game's text comes from your ROM and is untouched, so an English cartridge stays an English adventure with Spanish menus."
}
+37 -6
View File
@@ -357,13 +357,44 @@ build_linux() {
cp "$LOVE_FILE" "$appdir/game.love"
# The .desktop's Icon=love resolves against the AppDir root by basename,
# so drop the stock love.svg and provide our PNG under the same name;
# .DirIcon is what appimaged/thumbnailers show for the file itself.
# 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
# managers displayed this image as. Same file as the arm64 build writes,
# so both architectures integrate under the game's name.
local stock_desktop
stock_desktop="$(find "$appdir" -maxdepth 1 -name '*.desktop' | wc -l | tr -d ' ')"
[ "$stock_desktop" = 1 ] \
|| fail "expected exactly one .desktop at the AppDir root, found $stock_desktop"
rm -f "$appdir"/*.desktop
# share/ carries a second, NoDisplay copy of the same entry plus the .love
# file-type icons and mime rule, all left over from LÖVE's `make install`
# (its Exec even points at the CI runner that built it). Nothing at runtime
# reads them -- only share/lua and share/luajit-* are on LUA_PATH -- but
# AppRun puts $APPDIR/share on XDG_DATA_DIRS, so anyone extracting the image
# gets a "LÖVE" entry back. The arm64 AppDir never had them.
rm -rf "$appdir/share/applications" "$appdir/share/pixmaps" \
"$appdir/share/mime" "$appdir/share/icons"
cat > "$appdir/$APP_NAME.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=gen1recomp
Comment=Pokémon Gen 1 recompilation
Exec=$APP_NAME
Icon=$APP_NAME
StartupWMClass=love
Categories=Game;
Terminal=false
EOF
# Icon= resolves against the AppDir root by basename, so the PNG has to be
# named after the desktop entry; .DirIcon is what appimaged and
# file-manager thumbnailers show for the file itself.
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
rm -f "$appdir/love.svg" "$appdir/.DirIcon"
sips -z 512 512 "$ICON_SRC" --out "$appdir/love.png" >/dev/null
cp "$appdir/love.png" "$appdir/.DirIcon"
rm -f "$appdir/love.svg" "$appdir/love.png" "$appdir/.DirIcon"
sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null
cp "$appdir/$APP_NAME.png" "$appdir/.DirIcon"
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
+1
View File
@@ -369,6 +369,7 @@ Name=gen1recomp
Comment=Pokémon Gen 1 recompilation
Exec=$APP_NAME
Icon=$APP_NAME
StartupWMClass=love
Categories=Game;
Terminal=false
EOF
+4 -1
View File
@@ -142,7 +142,10 @@ download_pinned() {
rm -f "$dest"
fi
say "downloading $(basename "$dest")"
curl -fL --progress-bar "$url" -o "$dest.tmp" || fail "download failed: $url"
# --retry-all-errors because plain --retry skips TLS handshake failures,
# which is how xiph.org drops these tarballs; the pin below still gates it.
curl -fL --retry 5 --retry-delay 2 --retry-all-errors --progress-bar \
"$url" -o "$dest.tmp" || fail "download failed: $url"
got="$(sha256_file "$dest.tmp")"
[ "$got" = "$want" ] || fail "$(printf '%s\n expected %s\n got %s' \
"checksum mismatch for $(basename "$dest")" "$want" "$got")"
+7 -4
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# Run the LÖVE2D Pokémon Red port (macOS-friendly).
#
# Assumes scripts/setup.sh has been run once (generated data present and
# LÖVE installed). Extra arguments are passed through to LÖVE.
# Assumes scripts/setup.sh has been run once for at least one game (generated
# data present and LÖVE installed). Extra arguments are passed through to LÖVE.
#
# Link play is peer-to-peer (lua-enet, bundled with LÖVE): one player
# picks HOST A GAME in START > LINK and reads out the address shown;
@@ -15,8 +15,11 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
[ -f "$ROOT/data/generated/maps.lua" ] \
|| fail "generated data missing, run scripts/setup.sh first"
if [ ! -f "$ROOT/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ]; then
fail "generated data missing, run scripts/setup.sh first"
fi
find_love() {
command -v love >/dev/null 2>&1 && { echo "love"; return; }
+1
View File
@@ -66,6 +66,7 @@ run_tier() {
# ------- ROM-free tiers: these are what CI runs
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
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
+65 -11
View File
@@ -37,6 +37,12 @@ local BattleState = {}
BattleState.__index = BattleState
BattleState.isOpaque = true
-- Category identity for per-category GAME SPEED (RFC 0007), the same
-- style OverworldController.isOverworld already uses. Every battle --
-- wild, trainer, link, safari, the old-man demo -- is this metatable, so
-- Game.speedCategoryInStack needs no special-casing beyond this one flag.
BattleState.isBattle = true
function BattleState:romText(label, fallback, ...)
return romText(self.data, label, fallback, ...)
end
@@ -98,6 +104,15 @@ function BattleState:bgMode()
return "white"
end
-- Resume a semantic checkpoint directly at the command menu. Unlike enter(),
-- this deliberately does not replay the battle transition, intro queues,
-- cries, happiness changes, or battle-start events.
function BattleState:resumeCheckpoint()
self.isOpaque = self:bgMode() ~= "world"
require("src.core.Music").playBattle(self.data,
self.musicKind or self:computeMusicKind())
end
-- How far to dim the overworld behind a "world" background, 0..1. Enough
-- that the battle reads as the foreground rather than competing with a fully
-- lit map behind it.
@@ -118,6 +133,18 @@ function BattleState:sgbPalettes()
return nil
end
function BattleState:bottomUIVisible()
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
return Runtime.call("battle.bottom_ui_visible", function() return true end,
self) ~= false
end
function BattleState:statusHUDVisible()
if not Runtime.wantsHook("battle.status_hud_visible") then return true end
return Runtime.call("battle.status_hud_visible", function() return true end,
self) ~= false
end
local Rulesets = {
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
modern_clean = require("src.battle.rulesets.modern_clean"),
@@ -424,7 +451,7 @@ function StatBox:draw()
{ Strings("SPEED"), s.speed },
{ Strings("SPECIAL"), s.special } }
for i, r in ipairs(rows) do
Font.draw(r[1], 88, 24 + (i - 1) * 16)
Font.draw(Strings(r[1]), 88, 24 + (i - 1) * 16)
Font.draw(("%3d"):format(r[2]), 128, 32 + (i - 1) * 16)
end
love.graphics.setColor(1, 1, 1, 1)
@@ -1411,6 +1438,26 @@ function BattleState:computeMusicKind()
return "wild"
end
-- a mod-set per-trainer battle theme (trainers.battleTheme, an audio.songs
-- id); nil for vanilla trainers, so the kind default is untouched (#782)
function BattleState:battleTheme()
local trainer = self.trainer
if trainer and trainer.battleTheme then return trainer.battleTheme end
return nil
end
-- the battle-theme cue for this battle: the mod-set trainer battleTheme
-- when the class has one, else the kind default. The single choke point
-- both the transition-wipe start (OverworldController:pushBattle) and
-- enter() route through, so a per-trainer override can't drift between
-- them. self.musicKind is set by enter(); pushBattle runs before that,
-- so compute it here when absent.
function BattleState:playBattleTheme()
require("src.core.Music").playBattle(self.data,
self.musicKind or self:computeMusicKind(),
self.trainer and self.trainer.id, self:battleTheme())
end
-- side tables mirror the singles battlers; called before every
-- battler-switch notification so sides[i].battlers[1] stays honest
function BattleState:syncSides()
@@ -1462,7 +1509,6 @@ function BattleState:enter()
.. Strings("%s blacked\nout!", name), blackedOut))
return
end
local Music = require("src.core.Music")
self.musicKind = self:computeMusicKind()
if self.isGymLeader then
require("src.world.PikachuFollower")
@@ -1472,7 +1518,7 @@ function BattleState:enter()
-- (audio/play_battle_music.asm runs before the transition, and
-- Music.play no-ops on the same song); this covers battles pushed
-- without a transition (link battles, scripted pushes)
Music.playBattle(self.data, self.musicKind)
self:playBattleTheme()
-- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both
-- sides slide in; the trainer pics stay up until the send-outs
-- BATTLE BG "world" drops this battle's opacity so StateStack keeps drawing
@@ -1865,6 +1911,7 @@ function BattleState:update(dt)
end
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
end
return
@@ -1901,6 +1948,7 @@ function BattleState:update(dt)
end
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex]
if choice == "fight" and self.ghost then
self:say(Strings("%s is too\nscared to move!", self.player.name))
@@ -1962,9 +2010,11 @@ function BattleState:update(dt)
self.moveSwapIndex = self.moveIndex
end
elseif input:wasPressed("b") then
require("src.core.Sound").play(self.data, "Press_AB")
self.moveSwapIndex = nil
self.phase = "menu"
elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
if self.moveSwapIndex then
self:swapMoves(self.moveSwapIndex, self.moveIndex)
self.moveSwapIndex = nil
@@ -2003,6 +2053,7 @@ function BattleState:update(dt)
elseif input:wasPressed("down") then
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
local pick = moves[self.mimicIndex]
local ctx = self.mimicCtx
self.mimicMoves, self.mimicCtx = nil, nil
@@ -5319,7 +5370,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
-- .mimicmenu) wipes rows 7+. The port draws pics above the menu
-- layer in the colorized pipeline, so clip them to the visible rows.
local g = love.graphics
local clipY = not skipMenuClip
local clipY = not skipMenuClip and self:bottomUIVisible()
and (self.phase == "mimicSelect" and 56
or self.phase == "moveSelect" and 64)
or nil
@@ -5431,6 +5482,7 @@ function BattleState:drawHUDs(slide)
-- per-pixel tint (grayFill) -- otherwise GREENBAR's red-channel-0 fill
-- double-applies and the zone shade shader maps the whole bar to black (#229).
local grayFill = self:colorMode()
local showStatus = self:statusHUDVisible()
local barData = self.data
local fx = self.fx
local hudShake = (fx and fx.hudShakeX) or 0
@@ -5440,7 +5492,8 @@ function BattleState:drawHUDs(slide)
-- DrawEnemyHUDAndHPBar is called from _InitBattleCommon (core.asm:6763)
-- AFTER PrintBeginningBattleText returns, so "Wild X appeared!" shows the
-- player's ball row with no enemy HUD beside it (#317)
if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut
if showStatus and self.enemy and not self.showEnemyTrainer
and not self.enemySendingOut
and not self:growInScale(self.enemy) and slide == 0
and not self.introBalls and not self.enemy.fainted then
-- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1),
@@ -5527,7 +5580,7 @@ function BattleState:drawHUDs(slide)
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
end
local hidePlayer = self.safari or self.demo
if self.player and not hidePlayer and not self.showPlayerBack
if showStatus and self.player and not hidePlayer and not self.showPlayerBack
and slide == 0 then
-- player HUD (DrawPlayerHUDAndHPBar): name (10,7), <LV>+level
-- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with
@@ -5552,6 +5605,7 @@ function BattleState:drawHUDs(slide)
end
function BattleState:drawTextArea()
if not self:bottomUIVisible() then return end
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if self.phase == "messages"
@@ -5587,9 +5641,9 @@ function BattleState:drawTextArea()
-- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16)
Font.drawBox(8, 12, 12, 6)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("FIGHT"), 80, 112)
Font.draw(Strings("FIGHT", "battle"), 80, 112)
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("RUN"), 128, 128)
Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128)
Font.drawCode(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128)
elseif self.phase == "menu" then
local col = (self.menuIndex - 1) % 2
@@ -5599,7 +5653,7 @@ function BattleState:drawTextArea()
-- THROW ROCK RUN" from (2,14)
Font.drawBox(0, 12, 20, 6)
Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112)
Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN"), 112, 128)
Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN", "battle"), 112, 128)
-- DisplayBattleMenu .safariLeftColumn / .safariRightColumn print
-- wNumSafariBalls at hlcoord 7,14 with `lb bc, 1, 2` -- one byte, two
-- digits, space padded -- right after the "BALLx" label at columns
@@ -5610,9 +5664,9 @@ function BattleState:drawTextArea()
-- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT <PK><MN> /
-- ITEM RUN" from (10,14); cursor columns 9 / 15
Font.drawBox(8, 12, 12, 6)
Font.draw(Strings("FIGHT"), 80, 112)
Font.draw(Strings("FIGHT", "battle"), 80, 112)
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("RUN"), 128, 128)
Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128)
Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16)
end
elseif self.phase == "moveSelect" then
+5 -5
View File
@@ -281,11 +281,11 @@ MoveEffects.primary = {
failed = true }
end
local cost = math.floor(user.mon.stats.hp / 4)
-- substitute.asm only fails on subtraction underflow (current HP
-- strictly below maxHP/4); at equality the substitute is built and
-- the user is left standing on exactly 0 HP (it faints only when
-- the engine next checks HP, not here)
if user.mon.hp < cost then
-- A Substitute costs one quarter of max HP, rounded down. Do not let
-- the cost consume the user's last HP: the move must fail at the exact
-- boundary as well as below it, or the next turn's HP guard can leave a
-- trainer battle unable to progress.
if user.mon.hp <= cost then
return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!"),
failed = true }
end
+4 -2
View File
@@ -128,7 +128,8 @@ local function drawIntroBalls(battle)
end
local function drawHUDs(battle, slide)
if battle.enemy and not battle.showEnemyTrainer
local showStatus = battle:statusHUDVisible()
if showStatus and battle.enemy and not battle.showEnemyTrainer
and not battle.enemySendingOut and not battle:growInScale(battle.enemy)
and slide == 0 and not battle.introBalls and not battle.enemy.fainted then
drawStatusPanel(battle, battle.enemy, 0, 0, false)
@@ -139,7 +140,7 @@ 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).
if not battle.safari and battle.player and not battle.demo
if showStatus and not battle.safari and battle.player and not battle.demo
and not battle.showPlayerBack and slide == 0 then
drawStatusPanel(battle, battle.player, 184, 56, true)
end
@@ -255,6 +256,7 @@ local function drawMoveMenu(battle)
end
local function drawTextArea(battle)
if not battle:bottomUIVisible() then return end
if battle.phase == "messages" and (battle.current or battle.animPlaying) then
drawMessageBox(battle)
elseif battle.phase == "menu" then
+432
View File
@@ -0,0 +1,432 @@
-- Semantic, data-only capture for settled single-player battle checkpoints.
-- Reconstruction lives here too; public mods only see the opaque checkpoint
-- facade in Loader.
local BattleCheckpoint = {}
local BattleState = require("src.battle.BattleState")
local BUILTIN_RULESETS = {
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
modern_clean = require("src.battle.rulesets.modern_clean"),
}
local function rulesets(game)
return game.data.rulesets or BUILTIN_RULESETS
end
local function rulesetId(game, record)
for id, candidate in pairs(rulesets(game)) do
if candidate == record then return id end
end
end
local BATTLER_FIELDS = {
"shownHP", "shownStatus", "stages", "curStats", "curTypes", "curMoves",
"sleepTurns", "confusedTurns", "disabledSlot", "disabledTurns",
"toxicCounter", "substituteHP", "bideDamage", "bideTurns", "boundTurns",
"chargeReady", "invulnerable", "mustRecharge",
"thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded",
"lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched",
"skipMove", "hazeStatReset", "drainFloor", "drainHold", "trappingTurns",
"trapMove", "trapDamage", "fainted",
"aiLayer2",
}
local MOVE_REFERENCE_FIELDS = {
charging = "chargingSlot",
thrashMove = "thrashMoveSlot",
rageMove = "rageMoveSlot",
}
local BATTLE_FIELDS = {
"oppClass", "partyIndex", "enemyIndex", "turnCount", "menuIndex",
"moveIndex", "moveSwapIndex", "aiUses", "runAttempts", "payDay",
"sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall",
"lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed",
"endBattleText",
}
local function partyIndex(party, mon)
for index, candidate in ipairs(party or {}) do
if candidate == mon then return index end
end
end
local function indexSet(set, party)
local out = {}
for mon, present in pairs(set or {}) do
if present then
local index = partyIndex(party, mon)
if index then out[#out + 1] = index end
end
end
table.sort(out)
return out
end
local function captureBattler(battler, index, copy)
local out = {
index = index,
curStatsFromMon = battler.curStats == battler.mon.stats,
curTypesFromDefinition = battler.curTypes == battler.def.types,
curMovesFromMon = battler.curMoves == battler.mon.moves,
}
for _, field in ipairs(BATTLER_FIELDS) do
if battler[field] ~= nil then out[field] = battler[field] end
end
for field, slotField in pairs(MOVE_REFERENCE_FIELDS) do
local reference = battler[field]
if reference ~= nil then
for slot, move in ipairs(battler.curMoves or {}) do
if move == reference then out[slotField] = slot break end
end
if out[slotField] == nil then return nil end
end
end
return copy(out)
end
local function integer(value, min, max)
return type(value) == "number" and value % 1 == 0
and value >= (min or -math.huge) and value <= (max or math.huge)
end
local function validateMoveList(data, moves)
if type(moves) ~= "table" then return false end
for _, move in ipairs(moves) do
if type(move) ~= "table" or type(move.id) ~= "string"
or type(data.moves[move.id]) ~= "table" or type(move.pp) ~= "number" then
return false
end
end
return true
end
local function validateMon(data, mon)
return type(mon) == "table" and type(mon.species) == "string"
and type(data.pokemon[mon.species]) == "table" and integer(mon.level, 1, 100)
and type(mon.hp) == "number" and type(mon.stats) == "table"
and validateMoveList(data, mon.moves)
end
local function validateBattler(data, battler, maxIndex)
if type(battler) ~= "table" or not integer(battler.index, 1, maxIndex) then
return false
end
if type(battler.curMoves) ~= "table" then return false end
if battler.stages ~= nil then
if type(battler.stages) ~= "table" then return false end
for _, stage in pairs(battler.stages) do
if not integer(stage, -6, 6) then return false end
end
end
for _, slotField in pairs(MOVE_REFERENCE_FIELDS) do
if battler[slotField] ~= nil
and not integer(battler[slotField], 1, #battler.curMoves) then
return false
end
end
return validateMoveList(data, battler.curMoves)
and type(battler.curStats) == "table" and type(battler.curTypes) == "table"
end
local function clone(value, copy)
if type(value) ~= "table" then return value end
return assert(copy(value))
end
local function captureMimicRestores(battle)
local out = {}
for _, restore in ipairs(battle.mimicRestores or {}) do
local side = restore.battler == battle.player and "player"
or restore.battler == battle.enemy and "enemy" or nil
local slot
for index, move in ipairs(restore.battler and restore.battler.curMoves or {}) do
if move == restore.entry then slot = index break end
end
if not side or not slot or type(restore.id) ~= "string" then return nil end
out[#out + 1] = { side = side, slot = slot, id = restore.id }
end
return out
end
function BattleCheckpoint.validate(game, checkpoint)
local model = checkpoint.runtime and checkpoint.runtime.battle
local rngState = checkpoint.rng and checkpoint.rng.love
if type(model) ~= "table" or type(model.origin) ~= "table"
or type(rngState) ~= "string" or rngState == "" then
return nil, "invalid_checkpoint", "Battle checkpoint data or RNG is missing."
end
local expectedOrigin = model.kind == "wild" and "wild_encounter"
or model.kind == "trainer" and "trainer_encounter" or nil
if not expectedOrigin or model.origin.kind ~= expectedOrigin
or model.origin.map ~= checkpoint.runtime.overworld.map then
return nil, "battle_origin_unsupported",
"Battle continuation data is unsupported or inconsistent."
end
if type(model.rulesetId) ~= "string"
or type(rulesets(game)[model.rulesetId]) ~= "table" then
return nil, "invalid_content", "Battle ruleset is unavailable."
end
if model.kind == "trainer" and (type(model.origin.npcId) ~= "string"
or model.origin.trainerClass ~= model.oppClass
or model.origin.partyIndex ~= (model.partyIndex or 1)) then
return nil, "battle_origin_unsupported",
"Trainer continuation data is incomplete or inconsistent."
end
local party = checkpoint.save.party
if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then
return nil, "invalid_content", "Player battle state is invalid."
end
if model.kind == "wild" then
if not validateMon(game.data, model.enemyMon)
or not validateBattler(game.data, model.enemy, 1) then
return nil, "invalid_content", "Wild opponent state is invalid."
end
else
local trainer = game.data.trainers and game.data.trainers[model.oppClass]
if type(trainer) ~= "table" or not integer(model.partyIndex, 1)
or type(model.enemyParty) ~= "table" or #model.enemyParty == 0
or not integer(model.enemyIndex, 1, #model.enemyParty)
or not validateBattler(game.data, model.enemy, #model.enemyParty) then
return nil, "invalid_content", "Trainer battle identity or roster is invalid."
end
for _, mon in ipairs(model.enemyParty) do
if not validateMon(game.data, mon) then
return nil, "invalid_content", "Trainer opponent state is invalid."
end
end
end
for _, indices in ipairs({ model.participants, model.leveledUp }) do
if type(indices) ~= "table" then
return nil, "invalid_checkpoint", "Battle party reference set is missing."
end
for _, index in ipairs(indices) do
if not integer(index, 1, #party) then
return nil, "invalid_checkpoint", "Battle party reference is invalid."
end
end
end
if type(model.mimicRestores) ~= "table" then
return nil, "invalid_checkpoint", "Mimic restore state is missing."
end
for _, restore in ipairs(model.mimicRestores) do
local battler = restore.side == "player" and model.player
or restore.side == "enemy" and model.enemy or nil
if not battler or not integer(restore.slot, 1, #battler.curMoves)
or type(restore.id) ~= "string"
or type(game.data.moves[restore.id]) ~= "table" then
return nil, "invalid_content", "Mimic restore state is invalid."
end
end
return true
end
local function applyBattler(target, captured, copy)
for _, field in ipairs(BATTLER_FIELDS) do
if field ~= "curStats" and field ~= "curTypes" and field ~= "curMoves" then
if captured[field] ~= nil then
target[field] = clone(captured[field], copy)
else
target[field] = nil
end
end
end
target.curStats = captured.curStatsFromMon and target.mon.stats
or assert(copy(captured.curStats))
target.curTypes = captured.curTypesFromDefinition and target.def.types
or assert(copy(captured.curTypes))
target.curMoves = captured.curMovesFromMon and target.mon.moves
or assert(copy(captured.curMoves))
for field, slotField in pairs(MOVE_REFERENCE_FIELDS) do
target[field] = captured[slotField] and target.curMoves[captured[slotField]] or nil
end
return target
end
local function restoreIndexSet(indices, party)
local out = {}
for _, index in ipairs(indices or {}) do out[party[index]] = true end
return next(out) and out or nil
end
function BattleCheckpoint.restore(game, checkpoint, copy)
local model = checkpoint.runtime.battle
local battle
if model.kind == "trainer" then
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex)
battle.enemyParty = assert(copy(model.enemyParty))
battle.enemyIndex = model.enemyIndex
else
battle = BattleState.newWild(game, model.enemyMon.species, model.enemyMon.level)
end
battle.player = BattleState.makeBattler(game.data,
game.save.party[model.player.index], true, game.save)
applyBattler(battle.player, model.player, copy)
local enemyMon
if model.kind == "trainer" then
enemyMon = battle.enemyParty[model.enemy.index]
else
enemyMon = assert(copy(model.enemyMon))
end
battle.enemy = BattleState.makeBattler(game.data, enemyMon, false)
applyBattler(battle.enemy, model.enemy, copy)
battle.mimicRestores = {}
for _, restore in ipairs(model.mimicRestores or {}) do
local battler = restore.side == "player" and battle.player or battle.enemy
battle.mimicRestores[#battle.mimicRestores + 1] = {
battler = battler,
entry = battler.curMoves[restore.slot],
id = restore.id,
}
end
if #battle.mimicRestores == 0 then battle.mimicRestores = nil end
for _, field in ipairs(BATTLE_FIELDS) do
if model[field] ~= nil then
battle[field] = clone(model[field], copy)
else
battle[field] = nil
end
end
battle.kind = model.kind
battle.ruleset = rulesets(game)[model.rulesetId]
battle.checkpointOrigin = assert(copy(model.origin))
battle.participants = restoreIndexSet(model.participants, game.save.party)
battle.leveledUp = restoreIndexSet(model.leveledUp, game.save.party)
battle.sides = assert(copy(model.sides))
battle.sides[1].battlers = { battle.player }
battle.sides[2].battlers = { battle.enemy }
battle.field = assert(copy(model.field))
battle.field.sides = battle.sides
battle.phase, battle.queue = "menu", {}
battle.frame = 0
battle.current, battle.afterQueue, battle.nextInsert = nil, nil, nil
battle.pendingHit, battle.waitingUI, battle.waitingSound = nil, nil, nil
battle.waitFrames, battle.draining, battle.animPlaying = nil, nil, nil
battle.introText, battle.introBalls, battle.introSlide = nil, nil, nil
battle.showPlayerBack, battle.showEnemyTrainer, battle.showEnemyBalls = nil, nil, nil
battle.player.shownHP, battle.player.shownStatus =
battle.player.mon.hp, battle.player.mon.status
battle.enemy.shownHP, battle.enemy.shownStatus =
battle.enemy.mon.hp, battle.enemy.mon.status
local ow = game.overworld
if not ow or type(ow.restoreBattleContinuation) ~= "function"
or ow:restoreBattleContinuation(battle, battle.checkpointOrigin) ~= true then
error("battle continuation reconstruction is unavailable", 0)
end
if type(game.restoreCheckpointBattle) ~= "function" then
error("game has no battle checkpoint reconstruction path", 0)
end
game:restoreCheckpointBattle(battle)
local setState = love and love.math and love.math.setRandomState
if type(setState) ~= "function" then error("battle RNG restore is unavailable", 0) end
setState(checkpoint.rng.love)
return battle
end
local function captureExtensions(battle, copy)
local sides = {}
for i = 1, 2 do
local side = battle.sides and battle.sides[i] or {}
local encoded, err = copy({
index = i,
screens = side.screens or {},
hazards = side.hazards or {},
tokens = side.tokens or {},
})
if not encoded then return nil, err end
sides[i] = encoded
end
local field, err = copy({
weather = battle.field and battle.field.weather or nil,
tokens = battle.field and battle.field.tokens or {},
})
if not field then return nil, err end
return sides, field
end
function BattleCheckpoint.capture(game, battle, progress, copy)
local getState = love and love.math and love.math.getRandomState
local setState = love and love.math and love.math.setRandomState
if type(getState) ~= "function" or type(setState) ~= "function" then
return nil, "rng_state_unavailable",
"This runtime cannot preserve deterministic battle randomness."
end
local ok, rngState = pcall(getState)
if not ok or type(rngState) ~= "string" or rngState == "" then
return nil, "rng_state_unavailable",
"The gameplay random-number state could not be captured."
end
local origin, originErr = copy(battle.checkpointOrigin)
if not origin then
return nil, "battle_origin_unsupported",
"The battle completion path is not data-only: " .. tostring(originErr)
end
local sides, fieldOrErr = captureExtensions(battle, copy)
if not sides then
return nil, "battle_extension_unsafe",
"Battle extension state is not data-only: " .. tostring(fieldOrErr)
end
local field = fieldOrErr
local liveParty = game.save.party
local playerIndex = partyIndex(liveParty, battle.player.mon)
if not playerIndex then
return nil, "battle_state_invalid",
"The active player battler is not in the current party."
end
local model = {
kind = battle.kind,
rulesetId = rulesetId(game, battle.ruleset),
origin = origin,
player = captureBattler(battle.player, playerIndex, copy),
participants = indexSet(battle.participants, liveParty),
leveledUp = indexSet(battle.leveledUp, liveParty),
sides = sides,
field = field,
mimicRestores = captureMimicRestores(battle),
}
if not model.rulesetId then
return nil, "battle_state_invalid", "Battle ruleset identity is unavailable."
end
if not model.player then
return nil, "battle_state_invalid", "Player move references are inconsistent."
end
if not model.mimicRestores then
return nil, "battle_state_invalid", "Mimic restore state is inconsistent."
end
if battle.kind == "trainer" then
model.enemyParty = copy(battle.enemyParty)
model.enemy = captureBattler(battle.enemy, battle.enemyIndex, copy)
else
model.enemyMon = copy(battle.enemy.mon)
model.enemy = captureBattler(battle.enemy, 1, copy)
end
if not model.enemy then
return nil, "battle_state_invalid", "Enemy move references are inconsistent."
end
for _, fieldName in ipairs(BATTLE_FIELDS) do
if battle[fieldName] ~= nil then model[fieldName] = battle[fieldName] end
end
model = copy(model)
if not model then
return nil, "battle_state_invalid",
"Battle state contains non-serializable runtime data."
end
local player = progress.player
return {
overworld = {
map = player.map, x = player.x, y = player.y,
facing = player.facing, surfing = player.surfing and true or false,
},
battle = model,
}, { love = rngState }
end
return BattleCheckpoint
+424
View File
@@ -0,0 +1,424 @@
-- Public runtime checkpoint implementation. Loader exposes bound forwarding
-- methods; mods never receive controller or state-stack internals from here.
local SaveSerializer = require("src.core.SaveSerializer")
local SaveData = require("src.core.SaveData")
local Version = require("src.core.Version")
local BattleState = require("src.battle.BattleState")
local BattleCheckpoint = require("src.core.BattleCheckpoint")
local ModRuntime = require("src.mods.Runtime")
local Checkpoint = {}
Checkpoint.FORMAT = 1
local function refusal(kind, reason, message)
return {
canCapture = false,
canRestore = false,
kind = kind or "unknown",
reason = reason,
message = message,
}
end
local function running(runner)
return runner and runner.isRunning and runner:isRunning()
end
local function nonempty(value)
return type(value) == "table" and next(value) ~= nil
end
local function scriptsBusy(ow)
return running(ow.runner) or nonempty(ow.parallelRunners)
or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue)
or nonempty(ow.scriptMoves)
end
local BATTLE_BUSY_FIELDS = {
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
}
local function inspectBattle(ow, battle)
if battle.kind == "link" then
return refusal("battle", "link_battle_unsupported",
"Network battles cannot be checkpointed.")
end
if battle.safari or battle.ghost or battle.scopeReveal or battle.demo
or battle.noCatch then
return refusal("battle", "battle_variant_unsupported",
"This battle variant does not have a checkpoint contract.")
end
if battle.kind ~= "wild" and battle.kind ~= "trainer" then
return refusal("battle", "battle_variant_unsupported",
"This battle kind does not have a checkpoint contract.")
end
local origin = battle.checkpointOrigin
local expectedOrigin = battle.kind == "wild" and "wild_encounter"
or "trainer_encounter"
if type(origin) ~= "table" or origin.kind ~= expectedOrigin then
return refusal("battle", "battle_origin_unsupported",
"The battle completion path cannot be reconstructed safely.")
end
if scriptsBusy(ow) then
return refusal("battle", "script_busy",
"A suspended or queued script cannot be checkpointed.")
end
if battle.phase ~= "menu" or nonempty(battle.queue) then
return refusal("battle", "battle_phase_busy",
"Wait for the player command menu before creating a checkpoint.")
end
for _, field in ipairs(BATTLE_BUSY_FIELDS) do
if battle[field] ~= nil and battle[field] ~= false then
return refusal("battle", "battle_phase_busy",
"Wait for the current battle action to finish.")
end
end
if not battle.player or not battle.enemy or battle.player.mon.hp <= 0
or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then
return refusal("battle", "battle_phase_busy",
"Wait for an ordinary player decision before creating a checkpoint.")
end
for _, battler in ipairs({ battle.player, battle.enemy }) do
if battler.shownHP ~= battler.mon.hp
or battler.shownStatus ~= battler.mon.status
or battler.drainFloor ~= nil or battler.drainHold ~= nil
or battler.faintQueued then
return refusal("battle", "battle_phase_busy",
"Wait for battle status and HP presentation to settle.")
end
end
return { canCapture = true, canRestore = true, kind = "battle" }
end
function Checkpoint.inspect(game)
local save = game and game.save
if type(save) ~= "table" or type(save.version) ~= "string" then
return refusal("unknown", "not_in_playthrough",
"A checkpoint requires an identified active playthrough.")
end
local ow = game.overworld
if type(ow) ~= "table" or type(ow.map) ~= "table"
or type(ow.map.id) ~= "string" or type(ow.player) ~= "table" then
return refusal("unknown", "not_overworld",
"Only a settled overworld can be checkpointed.")
end
local top = game.stack and game.stack.top and game.stack:top()
if getmetatable(top) == BattleState then
return inspectBattle(ow, top)
end
if top ~= ow then
return refusal("overworld", "screen_busy",
"Close the active menu or screen before creating a checkpoint.")
end
local identity = save.meta and save.meta.playthroughId
if type(identity) ~= "string" or identity == "" then
identity = SaveData.ensurePlaythroughId(save)
end
if type(identity) ~= "string" or identity == "" then
return refusal("overworld", "not_in_playthrough",
"The active playthrough could not be identified.")
end
if ow.transitioning then
return refusal("overworld", "transition_busy",
"Wait for the map transition to finish.")
end
if scriptsBusy(ow) then
return refusal("overworld", "script_busy",
"Wait for the active or queued script to finish.")
end
local animationFields = {
"engaging", "emote", "teleportOut", "dustAnim", "cutAnim", "fishPose",
"pikaHop", "healAnim", "flyAnim", "flyArrive",
}
for _, field in ipairs(animationFields) do
if ow[field] then
return refusal("overworld", "animation_busy",
"Wait for the overworld animation to finish.")
end
end
if ow.player.moving or ow.player.targetX ~= nil or ow.player.targetY ~= nil then
return refusal("overworld", "movement_busy",
"Wait for movement to settle on a tile.")
end
return { canCapture = true, canRestore = true, kind = "overworld" }
end
local function dataCopy(value)
local ok, encoded = pcall(SaveSerializer.encode, value)
if not ok then return nil, tostring(encoded) end
local decoded, err = SaveSerializer.decode(encoded)
if not decoded then return nil, err end
return decoded
end
local function captureRng()
local getState = love and love.math and love.math.getRandomState
local setState = love and love.math and love.math.setRandomState
if type(getState) ~= "function" or type(setState) ~= "function" then return nil end
local ok, state = pcall(getState)
if ok and type(state) == "string" and state ~= "" then
return { love = state }
end
return nil
end
local function restoreRng(rng)
if rng == nil then return end -- legacy format-1 overworld checkpoint
local setState = love and love.math and love.math.setRandomState
if type(rng) ~= "table" or type(rng.love) ~= "string"
or type(setState) ~= "function" then
error("checkpoint RNG restore is unavailable", 0)
end
setState(rng.love)
end
function Checkpoint.capture(game)
local capability = Checkpoint.inspect(game)
if not capability.canCapture then
return nil, capability.reason, capability.message
end
local progress = {}
for key, value in pairs(game.save) do
if key ~= "options" then progress[key] = value end
end
progress = dataCopy(progress)
if not progress then
return nil, "capture_failed", "Progress contains non-serializable runtime data."
end
local ok, err = pcall(game.overworld.captureSave, game.overworld, progress)
if not ok then
return nil, "capture_failed", "Could not synchronize overworld progress: "
.. tostring(err)
end
progress, err = dataCopy(progress)
if not progress then
return nil, "capture_failed", "Synchronized progress is not data-only: "
.. tostring(err)
end
if capability.kind == "battle" then
local battle = game.stack:top()
local runtime, rngOrCode, battleMessage =
BattleCheckpoint.capture(game, battle, progress, dataCopy)
if not runtime then return nil, rngOrCode, battleMessage end
return {
format = Checkpoint.FORMAT,
kind = "battle",
identity = {
engineVersion = Version.engine,
gameVersion = game.save.version,
playthroughId = game.save.meta.playthroughId,
},
save = progress,
runtime = runtime,
rng = rngOrCode,
}
end
local player = game.overworld.player
return {
format = Checkpoint.FORMAT,
kind = "overworld",
identity = {
engineVersion = Version.engine,
gameVersion = game.save.version,
playthroughId = game.save.meta.playthroughId,
},
save = progress,
runtime = { overworld = {
map = game.overworld.map.id,
x = player.cellX,
y = player.cellY,
facing = player.facing,
surfing = player.surfing and true or false,
} },
rng = captureRng(),
}
end
local FACINGS = { up = true, down = true, left = true, right = true }
local function validate(game, checkpoint)
if type(checkpoint) ~= "table" then
return nil, "invalid_checkpoint", "Checkpoint root must be a table."
end
if checkpoint.format ~= Checkpoint.FORMAT then
return nil, "unsupported_format", "This checkpoint format is not supported."
end
if checkpoint.kind ~= "overworld" and checkpoint.kind ~= "battle" then
return nil, "unsupported_runtime_kind", "This checkpoint runtime kind is not supported."
end
local copy, copyErr = dataCopy(checkpoint)
if not copy then
return nil, "invalid_checkpoint", "Checkpoint is not data-only: "
.. tostring(copyErr)
end
local identity = copy.identity
local current = game and game.save
local currentId = current and current.meta and current.meta.playthroughId
if type(identity) ~= "table" or type(identity.engineVersion) ~= "string"
or type(identity.gameVersion) ~= "string"
or type(identity.playthroughId) ~= "string" then
return nil, "invalid_checkpoint", "Checkpoint identity is missing or corrupt."
end
if identity.gameVersion ~= current.version then
return nil, "wrong_game", "Checkpoint belongs to another game version."
end
if identity.playthroughId ~= currentId then
return nil, "wrong_playthrough", "Checkpoint belongs to another playthrough."
end
local save = copy.save
local runtime = copy.runtime and copy.runtime.overworld
if type(save) ~= "table" or type(save.player) ~= "table"
or type(runtime) ~= "table" then
return nil, "invalid_checkpoint", "Checkpoint progress or runtime data is missing."
end
if save.version ~= identity.gameVersion
or not save.meta or save.meta.playthroughId ~= identity.playthroughId then
return nil, "invalid_checkpoint", "Checkpoint progress identity is inconsistent."
end
if copy.rng ~= nil and (type(copy.rng) ~= "table"
or type(copy.rng.love) ~= "string" or copy.rng.love == "") then
return nil, "invalid_checkpoint", "Checkpoint RNG state is corrupt."
end
if type(runtime.map) ~= "string" or type(runtime.x) ~= "number"
or type(runtime.y) ~= "number" or runtime.x % 1 ~= 0 or runtime.y % 1 ~= 0
or not FACINGS[runtime.facing] or type(runtime.surfing) ~= "boolean" then
return nil, "invalid_checkpoint", "Overworld position is missing or corrupt."
end
if save.player.map ~= runtime.map or save.player.x ~= runtime.x
or save.player.y ~= runtime.y or save.player.facing ~= runtime.facing
or (save.player.surfing and true or false) ~= runtime.surfing then
return nil, "invalid_checkpoint", "Progress and runtime position disagree."
end
local map = game.data and game.data.maps and game.data.maps[runtime.map]
if type(map) ~= "table" then
return nil, "invalid_map", "Checkpoint references a map that is unavailable."
end
local width, height = tonumber(map.width), tonumber(map.height)
if not width or not height or runtime.x < 0 or runtime.y < 0
or runtime.x >= width * 2 or runtime.y >= height * 2 then
return nil, "invalid_position", "Checkpoint position is outside the map."
end
-- A checkpoint is a strict restoration record, not an ordinary CONTINUE
-- migration. Reuse the canonical save validator on the detached copy, but
-- reject any quarantine, remap, reclaim, clamp, or content repair it would
-- perform instead of silently changing the state the caller selected.
local beforeContent = SaveSerializer.encode(copy.save)
local validOk, report = pcall(SaveData.validate, copy.save, game.data)
local afterOk, afterContent = pcall(SaveSerializer.encode, copy.save)
if not validOk or not afterOk or not SaveData.emptyReport(report)
or afterContent ~= beforeContent then
return nil, "invalid_content",
"Checkpoint references unavailable or invalid game content."
end
if copy.kind == "battle" then
local battleOk, battleCode, battleMessage = BattleCheckpoint.validate(game, copy)
if not battleOk then return nil, battleCode, battleMessage end
elseif copy.runtime.battle ~= nil then
return nil, "invalid_checkpoint",
"Overworld checkpoint contains unexpected battle state."
end
return copy
end
local function apply(game, checkpoint, options)
local save, err = dataCopy(checkpoint.save)
if not save then error("checkpoint progress decode failed: " .. tostring(err), 0) end
local runtime = checkpoint.runtime.overworld
save.options = options
save.player.map = runtime.map
save.player.x = runtime.x
save.player.y = runtime.y
save.player.facing = runtime.facing
save.player.surfing = runtime.surfing
if type(game.restoreCheckpointSave) ~= "function" then
error("game has no checkpoint reconstruction path", 0)
end
game:restoreCheckpointSave(save)
if checkpoint.kind == "battle" then
BattleCheckpoint.restore(game, checkpoint, dataCopy)
else
restoreRng(checkpoint.rng)
end
end
local function equalData(a, b)
local okA, encodedA = pcall(SaveSerializer.encode, a)
local okB, encodedB = pcall(SaveSerializer.encode, b)
return okA and okB and encodedA == encodedB
end
local function firstDifference(a, b, path)
path = path or "$"
if type(a) ~= type(b) then return path .. " (type)" end
if type(a) ~= "table" then
if a ~= b then return path end
return nil
end
for key, value in pairs(a) do
if b[key] == nil and value ~= nil then
return path .. "." .. tostring(key) .. " (missing)"
end
local found = firstDifference(value, b[key], path .. "." .. tostring(key))
if found then return found end
end
for key, value in pairs(b) do
if a[key] == nil and value ~= nil then
return path .. "." .. tostring(key) .. " (unexpected)"
end
end
return nil
end
function Checkpoint.restore(game, checkpoint)
local capability = Checkpoint.inspect(game)
if not capability.canRestore then
return false, capability.reason, capability.message
end
local validated, code, message = validate(game, checkpoint)
if not validated then return false, code, message end
local rollback, captureCode, captureMessage = Checkpoint.capture(game)
if not rollback then return false, captureCode, captureMessage end
local options = game.save.options
local ok, err = pcall(apply, game, validated, options)
if ok then
local restored, verifyCode = Checkpoint.capture(game)
if restored and validated.rng == nil then restored.rng = nil end
if restored and equalData(restored, validated) then
if ModRuntime.wants("checkpoint.restored") then
ModRuntime.emit("checkpoint.restored", {
game = game,
kind = validated.kind,
})
end
return true
end
err = restored and ("restored state differed at "
.. tostring(firstDifference(validated, restored) or "canonical encoding"))
or ("restored state could not be captured: " .. tostring(verifyCode))
end
local rolledBack, rollbackErr = pcall(apply, game, rollback, options)
if not rolledBack then
return false, "rollback_failed",
"Checkpoint restore and rollback both failed: " .. tostring(rollbackErr)
end
return false, "restore_failed", "Checkpoint restoration failed: " .. tostring(err)
end
return Checkpoint
+83 -30
View File
@@ -111,6 +111,11 @@ local NOISE_DIVISORS = {
[4] = 64, [5] = 80, [6] = 96, [7] = 112,
}
local HPF_CHARGE = 0.999958 ^ (GB_CLOCK / SAMPLE_RATE)
local LPF_ALPHA = 0.8
local MIX_SCALE = 0.5
local function snapTicks(ticks)
return math.floor((ticks * 1470 + 256) / 512)
end
@@ -269,6 +274,7 @@ function Channel.new(engine, spec, options)
phase = 0,
noiseLfsr = 0x7FFF,
noiseClock = 0,
drumTail = nil,
timeTicks = 0,
}, Channel)
end
@@ -527,6 +533,25 @@ local function envelopeVolume(volume, fade, elapsed)
return math.min(15, volume + steps)
end
local function envelopeRingSamples(volume, fade)
if not fade or fade <= 0 or not volume or volume <= 0 then return 0 end
return math.floor(volume * (fade / 64) * SAMPLE_RATE + 0.5)
end
local function extendDrumEnvelope(segments)
local last = segments and segments[#segments]
if not last then return segments end
local ringEnd = last.startSample + envelopeRingSamples(last.volume, last.fade)
if ringEnd > last.endSample then last.endSample = ringEnd end
return segments
end
local function drumAudioEnd(drum)
local last = drum and drum[#drum]
return last and last.endSample or 0
end
function Channel:resetNoise()
self.noiseLfsr = 0x7FFF
self.noiseClock = 0
@@ -566,8 +591,7 @@ function Channel:sampleNoise(parameter)
end
end
end
-- LuaGB: instantaneous inverted LFSR LSB (high when bit0 == 0)
return bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
return bit.band(self.noiseLfsr, 1) == 0 and 1 or 0
end
local function sweepCalculation(register, sweep)
@@ -611,21 +635,51 @@ end
function Channel:sample()
while not self.ended
and (not self.event or self.event.sample >= self.event.samples) do
local prev = self.event
self.event = self:nextEvent()
self.phase = 0
self:resetNoise()
if self.event and self.event.drum then
self.drumTail = nil
self:resetNoise()
elseif prev and prev.drum and prev.sample < drumAudioEnd(prev.drum) then
-- ..(audio/engine_1.asm ln 197)
self.drumTail = prev
elseif not (self.event and self.event.silence and self.drumTail) then
self.drumTail = nil
self:resetNoise()
end
end
local event = self.event
if not event then return 0 end
local gain = channelVolume[self.hardware] or 1
if not event then
local tail = self.drumTail
if not tail then return 0 end
local sampleIndex = tail.sample
tail.sample = sampleIndex + 1
if sampleIndex >= drumAudioEnd(tail.drum) then
self.drumTail = nil
return 0
end
return self:sampleDrum(tail, sampleIndex) * gain
end
local sampleIndex = event.sample
event.elapsed = sampleIndex / SAMPLE_RATE
event.sample = sampleIndex + 1
if event.silence then return 0 end
local gain = channelVolume[self.hardware] or 1
if event.silence then
local tail = self.drumTail
if not tail then return 0 end
local tailIndex = tail.sample
tail.sample = tailIndex + 1
if tailIndex >= drumAudioEnd(tail.drum) then
self.drumTail = nil
return 0
end
return self:sampleDrum(tail, tailIndex) * gain
end
if event.drum then
return self:sampleDrum(event, sampleIndex) * gain
end
self.drumTail = nil
local volume = envelopeVolume(
event.volume or 0, event.fade or 0, event.elapsed)
if event.noise then
@@ -665,7 +719,8 @@ function Channel:sample()
-- a def-local program may omit its wave table entirely
if not wave then return 0 end
local index = math.min(32, math.floor(phase * 32) + 1)
return wave[index] * event.waveLevel * gain
local nibble = math.max(0, math.min(15, wave[index] * 8 + 8))
return (nibble / 15) * event.waveLevel * gain
end
local duty = event.duty
if type(duty) == "table" then
@@ -674,7 +729,7 @@ function Channel:sample()
local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2]
local step = math.floor(phase * 8) % 8
if pattern[step + 1] == 0 then
return -volume / 15 * gain
return 0
end
return volume / 15 * gain
end
@@ -685,7 +740,7 @@ Engine.__index = Engine
function Engine:noiseInstrument(number)
-- a def-local drum wins over the ROM engine's table for that id
local custom = self.customDrums and self.customDrums[number]
if custom then return custom end
if custom then return extendDrumEnvelope(custom) end
local cached = self.noiseInstruments[number]
if cached then return cached end
@@ -718,6 +773,7 @@ function Engine:noiseInstrument(number)
end
end
extendDrumEnvelope(segments)
self.noiseInstruments[number] = segments
return segments
end
@@ -792,6 +848,8 @@ function Engine.new(data, header, options)
customDrums = chip and chip.drums or nil,
noiseInstruments = {},
channels = {},
hpfCap = 0, hpfCapLeft = 0, hpfCapRight = 0,
lpf = 0, lpfLeft = 0, lpfRight = 0,
}, Engine)
-- header.tempo: the Music_*AlternateTempo override Music.play stamps onto
-- a copy of the song def (audio/alternate_tempo.asm) (#847)
@@ -826,10 +884,20 @@ function Engine:finished()
return true
end
local function analogOut(engine, input, hpfField, lpfField)
local cap = engine[hpfField]
local hp = input - cap
engine[hpfField] = input - hp * HPF_CHARGE
local prev = engine[lpfField]
local lp = prev + LPF_ALPHA * (hp - prev)
engine[lpfField] = lp
return math.max(-1, math.min(1, lp * MIX_SCALE))
end
function Engine:sample()
local value = 0
for _, channel in ipairs(self.channels) do value = value + channel:sample() end
return math.max(-1, math.min(1, value / 4))
return analogOut(self, value, "hpfCap", "lpf")
end
function Engine:sampleStereo()
@@ -840,8 +908,8 @@ function Engine:sampleStereo()
if not event or event.panLeft ~= false then left = left + value end
if not event or event.panRight ~= false then right = right + value end
end
return math.max(-1, math.min(1, left / 4)),
math.max(-1, math.min(1, right / 4))
return analogOut(self, left, "hpfCapLeft", "lpfLeft"),
analogOut(self, right, "hpfCapRight", "lpfRight")
end
function Engine:sampleChannel(number)
@@ -850,7 +918,7 @@ function Engine:sampleChannel(number)
local value = channel:sample()
if channel.number == number then selected = value end
end
return math.max(-1, math.min(1, selected / 4))
return analogOut(self, selected, "hpfCap", "lpf")
end
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
@@ -870,22 +938,7 @@ local function soundData(engine, samples, channels)
return result
end
-- Render a one-shot effect (SFX/cry) to a two-channel SoundData, or nil when
-- it is too short to be audible. The caller wraps it in a static
-- love.audio.Source (a playback concern, hence not done here).
--
-- The synthesis is mono (one summed value per frame, unlike the music path's
-- sampleStereo), but the buffer is written stereo on purpose: OpenAL only
-- spatializes 1-channel Sources, and a Source left at the default (0,0,0)
-- position, exactly where the listener sits, is rendered as an ambient sound
-- spread over EVERY output channel the device exposes at gains that differ
-- from the front pair. On an interface with more than two outputs that put
-- the SFX on outputs 5+6 as well, while the 2-channel music source
-- (ChipAudio.playMusic) stayed on 1+2 (#626). Multi-channel buffers skip
-- spatialization entirely and map onto the front pair, so duplicating the
-- sample costs one buffer's memory and makes effects route exactly like
-- music. Deliberately not sampleStereo: that honors the NR51 panning byte
-- and would newly hard-pan any effect whose header issues command 0xEE.
local function renderEffectData(data, header, options)
if not header then return nil end
options = options or {}
+9
View File
@@ -84,6 +84,15 @@ function Data:applyVersionedFieldData()
-- Yellow caches carry the wrong demo species too. The fixed import
-- manifest below stamps RATTATA for fresh imports.
self.field.oldManBattle = { species = "RATTATA", level = 5 }
-- The Oak-speech show-off mon is the player's Pikachu in Yellow
-- (engine/battle/core.asm BATTLE_TYPE_PIKACHU / the ProfOak demo)
-- but caches imported before the manifest carried demoSpecies fell
-- back to Red's NIDORINO (#915). The fixed import manifest below
-- stamps PIKACHU for fresh imports; fill it here for stale caches.
local oakSpeech = self.field.oakSpeech
if type(oakSpeech) == "table" and not oakSpeech.demoSpecies then
oakSpeech.demoSpecies = "PIKACHU"
end
end
end
+82 -9
View File
@@ -221,24 +221,44 @@ function Game:step(dt)
-- it on its own real-time 60Hz accumulator instead.
end
-- The per-category (RFC 0007) save.options multiplier for whichever of
-- "battle"/"overworld"/"menu" Game.speedCategoryInStack says is active
-- right now. This is the "vanilla" the core.logic_speed hook wraps below
-- -- Game:logicSpeed calls it AFTER the link and speedOverride checks, so
-- neither a mod nor the category resolution ever has a seam to defeat them.
function Game:_resolveLogicSpeed()
local GameSpeed = require("src.core.GameSpeed")
local category = Game.speedCategoryInStack(self.stack)
local key = GameSpeed.optionKey(category)
local opts = self.save and self.save.options
return GameSpeed.clamp(opts and opts[key] or GameSpeed.DEFAULT)
end
-- The logic multiplier for this frame. Read live rather than cached so the
-- Options row takes effect immediately; speedOverride is the --speed /
-- Options rows take effect immediately; speedOverride is the --speed /
-- POKEPORT_SPEED run argument, which wins over the saved option so a bot
-- or screenshot run does not depend on whatever the player last chose.
function Game:logicSpeed()
local GameSpeed = require("src.core.GameSpeed")
-- Link play is always 1X on both machines, and this wins over every other
-- source including POKEPORT_SPEED. Fast-forward multiplies the logic
-- clock, so a peer at 10X burned a tournament shot clock ten times faster
-- than the opponent it is racing, and drove its own animation/message
-- queue at a different rate than the peer it is locked to. Nothing about
-- a match should depend on what either player set this to.
-- source including POKEPORT_SPEED and every per-category option.
-- Fast-forward multiplies the logic clock, so a peer at 10X burned a
-- tournament shot clock ten times faster than the opponent it is racing,
-- and drove its own animation/message queue at a different rate than the
-- peer it is locked to. Nothing about a match should depend on what
-- either player set this to -- checked here, before the core.logic_speed
-- hook ever runs, so a mod cannot defeat it either.
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
return 1
end
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
local opts = self.save and self.save.options
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
-- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's
-- core.logic_speed hook can return anything (0, negative, nil, NaN) and
-- Hooks:call only guards against a hook that throws, not one that
-- returns a bad value, so an unclamped result would flow straight into
-- the FixedStep accumulator math below and freeze or destabilize logic.
return GameSpeed.clamp(ModRuntime.call("core.logic_speed",
function(g) return g:_resolveLogicSpeed() end, self))
end
function Game:update(dt)
@@ -337,6 +357,28 @@ function Game.wideBattleInStack(stack)
return nil
end
-- Which of "battle"/"overworld"/"menu" per-category GAME SPEED (RFC 0007)
-- applies right now. Whole-stack, the same idiom as fillScaleInStack/
-- wideBattleInStack above: an overlay with neither marker (PartyMenu,
-- ChoiceBox, a NamingScreen, a text box) is transparent to the walk and
-- inherits whatever is under it, making the category a property of the
-- STACK POSITION the overlay sits over, not of the overlay itself. A
-- scripted sequence (script.started/ended) never pushes a state of its
-- own either -- it runs through the owning overworld/battle state's own
-- script runner or message queue -- so it inherits the same way. Nothing
-- identifying as either (the title screen, credits, an intro cutscene
-- with nothing under it) falls to "menu", the bucket every non-gameplay
-- screen gets; see the RFC's Decisions section for the full reasoning.
function Game.speedCategoryInStack(stack)
local states = stack and stack.states
for i = #(states or {}), 1, -1 do
local state = states[i]
if state and state.isBattle then return "battle" end
if state and state.isOverworld then return "overworld" end
end
return "menu"
end
-- Whether a state on the stack composes its own screen and so wants the
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
-- everything else here: the text box and YES/NO a battle puts up are states
@@ -556,8 +598,15 @@ function Game:_cycleSpeed(dir)
or ow.engaging or ow.emote))
end
if busy then return end
-- Cycles whichever category Game.speedCategoryInStack says is active
-- right now (RFC 0007) -- pressing the hotkey during a battle speeds up
-- just the battle, on the overworld just the walk, in a menu just the
-- menu. A single physical control that means "speed up whatever I'm
-- looking at right now" needs no new UI and matches what a player
-- pressing it mid-battle almost certainly wants.
local GameSpeed = require("src.core.GameSpeed")
self.save.options.speed = GameSpeed.cycle(self.save.options.speed, dir)
local key = GameSpeed.optionKey(Game.speedCategoryInStack(self.stack))
self.save.options[key] = GameSpeed.cycle(self.save.options[key], dir)
self:writeOptions()
end
@@ -1130,4 +1179,28 @@ function Game:restoreSave(loaded, recovered)
end
end
-- Reconstruct a previously validated runtime checkpoint without replaying the
-- ordinary CONTINUE lifecycle. In particular, map onEnter scripts and
-- save.loading/save.loaded events must not run a second time. Validation,
-- identity checks and transactional rollback live in Checkpoint.lua.
function Game:restoreCheckpointSave(loaded)
self.save = loaded
self:adoptSave(loaded)
while self.stack:top() do self.stack:pop() end
self.stack:push(self.overworld, loaded.player.map,
loaded.player.x, loaded.player.y, loaded.player.facing,
{ via = "checkpoint", checkpoint = true })
end
-- Install a reconstructed battle without calling BattleState:enter(), whose
-- transition, intro queues and battle-start side effects already happened in
-- the checkpointed timeline.
function Game:restoreCheckpointBattle(battle)
if self.stack:top() ~= self.overworld then
error("battle checkpoint requires a reconstructed overworld base", 0)
end
self.stack.states[#self.stack.states + 1] = battle
if battle.resumeCheckpoint then battle:resumeCheckpoint() end
end
return Game
+15
View File
@@ -51,4 +51,19 @@ function GameSpeed.cycle(v, dir)
return levels[nextIdx]
end
-- Per-category speed (RFC 0007): overworld walking, battle turns and menu
-- navigation each cycle their own multiplier instead of one global "speed"
-- value. This list is the single source of truth for which categories
-- exist and the order the Options rows/save.options keys follow;
-- Game.lua's stack-walk (Game.speedCategoryInStack) decides WHICH category
-- is active on a given frame, this module only knows the category names.
GameSpeed.CATEGORIES = { "overworld", "battle", "menu" }
-- the save.options field name a category's multiplier lives under, e.g.
-- "overworld" -> "speedOverworld". Centralized so Game.lua, OptionsMenu.lua,
-- LauncherSettings.lua and the SaveData migration never hand-spell the key.
function GameSpeed.optionKey(category)
return "speed" .. category:sub(1, 1):upper() .. category:sub(2)
end
return GameSpeed
+41 -42
View File
@@ -1,12 +1,3 @@
-- Music playback supports compact ROM channel programs synthesized live by
-- ChipAudio, def-local chip programs (ChipAsm), and file definitions. The
-- branch is chosen per song definition, never by a global import flag, so a
-- file-backed song and a chip song coexist in one dataset. Songs with split
-- files chain def.file into def.loopFile in Music.update().
-- Map themes switch on map change; battles override with the battle
-- theme and restore afterwards; riding the bike overrides outdoor map
-- themes with the bike song until dismount.
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
@@ -14,20 +5,10 @@ local Music = {}
local VOLUME = 0.7
-- port additions driven by OptionsMenu / save.options: musicVol scales
-- VOLUME (0-7 level like the GB's NR50 master volume) and musicFilter
-- low-passes the song. Each filter step keeps 40% of the previous
-- step's treble (highgain 0.4^level), so 2X/3X are the 1X filter
-- applied twice/three times over.
local volumeScale = 1
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
local filterLevel = 0
-- Forward-declared here so applyVolume (below) closes over the real playback
-- state rather than a nil global: the table literal is assigned further down,
-- but a `local state = {}` there would leave every reference above it bound
-- to the global `state`. Before this, registering the `music.volume` mod
-- hook crashed applyVolume on `state.current` (a nil index).
local state
local function applyVolume(src)
@@ -115,11 +96,6 @@ function Music.duckForFanfare(src)
end
end
-- Overworld themes where the bike can be ridden (outdoor maps plus the
-- caves/dungeons where gen-1 allows cycling). Indoor themes such as
-- Pokecenter/Gym/SilphCo never get replaced by the bike theme.
-- data.audio.outdoorSongs supersedes this; the copy stays as the fallback
-- for caches built before the importer wrote the table.
local OUTDOOR = {
Music_PalletTown = true,
Music_Cities1 = true,
@@ -218,6 +194,7 @@ end
-- the single choke point every song choice passes through, so one hook
-- covers map themes, battle themes, jingles and scene music
local function selectSong(song, ctx)
if ctx and ctx.selected then return song end
if not Runtime.wantsHook("music.select") then return song end
return Runtime.call("music.select", function(chosen) return chosen end, song, {
reason = ctx and ctx.reason or "direct",
@@ -234,17 +211,28 @@ end
function Music.play(data, song, loop, ctx)
if not song then return end
if not love.audio then return end -- headless test stub
ctx = ctx or {}
song = selectSong(song, ctx)
-- ctx.tempo is a Music_*AlternateTempo cue (audio/alternate_tempo.asm):
-- the same song restarted with channel 1 re-pointed at a stub whose only
-- difference is its `tempo`, so the same label at a different tempo is a
-- different cue and must not be deduped away (#847)
local tempo = ctx and ctx.tempo or nil
-- a hook may silence the cue outright, or swap in a label the dedupe
-- below has to compare against
if not song or (song == state.current and tempo == state.tempo) then return end
local def = songDef(data, song)
if not def or state.failed[song] then return end
if ctx.fade and state.source then
local queued = {}
for key, value in pairs(ctx) do queued[key] = value end
queued.fade, queued.selected = nil, true
local pending = { data = data, song = song, loop = loop, ctx = queued }
if state.fade then
state.fade.pending = pending
else
Music.fadeOut(ctx.fade, pending)
end
return
end
if tempo then
-- shallow copy: the registry def is shared, only this playback is slowed
local slowed = {}
@@ -317,24 +305,26 @@ function Music.reload()
Music.stop()
end
-- Ramp the current song's volume to silence, then stop it, mirroring the
-- Game Boy's audio fade-out (home/fade_audio.asm FadeOutAudio +
-- home/audio.asm's .fadeOut): rAUDVOL's master volume steps 7 -> 0 in
-- integer levels, one level every `control` frames, and the music stops
-- when it reaches 0. `control` is the wAudioFadeOutControl value the ROM
-- writes (oak_speech.asm sets 10 at the shrink beat -> 7*10 = 70 frames
-- to silence). Ticked once per frame from Music.update().
function Music.fadeOut(control)
if not state.source then Music.stop() return end
function Music.fadeOut(control, pending)
if not state.source then
Music.stop()
if pending then
Music.play(pending.data, pending.song, pending.loop, pending.ctx)
end
return
end
control = math.max(1, control or 10)
state.fade = {
control = control,
counter = control, -- frames until the next volume step
level = 7, -- current master-volume level (rAUDVOL nibble)
from = VOLUME * volumeScale, -- level-7 (full) source volume
pending = pending,
}
end
Music.MAP_FADE = 10
-- the song a map should currently play, honoring the bike/surf overrides
local function effectiveMapSong(data, song)
if not song or not outdoorSongs(data)[song] then return song end
@@ -351,14 +341,17 @@ end
-- overworld map theme; onBike/surfing override outdoor themes with the
-- bike/surf songs and restore the map theme when they end
function Music.playMap(data, mapId, onBike, surfing)
function Music.playMap(data, mapId, onBike, surfing, fade)
local song = data and data.audio and data.audio.mapSongs
and mapId and data.audio.mapSongs[mapId] or nil
state.mapSong = song
state.onBike = not not onBike
state.surfing = not not surfing
local play = effectiveMapSong(data, song)
if play then Music.play(data, play, nil, { reason = "map", mapId = mapId }) end
if play then
Music.play(data, play, nil,
{ reason = "map", mapId = mapId, fade = fade })
end
end
-- toggle the surf override mid-map (starting/ending a surf)
@@ -368,11 +361,12 @@ function Music.setSurfing(data, surfing)
if play then Music.play(data, play, nil, { reason = "map" }) end
end
-- battle themes; kind = "wild"|"trainer"|"gym"|"final"
function Music.playBattle(data, kind, trainerId)
-- battle themes; kind = "wild"|"trainer"|"gym"|"final". `song`, when
-- given, overrides the kind's default -- a mod-set trainer battleTheme.
function Music.playBattle(data, kind, trainerId, song)
local b = data.audio and data.audio.battle
if b then
Music.play(data, b[kind] or b.wild, nil,
Music.play(data, song or b[kind] or b.wild, nil,
{ reason = "battle", kind = kind, trainerId = trainerId })
end
end
@@ -474,8 +468,13 @@ function Music.update(data)
f.counter = f.control
f.level = f.level - 1
if f.level <= 0 then
-- ..(home/fade_audio.asm ln 36)
state.fade = nil
local pending = f.pending
Music.stop()
if pending then
Music.play(pending.data, pending.song, pending.loop, pending.ctx)
end
return
end
local vol = f.from * f.level / 7
+18
View File
@@ -0,0 +1,18 @@
-- Generic process-lifecycle mod hooks so a platform-specific launcher
-- integration (a native shell embedding this engine, e.g. wrapping the
-- window in a platform UI) can live entirely in a mod instead of
-- hand-patching main.lua, which every other engine change also touches.
-- See docs/modding.md's "Process-lifecycle hooks" section.
local ModRuntime = require("src.mods.Runtime")
local PlatformHooks = {}
function PlatformHooks.update(game, dt)
return ModRuntime.call("core.update", function(g, d) g:update(d) end, game, dt)
end
function PlatformHooks.quitToLauncher(vanilla)
return ModRuntime.call("core.quit_to_launcher", vanilla)
end
return PlatformHooks
+170 -7
View File
@@ -216,6 +216,13 @@ local function persistFs(fs)
return SaveData.portableFs() or fs or (love and love.filesystem)
end
-- Engine-owned persistence routing for subsystems that must follow the same
-- standard/portable root as saves without exposing raw filesystem access to a
-- mod. An explicitly injected headless filesystem still wins for tests.
function SaveData.persistenceFs(fs)
return persistFs(fs)
end
-- Port + original Options menu defaults. Missing keys on load are filled
-- from this table so old options.lua files stay compatible.
function SaveData.defaultOptions()
@@ -254,8 +261,12 @@ function SaveData.defaultOptions()
-- (the PIKACHU VOL row appears only on Yellow; see Sound.lua)
pikaVol = 7,
musicFilter = 0,
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
speed = 1,
-- Per-category logic fast-forward multiplier (RFC 0007); audio is
-- unaffected (GameSpeed.lua). Superseded from a single "speed" field --
-- mergeOptions migrates an old save's value into all three below.
speedOverworld = 1,
speedBattle = 1,
speedMenu = 1,
-- port display options (OptionsMenu / hotkeys 2/3/4/5)
colors = "gbc",
tilt = 0,
@@ -332,6 +343,19 @@ function SaveData.mergeOptions(loaded)
for k, v in pairs(loaded) do
opts[k] = v
end
-- RFC 0007 migration: a save from before per-category GAME SPEED still
-- has a single "speed" and none of the three new fields, so seed all
-- three from it -- an existing player's fast-forward preference
-- carries over instead of two of the three categories silently
-- resetting to 1X. "speed" is dropped on the way out (not kept as a
-- stale alias), so a re-save never re-triggers this migration.
if loaded.speed ~= nil and loaded.speedOverworld == nil
and loaded.speedBattle == nil and loaded.speedMenu == nil then
opts.speedOverworld = loaded.speed
opts.speedBattle = loaded.speed
opts.speedMenu = loaded.speed
end
opts.speed = nil
end
return opts
end
@@ -351,6 +375,22 @@ local function readTable(fs, name)
return SaveSerializer.decode(body)
end
-- Deep-copy a value folded in from the on-disk decode so the returned
-- options table never aliases the file's nested tables (SaveData must not
-- depend on src/mods/Merge.lua for this). Options data is plain tables of
-- strings/numbers/booleans/tables, so a cycle guard is belt-and-braces.
local function deepCopy(v, seen)
if type(v) ~= "table" then return v end
seen = seen or {}
if seen[v] then return seen[v] end
local copy = {}
seen[v] = copy
for k, val in pairs(v) do
copy[deepCopy(k, seen)] = deepCopy(val, seen)
end
return copy
end
-- the stub filesystem some headless harnesses inject has no remove; a
-- lingering tmp/bak there is harmless
local function remove(fs, name)
@@ -364,13 +404,48 @@ end
-- options round-trip headless (no love global).
function SaveData.saveOptions(opts, fs)
fs = persistFs(fs)
-- #932: options.lua is a WHOLE-FILE rewrite, so a caller that hands over a
-- PARTIAL table (just the keys it changed) would silently drop every key it
-- does not mention -- launcher-only keys like lastVersion, and keys the
-- launcher set (battleBg, tilt...) all fall back to defaults. Read the
-- on-disk file FIRST and fold caller-absent values underneath, so a delta
-- write changes only what it names.
--
-- A table holding EVERY defaultOptions key is a full snapshot
-- (loadOptions() results, game.save.options, the RESET REBINDS /
-- activeProfile-drop paths) and stays authoritative: its absent keys are
-- deliberate deletions, so nothing folds for it. Partial tables get every
-- on-disk key they do not provide folded in (deep-copied so the caller's
-- table is never aliased). This is the reconciling rule: bindings and
-- activeProfile -- not defaultOptions members -- can be deleted by their
-- sites precisely because those sites always write full tables.
local onDisk = readTable(fs, OPTIONS_FILENAME)
local isFull = type(opts) == "table"
if isFull then
for k in pairs(SaveData.defaultOptions()) do
if opts[k] == nil then isFull = false break end
end
end
if not isFull then
local merged = {}
if type(opts) == "table" then
for k, v in pairs(opts) do merged[k] = v end
end
if type(onDisk) == "table" then
for k, v in pairs(onDisk) do
if k ~= "modOptions" and merged[k] == nil then
merged[k] = deepCopy(v)
end
end
end
opts = merged
end
opts = SaveData.mergeOptions(opts)
-- modOptions is per-mod nested state: fold the on-disk sub-tree
-- underneath (newest value winning per key) so one caller's partial
-- write cannot clobber another mod's persisted keys. Every other
-- option stays on the shallow path.
local onDisk = readTable(fs, OPTIONS_FILENAME)
if onDisk and type(onDisk.modOptions) == "table" then
if type(onDisk) == "table" and type(onDisk.modOptions) == "table" then
local merged = {}
for modId, bucket in pairs(onDisk.modOptions) do
merged[modId] = bucket
@@ -479,6 +554,10 @@ end
-- working unchanged.
local activeSlotCache = {} -- version -> slotId in use, or false when none
local slotsChecked = {} -- version -> true once resolved this process
-- At most one New Game can be the live candidate for a first public tool
-- request. A single strong reference models that runtime fact without adding
-- marker data to the save or retaining abandoned playthrough tables.
local freshPlaythrough
local function slotDir(version) return "saves/" .. version end
@@ -815,6 +894,77 @@ end
function SaveData.resetSlotState()
for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end
for k in pairs(slotsChecked) do slotsChecked[k] = nil end
freshPlaythrough = nil
end
-- ------- opaque playthrough identity
-- An id must never perturb the engine's gameplay RNG: savestate tools need
-- repeatable random outcomes, and allocating persistence scope is not gameplay.
-- Combine wall/process time, a process-local sequence and a fresh table address
-- into four hex words. This is an opaque collision-resistant identifier, not a
-- secret or a player-visible value.
local playthroughSeq = 0
local function word(n)
return math.floor(tonumber(n) or 0) % 4294967296
end
function SaveData.newPlaythroughId()
playthroughSeq = playthroughSeq + 1
local address = tostring({}):match("0x(%x+)") or "0"
local addressLo = tonumber(address:sub(-8), 16) or 0
local clock = math.floor((os.clock() or 0) * 1000000)
return ("%08x%08x%08x%08x"):format(
word(os.time()), word(clock), word(addressLo), word(playthroughSeq))
end
local function playthroughScope(version, injectedFs)
version = version or GameVersion.get()
local fs = persistFs(injectedFs)
ensureVersionSlots(version, fs)
return activeSlotCache[version] or "legacy"
end
local function rememberPlaythroughId(save, opts, injectedFs)
local meta = type(save) == "table" and save.meta
local id = type(meta) == "table" and meta.playthroughId
if type(id) ~= "string" or id == "" then return opts, false end
local version = save.version or GameVersion.get()
local scope = playthroughScope(version, injectedFs)
opts = opts or SaveData.loadOptions(injectedFs)
opts.playthroughIds = opts.playthroughIds or {}
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
local changed = opts.playthroughIds[version][scope] ~= id
opts.playthroughIds[version][scope] = id
return opts, changed
end
-- Return an existing save identity or give a pre-identity save a stable one.
-- Legacy backfill lives in options.lua until the next normal SAVE stamps the id
-- into progress, so installing a tool mod never rewrites the player's checkpoint.
function SaveData.ensurePlaythroughId(save, injectedFs)
if type(save) ~= "table" then return nil end
save.meta = type(save.meta) == "table" and save.meta or {}
local id = save.meta.playthroughId
if type(id) == "string" and id ~= "" then return id end
local version = save.version or GameVersion.get()
local scope = playthroughScope(version, injectedFs)
local opts = SaveData.loadOptions(injectedFs)
local isFresh = save == freshPlaythrough
if isFresh then freshPlaythrough = nil end
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
id = not isFresh and byVersion and byVersion[scope] or nil
if type(id) ~= "string" or id == "" then
id = SaveData.newPlaythroughId()
opts.playthroughIds = opts.playthroughIds or {}
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
opts.playthroughIds[version][scope] = id
SaveData.saveOptions(opts, injectedFs)
end
save.meta.playthroughId = id
return id
end
-- ------- meta
@@ -838,6 +988,7 @@ function SaveData.buildMeta(mods, previous)
format = Version.saveFormat,
engine = Version.engine,
savedAt = os.time(),
playthroughId = type(previous) == "table" and previous.playthroughId or nil,
mods = list,
}
end
@@ -1048,7 +1199,15 @@ function SaveData.save(data, mods)
-- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version)
if data.options then
SaveData.saveOptions(data.options)
local opts = data.options
if data.meta and data.meta.playthroughId then
opts = rememberPlaythroughId(data, data.options)
end
data.options = opts
SaveData.saveOptions(opts)
elseif data.meta and data.meta.playthroughId then
local opts, changed = rememberPlaythroughId(data)
if changed then SaveData.saveOptions(opts) end
end
if mods ~= nil or data.meta == nil then
data.meta = SaveData.buildMeta(mods, data.meta)
@@ -1470,8 +1629,12 @@ function SaveData.newGame(boot)
options = SaveData.loadOptions(),
}
-- a total conversion reshapes the skeleton (spawn, party, money)
-- before anything reads it; unhooked this returns save unchanged
return Runtime.call("save.new_game", function(s) return s end, save)
-- before anything reads it; unhooked this returns save unchanged. Keep the
-- "fresh playthrough" marker outside the serialized table so a later tool
-- request can distinguish two unsaved New Games sharing one vanilla slot.
save = Runtime.call("save.new_game", function(s) return s end, save)
freshPlaythrough = save
return save
end
return SaveData
+7
View File
@@ -41,6 +41,13 @@ local function serialize(v, indent)
error("cannot serialize " .. t)
end
-- LuaJIT 2.1 can lose a just-added nested table entry when a GC step lands
-- inside a compiled recursive serialization trace. The symptom is valid input
-- becoming `{" followed by only the trailing comma, which then cannot be read
-- back. Save encoding is infrequent and I/O-bound, so keep this correctness-
-- critical recursion in the interpreter while leaving the game JIT enabled.
if jit and jit.off then jit.off(serialize, true) end
function SaveSerializer.encode(data)
return "return " .. serialize(data) .. "\n"
end
+17 -3
View File
@@ -214,10 +214,24 @@ local function coreRows(opts, hooks)
local okSpd, GameSpeed = pcall(require, "src.core.GameSpeed")
if okSpd then
add(Strings("GAME SPEED"),
function() return GameSpeed.levelLabel(opts.speed) end,
-- Per-category (RFC 0007): overworld/battle/menu each cycle their own
-- multiplier, mirroring OptionsMenu.lua's three rows.
add(Strings("OVERWORLD SPEED"),
function() return GameSpeed.levelLabel(opts.speedOverworld) end,
function(dir)
opts.speed = GameSpeed.cycle(opts.speed, dir)
opts.speedOverworld = GameSpeed.cycle(opts.speedOverworld, dir)
return true
end)
add(Strings("BATTLE SPEED"),
function() return GameSpeed.levelLabel(opts.speedBattle) end,
function(dir)
opts.speedBattle = GameSpeed.cycle(opts.speedBattle, dir)
return true
end)
add(Strings("MENU SPEED"),
function() return GameSpeed.levelLabel(opts.speedMenu) end,
function(dir)
opts.speedMenu = GameSpeed.cycle(opts.speedMenu, dir)
return true
end)
end
+35 -1
View File
@@ -198,6 +198,12 @@ function RomExtractor:extractTilesets()
out[constName] = {
id = constName,
source = ("ROM:Tilesets[%d]"):format(index - 1),
-- The raw Tilesets row, verbatim. A .sav export has to reproduce what
-- LoadTilesetHeader (engine/overworld/tilesets.asm) would have left in
-- wTilesetBank..wGrassTile, because a Continue never re-runs it -- see
-- src/save_convert/MapContext.lua (#889). Byte 12 is the tile
-- animation id, which rides in sTileAnimations.
header = self.rom:bytes(headers.bank, rowAddress, 12),
image = "assets/generated/tilesets/" .. base .. ".png",
imageWidth = spec.imageWidth,
imageHeight = spec.imageHeight,
@@ -268,6 +274,11 @@ function RomExtractor:extractMaps()
assert(tilesetId < #tilesets, constName .. ": unknown tileset id")
local blockPointer = self.rom:word(header.bank, address + 3)
local connectionFlags = self.rom:byte(header.bank, address + 9)
-- wCurMapHeader verbatim (tileset, height, width, data/text/script
-- pointers, connection flags). A save restores this window instead of
-- rebuilding it, so an export has to carry the real bytes (#889).
local headerBytes = self.rom:bytes(header.bank, address, 10)
local connectionStart = address + 10
address = address + 10
local connections = {}
@@ -289,6 +300,8 @@ function RomExtractor:extractMaps()
end
assert(bit.band(connectionFlags, 0xF0) == 0,
constName .. ": unknown connection flags")
local connectionBytes = self.rom:bytes(
header.bank, connectionStart, address - connectionStart)
local objectPointer = self.rom:word(header.bank, address)
local objectAddress = objectPointer
local borderBlock = self.rom:byte(header.bank, objectAddress)
@@ -376,6 +389,17 @@ function RomExtractor:extractMaps()
width = width, height = height, blocks = blocks,
borderBlock = borderBlock, connections = connections,
warps = warps, signs = signs, objects = objects,
-- Raw ROM bytes a .sav export replays through LoadMapHeader's WRAM
-- writes (src/save_convert/MapContext.lua, #889). Kept as the original
-- bytes rather than re-encoded from the decoded tables above: the
-- pointers in them (wCurMapDataPtr, the connection strip src/dest
-- addresses, sign text ids) have no equivalent in the port's own model.
sram = {
header = headerBytes,
connections = connectionBytes,
objects = self.rom:bytes(
header.bank, objectPointer, objectAddress - objectPointer),
},
}
self:tick("Maps", mapIndex, #keys)
end
@@ -1732,7 +1756,7 @@ end
function RomExtractor:extractField()
self:beginStage("Interface artwork")
local done, total = 0, 50
local done, total = 0, 51
local function tick()
done = done + 1
self:tick("Interface artwork", math.min(done, total), total)
@@ -1748,6 +1772,16 @@ function RomExtractor:extractField()
"title/copyright.png"); tick()
self:raw2bpp("GameFreakLogoGraphics", 72, 8,
"title/gamefreak_inc.png"); tick()
do
local gf = self.symbols["GameFreakLogoGraphics"]
local tb = self.symbols["TextBoxGraphics"]
if gf and tb and tb[2] == gf[2] + 9 * 16 + 16 then
local raw = self.rom:bytes(gf[1], gf[2] + 9 * 16, 16)
self:save(ImageWriter.decode2bpp(raw, 8, 8, false), "title/nine.png")
end
end
tick()
-- Yellow fixed Pikachu title art (no-op on Red/Blue manifests).
self:extractYellowTitleArt(); tick()
+20 -14
View File
@@ -30,7 +30,11 @@ end
-- Cache generation tag; bump to force every imported version to re-extract.
-- v9: Yellow audio re-anchored on pokeyellow.sym (#522) -- stale caches
-- carry Red's bank $1f header, wave-table, and CryData offsets.
local CACHE_FORMAT = "rom-cache-v9:"
-- v10: maps carry their raw map-header/connection/object bytes and tilesets
-- their Tilesets row (#889), which a .sav export replays so a Continue on
-- real hardware has a map to load; a v9 cache has none of them and exports
-- the same unbootable save as before.
local CACHE_FORMAT = "rom-cache-v10:"
-- The completion marker is written under each version's cache prefix
-- (red/rom-cache.complete, blue/rom-cache.complete, ...).
local MARKER_PATH = "rom-cache.complete"
@@ -152,18 +156,22 @@ local function allRequiredFilesExist(version)
return ok
end
-- A developer checkout / Python build leaves Red's generated data in the
-- physfs SOURCE at the un-prefixed root (the checked-out data/generated and
-- assets/generated); it is always current and never moves into red/. Only
-- Red ships this way (Blue/Yellow are import-only). The check goes through
-- love.filesystem directly so the red/ cache prefix cannot hide the source
-- tree, and the realDirectory test keeps a save-dir cache from counting.
local function sourceTreeHasData()
-- A developer checkout / Python build leaves generated data in the physfs
-- source: Red at the historical root, Blue/Yellow in their versioned trees.
-- Imported Red caches still live under red/. Check source paths directly so
-- that cache prefix cannot hide Red's source tree, and keep save-dir caches
-- from counting as current source data.
local function sourceTreeHasData(version)
if not love.filesystem.getRealDirectory then return false end
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
for _, path in ipairs(REQUIRED_FILES) do
if love.filesystem.getInfo(path, "file") == nil then return false end
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
end
local real = love.filesystem.getRealDirectory(REQUIRED_FILES[1])
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
end
local path = prefix .. REQUIRED_FILES[1]
local real = love.filesystem.getRealDirectory(path)
return real == love.filesystem.getSource()
end
@@ -240,10 +248,8 @@ function RomImporter.isReady(version)
-- save-directory copy that would otherwise shadow it at runtime.
purgeSaveDirCache()
end
-- Red generated data in the physfs source (developer checkout / Python
-- build) is always current; Blue is import-only and falls through to the
-- version-marker gate.
if version == "red" and sourceTreeHasData() then return true end
-- Generated data in a developer checkout / Python build is always current.
if sourceTreeHasData(version) then return true end
local saved = CacheFs.prefix
CacheFs.prefix = GameVersion.cachePrefix(version)
local marker = CacheFs.read(MARKER_PATH)
+46 -1
View File
@@ -25,6 +25,18 @@ local function noEffect(data)
return romText(data, "_ItemUseNoEffectText", "It won't have\nany effect.")
end
local function registeredEffect(data, itemDef)
if not data or not itemDef or not itemDef.effect then
return nil
end
if not data.item_effects then
return nil
end
return data.item_effects[itemDef.effect]
end
local HEAL_AMOUNT = {
POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200,
FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80,
@@ -72,7 +84,18 @@ function ItemEffects.healsHP(id)
end
-- Does this item need a party-member target?
function ItemEffects.needsTarget(id, itemDef)
-- 'data' is optional for compat purposes; targeting falls back to itemDef/vanilla detection
function ItemEffects.needsTarget(id, itemDef, data)
if itemDef and itemDef.needsTarget ~= nil then
return itemDef.needsTarget
end
local effect = registeredEffect(data, itemDef)
if effect and effect.needsTarget ~= nil then
return effect.needsTarget
end
return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION"
or id == "FULL_RESTORE" or id == "REVIVE" or id == "MAX_REVIVE"
or id == "RARE_CANDY" or STONES[id]
@@ -147,6 +170,28 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local itemDef = data.items[itemId]
local name = itemDef and itemDef.name or itemId
local effectDef = registeredEffect(data, itemDef)
if effectDef then
if battle and effectDef.battle == false then
return "failed", { notTime(data, save) }
end
if not battle and effectDef.field == false then
return "failed", { notTime(data, save) }
end
return effectDef.use({
data = data,
save = save,
itemId = itemId,
item = itemDef,
target = target,
battle = battle,
moveIndex = moveIndex,
overworld = ow,
})
end
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase /
-- ItemUseTMHM / ItemUseRepelCommon all refuse mid-battle
-- (jp nz, ItemUseNotTime)
+22
View File
@@ -171,4 +171,26 @@ function Json.decode(s)
return nil, v
end
-- For an HTTP response that was meant to carry JSON but did not. Returns nil
-- when `s` starts like a JSON object or array (the only shapes the update and
-- index endpoints publish), otherwise a short message naming what the server
-- actually sent -- so callers surface "the response was an HTML page/plain
-- text, not JSON (it starts with ...)" instead of leaking the decoder's
-- low-level "unexpected character 'E'" assert at the first byte of an error
-- page or plain-text outage message.
function Json.describeUnexpected(s)
if type(s) ~= "string" then
return "the response had no body to decode"
end
local first = s:match("^%s*(.)")
if first == "{" or first == "[" then return nil end
local preview = s:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
if preview == "" then
return "the response was empty, not JSON"
end
if #preview > 60 then preview = preview:sub(1, 57) .. "..." end
local kind = (first == "<") and "an HTML page" or "plain text"
return ("the response was %s, not JSON (it starts with %q)"):format(kind, preview)
end
return Json
+88 -82
View File
@@ -10,6 +10,7 @@ local Net = require("src.link.Net")
local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Session = require("src.link.Session")
local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
@@ -44,10 +45,8 @@ local function forceLevelLabel(v)
return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v))
end
-- stages before any Net object is meaningfully "this session's link" --
-- .net can still be a leftover failed attempt sitting on self, so error/
-- closed checks below skip these rather than keying off self.net's
-- presence alone
-- stages before a successful transport has become this link's Session;
-- terminal checks skip them rather than keying off self.net's presence
local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = true }
-- how long the host waits for a v2 hello before deciding the peer predates
@@ -70,6 +69,16 @@ local function ipDigits(ip)
return digits
end
local function openSession(role, connect)
local transport = Net.new()
if connect(transport) then
return Session.new(transport, { role = role, kind = "link" })
end
local detail = transport.error or "?"
transport:close()
return nil, detail
end
function LinkState.new(game)
local self = setmetatable({}, LinkState)
self.game = game
@@ -89,12 +98,15 @@ end
-- "connecting with this code", same as if the player had typed it in
function LinkState.newJoinOnline(game, code)
local self = LinkState.new(game)
self.net = Net.new()
if self.net:joinOnline(nil, code) then
local session, detail = openSession("guest", function(transport)
return transport:joinOnline(nil, code)
end)
if session then
self.net = session
self.stage = "onlineJoining"
else
self.stage = "menu" -- exitWith below needs a real stage to unwind from
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", detail))
end
return self
end
@@ -163,21 +175,13 @@ end
-- take the peer's hello out of the inbox without eating anything that
-- shares the batch with it
function LinkState:pollHello()
local msgs = self.net:poll()
local keep, got = {}, false
for _, msg in ipairs(msgs) do
if msg.type == "hello" and not self.peerHello then
self.peerHello = msg
self.peerName = msg.name
got = true
else
keep[#keep + 1] = msg
end
local message
if not self.peerHello then message = self.net:take("hello") end
if message then
self.peerHello = message
self.peerName = message.name
end
for i = #keep, 1, -1 do
table.insert(self.net.inbox, 1, keep[i])
end
return got, #keep > 0
return message ~= nil, self.net:hasPending()
end
function LinkState:sendHello(mode)
@@ -220,13 +224,15 @@ function LinkState:update(dt)
local input = self.game.input
if self.net then
self.net:update()
if self.net.error and not PRE_CONNECT_STAGES[self.stage] then
self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
local status = self.net:getStatus()
if status == "failed" and not PRE_CONNECT_STAGES[self.stage] then
self:exitWith(Strings("Link error:\n%s",
(self.net.error or "?"):sub(1, 60)))
return
end
-- the peer vanished without a bye (only once the inbox is drained,
-- the peer vanished without a bye (only once the session FIFO drains,
-- so a final message travelling with the disconnect still counts)
if self.net.closed and #self.net.inbox == 0
if status == "closed"
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
and self.stage ~= "codeEntry" and self.stage ~= "notice"
and self.stage ~= "battleRunning" then
@@ -269,12 +275,15 @@ function LinkState:update(dt)
self.stage = "menu"
self.index = 1
elseif input:wasPressed("a") then
self.net = Net.new()
if self.index == 1 then
if self.net:host() then
local session, detail = openSession("host", function(transport)
return transport:host()
end)
if session then
self.net = session
self.stage = "hosting"
else
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", detail))
end
else
self.stage = "addrEntry"
@@ -289,11 +298,14 @@ function LinkState:update(dt)
self.index = 2
elseif input:wasPressed("a") then
if self.index == 1 then
self.net = Net.new()
if self.net:hostOnline() then
local session, detail = openSession("host", function(transport)
return transport:hostOnline()
end)
if session then
self.net = session
self.stage = "onlineHosting"
else
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", detail))
end
else
self.stage = "codeEntry"
@@ -327,11 +339,14 @@ function LinkState:update(dt)
CodeEntry.right(self.codeEntry)
elseif input:wasPressed("a") then
local code = CodeEntry.text(self.codeEntry)
self.net = Net.new()
if self.net:joinOnline(nil, code) then
local session, detail = openSession("guest", function(transport)
return transport:joinOnline(nil, code)
end)
if session then
self.net = session
self.stage = "onlineJoining"
else
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", detail))
end
end
@@ -367,10 +382,15 @@ function LinkState:update(dt)
+ self.addr[base + 2] * 10
+ self.addr[base + 3])
end
if self.net:join(table.concat(octets, ".")) then
local address = table.concat(octets, ".")
local session, detail = openSession("guest", function(transport)
return transport:join(address)
end)
if session then
self.net = session
self.stage = "joining"
else
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", detail))
end
end
@@ -428,19 +448,11 @@ function LinkState:update(dt)
elseif self.stage == "waitMode" then -- guest waits for host's pick
if input:wasPressed("b") then self:exitWith(nil) return end
local msgs = self.net:poll()
for i, msg in ipairs(msgs) do
if msg.type == "hello" then
self.peerHello = msg
self.peerName = msg.name
-- the host's next messages (party, ...) can share this batch;
-- put them back so the new stage's poll sees them
for j = #msgs, i + 1, -1 do
table.insert(self.net.inbox, 1, msgs[j])
end
self:decideCompat(msg.mode, false)
break
end
local message = self.net:take("hello")
if message then
self.peerHello = message
self.peerName = message.name
self:decideCompat(message.mode, false)
end
elseif self.stage == "notice" then
@@ -456,40 +468,34 @@ function LinkState:update(dt)
elseif self.stage == "battleWait" then
if input:wasPressed("b") then self:exitWith(nil) return end
local msgs = self.net:poll()
for i, msg in ipairs(msgs) do
if msg.type == "party" then
-- the host owns this rule (same as mode); the guest only learns
-- it here, off the host's own party message
if not self.isHost then self.forceLevel = msg.forceLevel end
local LinkBattle = require("src.link.LinkBattle")
local opts = {
myParty = Protocol.packParty(self.game.save.party),
theirParty = msg.mons,
theirName = self.peerName or "FOE",
seed = self.isHost and self.linkSeed or msg.seed,
verdict = self.verdict,
strict = Handshake.strict(self.verdict),
forceLevel = self.forceLevel,
}
local battle, why
if self.isHost then
battle, why = LinkBattle.newHost(self.game, self.net, opts)
else
battle, why = LinkBattle.newGuest(self.game, self.net, opts)
end
if not battle then
self.net:send({ type = "bye" })
self:exitWith(why or Strings("Link battle\ncan't start."), "error")
return
end
self.game.stack:push(battle)
self.stage = "battleRunning"
for j = #msgs, i + 1, -1 do
table.insert(self.net.inbox, 1, msgs[j])
end
break
local message = self.net:take("party")
if message then
-- the host owns this rule (same as mode); the guest only learns
-- it here, off the host's own party message
if not self.isHost then self.forceLevel = message.forceLevel end
local LinkBattle = require("src.link.LinkBattle")
local opts = {
myParty = Protocol.packParty(self.game.save.party),
theirParty = message.mons,
theirName = self.peerName or "FOE",
seed = self.isHost and self.linkSeed or message.seed,
verdict = self.verdict,
strict = Handshake.strict(self.verdict),
forceLevel = self.forceLevel,
}
local battle, why
if self.isHost then
battle, why = LinkBattle.newHost(self.game, self.net, opts)
else
battle, why = LinkBattle.newGuest(self.game, self.net, opts)
end
if not battle then
self.net:send({ type = "bye" })
self:exitWith(why or Strings("Link battle\ncan't start."), "error")
return
end
self.game.stack:push(battle)
self.stage = "battleRunning"
end
elseif self.stage == "battleRunning" then
+5 -6
View File
@@ -207,7 +207,7 @@ function Net:send(msg)
end
if self.peerEnd then -- loopback: re-encode through json like the wire
local decoded = Json.decode(Json.encode(msg))
if decoded and not self.peerEnd.closed then
if decoded ~= nil and not self.peerEnd.closed then
table.insert(self.peerEnd.inbox, decoded)
end
return
@@ -256,13 +256,12 @@ end
function Net:handleTCPLine(line)
local msg = Json.decode(line)
if not msg then
if msg == nil then
Logger.warn("link: bad relay message %q", line:sub(1, 60))
return
end
if not handleGenericRelayControl(self, msg) then
table.insert(self.inbox, msg)
end
if type(msg) == "table" and handleGenericRelayControl(self, msg) then return end
table.insert(self.inbox, msg)
end
-- pulls every complete "\n"-terminated line out of rxBuf (leaving a
@@ -352,7 +351,7 @@ function Net:update()
end
elseif event.type == "receive" then
local msg = Json.decode(event.data)
if msg then
if msg ~= nil then
table.insert(self.inbox, msg)
else
Logger.warn("link: bad message %q", tostring(event.data):sub(1, 60))
+198
View File
@@ -0,0 +1,198 @@
local Session = {}
Session.__index = Session
local VALID_ROLES = { host = true, guest = true }
local REQUIRED_METHODS = { "update", "poll", "send", "close" }
function Session.new(transport, options)
assert(type(transport) == "table", "Session.new requires a transport")
assert(type(options) == "table", "Session.new requires options")
assert(VALID_ROLES[options.role], "Session role must be host or guest")
assert(type(options.kind) == "string" and options.kind ~= "",
"Session kind must be a non-empty string")
for _, method in ipairs(REQUIRED_METHODS) do
assert(type(transport[method]) == "function",
"Session transport requires " .. method)
end
local self = setmetatable({
_transport = transport,
_role = options.role,
_kind = options.kind,
_inbox = {},
_status = "connecting",
_terminal = nil,
_transportCloseCalled = false,
paired = false,
closed = false,
error = nil,
code = nil,
address = nil,
target = nil,
}, Session)
self:_syncMetadata()
self:_refreshStatus()
return self
end
function Session:_syncMetadata()
local transport = self._transport
self.paired = transport.paired == true
self.code = transport.code
self.address = transport.address
self.target = transport.target
end
function Session:_refreshStatus()
if not self._terminal then
self._status = self.paired and "paired" or "connecting"
self.closed = false
self.error = nil
return
end
if #self._inbox > 0 then
self._status = "draining"
self.closed = false
self.error = nil
return
end
self._status = self._terminal.status
self.closed = true
self.error = self._terminal.status == "failed"
and (self._terminal.detail or self._terminal.reason) or nil
end
function Session:_latchTerminal(status, reason, detail)
if self._terminal then return false end
self._terminal = { status = status, reason = reason, detail = detail }
self:_refreshStatus()
return true
end
function Session:_closeTransport()
if self._transportCloseCalled then return true end
self._transportCloseCalled = true
local ok, detail = pcall(self._transport.close, self._transport)
return ok, ok and nil or tostring(detail)
end
function Session:getRole() return self._role end
function Session:getKind() return self._kind end
function Session:getStatus() return self._status end
function Session:getFailure()
if not self._terminal or self._terminal.status ~= "failed" then
return nil, nil
end
return self._terminal.reason, self._terminal.detail
end
function Session:hasPending() return #self._inbox > 0 end
function Session:send(message)
if self._terminal then return nil end
return self._transport:send(message)
end
function Session:update()
if self._terminal then
self:_refreshStatus()
return
end
local failureReason, failureDetail
local updateOk, updateDetail = pcall(self._transport.update, self._transport)
self:_syncMetadata()
if not updateOk then
failureReason, failureDetail = "transport_error", tostring(updateDetail)
elseif self._transport.error then
failureReason = "transport_error"
failureDetail = tostring(self._transport.error)
end
local pollOk, messages = pcall(self._transport.poll, self._transport)
if not pollOk then
if not failureReason then
failureReason, failureDetail = "transport_error", tostring(messages)
end
elseif type(messages) ~= "table" then
if not failureReason then
failureReason, failureDetail = "transport_error",
"transport poll returned non-table"
end
else
for index = 1, #messages do
local message = messages[index]
if type(message) ~= "table" or type(message.type) ~= "string" then
if not failureReason then
failureReason = "protocol_error"
failureDetail = ("message %d must be a table with string type")
:format(index)
end
break
end
self._inbox[#self._inbox + 1] = message
end
end
self:_syncMetadata()
if failureReason then
self:_latchTerminal("failed", failureReason, failureDetail)
self:_closeTransport()
elseif self._transport.closed then
local closeOk, closeDetail = self:_closeTransport()
if closeOk then
self:_latchTerminal("closed")
else
self:_latchTerminal("failed", "transport_error", closeDetail)
end
end
self:_refreshStatus()
end
local function finishRead(self)
self:_refreshStatus()
end
function Session:take(messageType)
assert(type(messageType) == "string", "Session.take requires a message type")
for index, message in ipairs(self._inbox) do
if message.type == messageType then
local found = table.remove(self._inbox, index)
finishRead(self)
return found
end
end
return nil
end
function Session:pollOne()
if #self._inbox == 0 then return nil end
local message = table.remove(self._inbox, 1)
finishRead(self)
return message
end
function Session:poll()
local messages = self._inbox
self._inbox = {}
finishRead(self)
return messages
end
function Session:close()
if self._status == "closed" or self._status == "failed" then return end
if self._terminal then
self:_closeTransport()
self:_refreshStatus()
return
end
local ok, detail = self:_closeTransport()
if ok then
self:_latchTerminal("closed")
else
self:_latchTerminal("failed", "transport_error", detail)
end
self:_syncMetadata()
self:_refreshStatus()
end
return Session
+50 -51
View File
@@ -14,6 +14,7 @@ local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake")
local LinkBattle = require("src.link.LinkBattle")
local Net = require("src.link.Net")
local Session = require("src.link.Session")
local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local Sound = require("src.core.Sound")
@@ -132,12 +133,23 @@ end
-- host / join
-- -------------------------------------------------------------------
local function openSession(role)
local transport = Net.new()
if transport:connectTCP(Net.defaultRelayAddress()) then
return Session.new(transport, { role = role, kind = "tournament" })
end
local detail = transport.error or "?"
transport:close()
return nil, detail
end
function Tournament:startHosting()
self.net = Net.new()
if not self.net:connectTCP(Net.defaultRelayAddress()) then
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
local session, detail = openSession("host")
if not session then
self:exitWith(Strings("Link error:\n%s", detail))
return
end
self.net = session
local size, minL, maxL = partyStats(self.game.save.party)
self.isCreator = true
self.participating = self.settings.participating
@@ -156,11 +168,12 @@ function Tournament:startHosting()
end
function Tournament:startJoining(code)
self.net = Net.new()
if not self.net:connectTCP(Net.defaultRelayAddress()) then
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
local session, detail = openSession("guest")
if not session then
self:exitWith(Strings("Link error:\n%s", detail))
return
end
self.net = session
local size, minL, maxL = partyStats(self.game.save.party)
self.isCreator = false
self.participating = true -- joining is always to compete; only hosting can opt out
@@ -256,22 +269,14 @@ function Tournament:sendHello(mode)
end
function Tournament:pollHello()
local msgs = self.net:poll()
local keep, got = {}, false
for _, msg in ipairs(msgs) do
if msg.type == "hello" and not self.peerHello then
self.peerHello = msg
got = true
else
keep[#keep + 1] = msg
end
end
for i = #keep, 1, -1 do
table.insert(self.net.inbox, 1, keep[i])
end
return got
local message
if not self.peerHello then message = self.net:take("hello") end
if message then self.peerHello = message end
return message ~= nil
end
-- The relay assigns a side for each match; this can differ from the immutable
-- tournament creator/joiner role held by Session.
function Tournament:enterMatch(msg)
self.isHost = (msg.role == "host")
self.opponentName = msg.opponent
@@ -356,12 +361,13 @@ function Tournament:update(dt)
if self.net then
self.net:update()
if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings"
local status = self.net:getStatus()
if status == "failed" and self.stage ~= "menu" and self.stage ~= "hostSettings"
and self.stage ~= "codeEntry" then
self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
self:exitWith(Strings("Link error:\n%s", (self.net.error or "?"):sub(1, 60)))
return
end
if self.net.closed and self.stage ~= "done" then
if status == "closed" and self.stage ~= "done" then
self:exitWith(Strings("The tournament\nconnection was\nlost."))
return
end
@@ -370,24 +376,23 @@ function Tournament:update(dt)
if self.stage == "matchHello" then
if input:wasPressed("b") then self:exitWith(nil) return end
self:pollHello()
if self.peerHello then self:beginMatchBattle() end
for _, msg in ipairs(self.net:poll()) do self:handleMessage(msg) end
if self.peerHello then
self:beginMatchBattle()
return
end
for _, message in ipairs(self.net:poll()) do self:handleMessage(message) end
return
elseif self.stage == "matchWaitParty" then
if input:wasPressed("b") then self:exitWith(nil) return end
local msgs = self.net:poll()
for i, msg in ipairs(msgs) do
if msg.type == "party" then
self.pendingBattleOpts.theirParty = msg.mons
while self.net:hasPending() do
local message = self.net:pollOne()
if message.type == "party" then
self.pendingBattleOpts.theirParty = message.mons
if self.isHost then
self.pendingBattleOpts.seed = self.pendingBattleOpts.seed or self.linkSeed
else
self.pendingBattleOpts.seed = msg.seed
self.pendingBattleOpts.seed = message.seed
end
-- Split rather than `cond and newHost() or newGuest()`: the and/or
-- idiom truncates a call to its first result, so the second return
-- (the specific reason) was always dropped and every failure showed
-- the generic fallback instead of "same mods on both games" etc.
local battle, why
if self.isHost then
battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
@@ -398,27 +403,22 @@ function Tournament:update(dt)
self:exitWith(why or Strings("Link battle\ncan't start."))
return
end
-- anything after `party` in this same batch belongs to the
-- battle now, not to Tournament -- put it back for its own poll()
for j = #msgs, i + 1, -1 do
table.insert(self.net.inbox, 1, msgs[j])
end
self.activeBattle = battle
self.game.stack:push(battle)
self.stage = "matchRunning"
return
else
self:handleMessage(msg)
self:handleMessage(message)
end
end
return
elseif self.stage == "spectateWait" then
if input:wasPressed("b") then self:exitWith(nil) return end
local msgs = self.net:poll()
for i, msg in ipairs(msgs) do
if msg.type == "spectate" and msg.msg.type == "party" then
local inner = msg.msg
if msg.side == "host" then
while self.net:hasPending() do
local message = self.net:pollOne()
if message.type == "spectate" and message.msg.type == "party" then
local inner = message.msg
if message.side == "host" then
self.spectate.hostParty = inner.mons
self.spectate.seed = inner.seed
else
@@ -426,8 +426,10 @@ function Tournament:update(dt)
end
if self.spectate.hostParty and self.spectate.guestParty then
local battle, why = LinkBattle.newSpectator(self.game, self.net, {
hostParty = self.spectate.hostParty, guestParty = self.spectate.guestParty,
hostName = self.spectate.hostName, guestName = self.spectate.guestName,
hostParty = self.spectate.hostParty,
guestParty = self.spectate.guestParty,
hostName = self.spectate.hostName,
guestName = self.spectate.guestName,
seed = self.spectate.seed,
forceLevel = levelForWire(self.settings.forceLevel),
})
@@ -435,16 +437,13 @@ function Tournament:update(dt)
self:exitWith(why or Strings("Can't watch this\nmatch."))
return
end
for j = #msgs, i + 1, -1 do
table.insert(self.net.inbox, 1, msgs[j])
end
self.activeBattle = battle
self.game.stack:push(battle)
self.stage = "spectateRunning"
return
end
else
self:handleMessage(msg)
self:handleMessage(message)
end
end
return
+32
View File
@@ -569,6 +569,9 @@ end
function Loader:_api(mod)
local loader = self
local modId = mod.manifest.id
local Storage = engineRequire("src.mods.Storage")
local storage = Storage and Storage.new(modId, loader.fs)
local Checkpoint = engineRequire("src.core.Checkpoint")
local api = {
id = modId,
version = mod.manifest.version,
@@ -658,6 +661,25 @@ function Loader:_api(mod)
bucket[key] = value
end,
},
-- Data-only state independent of the vanilla progress checkpoint. The
-- engine binds version/playthrough/mod scope and portable persistence;
-- callers never receive paths or a raw filesystem handle.
storage = {
context = function(_, game) return storage:context(game) end,
write = function(_, game, key, value) return storage:write(game, key, value) end,
read = function(_, game, key) return storage:read(game, key) end,
list = function(_, game, prefix) return storage:list(game, prefix) end,
delete = function(_, game, key) return storage:delete(game, key) end,
},
-- Runtime safety and reconstruction stay engine-owned. Checkpoints contain
-- data only; no controller, stack, coroutine or renderer object crosses out.
checkpoints = {
inspect = function(_, game) return Checkpoint.inspect(game) end,
capture = function(_, game) return Checkpoint.capture(game) end,
restore = function(_, game, checkpoint)
return Checkpoint.restore(game, checkpoint)
end,
},
options = {
define = function(_, schema)
assert(type(schema) == "table", "options schema must be a table of rows")
@@ -929,6 +951,16 @@ function Loader:load(data)
end
end
end
-- A manifest may name an env var that force-enables it regardless of a
-- saved disable in options.mods -- generic, not tied to any mod id, for
-- a mod (e.g. a native-launcher bridge) that cannot function disabled on
-- the one build where its env var is set.
for id, mod in pairs(self.mods) do
local envName = mod.manifest.force_enable_env
if envName and os.getenv(envName) == "1" then
self.disabled[id] = nil
end
end
for id, mod in pairs(self.mods) do
mod.enabled = not self.disabled[id]
mod.state = mod.enabled and "pending" or "disabled"
+1
View File
@@ -229,6 +229,7 @@ function Manifest.validate(raw, path)
permissionSet = permissionSet,
options_schema = optionalFile(raw.options_schema, "options_schema"),
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
force_enable_env = optionalFile(raw.force_enable_env, "force_enable_env"),
path = path,
raw = raw,
}
+3 -1
View File
@@ -191,8 +191,10 @@ end
-- Never throws: a truncated download, an HTML error page, or a feed from a
-- future schema all come back as a message the panel can print.
function ModIndex.parse(jsonText, Json)
Json = Json or require("src.link.Json")
local notJson = Json.describeUnexpected(jsonText)
if notJson then return nil, notJson end
local ok, result, err = pcall(function()
Json = Json or require("src.link.Json")
local doc, decodeErr = Json.decode(jsonText)
if type(doc) ~= "table" then
return nil, decodeErr or "index.json is not an object"
+3 -1
View File
@@ -140,8 +140,10 @@ end
-- Decode a releases array (GET /repos/.../releases) into a sorted list
-- (newest first). Releases without a .zip asset are dropped. Never throws.
function ModUpdate.parseReleases(jsonText, modId, Json)
Json = Json or require("src.link.Json")
local notJson = Json.describeUnexpected(jsonText)
if notJson then return nil, notJson end
local ok, result, err = pcall(function()
Json = Json or require("src.link.Json")
local doc, decodeErr = Json.decode(jsonText)
if type(doc) ~= "table" then
return nil, decodeErr or "releases json is not an array"
+10
View File
@@ -577,6 +577,9 @@ R.trainers = {
aiMods = f.opt(f.any),
aiClass = f.opt(f.id("ai_classes")),
brain = f.opt(f.fn),
-- Per-trainer battle theme (an audio.songs id): overrides the
-- kind-based default (wild/trainer/gym/final) for this trainer's
-- battles. The victory jingle stays kind-based.
battleTheme = f.opt(f.id("music")),
},
example = 'mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })',
@@ -589,6 +592,13 @@ R.sprites = {
image = f.path,
frames = f.int(1),
walker = f.opt(f.bool),
-- Optional sheet geometry for mod actors. Defaults match the vanilla
-- 16x16 grounded walker; anchors are measured from each frame's
-- top-left in pixels (default: bottom-center).
frameWidth = f.opt(f.int(1)),
frameHeight = f.opt(f.int(1)),
anchorX = f.opt(f.num),
anchorY = f.opt(f.num),
trueColor = f.opt(f.bool),
-- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ
-- palette assignment without claiming that the image itself came from
+220
View File
@@ -0,0 +1,220 @@
-- Data-only per-mod persistence, scoped by game version and opaque playthrough.
-- This module is engine-private; Loader exposes only the bound facade methods.
local SaveData = require("src.core.SaveData")
local SaveSerializer = require("src.core.SaveSerializer")
local Version = require("src.core.Version")
local Storage = {}
Storage.__index = Storage
local ROOT = "mod_storage"
local function failure(code, message)
return nil, code, message
end
local function validSegment(value)
return type(value) == "string" and value ~= ""
and value:match("^[%w_-]+$") ~= nil
end
local function validKey(key, allowEmpty)
if type(key) ~= "string" or (key == "" and not allowEmpty) then return false end
if key == "" then return true end
if key:sub(1, 1) == "/" or key:sub(-1) == "/" or key:find("//", 1, true) then
return false
end
for segment in key:gmatch("[^/]+") do
if not validSegment(segment) then return false end
end
return true
end
local function ensureParent(fs, path)
local dir = path:match("^(.*)/[^/]+$")
if dir and fs.createDirectory then fs.createDirectory(dir) end
end
local function remove(fs, path)
if fs.remove then fs.remove(path) end
end
local function decodeAt(fs, path)
if not (fs.getInfo and fs.getInfo(path)) then return nil end
local body = fs.read and fs.read(path)
if type(body) ~= "string" then return nil end
local data = SaveSerializer.decode(body)
if not data then return nil end
return data, body
end
function Storage.new(modId, fs)
assert(validSegment(modId), "Storage.new needs a safe mod id")
return setmetatable({ modId = modId, injectedFs = fs }, Storage)
end
function Storage:_scope(game)
local save = game and game.save
local meta = save and save.meta
local version = save and save.version
if not (save and validSegment(version)) then
return failure("not_in_playthrough",
"Storage is available only inside an identified playthrough.")
end
local playthroughId = meta and meta.playthroughId
if not validSegment(playthroughId) then
playthroughId = SaveData.ensurePlaythroughId(save, self.injectedFs)
end
if not validSegment(playthroughId) then
return failure("not_in_playthrough",
"Storage could not identify the active playthrough.")
end
local fs = SaveData.persistenceFs(self.injectedFs)
if not (fs and fs.read and fs.write and fs.getInfo) then
return failure("storage_unavailable", "The persistence backend is unavailable.")
end
local base = table.concat({ ROOT, version, playthroughId, self.modId }, "/")
return { gameVersion = version, playthroughId = playthroughId,
base = base, fs = fs }
end
function Storage:context(game)
local scope, code, message = self:_scope(game)
if not scope then return nil, code, message end
return {
engineVersion = Version.engine,
gameVersion = scope.gameVersion,
playthroughId = scope.playthroughId,
}
end
function Storage:_names(game, key, allowEmpty)
if not validKey(key, allowEmpty) then
return failure("invalid_key",
"Storage keys use nonempty letters, numbers, underscore, dash and slash segments.")
end
local scope, code, message = self:_scope(game)
if not scope then return nil, code, message end
local path = scope.base .. (key ~= "" and ("/" .. key) or "")
return scope, path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp"
end
function Storage:write(game, key, value)
local scope, main, bak, tmp = self:_names(game, key, false)
if not scope then return false, main, bak end
if type(value) ~= "table" then
return false, "encode_failed", "Storage values must be data-only tables."
end
local encodedOk, encoded = pcall(SaveSerializer.encode, value)
if not encodedOk then
return false, "encode_failed", "Storage value is not serializable data: "
.. tostring(encoded)
end
local fs = scope.fs
ensureParent(fs, main)
local _, previous = decodeAt(fs, main)
if not previous then _, previous = decodeAt(fs, bak) end
local ok, err = fs.write(tmp, encoded)
if not ok then
return false, "write_failed", "Could not stage storage data: " .. tostring(err)
end
local staged = decodeAt(fs, tmp)
if not staged then
remove(fs, tmp)
return false, "verify_failed", "Staged storage data could not be verified."
end
if previous then fs.write(bak, previous) end
ok, err = fs.write(main, encoded)
if not ok then
remove(fs, tmp)
return false, "write_failed", "Could not replace storage data: " .. tostring(err)
end
local verified = decodeAt(fs, main)
if not verified then
remove(fs, main)
remove(fs, tmp)
return false, "verify_failed", "Replacement storage data could not be verified."
end
-- At rest both main and backup hold the newest verified record. If a later
-- write dies after rolling this copy aside, one verified generation remains.
fs.write(bak, encoded)
remove(fs, tmp)
return true
end
function Storage:read(game, key)
local scope, main, bak, tmp = self:_names(game, key, false)
if not scope then return nil, main, bak end
local fs = scope.fs
local data, body = decodeAt(fs, main)
if data then return data end
data, body = decodeAt(fs, tmp)
if not data then data, body = decodeAt(fs, bak) end
if not data then
return nil, "not_found", "No valid stored value exists for this key."
end
-- Best-effort healing. The recovered copy remains in tmp/bak if promotion
-- cannot land, so returning it is still safe and the next read can retry.
ensureParent(fs, main)
if fs.write(main, body) then fs.write(bak, body) end
remove(fs, tmp)
return data
end
function Storage:list(game, prefix)
prefix = prefix or ""
local scope, main, codeOrBak = self:_names(game, prefix, true)
if not scope then return nil, main, codeOrBak end
local fs = scope.fs
if not fs.getDirectoryItems then
return nil, "storage_unavailable", "The persistence backend cannot enumerate keys."
end
local base = scope.base
local start = prefix == "" and base or (base .. "/" .. prefix)
local out = {}
local function walk(path, logical)
local info = fs.getInfo(path)
if not info then return end
if info.type == "file" then
if path:sub(-4) == ".lua" then out[#out + 1] = logical:sub(1, -5) end
return
end
for _, child in ipairs(fs.getDirectoryItems(path) or {}) do
local childLogical = logical == "" and child or (logical .. "/" .. child)
walk(path .. "/" .. child, childLogical)
end
end
-- A prefix may identify one exact key or a directory of keys.
if fs.getInfo(start .. ".lua") then
out[#out + 1] = prefix
else
walk(start, prefix)
end
table.sort(out)
return out
end
function Storage:delete(game, key)
local scope, main, bak, tmp = self:_names(game, key, false)
if not scope then return false, main, bak end
local fs = scope.fs
if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)) then
return false, "not_found", "No stored value exists for this key."
end
remove(fs, main)
remove(fs, bak)
remove(fs, tmp)
return true
end
return Storage
+4 -2
View File
@@ -137,8 +137,10 @@ function Font.load(data)
-- typo'd path degrades exactly like a missing page image does above.
if type(def.ttf) == "table" then
local file = def.ttf.file or Font.PLAINPIXEL
local ok, obj = pcall(love.graphics.newFont, file,
def.ttf.size or Font.PLAINPIXEL_SIZE, "mono")
local size = def.ttf.size or Font.PLAINPIXEL_SIZE
-- The game renders into a pixel-exact canvas, so keep the TTF rasterizer
-- on that same 1x grid instead of inheriting the window DPI on mobile.
local ok, obj = pcall(love.graphics.newFont, file, size, "mono", 1)
if ok and obj then
-- nearest keeps the pixel font crisp under the integer UI scale
if obj.setFilter then pcall(obj.setFilter, obj, "nearest", "nearest") end
+15 -1
View File
@@ -5,13 +5,15 @@
local SecondScreen = {}
local C = nil
local ffi = nil
local function log(msg)
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
end
do
local ok, ffi = pcall(require, "ffi")
local ok
ok, ffi = pcall(require, "ffi")
if not (ok and ffi) then
log("ffi unavailable (not LuaJIT); second display disabled")
else
@@ -19,6 +21,7 @@ do
int love_android_secondary_ready();
void love_android_push_secondary(const void *rgba, int w, int h);
void love_android_secondary_enable(int on);
const char *love_android_poll_secondary_touch();
]])
local okLib, lib = pcall(ffi.load, "love")
if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then
@@ -51,6 +54,17 @@ function SecondScreen.push(imageData, w, h)
end)
end
-- Returns the oldest queued secondary-display event as "action,x,y", where
-- coordinates are in the submitted frame's pixel space.
function SecondScreen.pollTouch()
if not C then return nil end
local ok, event = pcall(function()
return C.love_android_poll_secondary_touch()
end)
if not ok or event == nil or event == ffi.NULL then return nil end
return ffi.string(event)
end
function SecondScreen.setEnabled(on)
if not C then return end
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
+137 -34
View File
@@ -1,7 +1,8 @@
-- Overworld character sprites. A 12-tile sheet (16x96 PNG) holds 6 16x16
-- frames: stand down/up/left, walk down/up/left (data/sprites/facings.asm).
-- Overworld character sprites. The vanilla 12-tile sheet (16x96 PNG) holds
-- 6 16x16 frames: stand down/up/left, walk down/up/left
-- (data/sprites/facings.asm). Mod records may opt into another frame size
-- and anchor; the defaults below preserve the original grounded placement.
-- Right-facing frames are horizontal flips of the left frames.
-- Sprites draw 4px above their cell, like the GB engine.
local Assets = require("src.render.Assets")
local PaletteFX = require("src.render.PaletteFX")
@@ -76,6 +77,58 @@ local WALK = { down = 3, up = 4, left = 5, right = 5 }
SpriteRenderer.STAND = STAND
SpriteRenderer.WALK = WALK
-- Sprite records are anchored at the point where the actor stands in the
-- world. In the vanilla renderer that point is the bottom-center of a
-- 16x16 frame: the frame starts at (px, py - 4), so the ground point is
-- (px + 8, py + 12). Custom anchors are measured from the frame's top-left
-- in sheet pixels and may be fractional for a sub-pixel art style.
local DEFAULT_FRAME_WIDTH = 16
local DEFAULT_FRAME_HEIGHT = 16
local DEFAULT_ANCHOR_X = 8
local DEFAULT_ANCHOR_Y = 16
local WORLD_ANCHOR_X = 8
local WORLD_ANCHOR_Y = 12
SpriteRenderer.DEFAULT_FRAME_WIDTH = DEFAULT_FRAME_WIDTH
SpriteRenderer.DEFAULT_FRAME_HEIGHT = DEFAULT_FRAME_HEIGHT
SpriteRenderer.DEFAULT_ANCHOR_X = DEFAULT_ANCHOR_X
SpriteRenderer.DEFAULT_ANCHOR_Y = DEFAULT_ANCHOR_Y
local function finiteNumber(value)
if type(value) ~= "number" or value ~= value
or value == math.huge or value == -math.huge then
return nil
end
return value
end
local function positiveInteger(value, fallback)
value = finiteNumber(value)
if value and value >= 1 then return math.floor(value) end
return fallback
end
local function numberOr(value, fallback)
return finiteNumber(value) or fallback
end
local function pose(self, facing, walkPhase, stepFlip)
if self.frameCount <= 1 then return 0, false end
local frame = (self.def.walker and walkPhase == 1)
and WALK[facing] or STAND[facing]
frame = frame or 0
-- Preserve the old fallback for a short custom sheet whose pose table
-- names a frame it does not provide.
if not self.frames[frame] then frame = 0 end
local flip = false
if facing == "right" then
flip = true
elseif (facing == "down" or facing == "up")
and walkPhase == 1 and stepFlip then
flip = true
end
return frame, flip
end
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
function SpriteRenderer.new(spriteDef, seed)
@@ -83,14 +136,62 @@ function SpriteRenderer.new(spriteDef, seed)
self.def = spriteDef
self.seed = seed
self.image = getImage(spriteDef.image)
self.frameCount = positiveInteger(spriteDef.frames, 1)
self.frameWidth = positiveInteger(spriteDef.frameWidth, DEFAULT_FRAME_WIDTH)
self.frameHeight = positiveInteger(spriteDef.frameHeight, DEFAULT_FRAME_HEIGHT)
self.anchorX = numberOr(spriteDef.anchorX, self.frameWidth / 2)
self.anchorY = numberOr(spriteDef.anchorY, self.frameHeight)
local iw, ih = self.image:getDimensions()
self.frames = {}
for f = 0, spriteDef.frames - 1 do
self.frames[f] = love.graphics.newQuad(0, f * 16, 16, 16, iw, ih)
for f = 0, self.frameCount - 1 do
self.frames[f] = love.graphics.newQuad(0, f * self.frameHeight,
self.frameWidth, self.frameHeight,
iw, ih)
end
return self
end
-- Return the sheet rectangle and top-left-relative anchor for a frame. The
-- result is a fresh table so a custom render pipeline may annotate it without
-- changing the renderer's shared definition.
function SpriteRenderer:getFrameGeometry(frame)
frame = math.floor(finiteNumber(frame) or 0)
if frame < 0 then frame = 0 end
if frame >= self.frameCount then frame = self.frameCount - 1 end
return {
frame = frame,
x = 0,
y = frame * self.frameHeight,
width = self.frameWidth,
height = self.frameHeight,
anchorX = self.anchorX,
anchorY = self.anchorY,
quad = self.frames[frame],
}
end
-- Return the frame geometry selected by the ordinary 2D pose rules, plus the
-- horizontal mirror state that :draw applies. This is the supported hook for
-- custom render pipelines that need to draw actors with the same pose/flip.
function SpriteRenderer:getPoseGeometry(facing, walkPhase, stepFlip)
local frame, flip = pose(self, facing, walkPhase, stepFlip)
local geometry = self:getFrameGeometry(frame)
geometry.facing = facing
geometry.walkPhase = walkPhase
geometry.stepFlip = stepFlip
geometry.mirror = flip
return geometry
end
-- Screen-space top-left for the actor's current world anchor. World-facing
-- effects such as fishing can use this instead of assuming a 16x16 frame.
function SpriteRenderer:getScreenOrigin(px, py, camX, camY)
local baseX = math.floor(px - camX) + WORLD_ANCHOR_X
local baseY = math.floor(py - camY) + WORLD_ANCHOR_Y
return math.floor(baseX - self.anchorX),
math.floor(baseY - self.anchorY)
end
-- The image this sprite would draw from right now: the plain sheet, or the
-- OBP-recolored bake of it. Exposed so a render pipeline can texture its
-- own geometry from the very same image -- the geometry carries sheet pixel
@@ -125,27 +226,32 @@ end
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
local function blitFrame(image, quad, x, y, flip, redraw)
local function blitFrame(image, quad, x, y, flip, redraw, frameWidth)
frameWidth = frameWidth or DEFAULT_FRAME_WIDTH
if flip then
love.graphics.draw(image, quad, x + 16, y, 0, -1, 1)
if redraw then PaletteFX.markSpriteRedraw(image, quad, x + 16, y, -1) end
love.graphics.draw(image, quad, x + frameWidth, y, 0, -1, 1)
if redraw then
PaletteFX.markSpriteRedraw(image, quad, x + frameWidth, y, -1)
end
else
love.graphics.draw(image, quad, x, y)
if redraw then PaletteFX.markSpriteRedraw(image, quad, x, y, 1) end
end
end
-- topHalf blits only the upper 8 rows of the frame: FishingAnim overwrites the
-- bottom tile row of the standing frames with the fishing pose art, which the
-- caller then draws itself through :drawTile (Player:draw, #384)
-- topHalf blits everything above the bottom 8-pixel tile row: FishingAnim
-- overwrites that row of the standing frames with fishing pose art, which the
-- caller then draws itself through :drawTile (Player:draw, #384). Vanilla
-- frames therefore still draw 8 rows, while taller frames keep their larger
-- body and reserve only the overlay row.
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf)
local x = math.floor(px - camX)
local y = math.floor(py - camY) - 4
local x, y = self:getScreenOrigin(px, py, camX, camY)
local image = self.image
local redraw = false
-- full-color art claims its 16x16 cell out of the shade-remap pass
-- True-color sheets bypass every palette bake; the screen-space exemption
-- is recorded below once the final frame/height is known.
if self.def.trueColor then
PaletteFX.markTrueColor(x, y, 16, 16)
image = self.image
elseif PaletteFX.usesGbcPack() then
-- RED++: the world canvas is already true-color (TileRenderer bakes
-- terrain, this bakes the sprite) and the world pass runs unshaded
@@ -177,31 +283,28 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, to
-- being colorized by the zone IS the point (#301).
image = getObpImage(self.def.image, PaletteFX.dmgObj())
end
-- single-frame sprites (item balls, fossils...) have one fixed pose;
-- Single-frame sprites (item balls, fossils...) have one fixed pose;
-- still 3-frame sprites turn to face (the nurse at her machine,
-- facePlayer on STAY NPCs) but never show walk frames
if self.def.frames <= 1 then
blitFrame(image, self.frames[0], x, y, false, redraw)
return
end
local frame = (self.def.walker and walkPhase == 1)
and WALK[facing] or STAND[facing]
local flip = false
if facing == "right" then
flip = true
elseif (facing == "down" or facing == "up") and walkPhase == 1 and stepFlip then
flip = true
end
local quad = self.frames[frame] or self.frames[0]
if topHalf then
-- facePlayer on STAY NPCs) but never show walk frames.
local frame, flip = pose(self, facing, walkPhase, stepFlip)
local quad = self.frames[frame]
local drawHeight = self.frameHeight
if topHalf and self.frameCount > 1 then
self.halfFrames = self.halfFrames or {}
if not self.halfFrames[frame] then
local iw, ih = self.image:getDimensions()
self.halfFrames[frame] = love.graphics.newQuad(0, frame * 16, 16, 8, iw, ih)
local topHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight))
self.halfFrames[frame] = love.graphics.newQuad(
0, frame * self.frameHeight, self.frameWidth, topHeight, iw, ih)
end
quad = self.halfFrames[frame]
drawHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight))
end
blitFrame(image, quad, x, y, flip, redraw)
-- Full-color art claims exactly the portion of the frame that was drawn.
if self.def.trueColor then
PaletteFX.markTrueColor(x, y, self.frameWidth, drawHeight)
end
blitFrame(image, quad, x, y, flip, redraw, self.frameWidth)
end
-- Blit a loose 16-wide fx tile at screen (x, y) wearing THIS sprite's OBJ
@@ -225,7 +328,7 @@ function SpriteRenderer:drawTile(path, x, y, flip)
self.tileQuads = self.tileQuads or {}
self.tileQuads[path] = self.tileQuads[path]
or love.graphics.newQuad(0, 0, iw, ih, iw, ih)
blitFrame(image, self.tileQuads[path], x, y, flip, redraw)
blitFrame(image, self.tileQuads[path], x, y, flip, redraw, iw)
end
return SpriteRenderer
+7
View File
@@ -7,11 +7,13 @@
-- the text is exhausted and A is pressed, then calls onDone.
local Font = require("src.render.Font")
local Runtime = require("src.mods.Runtime")
local Theme = require("src.ui.Theme")
local Timing = require("src.core.Timing")
local TextBox = {}
TextBox.__index = TextBox
TextBox.isTextBox = true
-- theme-free fallbacks; geometry resolves against Theme.textBox at
-- construction time, so an unthemed boot stays byte-identical
@@ -344,6 +346,11 @@ function TextBox:update(dt)
end
function TextBox:draw()
if Runtime.wantsHook("battle.bottom_ui_visible")
and Runtime.call("battle.bottom_ui_visible", function() return true end,
self) == false then
return
end
-- The dialogue box belongs against the bottom of the screen, not floating
-- in the middle of a zoomed-out letterbox. Declared per frame; the
-- renderer blits this region to the screen edge and the rest of the UI
+39
View File
@@ -27,6 +27,7 @@
-- game regenerates all of it from wCurMap on the next map load anyway.
local bit = require("bit")
local MapContext = require("src.save_convert.MapContext")
local GenSave = {}
@@ -1023,6 +1024,44 @@ function GenSave.encode(save, data, template)
setByte(buf, O.lastMap, cw.mapsIndex[save.lastOutdoor.id] or 0)
end
-- Current-map engine state (see src/save_convert/MapContext.lua). A
-- Continue restores this window from the save and never rebuilds it, so a
-- zero-filled one boots into a garbled map on a silent hang (#889).
--
-- Rebuilt when there is no template at all (a save that began as a New Game
-- in this port), and when the template was saved on a DIFFERENT map than the
-- one the player is standing on now -- an imported save that has since been
-- played carries the old map's header, which is just as unbootable. A
-- template still on its own map keeps its bytes untouched: they are the
-- game's own, including live NPC positions, and preserving them is what
-- makes import -> export byte-identical.
local mapId = save.player and save.player.map
if mapId then
local rebuild = true
if src then
-- compare the way the byte was written (masked), and rebuild when the
-- map has no index at all rather than trusting a stale template
local index = cw.mapsIndex[mapId]
rebuild = index == nil or u8(src, O.curMap) ~= bit.band(index, 0xFF)
end
if rebuild then
local ctx = MapContext.build(data, mapId,
(save.player and save.player.x) or 0, (save.player and save.player.y) or 0)
if ctx then
for offset, values in pairs(ctx.writes) do
for i, value in ipairs(values) do
setByte(buf, O.mainData + offset + i - 1, value)
end
end
for i, value in ipairs(ctx.spriteData) do
setByte(buf, O.spriteData + i - 1, value)
end
-- sTileAnimations, the byte between sCurBoxData and the checksum
setByte(buf, O.checksumEnd - 1, ctx.tileAnimations)
end
end
end
-- play time: split save.playTime (seconds) back into H/M/S/F. The real
-- game freezes the clock at 255h and sets wPlayTimeMaxed once past it, so
-- mirror that cap rather than letting hours overflow a single byte.
+263
View File
@@ -0,0 +1,263 @@
-- MapContext -- the current map's engine state, as a vanilla Gen1 save has to
-- carry it (#889).
--
-- Continuing a real save never re-loads the map header. LoadMainData
-- (engine/menus/save.asm) copies sMainData back into WRAM and then sets
-- BIT_NO_PREVIOUS_MAP in wCurMapTileset; LoadMapHeader (home/overworld.asm)
-- clears that bit and RETURNS IMMEDIATELY when it was set, so every byte it
-- would otherwise have written -- the map header, connections, warps, signs,
-- sprites, the tileset header, the map's music -- comes straight out of the
-- save file. A cartridge save always has them because the game saved them.
--
-- An export from this port did not: GenSave models the player's progress, not
-- the engine scratch around it, and left that whole window zero-filled unless
-- it had an imported SRAM image to copy from. The game then continued into
-- tileset 0 with a $0000 map-data pointer and sound id 0, which is the
-- garbled overworld / silent hang users reported after exporting a save that
-- began as a New Game in the port.
--
-- This module rebuilds the window from the extracted ROM data, replaying the
-- same writes LoadMapHeader makes, so a port-origin save boots on hardware.
-- The offsets are all relative to wMainDataStart (= sMainData in a .sav) and
-- were summed from ram/wram.asm, then checked byte-for-byte against a real
-- cartridge save: rebuilding its current map reproduces the bytes it already
-- held, everywhere except the stale tails of disabled connection slots (which
-- the engine never reads once the connected-map byte is $FF).
--
-- Pure Lua, no love.*: shared by the runtime exporter, the CLI and the tests.
local MapContext = {}
-- wMainDataStart-relative offsets. Anchors: wCurMap is +103 (the offset
-- GenSave already decodes the player's map from), and the run from there is
-- wCurMap, wCurrentTileBlockMapViewPointer(2), wYCoord, wXCoord, wYBlockCoord,
-- wXBlockCoord, wLastMap, wUnusedLastMapWidth, wCurMapHeader -- so the header
-- lands at +112, and every later label follows from the declaration sizes in
-- ram/wram.asm (MAX_WARP_EVENTS 32, MAX_BG_EVENTS 16, MAX_OBJECT_EVENTS 16,
-- SPRITE_SET_LENGTH 11).
local O = {
mapMusicSoundID = 100, -- wMapMusicSoundID
mapMusicROMBank = 101, -- wMapMusicROMBank
viewPointer = 104, -- wCurrentTileBlockMapViewPointer (2)
yCoord = 106,
xCoord = 107,
yBlockCoord = 108,
xBlockCoord = 109,
curMapHeader = 112, -- wCurMapHeader (10)
connectionHeaders = 122, -- wNorth/South/West/EastConnectionHeader (4 x 11)
mapBackgroundTile = 182,
numberOfWarps = 183,
warpEntries = 184, -- MAX_WARP_EVENTS x 4
numSigns = 441,
signCoords = 442, -- MAX_BG_EVENTS x 2
signTextIDs = 474, -- MAX_BG_EVENTS
numSprites = 490,
mapSpriteData = 493, -- MAX_OBJECT_EVENTS x 2
mapSpriteExtra = 525, -- MAX_OBJECT_EVENTS x 2
currentMapHeight2 = 557,
currentMapWidth2 = 558,
tilesetHeader = 564, -- wTilesetBank .. wGrassTile (11)
}
MapContext.OFFSETS = O
local MAX_WARP_EVENTS = 32
local MAX_BG_EVENTS = 16
local MAX_OBJECT_EVENTS = 16
local CONNECTION_STRUCT = 11 -- map_connection_struct (macros/ram.asm)
local SPRITE_STRUCT = 16 -- one wSpriteStateData1/2 entry
local NUM_SPRITE_STRUCTS = 16
-- wOverworldMap, the tile-block map the view pointer indexes. Recovered from
-- the event_displacement formula below applied to a real save's coordinates,
-- and it is the same $C6E8 every Gen1 reference quotes.
local OVERWORLD_MAP = 0xC6E8
-- Audio header tables start at $4000 in each of the three audio banks, and
-- constants/music_constants.asm derives every song id arithmetically from
-- that base ("Song ids are calculated by address", music_const:
-- (Music_X - SFX_Headers_1) / 3). So the extracted header address IS the id,
-- and no separate MapSongBanks extraction is needed.
local SFX_HEADERS_BASE = 0x4000
local function soundId(address)
if type(address) ~= "number" then return nil end
local delta = address - SFX_HEADERS_BASE
if delta < 0 or delta % 3 ~= 0 then return nil end
local id = delta / 3
if id > 0xFF then return nil end
return id
end
-- LoadMapHeader parses the map's object data (data/maps/objects/*.asm) into
-- five separate WRAM arrays. Walk it exactly as the engine does; `bytes` is
-- the 1-based array of raw object-data bytes the extractor captured.
local function parseObjects(bytes)
local pos = 1
local function take()
local b = bytes[pos]
pos = pos + 1
return b
end
local out = { warps = {}, signCoords = {}, signTexts = {},
spriteData = {}, spriteExtra = {}, sprites = {} }
out.backgroundTile = take()
local warpCount = take()
if warpCount == nil then return nil end
out.warpCount = warpCount
for _ = 1, math.min(warpCount, MAX_WARP_EVENTS) * 4 do
out.warps[#out.warps + 1] = take()
end
local signCount = take()
if signCount == nil then return nil end
out.signCount = signCount
for _ = 1, math.min(signCount, MAX_BG_EVENTS) do
out.signCoords[#out.signCoords + 1] = take() -- Y
out.signCoords[#out.signCoords + 1] = take() -- X
out.signTexts[#out.signTexts + 1] = take()
end
local spriteCount = take()
if spriteCount == nil then return nil end
out.spriteCount = spriteCount
for _ = 1, math.min(spriteCount, MAX_OBJECT_EVENTS) do
local picture, mapY, mapX = take(), take(), take()
local movement1, movement2, textId = take(), take(), take()
if textId == nil then return nil end
out.sprites[#out.sprites + 1] = {
picture = picture, mapY = mapY, mapX = mapX, movement1 = movement1,
}
-- wMapSpriteData: movement byte 2, then the text id with its trainer/item
-- flag bits masked off (LoadMapHeader's `and $3f`).
out.spriteData[#out.spriteData + 1] = movement2
out.spriteData[#out.spriteData + 1] = textId % 0x40
-- BIT_TRAINER ($40) is tested before BIT_ITEM ($80), as LoadMapHeader does
if textId % 0x80 >= 0x40 then -- trainer: class, then party id
out.spriteExtra[#out.spriteExtra + 1] = take()
out.spriteExtra[#out.spriteExtra + 1] = take()
elseif textId >= 0x80 then -- item ball: item id, second byte unused
out.spriteExtra[#out.spriteExtra + 1] = take()
out.spriteExtra[#out.spriteExtra + 1] = 0
else
out.spriteExtra[#out.spriteExtra + 1] = 0
out.spriteExtra[#out.spriteExtra + 1] = 0
end
end
return out
end
-- build(data, mapId, x, y) -> ctx, err
--
-- ctx.writes [wMainDataStart-relative offset] = array of bytes
-- ctx.spriteData 512 bytes for sSpriteData (wSpriteStateData1 then 2)
-- ctx.tileAnimations the byte sTileAnimations holds
--
-- Returns nil plus a reason when the data set predates the extractor fields
-- this needs (an older ROM cache), so callers can fall back rather than fail.
function MapContext.build(data, mapId, x, y)
local map = data and data.maps and data.maps[mapId]
if not map then return nil, "unknown map " .. tostring(mapId) end
local sram = map.sram
if not (sram and sram.header and sram.objects) then
return nil, "map cache has no saved-map bytes (re-import the ROM)"
end
x, y = math.floor(tonumber(x) or 0), math.floor(tonumber(y) or 0)
local writes = {}
local header = sram.header
writes[O.curMapHeader] = header
local height, width = header[2], header[3]
local connectionFlags = header[10]
-- Connections: LoadMapHeader writes $FF over all four connected-map bytes
-- and then copies 11 bytes per present direction, north/south/west/east.
-- The disabled slots' remaining bytes are never read.
local conn = {}
for i = 1, 4 * CONNECTION_STRUCT do conn[i] = 0 end
for slot = 0, 3 do conn[slot * CONNECTION_STRUCT + 1] = 0xFF end
local pos = 1
for slot, flag in ipairs({ 0x08, 0x04, 0x02, 0x01 }) do
if math.floor(connectionFlags / flag) % 2 == 1 then
for i = 0, CONNECTION_STRUCT - 1 do
conn[(slot - 1) * CONNECTION_STRUCT + 1 + i] = (sram.connections or {})[pos + i] or 0
end
pos = pos + CONNECTION_STRUCT
end
end
writes[O.connectionHeaders] = conn
local parsed = parseObjects(sram.objects)
if not parsed then return nil, "malformed object data for " .. tostring(mapId) end
writes[O.mapBackgroundTile] = { parsed.backgroundTile }
writes[O.numberOfWarps] = { parsed.warpCount }
writes[O.warpEntries] = parsed.warps
writes[O.numSigns] = { parsed.signCount }
writes[O.signCoords] = parsed.signCoords
writes[O.signTextIDs] = parsed.signTexts
writes[O.numSprites] = { parsed.spriteCount }
writes[O.mapSpriteData] = parsed.spriteData
writes[O.mapSpriteExtra] = parsed.spriteExtra
-- "map height/width in 2x2 tile blocks", doubled at the end of LoadMapHeader
writes[O.currentMapHeight2] = { (height * 2) % 256 }
writes[O.currentMapWidth2] = { (width * 2) % 256 }
-- The tileset header, as predef LoadTilesetHeader would have copied it.
local tilesets = data.tilesets or {}
local tilesetDef = tilesets[map.tileset]
local tileAnimations = 0
if tilesetDef and tilesetDef.header then
local row = {}
for i = 1, 11 do row[i] = tilesetDef.header[i] end
writes[O.tilesetHeader] = row
tileAnimations = tilesetDef.header[12] or 0
end
-- MapSongBanks: without it the game continues with sound id 0 and audio
-- bank 0, which is what actually hangs a Continue on a white screen.
local audio = data.audio
local songLabel = audio and audio.mapSongs and audio.mapSongs[mapId]
local song = songLabel and audio.songs and audio.songs[songLabel]
local id = song and soundId(song.address)
if id and song.bank then
writes[O.mapMusicSoundID] = { id }
writes[O.mapMusicROMBank] = { song.bank % 256 }
end
-- Player position within its block, and the upper-left corner of the view.
-- The pointer is the same expression the warp_to tables are assembled with
-- (macros/coords.asm event_displacement).
writes[O.yBlockCoord] = { y % 2 }
writes[O.xBlockCoord] = { x % 2 }
local view = OVERWORLD_MAP + 7 + width
+ (width + 6) * math.floor(y / 2) + math.floor(x / 2)
writes[O.viewPointer] = { view % 256, math.floor(view / 256) % 256 }
-- sSpriteData. LoadMapHeader zeroes structs 1-15, sets their image index to
-- $ff (off screen until the engine places them), then fills in each map
-- object's picture id and its map Y/X plus movement byte 1. Struct 0 is the
-- player, which SpecialEnterMap rebuilds through ResetPlayerSpriteData.
local spriteData = {}
for i = 1, NUM_SPRITE_STRUCTS * SPRITE_STRUCT * 2 do spriteData[i] = 0 end
local data2 = NUM_SPRITE_STRUCTS * SPRITE_STRUCT
for slot = 1, NUM_SPRITE_STRUCTS - 1 do
spriteData[slot * SPRITE_STRUCT + 2 + 1] = 0xFF
end
for index, sprite in ipairs(parsed.sprites) do
local base = index * SPRITE_STRUCT
spriteData[base + 1] = sprite.picture
spriteData[data2 + base + 4 + 1] = sprite.mapY
spriteData[data2 + base + 5 + 1] = sprite.mapX
spriteData[data2 + base + 6 + 1] = sprite.movement1
end
return {
writes = writes,
spriteData = spriteData,
tileAnimations = tileAnimations,
}
end
return MapContext
+10 -1
View File
@@ -42,11 +42,17 @@ local DATA_MODULES = {
moves = { "data.generated.moves", "data/generated/moves.lua" },
items = { "data.generated.items", "data/generated/items.lua" },
maps = { "data.generated.maps", "data/generated/maps.lua" },
-- tilesets/audio are only read by src/save_convert/MapContext.lua, to
-- rebuild the current map's engine state on export (#889)
tilesets = { "data.generated.tilesets", "data/generated/tilesets.lua" },
audio = { "data.generated.audio", "data/generated/audio.lua" },
charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" },
eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" },
toggleObjects = { "src.save_convert.data.toggle_objects", "src/save_convert/data/toggle_objects.lua" },
}
local OPTIONAL_MODULES = { tilesets = true, audio = true }
-- Yellow renumbers wEventFlags bits: pokeyellow's constants/event_constants.asm
-- inserts events pokered does not have (the Jessie & James fights, catch
-- training, the Officer Jenny Squirtle) and shifts the Mt Moon 3 / Silph Co
@@ -132,7 +138,10 @@ local function ensureData(gameVersion)
if not mod then
local e
mod, e = loadTable(spec[1], spec[2])
if not mod then return nil, e end
-- tilesets/audio only sharpen the export (MapContext); a cache
-- without them still imports and exports, just without the
-- rebuilt map window, so they must not fail the whole load
if not mod and not OPTIONAL_MODULES[name] then return nil, e end
end
data[name] = mod
end
+13 -1
View File
@@ -253,6 +253,18 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
return
end
if result == "kept" then
if battle then
list:close()
showMessages(game, payload, function()
battle:itemUsed({})
end)
else
showMessages(game, payload, closePicker)
end
return
end
if result == "consumed" then
consume(game, id)
-- refresh counts in the list
@@ -404,7 +416,7 @@ local function useItem(game, battle, id, list)
showMessages(game, payload)
return
end
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
if ItemEffects.needsTarget(id, def, game.data) and not ItemEffects.isBall(id) then
-- TMs/HMs boot up and announce their move before the target picker
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
if def and def.machine then
+4
View File
@@ -67,6 +67,7 @@ local function withdraw(game)
game.stack:push(ListMenu.new(game,
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_withdraw",
onChoose = function(item, list)
local mon = box[item.value]
if not mon then return end
@@ -114,6 +115,7 @@ local function deposit(game)
end
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_deposit",
onChoose = function(item, list)
local mon = game.save.party[item.value]
if not mon then return end
@@ -160,6 +162,7 @@ local function release(game)
game.stack:push(ListMenu.new(game,
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_release",
onChoose = function(_, list)
local mon = box[list.index]
if not mon then return end
@@ -193,6 +196,7 @@ local function changeBox(game)
end
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_change",
onChoose = function(item, list)
-- the original asks BEFORE switching ("When you change a #MON
-- BOX, data will be saved. OK?"); declining aborts the change
+27 -8
View File
@@ -31,6 +31,7 @@
-- to just THE END.
local Font = require("src.render.Font")
local GameVersion = require("src.core.GameVersion")
local Music = require("src.core.Music")
local Strings = require("src.core.Strings")
@@ -64,11 +65,12 @@ local MON_PREP_FRAMES = 9
-- LoadCopyrightTiles (engine/movie/title.asm CopyrightTextString): tile
-- sequences into the extracted title/copyright.png strip (tiles $60-$72:
-- (c)'95.'96.'98 + Nintendo + Creatures inc.); the GAME FREAK inc. row is
-- the title/gamefreak_inc.png strip (GameFreakLogoGraphics, tiles
-- $73-$7B), with the intro's composed gamefreak_text.png as a fallback
-- for pre-regeneration data.
local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 } -- (c)'95.'96.'98
-- Red/Blue (c)'95.'96.'98, Yellow (c)1995-1999 + NineTile) + Nintendo +
-- Creatures inc.; the GAME FREAK inc. row is title/gamefreak_inc.png
-- (GameFreakLogoGraphics, tiles $73-$7B), with the intro's composed
-- gamefreak_text.png as a fallback for pre-regeneration data.
local COPY_PREFIX_RB = { 0, 1, 2, 1, 3, 1, 4 } -- (c)'95.'96.'98
local COPY_PREFIX_YELLOW = { 0, 1, 2, 3, 1, 2 } -- (c)1995-199
local COPY_NINTENDO = { 5, 6, 7, 8, 9, 10 } -- Nintendo
local COPY_CREATURES = { 11, 12, 13, 14, 15, 16, 17, 18 } -- Creatures inc.
@@ -140,6 +142,12 @@ function Credits.new(game, onDone, onTheEnd)
and title.gamefreakInc.path)
or tryImage(intro and intro.gamefreakText
and intro.gamefreakText.path)
self.yellowCopy = GameVersion.isYellow()
or (title and title.layout == "yellow_pikachu")
self.copyPrefix = self.yellowCopy and COPY_PREFIX_YELLOW or COPY_PREFIX_RB
self.nineImg = self.yellowCopy and tryImage(
title and title.nine and title.nine.path
or "assets/generated/title/nine.png") or nil
return self
end
@@ -255,6 +263,17 @@ function Credits:drawPage(screen, xoff, shade)
if screen.copyright then self:drawCopyright(xoff) end
end
function Credits:drawCopyPrefix(x, y)
local img = self.copyImg
for _, t in ipairs(self.copyPrefix) do
love.graphics.draw(img, self.copyQuads[t], x, y)
x = x + 8
end
if self.nineImg then
love.graphics.draw(self.nineImg, x, y)
end
end
function Credits:drawCopyright(xoff)
local img = self.copyImg
if img then
@@ -267,11 +286,11 @@ function Credits:drawCopyright(xoff)
end
return x
end
row(COPY_PREFIX, xoff + 16, 56)
self:drawCopyPrefix(xoff + 16, 56)
row(COPY_NINTENDO, xoff + 80, 56)
row(COPY_PREFIX, xoff + 16, 72)
self:drawCopyPrefix(xoff + 16, 72)
row(COPY_CREATURES, xoff + 80, 72)
row(COPY_PREFIX, xoff + 16, 88)
self:drawCopyPrefix(xoff + 16, 88)
if self.gfImg then
love.graphics.draw(self.gfImg, xoff + 80, 88)
else
+3 -3
View File
@@ -94,7 +94,7 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
-- same number width as the list (constants.dexDigits), so a dex past 999
-- prints the extra digit everywhere at once
local digits = (game.data.constants or {}).dexDigits or 3
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
local owned = forceOwned
or (game.save.pokedex and game.save.pokedex.owned[def.id])
-- height/weight print only once owned, like the description
@@ -105,8 +105,8 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
-- engine/gfx/load_pokedex_tiles.asm)
if e.heightM then
Font.draw((("GR. %.1fm"):format(e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 64, 44)
Font.draw((("GEW. %.1fkg"):format(e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 64, 54)
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 44)
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 54)
else
Font.draw(Strings("HT %d%02d″", e.heightFt, e.heightIn or 0), 72, 44)
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
+87 -59
View File
@@ -1,31 +1,8 @@
-- Boot splash + attract movie, a faithful port of PlayIntro
-- (engine/movie/intro.asm) and AnimateShootingStar (engine/movie/splash.asm)
-- using the real extracted art (data/generated/field.lua `intro` manifest).
--
-- Three frame-counted phases:
-- 1. copyright card, 180 frames (intro.asm:311-312).
-- 2. shooting star: 64 frames of empty letterbox (intro.asm:323-324), then
-- the big star streaks down-left for 40 frames while the studio logo
-- sits centered in the letterbox band (the GAME FREAK logo + letter
-- row it replaces sat at (72,56)/(40,80); splash.asm:27-60, 211-228),
-- the logo flashes 3x10 frames (splash.asm:72-82), 4 waves of small
-- stars rain from the logo -- 6x24 frames, +1px every 3 frames, lower
-- star blinking (splash.asm:97-146, 163-209) -- and a 40 frame hold
-- (intro.asm:329-331).
-- 3. the Gengar/Nidorino fight (PlayIntroScene, intro.asm:23-141), played
-- from FIGHT_SCRIPT below: Music_IntroBattle starts, Gengar (56x56 BG
-- pose from a gengar_N.tilemap, at tile 13,7 = x104,y56) scrolls left
-- while Nidorino (48x48 OAM at x-8,y72) walks right, then the scripted
-- hip/hop hops, Gengar's raise + slash lunge, Nidorino's dodge leap,
-- retreat, crouch and final lunge, ending in a 24-frame fade to white
-- (GBFadeOutToWhite, home/fade.asm:26-40).
--
-- Any of A/B/START skips the whole movie (CheckForUserInterruption).
-- Pops itself and calls onDone() when finished or skipped. All art loads
-- through pcall and every missing graphic degrades to a text/rect
-- fallback, so the movie stays headless-safe.
-- ..(engine/movie/intro.asm ln 8)
-- ..(engine/movie/splash.asm ln 27)
local Font = require("src.render.Font")
local GameVersion = require("src.core.GameVersion")
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
@@ -76,15 +53,20 @@ local WAVE_FRAMES = 24 -- 8 substeps x 3 frames (splash.asm:186-209)
local WAVES_END = WAVES_START + 6 * WAVE_FRAMES -- 4 waves + 2 empty
local SPLASH_FRAMES = WAVES_END + 40 -- ld c, 40 (intro.asm:329-331)
-- logo 16x24 at grid (10,9), letters row at grid y=12 cols 6..15
-- (GameFreakLogoOAMData, splash.asm:211-228; screen = grid*8, OAM offsets
-- cancel)
-- ..(engine/movie/splash.asm ln 211)
local LOGO_X, LOGO_Y = 72, 56
local TEXT_X, TEXT_Y = 40, 80
-- CopyrightTextString (engine/movie/title.asm). Red/Blue years are
-- (c)'95.'96.'98; Yellow's sheet spells (c)1995-1999 and finishes the
-- last digit with NineTile (title screen). Intro originally overflows
-- into the font "A" tile for that digit; we draw NineTile so both
-- screens read 1999.
local COPY_PREFIX_RB = { 0, 1, 2, 1, 3, 1, 4 }
local COPY_PREFIX_YELLOW = { 0, 1, 2, 3, 1, 2 }
local COPY_NINTENDO = { 5, 6, 7, 8, 9, 10 }
local COPY_CREATURES = { 11, 12, 13, 14, 15, 16, 17, 18 }
-- The studio logo (assets/logo/minilogo.png) stands in for both the
-- GAME FREAK logo and its letter row, so it gets the whole band between
-- the letterbox bars (y 32..112) down to where the star waves spawn
-- (y=88): fit it inside this box, centered, aspect preserved.
local STUDIO_BOX = { w = 128, h = 52, cx = 80, cy = 60 }
-- the 4 waves of small stars: screen X positions, all spawning at y=88
@@ -155,10 +137,25 @@ function IntroMovie.new(game, onDone)
self.studio = intro.studio or {}
self.skipAll = intro.skip and true or false
local function img(e) return tryImage(e and e.path) end
self.copyright = tryImage("assets/generated/title/copyright.png")
-- studio mark replaces the GAME FREAK logo + splash text entirely; the
-- extracted logo stays as the fallback if the asset is missing
self.studioLogo = tryImage(self.studio.logo or "assets/logo/minilogo.png")
local titleCfg = game.data.field and game.data.field.title or {}
self.copyright = img(titleCfg.copyright)
or tryImage("assets/generated/title/copyright.png")
self.copyQuads = {}
if self.copyright then
local iw, ih = self.copyright:getDimensions()
for t = 0, 18 do
self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih)
end
end
self.gfInc = img(titleCfg.gamefreakInc)
or tryImage("assets/generated/title/gamefreak_inc.png")
-- ..(pokeyellow engine/movie/title.asm CopyrightTextString / NineTile)
self.yellowCopy = GameVersion.isYellow()
or titleCfg.layout == "yellow_pikachu"
self.copyPrefix = self.yellowCopy and COPY_PREFIX_YELLOW or COPY_PREFIX_RB
self.nineImg = self.yellowCopy and (
img(titleCfg.nine) or tryImage("assets/generated/title/nine.png")) or nil
self.studioLogo = tryImage(self.studio.logo)
if self.studioLogo then
self.studioLogo:setFilter("nearest", "nearest")
local iw, ih = self.studioLogo:getDimensions()
@@ -306,24 +303,12 @@ function IntroMovie:drawSplash()
if self.studioLogo then
love.graphics.draw(self.studioLogo, self.studioX, self.studioY,
0, self.studioScale, self.studioScale)
elseif self.logo then
love.graphics.draw(self.logo, LOGO_X, LOGO_Y)
else
if self.logo then love.graphics.draw(self.logo, LOGO_X, LOGO_Y) end
if self.gfText then love.graphics.draw(self.gfText, TEXT_X, TEXT_Y) end
end
love.graphics.setColor(1, 1, 1, 1)
end
if t >= STAR_START and t < FLASH_START then
-- big star: from OAM (160,0) moving +4Y/-4X per frame
-- (GameFreakShootingStarOAMData + .bigStarLoop, splash.asm:32-60)
local n = t - STAR_START + 1
local sx, sy = 152 - 4 * n, -16 + 4 * n
if self.bigStar then
love.graphics.draw(self.bigStar, sx, sy)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
end
end
if t >= WAVES_START then
-- small stars: wave w spawns at y=88 every 24 frames, everything falls
-- +1px per 3-frame substep until the wave loop ends; the lower star in
@@ -351,6 +336,18 @@ function IntroMovie:drawSplash()
end
end
drawBars()
if t >= STAR_START and t < FLASH_START then
-- ..(engine/movie/splash.asm ln 32)
local n = t - STAR_START + 1
local sx, sy = 152 - 4 * n, -16 + 4 * n
if self.bigStar then
love.graphics.draw(self.bigStar, sx, sy)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
end
end
end
function IntroMovie:drawFight()
@@ -379,17 +376,48 @@ function IntroMovie:drawFight()
end
end
function IntroMovie:drawCopyPrefix(x, y)
for _, t in ipairs(self.copyPrefix) do
love.graphics.draw(self.copyright, self.copyQuads[t], x, y)
x = x + 8
end
if self.nineImg then
love.graphics.draw(self.nineImg, x, y)
end
end
function IntroMovie:drawCopyright()
if self.studio.card or self.studio.credit then
local card = self.studio.card or ""
local credit = self.studio.credit or ""
love.graphics.setColor(0, 0, 0, 1)
Font.draw(card, (160 - #card * 8) / 2, 64)
Font.draw(credit, (160 - #credit * 8) / 2, 80)
elseif self.copyright and self.gfInc then
local function row(seq, x, y)
for _, t in ipairs(seq) do
love.graphics.draw(self.copyright, self.copyQuads[t], x, y)
x = x + 8
end
end
for _, y in ipairs({ 56, 72, 88 }) do self:drawCopyPrefix(16, y) end
row(COPY_NINTENDO, 80, 56)
row(COPY_CREATURES, 80, 72)
love.graphics.draw(self.gfInc, 80, 88)
else
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("Nintendo"), 80, 56)
Font.draw(Strings("Creatures inc."), 80, 72)
Font.draw(Strings("GAME FREAK inc."), 16, 88)
end
love.graphics.setColor(1, 1, 1, 1)
end
function IntroMovie:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if self.phase == 1 then
-- custom boot card (replaces the Nintendo / GAME FREAK copyright
-- card; no (c) glyph in the charmap, keep it ASCII-safe)
love.graphics.setColor(0, 0, 0, 1)
local credit = self.studio.credit or Strings("bois club")
Font.draw("2026", (160 - 4 * 8) / 2, 48)
Font.draw(credit, (160 - #credit * 8) / 2, 64)
Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80)
self:drawCopyright()
elseif self.phase == 2 then
self:drawSplash()
else
+1
View File
@@ -51,6 +51,7 @@ function ListMenu.new(game, title, items, opts)
local self = setmetatable({}, ListMenu)
self.game = game
self.title = title
self.kind = opts.kind or title
self.items = items
self.index = 1
self.scroll = 0
+2 -1
View File
@@ -265,7 +265,8 @@ function OakSpeech.new(game, onDone)
or "assets/generated/intro/shrink2.png")
-- RedSprite: the walking sprite the pic shrinks into (frame 0 =
-- standing, facing down)
local red = game.data.sprites and game.data.sprites.SPRITE_RED
local playerSprites = (game.data.field and game.data.field.playerSprites) or {}
local red = game.data.sprites and game.data.sprites[playerSprites.walk or "SPRITE_RED"] or game.data.sprites.SPRITE_RED
self.walkSheet = tryImage(red and red.image)
return self
end
+32 -5
View File
@@ -392,14 +392,35 @@ local function buildRows(game)
return true
end },
-- fast-forward the logic clock only; music and sfx keep their tempo
-- (src/core/GameSpeed.lua), so this is safe to leave on
{ id = "speed", label = Strings("GAME SPEED"),
-- (src/core/GameSpeed.lua), so this is safe to leave on. Per-category
-- (RFC 0007): overworld walking, battle turns and menu navigation each
-- cycle their own multiplier -- GameSpeed.CATEGORIES is the single
-- source of truth for which three rows exist.
{ id = "speedOverworld", label = Strings("OVERWORLD SPEED"),
value = function(g)
return GameSpeed.levelLabel(g.save.options.speed)
return GameSpeed.levelLabel(g.save.options.speedOverworld)
end,
step = function(g, dir)
local o = g.save.options
o.speed = GameSpeed.cycle(o.speed, dir)
o.speedOverworld = GameSpeed.cycle(o.speedOverworld, dir)
return true
end },
{ id = "speedBattle", label = Strings("BATTLE SPEED"),
value = function(g)
return GameSpeed.levelLabel(g.save.options.speedBattle)
end,
step = function(g, dir)
local o = g.save.options
o.speedBattle = GameSpeed.cycle(o.speedBattle, dir)
return true
end },
{ id = "speedMenu", label = Strings("MENU SPEED"),
value = function(g)
return GameSpeed.levelLabel(g.save.options.speedMenu)
end,
step = function(g, dir)
local o = g.save.options
o.speedMenu = GameSpeed.cycle(o.speedMenu, dir)
return true
end },
-- the manager's discoverable home (18-mod-manager-ux); inert until
@@ -585,8 +606,14 @@ function OptionsMenu:update(dt)
end
function OptionsMenu:draw()
-- Through Strings, like every other label on this menu. CANCEL is
-- appended AFTER the rows hook (see the header), which is what keeps a mod
-- from orphaning the exit -- but it also means a translation mod never sees
-- this string, and cannot: there is no row for it to rewrite. So the one
-- word a Spanish player could not read on a fully translated OPTIONS menu
-- was the way out of it.
OptionRows.draw(self.game, self.rows, self.index, self.scroll or 0,
"CANCEL", #self.rows + 1)
Strings("CANCEL"), #self.rows + 1)
end
return OptionsMenu
+4 -1
View File
@@ -44,7 +44,7 @@ local function askQuantity(game, list, count, id, cb)
cb(1)
return
end
list.footer = "How many?"
list.footer = Strings("How many?")
local QuantityBox = require("src.ui.QuantityBox")
game.stack:push(QuantityBox.new(game, {
max = count,
@@ -72,6 +72,7 @@ end
local function withdraw(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
kind = "pc_item_withdraw",
messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
@@ -110,6 +111,7 @@ local function deposit(game)
if not Bag.isBadge(id) then depositable[id] = count end
end
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
kind = "pc_item_deposit",
messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
@@ -131,6 +133,7 @@ end
local function toss(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
kind = "pc_item_toss",
messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
+211 -53
View File
@@ -103,11 +103,40 @@ local YELLOW_CYCLE_SPECIES = {
"JIGGLYPUFF", "MEOWTH", "PSYDUCK", "VULPIX", "ABRA",
"GROWLITHE", "CUBONE", "GASTLY", "HITMONLEE", "SNORLAX", "DRAGONITE",
}
local CYCLE_FRAMES = 240 -- the original waits ~4s between picks
-- ..(engine/movie/title.asm ln 227)
local HOLD_FRAMES = 200
local STARTERS = { CHARMANDER = true, SQUIRTLE = true, BULBASAUR = true }
-- ..(engine/movie/title2.asm ln 13)
local function scrollFrames(steps, offset)
local frames = {}
for _, step in ipairs(steps) do
for _ = 1, step[2] do
frames[#frames + 1] = offset
offset = offset - step[1]
end
end
return frames
end
local OUT_FRAMES = scrollFrames(
{ { 1, 2 }, { 2, 2 }, { 3, 2 }, { 4, 2 }, { 5, 2 }, { 6, 2 },
{ 8, 3 }, { 9, 3 } }, 0)
local IN_FRAMES = scrollFrames(
{ { 10, 2 }, { 9, 4 }, { 8, 4 }, { 6, 3 }, { 5, 2 }, { 3, 1 },
{ 1, 1 } }, 120)
-- ..(engine/movie/title2.asm ln 85)
local BALL_FRAMES = { 97, 95, 94, 93, 92, 93, 94, 95, 97, 100 }
local BALL_REST = 100
local function tryImage(path)
if not path then return nil end
local ok, img = pcall(love.graphics.newImage, path)
-- resolve through Assets so a mod's derived art (save/mod-derived/)
-- wins here the way it does for every other generated sheet -- but
-- load uncached, because on NX the per-version overlay redirects the
-- open itself and a cached image would leak across Yellow/Blue boots
local ok, img = pcall(love.graphics.newImage,
require("src.render.Assets").resolve(path))
return ok and img or nil
end
@@ -151,16 +180,57 @@ function TitleState.new(game, opts)
-- branding comes from field.title with the shipped art as fallback, so
-- a total conversion rebrands the title without replacing the screen
local title = (game.data.field and game.data.field.title) or {}
-- field.title itself is extraction data the field schema never exposes;
-- boot.title is the mod-reachable half of the same seam, so its keys
-- override here (a localized ribbon, a rebranded logo)
local boot = game.data.field and game.data.field.boot
if boot and type(boot.title) == "table" then
local merged = {}
for key, value in pairs(title) do merged[key] = value end
for key, value in pairs(boot.title) do merged[key] = value end
title = merged
end
self.title = title
self.logo = tryImage(imagePath(title.logo)
or "assets/logo/pokemon_logo.png")
-- versionRibbon is the file-12 key; version is the importer's
-- versionRibbon is the file-12 key; version is the importer's. The
-- vanilla sheet is two fragments the draw pass repositions, so an
-- explicit ribbon (a conversion's or a translation's continuous art)
-- draws whole instead.
self.versionFull = imagePath(title.versionRibbon) ~= nil
self.version = tryImage(imagePath(title.versionRibbon or title.version)
or "assets/generated/title/red_version.png")
self.player = tryImage("assets/generated/title/player.png")
self.player = tryImage(imagePath(title.player)
or "assets/generated/title/player.png")
-- ..(engine/movie/title2.asm ln 85)
if self.player then
local pw, ph = self.player:getDimensions()
self.ballQuad = love.graphics.newQuad(0, 16, 8, 8, pw, ph)
self.playerQuads = {
{ love.graphics.newQuad(0, 0, pw, 16, pw, ph), 0, 0 },
{ love.graphics.newQuad(8, 16, pw - 8, 8, pw, ph), 8, 16 },
{ love.graphics.newQuad(0, 24, pw, ph - 24, pw, ph), 0, 24 },
}
end
self.copyImg = tryImage(imagePath(title.copyright)
or "assets/generated/title/copyright.png")
self.copyQuads = {}
if self.copyImg then
local iw, ih = self.copyImg:getDimensions()
for t = 0, 18 do
self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih)
end
end
self.gfInc = tryImage(imagePath(title.gamefreakInc)
or "assets/generated/title/gamefreak_inc.png")
self.blue = GameVersion.isBlue()
self.yellow = GameVersion.isYellow()
or title.layout == "yellow_pikachu"
-- ..(pokeyellow engine/movie/title.asm .tileScreenCopyrightTiles / NineTile)
self.nineImg = self.yellow and tryImage(imagePath(title.nine)
or "assets/generated/title/nine.png") or nil
self.copyPrefix = self.yellow
and { 0, 1, 2, 3, 1, 2 } or { 0, 1, 2, 1, 3, 1, 4 }
-- Yellow title is a fixed Pikachu composition (title_yellow.asm), not
-- TitleMons cycling. Prefer composed pikachu.png from the Yellow import.
self.yellowPikachu = self.yellow and tryImage(imagePath(title.pikachu)
@@ -183,7 +253,10 @@ function TitleState.new(game, opts)
self.blinkTimer = 0
self.blinkAt = nil
else
self.phase = "loop"
-- ..(engine/movie/title.asm ln 28)
self.scy = 0x40
self.phase = "drop"
self.dropStep, self.dropLeft = 1, nil
self.showBubble = true
end
local defaultCycle = self.yellowLayout and { "PIKACHU" }
@@ -196,14 +269,15 @@ function TitleState.new(game, opts)
self.cycleIndex = 1
self.timer = 0
self.blink = 0
self.scrollPhase = "hold"
self.scrollFrame = 1
self.monOffset = 0
self.ballY = BALL_REST
return self
end
function TitleState:enter()
-- Yellow defers the title theme until after the logo drop and
-- Pikachu's cry (title.asm plays MUSIC_TITLE_SCREEN only after
-- WaitForSoundToFinish on PikachuCry1)
if self.yellowLayout then return end
if self.phase ~= "loop" then return end
self:startMusic()
end
@@ -220,6 +294,11 @@ end
local DROP_STEPS = {
{ -4, 16 }, { 3, 4 }, { -3, 4 }, { 2, 2 }, { -2, 2 }, { 1, 2 }, { -1, 2 },
}
local SETTLE_FRAMES = 36
-- ..(engine/movie/title.asm ln 201)
local RIBBON_FRAMES = {}
for offset = 112, 4, -4 do RIBBON_FRAMES[#RIBBON_FRAMES + 1] = offset end
-- the boot cinematic up to the interactive loop; one call per frame
function TitleState:updateSequence()
@@ -243,12 +322,23 @@ function TitleState:updateSequence()
self.dropLeft = nil
end
elseif self.phase == "settle" then
-- ld c, 36 / DelayFrames, then the whoosh and the bubble
self.timer = self.timer + 1
if self.timer >= 36 then
if self.timer >= SETTLE_FRAMES then
Sound.play(data, "Intro_Whoosh")
self.showBubble = true
self.phase = "bubble"
self.phase = self.yellowLayout and "bubble" or "ribbon"
self.ribbonOffset = RIBBON_FRAMES[1]
self.timer = 0
end
elseif self.phase == "ribbon" then
self.timer = self.timer + 1
local offset = RIBBON_FRAMES[self.timer + 1]
if offset then
self.ribbonOffset = offset
else
self.ribbonOffset = nil
self:startMusic()
self.phase = "loop"
self.timer = 0
end
elseif self.phase == "bubble" then
@@ -347,8 +437,12 @@ function ContinueInfo:draw()
-- box at (4,7), 8x14 content; labels double-spaced from (5,9)
Font.drawBox(4, 7, 16, 10)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("PLAYER"), 40, 72)
Font.draw((save.player and save.player.name) or "RED", 96, 72)
-- the name follows the label's real width (one space after it), so a
-- localized label longer than PLAYER's six glyphs cannot run into it
local playerLabel = Strings("PLAYER")
Font.draw(playerLabel, 40, 72)
Font.draw((save.player and save.player.name) or "RED",
math.max(96, 40 + (#Font.split(playerLabel) + 1) * 8), 72)
local badges = require("src.inventory.Badges").count(self.game.data, save)
Font.draw(Strings("BADGES"), 40, 88)
Font.draw(("%2d"):format(badges), 128, 88)
@@ -401,17 +495,70 @@ function TitleState:openMenu()
end
local th = #items * 2 + 2
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
-- full-width title LOGO zones would recolor this box; see sgbPalettes
menu.titleUiBox = { 0, 0, 12, th - 1 }
-- full-width title LOGO zones would recolor this box; see sgbPalettes.
-- Menu.new may have grown tw for longer (e.g. localized) labels, so the
-- recolor zone follows the box's real width instead of the vanilla 13.
menu.titleUiBox = { 0, 0, menu.tw - 1, th - 1 }
game.stack:push(menu)
end
-- ..(engine/movie/title.asm ln 271)
function TitleState:pickNewMon()
if #self.cycleSpecies < 2 then return end
local pick = self.cycleIndex
while pick == self.cycleIndex do
pick = love.math.random(1, #self.cycleSpecies)
end
self.cycleIndex = pick
end
function TitleState:setCyclePhase(phase)
self.scrollPhase = phase
self.scrollFrame = 1
self.timer = 0
if phase == "in" then
self:pickNewMon()
self.monOffset = IN_FRAMES[1]
elseif phase == "out" then
self.monOffset = OUT_FRAMES[1]
elseif phase == "ball" then
self.ballY = BALL_FRAMES[1]
else
self.monOffset = 0
end
end
function TitleState:updateCycle()
local phase = self.scrollPhase
if phase == "hold" then
if self.timer >= HOLD_FRAMES then self:setCyclePhase("out") end
return
end
local frames = phase == "out" and OUT_FRAMES
or phase == "ball" and BALL_FRAMES or IN_FRAMES
self.scrollFrame = self.scrollFrame + 1
local value = frames[self.scrollFrame]
if value then
if phase == "ball" then self.ballY = value else self.monOffset = value end
return
end
if phase == "out" then
-- ..(engine/movie/title.asm ln 235)
self:setCyclePhase(
STARTERS[self.cycleSpecies[self.cycleIndex]] and "ball" or "in")
elseif phase == "ball" then
self:setCyclePhase("in")
else
self:setCyclePhase("hold")
end
end
function TitleState:update(dt)
if self.phase ~= "loop" then
self:updateSequence()
return
end
if self.yellowLayout then
if self.phase ~= "loop" then
self:updateSequence()
return -- input is ignored until the cinematic lands (title.asm)
end
self:updateBlink()
local input = self.game.input
if input:wasPressed("start") or input:wasPressed("a") then
@@ -426,21 +573,7 @@ function TitleState:update(dt)
end
self.timer = self.timer + 1
self.blink = (self.blink + 1) % 60
if not self.yellowLayout and self.timer >= CYCLE_FRAMES then
self.timer = 0
-- random pick that never repeats the current one
if #self.cycleSpecies > 1 then
local pick = self.cycleIndex
while pick == self.cycleIndex do
pick = love.math.random(1, #self.cycleSpecies)
end
self.cycleIndex = pick
end
self.slideIn = 20 -- TitleScreenScrollInMon slides the pic in
end
if self.slideIn and self.slideIn > 0 then
self.slideIn = self.slideIn - 1
end
self:updateCycle()
local input = self.game.input
if input:wasPressed("start") or input:wasPressed("a") then
-- the title mon cries when you leave the title (.finishedWaiting);
@@ -452,15 +585,14 @@ function TitleState:update(dt)
end
end
-- The original tilemap (engine/movie/title.asm): logo at tile (2,1),
-- the version ribbon at (7,8), Red's title art as OAM at px (82,80),
-- the title mon in the 7x7 box at tile (5,10), copyright on row 17.
-- Yellow (title_yellow.asm): logo (2,1), speech bubble (6,4), Pikachu
-- (4,8) 12x9 -- no version ribbon, no cycling mon, no Red OAM.
-- ..(engine/movie/title.asm ln 28)
function TitleState:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local scrollY = self.yellowLayout and -(self.scy or 0) or 0
local scrollY = -(self.scy or 0)
-- ..(engine/movie/title.asm ln 28)
local preRibbon = not self.yellowLayout
and (self.phase == "drop" or self.phase == "settle")
if self.logo then
love.graphics.draw(self.logo, 16, 8 + scrollY)
else
@@ -488,24 +620,29 @@ function TitleState:draw()
-- Yellow's Version_GFX slot holds a leftover "Blue Version" ribbon
-- (pokeyellow gfx/title/blue_version.png, unreferenced by title code);
-- the Yellow fallback layout draws no ribbon at all.
if self.version and not self.yellow then
if self.version and not self.yellow and not preRibbon then
local iw, ih = self.version:getDimensions()
if self.blue then
local rx = self.ribbonOffset or 0
if self.versionFull then
-- a continuous ribbon (versionRibbon) centers as one piece
love.graphics.draw(self.version, math.floor((160 - iw) / 2) + rx, 64)
elseif self.blue then
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64)
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56 + rx, 64)
else
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56 + rx, 64)
love.graphics.draw(self.version,
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80 + rx, 64)
end
end
local sprite, spriteTrueColor = self:currentSprite()
local sprite, spriteTrueColor
if self.scrollPhase ~= "ball" then
sprite, spriteTrueColor = self:currentSprite()
end
if sprite then
local w, h = sprite:getDimensions()
local slide = (self.slideIn or 0) * 8 -- scroll in from the right
-- bottom-aligned and centered in the (5,10)-(11,16) tile box
local x = 40 + math.floor((56 - w) / 2) + slide
local x = 40 + math.floor((56 - w) / 2) + self.monOffset
local y = 136 - h
love.graphics.draw(sprite, x, y)
-- a full-color mon keeps its own palette through the SGB pass, minus
@@ -521,13 +658,34 @@ function TitleState:draw()
end
end
-- Red is OAM in the original: he draws over the mon's box edge
if self.player then
if self.playerQuads then
for _, part in ipairs(self.playerQuads) do
love.graphics.draw(self.player, part[1], 82 + part[2], 80 + part[3])
end
love.graphics.draw(self.player, self.ballQuad, 82, self.ballY)
elseif self.player then
love.graphics.draw(self.player, 82, 80)
end
end
self:drawCopyright(136 + (preRibbon and 0 or scrollY))
end
function TitleState:drawCopyright(y)
if not self.title.copyrightText and self.copyImg and self.gfInc then
local x = 16
for _, t in ipairs(self.copyPrefix) do
love.graphics.draw(self.copyImg, self.copyQuads[t], x, y)
x = x + 8
end
if self.nineImg then
love.graphics.draw(self.nineImg, x, y)
x = x + 8
end
love.graphics.draw(self.gfInc, x, y)
return
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.title.copyrightText or Strings("2026 bois club games"),
1, 136 + scrollY)
Font.draw(self.title.copyrightText or Strings("GAME FREAK inc."), 16, y)
love.graphics.setColor(1, 1, 1, 1)
end
+122 -28
View File
@@ -37,6 +37,10 @@ local mapScripts -- registry of hand-ported map scripts
local COMPASS = { up = "north", down = "south", left = "west", right = "east" }
local DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
-- pokered's wNumberOfNoRandomBattleStepsLeft: three completed steps
-- after a wild battle before another random battle can start.
local WILD_ENCOUNTER_GRACE_STEPS = 3
-- Fly animation coord paths (engine/overworld/player_animations.asm):
-- y/x pairs in GB screen pixels, one pair every 3 frames (DoFlyAnimation's
-- Delay3). The port anchors a path on the player's own position instead
@@ -81,8 +85,10 @@ local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 }
-- above (screen = tile*8 + pixel - 8/16), measured against the player
-- sprite's fixed screen spot: ResetPlayerSpriteData parks it at $3c/$40
-- (home/reset_player_sprite.asm), i.e. screen (64,60). So what ports over
-- is the delta from the sprite's top-left, which SpriteRenderer:draw puts at
-- (px, py - 4). `tile` indexes the three stacked 8x8 tiles of
-- is the delta from the sprite's top-left, which the vanilla
-- SpriteRenderer:draw puts at (px, py - 4); custom frame anchors move that
-- origin while keeping these offsets frame-relative. `tile` indexes the
-- three stacked 8x8 tiles of
-- assets/generated/fx/fishing_rod.png: FishingRodOAM only ever draws $fd
-- (row 0, up/down) and $fe (row 1, left/right), and RIGHT is the LEFT tile
-- x-flipped. Blitting the whole 8x24 sheet is what drew the rod as a
@@ -206,7 +212,7 @@ function OverworldState.computeNeighbors(maps, rootId, hops, reachW, reachH)
return out
end
function OverworldState:enter(mapId, x, y, facing)
function OverworldState:enter(mapId, x, y, facing, opts)
Game = require("src.core.Game")
Game.overworld = self
Collision.load(Game.data) -- tile-pair (elevation) collisions
@@ -224,10 +230,12 @@ function OverworldState:enter(mapId, x, y, facing)
-- a fresh entry, or a stale flag can freeze player input forever
self.engaging = false
self.emote = nil
-- volatile WRAM state in pokered; never serialize across save/load
self.wildEncounterGraceSteps = 0
-- survives save/load: a loaded game may start inside a building whose
-- exit mat is a LAST_MAP warp
self.lastOutdoor = Game.save.lastOutdoor
self:setMap(mapId, x, y, facing, { via = "boot" })
self:setMap(mapId, x, y, facing, opts or { via = "boot" })
-- boot/load: derive the flag from the tile the save left us standing on,
-- like MapEntryAfterBattle's IsPlayerStandingOnWarp, so a game saved on a
-- door mat can still walk straight back out (issue #378)
@@ -282,7 +290,7 @@ end
function OverworldState:setMap(mapId, x, y, facing, opts)
local fromMapId = self.map and self.map.id
if fromMapId then
if fromMapId and not (opts and opts.checkpoint) then
Runtime.emit("map.exited", { mapId = fromMapId, toMapId = mapId })
end
-- ambient choreography is per-map: parallel runners die here, and the
@@ -451,8 +459,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
local keepMusic = (opts and opts.keepMusic) or self.keepMusicOnce
self.keepMusicOnce = nil
if not keepMusic then
require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike,
self.player.surfing)
-- ..(home/overworld.asm ln 2346)
local Music = require("src.core.Music")
Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing,
Music.MAP_FADE)
end
-- forced bike/surf tiles fire the moment the player is placed on the
@@ -460,13 +470,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
-- (home/overworld.asm) -- a warp can land directly on one (the Route
-- 16/18 gate exits), and the scripted door-mat walkout that follows
-- suppresses onStepComplete, so waiting for a plain step never mounts
self:checkForcedMovement()
if not (opts and opts.checkpoint) then self:checkForcedMovement() end
-- Seafoam B4F's map script pushes off the B3F stair warps every frame
-- while the upper plugs are out (SeafoamIslandsB4FDefaultScript); the
-- B3F/B4F force-surf mouths also arm their MOVE_OBJECT current scripts
-- from CheckForceBikeOrSurf. Re-check here so a warp-in does not sit
-- idle on those cells waiting for a player step.
self:checkSeafoamCurrent()
if not (opts and opts.checkpoint) then self:checkSeafoamCurrent() end
-- snap the camera immediately: the overworld doesn't update while a
-- Transition is on top, so a stale camera would show the new map at
@@ -476,27 +486,31 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
-- fires before the onEnter chain so a listener sees the map in the same
-- state the map script does
Runtime.emit("map.entered", {
mapId = mapId, map = self.map, fromMapId = fromMapId,
via = (opts and opts.via)
or (opts and opts.seamless and "connection")
or (fromMapId and "warp" or "boot"),
})
if not (opts and opts.checkpoint) then
Runtime.emit("map.entered", {
mapId = mapId, map = self.map, fromMapId = fromMapId,
via = (opts and opts.via)
or (opts and opts.seamless and "connection")
or (fromMapId and "warp" or "boot"),
})
end
-- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers).
-- fromMapId lets elevators seed a valid walk-out floor when the ROM
-- car warps still point at a missing map (Silph's UNUSED_MAP_ED) and
-- the player B-cancels the floor menu without .UpdateWarp.
local hooks = mapScripts.get(mapId)
if hooks and hooks.onEnter then
hooks.onEnter(Game, self, fromMapId)
if not (opts and opts.checkpoint) then
local hooks = mapScripts.get(mapId)
if hooks and hooks.onEnter then
hooks.onEnter(Game, self, fromMapId)
end
end
self:rebuildNeighbors()
Logger.info("map: %s at (%d,%d)", mapId, x, y)
-- Route22Gate_Script rewrites wLastMap from the player's Y on entry
-- too (not only on step), so a save/load mid-gate keeps exits correct
self:syncLastMapRewrite()
if not (opts and opts.checkpoint) then self:syncLastMapRewrite() end
end
-- Neighbor maps drawn at the composed connection offsets: at least the
@@ -760,8 +774,8 @@ function OverworldState:pushBattle(battle)
local enemyLevel = battle.enemy and battle.enemy.mon and battle.enemy.mon.level or 0
-- the battle theme starts with the wipe, not after it
-- (audio/play_battle_music.asm runs before the transition)
if battle.computeMusicKind then
require("src.core.Music").playBattle(Game.data, battle:computeMusicKind())
if battle.playBattleTheme then
battle:playBattleTheme()
end
-- The fade back in from white on the way out is BattleState:finish()'s
@@ -969,8 +983,14 @@ function OverworldState:update(dt)
-- the bird carries the player in on landing, with its own
-- SFX_FLY (EnterMapAnim .flyAnimation)
self.arriveWarp = "fly"
-- keep the sprite hidden through the warp fade-out (#916): flyAnim
-- just went nil but flyArrive is not armed until startWarpTo's
-- midpoint, and the overworld keeps drawing beneath the veil, so
-- without this the trainer pops back in at the old cell for 32 frames
self.playerHidden = true
self:startWarpTo(d.map, d.x, d.y, "down", nil, { via = "fly" })
else
self.playerHidden = false
self.player.inputLocked = false
end
return
@@ -1002,6 +1022,10 @@ function OverworldState:update(dt)
self.player.spinFrames = nil
self.player.spinRise = nil
self.player.inputLocked = false
-- keep the sprite hidden through the warp fade-out (#916): the spin is
-- over but the arrival spin-drop is not armed until startWarpTo's
-- midpoint, so without this the standing trainer shows under the veil
self.playerHidden = true
self:warpToHealPoint(onDone, { arrive = "teleport" })
return
end
@@ -1079,8 +1103,10 @@ function OverworldState:update(dt)
local mapId = self.pendingSeamMusic
self.pendingSeamMusic = nil
if mapId == self.map.id then
require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike,
self.player.surfing)
-- ..(home/overworld.asm ln 677)
local Music = require("src.core.Music")
Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing,
Music.MAP_FADE)
end
end
if stepped and not scripted then
@@ -3045,6 +3071,14 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
if theme then require("src.core.Music").play(Game.data, theme) end
end
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty)
battle.checkpointOrigin = {
kind = "trainer_encounter",
map = self.map.id,
npcId = npc.id,
trainerClass = d.trainerClass,
partyIndex = d.trainerParty or 1,
event = header and header.event or nil,
}
-- PrintEndBattleText (home/trainers.asm:341) is called from
-- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle
-- screen once ScrollTrainerPicAfterBattle has brought the beaten trainer
@@ -3450,6 +3484,10 @@ end
function OverworldState:onStepComplete()
local p = self.player
local suppressWildEncounter = self.wildEncounterGraceSteps > 0
if suppressWildEncounter then
self.wildEncounterGraceSteps = self.wildEncounterGraceSteps - 1
end
self.todSteps = (self.todSteps or 0) + 1
-- UpdatePikachuHappinessAndMood rides the step counter (poison.asm)
require("src.world.PikachuFollower").onStep(Game.save)
@@ -3562,6 +3600,9 @@ function OverworldState:onStepComplete()
-- wild encounters in grass, on water while surfing, or -- on indoor
-- maps whose tileset is not FOREST -- on EVERY tile
-- (wild_encounters.asm: caves, towers, the Mansion, Power Plant)
-- The cooldown is checked after all other step processing so repel and
-- movement systems continue to advance during the protected steps.
if suppressWildEncounter then return end
local encDef = Game.data.encounters[self.map.id]
local enc
local indoor = Game.data.field.indoorEncounters
@@ -3582,6 +3623,10 @@ function OverworldState:onStepComplete()
end
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(Game, enc.species, enc.level)
battle.checkpointOrigin = {
kind = "wild_encounter",
map = self.map.id,
}
-- map.ghostBattles: unidentifiable without the named item (the
-- Pokemon Tower's Silph Scope)
local ghost = Map.ghostBattles(self.map.def)
@@ -3944,6 +3989,9 @@ end
-- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the
-- blackout (pret HandlePlayerBlackOut) so the map script can HealParty.
function OverworldState:afterBattle(result, battle)
if battle and battle.kind == "wild" then
self.wildEncounterGraceSteps = WILD_ENCOUNTER_GRACE_STEPS
end
local lead = Game.save.party[1]
Logger.info("battle over: %s (lead %s %d/%d)", tostring(result),
lead and lead.species or "-", lead and lead.hp or 0,
@@ -3986,6 +4034,39 @@ function OverworldState:afterBattle(result, battle)
end
end
-- Rebind the data-only continuation attached to a supported battle checkpoint.
-- The overworld was reconstructed first, so transient input/NPC freezes from
-- the original encounter are intentionally not resumed.
function OverworldState:restoreBattleContinuation(battle, origin)
local game = battle and battle.game
if not game or type(origin) ~= "table" or not self.map
or origin.map ~= self.map.id then
return false
end
if origin.kind == "wild_encounter" and battle.kind == "wild" then
battle.onFinish = function(result) self:afterBattle(result, battle) end
return true
end
if origin.kind ~= "trainer_encounter" or battle.kind ~= "trainer"
or origin.trainerClass ~= battle.oppClass
or origin.partyIndex ~= (battle.partyIndex or 1)
or type(origin.npcId) ~= "string" then
return false
end
battle.onFinish = function(result)
if result == "win" then
game.save.defeatedTrainers[origin.npcId] = true
if origin.event then game.save.flags[origin.event] = true end
self:checkVictoryRewards(battle.oppClass, battle.partyIndex)
end
self:afterBattle(result, battle)
self.engaging = false
local npc = self.npcPool and self.npcPool[origin.npcId]
if npc then npc.frozen = false end
end
return true
end
-- -------------------------------------------------------------------------
-- warps
-- -------------------------------------------------------------------------
@@ -4136,6 +4217,11 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
self.arriveWarp = nil
Game.stack:push(Transition.new(Game, function()
self:setMap(mapId, x, y, facing or "down", opts)
-- the departure-side hide from flyAnim/teleportOut ends here, on the new
-- map; the arrival arms its own cover (flyArrive / spinDrop) a few lines
-- down, so the player is never drawable mid-fade nor standing bare on the
-- landing frame (#916)
self.playerHidden = false
-- The warp we land ON stays inert for the completed-step check until we
-- physically step off it, so a warp whose destination cell is itself a
-- warp cannot bounce us straight back (elevator cars, stacked stair/door
@@ -4735,9 +4821,15 @@ function OverworldState:drawWorld()
end
end
local quad = self.rodQuads[oam.tile]
-- the sprite's top-left is 4px above its cell (SpriteRenderer:draw)
local rx = p.px - cam.x + oam.dx
local ry = p.py - cam.y - 4 + oam.dy
-- Place the rod against the active sprite's anchored top-left. The
-- vanilla result is still (px-cam, py-cam-4), while custom larger
-- sheets keep the rod attached to their feet.
-- Fishing always uses the on-foot player sheet; read its fields
-- directly so this FX pass does not advance pose-side animation.
local sprite, px, py = p.sprite, p.px, p.py
local sx, sy = sprite:getScreenOrigin(px, py, cam.x, cam.y)
local rx = sx + oam.dx
local ry = sy + oam.dy
love.graphics.setColor(1, 1, 1, 1)
if quad and oam.flip then
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1)
@@ -4860,7 +4952,8 @@ function OverworldState:drawWorld()
g.npc:draw(cam.x - g.ox, cam.y - g.oy)
end
for _, e in ipairs(self.entities) do
if not ((self.flyAnim or self.flyArrive) and e == self.player) then
if not ((self.flyAnim or self.flyArrive or self.playerHidden)
and e == self.player) then
e:draw(cam.x, cam.y)
-- tall grass overdraws the sprite's feet (GB sprite priority);
-- the overdraw is BG tiles, so it rides the shake offset too
@@ -4911,7 +5004,8 @@ function OverworldState:drawWorld()
items[#items + 1] = { y = g.npc.py + g.oy + 16, kind = "ghost", g = g }
end
for _, e in ipairs(self.entities) do
if not ((self.flyAnim or self.flyArrive) and e == self.player) then
if not ((self.flyAnim or self.flyArrive or self.playerHidden)
and e == self.player) then
items[#items + 1] = { y = e.py + 16, kind = "entity", e = e }
end
end
+7 -2
View File
@@ -335,8 +335,13 @@ function Player:draw(camX, camY)
local fishTile = self.fishing and self.fishTiles and self.fishTiles[facing]
if fishTile then
sprite:draw(px, py, camX, camY, facing, 0, false, true)
sprite:drawTile(fishTile, math.floor(px - camX),
math.floor(py - camY) - 4 + 8, facing == "right")
-- The fishing pose replaces the bottom 8-pixel tile. Use the sprite's
-- actual anchored frame origin so larger/custom sheets keep the pose at
-- their feet instead of falling back to the vanilla 16x16 top-left.
local sx, sy = sprite:getScreenOrigin(px, py, camX, camY)
sprite:drawTile(fishTile, sx,
sy + math.max(0, sprite.frameHeight - 8),
facing == "right")
return
end
sprite:draw(px, py, camX, camY, facing, phase, flip)
+145
View File
@@ -6,13 +6,68 @@
-- stays unsupported; anything a mod legitimately needs belongs here.
local Logger = require("src.core.Logger")
local Assets = require("src.render.Assets")
local MapLoader = require("src.world.MapLoader")
local Party = require("src.pokemon.Party")
local Runtime = require("src.mods.Runtime")
local WorldAPI = {}
WorldAPI.__index = WorldAPI
local NO_OVERWORLD = "no overworld"
local overviewShades = {}
Assets.register(function() overviewShades = {} end)
local function shadeDigit(sum, pixelCount)
return tostring(math.max(0, math.min(3,
math.floor((1 - sum / pixelCount) * 3 + 0.5))))
end
local function mapTileRows(map)
local tileset = map.tileset
if not (tileset and tileset.image and tileset.tilesPerRow) then return nil end
local cached = overviewShades[tileset.image]
if not cached then
local ok, pixels = pcall(Assets.imageData, tileset.image)
if not ok then return nil end
cached = { pixels = pixels, shades = {} }
overviewShades[tileset.image] = cached
end
local rows, detailRows, perRow = {}, {}, tileset.tilesPerRow
for ty = 0, map.heightCells * 2 - 1 do
local row, detailTop, detailBottom = {}, {}, {}
for tx = 0, map.widthCells * 2 - 1 do
local tile = map:tileAt(tx, ty)
local shades = cached.shades[tile]
if shades == nil then
local sums = { 0, 0, 0, 0 }
local ox, oy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
for py = 0, 7 do
for px = 0, 7 do
local r, g, b = cached.pixels:getPixel(ox + px, oy + py)
local quadrant = math.floor(py / 4) * 2 + math.floor(px / 4) + 1
sums[quadrant] = sums[quadrant]
+ r * 0.2126 + g * 0.7152 + b * 0.0722
end
end
shades = {
shadeDigit(sums[1] + sums[2] + sums[3] + sums[4], 64),
shadeDigit(sums[1], 16), shadeDigit(sums[2], 16),
shadeDigit(sums[3], 16), shadeDigit(sums[4], 16),
}
cached.shades[tile] = shades
end
row[#row + 1] = shades[1]
detailTop[#detailTop + 1] = shades[2] .. shades[3]
detailBottom[#detailBottom + 1] = shades[4] .. shades[5]
end
rows[#rows + 1] = table.concat(row)
detailRows[#detailRows + 1] = table.concat(detailTop)
detailRows[#detailRows + 1] = table.concat(detailBottom)
end
return rows, detailRows
end
function WorldAPI.new(game, modId)
return setmetatable({ game = game, modId = modId }, WorldAPI)
@@ -43,6 +98,53 @@ function WorldAPI:current()
facing = p and p.facing }
end
-- A compact, read-only view of the active map for minimaps and companion UIs.
-- `rows` describes collision terrain; optional `tileRows` reduces each real
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
-- `tileDetailRows` preserves one shade per 4x4 quadrant. Markers identify
-- exits and item spots that are still active without exposing world internals.
function WorldAPI:mapOverview()
local ow = self:overworld()
if not ow or not ow.map then return nil, NO_OVERWORLD end
local map, rows, markers = ow.map, {}, {}
for y = 0, map.heightCells - 1 do
local row = {}
for x = 0, map.widthCells - 1 do
row[#row + 1] = map:isWarpTileCell(x, y) and "+"
or map:isWaterCell(x, y) and "~"
or map:isWalkableCell(x, y) and "." or " "
end
rows[#rows + 1] = table.concat(row)
end
local def = map.def or {}
for _, warp in ipairs(def.warps or {}) do
markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y }
end
local game, save = self.game, self.game.save or {}
for _, obj in ipairs(def.objects or {}) do
if obj.item and obj.item ~= "0" and obj.item ~= 0
and ow.objectVisible(save, map.id, obj) then
markers[#markers + 1] = { kind = "item", x = obj.x, y = obj.y }
end
end
local hidden = game.data and game.data.field and game.data.field.hiddenItems
for _, item in ipairs(hidden and hidden[map.id] or {}) do
local key = map.id .. "_" .. item.x .. "_" .. item.y
if not (save.hiddenTaken and save.hiddenTaken[key]) then
markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y }
end
end
local tileRows, tileDetailRows = mapTileRows(map)
return { mapId = map.id, width = map.widthCells,
height = map.heightCells, rows = rows, markers = markers,
tileRows = tileRows,
tileWidth = tileRows and map.widthCells * 2,
tileHeight = tileRows and map.heightCells * 2,
tileDetailRows = tileDetailRows,
tileDetailWidth = tileDetailRows and map.widthCells * 4,
tileDetailHeight = tileDetailRows and map.heightCells * 4 }
end
-- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else
-- lands the player without one, like a scripted warp.
function WorldAPI:warpTo(mapId, x, y, facing, opts)
@@ -164,6 +266,49 @@ function WorldAPI:queueScript(rows, extra)
return true
end
-- The supported way to start a wild encounter. Hand-rolling this -- build a
-- BattleState, push it -- silently costs evolutions and blackout-on-loss
-- (both hang off onFinish -> afterBattle) plus the entry wipe and battle
-- theme (both owned by pushBattle). Nothing raises when they are missing.
function WorldAPI:startWildBattle(species, level)
local ow = self:overworld()
if not ow then return nil, NO_OVERWORLD end
if not self.game.data.pokemon[species] then
return nil, "unknown species: " .. tostring(species)
end
-- Pokemon.new writes the level through verbatim -- into level, the stat
-- calc and the exp curve -- so a fraction has to be refused here rather
-- than round somewhere downstream. The % test also catches NaN, which
-- passes both range comparisons.
level = tonumber(level)
if not level or level % 1 ~= 0 or level < 1 or level > 100 then
return nil, "level must be a whole number 1..100"
end
-- overworld() resolves the world from UNDER whatever sits on top of it,
-- so from a battle hook this would otherwise stack a second battle over
-- the live one -- and on a loss its afterBattle blacks out and warps
-- with the outer battle still on the stack.
local BattleTransition = require("src.render.BattleTransition")
for _, state in ipairs(self.game.stack and self.game.stack.states or {}) do
if state.awardExp or getmetatable(state) == BattleTransition then
return nil, "a battle is already running"
end
end
if ow.transitioning then return nil, "the world is mid-warp" end
-- BattleState.newWild marks the species SEEN before it reports an empty
-- party, so the party check comes first: a refused call must not leave a
-- Pokedex entry behind.
local save = self.game.save
if not (save and Party.firstHealthy(save.party or {})) then
return nil, "no healthy party"
end
local battle = require("src.battle.BattleState")
.newWild(self.game, species, level)
battle.onFinish = function(result) ow:afterBattle(result, battle) end
ow:pushBattle(battle)
return true
end
-- drop a map's cached instance so the next load re-reads its record; when
-- it is the active map the world reloads around the player in place
function WorldAPI:invalidateMap(mapId)
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""ROM-free regression tests for source-build version detection and routing."""
from contextlib import redirect_stderr, redirect_stdout
from io import StringIO
from pathlib import Path
from types import SimpleNamespace
from unittest import TestCase, main, mock
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
import build_rom_data # noqa: E402
class BuildRomDataCliTest(TestCase):
def run_builder(self, sha1, *extra):
manifest = {"romSha1": sha1, "symbols": {}}
rom = SimpleNamespace(sha1=sha1)
with mock.patch.object(build_rom_data, "RomImage", return_value=rom), \
mock.patch.object(build_rom_data, "load_manifest", return_value=manifest), \
mock.patch.object(build_rom_data, "build") as build, \
mock.patch.object(build_rom_data.os, "makedirs"), \
redirect_stdout(StringIO()), redirect_stderr(StringIO()):
result = build_rom_data.main([
"--rom", "fixture.gb", "--only", "constants", *extra])
return result, build
def test_blue_rom_selects_blue_manifest_and_cache_paths(self):
result, build = self.run_builder(
build_rom_data.CANONICAL_BLUE_SHA1)
self.assertEqual(result, 0)
args = build.call_args.args
self.assertEqual(args[3], "blue/data/generated")
self.assertEqual(args[4], "blue/assets/generated")
def test_red_rom_keeps_historical_root_paths(self):
result, build = self.run_builder(build_rom_data.CANONICAL_RED_SHA1)
self.assertEqual(result, 0)
args = build.call_args.args
self.assertEqual(args[3], "data/generated")
self.assertEqual(args[4], "assets/generated")
def test_explicit_output_paths_are_preserved(self):
result, build = self.run_builder(
build_rom_data.CANONICAL_YELLOW_SHA1,
"--out", "/tmp/custom-data", "--assets", "/tmp/custom-assets")
self.assertEqual(result, 0)
args = build.call_args.args
self.assertEqual(args[3], "/tmp/custom-data")
self.assertEqual(args[4], "/tmp/custom-assets")
def test_unknown_rom_is_rejected_before_build(self):
unknown = "0" * 40
result, build = self.run_builder(unknown)
self.assertEqual(result, 1)
build.assert_not_called()
if __name__ == "__main__":
main()
+51
View File
@@ -0,0 +1,51 @@
-- Driver (#889): export a .sav from a save that never came from a ROM import,
-- the case that used to write a save with no map context at all -- no map
-- header, no tileset header, sound id 0 -- so a real Game Boy continued into a
-- garbled map and hung on a white screen.
--
-- Saves the game in two places (an interior and an outdoor map with
-- connections), exports each, and prints the bytes the engine reads back on
-- Continue so a human can eyeball them, plus the export path to load in an
-- emulator.
return function(game)
local U = dofile("tests/drivers/util.lua")
local SaveFileIO = require("src.import.SaveFileIO")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local version = GameVersion.get()
local spots = {
{ "REDS_HOUSE_2F", 3, 6 },
{ "PALLET_TOWN", 5, 6 },
}
for _, spot in ipairs(spots) do
local map, x, y = spot[1], spot[2], spot[3]
U.teleport(game, map, x, y, "down")
U.wait(30)
-- the same sync the in-game SAVE does before writing (OverworldState:
-- captureSave), so the slot on disk holds the position we just walked to
local top = game.stack:top()
if top and top.captureSave then top:captureSave(game.save) end
SaveData.save(game.save)
local ok, pathOrErr = SaveFileIO.exportActiveSlot(version)
if not ok then
U.log(("export_sav_bug889: %s FAILED: %s"):format(map, tostring(pathOrErr)))
else
local bytes = love.filesystem.read(
("exports/%s/gen1recomp-%s-%s.sav"):format(
version, version, SaveData.activeSlot(version) or "save"))
local main = 0x2598 + 11
local function u8(off) return bytes:byte(main + off + 1) end
local hdr = {}
for i = 0, 9 do hdr[#hdr + 1] = ("%02X"):format(u8(112 + i)) end
U.log(("export_sav_bug889: %s -> %s"):format(map, pathOrErr))
U.log((" wCurMap=%02X wCurMapHeader=%s music=%02X/%02X tilesetBank=%02X"):
format(u8(103), table.concat(hdr, " "), u8(100), u8(101), u8(564)))
end
end
U.log("export_sav_bug889: done")
love.event.quit()
while true do coroutine.yield() end
end
+4 -2
View File
@@ -195,7 +195,7 @@ return function(game)
ow:queueScript(slice, { npc = rival })
local startY = ow.player.cellY
local minY, walkShot = startY, false
local minY, walkShot, sharedCell = startY, false, false
local hof
for i = 1, 5000 do
local top = game.stack:top()
@@ -205,8 +205,9 @@ return function(game)
end
local w = game.overworld
if w and w.map and w.map.id == "CHAMPIONS_ROOM" then
local y = w.player.cellY
local x, y = w.player.cellX, w.player.cellY
if y < minY then minY = y end
if x == rival.cellX and y == rival.cellY then sharedCell = true end
if y <= 2 and not walkShot then
walkShot = U.shot(game, DIR .. "/hof704_follows_oak.png")
end
@@ -215,6 +216,7 @@ return function(game)
end
check("the player walked out of the room before the warp (#704)",
minY < startY)
check("the player routed around the rival", not sharedCell)
check("walk-out screenshot", walkShot)
check("the induction started", hof ~= nil)
if not hof then
+6 -2
View File
@@ -55,8 +55,12 @@ return function(game)
U.wait(10)
-- the GAME SPEED forcing under test: a link session pins the logic clock
-- to 1X no matter what the option or POKEPORT_SPEED says
game.save.options.speed = 10
-- to 1X no matter what the option or POKEPORT_SPEED says. RFC 0007: set
-- all three per-category speeds high, since the link lock has to win over
-- every one of them, not just whichever category happens to be active.
game.save.options.speedOverworld = 10
game.save.options.speedBattle = 10
game.save.options.speedMenu = 10
U.wait(2)
log("logicSpeed with GAME SPEED=10 during link:", game:logicSpeed())
+5 -1
View File
@@ -55,7 +55,11 @@ return function(game)
local link = LinkState.new(game)
game.stack:push(link)
U.wait(10)
game.save.options.speed = 20
-- RFC 0007: set all three per-category speeds high, since the link lock
-- has to win over every one of them, not just whichever is active.
game.save.options.speedOverworld = 20
game.save.options.speedBattle = 20
game.save.options.speedMenu = 20
U.wait(2)
log("logicSpeed with GAME SPEED=20 during link:", game:logicSpeed())
@@ -46,7 +46,9 @@ return function(game)
os.getenv("POKEPORT_DRIVER") == nil)
check("no fast-forward multiplier is set",
(tonumber(os.getenv("POKEPORT_SPEED")) or 1) == 1
and (game.save.options.speed or 1) == 1)
and (game.save.options.speedOverworld or 1) == 1
and (game.save.options.speedBattle or 1) == 1
and (game.save.options.speedMenu or 1) == 1)
check("a window is up to watch (this is a visual call)",
love.window ~= nil and love.window.isOpen and love.window.isOpen())
U.log("MAX FPS reads", FrameCap.label(game.save.options.fpsCap),
+66
View File
@@ -0,0 +1,66 @@
-- ..(engine/movie/title.asm ln 28)
-- ..(engine/movie/title2.asm ln 13)
-- POKEPORT_DRIVER=tests/drivers/title_cycle_test.lua POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local shot = 0
local function grab(tag)
shot = shot + 1
U.shot(game, ("%s/title_%02d_%s.png"):format(DIR, shot, tag))
end
U.wait(30)
grab("copyright")
-- ..(engine/movie/splash.asm ln 230)
local movie = game.stack:top()
while movie.phase ~= 2 or movie.timer < 70 do U.wait(1) end
grab("star_topbar")
while movie.timer < 88 do U.wait(1) end
grab("star_middle")
while movie.timer < 100 do U.wait(1) end
grab("star_lowbar")
while movie.timer < 130 do U.wait(1) end
grab("gamefreak")
U.tap(game, "start")
U.wait(2)
local title = game.stack:top()
U.log("top is", tostring(title and title.screenId))
if not (title and title.scrollPhase) then
U.log("no TitleState on top; nothing below can run")
while true do coroutine.yield() end
end
grab("drop_early")
U.wait(14)
grab("drop_late")
while title.phase == "drop" do U.wait(1) end
grab("settle")
while title.phase == "settle" do U.wait(1) end
grab("ribbon_start")
U.wait(10)
U.shot(game, DIR .. "/title_ribbon_mid.png")
while title.phase ~= "loop" do U.wait(1) end
grab("landed")
title.cycleIndex = 1
title.scrollPhase, title.scrollFrame, title.timer = "hold", 1, 0
title.monOffset = 0
while title.scrollPhase == "hold" do U.wait(1) end
grab("out_a")
U.wait(6)
grab("out_b")
while title.scrollPhase == "out" do U.wait(1) end
U.log("after the scroll out the phase is", title.scrollPhase)
for _ = 1, 5 do
grab("ball")
U.wait(1)
end
while title.scrollPhase == "ball" do U.wait(1) end
grab("in")
U.wait(30)
grab("next_mon")
U.log("captured", DIR)
while true do coroutine.yield() end
end
+6 -2
View File
@@ -65,8 +65,12 @@ function U.newGame(game)
U.wait(5)
U.tap(game, "start") -- skip intro movie
U.wait(10)
U.tap(game, "a") -- title -> menu
U.wait(5)
local title = game.stack:top()
for _ = 1, 60 do
U.tap(game, "a")
U.wait(5)
if game.stack:top() ~= title then break end
end
-- menu: CONTINUE may or may not exist; NEW GAME is first without a save
U.tap(game, "a")
U.wait(10)
@@ -0,0 +1,85 @@
-- Battle checkpoints are exposed only at a settled, reconstructable player
-- decision boundary. This suite is ROM-free and exercises the public engine
-- checkpoint capability against the fixture battle implementation.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("battle checkpoint boundary")
local Fixtures = require("tests.modkit").fixtures
local BattleState = require("src.battle.BattleState")
local Checkpoint = require("src.core.Checkpoint")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local Data = Fixtures.fresh()
local function makeGame()
local save = SaveData.newGame()
save.meta.playthroughId = "battle-playthrough"
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
local stack = setmetatable({ states = {} }, { __index = StateStack })
local overworld = {
map = { id = save.player.map },
player = {
cellX = save.player.x, cellY = save.player.y,
facing = save.player.facing, surfing = false,
},
runner = { isRunning = function() return false end },
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
}
local game = { data = Data, save = save, stack = stack, overworld = overworld }
stack.states[1] = overworld
local battle = BattleState.newWild(game, "FIXMON_B", 12)
battle.phase = "menu"
battle.queue = {}
battle.checkpointOrigin = { kind = "wild_encounter" }
battle.onFinish = function() end
stack.states[2] = battle
return game, overworld, battle
end
local game, overworld, battle = makeGame()
T.same(Checkpoint.inspect(game), {
canCapture = true, canRestore = true, kind = "battle",
}, "settled standard wild battle is a checkpoint boundary")
local function refused(mutator, code, label)
local game2, ow2, battle2 = makeGame()
mutator(game2, ow2, battle2)
local capability = Checkpoint.inspect(game2)
T.check(capability.canCapture == false and capability.reason == code,
label .. ": " .. tostring(capability.reason))
end
refused(function(_, _, b) b.phase = "messages" end,
"battle_phase_busy", "message phase is rejected")
refused(function(_, _, b) b.queue = { { text = "busy" } } end,
"battle_phase_busy", "nonempty action queue is rejected")
refused(function(_, _, b) b.waitFrames = 1 end,
"battle_phase_busy", "partial wait is rejected")
refused(function(_, _, b) b.enemy.mon.hp = b.enemy.mon.hp - 1 end,
"battle_phase_busy", "unfinished HP display synchronization is rejected")
refused(function(_, _, b) b.player.mustRecharge = true end,
"battle_phase_busy", "automatic locked action is rejected")
refused(function(_, ow) ow.runner = { isRunning = function() return true end } end,
"script_busy", "suspended script beneath battle is rejected")
refused(function(_, _, b) b.checkpointOrigin = nil end,
"battle_origin_unsupported", "unknown completion closure is rejected")
refused(function(_, _, b) b.safari = { balls = 30, steps = 10 } end,
"battle_variant_unsupported", "Safari battle is rejected")
refused(function(_, _, b) b.ghost = true end,
"battle_variant_unsupported", "ghost battle is rejected")
refused(function(_, _, b) b.demo = true end,
"battle_variant_unsupported", "old-man demo is rejected")
refused(function(_, _, b) b.kind = "link" end,
"link_battle_unsupported", "link battle is rejected")
-- Ordinary overworld behavior remains unchanged by the battle branch.
game.stack.states[2] = nil
T.same(Checkpoint.inspect(game), {
canCapture = true, canRestore = true, kind = "overworld",
}, "settled overworld remains supported")
T.finish()
+168
View File
@@ -0,0 +1,168 @@
-- Data-only capture of a settled battle checkpoint, including deterministic
-- gameplay RNG and normalized object-reference sets.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("battle checkpoint capture")
local Fixtures = require("tests.modkit").fixtures
local BattleState = require("src.battle.BattleState")
local Checkpoint = require("src.core.Checkpoint")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local SaveSerializer = require("src.core.SaveSerializer")
local StateStack = require("src.core.StateStack")
local Data = Fixtures.fresh()
local oldGet, oldSet = love.math.getRandomState, love.math.setRandomState
local randomState = "fixture-rng-A"
love.math.getRandomState = function() return randomState end
love.math.setRandomState = function(state) randomState = state end
local function makeGame(kind)
local save = SaveData.newGame()
save.meta.playthroughId = "battle-playthrough"
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
save.party = {
Pokemon.new(Data, "FIXMON_A", 20),
Pokemon.new(Data, "FIXMON_C", 15),
}
local stack = setmetatable({ states = {} }, { __index = StateStack })
local overworld = {
map = { id = "FIX_TOWN" },
player = { cellX = 2, cellY = 3, facing = "left", surfing = false },
runner = { isRunning = function() return false end },
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
}
function overworld:captureSave(target)
target.player.map = self.map.id
target.player.x, target.player.y = self.player.cellX, self.player.cellY
target.player.facing = self.player.facing
target.player.surfing = self.player.surfing and true or false
end
local game = { data = Data, save = save, stack = stack, overworld = overworld }
stack.states[1] = overworld
local battle
if kind == "trainer" then
battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
battle.checkpointOrigin = {
kind = "trainer_encounter", map = "FIX_TOWN", npcId = "TRAINER_1",
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
event = "EVENT_BEAT_TRAINER_1",
}
else
battle = BattleState.newWild(game, "FIXMON_B", 12)
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
end
battle.phase, battle.queue = "menu", {}
battle.onFinish = function() end
stack.states[2] = battle
return game, battle
end
local game, battle = makeGame("wild")
battle.turnCount = 7
battle.runAttempts = 2
battle.payDay = 45
battle.player.stages.attack = 2
battle.player.confusedTurns = 3
battle.player.curTypes = { "FIRE", "FLYING" }
local originalMoveId = battle.player.curMoves[1].id
battle.player.curMoves[1].id = "FIX_CUT"
battle.player.curMoves[1].mimic = true
battle.mimicRestores = {
{ battler = battle.player, entry = battle.player.curMoves[1], id = originalMoveId },
}
battle.enemy.mon.hp = battle.enemy.mon.hp - 4
battle.enemy.shownHP = battle.enemy.mon.hp
battle.enemy.stages.defense = -1
battle.enemy.aiLayer2 = 1
battle.enemy.thrashMove = battle.enemy.curMoves[1]
battle.enemy.thrashTurns = 2
battle.participants = { [game.save.party[1]] = true,
[game.save.party[2]] = true }
battle.leveledUp = { [game.save.party[2]] = true }
battle.sideToxic = { enemy = 3 }
battle.sides[1].screens.reflect = { turns = 2 }
battle.field.weather = { id = "fixture-rain", turns = 4 }
local snapshot, code, message = Checkpoint.capture(game)
T.check(snapshot ~= nil, "settled battle captures: " .. tostring(code or message))
T.eq(snapshot and snapshot.kind, "battle", "checkpoint kind is battle")
if snapshot and snapshot.kind == "battle" then
T.eq(snapshot.rng.love, "fixture-rng-A", "LÖVE RNG state is captured")
T.same(snapshot.runtime.overworld,
{ map = "FIX_TOWN", x = 2, y = 3, facing = "left", surfing = false },
"return overworld point is captured")
T.same(snapshot.runtime.battle.origin,
{ kind = "wild_encounter", map = "FIX_TOWN" },
"semantic continuation origin is data-only")
T.eq(snapshot.runtime.battle.turnCount, 7, "turn count is captured")
T.eq(snapshot.runtime.battle.rulesetId, "gen1_faithful",
"battle mechanics ruleset identity is captured")
T.eq(snapshot.runtime.battle.runAttempts, 2, "escape attempts are captured")
T.eq(snapshot.runtime.battle.player.stages.attack, 2,
"player stat stages are captured")
T.eq(snapshot.runtime.battle.player.confusedTurns, 3,
"player volatile status is captured")
T.same(snapshot.runtime.battle.player.curTypes, { "FIRE", "FLYING" },
"transformed battle types are captured")
T.same(snapshot.runtime.battle.mimicRestores,
{ { side = "player", slot = 1, id = originalMoveId } },
"Mimic restore pointers normalize to side and move slot")
T.eq(snapshot.runtime.battle.enemy.stages.defense, -1,
"enemy stat stages are captured")
T.eq(snapshot.runtime.battle.enemy.aiLayer2, 1,
"enemy AI selection layer is captured")
T.eq(snapshot.runtime.battle.enemy.thrashMoveSlot, 1,
"move-instance references normalize to move slots")
T.eq(snapshot.runtime.battle.enemy.thrashMove, nil,
"live move-instance references are not serialized as detached copies")
T.same(snapshot.runtime.battle.participants, { 1, 2 },
"Pokemon-keyed participants normalize to party indices")
T.same(snapshot.runtime.battle.leveledUp, { 2 },
"Pokemon-keyed level-up set normalizes to party indices")
T.same(snapshot.runtime.battle.sides[1].screens.reflect, { turns = 2 },
"data-only side extensions are captured")
T.same(snapshot.runtime.battle.field.weather,
{ id = "fixture-rain", turns = 4 },
"data-only field extensions are captured")
local encoded = SaveSerializer.encode(snapshot)
T.check(type(encoded) == "string" and #encoded > 0,
"battle checkpoint passes the canonical data-only serializer")
snapshot.save.money = 1
snapshot.runtime.battle.player.stages.attack = -6
T.check(game.save.money ~= 1, "checkpoint progress is detached")
T.eq(battle.player.stages.attack, 2, "checkpoint battle state is detached")
end
local trainerGame, trainer = makeGame("trainer")
trainer.enemyIndex = 1
trainer.aiUses = 2
local trainerSnapshot = Checkpoint.capture(trainerGame)
T.eq(trainerSnapshot and trainerSnapshot.kind, "battle",
"ordinary trainer battle captures")
if trainerSnapshot and trainerSnapshot.kind == "battle" then
T.eq(trainerSnapshot.runtime.battle.oppClass, "OPP_FIX_YOUNGSTER",
"trainer class is captured")
T.eq(trainerSnapshot.runtime.battle.partyIndex, 1,
"trainer roster index is captured")
T.eq(#trainerSnapshot.runtime.battle.enemyParty, #trainer.enemyParty,
"complete enemy roster is captured")
end
local extensionGame, extensionBattle = makeGame("wild")
extensionBattle.field.tokens[1] = { id = "callback-token", onExpire = function() end }
local unsafe, unsafeCode = Checkpoint.capture(extensionGame)
T.check(unsafe == nil and unsafeCode == "battle_extension_unsafe",
"callback-bearing battle extensions are rejected, not stripped")
love.math.getRandomState = nil
local rngGame = makeGame("wild")
local noRng, rngCode = Checkpoint.capture(rngGame)
T.check(noRng == nil and rngCode == "rng_state_unavailable",
"battle capture fails closed without serializable gameplay RNG")
love.math.getRandomState, love.math.setRandomState = oldGet, oldSet
T.finish()
@@ -0,0 +1,102 @@
-- Engine-owned battle continuations replace unserializable onFinish closures
-- after a persistent checkpoint reconstructs the overworld and battle.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("battle checkpoint continuation")
local GameMethods = require("src.core.Game")
local OverworldState = require("src.world.OverworldController")
local function fakeOverworld()
local npc = { id = "FIX_TOWN_obj_1", frozen = true }
local ow = setmetatable({
map = { id = "FIX_TOWN" },
npcPool = { [npc.id] = npc },
engaging = true,
}, { __index = OverworldState })
ow.afterBattle = function(self, result, battle)
self.after = { result = result, battle = battle }
end
ow.checkVictoryRewards = function(self, class, party)
self.reward = { class = class, party = party }
end
return ow, npc
end
local wildOw = fakeOverworld()
local wildGame = { save = { defeatedTrainers = {}, flags = {} } }
local wild = { game = wildGame, kind = "wild" }
T.check(wildOw:restoreBattleContinuation(wild,
{ kind = "wild_encounter", map = "FIX_TOWN" }) == true,
"ordinary wild continuation binds")
wild.onFinish("run")
T.same(wildOw.after, { result = "run", battle = wild },
"wild continuation returns through canonical afterBattle")
local trainerOw, trainerNpc = fakeOverworld()
local trainerGame = { save = { defeatedTrainers = {}, flags = {} } }
local trainer = {
game = trainerGame, kind = "trainer",
oppClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
}
local trainerOrigin = {
kind = "trainer_encounter", map = "FIX_TOWN",
npcId = trainerNpc.id, trainerClass = trainer.oppClass, partyIndex = 1,
event = "EVENT_BEAT_FIX_TRAINER",
}
T.check(trainerOw:restoreBattleContinuation(trainer, trainerOrigin) == true,
"ordinary trainer continuation binds")
trainer.onFinish("win")
T.check(trainerGame.save.defeatedTrainers[trainerNpc.id] == true,
"trainer win stamps the stable object id")
T.check(trainerGame.save.flags.EVENT_BEAT_FIX_TRAINER == true,
"trainer win stamps the header event")
T.same(trainerOw.reward,
{ class = "OPP_FIX_YOUNGSTER", party = 1 },
"trainer win runs canonical victory rewards")
T.same(trainerOw.after, { result = "win", battle = trainer },
"trainer win returns through canonical afterBattle")
T.check(trainerOw.engaging == false and trainerNpc.frozen == false,
"reconstructed trainer completion leaves overworld input unfrozen")
local lossOw, lossNpc = fakeOverworld()
local lossGame = { save = { defeatedTrainers = {}, flags = {} } }
local lossBattle = {
game = lossGame, kind = "trainer",
oppClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
}
T.check(lossOw:restoreBattleContinuation(lossBattle, trainerOrigin) == true,
"trainer loss continuation binds")
lossBattle.onFinish("lose")
T.eq(lossGame.save.defeatedTrainers[lossNpc.id], nil,
"trainer loss does not stamp the trainer defeated")
T.eq(lossGame.save.flags.EVENT_BEAT_FIX_TRAINER, nil,
"trainer loss does not stamp the header event")
T.eq(lossOw.reward, nil, "trainer loss does not grant victory rewards")
local mismatchOw = fakeOverworld()
T.check(mismatchOw:restoreBattleContinuation(trainer, {
kind = "trainer_encounter", map = "OTHER_MAP", npcId = trainerNpc.id,
trainerClass = trainer.oppClass, partyIndex = 1,
}) == false, "continuation from another map is rejected")
T.check(mismatchOw:restoreBattleContinuation(trainer, {
kind = "trainer_encounter", map = "FIX_TOWN", npcId = trainerNpc.id,
trainerClass = "OPP_OTHER", partyIndex = 1,
}) == false, "mismatched trainer identity is rejected")
local ow = {}
local stack = { states = { ow } }
function stack:top() return self.states[#self.states] end
local game = setmetatable({ overworld = ow, stack = stack }, { __index = GameMethods })
local entered, resumed = false, false
local battle = {
enter = function() entered = true end,
resumeCheckpoint = function() resumed = true end,
}
game:restoreCheckpointBattle(battle)
T.check(game.stack:top() == battle, "reconstructed battle is installed on stack")
T.check(resumed == true, "checkpoint-specific battle resume path runs")
T.check(entered == false, "ordinary battle intro is not replayed")
T.finish()
+315
View File
@@ -0,0 +1,315 @@
-- A battle checkpoint reconstructs a new controller from data, rather than
-- retaining the original table/closure, and restores gameplay RNG exactly.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("battle checkpoint restore")
local Fixtures = require("tests.modkit").fixtures
local BattleState = require("src.battle.BattleState")
local Checkpoint = require("src.core.Checkpoint")
local Damage = require("src.battle.Damage")
local Encounter = require("src.world.Encounter")
local GameMethods = require("src.core.Game")
local Music = require("src.core.Music")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local SaveSerializer = require("src.core.SaveSerializer")
local StateStack = require("src.core.StateStack")
local TrainerAI = require("src.battle.TrainerAI")
local Data = Fixtures.fresh()
local oldRandom = love.math.random
local oldGet, oldSet = love.math.getRandomState, love.math.setRandomState
local oldPlayBattle = Music.playBattle
Music.playBattle = function() end
local rng = 12345
love.math.getRandomState = function() return tostring(rng) end
love.math.setRandomState = function(state) rng = assert(tonumber(state)) end
love.math.random = function(a, b)
rng = (rng * 1103515245 + 12345) % 2147483648
local unit = rng / 2147483648
if a == nil then return unit end
if b == nil then return math.floor(unit * a) + 1 end
return a + math.floor(unit * (b - a + 1))
end
local function makeGame(kind)
local save = SaveData.newGame()
save.meta.playthroughId = "battle-playthrough"
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
save.player.facing, save.player.surfing = "left", false
save.party = {
Pokemon.new(Data, "FIXMON_A", 20),
Pokemon.new(Data, "FIXMON_C", 15),
}
-- Strip new-game defaults that are intentionally absent from the tiny
-- fixture registry, then place the sanitized save on a fixture map.
SaveData.validate(save, Data)
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
save.player.facing, save.player.surfing = "left", false
local stack = setmetatable({ states = {} }, { __index = StateStack })
local overworld = {
map = { id = "FIX_TOWN" },
player = { cellX = 2, cellY = 3, facing = "left", surfing = false },
runner = { isRunning = function() return false end },
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
}
function overworld:captureSave(target)
target.player.map = self.map.id
target.player.x, target.player.y = self.player.cellX, self.player.cellY
target.player.facing = self.player.facing
target.player.surfing = self.player.surfing and true or false
end
function overworld:restoreBattleContinuation(battle, origin)
battle.onFinish = function(result)
self.lastRestoredFinish = { result = result, origin = origin.kind }
end
return true
end
local game = setmetatable(
{ data = Data, save = save, stack = stack, overworld = overworld },
{ __index = GameMethods })
function game:restoreCheckpointSave(loaded)
self.save = loaded
self.overworld.map = { id = loaded.player.map }
self.overworld.player = {
cellX = loaded.player.x, cellY = loaded.player.y,
facing = loaded.player.facing,
surfing = loaded.player.surfing and true or false,
}
self.overworld.runner = { isRunning = function() return false end }
self.overworld.parallelRunners, self.overworld.pendingScripts = {}, {}
self.overworld.parallelQueue, self.overworld.scriptMoves = {}, {}
self.stack.states = { self.overworld }
end
stack.states[1] = overworld
local battle
if kind == "trainer" then
battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
battle.checkpointOrigin = {
kind = "trainer_encounter", map = "FIX_TOWN", npcId = "TRAINER_1",
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
event = "EVENT_BEAT_TRAINER_1",
}
else
battle = BattleState.newWild(game, "FIXMON_B", 12)
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
end
battle.phase, battle.queue = "menu", {}
battle.musicKind = battle:computeMusicKind()
battle.onFinish = function() end
stack.states[2] = battle
return game, battle
end
local function settleOverworld(game)
game.stack.states = { game.overworld }
game.save.money = 999999
game.save.party[1].hp = 1
game.overworld.player.cellX = 8
game.overworld.player.facing = "up"
end
local game, originalBattle = makeGame("wild")
originalBattle.turnCount = 4
originalBattle.runAttempts = 1
originalBattle.player.stages.speed = 3
local originalMoveId = originalBattle.player.curMoves[1].id
originalBattle.player.curMoves[1].id = "FIX_CUT"
originalBattle.player.curMoves[1].mimic = true
originalBattle.mimicRestores = {
{ battler = originalBattle.player, entry = originalBattle.player.curMoves[1],
id = originalMoveId },
}
originalBattle.enemy.mon.hp = originalBattle.enemy.mon.hp - 5
originalBattle.enemy.shownHP = originalBattle.enemy.mon.hp
originalBattle.enemy.disabledSlot = 1
originalBattle.enemy.disabledTurns = 2
originalBattle.enemy.aiLayer2 = 1
originalBattle.enemy.thrashTurns = 2
originalBattle.enemy.mon.moves = {
{ id = "FIX_SCRATCH", pp = 35 }, { id = "FIX_CUT", pp = 30 },
}
originalBattle.enemy.curMoves = originalBattle.enemy.mon.moves
originalBattle.enemy.thrashMove = originalBattle.enemy.curMoves[1]
originalBattle.participants = { [game.save.party[1]] = true }
local checkpoint = assert(Checkpoint.capture(game))
local encounterDef = { grass = {
rate = 256, buckets = { 128, 256 },
slots = { { species = "FIXMON_A", level = 4 },
{ species = "FIXMON_B", level = 7 } },
} }
Encounter.load(Data)
local function randomOutcomes(battle)
local damage, detail = Damage.compute(battle.ruleset, battle.player,
battle.enemy, Data.moves.FIX_CUT, { rng = battle.rng })
local hit = Damage.accuracyRoll(battle.ruleset, Data.moves.FIX_CUT,
battle.player, battle.enemy, battle.rng)
local ai = TrainerAI.chooseMove(battle.enemy, battle.rng, nil)
local escaped = battle:runRollVanilla(1, 100)
local encounter = Encounter.roll(encounterDef, love.math.random)
local nextValue = love.math.random(1, 1000000)
return {
damage = damage, critical = detail.crit, hit = hit,
ai = ai.id, escaped = escaped, encounter = encounter,
nextValue = nextValue,
}
end
local expectedOutcomes = randomOutcomes(originalBattle)
settleOverworld(game)
game.save.options.ruleset = "modern_clean"
rng = 777
local restored, code, message = Checkpoint.restore(game, checkpoint)
T.check(restored == true, "battle checkpoint restores: " .. tostring(message or code))
local rebuilt = restored and game.stack:top()
if restored then
T.check(rebuilt ~= originalBattle, "restore creates a new battle controller")
T.eq(getmetatable(rebuilt), BattleState, "restored stack top is a BattleState")
T.eq(rebuilt.phase, "menu", "restored battle resumes at the decision menu")
T.eq(#rebuilt.queue, 0, "restored battle has no stale action queue")
T.eq(rebuilt.turnCount, 4, "turn count roundtrips")
T.eq(rebuilt.runAttempts, 1, "escape state roundtrips")
T.eq(rebuilt.player.stages.speed, 3, "player stages roundtrip")
T.check(rebuilt.mimicRestores and rebuilt.mimicRestores[1]
and rebuilt.mimicRestores[1].battler == rebuilt.player
and rebuilt.mimicRestores[1].entry == rebuilt.player.curMoves[1],
"Mimic restore references are rebuilt against the new battler")
rebuilt:restoreMimicked(rebuilt.player)
T.eq(rebuilt.player.curMoves[1].id, originalMoveId,
"restored Mimic move returns to its canonical id when battle copy leaves")
T.eq(rebuilt.player.curMoves[1].mimic, nil,
"restored Mimic marker clears with the battle copy")
-- Put the checkpointed battle state back before differential recapture.
rebuilt.player.curMoves[1].id = "FIX_CUT"
rebuilt.player.curMoves[1].mimic = true
rebuilt.mimicRestores = {
{ battler = rebuilt.player, entry = rebuilt.player.curMoves[1], id = originalMoveId },
}
T.eq(rebuilt.enemy.disabledSlot, 1, "enemy volatile state roundtrips")
T.eq(rebuilt.enemy.disabledTurns, 2, "enemy volatile duration roundtrips")
T.eq(rebuilt.enemy.aiLayer2, 1, "enemy AI selection layer roundtrips")
T.check(rebuilt.enemy.thrashMove == rebuilt.enemy.curMoves[1],
"multi-turn move references rebuild against the new move list")
T.eq(rebuilt.enemy.mon.hp, checkpoint.runtime.battle.enemyMon.hp,
"enemy Pokemon model roundtrips")
T.eq(game.save.money, checkpoint.save.money, "persistent progress roundtrips")
T.eq(game.save.party[1].hp, checkpoint.save.party[1].hp,
"party model roundtrips")
T.eq(game.save.options.ruleset, "modern_clean",
"current global ruleset option remains untouched")
T.check(rebuilt.ruleset == require("src.battle.rulesets.gen1_faithful"),
"restored battle keeps the mechanics ruleset it was captured with")
T.same(Checkpoint.capture(game), checkpoint,
"capture A, discard, restore A, capture A2 yields normalized A == A2")
local replayed = randomOutcomes(rebuilt)
T.same(replayed, expectedOutcomes,
"damage, critical, accuracy, AI, escape, encounter and next RNG replay exactly")
rebuilt.onFinish("run")
T.same(game.overworld.lastRestoredFinish,
{ result = "run", origin = "wild_encounter" },
"restored battle receives a reconstructed semantic continuation")
end
local partyGame, switchedOriginal = makeGame("wild")
partyGame.save.party[1].hp = 0
local activeMon = partyGame.save.party[2]
activeMon.status = "PAR"
activeMon.moves[1].pp = activeMon.moves[1].pp - 4
switchedOriginal.player = BattleState.makeBattler(
Data, activeMon, true, partyGame.save)
switchedOriginal.sides[1].battlers = { switchedOriginal.player }
switchedOriginal.participants = {
[partyGame.save.party[1]] = true,
[partyGame.save.party[2]] = true,
}
local partyCheckpoint = assert(Checkpoint.capture(partyGame))
settleOverworld(partyGame)
partyGame.save.party[2].status = nil
partyGame.save.party[2].moves[1].pp = 1
restored, code, message = Checkpoint.restore(partyGame, partyCheckpoint)
T.check(restored == true,
"switched/status/PP checkpoint restores: " .. tostring(message or code))
local partyRebuilt = partyGame.stack:top()
if restored then
T.eq(partyGame.save.party[1].hp, 0,
"fainted non-active party member roundtrips")
T.check(partyRebuilt.player.mon == partyGame.save.party[2],
"switched active Pokemon reconstructs against restored party identity")
T.eq(partyRebuilt.player.mon.status, "PAR", "active status roundtrips")
T.eq(partyRebuilt.player.mon.moves[1].pp,
partyCheckpoint.save.party[2].moves[1].pp, "reduced PP roundtrips")
T.check(partyRebuilt.participants[partyGame.save.party[1]] == true
and partyRebuilt.participants[partyGame.save.party[2]] == true,
"participant references rebuild against fainted and active party members")
T.same(Checkpoint.capture(partyGame), partyCheckpoint,
"switch, faint, status and PP differential recapture is exact")
end
local trainerGame, trainerOriginal = makeGame("trainer")
trainerOriginal.turnCount = 6
trainerOriginal.enemy.mon.hp = trainerOriginal.enemy.mon.hp - 3
trainerOriginal.enemy.shownHP = trainerOriginal.enemy.mon.hp
trainerOriginal.aiUses = 1
trainerOriginal.participants = { [trainerGame.save.party[1]] = true,
[trainerGame.save.party[2]] = true }
local trainerCheckpoint = assert(Checkpoint.capture(trainerGame))
settleOverworld(trainerGame)
restored, code, message = Checkpoint.restore(trainerGame, trainerCheckpoint)
T.check(restored == true, "trainer checkpoint restores: " .. tostring(message or code))
local trainerRebuilt = trainerGame.stack:top()
if restored then
T.check(trainerRebuilt ~= trainerOriginal,
"trainer restore is independent of the original controller")
T.eq(trainerRebuilt.oppClass, "OPP_FIX_YOUNGSTER", "trainer class roundtrips")
T.eq(trainerRebuilt.enemyIndex, 1, "enemy roster index roundtrips")
T.eq(trainerRebuilt.aiUses, 1, "trainer AI item budget roundtrips")
T.same(Checkpoint.capture(trainerGame), trainerCheckpoint,
"trainer differential recapture is exact")
end
local function clone(value)
return assert(SaveSerializer.decode(SaveSerializer.encode(value)))
end
local beforeRejected = assert(Checkpoint.capture(trainerGame))
local missingSpecies = clone(trainerCheckpoint)
missingSpecies.runtime.battle.enemyParty[1].species = "MISSING_SPECIES"
restored, code = Checkpoint.restore(trainerGame, missingSpecies)
T.check(restored == false and code == "invalid_content",
"unknown battle content is rejected before mutation")
T.same(Checkpoint.capture(trainerGame), beforeRejected,
"rejected battle content leaves runtime and RNG unchanged")
local badOrigin = clone(trainerCheckpoint)
badOrigin.runtime.battle.origin.npcId = nil
restored, code = Checkpoint.restore(trainerGame, badOrigin)
T.check(restored == false and code == "battle_origin_unsupported",
"incomplete semantic continuation is rejected before mutation")
T.same(Checkpoint.capture(trainerGame), beforeRejected,
"rejected continuation leaves runtime and RNG unchanged")
-- Fail after the new battle has been installed, when its RNG is applied.
-- The transaction must reconstruct the prior battle and restore its RNG.
local workingSetRandomState = love.math.setRandomState
local setCalls = 0
love.math.setRandomState = function(state)
setCalls = setCalls + 1
if setCalls == 1 then error("injected RNG restore failure") end
return workingSetRandomState(state)
end
local beforeFailure = assert(Checkpoint.capture(trainerGame))
local rngBeforeFailure = rng
restored, code = Checkpoint.restore(trainerGame, trainerCheckpoint)
T.check(restored == false and code == "restore_failed",
"post-install RNG failure is returned as a structured restore failure")
T.eq(rng, rngBeforeFailure, "failed battle restore rolls RNG back exactly")
T.same(Checkpoint.capture(trainerGame), beforeFailure,
"failed battle restore rolls the complete runtime back exactly")
love.math.setRandomState = workingSetRandomState
love.math.random = oldRandom
love.math.getRandomState, love.math.setRandomState = oldGet, oldSet
Music.playBattle = oldPlayBattle
T.finish()
+103
View File
@@ -0,0 +1,103 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
love = require("tests.love_stub")
local ChipAsm = require("src.audio.ChipAsm")
local ChipSynth = require("src.core.ChipSynth")
local data = { audio = {} }
local function pulseSong()
return ChipAsm.song{
channels = { { hw = 1, program = {
{ duty = 2 },
{ notetype = { speed = 12, volume = 15, fade = 0 } },
{ octave = 4 },
{ note = "C", len = 15 },
} } },
}
end
local function noiseSong()
return ChipAsm.sfx{
channels = { { hw = 4, program = {
{ noiseNote = { len = 8, volume = 15, fade = 1, parameter = 0x34 } },
} } },
}
end
do
local engine = ChipSynth.newEngine(data, pulseSong(), { allowLoops = false })
local sawNeg, sawPos = false, false
for _ = 1, 512 do
local v = engine.channels[1]:sample()
if v < -1e-12 then sawNeg = true end
if v > 1e-12 then sawPos = true end
end
check(sawPos and not sawNeg,
"pulse DAC is unipolar (high = volume, low = 0)")
end
do
local engine = ChipSynth.newEngine(data, noiseSong(), {
sfx = true, allowLoops = false,
})
local sawNeg, sawPos = false, false
for _ = 1, 2048 do
local v = engine.channels[1]:sample()
if v < -1e-12 then sawNeg = true end
if v > 1e-12 then sawPos = true end
end
check(sawPos and not sawNeg,
"noise DAC is unipolar (LFSR high = volume, low = 0)")
end
local function crossingsAndSign(engine, frames)
local count, prev = 0, nil
local sawNeg, sawPos = false, false
for _ = 1, frames do
local sample = engine:sample()
if sample < -1e-12 then sawNeg = true end
if sample > 1e-12 then sawPos = true end
if prev and prev * sample < 0 then count = count + 1 end
prev = sample
end
return count, sawNeg, sawPos
end
do
local engine = ChipSynth.newEngine(data, pulseSong(), { allowLoops = false })
local count, sawNeg, sawPos = crossingsAndSign(engine, 4000)
check(sawNeg and sawPos, "HPF centers a unipolar pulse around analog 0")
check(count > 20, ("HPF'd pulse crosses zero (%d crossings)"):format(count))
end
do
local engine = ChipSynth.newEngine(data, noiseSong(), {
sfx = true, allowLoops = false,
})
local count, sawNeg, sawPos = crossingsAndSign(engine, 8000)
check(sawNeg and sawPos, "HPF centers noise / drums around analog 0")
check(count > 50, ("HPF'd noise crosses zero (%d crossings)"):format(count))
end
do
local song = pulseSong()
ChipSynth.setChannelVolumes({ 1, 1, 1, 1 })
local a = ChipSynth.newEngine(data, song, { allowLoops = false })
local base = a.channels[1]:sample()
ChipSynth.setChannelVolume(1, 0.25)
local b = ChipSynth.newEngine(data, song, { allowLoops = false })
local quarter = b.channels[1]:sample()
ChipSynth.setChannelVolumes({ 1, 1, 1, 1 })
check(base > 0 and math.abs(quarter - base * 0.25) < 1e-9,
"channelVolume still quarters the unipolar DAC level")
end
eq(type(ChipSynth.newEngine), "function", "engine factory still exported")
T.finish("chip analog path")
+82
View File
@@ -0,0 +1,82 @@
-- ..(audio/engine_1.asm ln 197)
-- ..(audio/sfx/noise_instrument01_1.asm ln 1)
-- luajit tests/engine/drum_envelope_ring.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
love = require("tests.love_stub")
local ChipAsm = require("src.audio.ChipAsm")
local ChipSynth = require("src.core.ChipSynth")
local snare = ChipAsm.song{
channels = {
{ hw = 4, program = {
{ notetype = { speed = 12 } },
{ drum = 1, len = 2 },
{ rest = 16 },
} },
},
drums = {
[1] = {
{ len = 1, volume = 12, fade = 1, parameter = 0x33 },
},
},
}
local engine = ChipSynth.newEngine({ audio = {} }, snare, { allowLoops = false })
local segs = engine:noiseInstrument(1)
local last = segs[#segs]
local ringMs = (last.endSample - last.startSample) / ChipSynth.SAMPLE_RATE * 1000
check(ringMs > 150 and ringMs < 220,
("snare instrument rings ~188ms, not the 17ms note (%0.1fms)"):format(ringMs))
local energyEarly, energyLate, energyEnd = 0, 0, 0
local total = math.floor(ChipSynth.SAMPLE_RATE * 0.25)
for i = 1, total do
local s = engine:sample()
local e = s * s
local t = i / ChipSynth.SAMPLE_RATE
if t < 0.02 then
energyEarly = energyEarly + e
elseif t > 0.05 and t < 0.12 then
energyLate = energyLate + e
elseif t > 0.20 then
energyEnd = energyEnd + e
end
end
check(energyEarly > 0, "snare attack is audible")
check(energyLate > energyEarly * 0.05,
("snare body still sounds at 50-120ms (early=%.4f late=%.4f)")
:format(energyEarly, energyLate))
check(energyEnd < energyLate * 0.1,
"snare has decayed by 200ms")
local hats = ChipAsm.song{
channels = {
{ hw = 4, program = {
{ notetype = { speed = 12 } },
{ drum = 1, len = 2 },
{ drum = 1, len = 2 },
} },
},
drums = {
[1] = {
{ len = 1, volume = 8, fade = 1, parameter = 0x10 },
},
},
}
local hatEngine = ChipSynth.newEngine({ audio = {} }, hats, { allowLoops = false })
local hits = 0
local prev = 0
for _ = 1, math.floor(ChipSynth.SAMPLE_RATE * 0.3) do
local s = math.abs(hatEngine:sample())
if prev < 0.01 and s >= 0.01 then hits = hits + 1 end
prev = s
end
check(hits >= 2, ("two rapid drum_notes both trigger (%d onsets)"):format(hits))
T.finish("drum envelope ring")
+196
View File
@@ -0,0 +1,196 @@
-- Per-category GAME SPEED (RFC 0007): Game.speedCategoryInStack's stack
-- walk, Game:logicSpeed()'s precedence (link lock / run-argument override /
-- the core.logic_speed hook), Game:_cycleSpeed's per-category cycling, and
-- the core.logic_speed hook itself exercised through the public mod API
-- (Hooks.new() + bus:wrap, the same idiom other hooks' tests use -- not a
-- private require).
-- luajit tests/engine/game_speed_categories_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local check, eq = T.check, T.eq
local Game = require("src.core.Game")
local GameSpeed = require("src.core.GameSpeed")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")
-- ------- Game.speedCategoryInStack: the whole-stack walk
local function stack(...) return { states = { ... } } end
local battle = { isBattle = true }
local overworld = { isOverworld = true }
local overlay = {} -- a party menu/choice box/naming screen/text box: no marker
eq(Game.speedCategoryInStack(nil), "menu", "a nil stack falls to menu")
eq(Game.speedCategoryInStack(stack()), "menu", "an empty stack falls to menu")
eq(Game.speedCategoryInStack(stack(overlay)), "menu",
"an unmarked state alone (title screen, credits, a standalone cutscene) is menu")
eq(Game.speedCategoryInStack(stack(overworld)), "overworld",
"the overworld alone resolves to overworld")
eq(Game.speedCategoryInStack(stack(battle)), "battle",
"a battle alone resolves to battle")
eq(Game.speedCategoryInStack(stack(overworld, overlay)), "overworld",
"a menu opened while walking inherits overworld")
eq(Game.speedCategoryInStack(stack(battle, overlay)), "battle",
"a menu opened mid-battle inherits battle, not menu")
eq(Game.speedCategoryInStack(stack(overworld, overlay, overlay)), "overworld",
"the inheritance walk sees through more than one stacked overlay")
eq(Game.speedCategoryInStack(stack(overworld, battle)), "battle",
"a battle opened over the overworld reads as battle, not the overworld underneath it")
eq(Game.speedCategoryInStack(stack(overworld, battle, overlay)), "battle",
"and a menu on top of THAT still reads as battle")
-- ------- Game:_resolveLogicSpeed: category -> save.options key -> clamp
local unpack = table.unpack or unpack
local function gameWith(states, options)
return setmetatable({
save = { options = options },
stack = stack(unpack(states or {})),
}, { __index = Game })
end
do
local g = gameWith({ overworld },
{ speedOverworld = 4, speedBattle = 10, speedMenu = 2 })
eq(g:_resolveLogicSpeed(), 4, "overworld reads speedOverworld")
end
do
local g = gameWith({ battle },
{ speedOverworld = 4, speedBattle = 10, speedMenu = 2 })
eq(g:_resolveLogicSpeed(), 10, "battle reads speedBattle")
end
do
local g = gameWith({ overlay },
{ speedOverworld = 4, speedBattle = 10, speedMenu = 2 })
eq(g:_resolveLogicSpeed(), 2, "menu reads speedMenu")
end
do
local g = gameWith({ overworld }, { speedOverworld = 7 })
eq(g:_resolveLogicSpeed(), GameSpeed.clamp(7),
"an odd value clamps to the nearest LEVELS entry, like the old single field")
end
do
local g = gameWith({ overworld }, nil)
eq(g:_resolveLogicSpeed(), GameSpeed.DEFAULT,
"no save.options at all defaults rather than erroring")
end
-- ------- Game:logicSpeed(): link and speedOverride win over every category
-- and over a hook override; the hook only ever sees the ordinary case
do
local g = gameWith({ battle }, { speedBattle = 50 })
g.linkSession = true
eq(g:logicSpeed(), 1, "an active link session forces 1X even at 50X battle")
end
do
local g = gameWith({ battle }, { speedBattle = 50 })
g.linkNet = { closed = false }
eq(g:logicSpeed(), 1, "an open linkNet forces 1X the same way")
end
do
local g = gameWith({ battle }, { speedBattle = 50 })
g.linkNet = { closed = true }
eq(g:logicSpeed(), 50, "a CLOSED linkNet does not force 1X")
end
do
local g = gameWith({ overworld }, { speedOverworld = 4 })
g.speedOverride = 20
eq(g:logicSpeed(), 20,
"speedOverride (--speed/POKEPORT_SPEED) wins over the category option")
end
do
local g = gameWith({ battle }, { speedBattle = 50 })
g.linkSession = true
local bus = Hooks.new()
local savedHooks = Runtime.hooks
Runtime.hooks = bus
local hookRan = false
local unsub = bus:wrap("core.logic_speed", function(next, game)
hookRan = true
return 999
end)
eq(g:logicSpeed(), 1,
"the link lock wins even over a mod's core.logic_speed override")
check(not hookRan, "...because the hook is never called during link play")
unsub()
Runtime.hooks = savedHooks
end
-- ------- core.logic_speed: the mod-API seam, driven through Runtime.call/
-- Hooks.new + bus:wrap like every other hook's public-API test
local function callLogicSpeed(g)
return Runtime.call("core.logic_speed",
function(gg) return gg:_resolveLogicSpeed() end, g)
end
do
local g = gameWith({ battle }, { speedBattle = 4 })
eq(callLogicSpeed(g), 4,
"with no subscriber, the hook returns the vanilla category resolution")
end
do
local g = gameWith({ overworld }, { speedOverworld = 4 })
local bus = Hooks.new()
local savedHooks = Runtime.hooks
Runtime.hooks = bus
local nextArg = nil
local unsub = bus:wrap("core.logic_speed", function(next, game)
nextArg = next(game)
return nextArg
end)
eq(callLogicSpeed(g), 4,
"a subscriber calling next(game) passes the vanilla value through")
eq(nextArg, 4, "...and next(game) itself returned the vanilla resolution")
unsub()
-- a bot mod forcing 1X for one route segment regardless of the category
unsub = bus:wrap("core.logic_speed", function(next, game) return 1 end)
eq(callLogicSpeed(g), 1,
"a subscriber may override the resolved multiplier outright")
unsub()
Runtime.hooks = savedHooks
end
-- ------- Game:_cycleSpeed: cycles whichever category is active, and only it
do
local writeOptions = { calls = 0 }
local g = gameWith({ battle },
{ speedOverworld = 1, speedBattle = 1, speedMenu = 1 })
function g:writeOptions() writeOptions.calls = writeOptions.calls + 1 end
g:_cycleSpeed(1)
eq(g.save.options.speedBattle, 2, "cycling during battle bumps speedBattle")
eq(g.save.options.speedOverworld, 1, "...and leaves speedOverworld alone")
eq(g.save.options.speedMenu, 1, "...and leaves speedMenu alone")
eq(writeOptions.calls, 1, "a successful cycle persists the option")
end
do
local g = gameWith({ overworld },
{ speedOverworld = 1, speedBattle = 1, speedMenu = 1 })
function g:writeOptions() end
g:_cycleSpeed(1)
eq(g.save.options.speedOverworld, 2, "cycling on the overworld bumps speedOverworld")
eq(g.save.options.speedBattle, 1, "...and leaves speedBattle alone")
end
do
local g = gameWith({ overlay },
{ speedOverworld = 1, speedBattle = 1, speedMenu = 1 })
function g:writeOptions() end
g:_cycleSpeed(1)
eq(g.save.options.speedMenu, 2, "cycling in a menu bumps speedMenu")
end
T.finish("game_speed_categories")
+345
View File
@@ -0,0 +1,345 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Net = require("src.link.Net")
local Json = require("src.link.Json")
local Session = require("src.link.Session")
local function sessionPair()
local hostNet, guestNet = Net.loopbackPair()
return Session.new(hostNet, { role = "host", kind = "link" }),
Session.new(guestNet, { role = "guest", kind = "link" })
end
local function fakeTransport(options)
options = options or {}
local transport = {
paired = options.paired ~= false,
closed = false,
error = nil,
inbox = options.inbox or {},
closeCount = 0,
}
function transport:update()
if options.onUpdate then options.onUpdate(self) end
if options.updateError then error(options.updateError) end
end
function transport:poll()
if options.pollError then error(options.pollError) end
local messages = self.inbox
self.inbox = {}
return messages
end
function transport:send(message)
self.sent = message
return true
end
function transport:close()
self.closeCount = self.closeCount + 1
self.closed = true
if options.closeError then error(options.closeError) end
end
return transport
end
local function readFile(path)
local handle = assert(io.open(path, "rb"))
local body = handle:read("*a")
handle:close()
return body
end
do
local host, guest = sessionPair()
T.eq(host:getRole(), "host", "host role is assigned locally")
T.eq(guest:getRole(), "guest", "guest role is assigned locally")
T.eq(host:getKind(), "link", "session kind is retained")
T.eq(host:getStatus(), "paired", "wrapped loopback starts paired")
guest:send({
type = "hello", name = "BLUE", role = "host", kind = "tournament",
})
host:update()
local hello = host:take("hello")
T.eq(hello.name, "BLUE", "send forwards the original payload")
T.eq(hello.session, nil, "send adds no session envelope")
T.eq(host:getRole(), "host", "peer payload cannot replace local role")
T.eq(host:getKind(), "link", "peer payload cannot replace local kind")
end
do
local host, guest = sessionPair()
guest:send({ type = "before", sequence = 1 })
guest:send({ type = "hello", sequence = 2 })
guest:send({ type = "after", sequence = 3 })
guest:send({ type = "hello", sequence = 4 })
host:update()
local hello = host:take("hello")
T.eq(hello.sequence, 2, "take removes the first matching packet")
T.eq(host:pollOne().sequence, 1, "pollOne removes only the FIFO head")
local rest = host:poll()
T.eq(#rest, 2, "poll returns every remaining packet once")
T.eq(rest[1].sequence, 3, "take preserves the earlier remainder order")
T.eq(rest[2].sequence, 4, "take preserves repeated-type order")
T.eq(#host:poll(), 0, "poll clears the private FIFO")
end
do
local sent
local transport = {
paired = false,
code = nil,
address = "192.0.2.5:7777",
target = "ROOM01",
update = function(self)
self.paired = true
self.code = "ROOM02"
end,
poll = function() return {} end,
send = function(_, message)
sent = message
return "queued", 7
end,
close = function(self) self.closed = true end,
}
local session = Session.new(transport, { role = "guest", kind = "tournament" })
T.eq(session:getStatus(), "connecting", "unpaired transport starts connecting")
T.eq(session.address, "192.0.2.5:7777", "address metadata is mirrored")
T.eq(session.target, "ROOM01", "target metadata is mirrored")
local outbound = { type = "ping" }
local result, count = session:send(outbound)
T.eq(result, "queued", "send preserves the transport's first return")
T.eq(count, 7, "send preserves the transport's second return")
T.eq(sent, outbound, "send forwards the original table unchanged")
session:update()
T.eq(session:getStatus(), "paired", "update observes transport pairing")
T.eq(session.code, "ROOM02", "update refreshes relay metadata")
end
do
local transport = fakeTransport({ onUpdate = function(self)
self.inbox[#self.inbox + 1] = { type = "bye", final = true }
self.closed = true
end })
local session = Session.new(transport, { role = "host", kind = "link" })
session:update()
T.eq(session:getStatus(), "draining", "normal close drains its final packet")
T.eq(session.closed, false, "compatibility closed waits for the FIFO")
T.eq(session:take("bye").final, true, "final close packet remains observable")
T.eq(session:getStatus(), "closed", "normal drain reaches closed")
T.eq(transport.closeCount, 1, "transport cleanup runs once")
end
do
local transport = fakeTransport({
onUpdate = function(self) self.closed = true end,
closeError = "normal cleanup exploded",
})
local session = Session.new(transport, { role = "host", kind = "link" })
T.check(pcall(session.update, session),
"normal-close cleanup exception does not escape the game loop")
local reason, detail = session:getFailure()
T.eq(reason, "transport_error",
"normal-close cleanup exception becomes a transport failure")
T.check(detail:find("normal cleanup exploded", 1, true) ~= nil,
"normal-close cleanup failure keeps its diagnostic detail")
T.eq(session:getStatus(), "failed",
"normal-close cleanup exception cannot report a clean close")
end
do
local transport = fakeTransport({ onUpdate = function(self)
self.inbox = { { type = "before", sequence = 1 } }
self.error = "socket failed"
self.closed = true
end })
local session = Session.new(transport, { role = "guest", kind = "link" })
session:update()
local reason, detail = session:getFailure()
T.eq(reason, "transport_error", "transport failure has a stable reason")
T.eq(detail, "socket failed", "transport failure retains original detail")
T.eq(session:getStatus(), "draining", "transport failure drains valid prefix")
T.eq(session.error, nil, "legacy error stays hidden during drain")
T.eq(session.closed, false, "legacy closed stays false during failed drain")
T.eq(session:pollOne().sequence, 1, "failed drain returns its valid prefix")
T.eq(session:getStatus(), "failed", "failed drain reaches failed")
T.eq(session.error, "socket failed", "legacy error appears at terminal failure")
transport.error = "later error"
session:update()
local _, latchedDetail = session:getFailure()
T.eq(latchedDetail, "socket failed", "first terminal failure stays latched")
end
do
local transport = fakeTransport({ inbox = {
{ type = "before", sequence = 1 },
false,
{ type = "after", sequence = 3 },
} })
local session = Session.new(transport, { role = "host", kind = "link" })
session:update()
local reason = session:getFailure()
T.eq(reason, "protocol_error", "malformed packet fails as protocol_error")
T.eq(session:getStatus(), "draining", "malformed batch drains valid prefix")
local messages = session:poll()
T.eq(#messages, 1, "malformed value and untrusted tail are not exposed")
T.eq(messages[1].sequence, 1, "valid prefix survives malformed packet")
T.eq(session:getStatus(), "failed", "protocol drain reaches failed")
end
do
local transport = fakeTransport({ inbox = { { type = 7 } } })
local session = Session.new(transport, { role = "host", kind = "link" })
session:update()
T.eq(session:getFailure(), "protocol_error",
"table without string type is a protocol error")
end
do
local transport = fakeTransport({
inbox = { { type = "future_world_packet", value = 9 } },
})
local session = Session.new(transport, { role = "host", kind = "link" })
session:update()
T.eq(session:pollOne().value, 9, "unknown typed packet stays mode-owned")
end
do
local transport = fakeTransport({
inbox = { { type = "already_decoded", value = 4 } },
updateError = "update exploded",
})
local session = Session.new(transport, { role = "host", kind = "link" })
local ok = pcall(session.update, session)
T.check(ok, "transport update exception does not escape the game loop")
T.eq(session:getStatus(), "draining", "update exception still drains prior inbox")
T.eq(session:pollOne().value, 4, "decoded packet survives update exception")
T.eq(session:getStatus(), "failed", "update exception becomes terminal failure")
end
do
local transport = fakeTransport({ pollError = "poll exploded" })
local session = Session.new(transport, { role = "host", kind = "link" })
T.check(pcall(session.update, session),
"transport poll exception does not escape the game loop")
local reason, detail = session:getFailure()
T.eq(reason, "transport_error", "poll exception is a transport failure")
T.check(detail:find("poll exploded", 1, true) ~= nil,
"poll exception keeps its diagnostic detail")
end
do
local transport = fakeTransport({ closeError = "close exploded" })
local session = Session.new(transport, { role = "guest", kind = "link" })
T.check(pcall(session.close, session),
"transport close exception does not escape cleanup")
T.eq(session:getFailure(), "transport_error",
"close exception is a transport failure")
session:close()
T.eq(transport.closeCount, 1, "failed close is still attempted only once")
end
do
local transport = fakeTransport()
local session = Session.new(transport, { role = "guest", kind = "link" })
session:close()
session:close()
session:update()
T.eq(transport.closeCount, 1, "close and post-terminal update are idempotent")
T.eq(session:getStatus(), "closed", "explicit close reaches closed")
end
do
T.check(not pcall(Session.new, nil, { role = "host", kind = "link" }),
"constructor rejects missing transport")
local transport = fakeTransport()
T.check(not pcall(Session.new, transport, { role = "leader", kind = "link" }),
"constructor rejects unsupported role")
T.check(not pcall(Session.new, transport, { role = "host", kind = "" }),
"constructor rejects empty kind")
end
do
local senderNet, receiverNet = Net.loopbackPair()
local receiver = Session.new(receiverNet, { role = "guest", kind = "link" })
senderNet:send(false)
receiver:update()
T.eq(receiver:getFailure(), "protocol_error",
"loopback forwards decoded false to session validation")
end
do
local delivered = false
local transport = Net.new()
transport.enetHost = {
service = function()
if delivered then return nil end
delivered = true
return { type = "receive", data = "false" }
end,
}
local session = Session.new(transport, { role = "guest", kind = "link" })
session:update()
T.eq(session:getFailure(), "protocol_error",
"ENet forwards decoded false to session validation")
end
do
local transport = Net.new()
local session = Session.new(transport, { role = "host", kind = "tournament" })
T.check(pcall(transport.handleTCPLine, transport, "42"),
"TCP control handoff does not index a decoded scalar")
session:update()
T.eq(session:getFailure(), "protocol_error",
"decoded TCP scalar reaches session validation")
end
do
local transport = Net.new()
transport:handleTCPLine(Json.encode({ type = "hosted", code = "ABCDEF" }))
T.eq(transport.code, "ABCDEF", "valid relay controls stay transport-owned")
transport:handleTCPLine(Json.encode({ type = "hello", name = "RED" }))
T.eq(transport:poll()[1].name, "RED", "valid application packet stays intact")
end
do
local source = readFile("src/link/LinkState.lua")
T.check(source:find('require("src.link.Session")', 1, true) ~= nil,
"LinkState depends on the session boundary")
T.check(source:find('kind = "link"', 1, true) ~= nil,
"LinkState assigns the link session kind locally")
T.check(source:find("self.net.inbox", 1, true) == nil,
"LinkState never mutates a transport inbox")
T.check(source:find("self.net = Net.new()", 1, true) == nil,
"LinkState stores only successful session wrappers")
T.check(source:find(':take("hello")', 1, true) ~= nil,
"LinkState retrieves hello without draining unrelated packets")
T.check(source:find(':take("party")', 1, true) ~= nil,
"LinkState leaves battle handoff packets in session order")
T.check(source:find("getStatus()", 1, true) ~= nil,
"LinkState uses the session lifecycle instead of raw terminal flags")
end
do
local source = readFile("src/link/Tournament.lua")
T.check(source:find('require("src.link.Session")', 1, true) ~= nil,
"Tournament depends on the session boundary")
T.check(source:find('kind = "tournament"', 1, true) ~= nil,
"Tournament assigns its connection role and kind locally")
T.check(source:find("self.net.inbox", 1, true) == nil,
"Tournament never mutates a transport inbox")
T.check(source:find("self.net = Net.new()", 1, true) == nil,
"Tournament stores only a successful session wrapper")
T.check(source:find(':take("hello")', 1, true) ~= nil,
"Tournament retrieves match hello without draining its tail")
T.check(source:find(":pollOne()", 1, true) ~= nil,
"Tournament processes handoff prefixes one packet at a time")
T.check(source:find("getStatus()", 1, true) ~= nil,
"Tournament uses the session lifecycle instead of raw terminal flags")
end
T.finish("link_session")

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