Compare commits

..

43 Commits

Author SHA1 Message Date
github-actions 7b77dbe3ea chore(ios): update app-repo.json [skip ci] 2026-08-25 16:20:40 -04:00
bryanthaboi 0c6c813e87 Merge pull request #1816 from bryanthaboi/dev
fixes and stuff
2026-08-25 16:04:42 -04:00
bryanthaboi a6c914ecad Update video link and thumbnail in README 2026-08-25 16:02:55 -04:00
bryanthaboi 1f12e4838f Merge pull request #1791 from HighDrexler/fix/mobile-required-import-streaming-v2
Fix/mobile required import streaming v2
2026-08-25 15:55:34 -04:00
bryanthaboi 7fa762871f Merge pull request #1798 from campavao/rfc-0015-battle-rule-hooks
engine: battle.style and catch.nickname rule hooks
2026-08-25 15:55:07 -04:00
bryanthaboi 4d805abb9c Merge pull request #1812 from 1Jamie/save-editor-safe-areas-move-search
fix(save-editor): mobile safe areas, move search picker, and release window titles
2026-08-25 15:54:19 -04:00
bryanthaboi 51908faabf fix(mods): stop the Gen 2 require facade answering the engine
bootGame's require("src.core.Game") was resolving to Gen2Compat's Game
facade after a Gold session, so the next Gen 1 boot got an empty proxy
whose rawget(load) is nil (iOS: "src.core.Game missing load after
reload"; Android: Game:load called on a nil value).

Two independent causes, both needed:

  * callerIsMod decided "not under src/" meant "a mod", and main.lua is
    not under src/, so the engine's own require was gated as a mod's.
    Root chunks main.lua and conf.lua are now matched exactly, which a
    mod's own mods/<id>/main.lua cannot collide with.

  * devShim.generation was only ever set, never cleared, so the
    generation Gold declared outlived Gold's session. Loader.endSession
    drops it from SessionLifecycle.endMountedSession, and the facade
    gate now requires generation == 2 rather than ~= 1.

Only platforms that return to the launcher in-process (Android, and iOS
since #1808) keep the shim alive across sessions; everywhere else the
process restart cleared it.
2026-08-25 15:14:47 -04:00
bryanthaboi 54c7a3aeae Merge pull request #1808 from 1Jamie/fix/mobile-lc
fix(mobile): in-process launcher return for Android and iOS
2026-08-25 15:14:05 -04:00
bryanthaboi 5cc33af354 Merge pull request #1809 from 1Jamie/yellow-fixes
fix(yellow): address version:yellow issues and title pupil bake
2026-08-25 14:00:48 -04:00
1jamie 93c7dff4d6 fix(tests): keep yellow-fixes suites ROM-free and dig-fade accurate
Drop Data:load from #1635/#1743 engine tests so headless CI without
data/generated/ can run them, and relax dig's #916 hide-ratio check for
white fade-in under spinDrop (#1644).
2026-08-25 12:58:03 -05:00
1jamie b796c30278 fix(save-editor): mobile safe areas, move search picker, and release window titles
Inset save-editor chrome and pickers into SafeArea so Save stays tappable on punch-hole phones (#917), replace move cycling with a ranked search modal that reflows on phones/RGxxx, and omit the engine version from window titles on stamped release builds.
2026-08-25 12:43:05 -05:00
HighDrexler ec6b03f84b fix: stream large required imports on mobile 2026-08-25 13:22:45 -04:00
1jamie 69c7ee89b5 fix(yellow): address version:yellow issues and title pupil bake
Seed Mt Moon Super Nerd dialogue (#1743), play item-use heal jingles
(#1635), use white fades for fly/teleport warps (#1644), add a
Yellow-only Advanced palette overlay (#1639), parse Yellow Super Rod
data including Safari Dragonair (#1074), and apply title rOBP0 when
baking Pikachu eye OAM so pupils stay black with white glints.
2026-08-25 11:31:48 -05:00
1jamie 28dc9b9bb0 fix(mobile): in-process launcher return for Android and iOS
Stop joining ChipAudio on every EXIT GAME, always rebuild the Gen1 Game
module on Play-again, and treat iOS like Android for return-to-launcher so
release/mobile LOVE restarts no longer wipe Game.load or crash on quit.
2026-08-25 11:25:08 -05:00
DESKTOP-8SRFDDM\cam95 2ca07cfd09 engine: battle.style and catch.nickname rule hooks (RFC 0015)
Two decisions the OPTION screen and the cart make for the player, made
hookable so a game mode can make them instead: whether a faint offers the
SHIFT free switch, and whether a catch asks for a nickname.  Guarded call
sites, file-local vanilla links, docs and a public-API modkit case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1DVpYXGabigHqMwviDoKV
2026-08-25 09:20:35 -05:00
github-actions b3159c19fd chore(ios): update app-repo.json [skip ci] 2026-08-25 09:48:45 -04:00
bryanthaboi 08121faa3d Merge pull request #1806 from bryanthaboi/dev
bugs and shader fix
2026-08-25 09:33:02 -04:00
bryanthaboi bbe0f0d9ae crystal intro fix 2026-08-25 09:30:37 -04:00
bryanthaboi b35fab845a fix deez tests 2026-08-25 09:21:33 -04:00
bryanthaboi d622e83a8e shaderfix 2026-08-25 09:16:59 -04:00
bryanthaboi 9dd38e06a5 Merge pull request #1767 from MaxTomahawk/adaptive-trainers/dataset-view-api
feat(mod-api): expose imported dataset views
2026-08-25 08:26:51 -04:00
bryanthaboi 6b4796b08f Merge pull request #1782 from AverageConsumer/codex/gen2-party-reorder-api
Add Gen 2 party reorder API parity
2026-08-25 08:10:54 -04:00
bryanthaboi ab61d08fe9 Merge pull request #1786 from 1Jamie/more-android-lc
fix(android): keep Gen1 Game.load after in-process EXIT GAME
2026-08-25 08:10:21 -04:00
bryanthaboi c94ff016d9 Merge pull request #1794 from 1Jamie/pika-surf-title-import
Enhance Surfing Pikachu minigame assets and functionality
2026-08-25 08:09:39 -04:00
bryanthaboi 85e47bc422 Merge pull request #1799 from campavao/rfc-0018-catch-party-full
engine: catch.party_full — custody of a catch the party cannot hold
2026-08-25 08:08:15 -04:00
bryanthaboi 1850b50ee6 CLOSES #941, CLOSES #1081, CLOSES #1349, CLOSES #1602, CLOSES #1618, CLOSES #1624, CLOSES #1630, CLOSES #1633, CLOSES #1661, CLOSES #1669, CLOSES #1676, CLOSES #1731, CLOSES #1739, CLOSES #1740, CLOSES #1742, CLOSES #1744, CLOSES #1753, CLOSES #1757, CLOSES #1758, CLOSES #1760, CLOSES #1761, CLOSES #1762, CLOSES #1763, CLOSES #1765, CLOSES #1770, CLOSES #1777, CLOSES #1781, CLOSES #1783, CLOSES #1784, CLOSES #1785, CLOSES #1787, CLOSES #1788, CLOSES #1789, CLOSES #1790, CLOSES #1792, CLOSES #1797, CLOSES #1803 2026-08-25 07:55:52 -04:00
MaxTomahawk ef62962010 Merge upstream dev into dataset view API 2026-08-25 11:54:22 +00:00
bryanthaboi ba201aa4ad issue template: add Silver and Crystal to the version dropdown 2026-08-25 06:33:08 -04:00
DESKTOP-8SRFDDM\cam95 7d6566fa51 engine: catch.party_full custody for a full-party catch (RFC 0018)
A capture the party cannot hold silently falls through to the box; the
new partyFullDestination seam lets a mode claim custody at that moment
instead, and pokemon.caught reports destination "mod" so the mode can
find the mon again.  Guarded call site, file-local vanilla, docs and a
public-API modkit case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1DVpYXGabigHqMwviDoKV
2026-08-24 21:06:07 -05:00
1jamie 1ad6c810fa fix SurfingMinigame column definitions 2026-08-24 17:30:09 -05:00
1jamie 233082967c Enhance Surfing Pikachu minigame assets and functionality
- Updated .gitignore to include new generated assets for the Surfing Pikachu minigame.
- Added new image assets for the minigame, including title background and intro frames.
- Implemented extraction of Surfing Pikachu title art in RomExtractor.
- Updated CacheContract to include new asset paths.
- Enhanced SurfingMinigame with new constants and functions for improved gameplay mechanics.
- Added unit tests for new features and ensured existing tests pass.
2026-08-24 17:24:11 -05:00
1jamie d76d173b60 fix(android): keep Gen1 Game.load after in-process EXIT GAME
Stop fanning out arbitrary field:release() during Game/Game2 reset — shared
modules use :release as a handle API, and that teardown left the Gen1
singleton unbootable (Game:load nil) on Play-again. Harden bootGame and
endGameSession to rebuild the module if load is missing.
2026-08-24 14:33:43 -05:00
github-actions bc7b96c963 chore(ios): update app-repo.json [skip ci] 2026-08-24 13:18:39 -04:00
bryanthaboi aea38240b2 Merge pull request #1780 from bryanthaboi/dev
gender name string
2026-08-24 13:08:31 -04:00
AverageConsumer 56fc4653d7 Add Gen 2 party reorder API parity 2026-08-24 19:07:56 +02:00
bryanthaboi 0142468125 gender name string 2026-08-24 13:07:11 -04:00
github-actions e7d898625d chore(ios): update app-repo.json [skip ci] 2026-08-24 12:48:34 -04:00
MaxTomahawk 68fc01dd1c fix(mod-api): silence optional dataset absence 2026-08-24 10:25:37 +02:00
MaxTomahawk cec19db5e0 test(mod-api): cover reserved metadata replace 2026-08-24 10:19:33 +02:00
MaxTomahawk 38d515f547 fix(mod-api): reserve generated metadata ids 2026-08-24 10:10:23 +02:00
MaxTomahawk 7e069df740 fix(mod-api): validate dataset roots lazily 2026-08-24 09:57:57 +02:00
MaxTomahawk ca700c44d7 fix(mod-api): harden imported dataset views 2026-08-24 09:33:51 +02:00
MaxTomahawk b286e6584f feat(mod-api): add imported dataset views 2026-08-24 09:10:35 +02:00
147 changed files with 7489 additions and 1186 deletions
+2
View File
@@ -33,6 +33,8 @@ body:
- Blue
- Yellow
- Gold
- Silver
- Crystal
- N/A
validations:
required: true
+9 -2
View File
@@ -1,5 +1,9 @@
# Generated from a user-provided Pokemon Red ROM.
# Regenerate with: python3 tools/build_data.py --rom /path/to/pokemon-red.gb --clean
# Generated from a user-provided Pokemon ROM (Red/Blue/Yellow/Gold/Silver/Crystal).
# Regenerate with: python3 tools/build_data.py --rom /path/to/rom.gb --clean
# Yellow Surfing Pikachu minigame art also lands here on import:
# assets/generated/minigame/surf_1{a,b,c}.png
# assets/generated/minigame/title_bg.png
# assets/generated/minigame/intro_pika_{0,1,2,3}.png
data/generated/
assets/generated/
@@ -62,6 +66,9 @@ mobile/dist/
# ROM cache exactly like data/generated/, so it is never committable.
/build/tiled/
# Local visual scratch from minigame development (not wired to CI).
/tests/fixtures/
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
mobile/ios/bundle_id.local
+1 -1
View File
@@ -53,7 +53,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
### Watch the latest update video
[![Watch the latest update video](https://img.youtube.com/vi/yi7LkWQPKKM/maxresdefault.jpg)](https://youtu.be/yi7LkWQPKKM)
[![Watch the latest update video](https://img.youtube.com/vi/0hgtLDNGpdo/maxresdefault.jpg)](https://youtu.be/0hgtLDNGpdo)
This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold,
+270
View File
@@ -0,0 +1,270 @@
-- Yellow-only Advanced (redpp) deltas. Merged on top of data/palettes_gbc.lua
-- world tables when GameVersion.isYellow(); Red/Blue never load this file's
-- world path. Named SuperPalettes stay washed on Yellow -- PaletteFX.pal
-- prefers CGBBase under Advanced instead (title MEWMON/LOGO, YELLOWMON).
-- See #1639.
return {
world = {
-- Summer Beach House: Yellow-only tileset. Tile→group map mirrors HOUSE
-- (closest Advanced indoor profile); colors lean sand / wood / water.
tileGroups = {
BEACH_HOUSE = {
[0] = 5,
[1] = 0,
[2] = 0,
[3] = 0,
[4] = 1,
[5] = 0,
[6] = 3,
[7] = 3,
[8] = 2,
[9] = 2,
[10] = 2,
[11] = 2,
[12] = 2,
[13] = 2,
[14] = 5,
[15] = 5,
[16] = 0,
[17] = 0,
[18] = 0,
[19] = 0,
[20] = 1,
[21] = 0,
[22] = 3,
[23] = 3,
[24] = 5,
[25] = 5,
[26] = 5,
[27] = 5,
[28] = 0,
[29] = 0,
[30] = 5,
[31] = 5,
[32] = 0,
[33] = 0,
[34] = 3,
[35] = 5,
[36] = 5,
[37] = 5,
[38] = 5,
[39] = 5,
[40] = 5,
[41] = 5,
[42] = 2,
[43] = 2,
[44] = 5,
[45] = 5,
[46] = 5,
[47] = 5,
[48] = 5,
[49] = 5,
[50] = 5,
[51] = 5,
[52] = 5,
[53] = 5,
[54] = 5,
[55] = 5,
[56] = 5,
[57] = 5,
[58] = 5,
[59] = 5,
[60] = 5,
[61] = 5,
[62] = 5,
[63] = 5,
[64] = 0,
[65] = 0,
[66] = 0,
[67] = 0,
[68] = 0,
[69] = 0,
[70] = 5,
[71] = 5,
[72] = 5,
[73] = 5,
[74] = 5,
[75] = 5,
[76] = 5,
[77] = 5,
[78] = 5,
[79] = 5,
[80] = 5,
[81] = 5,
[82] = 5,
[83] = 5,
[84] = 5,
[85] = 5,
[86] = 5,
[87] = 5,
[88] = 5,
[89] = 5,
[90] = 5,
[91] = 5,
[92] = 5,
[93] = 5,
[94] = 5,
[95] = 5,
},
},
groupColors = {
BEACH_HOUSE = {
-- sand / wood floor
{
{ 255, 239, 198 },
{ 206, 173, 115 },
{ 156, 123, 74 },
{ 58, 58, 58 },
},
-- signs / accents (warm coral)
{
{ 255, 239, 198 },
{ 255, 156, 148 },
{ 230, 82, 66 },
{ 58, 58, 58 },
},
-- foliage
{
{ 255, 239, 198 },
{ 123, 189, 82 },
{ 66, 123, 41 },
{ 58, 58, 58 },
},
-- water / windows
{
{ 255, 239, 198 },
{ 99, 189, 230 },
{ 41, 115, 189 },
{ 58, 58, 58 },
},
-- yellow props
{
{ 255, 239, 198 },
{ 255, 255, 82 },
{ 230, 148, 25 },
{ 58, 58, 58 },
},
-- wood walls / furniture
{
{ 255, 239, 198 },
{ 189, 140, 74 },
{ 140, 99, 41 },
{ 58, 58, 58 },
},
-- sky / cool trim
{
{ 255, 239, 198 },
{ 148, 189, 255 },
{ 99, 148, 230 },
{ 58, 58, 58 },
},
-- text
{
{ 255, 255, 255 },
{ 255, 255, 255 },
{ 255, 255, 255 },
{ 0, 0, 0 },
},
},
},
-- Yellow spriteOrder is 82 entries; Red Advanced assignment is 72.
-- Indices 60-69 are Yellow inserts (Pikachu, Jenny, mons, Jessie/James);
-- Red's ball/fossil/snorlax/... shift to 70-81.
spriteAssignment = {
[0] = 0,
[1] = 1,
[2] = 3,
[3] = "random",
[4] = 0,
[5] = "random",
[6] = "random",
[7] = "random",
[8] = 0,
[9] = "random",
[10] = "random",
[11] = "random",
[12] = "random",
[13] = "random",
[14] = "random",
[15] = 1,
[16] = 1,
[17] = "random",
[18] = "random",
[19] = "random",
[20] = "random",
[21] = 2,
[22] = 1,
[23] = 3,
[24] = "random",
[25] = "random",
[26] = "random",
[27] = "random",
[28] = "random",
[29] = 0,
[30] = 3,
[31] = 3,
[32] = "random",
[33] = "random",
[34] = "random",
[35] = "random",
[36] = "random",
[37] = "random",
[38] = "random",
[39] = "random",
[40] = 0,
[41] = 2,
[42] = "random",
[43] = "random",
[44] = "random",
[45] = "random",
[46] = "random",
[47] = "random",
[48] = "random",
[49] = "random",
[50] = "random",
[51] = "random",
[52] = "random",
[53] = "random",
[54] = "random",
[55] = 0,
[56] = 1,
[57] = 3,
[58] = 0,
[59] = 0,
[60] = 4, -- SPRITE_PIKACHU
[61] = 1, -- SPRITE_OFFICER_JENNY
[62] = 3, -- SPRITE_SANDSHREW
[63] = 2, -- SPRITE_ODDISH
[64] = 2, -- SPRITE_BULBASAUR
[65] = "random", -- SPRITE_JIGGLYPUFF
[66] = "random", -- SPRITE_CLEFAIRY
[67] = "random", -- SPRITE_CHANSEY
[68] = "random", -- SPRITE_JESSIE
[69] = "random", -- SPRITE_JAMES
[70] = 0, -- SPRITE_POKE_BALL
[71] = 0, -- SPRITE_FOSSIL
[72] = 3, -- SPRITE_BOULDER
[73] = 3, -- SPRITE_PAPER
[74] = 0, -- SPRITE_POKEDEX
[75] = 3, -- SPRITE_CLIPBOARD
[76] = 0, -- SPRITE_SNORLAX
[77] = 3, -- SPRITE_UNUSED_OLD_AMBER
[78] = 3, -- SPRITE_OLD_AMBER
[79] = "random",
[80] = "random",
[81] = 3, -- SPRITE_GAMBLER_ASLEEP
},
-- Group 4 is Pikachu yellow on Yellow Advanced (Red keeps its own [4]).
spritePalettes = {
[4] = {
{ 222, 255, 222 },
{ 255, 255, 0 },
{ 230, 115, 0 },
{ 0, 0, 0 },
},
},
},
}
+21 -25
View File
@@ -42,27 +42,29 @@ local LINK_HEADINGS = { "HOW TO LINK", "COLOSSEUM", "TRADE CENTER" }
local function linkCableHelp(game)
local text = game.data.text or {}
local items, showMenu, askHeading
function showMenu()
game.stack:push(Menu.new(game, items,
{ tx = 0, ty = 0, tw = 15, th = 10, rowStep = 1 }))
end
function askHeading()
game.stack:push(TextBox.new(game,
text._LinkCableHelpText2 or "Which heading do\nyou want to read?",
showMenu))
local items, menu, openMenu
local function closeAll()
game.stack:pop()
end
items = {}
for i, label in ipairs(LINK_HEADINGS) do
items[i] = { label = label, onSelect = function()
items[i] = { label = label, keepOpen = true, onSelect = function()
game.stack:push(TextBox.new(game,
text["_LinkCableInfoText" .. i] or label, askHeading))
text["_LinkCableInfoText" .. i] or label))
end }
end
items[#items + 1] = { label = "STOP READING" }
items[#items + 1] = { label = "STOP READING", onSelect = closeAll }
menu = Menu.new(game, items,
{ tx = 0, ty = 0, tw = 15, th = 10, rowStep = 2, itemY = 2,
onCancel = closeAll })
function openMenu() game.stack:push(menu) end
game.stack:push(TextBox.new(game,
text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable",
askHeading))
function()
game.stack:push(TextBox.new(game,
text._LinkCableHelpText2 or "Which heading do\nyou want to read?",
nil, { stay = { onShown = openMenu } }))
end))
end
return {
@@ -84,21 +86,15 @@ return {
{ "jump_if_false", 5 }, -- 2
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 3 (old saves)
{ "jump", 13 }, -- 4
{ "give_pokemon", "EEVEE", 25 }, -- 5
{ "jump_if_false", 12 }, -- 6 (party+box full)
-- flag + HideObject before the jingle and GotMonText: the nickname
-- prompt inside give_pokemon and the text row both yield, and a
-- script that dies there would leave the EEVEE taken with the ball
-- still on the table and the gift claimable again (#426). The row
-- count is unchanged, so the numeric jump targets still hold.
{ "jump", 11 }, -- 4
{ "give_pokemon", "EEVEE", 25, false, true }, -- 5
{ "jump_if_false", 10 }, -- 6 (party+box full)
-- scripts/CeladonMansionRoofHouse.asm:17-20 (#426)
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
{ "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
{ "jump", 13 }, -- 11
{ "show_text", "_BoxIsFullText" }, -- 12
{ "jump", 11 }, -- 9
{ "show_text", "_BoxIsFullText" }, -- 10
},
},
}
+2 -4
View File
@@ -2,12 +2,10 @@
return {
CELADON_CITY = {
talk = {
-- CeladonCityPoliwrathText (scripts/CeladonCity.asm): text_far
-- _CeladonCityPoliwrathText, then plays the POLIWRATH cry and ends.
-- The cry playback has no port-side equivalent command, so we just
-- show the flavor line.
TEXT_CELADONCITY_POLIWRATH = {
{"face_player"},
-- pokered/scripts/CeladonCity.asm:90
{"play_cry", "POLIWRATH", true},
{"show_text", "_CeladonCityPoliwrathText"},
},
},
+6 -12
View File
@@ -2,30 +2,24 @@
return {
CELADON_MANSION_1F = {
talk = {
-- CeladonMansion1FClefairyText (scripts/CeladonMansion1F.asm): text_far
-- _CeladonMansion1FClefairyText, then plays the CLEFAIRY cry and ends.
-- The cry playback has no port-side equivalent command, so we just
-- show the flavor line.
TEXT_CELADONMANSION1F_CLEFAIRY = {
{"face_player"},
-- pokered/scripts/CeladonMansion1F.asm:27
{"play_cry", "CLEFAIRY", true},
{"show_text", "_CeladonMansion1FClefairyText"},
},
-- CeladonMansion1FMeowthText (scripts/CeladonMansion1F.asm): text_far
-- _CeladonMansion1FMeowthText, then plays the MEOWTH cry and ends.
-- The cry playback has no port-side equivalent command, so we just
-- show the flavor line.
TEXT_CELADONMANSION1F_MEOWTH = {
{"face_player"},
-- pokered/scripts/CeladonMansion1F.asm:18
{"play_cry", "MEOWTH", true},
{"show_text", "_CeladonMansion1FMeowthText"},
},
-- CeladonMansion1FNidoranFText (scripts/CeladonMansion1F.asm): text_far
-- _CeladonMansion1FNidoranFText, then plays the NIDORAN_F cry and ends.
-- The cry playback has no port-side equivalent command, so we just
-- show the flavor line.
TEXT_CELADONMANSION1F_NIDORANF = {
{"face_player"},
-- pokered/scripts/CeladonMansion1F.asm:33
{"play_cry", "NIDORAN_F", true},
{"show_text", "_CeladonMansion1FNidoranFText"},
},
},
@@ -4,11 +4,10 @@
return {
LAVENDER_CUBONE_HOUSE = {
talk = {
-- LavenderCuboneHouseCuboneText: text_far _LavenderCuboneHouseCuboneText,
-- then text_asm plays the CUBONE cry. Cry playback has no Commands
-- equivalent in this port, so just show the line.
-- LavenderCuboneHouse.asm:10
TEXT_LAVENDERCUBONEHOUSE_CUBONE = {
{ "face_player" },
{ "play_cry", "CUBONE", true },
{ "show_text", "_LavenderCuboneHouseCuboneText" },
},
+4 -5
View File
@@ -31,16 +31,15 @@ M.MR_FUJIS_HOUSE = {
{ "show_text", "_MrFujisHouseLittleGirlPokemonAreNiceToHugText" }, -- 6
},
-- scripts/MrFujisHouse.asm MrFujisHousePsyduckText: text_far then
-- PlayCry(PSYDUCK). Cry playback isn't modeled by Commands, so just
-- show the flavor text.
-- scripts/MrFujisHouse.asm:56
TEXT_MRFUJISHOUSE_PSYDUCK = {
{ "play_cry", "PSYDUCK", true },
{ "show_text", "_MrFujisHousePsyduckText" },
},
-- scripts/MrFujisHouse.asm MrFujisHouseNidorinoText: text_far then
-- PlayCry(NIDORINO).
-- scripts/MrFujisHouse.asm:63
TEXT_MRFUJISHOUSE_NIDORINO = {
{ "play_cry", "NIDORINO", true },
{ "show_text", "_MrFujisHouseNidorinoText" },
},
},
+2 -3
View File
@@ -4,10 +4,9 @@
return {
ROUTE_16_FLY_HOUSE = {
talk = {
-- Route16FlyHouseFearowText: text_asm just prints the one line and
-- plays the FEAROW cry (no cry-playback command exists in this
-- port's Commands vocabulary, so only the text is ported).
-- scripts/Route16FlyHouse.asm:45-51
TEXT_ROUTE16FLYHOUSE_FEAROW = {
{ "play_cry", "FEAROW", true },
{ "show_text", "_Route16FlyHouseFearowText" },
},
},
+1
View File
@@ -1047,6 +1047,7 @@ local championsRoomRivalScript = {
-- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past
-- it, so a returning champion warped straight into the induction.
{ "jump_if_true", "end" }, -- 3
{ "set_option", "animations", true }, -- scripts/ChampionsRoom.asm:57
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
-- the intro text, before the battle itself (#706); pushBattle's wipe-time
+9 -5
View File
@@ -359,18 +359,22 @@ M.ROUTE_16_FLY_HOUSE = {
TEXT_ROUTE16FLYHOUSE_BRUNETTE_GIRL = function(game, ow, npc, done)
local t = text(game)
if game.save.flags.EVENT_GOT_HM02 then
push(game, t._Route16FlyHouseBrunetteGirlHm02ExplanationText
or "HM02 is FLY!\fIt will whisk you\nback to any town!", done)
push(game, t._Route16FlyHouseBrunetteGirlHM02ExplanationText
or "HM02 is FLY.\nIt will take you\vback to any town.\fPut it to good\nuse!", done)
return
end
push(game, t._Route16FlyHouseBrunetteGirlText
or "Shh! It's a\nsecret!\fMy POKéMON's\nHM02, take it!", function()
or "Oh, you found my\nsecret retreat!", function()
if not require("src.inventory.Bag").add(game.save, "HM_FLY", 1) then
push(game, "You don't have\nroom for HM02!", done)
push(game, t._Route16FlyHouseBrunetteGirlHM02NoRoomText
or "You don't have any\nroom for this.", done)
return
end
game.save.flags.EVENT_GOT_HM02 = true
push(game, ("%s got\nHM02!"):format(game.save.player.name), done)
-- scripts/Route16FlyHouse.asm:32-35
push(game, fill(t._Route16FlyHouseBrunetteGirlReceivedHM02Text
or "{PLAYER} received\nHM02!", { player = game.save.player.name }),
done, require("src.render.TextBox").soundOpts(game, "Get_Key_Item"))
end)
end,
},
+113
View File
@@ -200,6 +200,60 @@ the adapter's own coverage table:
python3 tools/modkit.py gen2check mods/my_mod
```
## Imported version datasets
A mod can inspect semantic content from another game the player has already
imported without switching the active game or reaching into engine cache
internals:
```lua
local gold, reason = mod.datasets:open("gold")
if not gold then
-- reason is "unknown_version", "not_imported", or "invalid_cache"
return
end
local chikorita = gold.content.pokemon:get("CHIKORITA")
local normalVsGhost = gold.content.type_chart:get("NORMAL>GHOST")
local spritePath = gold.assets:path(chikorita.spriteFront)
for id, record in gold.content.pokemon:each() do
-- ids are returned in deterministic lexical order
end
```
`view.version` and `view.generation` identify the selected dataset.
`view.content` exposes the same registry names, aliases, generation routing,
and data-only record shapes as `mod.content`, but only `get`, `has`, and
`each`. Returned records are detached copies and cannot mutate either dataset.
Every generated base record passes the selected generation's existing public
schema before it is returned; extractor metadata beside record maps stays out
of the registry id space and is reserved against `register`, `override`,
`patch`, and `remove` writes through the active registry. A malformed record
makes `get` return nil, `has`
return false, and `each` return no rows, and invalidates that dataset view.
Records containing functions, userdata, threads, metatables, or cycles are not
exposed. Each open call receives an independent facade, so one mod cannot
replace another mod view method. Canonical boot shaping is included, such as
Gen 1 defaults and Yellow corrections, and Gold's Foresight matchup rows and
derived `held_items`.
`open` checks the completion marker, exact version-specific file inventory,
source/cache boundary, and file-size bounds without reading or decoding the
semantic modules. A root is read, bounded-decoded, normalized, and cached only
when a content operation first needs it. Each later view operation rechecks
readiness and the source bytes behind already cached roots; unchanged roots are
not decoded again. Missing, partial, and stale imports return
`nil, "not_imported"`. Malformed syntax, a resource-limit violation, or a
record that fails the public schema is discovered on first root access and
fails that operation closed; a later `open` against the same source returns
`nil, "invalid_cache"`. Generated modules use a bounded literal-only grammar
and are never executed. No raw ROM bytes or generated source are exposed.
`view.assets:path(relative)` and
`view.assets:info(relative)` accept only `assets/generated/...` paths and
keep them under the selected version cache prefix. The API never changes
`mod.game`, the active `Data` table, `GameVersion`, or cache mount state.
## Editing maps in Tiled
Maps are data, not assets, so they can be authored in a real map editor and
@@ -451,6 +505,65 @@ Menu choices and moves use the same engine methods as the native controls;
their mutable logic. Tutorial, link, forced, stale, and covered battle states
refuse core intents. Use `mod.input` for ordinary text advance.
## Battle rule hooks
Two decisions the OPTION screen and the cart make for the player, which a game
mode can make instead (RFC 0015). Neither writes the player's saved
preference, so a mode can hold a rule for as long as it is active and hand the
player's own setting back untouched.
`battle.style` wraps the SHIFT/SET read at the moment the foe's Pokémon faints
and the engine would offer a free switch:
```lua
mod.hooks:wrap("battle.style", function(next, battle)
if myMode.active then return "set" end -- no "will you change POKéMON?"
return next(battle) -- the OPTION row, as today
end)
```
Return `"set"` or `"shift"`; anything else reads as the vanilla answer.
`catch.nickname` wraps the `AskName` prompt after a capture (party or box),
the same question `pokemon.before_give`'s `gift.nickname` already answers for
script gifts:
```lua
mod.hooks:wrap("catch.nickname", function(next, mon, ctx)
-- ctx = { battle = <BattleState>, name = <display name>, game = <Game> }
if myMode.active then return false end -- keep the species name
if myNames then return myNames[mon.species] end -- a string names it, no prompt
return next(mon, ctx) -- true: ask, as today
end)
```
`false` keeps the species name with no prompt. A string is the nickname with
no prompt, clipped to the naming grid's ten characters (an empty string names
nothing). Anything else queues the prompt.
## Party-full custody at a catch
When a capture lands on a full party, the cart deposits the mon in storage
without a question. `catch.party_full` (RFC 0018) lets a mode stand in front
of `SendNewMonToBox` and take custody instead:
```lua
mod.hooks:wrap("catch.party_full", function(next, ctx)
-- ctx = { battle = <BattleState>, mon = <Pokemon>, name = <display name>,
-- game = <Game> }
if myMode.active then
takeCustody(ctx.mon) -- the mon is the mod's problem now
return true -- nothing is deposited
end
return next(ctx) -- false: deposit, as today
end)
```
A truthy return skips the box entirely -- the mon is neither in the party nor
in any box, and `pokemon.caught` reports `destination = "mod"` so the mode can
find its own custody again. Anything falsy deposits as always, "But every BOX
is full!" included.
## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it
+50 -1
View File
@@ -342,6 +342,7 @@ accepted and merged as-is.
| `source` | string |
| `swarmGrass` | map of string -> {map?, rates, slots} |
| `swarmWater` | map of string -> {map?, rate, slots} |
| `timeFishGroups` | map of string | integer 0..255 -> {day, nite} |
| `treeSets` | map of string -> {common, rare} |
| `trees` | map of string -> string |
| `water` | map of string -> {map?, rate, slots} |
@@ -509,6 +510,26 @@ mod.content.item_effects:register("MOON_FLUTE", { use = fn, field = true })
mod.content.items:patch("POTION", { price = 100 })
```
### On Gold (Gen 2)
- semantics: `record`
- target: `Data.items`
The record differs; the registry name, the verbs and the id space
do not.
| field | type | required |
|---|---|---|
| `ball` | balls id | no |
| `effect` | item_effects id | no |
| `id` | string | yes |
| `index` | integer 0..255 | no |
| `machine` | {kind, move, number} | no |
| `name` | string | yes |
| `needsTarget` | boolean | no |
| `price` | integer >= 0 | yes |
| `tossable` | boolean | no |
## landmarks
- semantics: `record`
@@ -598,7 +619,7 @@ mod.content.map_songs:override("PALLET_TOWN", "Music_Routes1")
| `id` | string | yes |
| `index` | integer >= 0 | no |
| `label` | string | no |
| `objects` | list of any value | no |
| `objects` | list of {pokemon?, ...} | no |
| `palette` | string | no |
| `signs` | list of any value | no |
| `tileset` | tilesets id | yes |
@@ -673,6 +694,34 @@ mod.content.move_effects:register("DRAIN_PP_EFFECT", { kind = "primary", run = f
mod.content.moves:patch("BLIZZARD", { accuracy = 70 })
```
### On Gold (Gen 2)
- semantics: `record`
- target: `Data.moves`
The record differs; the registry name, the verbs and the id space
do not.
| field | type | required |
|---|---|---|
| `accuracy` | integer 0..100 | yes |
| `anim` | any value | no |
| `category` | one of "physical" | "special" | "status" | no |
| `chargeText` | string | no |
| `counterable` | boolean | no |
| `effect` | move_effects id | yes |
| `fixedDamage` | integer >= 1 | function | no |
| `highCrit` | boolean | no |
| `id` | string | yes |
| `index` | integer 0..255 | no |
| `multiHit` | integer >= 1 | list of integer >= 1 | no |
| `name` | string | yes |
| `power` | integer 0..255 | yes |
| `pp` | integer 0..64 | yes |
| `priority` | integer -7..7 | no |
| `semiInvulnerable` | boolean | no |
| `type` | type_chart id | yes |
## music
- semantics: `record`
+110
View File
@@ -0,0 +1,110 @@
# RFC 0015: Battle rule hooks — `battle.style` and `catch.nickname`
## Status
Proposed.
## Motivation
Two decisions in a Red/Blue/Yellow battle are made for the player by the
OPTION screen and by the cart, and a game mode has no way to make them
instead:
**Whether a faint offers a free switch.** `EnemySendOutFirstMon` reads the
battle-style bit: SHIFT asks "will you change POKéMON?" when the foe's Pokémon
faints, SET does not. The engine reads `save.options.battleStyle` inline at
that moment. A mode that wants SET — a tournament, a Nuzlocke, a battle royale
where party-as-health is the whole design — can only get it by writing the
player's saved preference, which leaks into their real playthrough the first
time anything calls `Game:writeOptions` (the speed hotkey does, on every
press).
**Whether a catch asks for a nickname.** `AddPartyMon` and `SendNewMonToBox`
both run `AskName` after a capture. A mode with a disposable team, a
randomizer that names what it hands out, a speedrun practice mod — all want to
answer that prompt themselves, and the only way today is to drive the yes/no
box from outside. The script-gift path already has this seam: a mod that sets
`gift.nickname` on `pokemon.before_give` skips `AskName`. The catch path does
not.
The immediate consumer is a battle-royale mode; neither hook is specific to it.
## The decision it extends
This extends the **additive, guarded seam convention** Route B in
`CONTRIBUTING-mods.md` documents, and is gated by the parity guarantee
`tests/engine/gate_meta_coverage.lua` enforces. `catch.nickname` mirrors a
contract that already exists for gifts (`pokemon.before_give`'s
`gift.nickname`), so the two ways a Pokémon joins the party answer the same
question the same way.
There is no in-repo D-number registry to amend.
## Exact API delta
### New hook: `battle.style`
```lua
mod.hooks:wrap("battle.style", function(next, battle)
if myMode.active then return "set" end
return next(battle) -- the OPTION row, as today
end)
```
Call site: `BattleState:battleStyle()`, called from the enemy send-out path at
the moment the SHIFT prompt would be offered. The vanilla link reads
`save.options.battleStyle` (lower-cased, default `"shift"`) exactly as the
inline read did. A return of `"set"` or `"shift"` is used; anything else reads
as the vanilla answer, so a hook that returns nothing by mistake cannot change
the rule. The player's saved preference is never written.
### New hook: `catch.nickname`
```lua
mod.hooks:wrap("catch.nickname", function(next, mon, ctx)
-- ctx = { battle = <BattleState>, name = <display name>, game = <Game> }
if myMode.active then return false end -- keep the species name
if randomizer then return pickName(mon) end -- a string names it
return next(mon, ctx) -- true: ask, as today
end)
```
Call site: `BattleState:offerNickname(mon, displayName)`, called from the
capture path where `AskName` ran, before the prompt is queued. The vanilla
link returns `true`. `false` skips the prompt and keeps the species name; a
string skips the prompt and is the nickname, clipped to the naming grid's ten
characters (an empty string names nothing, like the grid's own empty entry);
anything else queues the prompt as today. The method returns whether a prompt
was queued.
### No other surface changes
Both call sites are guarded by `Runtime.wantsHook`, and both vanilla links are
file-local functions, so a build with nothing wrapped runs the branch exactly
as before and allocates nothing it did not allocate before. `askNicknameUI` is
unchanged and still public.
## Migration
Nothing changes for existing mods. A mod that was writing
`save.options.battleStyle` to force a style should wrap `battle.style` instead
and stop writing the option.
## Verification
- `tests/modkit/cases/battle_rule_hooks.lua` — through the public mod API: the
no-mod answers match the OPTION row and the cart's AskName; a wrapped mod
forces either style without the row being written; `false`, a string, a
too-long string, an empty string, and a fall-through each do what this RFC
says at the catch prompt.
- `tests/engine/gate_hooks.lua` — both names are in the live catalog and pass
the no-mod parity gate (vanilla called exactly once, result unchanged,
nothing allocated).
- `tests/engine/gate_meta_coverage.lua` — both names are covered by the unit
corpus.
## Backward compatibility
Additive. No existing hook, event, registry or manifest field changes shape.
The two new methods on `BattleState` are called only from the paths that
previously inlined their logic; the results with no subscriber are identical.
+154
View File
@@ -0,0 +1,154 @@
# RFC 0015: Read-only imported dataset views
## Status
Proposed.
## Motivation
A cross-version content mod can read only the active merged dataset through
`mod.content`. Even when the player has already imported another supported
game, a mod cannot inspect that version's semantic species, moves, items, type
chart, or generated asset namespace. The available alternatives are private:
mutating `CacheFs.prefix`, mounting another cache over the active one, loading
generated Lua directly, or asking for a second raw-ROM import. They compose
poorly with the active game, expose unstable import layout, and make safe
read-only use impossible from the sandbox.
The concrete consumer is **Adaptive Trainers**, whose approved Phase G Kanto+
sidecar must derive nine Kanto-line continuations, Steel/type and move
definitions, and generated sprites from the player's existing verified Gold
cache while Red, Blue, or Yellow remains active. `mod.content` exposes only the
active R/B/Y dataset; `mod.imports` can read only separately declared raw mod
imports; and `mod.cache` is mod-private generated output. None can inspect the
launcher-owned Gold semantic dataset without a second ROM import and mod-side
ROM interpretation. The engine API remains generic and contains no Adaptive
Trainers policy.
## Decision and plan extended
This implements **D-AT-004: optional Kanto+ content consumes an
active-independent semantic dataset view**. The consuming design is tracked in
the Adaptive Trainers implementation plan,
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
Task 8. The delta is a generic, additive public API and contains no trainer
pools, scaling, boss identities, version-mixing rules, or Adaptive Trainers
policy.
## Exact API delta
Every sandboxed mod receives:
```lua
local view, reason = mod.datasets:open("gold")
```
A known version with the current completed import marker returns a read-only view:
```lua
view = {
version = "gold",
generation = 2,
content = {
pokemon = {
get = function(self, id) end,
has = function(self, id) end,
each = function(self) end,
},
-- every public registry name and alias
},
assets = {
path = function(self, generatedPath) end,
info = function(self, generatedPath) end,
},
}
```
`get` returns a bounded detached data-only copy or nil. `has` reports data-only
semantic presence.
`each` returns ids in lexical order and detached values. The registries use
the selected version's generation routing and the engine's existing
`Schemas`, `Registry`, and `Builtins` normalization, so structured sources
such as type matchups retain the same public ids used by the active
`mod.content` facade and extractor metadata beside record maps is not exposed
as a record id or writable through the active registry's mutation verbs. Every
generated base record is checked with that selected generation's existing
public schema before it can cross `get`, `has`, or
`each`; there is no dataset-specific duplicate schema. No register, patch,
override, or remove verb is exposed. Each call returns an independent facade
over the cached internal dataset, so facade mutation cannot cross mod
boundaries.
`assets:path` returns the selected cache-prefixed virtual path.
`assets:info` returns sanitized `type` and optional `size` metadata. Both
accept only relative paths below `assets/generated/`, reject control
characters, absolute paths, backslashes, and traversal, and expose no byte
reader.
An unknown version returns `nil, "unknown_version"`. `open` checks the current
completion marker, exact version-specific file inventory, source/cache
boundary, and available file sizes; it does not read or decode semantic
modules. A missing, partial, or stale cache returns `nil, "not_imported"`.
The first content operation that needs a root reads it once, applies the
limits (8 MiB per module, 48 MiB aggregate, depth 64, 500,000 values, 2 MiB
per string, and 250,000 entries per table), decodes the restricted literal
grammar, applies canonical selected-version normalization, and caches the
detached root. Each later content or asset operation rechecks marker/file and
source-bound readiness. It also rereads the source bytes for already cached
roots; unchanged roots are not decoded again, while a changed root clears the
derived registry cache and is decoded on its next use.
Malformed syntax, non-table roots, binary/trailing content, resource-limit
violations, and records that fail the public schema are therefore discovered
on first access rather than during `open`. The triggering `get` returns nil,
`has` returns false, or `each` returns no rows; the whole internal view is
invalidated, and a subsequent `open` against the unchanged source returns
`nil, "invalid_cache"`. Actionable detail is engine-logged but not exposed to
the mod. Generated Lua is never executed. Functions, userdata, threads,
metatables, and cycles cannot cross the facade. The API never exposes raw ROM
bytes, generated source, host paths, or a mount.
## Migration and compatibility
Existing mods change nothing. `mod.datasets` is additive and requires no
permission. The service is allocated lazily on the first explicit
`mod.datasets:open` call. A boot with no mods, or with mods that do not call
it, performs no cross-version cache reads.
Opening a view does not change `GameVersion`, `CacheFs.prefix`, the active
`Data` table, PhysFS mounts, save state, or the selected game's behavior.
Red, Blue, Yellow, Gold, Silver, and Crystal keep their existing active data paths.
The completion marker and per-version required-file rules live in the pure,
injected `CacheContract` shared by the importer and dataset service. It also
defines source-tree behavior. Neither consumer mutates `CacheFs.prefix` while
checking readiness. Every `open` revalidates the contract and required module
inventory without semantic decoding. Every view operation rechecks that
readiness before serving cached state; a stale/remove/reimport transition
evicts the previous semantic view.
## Verification
- `tests/modkit/cases/dataset_views.lua` loads sandboxed fixture mods through
the public API and covers Red, Blue, Yellow, Gold, Silver, and Crystal independently.
- The test proves semantic registry normalization, deterministic iteration,
detached records, read-only facades, cross-mod facade isolation,
version-prefixed generated assets,
traversal rejection, stable failure reasons, and stale-marker rejection.
- It also proves missing/empty/partial/stale/remove/reimport behavior, hostile
generated-source and malformed-record rejection, canonical Gen
1/Yellow/Gold hydration, active Red/Blue/Yellow isolation while reading Gold,
and the approved Kanto+ Gold records and assets.
- `tests/engine/dataset_views_lazy_validation.lua` counts semantic reads and
decoder calls to prove `open` decodes nothing, unused roots stay unread, and
repeated access does not re-decode an unchanged cached root.
- `tests/modkit/cases/dataset_views_nontermination.lua` proves generated code
is rejected rather than executed; `tests/engine/generated_data_decoder_test.lua`
proves every decoder resource bound.
- `tests/engine/dataset_views_no_mod_parity.lua` is the separate guarded no-mod
parity suite and proves the service stays unallocated with zero cache reads.
## Deprecation etiquette
Nothing is removed, renamed, superseded, or deprecated.
+113
View File
@@ -0,0 +1,113 @@
# RFC 0018: `catch.party_full` — custody of a catch the party cannot hold
## Status
Proposed.
## Motivation
When a capture lands and the party already holds six Pokémon, the cart does
not ask the player anything: `AddPartyMon` fails and the mon goes to
`SendNewMonToBox` without a stop. The engine mirrors that —
`BattleState:storeCaughtMon` falls through to `Boxes.deposit` — and a game
mode has no way to stand in front of it.
That silence is wrong for any mode where the box is not a thing. A battle
royale locks the Pokémon Center PC for the whole match (the box is a second
health bar: deposit the healthy ones, fight with one, withdraw fresh ones),
so a seventh catch is deposited into storage the player cannot reach — the
mon is gone and everyone saw the fanfare. A Nuzlocke that counts a boxed mon
as lost, a randomizer that wants to hand out a replacement instead, a
challenge run that makes the player release someone for the catch — all want
the decision, and today there is no seam to catch it at.
The immediate consumer is a battle-royale mode, where the fix is the game's
own rule: at 6/6 you choose who makes room, and whoever leaves hits the
ground as a ball. Neither the decision nor the spill is specific to it.
## The decision it extends
This extends the **additive, guarded seam convention** Route B in
`CONTRIBUTING-mods.md` documents, and is gated by the parity guarantee
`tests/engine/gate_meta_coverage.lua` enforces. It sits next to
`catch.nickname` (RFC 0015) on the same capture path: that hook answers
*what the mon is called* once its home is decided; this one decides the home
when the party cannot hold it.
There is no in-repo D-number registry to amend.
## Exact API delta
### New hook: `catch.party_full`
```lua
mod.hooks:wrap("catch.party_full", function(next, ctx)
-- ctx = { battle = <BattleState>, mon = <Pokemon>, name = <display name>,
-- game = <Game> }
if myMode.active then
takeCustody(ctx.mon) -- the mon is the mod's problem now
return true -- nothing is deposited
end
return next(ctx) -- false: deposit, as today
end)
```
Call site: `BattleState:partyFullDestination(mon)`, called from the capture
path at the moment `AddPartyMon` has failed and `SendNewMonToBox` would run.
The vanilla link returns `false`. A truthy return skips the box entirely —
the mon is neither in the party nor in any box, and `pokemon.caught` reports
`destination = "mod"` so the mode can find its own custody again. Anything
falsy deposits as always, "But every BOX is full!" included.
The method returns `"box"` or `"mod"`, and is a *method* on purpose: the
compatibility seam for engines that predate this RFC needs a name to ask for
(see below), and `battleStyle`/`offerNickname` set the precedent.
### One event value, already emitted
`pokemon.caught`'s `destination` gains a third value, `"mod"`, next to
`"party"` and `"box"`. Nothing that reads the event today matches on it.
### No other surface changes
The call site is guarded by `Runtime.wantsHook`, and the vanilla link is a
file-local, so a build with nothing wrapped runs the branch exactly as
before and allocates nothing it did not allocate before.
## Migration
Nothing changes for existing mods. A mode that was fighting the box after
the fact — withdrawing the deposit it could not prevent, or eating the loss —
should wrap `catch.party_full` and take the mon at the moment of the catch.
## Verification
- `tests/modkit/cases/catch_party_full.lua` — through the public mod API:
with no mod a 6/6 catch lands in the box with the transfer text; a wrapped
mod that claims custody leaves the mon out of both party and boxes; a
fall-through deposits; the hook's ctx carries the battle, the mon, the
display name and the game.
- `tests/engine/gate_hooks.lua` — the name is in the live catalog and passes
the no-mod parity gate (vanilla called exactly once, result unchanged,
nothing allocated).
- `tests/engine/gate_meta_coverage.lua` — the name is covered by the unit
corpus.
## Backward compatibility
Additive. No existing hook, event, registry or manifest field changes shape.
`storeCaughtMon`'s box branch keeps its text, its nickname offer and its
`pokemon.caught` emit; the only reader-visible difference with no subscriber
is one extra method on `BattleState`.
## Compatibility seam for older engines
The call site is mid-function, so a mod on a stock engine cannot see it —
the same problem `world.talk` has. The battle-royale mod's shim covers the
gap by wrapping `Boxes.deposit` (the one call the old branch makes that a
patch can reach): during a match it raises `catch.party_full` first, and a
claim refuses the deposit, so nothing reaches a box even there. The mod then
takes custody from the `pokemon.caught` emit, which carries the mon. What
the shim cannot repair is the text: the old branch answers a refused deposit
with "But every BOX is full!" before the mode's own prompt opens — the wrong
reason for the right decision, and the argument for the seam.
+50 -1
View File
@@ -220,6 +220,54 @@ output directory; nothing packages it next to a shipped game yet.
and `ShaderFX.bridgeError()` says why not. Activating an already-converted
preset never needs any of this.
**Statically linked bridges.** On iOS there is no loadable library at all:
the bridge is linked straight into the app binary, so its symbols live in the
main image and are reachable only through `ffi.C`. The candidate list is empty
on iOS and, on every platform, a failed candidate walk falls back to probing
`ffi.C` for `librashader_translate_preset` after the `ffi.cdef`. If the symbol
is there, `ffi.C` becomes the library handle and `ShaderFX.translate` uses it
unchanged. If it is not, `ShaderFX.bridgeError()` reports that `ffi.C` was
probed as well, followed by the file paths that were tried (omitted on iOS,
where none are).
**Android ships the bridge inside the APK.** `scripts/build_android.sh`
stages `liblibrashader_bridge.so` into
`mobile/android/app/src/main/jniLibs/<abi>/` for the two ABIs the APK ships,
arm64-v8a and armeabi-v7a, so `ffi.load("liblibrashader_bridge.so")` finds it
by bare name in the app's native library directory. The build cross-compiles
the crate with `cargo ndk` against the same NDK the gradle project uses
(25.2.9519653), which needs `cargo install cargo-ndk` and
`rustup target add aarch64-linux-android armv7-linux-androideabi`; the script
names the exact command for any target that is missing. Set
`SHADERFX_BRIDGE_ANDROID_DIR` to a directory holding
`<abi>/liblibrashader_bridge.so` to bundle prebuilt libraries instead. As on
desktop, the bridge is only needed to CONVERT a preset: a build without it
still runs presets converted elsewhere, and the packager warns and continues
rather than failing.
**iOS links the bridge instead of loading it.** An iOS app cannot `dlopen` a
dylib shipped beside its binary, so `tools/shaderfx-bridge` also builds as a
`staticlib` and `scripts/build_ios.sh` links it into the app executable
(`bundle_shader_bridge_ios`, mirroring `bundle_shader_bridge` in
`scripts/build.sh`). `SHADERFX_BRIDGE_IOS` points at a prebuilt archive;
otherwise cargo builds `aarch64-apple-ios` for device builds and every
installed simulator target (`aarch64-apple-ios-sim`, `x86_64-apple-ios`) for
the Simulator, with `IPHONEOS_DEPLOYMENT_TARGET=15.0`, and `ARCHS` is pinned to
what got built. The archive is never linked directly: LÖVE 12 carries its own
glslang for shader validation and the crate drags in a second, incompatible
one, and linking both crashed inside `Shader::validateInternal` at startup.
Each slice is therefore prelinked with `ld -r -all_load
-exported_symbols_list` so only `_librashader_translate_preset` and
`_librashader_free_string` stay external and every other symbol (glslang,
spirv-cross, Rust std) becomes private to the object, then `rust-objcopy`
drops the `__LLVM` bitcode sections rustup's prebuilt std carries, since
Apple's `nm` cannot read them. `OTHER_LDFLAGS` adds `-Wl,-u` on both entry
points to keep them past dead-stripping and `-lc++` for the C++ the crate
needs; no Objective-C framework is involved. A build that linked the object
fails if `nm` cannot find `_librashader_translate_preset` in the finished
binary; a build that could not produce one only warns and lands where the
desktop packages without cargo land: converted presets run, CONVERT does not.
### Fixup
`ShaderFixup.lua` mechanically rewrites the emitted GLSL into something LOVE
@@ -656,7 +704,8 @@ None of these are theoretical.
`bundle_shader_bridge`, building it with cargo when a prebuilt one is not
supplied through `SHADERFX_BRIDGE`. A build host without cargo produces a
package that can run converted presets but cannot CONVERT new ones, and says
so rather than failing. Android ships the `.so` via `jniLibs`.
so rather than failing. `scripts/build_android.sh` and `scripts/build_ios.sh`
follow the same rule with `cargo ndk` and the iOS static archive.
- **The buildbot shortlist is a temporary trim.** `KEPT_PRESETS` reflects one
manual pass over `handheld/` and is expected to change, most likely to shrink.
- **Tilt direction is unverified.** Which way forward and back rocking moves the
+56 -11
View File
@@ -384,7 +384,16 @@ function bootGame(version, cartId)
Game = require("src.core.Game2").new()
Game:load()
else
Game = require("src.core.Game")
-- Gen1 Game is a module singleton. Always re-require after in-process
-- EXIT GAME so a prior session cannot leave a table whose rawget(load)
-- is nil (release Android: bootGame then dies with load-a-nil-value).
-- rawget: type(mod.load) can lie via __index and skip a rebuild.
package.loaded["src.core.Game"] = nil
local gameMod = require("src.core.Game")
if type(rawget(gameMod, "load")) ~= "function" then
error("src.core.Game missing load after reload")
end
Game = gameMod
Game:load()
if os.getenv("POKEPORT_AUTOPILOT") then
autopilot = require("tests.autopilot")
@@ -1118,15 +1127,23 @@ function love.quit()
-- 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 isAndroid = (love.system and love.system.getOS and love.system.getOS() == "Android")
--
-- Android and iOS both tear down LOVE in-process rather than
-- love.event.quit("restart"): Android's vendored love.cpp PHYSFS-crashes
-- on a second init (#575), and iOS's love.cpp forces DONE_RESTART for
-- every quit while warning that leftover threads make that unreliable.
-- SessionLifecycle workers (ChipAudio / Fetch / Check) make that warning
-- real -- endProcess joins them, then the native restart still blows up.
local osName = love.system and love.system.getOS and love.system.getOS()
local inProcessReturn = (osName == "Android" or osName == "iOS")
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
return Game and not Importer and not quitToLauncher and not scripted
and (isAndroid or not launchedIntoGame)
and (inProcessReturn or not launchedIntoGame)
end)
if wouldReturnToLauncher then
if isAndroid then
if inProcessReturn then
returnToLauncher()
return true -- abort this quit; the restart lands back in the launcher
return true -- abort this quit; stay in the same LOVE run
end
quitToLauncher = true
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
@@ -1170,6 +1187,17 @@ function love.run()
-- per-frame sleep-granularity jitter.
local nextFrame = love.timer and love.timer.getTime() or 0
local dt = 0
local idleFor = 0
local SLEEP_FLOOR = 0.001
local WAKE = {
keypressed = true, keyreleased = true, textinput = true,
mousepressed = true, mousereleased = true, mousemoved = true,
wheelmoved = true, touchpressed = true, touchreleased = true,
touchmoved = true, joystickpressed = true, joystickreleased = true,
joystickhat = true, gamepadpressed = true, gamepadreleased = true,
joystickadded = true, joystickremoved = true, filedropped = true,
directorydropped = true, focus = true, visible = true, resize = true,
}
return function()
-- process events
@@ -1188,17 +1216,34 @@ function love.run()
return a or 0
end
end
if WAKE[name] then
idleFor = 0
elseif name == "joystickaxis" and type(c) == "number" and math.abs(c) > 0.5 then
idleFor = 0
end
love.handlers[name](a, b, c, d, e, f)
end
end
-- update dt
if love.timer then dt = love.timer.step() end
idleFor = idleFor + dt
-- call update and draw
if love.update then love.update(dt) end
if love.graphics and love.graphics.isActive() then
local visible = not (love.window and love.window.isVisible)
or love.window.isVisible()
local focused = not (love.window and love.window.hasFocus)
or love.window.hasFocus()
local cap = FrameCap.current
if not visible then
cap = 10
elseif Importer and (not focused or idleFor > 30) then
cap = 15
end
if visible and love.graphics and love.graphics.isActive() then
love.graphics.origin()
love.graphics.clear(love.graphics.getBackgroundColor())
if love.draw then love.draw() end
@@ -1209,9 +1254,9 @@ function love.run()
if paced then
-- Sleep out the remainder of the frame budget, measured from the
-- carried deadline, in small chunks so the OS timer stays
-- responsive. vsync is untouched: when it already paces slower
-- than the cap the remainder is <= 0 and this rounds to a no-op.
local budget = 1 / FrameCap.current
-- responsive. The pacer yields to vsync inside a 1ms dead band, so
-- when the panel already paces at or below the cap it is a no-op.
local budget = 1 / cap
nextFrame = nextFrame + budget
local now = love.timer.getTime()
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
@@ -1222,8 +1267,8 @@ function love.run()
end
while true do
local remaining = nextFrame - love.timer.getTime()
if remaining <= 0 then break end
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
if remaining <= SLEEP_FLOOR then break end
love.timer.sleep(0.001)
end
else
love.timer.sleep(0.001)
+5
View File
@@ -100,6 +100,11 @@ love-android 11.5a expects:
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
`local.properties` when it finds `~/Library/Android/sdk`.
**ShaderFX bridge**: `scripts/build_android.sh` bundles
`liblibrashader_bridge.so` for arm64-v8a and armeabi-v7a via `cargo ndk` (or
from `SHADERFX_BRIDGE_ANDROID_DIR`), and warns and continues when neither is
available; see `docs/shaderfx.md`.
Gradle flavor used: **`embedNoRecord`** (game fused into the APK, no microphone).
Build task: `assembleEmbedNoRecordDebug`.
@@ -182,7 +182,7 @@ void System::vibrate(double seconds) const
#endif
}
bool System::pickFile(const char *kind) const
bool System::pickFile(const char *kind, const char *destination) const
{
#ifdef LOVE_ANDROID
const char *dest = "picked_rom.gb";
@@ -193,7 +193,8 @@ bool System::pickFile(const char *kind) const
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
dest = "picked_save.sav";
else if (strcmp(kind, "required_import") == 0)
dest = "picked_required_import.bin";
dest = (destination != nullptr && destination[0] != '\0')
? destination : "picked_required_import.bin";
else if (strcmp(kind, "rom") == 0)
dest = "picked_rom.gb";
// Unknown kinds used to fall through to the ROM destination. Refuse them
@@ -116,7 +116,7 @@ public:
* "required_import" -> picked_required_import.bin.
* @return Whether the picker was shown.
**/
virtual bool pickFile(const char *kind = nullptr) const;
virtual bool pickFile(const char *kind = nullptr, const char *destination = nullptr) const;
virtual const char *pickFileKinds() const;
/**
@@ -101,7 +101,8 @@ int w_vibrate(lua_State *L)
int w_pickFile(lua_State *L)
{
const char *kind = luaL_optstring(L, 1, nullptr);
luax_pushboolean(L, instance()->pickFile(kind));
const char *destination = luaL_optstring(L, 2, nullptr);
luax_pushboolean(L, instance()->pickFile(kind, destination));
return 1;
}
@@ -39,6 +39,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.security.MessageDigest;
import android.Manifest;
import android.app.AlarmManager;
@@ -111,6 +112,8 @@ public class GameActivity extends SDLActivity {
// basename as its body, so RomImporter:focus can say so in the launcher
// instead of leaving the player on "No ROM imported" (issue #442).
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
// Written after a direct required-import copy has been fully published.
private static final String PICK_COMPLETE_FILENAME = "pick_complete.flag";
// Step bridge (love.system.syncHealthSteps): pending-steps delivery
// consumed by the Pokéwalker mod, same contract as the iOS
// GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
@@ -576,6 +579,21 @@ public class GameActivity extends SDLActivity {
* picked_rom.gb, picked_mod.zip, picked_save.sav, or
* picked_required_import.bin)
*/
private static boolean isDirectRequiredDestination(String relative) {
if (relative == null || relative.length() == 0 || relative.startsWith("/")) return false;
String normalized = relative.replace('\\', '/');
if (!normalized.startsWith("mods/")) return false;
int marker = normalized.indexOf("/baseroms/");
if (marker <= "mods/".length() || marker + "/baseroms/".length() >= normalized.length()) {
return false;
}
return !normalized.contains("//")
&& !normalized.equals("..")
&& !normalized.startsWith("../")
&& !normalized.contains("/../")
&& !normalized.endsWith("/..");
}
/** Legacy single-argument entry; resolves the save dir itself. */
@Keep
public static boolean showFilePicker(String destFilename) {
@@ -593,14 +611,30 @@ public class GameActivity extends SDLActivity {
// onActivityResult copies the pick there, not into a recomputed
// (possibly different-volume) root (#604, #839).
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
// Reject path separators so a hostile JNI caller cannot escape the
// save identity directory.
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
// Basename destinations keep the historical ROM/mod/save staging path.
// A nested destination is accepted only for an engine-generated mod
// baseroms path, then canonicalized beneath LOVE's mounted save root.
String normalizedDest = destFilename.replace('\\', '/');
boolean nested = normalizedDest.indexOf('/') >= 0;
if (nested && !isDirectRequiredDestination(normalizedDest)) {
Log.d("GameActivity", "refusing non-baseroms picker dest: " + destFilename);
return false;
}
try {
File rootCanonical = self.saveIdentityDir().getCanonicalFile();
File destCanonical = new File(rootCanonical, normalizedDest).getCanonicalFile();
String rootPrefix = rootCanonical.getPath() + File.separator;
if (destCanonical.equals(rootCanonical)
|| !destCanonical.getPath().startsWith(rootPrefix)) {
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
return false;
}
} catch (IOException e) {
Log.d("GameActivity", "could not validate picker dest: " + e.getMessage());
return false;
}
self.pendingPickFilename = destFilename;
self.pendingPickFilename = normalizedDest;
if (android.os.Build.VERSION.SDK_INT >= 21) {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
@@ -1277,13 +1311,7 @@ public class GameActivity extends SDLActivity {
/** Drops a small flag file in the save identity for Lua to consume on focus. */
private void writeSaveDirFlag(String name, String body) {
try {
FileOutputStream fos = new FileOutputStream(new File(saveIdentityDir(), name), false);
fos.write(body.getBytes());
fos.close();
} catch (IOException e) {
Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
}
writeFlagFile(saveIdentityDir(), name, body);
}
/**
@@ -1506,9 +1534,26 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "could not create " + destDir);
return;
}
String destName = pendingPickFilename != null
final String destName = pendingPickFilename != null
? pendingPickFilename : PICKED_ROM_FILENAME;
File destFile = new File(destDir, destName);
final boolean directRequired = isDirectRequiredDestination(destName);
final File destFile;
try {
File rootCanonical = destDir.getCanonicalFile();
destFile = new File(rootCanonical, destName).getCanonicalFile();
String rootPrefix = rootCanonical.getPath() + File.separator;
if (destFile.equals(rootCanonical)
|| !destFile.getPath().startsWith(rootPrefix)
|| (destName.indexOf('/') >= 0 && !directRequired)) {
Log.d("GameActivity", "refusing unsafe result dest: " + destName);
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
return;
}
} catch (IOException e) {
Log.d("GameActivity", "could not validate result dest: " + e.getMessage());
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
return;
}
// ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but
// some OEM shells (ColorOS) offer third-party file managers in a
@@ -1534,15 +1579,110 @@ public class GameActivity extends SDLActivity {
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
return;
}
if (!copyAssetFile(source, destFile.getPath())) {
final InputStream pickedSource = source;
final File pickedRoot = destDir;
if (directRequired) {
// Optical-disc-sized imports must not block Android's UI thread and
// must not create a second picked_required_import.bin copy.
new Thread(new Runnable() {
@Override public void run() {
PickCopyResult result = copyRequiredImport(pickedSource, destFile);
if (!result.ok) {
writeFlagFile(pickedRoot, PICK_ERROR_FILENAME, destName);
return;
}
String marker = "v1\n" + destName + "\n" + result.md5 + "\n"
+ Long.toString(result.bytes) + "\n";
writeFlagFile(pickedRoot, PICK_COMPLETE_FILENAME, marker);
}
}, "gen1recomp-required-import").start();
return;
}
if (!copyAssetFile(pickedSource, destFile.getPath())) {
Log.d("GameActivity", "could not copy picked file to " + destFile);
// A truncated pick would only fail verification later, so drop it
// and report instead.
destFile.delete();
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
}
}
private static final class PickCopyResult {
boolean ok = false;
long bytes = 0;
String md5 = "";
}
private static String hex(byte[] bytes) {
StringBuilder out = new StringBuilder(bytes.length * 2);
for (byte b : bytes) out.append(String.format(Locale.US, "%02x", b & 0xff));
return out.toString();
}
private static void writeFlagFile(File dir, String name, String body) {
try {
if (!dir.isDirectory() && !dir.mkdirs()) return;
File tmp = new File(dir, name + ".tmp");
File dest = new File(dir, name);
FileOutputStream out = new FileOutputStream(tmp, false);
out.write(body.getBytes("UTF-8"));
out.getFD().sync();
out.close();
if (dest.exists() && !dest.delete()) { tmp.delete(); return; }
if (!tmp.renameTo(dest)) tmp.delete();
} catch (Exception e) {
Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
}
}
private static PickCopyResult copyRequiredImport(InputStream source, File destination) {
PickCopyResult result = new PickCopyResult();
File parent = destination.getParentFile();
File partial = new File(destination.getPath() + ".part");
BufferedInputStream in = null;
BufferedOutputStream out = null;
FileOutputStream rawOut = null;
try {
if (parent != null && !parent.isDirectory() && !parent.mkdirs()) return result;
if (partial.exists() && !partial.delete()) return result;
MessageDigest md5 = MessageDigest.getInstance("MD5");
in = new BufferedInputStream(source, 1024 * 1024);
rawOut = new FileOutputStream(partial, false);
out = new BufferedOutputStream(rawOut, 1024 * 1024);
byte[] buf = new byte[1024 * 1024];
int n;
long total = 0;
while ((n = in.read(buf)) != -1) {
if (n == 0) continue;
out.write(buf, 0, n);
md5.update(buf, 0, n);
total += n;
}
out.flush();
rawOut.getFD().sync();
out.close(); out = null; rawOut = null;
in.close(); in = null;
// Publish only a complete same-directory file. Validation still
// happens in Lua against the manifest before a receipt is written.
if (destination.exists() && !destination.delete()) return result;
if (!partial.renameTo(destination)) return result;
result.ok = true;
result.bytes = total;
result.md5 = hex(md5.digest());
Log.d("GameActivity", "direct required import copied " + total
+ " bytes to " + destination);
return result;
} catch (Exception e) {
Log.d("GameActivity", "direct required import failed: " + e.getMessage());
return result;
} finally {
try { if (in != null) in.close(); } catch (IOException ignored) {}
try { if (out != null) out.close(); } catch (IOException ignored) {}
try { if (rawOut != null) rawOut.close(); } catch (IOException ignored) {}
if (!result.ok && partial.exists()) partial.delete();
}
}
/**
* Copies a given file from the assets folder to the destination.
*
@@ -1565,13 +1705,12 @@ public class GameActivity extends SDLActivity {
assert (source != null && destination != null);
try {
byte[] buf = new byte[1024];
chunk_read = source.read(buf);
do {
byte[] buf = new byte[1024 * 1024];
while ((chunk_read = source.read(buf)) != -1) {
if (chunk_read == 0) continue;
destination.write(buf, 0, chunk_read);
bytes_written += chunk_read;
chunk_read = source.read(buf);
} while (chunk_read != -1);
}
} catch (IOException e) {
Log.d("GameActivity", "Copying failed:" + e.getMessage());
}
+1
View File
@@ -52,6 +52,7 @@ The script verifies the final app before packaging it:
- the public Documents plist settings are present
- the native picker bridge is present
- the SHADER FX librashader bridge is linked into the app binary, when this build produced one
- `game.love` exists and is non-empty
If the payload is missing, the build fails instead of producing a blank app.
+28
View File
@@ -12,6 +12,34 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.2.26",
"date": "2026-08-25",
"size": 21326715,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.26/gen1recomp++-0.2.26-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #917 Save editor still unable to save\n- #1074 Yellow ROM Importer may be broken\n- #1635 Missing FX jingle sounds for items\n- #1639 [Yellow] Summer Beach House colors in Advanced mode\n- #1644 Minor bug: Fly's screen fade is black instead of white\n- #1743 Super Nerd in Mt. Moon Wrong Dialogue\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @campavao\n- @HighDrexler"
},
{
"version": "0.2.25",
"date": "2026-08-25",
"size": 21307139,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.25/gen1recomp++-0.2.25-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #941 Screen Size in Portrait\n- #1081 OS Slideshow\n- #1349 Modded Transparent Sprites getting cropped\n- #1602 Default on screen controls are different between RBY and Gold\n- #1618 [Silver] Potions [the item] are able to use rock smash as if they are Pokemon\n- #1624 gen 2 fullscreen\n- #1630 Gold missing attack feedback\n- #1633 #1488 Issue fixed, but a minor blemish remains.\n- #1661 bug: built in controls are missing.\n- #1669 gold item pc cannot hold two stacks of the same item id\n- #1676 Battle size: world\n- #1731 (Gold) 2 turn moves are broken\n- #1739 Town Map Sprite Icon dont have Color in Advanced Color Setting\n- #1740 Misaligned ! Box\n- #1742 Healing Screen\n- #1744 foresight does not allow normal type moves to hit ghosts\n- #1753 Pokémon show up in wide battle layout and use the incorrect colour palette\n- #1757 Potential deviations in game mechanics\n- #1758 Smog animation colors\n- #1760 \"Sent some money to mom\" cut off\n- #1761 Return / Frustration have incorrect base power\n- #1762 No incoming phone calls\n- #1763 [Gen2] Title Screen should restart after song ends\n- #1765 Battle animations all black when Flash isn't used in Rock Tunnel\n- #1770 \"Go!\" text visible when opening item menu\n- #1777 bug: Frame pacing micro stutter above 60Hz on steam deck oled\n- #1781 Missing Pokemon cries in Lavender Town\n- #1783 Order of items displayed is different from inventory when depositing\n- #1784 Pokemon cries not played in Celadon City\n- #1785 Wrong text order when picking up Eevee\n- #1787 Blackboard in Celadon Mansion is a little buggy\n- #1788 Cry of Pokemon that's about to evolve isn't played\n- #1789 Champion Blue doesn't force battle animations\n- #1790 Kris is red in battle screen\n- #1792 Violet City Bellsprout for Onix Trade Offers Bellsprout for Bellsprout\n- #1797 Pokedex diploma has minor visual errors\n- #1802 Weird persistant CPU usage spike after viewing mod list\n- #1803 A couple of issues in the secret house\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @campavao\n- MaxTomahawk"
},
{
"version": "0.2.24",
"date": "2026-08-24",
"size": 14060528,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.24/gen1recomp++-0.2.24-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.23",
"date": "2026-08-24",
"size": 14060437,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.23/gen1recomp++-0.2.23-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.22",
"date": "2026-08-24",
+101 -4
View File
@@ -13,6 +13,7 @@
import UIKit
import UniformTypeIdentifiers
import CryptoKit
@objc(GRPickerBridge)
public final class GRPickerBridge: NSObject {
@@ -190,9 +191,17 @@ public final class GRPickerBridge: NSObject {
@objc(presentPickerWithKind:saveDir:)
public static func presentPicker(kind: UnsafePointer<CChar>?,
saveDir: UnsafePointer<CChar>?) -> Bool {
return presentPicker(kind: kind, saveDir: saveDir, destination: nil)
}
@objc(presentPickerWithKind:saveDir:destination:)
public static func presentPicker(kind: UnsafePointer<CChar>?,
saveDir: UnsafePointer<CChar>?,
destination: UnsafePointer<CChar>?) -> Bool {
let kindStr = kind.map { String(cString: $0) } ?? "rom"
guard let dir = resolvedSaveDir(saveDir) else { return false }
let requestedDestination = destination.map { String(cString: $0) }
let destName: String
var types: [UTType] = []
switch kindStr {
@@ -202,7 +211,13 @@ public final class GRPickerBridge: NSObject {
case "sav":
destName = "picked_save.sav"
case "required_import":
destName = "picked_required_import.bin"
if let requestedDestination,
isDirectRequiredDestination(requestedDestination),
safeDestination(in: dir, relative: requestedDestination) != nil {
destName = requestedDestination
} else {
destName = "picked_required_import.bin"
}
// 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
@@ -234,12 +249,18 @@ public final class GRPickerBridge: NSObject {
types.append(.data)
if !types.contains(.item) { types.append(.item) }
let directRequired = kindStr == "required_import"
&& destName != "picked_required_import.bin"
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types,
asCopy: true)
asCopy: !directRequired)
picker.allowsMultipleSelection = false
let delegate = PickerDelegate { urls in
guard let src = urls.first else { return }
copyItem(at: src, into: dir, named: destName)
if directRequired {
copyRequiredItemAsync(at: src, into: dir, relative: destName)
} else {
copyItem(at: src, into: dir, named: destName)
}
}
return present(picker, with: delegate)
}
@@ -410,6 +431,82 @@ public final class GRPickerBridge: NSObject {
try? fm.moveItem(at: item, to: target)
}
private static func isDirectRequiredDestination(_ relative: String) -> Bool {
let normalized = relative.replacingOccurrences(of: "\\", with: "/")
guard normalized.hasPrefix("mods/"),
let range = normalized.range(of: "/baseroms/"),
range.lowerBound > normalized.index(normalized.startIndex, offsetBy: 5),
range.upperBound < normalized.endIndex else { return false }
return !normalized.hasPrefix("/")
&& !normalized.contains("//")
&& !normalized.contains("/../")
&& !normalized.hasSuffix("/..")
}
private static func safeDestination(in root: URL, relative: String) -> URL? {
guard isDirectRequiredDestination(relative) else { return nil }
let rootURL = root.standardizedFileURL
let candidate = rootURL.appendingPathComponent(relative).standardizedFileURL
let rootPath = rootURL.path.hasSuffix("/") ? rootURL.path : rootURL.path + "/"
guard candidate.path.hasPrefix(rootPath) else { return nil }
return candidate
}
private static func writeFlag(in dir: URL, name: String, body: String) {
try? body.data(using: .utf8)?.write(to: dir.appendingPathComponent(name),
options: .atomic)
}
private static func copyRequiredItemAsync(at src: URL, into dir: URL,
relative: String) {
DispatchQueue.global(qos: .userInitiated).async {
let scoped = src.startAccessingSecurityScopedResource()
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
guard let dest = safeDestination(in: dir, relative: relative) else {
writeFlag(in: dir, name: "pick_error.flag", body: relative)
return
}
let fm = FileManager.default
ensureDirectory(dest.deletingLastPathComponent())
let partial = URL(fileURLWithPath: dest.path + ".part")
try? fm.removeItem(at: partial)
guard fm.createFile(atPath: partial.path, contents: nil) else {
writeFlag(in: dir, name: "pick_error.flag", body: relative)
return
}
var hasher = Insecure.MD5()
var total: UInt64 = 0
do {
let input = try FileHandle(forReadingFrom: src)
let output = try FileHandle(forWritingTo: partial)
defer {
try? input.close()
try? output.close()
}
while true {
let data = input.readData(ofLength: 1024 * 1024)
if data.isEmpty { break }
hasher.update(data: data)
output.write(data)
total += UInt64(data.count)
}
output.synchronizeFile()
try? fm.removeItem(at: dest)
try fm.moveItem(at: partial, to: dest)
let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined()
let marker = "v1\n" + relative + "\n" + digest + "\n"
+ String(total) + "\n"
writeFlag(in: dir, name: "pick_complete.flag", body: marker)
NSLog("GRPickerBridge: direct required import delivered %llu bytes", total)
} catch {
try? fm.removeItem(at: partial)
NSLog("GRPickerBridge: direct required import failed: \(error)")
writeFlag(in: dir, name: "pick_error.flag", body: relative)
}
}
}
private static func copyItem(at src: URL, into dir: URL, named name: String) {
let scoped = src.startAccessingSecurityScopedResource()
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
@@ -426,7 +523,7 @@ public final class GRPickerBridge: NSObject {
let report = "Could not copy \(src.lastPathComponent): " +
error.localizedDescription
try? report.data(using: .utf8)?
.write(to: dir.appendingPathComponent("pick_error.txt"))
.write(to: dir.appendingPathComponent("pick_error.flag"))
}
}
+14 -1
View File
@@ -93,7 +93,20 @@ static int gr_callBridge(lua_State *L, const char *className,
int w_pickFile(lua_State *L)
{
const char *kind = luaL_optstring(L, 1, "rom");
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
const char *destination = luaL_optstring(L, 2, nullptr);
Class cls = objc_getClass("GRPickerBridge");
if (cls == nullptr)
{
lua_pushboolean(L, 0);
return 1;
}
typedef signed char (*GRPick)(Class, SEL, const char *, const char *,
const char *);
signed char ok = ((GRPick)objc_msgSend)(
cls, sel_registerName("presentPickerWithKind:saveDir:destination:"),
kind, gr_saveDirectory(), destination);
lua_pushboolean(L, ok != 0);
return 1;
}
// love.system.pickFileKinds() -> the comma-separated kinds supported by the
+117
View File
@@ -106,6 +106,13 @@ if [ -n "$TEST_APPLICATION_ID" ]; then
APPLICATION_ID="$TEST_APPLICATION_ID"
APP_NAME="$APP_NAME (test)"
fi
# Optional overrides for side-by-side test APKs (never used by CI shipping builds).
if [ -n "${GEN1RECOMP_ANDROID_APPLICATION_ID:-}" ]; then
APPLICATION_ID="$GEN1RECOMP_ANDROID_APPLICATION_ID"
fi
if [ -n "${GEN1RECOMP_ANDROID_APP_NAME:-}" ]; then
APP_NAME="$GEN1RECOMP_ANDROID_APP_NAME"
fi
# --------------------------------------------------------------- preconditions
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
@@ -461,6 +468,115 @@ pack_game_love() {
fi
}
# --------------------------------------------------------------- ShaderFX bridge
SHADER_BRIDGE_LIB="liblibrashader_bridge.so"
SHADER_BRIDGE_ABIS="arm64-v8a armeabi-v7a"
shader_bridge_rust_target() {
case "$1" in
arm64-v8a) printf 'aarch64-linux-android' ;;
armeabi-v7a) printf 'armv7-linux-androideabi' ;;
x86_64) printf 'x86_64-linux-android' ;;
x86) printf 'i686-linux-android' ;;
*) printf '' ;;
esac
}
shader_bridge_staged_count() {
local jni="$1" abi count=0
for abi in $SHADER_BRIDGE_ABIS; do
[ -f "$jni/$abi/$SHADER_BRIDGE_LIB" ] && count=$((count + 1))
done
printf '%s' "$count"
}
bundle_shader_bridge_android() {
local jni="$ANDROID_DIR/app/src/main/jniLibs"
local crate="$ROOT/tools/shaderfx-bridge"
local abi target
for abi in $SHADER_BRIDGE_ABIS; do
rm -f "$jni/$abi/$SHADER_BRIDGE_LIB"
done
local prebuilt="${SHADERFX_BRIDGE_ANDROID_DIR:-}"
if [ -n "$prebuilt" ]; then
for abi in $SHADER_BRIDGE_ABIS; do
if [ -f "$prebuilt/$abi/$SHADER_BRIDGE_LIB" ]; then
mkdir -p "$jni/$abi"
cp "$prebuilt/$abi/$SHADER_BRIDGE_LIB" "$jni/$abi/$SHADER_BRIDGE_LIB"
else
warn "SHADERFX_BRIDGE_ANDROID_DIR has no $abi/$SHADER_BRIDGE_LIB"
fi
done
if [ "$(shader_bridge_staged_count "$jni")" -gt 0 ]; then
say "bundled $SHADER_BRIDGE_LIB for SHADER FX preset conversion (prebuilt)"
return
fi
fi
if [ ! -f "$crate/Cargo.toml" ]; then
warn "$SHADER_BRIDGE_LIB not found: this build can run converted presets but not CONVERT new ones (tools/shaderfx-bridge is missing)"
return
fi
if ! command -v cargo >/dev/null 2>&1 || ! cargo ndk --version >/dev/null 2>&1; then
warn "$SHADER_BRIDGE_LIB not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE_ANDROID_DIR or run 'cargo install cargo-ndk')"
return
fi
local ndk="${ANDROID_NDK_HOME:-}"
if [ -z "$ndk" ] || [ ! -d "$ndk" ]; then
ndk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Library/Android/sdk}}/ndk/$NDK_VERSION"
fi
if [ ! -d "$ndk" ]; then
warn "$SHADER_BRIDGE_LIB not built: NDK $NDK_VERSION not found (set ANDROID_NDK_HOME)"
return
fi
local installed missing="" buildable=""
installed="$(rustup target list --installed 2>/dev/null || true)"
for abi in $SHADER_BRIDGE_ABIS; do
target="$(shader_bridge_rust_target "$abi")"
if grep -qx "$target" <<< "$installed"; then
buildable="$buildable $abi"
else
missing="$missing $target"
fi
done
if [ -n "$missing" ]; then
warn "$SHADER_BRIDGE_LIB: skipping$missing. Run: rustup target add$missing"
fi
if [ -z "$buildable" ]; then
warn "$SHADER_BRIDGE_LIB not built: this build can run converted presets but not CONVERT new ones (no Android Rust targets installed)"
return
fi
local args=()
for abi in $buildable; do
args+=(-t "$abi")
done
say "building the ShaderFX bridge with cargo-ndk (${buildable# })"
mkdir -p "$jni"
if ! (
cd "$crate"
export ANDROID_NDK_HOME="$ndk"
export ANDROID_NDK_ROOT="$ndk"
export CARGO_PROFILE_RELEASE_STRIP="symbols"
cargo ndk "${args[@]}" -o "$jni" build --release
); then
warn "$SHADER_BRIDGE_LIB failed to cross-compile: this build can run converted presets but not CONVERT new ones"
return
fi
if [ "$(shader_bridge_staged_count "$jni")" -gt 0 ]; then
say "bundled $SHADER_BRIDGE_LIB for SHADER FX preset conversion"
else
warn "$SHADER_BRIDGE_LIB not found after cargo-ndk: this build can run converted presets but not CONVERT new ones"
fi
}
# --------------------------------------------------------------- SDK check
require_android_sdk() {
local sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
@@ -575,5 +691,6 @@ if $PACKAGE_ONLY; then
fi
require_android_sdk
bundle_shader_bridge_android
run_gradle
say "done"
+149 -3
View File
@@ -615,6 +615,138 @@ verify_game_payload() {
say "game.love present ($(du -h "$app/game.love" | cut -f1))"
}
SHADER_BRIDGE_LIB=""
SHADER_BRIDGE_NAME="liblibrashader_bridge.a"
SHADER_BRIDGE_OBJ="librashader_bridge.o"
SHADER_BRIDGE_ARCHS=""
rust_targets_for_sdk() {
if [ "$1" = "iphoneos" ]; then
printf 'aarch64-apple-ios'
else
printf 'aarch64-apple-ios-sim x86_64-apple-ios'
fi
}
build_shader_bridge_slice() {
local rust_target="$1"
local crate="$ROOT/tools/shaderfx-bridge"
local built="$crate/target/$rust_target/release/$SHADER_BRIDGE_NAME"
if [ -f "$built" ]; then
printf '%s' "$built"
return 0
fi
if command -v cargo >/dev/null 2>&1 \
&& rustup target list --installed 2>/dev/null \
| grep -x "$rust_target" >/dev/null; then
say "building the ShaderFX bridge with cargo ($rust_target)" >&2
if (cd "$crate" && IPHONEOS_DEPLOYMENT_TARGET=15.0 \
cargo build --release --target "$rust_target" >/dev/null 2>&1); then
printf '%s' "$built"
return 0
fi
fi
return 1
}
prelink_shader_bridge_slice() {
local archive="$1" sdk="$2" out="$3"
local arch platform sdk_version syms objcopy tmp
arch="$(lipo -archs "$archive" 2>/dev/null | awk '{print $1}')"
[ -n "$arch" ] || return 1
if [ "$sdk" = "iphoneos" ]; then platform="ios"; else platform="ios-simulator"; fi
sdk_version="$(xcrun --sdk "$sdk" --show-sdk-version 2>/dev/null)"
[ -n "$sdk_version" ] || return 1
syms="$LIBS_DIR/librashader_bridge.exports"
printf '_librashader_translate_preset\n_librashader_free_string\n' > "$syms"
tmp="$out.tmp"
xcrun ld -r -arch "$arch" -platform_version "$platform" 15.0 "$sdk_version" \
-all_load -exported_symbols_list "$syms" -o "$tmp" "$archive" || return 1
objcopy="$(ls "$(rustc --print sysroot 2>/dev/null)"/lib/rustlib/*/bin/rust-objcopy 2>/dev/null | head -1)"
if [ -n "$objcopy" ] && "$objcopy" --remove-section __LLVM,__bitcode \
--remove-section __LLVM,__cmdline "$tmp" "$out" 2>/dev/null; then
rm -f "$tmp"
else
mv "$tmp" "$out"
fi
}
bundle_shader_bridge_ios() {
local rust_targets="$1" sdk="$2"
local src="${SHADERFX_BRIDGE_IOS:-}"
local slices=() objs=() target slice obj i
SHADER_BRIDGE_LIB=""
SHADER_BRIDGE_ARCHS=""
rm -f "$LIBS_DIR/$SHADER_BRIDGE_NAME" "$LIBS_DIR/$SHADER_BRIDGE_OBJ" "$LIBS_DIR"/librashader_bridge.*.o
if [ -n "$src" ]; then
if [ -f "$src" ]; then
slices+=("$src")
else
warn "SHADERFX_BRIDGE_IOS=$src does not exist"
fi
else
for target in $rust_targets; do
if slice="$(build_shader_bridge_slice "$target")"; then
slices+=("$slice")
fi
done
fi
if [ "${#slices[@]}" -gt 0 ]; then
mkdir -p "$LIBS_DIR"
i=0
for slice in "${slices[@]}"; do
i=$((i + 1))
obj="$LIBS_DIR/librashader_bridge.$i.o"
if prelink_shader_bridge_slice "$slice" "$sdk" "$obj"; then
objs+=("$obj")
else
warn "could not prelink $(basename "$slice") for $sdk"
fi
done
fi
if [ "${#objs[@]}" -eq 1 ]; then
mv "${objs[0]}" "$LIBS_DIR/$SHADER_BRIDGE_OBJ"
elif [ "${#objs[@]}" -gt 1 ]; then
lipo -create "${objs[@]}" -output "$LIBS_DIR/$SHADER_BRIDGE_OBJ"
rm -f "${objs[@]}"
fi
if [ -f "$LIBS_DIR/$SHADER_BRIDGE_OBJ" ]; then
SHADER_BRIDGE_LIB="$LIBS_DIR/$SHADER_BRIDGE_OBJ"
SHADER_BRIDGE_ARCHS="$(lipo -archs "$SHADER_BRIDGE_LIB" 2>/dev/null || true)"
say "linking $SHADER_BRIDGE_OBJ for SHADER FX preset conversion (${SHADER_BRIDGE_ARCHS:-unknown arch})"
else
warn "$SHADER_BRIDGE_NAME not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE_IOS, or install cargo plus one of: rustup target add $rust_targets)"
fi
}
verify_shader_bridge() {
local app="$1"
local exe bin
if [ -z "$SHADER_BRIDGE_LIB" ]; then
warn "no SHADER FX bridge in this build: CONVERT stays unavailable, converted presets still run"
return 0
fi
exe="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' \
"$app/Info.plist" 2>/dev/null || true)"
bin="$app/${exe:-love}"
[ -f "$bin" ] || bin="$app/love"
if [ ! -f "$bin" ]; then
warn "no executable inside $(basename "$app"); skipping SHADER FX bridge check"
return 0
fi
if nm "$bin" 2>/dev/null | grep -E ' _librashader_translate_preset$' >/dev/null \
|| xcrun dyld_info -exports "$bin" 2>/dev/null \
| grep -E ' _librashader_translate_preset$' >/dev/null; then
say "SHADER FX bridge present (librashader_translate_preset)"
return 0
fi
fail "built app does not carry the SHADER FX bridge symbols.
$SHADER_BRIDGE_OBJ was linked but librashader_translate_preset is absent,
so SHADER FX CONVERT would fail at runtime.
Rebuild after: rm -rf tools/shaderfx-bridge/target"
}
run_xcodebuild() {
local config sdk destination
if $RELEASE; then
@@ -633,6 +765,8 @@ run_xcodebuild() {
mkdir -p "$BUILD_DIR"
bundle_shader_bridge_ios "$(rust_targets_for_sdk "$sdk")" "$sdk"
# Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires
# -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes.
# Always stamp both: the overlay plist expands $(MARKETING_VERSION) /
@@ -661,6 +795,13 @@ run_xcodebuild() {
ONLY_ACTIVE_ARCH=NO
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
)
if [ -n "$SHADER_BRIDGE_LIB" ]; then
args+=(OTHER_LDFLAGS="-Wl,-u,_librashader_translate_preset -Wl,-u,_librashader_free_string \"$SHADER_BRIDGE_LIB\" -lc++")
if [ -n "$SHADER_BRIDGE_ARCHS" ]; then
args+=(ARCHS="$SHADER_BRIDGE_ARCHS")
fi
fi
if ! $DEVICE; then
# Simulator: ad-hoc signing (no certificate needed). A plain unsigned
# build would drop the entitlements file, and HealthKit refuses to run
@@ -723,11 +864,14 @@ run_xcodebuild() {
local products="$BUILD_DIR/Build/Products/${config}-${sdk}"
local app=""
local candidate
local candidate newest=0 mtime
for candidate in "$products/$PRODUCT_NAME.app" "$products/$APP_NAME.app" "$products/love.app"; do
if [ -d "$candidate" ]; then
app="$candidate"
break
mtime="$(stat -f %m "$candidate" 2>/dev/null || echo 0)"
if [ "$mtime" -gt "$newest" ]; then
newest="$mtime"
app="$candidate"
fi
fi
done
if [ -z "$app" ]; then
@@ -736,6 +880,7 @@ run_xcodebuild() {
return 0
fi
if [ "$app" != "$products/$APP_NAME.app" ]; then
rm -rf "$products/$APP_NAME.app"
mv "$app" "$products/$APP_NAME.app"
app="$products/$APP_NAME.app"
fi
@@ -754,6 +899,7 @@ run_xcodebuild() {
verify_game_payload "$app"
verify_native_bridge "$app"
verify_shader_bridge "$app"
local dist_dir="$DIST/${config}-${sdk}"
rm -rf "$dist_dir"
+1
View File
@@ -114,6 +114,7 @@ fi
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
run_tier "T0 Yellow title OBP eye remap" python3 tests/title_pikachu_obp_test.py
run_tier "T0 Crystal manifest + specials coverage" "$LUA" tests/crystal_import_test.lua
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
+161 -48
View File
@@ -97,7 +97,7 @@ end
-- space.
function BattleState:extendedHUD()
local options = self.game and self.game.save and self.game.save.options
local bg = options and options.battleBg
local bg = options and self:bgMode()
return self:wideLayout()
and options and options.battleHud == "extended"
and ((options.battleFit == "fixed"
@@ -108,12 +108,12 @@ end
function BattleState:extendedWorldHUD()
local options = self.game and self.game.save and self.game.save.options
return self:extendedHUD() and options
and options.battleFit == "fixed" and options.battleBg == "world"
and options.battleFit == "fixed" and self:bgMode() == "world"
end
function BattleState:extendedBlackHUD()
local options = self.game and self.game.save and self.game.save.options
return self:extendedHUD() and options and options.battleBg == "black"
return self:extendedHUD() and options and self:bgMode() == "black"
end
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
@@ -130,6 +130,10 @@ end
-- opaque 160x144 field over the top, so only the surround changes.
function BattleState:bgMode()
local options = self.game and self.game.save and self.game.save.options
if options and self:wideLayout() and options.battleFit == "fill"
and options.battleHud == "extended" then
return "white"
end
local mode = options and options.battleBg
if mode == "black" or mode == "world" then return mode end
return "white"
@@ -212,6 +216,13 @@ local BALL_ANIMS = {
SHAKE_ANIM = true, SHOWPIC_ANIM = true,
}
-- engine/battle/effects.asm:552
local ENEMY_STAT_DOWN_MISS = {
ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true,
DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true,
ACCURACY_DOWN1_EFFECT = true,
}
local imageCache = {}
-- The three tables below are keyed by the Image OBJECT, not by a path, and a
-- running battle holds the pics it built at enter() (battler.sprite,
@@ -229,6 +240,11 @@ local imagePadLeft = setmetatable({}, WEAK_KEYS)
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
-- rebuilt for any battle pic, whatever code loaded it
local imageMeta = setmetatable({}, WEAK_KEYS)
local function mattedPic(path)
return path:sub(1, 17) == "assets/generated/"
or path:sub(1, 17) == "save/mod-derived/"
end
-- pal = { name, colors } recolors the 4 GB shades like the Super Game Boy.
-- trueColor art (14 §the 4-shade contract) opts out of the quantize
-- entirely, so its palette variant collapses back onto the plain path.
@@ -254,29 +270,31 @@ local function getImage(path, pal, trueColor)
end)
end
local w, h = id:getDimensions()
local bottom = h - 1
while bottom >= 0 do
local opaque = false
for x = 0, w - 1 do
local _, _, _, a = id:getPixel(x, bottom)
if a > 0 then opaque = true break end
if mattedPic(Assets.resolve(path)) then
local bottom = h - 1
while bottom >= 0 do
local opaque = false
for x = 0, w - 1 do
local _, _, _, a = id:getPixel(x, bottom)
if a > 0 then opaque = true break end
end
if opaque then break end
bottom = bottom - 1
end
if opaque then break end
bottom = bottom - 1
end
local left = 0
while left < w do
local opaque = false
for y = 0, h - 1 do
local _, _, _, a = id:getPixel(left, y)
if a > 0 then opaque = true break end
local left = 0
while left < w do
local opaque = false
for y = 0, h - 1 do
local _, _, _, a = id:getPixel(left, y)
if a > 0 then opaque = true break end
end
if opaque then break end
left = left + 1
end
if opaque then break end
left = left + 1
pad = h - 1 - bottom
padL = left
end
img = love.graphics.newImage(id)
pad = h - 1 - bottom
padL = left
else
img = Assets.image(path) -- headless stub: no pixel access
end
@@ -2214,6 +2232,9 @@ function BattleState:update(dt)
self.waitFrames = nil
if destination == "menu" then
self.introSlide = nil
-- engine/battle/core.asm:2007
self.msgHold = nil
self.shown = nil
self.phase = "menu"
elseif destination == "finish" then
self:finish()
@@ -3041,18 +3062,33 @@ local BGP_INVERT = { [0] = 3, 2, 1, 0 } -- $1b (flash phase 1)
local BGP_WHITE = { [0] = 0, 0, 0, 0 } -- $00 (flash phase 2)
local BGP_DARK = { [0] = 3, 3, 2, 1 } -- $6f DarkScreenPalette
local BGP_LIGHT = { [0] = 0, 0, 1, 2 } -- $90 LightScreenPalette
local BGP_DARKEN = { [0] = 0, 1, 3, 3 } -- $f4 DarkenMonPalette (SGB)
local BGP_DARKEN_SGB = { [0] = 0, 1, 3, 3 } -- $f4
local BGP_DARKEN_MONO = { [0] = 1, 2, 3, 3 } -- $f9
-- engine/battle/animations.asm:1090
local function onSgb()
local m = require("src.render.PaletteFX").mode
return m == "gbc" or m == "gbc_inv"
end
-- FlashScreenLongSGB (animations.asm:1010): 12 BGP values per cycle,
-- 3 cycles; the first cycle holds each for 2 frames, the rest for 1
-- (FlashScreenLongDelay)
local FLASH_LONG_MAPS = {
local FLASH_LONG_SGB = {
{ [0] = 0, 2, 3, 3 }, { [0] = 0, 3, 3, 3 }, { [0] = 3, 3, 3, 3 },
{ [0] = 0, 3, 3, 3 }, { [0] = 0, 2, 3, 3 }, { [0] = 0, 1, 2, 3 },
{ [0] = 0, 0, 1, 2 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 0, 0 },
{ [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 1, 2, 3 },
}
-- engine/battle/animations.asm:992
local FLASH_LONG_MONO = {
{ [0] = 1, 2, 3, 3 }, { [0] = 2, 3, 3, 3 }, { [0] = 3, 3, 3, 3 },
{ [0] = 2, 3, 3, 3 }, { [0] = 1, 2, 3, 3 }, { [0] = 0, 1, 2, 3 },
{ [0] = 0, 0, 1, 2 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 0, 0 },
{ [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 1, 2, 3 },
}
-- the shade map in force this frame (a running flash wins over the
-- persistent palette)
function BattleState:activeBgp()
@@ -3186,7 +3222,7 @@ function BattleState:applyAnimEffect(ev)
elseif e == "SE_LIGHT_SCREEN_PALETTE" then
fx.bgp = BGP_LIGHT
elseif e == "SE_DARKEN_MON_PALETTE" then
fx.bgp = BGP_DARKEN
fx.bgp = onSgb() and BGP_DARKEN_SGB or BGP_DARKEN_MONO
elseif e == "SE_RESET_SCREEN_PALETTE" then
fx.bgp = nil
elseif e == "SE_DARK_SCREEN_FLASH" then
@@ -3196,8 +3232,9 @@ function BattleState:applyAnimEffect(ev)
idx = 1, left = 2 }
elseif e == "SE_FLASH_SCREEN_LONG" then
local steps = {}
local maps = onSgb() and FLASH_LONG_SGB or FLASH_LONG_MONO
for cycle = 1, 3 do
for _, m in ipairs(FLASH_LONG_MAPS) do
for _, m in ipairs(maps) do
steps[#steps + 1] = { map = m, frames = (cycle == 1) and 2 or 1 }
end
end
@@ -4139,6 +4176,12 @@ function BattleState:performMove(user, target, moveInst, isCalled)
-- pure status moves
if move.power == 0 and record and record.kind == "primary" and record.run then
if ENEMY_STAT_DOWN_MISS[move.effect] and not user.isPlayer
and self.kind ~= "link" and self.rng(0, 255) < 64 then
self:cancelMoveAnim()
self:sayNext(self:romText("_AttackMissedText", "%s's\nattack missed!", displayName(user)))
return
end
-- accuracy-checked status effects run MoveHitTest, which has no
-- 100%-accuracy early-out (even Thunder Wave misses on the 255
-- roll) and misses outright against a mid-Fly/Dig target; the
@@ -4417,7 +4460,11 @@ function BattleState:awardExp()
-- new current HP (house convention: potions drain the bar too, see
-- itemUsed) so the bar grows instead. Only the active player battler
-- shares its table with the HUD; other party mons (EXP.ALL) have no bar.
if mon == self.player.mon then self:drainNext() end
if mon == self.player.mon then
-- engine/battle/experience.asm:236
self.player.badgeExtraBoosts = nil
self:drainNext()
end
for _, moveId in ipairs(Experience.movesLearnedAt(
self.data.pokemon[mon.species], lv)) do
self:learnMove(mon, moveId)
@@ -4484,8 +4531,7 @@ function BattleState:enemyMonFainted()
-- remaining HP); SET / single-mon / fainted active skip the prompt.
local nextMon = self.enemyParty[self.enemyIndex]
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
:lower()
local style = self:battleStyle()
local partyCount = #self:playerPartyView()
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
@@ -5053,6 +5099,57 @@ function BattleState:ballMissMessage(shakes)
return t._ItemUseBallText04 or self:romText("_ItemUseBallText04", "Shoot! It was so\nclose too!")
end
-- ------- battle rules a mode may own
--
-- Two decisions the OPTION screen and the cart make for the player that a
-- game mode may want to make instead: whether a faint offers a free switch,
-- and whether a catch asks for a nickname. Each is a hook around the vanilla
-- answer, so a mode can force it without touching the player's saved
-- preference and without the player being able to change it mid-match.
--
-- The vanilla links are file-locals so an empty chain allocates no closure.
local function styleFromOptions(battle)
return tostring(((battle.game.save or {}).options or {}).battleStyle or "shift")
:lower()
end
local function alwaysAsk() return true end
-- "shift" or "set" for this battle. battle.style wraps the OPTION row: a
-- mod returns "set" or "shift"; anything else reads as the vanilla answer.
function BattleState:battleStyle()
if not Runtime.wantsHook("battle.style") then return styleFromOptions(self) end
local style = Runtime.call("battle.style", styleFromOptions, self)
if style == "set" then return "set" end
if style == "shift" then return "shift" end
return styleFromOptions(self)
end
-- AskName for a catch (AddPartyMon / SendNewMonToBox). Vanilla queues the
-- yes/no prompt. catch.nickname may answer for the player: false keeps the
-- species name and shows nothing; a string is the nickname, shown nothing;
-- anything else asks as usual. Returns whether a prompt was queued.
--
-- The same verdict a script gift already takes from pokemon.before_give's
-- gift.nickname, for the other way a Pokemon joins the party.
function BattleState:offerNickname(mon, displayName)
if Runtime.wantsHook("catch.nickname") then
local verdict = Runtime.call("catch.nickname", alwaysAsk, mon,
{ battle = self, name = displayName, game = self.game })
if verdict == false then return false end
if type(verdict) == "string" then
-- the naming grid's own limit, so a mod cannot hand the party a name
-- the summary screen has no room to draw
verdict = verdict:sub(1, 10)
if #verdict > 0 then mon.nickname = verdict end
return false
end
end
self:uiNext(function() return self:askNicknameUI(mon, displayName) end)
return true
end
-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
-- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party
-- AddPartyMon and SendNewMonToBox (#172).
@@ -5086,6 +5183,22 @@ end
-- "New POKéDEX data will be added" + the dex entry page, then
-- AddPartyMon or SendNewMonToBox (both call AskName), then the PC
-- transfer text when the party was full.
-- Where a caught mon goes when the party has no room for it (RFC 0018):
-- "box", as AddPartyMon falling through to SendNewMonToBox always did, or
-- "mod" when the catch.party_full hook claims it -- a game mode that has
-- done away with storage hands the decision to the player instead of
-- laundering the catch through a PC it has locked. A method rather than
-- an inline read, so a mod or a compatibility shim can tell a seam engine
-- from a stock one by name.
function BattleState:partyFullDestination(mon)
if not Runtime.wantsHook("catch.party_full") then return "box" end
local claimed = Runtime.call("catch.party_full", function() return false end,
{ battle = self, mon = mon, name = self.enemy and self.enemy.name,
game = self.game })
if claimed then return "mod" end
return "box"
end
function BattleState:storeCaughtMon()
-- ItemUseBall reloads the caught mon via LoadEnemyMonData
-- (item_effects.asm:472-501), regenerating its move list from the
@@ -5117,28 +5230,26 @@ function BattleState:storeCaughtMon()
end)
end
local function askCaughtNickname()
local caught = self.enemy.mon
local enemyName = self.enemy.name
self:uiNext(function()
return self:askNicknameUI(caught, enemyName)
end)
self:offerNickname(self.enemy.mon, self.enemy.name)
end
if Party.add(game.save.party, self.enemy.mon) then
askCaughtNickname()
else
destination = "box"
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
if boxNum then
askCaughtNickname()
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
self:sayNext(self:romText(
metBill and "_ItemUseBallText07" or "_ItemUseBallText08",
metBill and "%s was\ntransferred to\nBILL's PC!"
or "%s was\ntransferred to\nsomeone's PC!",
self.enemy.name))
else
self:sayNext(Strings("But every BOX\nis full!"))
destination = self:partyFullDestination(self.enemy.mon)
if destination == "box" then
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
if boxNum then
askCaughtNickname()
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
self:sayNext(self:romText(
metBill and "_ItemUseBallText07" or "_ItemUseBallText08",
metBill and "%s was\ntransferred to\nBILL's PC!"
or "%s was\ntransferred to\nsomeone's PC!",
self.enemy.name))
else
self:sayNext(Strings("But every BOX\nis full!"))
end
end
end
Runtime.emit("pokemon.caught", {
@@ -5920,7 +6031,9 @@ function BattleState:animSpriteColors(s, px, py)
local P
-- engine/battle/animations.asm:551 (.notSGB)
if PaletteFX.usesSpriteObp() then
P = PaletteFX.ogObj()
-- engine/battle/init_battle_variables.asm:18
P = require("src.core.GameVersion").isBlue() and PaletteFX.GBC_OBJ_BLUE
or PaletteFX.GBC_OBJ
if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end
else
P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4))
+30 -8
View File
@@ -41,6 +41,34 @@ local function badgeBoost(battler, stat)
return nil
end
-- engine/battle/core.asm:6454
function Damage.applyBadgeBoost(battler, stat, value)
if battler.hazeStatReset then return value end
local row = badgeBoost(battler, stat)
if not row then return value end
local extra = battler.badgeExtraBoosts and battler.badgeExtraBoosts[stat] or 0
for _ = 1, 1 + extra do
value = math.min(999, math.floor(value * (row.num or 9) / (row.den or 8)))
end
return value
end
-- engine/battle/effects.asm:498,689
function Damage.reapplyBadgeBoosts(battler, changedStat)
if not battler or not battler.badges then return end
local extra = battler.badgeExtraBoosts
if not extra then extra = {} battler.badgeExtraBoosts = extra end
for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do
if battler.badges[row.badge] then
if row.stat == changedStat then
extra[row.stat] = 0
else
extra[row.stat] = (extra[row.stat] or 0) + 1
end
end
end
end
-- the merged status record for a battler's persistent condition, or nil
local function statusRecord(battler)
return Status.recordFor(battler.statuses, battler.mon.status)
@@ -178,14 +206,8 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
-- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts:
-- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder),
-- Volcano -> special
local atkBoost = badgeBoost(attacker, atkStat)
if atkBoost then
atk = math.floor(atk * (atkBoost.num or 9) / (atkBoost.den or 8))
end
local defBoost = badgeBoost(defender, defStat)
if defBoost then
dfn = math.floor(dfn * (defBoost.num or 9) / (defBoost.den or 8))
end
atk = Damage.applyBadgeBoost(attacker, atkStat, atk)
dfn = Damage.applyBadgeBoost(defender, defStat, dfn)
-- burn halves physical attack (applied as part of the stat in Gen 1;
-- the status record's statPenalty names the stat it cuts).
-- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the
+11
View File
@@ -11,6 +11,7 @@
-- is the registry view of all three -- the merged Data.move_effects a
-- battle dispatches on serves these same objects.
local Damage = require("src.battle.Damage")
local Logger = require("src.core.Logger")
local StatusRegistry = require("src.battle.StatusRegistry")
local TurnOrder = require("src.battle.TurnOrder")
@@ -57,6 +58,10 @@ local function changeStage(battle, who, stat, delta, fromEnemy)
-- recomputed and QuarterSpeedDueToParalysis/HalveAttackDueToBurn re-run,
-- re-baking the burn/para penalty and ending Haze's temporary lift.
who.hazeStatReset = nil
if battle.ruleset and battle.ruleset.badgeBoostReapplyBug
and battle.kind ~= "link" and who == battle.player then
Damage.reapplyBadgeBoosts(who, stat)
end
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
-- two-stage variants scroll "greatly" onto a third line
local label = Strings(STAT_LABEL[stat]) -- looked up here, not at require (#811)
@@ -132,6 +137,11 @@ end
local function statDownSide(stat)
return function(battle, user, target)
-- engine/battle/effects.asm:552
if not user.isPlayer and battle.kind ~= "link"
and battle.rng(0, 255) < 64 then
return {}
end
if target.substituteHP then return {} end
if battle.rng(0, 255) >= 85 then return {} end -- 33 percent + 1 (85/256)
-- StatModifierDownEffect's side-effect branch never runs MoveHitTest,
@@ -254,6 +264,7 @@ MoveEffects.primary = {
-- Attack-halving and paralysis Speed-quartering on BOTH battlers
-- until the next stat recompute (a stage change or switch-in).
b.hazeStatReset = true
b.badgeExtraBoosts = nil
end
-- Gen 1 also removes the enemy's major status; if that cured sleep
-- or freeze, the target forfeits its move this turn (haze.asm
+1 -9
View File
@@ -14,15 +14,7 @@ local function effectiveSpeed(battler)
battler.stages and battler.stages.speed or 0)
-- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from
-- the battler's merged badgeBoosts with the vanilla list as fallback
local badges = battler.badges
if badges then
for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do
if row.stat == "speed" and badges[row.badge] then
spd = math.floor(spd * (row.num or 9) / (row.den or 8))
break
end
end
end
spd = Damage.applyBadgeBoost(battler, "speed", spd)
-- paralysis quarters speed (the status record's statPenalty);
-- hazeStatReset suppresses it because Haze (haze.asm ResetStats)
-- copied the unmodified speed over the quartered battle stat, lifting
+14 -1
View File
@@ -97,6 +97,19 @@ local function battleIsTopState(battle)
return not (stack and stack.top) or stack:top() == battle
end
-- engine/menus/party_menu.asm:4
local function coveredByOpaqueState(battle)
local stack = battle.game and battle.game.stack
local states = stack and stack.states
if not states then return false end
local above = false
for i = 1, #states do
if above and states[i] and states[i].isOpaque then return true end
if states[i] == battle then above = true end
end
return false
end
local function anchorHUD(battle, x, y, w, h, anchor)
if not battle:extendedHUD() or not battleIsTopState(battle) then return end
local renderer = battle.game and battle.game.renderer
@@ -351,7 +364,7 @@ function WideBattle.draw(battle)
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
end
-- AskName clears the field the same way the classic layout does
if battle.blankForAskName then return end
if battle.blankForAskName or coveredByOpaqueState(battle) then return end
local fx = battle.fx
local sx = (fx and fx.shakeX) or 0
+64 -7
View File
@@ -645,6 +645,7 @@ function Battle:smartAiState()
-- wPlayerSubStatus5 & SUBSTATUS_LOCK_ON: the enemy's OWN Lock-On, since
-- BattleCommand_LockOn sets the bit on the target it was aimed at.
playerLockOn = playerState.lockOn or nil,
playerIdentified = playerState.identified or nil,
playerPhysicalMoves = physical,
enemyRage = enemyState.rage,
enemyRageCount = enemyState.rageCount,
@@ -1082,7 +1083,7 @@ function Battle:hitOnce(attacker, defender, def, opts)
local attackerStages = self.stages[self:sideOf(attacker)]
local defenderStages = self.stages[self:sideOf(defender)]
local types = self.data.type_chart and self.data.type_chart.types
local matchups = self.data.type_chart and self.data.type_chart.matchups
local matchups = self:matchupsAgainst(defender)
local heldEffect, heldParam = self:heldEffect(attacker, "damage")
-- BattleCommand_Critical: SUBSTATUS_FOCUS_ENERGY (Focus Energy or a
@@ -1202,6 +1203,8 @@ function Battle:hitOnce(attacker, defender, def, opts)
if def.effect == "EFFECT_FALSE_SWIPE" and damage >= (defender.hp or 0) then
damage = math.max(0, (defender.hp or 0) - 1)
end
-- engine/battle_anims/anim_commands.asm:1200
if self.moveEvent then self.moveEvent.effectiveness = info.effectiveness end
return self:dealDamage(attacker, defender, damage, {
critical = critical, effectiveness = info.effectiveness,
-- Counter answers physical damage and Mirror Coat special, so what kind
@@ -1690,6 +1693,13 @@ function Battle:useMove(attacker, defender, moveId)
text = ("Magnitude %d!"):format(number) })
end
-- data/moves/effects.asm:1607, :1649
if def.effect == "EFFECT_RETURN" then
powerOverride = Effects.happinessPower(attacker.happiness)
elseif def.effect == "EFFECT_FRUSTRATION" then
powerOverride = Effects.happinessPower(attacker.happiness, true)
end
if not sureHit
and not self:accuracyRoll(def, attacker, defender) then
-- data/moves/effects.asm:148-151: `selfdestruct` sits between checkhit and
@@ -1744,7 +1754,7 @@ function Battle:useMove(attacker, defender, moveId)
-- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG.
local defenderTypes = (self:speciesDef(defender) or {}).types
or defender.types
local matchups = self.data.type_chart and self.data.type_chart.matchups
local matchups = self:matchupsAgainst(defender)
if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then
self:markMissed()
self:emit({ kind = "message",
@@ -2119,6 +2129,21 @@ Battle.MOVE_EFFECTS.EFFECT_LOCK_ON = function(self, attacker, defender)
text = self:monName(attacker) .. " took aim!" })
end
-- engine/battle/move_effects/foresight.asm
Battle.MOVE_EFFECTS.EFFECT_FORESIGHT = function(self, attacker, defender,
def, _, sureHit)
if not sureHit
and not self:accuracyRoll(def, attacker, defender) then
return fail(self)
end
local target = self:volatile(defender)
if target.vanished or target.identified then return fail(self) end
target.identified = true
self:emit({ kind = "message",
text = self:monName(attacker) .. " identified "
.. self:monName(defender) .. "!" })
end
-- BattleCommand_CheckHit's .LockOn: the flag is read AND cleared by the next
-- move aimed at the mon carrying it, whether or not that move was the one the
-- lock-on was meant for, and whether or not the exception at :1683-1688 then
@@ -2166,9 +2191,15 @@ function Battle:accuracyRoll(def, attacker, defender, accuracy)
end
function Battle:vanillaAccuracyRoll(accuracy, attacker, defender)
return Damage.rollHit(self:moveAccuracy(accuracy, defender),
self.stages[self:sideOf(attacker)].accuracy,
self.stages[self:sideOf(defender)].evasion, self.random)
local acc = self.stages[self:sideOf(attacker)].accuracy
local eva = self.stages[self:sideOf(defender)].evasion
-- engine/battle/effect_commands.asm:1786
if defender and self:volatile(defender).identified
and (eva or 0) >= (acc or 0) then
acc, eva = 0, 0
end
return Damage.rollHit(self:moveAccuracy(accuracy, defender), acc, eva,
self.random)
end
-- BattleCommand_StatDown's SUBSTATUS_MIST arm (a GUARD SPEC): a drop the FOE
@@ -2354,7 +2385,7 @@ Battle.MOVE_EFFECTS.EFFECT_FUTURE_SIGHT = function(self, attacker, defender, def
stages = self.stages[self:sideOf(defender)],
},
types = self.data.type_chart and self.data.type_chart.types,
matchups = self.data.type_chart and self.data.type_chart.matchups,
matchups = self:matchupsAgainst(defender),
random = self.random,
})
state.futureSight = Effects.FUTURE_SIGHT_TURNS
@@ -3072,6 +3103,30 @@ function Battle:safeguarded(mon)
return (self.screens[self:sideOf(mon)].safeguard or 0) > 0
end
-- engine/battle/effect_commands.asm:1305
function Battle:matchupsAgainst(defender)
local chart = self.data.type_chart
local rows = chart and chart.matchups
if not rows or not defender then return rows end
if not self:volatile(defender).identified then return rows end
local skipped = chart.foresightMatchups
if not skipped or #skipped == 0 then return rows end
if not self.identifiedMatchups then
local drop = {}
for _, row in ipairs(skipped) do
drop[tostring(row.attacker) .. "/" .. tostring(row.defender)] = true
end
local out = {}
for _, row in ipairs(rows) do
if not drop[tostring(row.attacker) .. "/" .. tostring(row.defender)] then
out[#out + 1] = row
end
end
self.identifiedMatchups = out
end
return self.identifiedMatchups
end
-- `source` is the battler that inflicted it, carried only so
-- battle.status_inflicted can name it the way Gen 1's does.
-- BattleCommand_Paralyze and BattleCommand_Poison refuse on a zero matchup,
@@ -3084,7 +3139,7 @@ function Battle:statusRefusedByType(defender, moveType, status)
end
local types = (self:speciesDef(defender) or {}).types or defender.types or {}
if moveType then
local matchups = self.data.type_chart and self.data.type_chart.matchups
local matchups = self:matchupsAgainst(defender)
if Damage.typeMultiplier(moveType, types, matchups) == 0 then return true end
end
if status == "poison" or status == "toxic" then
@@ -3917,6 +3972,8 @@ end
-- check. Split out because playerAttack needs the same answer.
function Battle:lockedInMove(mon)
local state = self:volatile(mon)
-- engine/battle/core.asm:543
if state.chargeMove then return state.chargeMove end
if state.rolloutLock then return state.rolloutLock end
if state.rampageMove and (state.rampageTurns or 0) > 0 then
return state.rampageMove
+8
View File
@@ -284,6 +284,14 @@ function Effects.magnitudePower(random)
return last[2], last[3]
end
-- engine/battle/move_effects/return.asm:1-24, frustration.asm:1-25
function Effects.happinessPower(happiness, frustration)
local h = happiness or 0
if h < 0 then h = 0 elseif h > 255 then h = 255 end
if frustration then h = 255 - h end
return math.floor(h * 10 / 25)
end
-- ------------------------------------------------------------------- weather
--
-- BattleCommand_StartRain / StartSun / StartSandstorm all set wWeatherCount to
+5 -2
View File
@@ -58,9 +58,12 @@ Prize.AMULET_COIN = "AMULET_COIN"
-- data/text/battle.asm. Declared here and formatted at the call site so
-- Strings.source is what registers them, the same way Decorations declares
-- its own five. No line markers: every battle message in this port is one
-- flowing string that Chrome.wrap breaks to the box.
-- flowing string that Chrome.wrap breaks to the box, except SentSomeToMomText,
-- which keeps the cart's own `line`/`cont` breaks because it does not fit two
-- rows.
local GOT_MONEY = Strings.source("%s got %s%d for winning!")
local SENT_SOME = Strings.source("%s got %s%d for winning! Sent some to MOM!")
-- data/text/battle.asm:179-185
local SENT_SOME = Strings.source("%s got %s%d\nfor winning!\vSent some to MOM!")
-- The half and all texts really are this short on the cart: they replace the
-- money line rather than following it, which is a quirk no Gold player can
-- see because BankOfMom only ever writes MOM_SAVING_SOME_MONEY_F.
+2
View File
@@ -23,4 +23,6 @@ return {
-- (core.asm:426-464): poison/burn/leech seed tick before the slower
-- mon acts, not in an end-of-round sweep like Gen 3+.
residualAfterMove = true,
-- engine/battle/effects.asm:498,689
badgeBoostReapplyBug = true,
}
+1 -1
View File
@@ -27,7 +27,7 @@ local BATTLER_FIELDS = {
"chargeReady", "invulnerable", "mustRecharge",
"thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded",
"lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched",
"skipMove", "hazeStatReset", "drainFloor", "drainHold", "trappingTurns",
"skipMove", "hazeStatReset", "badgeExtraBoosts", "drainFloor", "drainHold", "trappingTurns",
"trapMove", "trapDamage", "fainted",
"aiLayer2",
}
+32 -7
View File
@@ -82,8 +82,9 @@ local function copy(value)
return out
end
function Data:applyVersionedFieldData()
if require("src.core.GameVersion").isYellow() then
function Data:applyVersionedFieldData(version)
version = version or require("src.core.GameVersion").get()
if version == "yellow" then
self.field.trades = copy(YELLOW_TRADES)
-- The old man's catch demo is a RATTATA in Yellow
-- (scripts/ViridianCity.asm ViridianCityOldManStartCatchTrainingScript
@@ -106,7 +107,8 @@ end
-- Fills only what the cache is missing, so an importer that learns to
-- stamp one of these keys silently takes over from the engine.
function Data:seedDefaults()
function Data:seedDefaults(version)
version = version or require("src.core.GameVersion").get()
local constants = self.constants or {}
self.constants = constants
self.field = self.field or {}
@@ -131,7 +133,7 @@ function Data:seedDefaults()
if constants.dexDigits == nil then
constants.dexDigits = math.max(3, #tostring(constants.dexSize))
end
self:applyVersionedFieldData()
Data.applyVersionedFieldData(self, version)
local boot = self.field.boot
if boot == nil then
boot = {}
@@ -144,7 +146,7 @@ function Data:seedDefaults()
-- only the un-overridden default flips, so a total conversion that set
-- field.boot.screens.splash keeps its choice on any version.
if boot.screens.splash == BOOT_DEFAULTS.screens.splash
and require("src.core.GameVersion").isYellow() then
and version == "yellow" then
boot.screens.splash = "YellowIntro"
end
-- the naming screen presets the importer already extracts but nothing
@@ -163,11 +165,15 @@ function Data:seedDefaults()
-- extractor never writes headers for them. Seed the EVENT_BEAT_* /
-- after-battle rows so Blaine's SetEventRange deactivation and talk
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
self:seedCinnabarGymTrainerHeaders()
Data.seedCinnabarGymTrainerHeaders(self)
-- #197: the Fighting Dojo Karate Master is text_asm, so the extractor
-- writes no header for him -- seed one so he engages on sight and has
-- his defeat / re-talk lines (same idea as the Cinnabar seed above).
self:seedFightingDojoKarateMaster()
Data.seedFightingDojoKarateMaster(self)
-- #1743: Mt. Moon B2F Super Nerd is text_asm (no def_trainers), so Yellow's
-- extractor never writes his header -- seed one so engageTrainer finds
-- battle/won/after text instead of the "I like shorts!" fallback.
Data.seedMtMoonB2FSuperNerd(self)
-- #189: 1F cabin door order vs rooms map (survey zoom)
require("src.world.SsAnneLayout").apply(self.maps)
end
@@ -199,6 +205,25 @@ function Data:seedFightingDojoKarateMaster()
}
end
-- Mt. Moon B2F Super Nerd (object index 1) is text_asm with no def_trainers
-- row, so Yellow never gets a trainerHeaders.MtMoonB2F[1] entry (Red/Blue
-- pin one in make_rom_manifest.py). Without it, engageTrainer falls through
-- to the hard-coded "I like shorts!" fallback (#1743). trainerDefeated still
-- tracks him via defeatedTrainers[npc.id]; the fabricated event name matches
-- the shipped Red/Blue manifest pin for consistency with fossil drivers.
function Data:seedMtMoonB2FSuperNerd()
local headers = self.trainer_headers
if not headers then return end
headers.MtMoonB2F = headers.MtMoonB2F or {}
if headers.MtMoonB2F[1] then return end
headers.MtMoonB2F[1] = {
battle = "_MtMoonB2FSuperNerdTheyreBothMineText",
won = "_MtMoonB2FSuperNerdOkIllShareText",
after = "_MtMoonB2FSuperNerdTheresAPokemonLabText",
event = "EVENT_BEAT_MT_MOON_3_SUPER_NERD",
}
end
function Data:seedCinnabarGymTrainerHeaders()
local headers = self.trainer_headers
if not headers or headers.CinnabarGym then return end
+29
View File
@@ -0,0 +1,29 @@
-- Canonical pure shaping shared by the active boot and inactive dataset views.
-- The caller supplies both the data table and selected version; no global
-- GameVersion, cache prefix, mount, or active Data table is changed.
local GameVersion = require("src.core.GameVersion")
local DatasetHydration = {}
function DatasetHydration.applyGen2(data, moduleLoader)
local chart = data.type_chart or {}
chart.matchups = chart.matchups or {}
for _, row in ipairs(chart.foresightMatchups or {}) do
chart.matchups[#chart.matchups + 1] = row
end
data.type_chart = chart
data.gen2HeldItems =
(moduleLoader or require)("src.core.gen2.ItemEffects").heldItemsFrom(data.items)
return data
end
function DatasetHydration.apply(data, version, moduleLoader)
if GameVersion.generation(version) == 2 then
return DatasetHydration.applyGen2(data, moduleLoader)
end
require("src.core.Data").seedDefaults(data, version)
return data
end
return DatasetHydration
+19 -1
View File
@@ -6,6 +6,9 @@ local FixedStep = {}
FixedStep.STEP = 1 / 60
local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall
local SMOOTH_FRAMES = 4
local SMOOTH_MAX = 1 / 60 * 2.5
local STEP_EPS = 1 / 60 * 0.02
-- Phase the accumulator is re-seeded with once an absorbed hitch frame has
-- been paid for. Half a step is the balanced point: a frame has to come in
@@ -23,6 +26,7 @@ function FixedStep:init(callback)
self.accum = 0
self.callback = callback
self.suppressCatchup = false
self.dtHistory, self.dtSum = nil, 0
end
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
@@ -39,12 +43,26 @@ function FixedStep:update(dt)
-- the burst it would otherwise release doesn't play out as a slide.
if self.suppressCatchup then
self.suppressCatchup = false
self.dtHistory, self.dtSum = nil, 0
self.accum = self.STEP * RESEED_PHASE
self.callback(self.STEP)
return
end
if dt > 0 and dt <= SMOOTH_MAX then
local hist = self.dtHistory
if not hist then hist = {}; self.dtHistory = hist; self.dtSum = 0 end
hist[#hist + 1] = dt
self.dtSum = self.dtSum + dt
if #hist > SMOOTH_FRAMES then
self.dtSum = self.dtSum - hist[1]
table.remove(hist, 1)
end
dt = self.dtSum / #hist
else
self.dtHistory, self.dtSum = nil, 0
end
self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM)
while self.accum >= self.STEP do
while self.accum >= self.STEP - STEP_EPS do
self.accum = self.accum - self.STEP
self.callback(self.STEP)
end
+15 -12
View File
@@ -1324,9 +1324,7 @@ function Game:restoreSave(loaded, recovered, opts)
self:adoptSave(loaded)
-- SaveData.load already attached the standalone options.lua table
self:applyOptions(loaded.options)
-- saves from before OT/ID stamping: backfill with the player's (after
-- the scrub, so every mon the stamp loop sees is known)
SaveData.repairTradedOtIds(loaded)
-- saves from before OT/ID stamping: backfill with the player's
local stamp = require("src.battle.BattleState").stampOT
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
for _, box in ipairs(loaded.boxes or {}) do
@@ -1389,8 +1387,13 @@ end
-- Drop every session-owned field so the next Game:load() starts clean when
-- the process returns to the launcher in-place (Android / intent_game).
-- main.lua must not guess field names: new systems (Game.network, …) are
-- cleared automatically because only functions (methods) are kept.
--
-- Gen1 Game is a MODULE SINGLETON (methods live on this table). Never fan
-- out arbitrary field:release() here: session fields can hold shared modules
-- (Fetch, SyncClient, …) whose :release is a job-handle API, not instance
-- teardown -- calling them as value:release() corrupts process state and has
-- been observed to leave Game.load nil after EXIT GAME on Android.
-- Explicit GPU owners are released below; everything else is just dropped.
function Game:reset()
if self.stack and self.stack.clear then
pcall(function() self.stack:clear() end)
@@ -1403,23 +1406,23 @@ function Game:reset()
if canvas and canvas.release then pcall(canvas.release, canvas) end
end
end
if self.renderer and self.renderer.releaseCanvases then
pcall(function() self.renderer:releaseCanvases() end)
if self.renderer then
local release = self.renderer.releaseCanvases or self.renderer.release
if release then pcall(release, self.renderer) end
end
-- Keep methods; clear every other field (including session scalars like
-- speedOverride). Re-seed module constants afterward.
local skinFast = self.SKIN_FAST_FORWARD
local keys = {}
for key, value in pairs(self) do
if type(value) ~= "function" then
if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then
if type(value) == "table" and value.release then
pcall(value.release, value)
end
end
keys[#keys + 1] = key
end
end
for _, key in ipairs(keys) do
self[key] = nil
end
self.SKIN_FAST_FORWARD = skinFast or 4
end
return Game
+9 -7
View File
@@ -350,6 +350,10 @@ function Game2:showTitle()
onContinue = function()
self:showMainMenu()
end,
-- engine/menus/intro_menu.asm:848-889
onTimeout = function()
self:showCopyright()
end,
})
end
@@ -2204,6 +2208,8 @@ end
-- In-process return-to-launcher (Android / intent_game): drop session fields
-- so a later Game2.new() + load is not sharing a live stack or mod loader.
-- Methods live on the class table; pairs(self) only sees instance state.
-- Same rule as Gen1: only release known GPU owners -- never fan out
-- arbitrary field:release() (shared modules use :release as a handle API).
function Game2:reset()
if self.stack and self.stack.clear then
pcall(function() self.stack:clear() end)
@@ -2216,17 +2222,13 @@ function Game2:reset()
if canvas and canvas.release then pcall(canvas.release, canvas) end
end
end
if self.renderer and self.renderer.releaseCanvases then
pcall(function() self.renderer:releaseCanvases() end)
if self.renderer then
local release = self.renderer.releaseCanvases or self.renderer.release
if release then pcall(release, self.renderer) end
end
local keys = {}
for key, value in pairs(self) do
if type(value) ~= "function" then
if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then
if type(value) == "table" and value.release then
pcall(value.release, value)
end
end
keys[#keys + 1] = key
end
end
+17 -1
View File
@@ -158,7 +158,15 @@ end
-- and the app dies. There we relaunch through the GameActivity.restartApp
-- JNI bridge (love.system.restartApp), which schedules our launch intent
-- and kills the process so no native state can leak into the fresh run.
-- On every other platform the in-process restart works, so keep it.
-- iOS is the same class of problem with a sharper edge: love.cpp under
-- LOVE_IOS forces DONE_RESTART for *every* quit (Apple forbids programmatic
-- exit) and comments that leftover threads make that restart unreliable --
-- which our ChipAudio / Fetch / Check workers are. There is no
-- restartApp bridge on iOS, so callers that want "back to launcher" must
-- use main.lua's in-process returnToLauncher (love.quit aborts the quit);
-- HostShell.restart itself refuses quit("restart") and falls back to a
-- bare quit() so a mod that still calls restart does not pick the worst
-- path on purpose.
function HostShell.restart()
if not (love and love.event and love.event.quit) then return end
@@ -174,6 +182,14 @@ function HostShell.restart()
love.event.quit()
return
end
if osName == "iOS" then
-- No process-kill bridge. A bare quit still becomes DONE_RESTART in
-- love.cpp, but quit("restart") is the path that also runs our
-- endProcess worker joins first and then re-enters runlove -- the
-- combination that crashes EXIT GAME. Prefer the softer quit.
love.event.quit()
return
end
local appimage = os.getenv("APPIMAGE")
if not appimage then
+5 -1
View File
@@ -227,7 +227,11 @@ local function metadata(options, context)
end
if flags and flags.fullscreen == true then add("Fullscreen", "yes") end
local version = appVersion()
add("App", version ~= "" and Version.title() or "gen1recomp")
-- Bug reports always want the stamped engine when we have one; window
-- chrome hides it on release builds (Version.title). Unstamped
-- working-tree builds stay as plain "gen1recomp" so the placeholder
-- never lands in a filed issue.
add("App", version ~= "" and ("gen1recomp v" .. version) or "gen1recomp")
add("LÖVE", loveVersion())
if safeMode then add("Safe mode", "on") end
return {
+7 -2
View File
@@ -1983,6 +1983,11 @@ SaveData.addCoreMigration(3, function(save)
end
end)
-- #1461 repair, one-shot on pre-format-5 saves
SaveData.addCoreMigration(4, function(save)
SaveData.repairTradedOtIds(save)
end)
-- ------- write
-- Game progress only; options are written separately via saveOptions.
@@ -2394,8 +2399,8 @@ function SaveData.applyPostGameHome(save, boot)
end
-- 0.1.82-0.1.9x loads stamped the player's own id onto traded mons and saved
-- it, which reads back as home-caught. A caught mon can never carry
-- traded=true, so clearing that pair is safe on every load. #1461
-- it, which reads back as home-caught. Run only as the pre-format-5
-- migration: a same-id trade partner makes that pair legitimate. #1461
function SaveData.repairTradedOtIds(save)
local playerId = save and save.player and save.player.id
if playerId == nil then return 0 end
+70 -15
View File
@@ -70,9 +70,27 @@ local function fail(state, why)
error(("parse error at byte %d: %s"):format(state.pos, why), 0)
end
local function limit(state, name)
return state.limits and state.limits[name]
end
local function bumpNode(state)
state.nodes = state.nodes + 1
local maximum = limit(state, "maxNodes")
if maximum and state.nodes > maximum then fail(state, "too many values") end
end
local function skip(state)
local _, last = state.src:find("^[ \t\r\n]*", state.pos)
state.pos = last + 1
while true do
local _, last = state.src:find("^[ \t\r\n]*", state.pos)
state.pos = last + 1
if not (state.limits and state.limits.allowComments
and state.src:sub(state.pos, state.pos + 1) == "--") then
return
end
local newline = state.src:find("\n", state.pos + 2, true)
state.pos = newline and newline + 1 or (#state.src + 1)
end
end
local function peek(state)
@@ -90,7 +108,10 @@ local function readString(state)
fail(state, "unterminated string")
elseif c == '"' then
state.pos = i + 1
return table.concat(out)
local value = table.concat(out)
local maximum = limit(state, "maxStringBytes")
if maximum and #value > maximum then fail(state, "string too long") end
return value
elseif c == "\\" then
local nxt = src:sub(i + 1, i + 1)
if nxt:match("%d") then
@@ -136,10 +157,14 @@ end
local readValue
local function readTable(state)
bumpNode(state)
state.depth = state.depth + 1
if state.depth > MAX_DEPTH then fail(state, "table nesting too deep") end
if state.depth > (limit(state, "maxDepth") or MAX_DEPTH) then
fail(state, "table nesting too deep")
end
state.pos = state.pos + 1
local out = {}
local entries, nextArray = 0, 1
skip(state)
if peek(state) == "}" then
state.pos = state.pos + 1
@@ -148,7 +173,7 @@ local function readTable(state)
end
while true do
skip(state)
local key
local key, value
local c = peek(state)
if c == "[" then
state.pos = state.pos + 1
@@ -156,15 +181,38 @@ local function readTable(state)
skip(state)
if peek(state) ~= "]" then fail(state, "expected ]") end
state.pos = state.pos + 1
skip(state)
if peek(state) ~= "=" then fail(state, "expected =") end
state.pos = state.pos + 1
value = readValue(state)
elseif c:match("[%a_]") then
key = readIdent(state)
local start = state.pos
local ident = readIdent(state)
skip(state)
if peek(state) == "=" then
key = ident
state.pos = state.pos + 1
value = readValue(state)
elseif state.limits and state.limits.allowArray then
state.pos = start
key = nextArray
value = readValue(state)
else
fail(state, "expected =")
end
elseif state.limits and state.limits.allowArray then
key = nextArray
value = readValue(state)
else
fail(state, "expected key")
end
skip(state)
if peek(state) ~= "=" then fail(state, "expected =") end
state.pos = state.pos + 1
out[key] = readValue(state)
if key == nil then fail(state, "nil table key") end
if out[key] ~= nil then fail(state, "duplicate table key") end
entries = entries + 1
local maximum = limit(state, "maxTableEntries")
if maximum and entries > maximum then fail(state, "too many table entries") end
out[key] = value
if type(key) == "number" and key == nextArray then nextArray = nextArray + 1 end
skip(state)
local sep = peek(state)
if sep == "," then
@@ -189,25 +237,30 @@ readValue = function(state)
skip(state)
local c = peek(state)
if c == '"' then
bumpNode(state)
return readString(state)
elseif c == "{" then
return readTable(state)
elseif c:match("[%a_]") then
-- the only bare words in the grammar are the boolean literals
local word = readIdent(state)
if word == "true" then return true end
if word == "false" then return false end
if word == "true" then bumpNode(state); return true end
if word == "false" then bumpNode(state); return false end
state.pos = state.pos - #word
fail(state, "unexpected name '" .. word .. "'")
elseif c:match("[%-%d%.]") then
bumpNode(state)
return readNumber(state)
end
fail(state, c == "" and "unexpected end of input" or "unexpected character")
end
function SaveSerializer.decode(str)
function SaveSerializer.decode(str, limits)
if type(str) ~= "string" then return nil, "save must be a string" end
local state = { src = str, pos = 1, depth = 0 }
if limits and limits.maxBytes and #str > limits.maxBytes then
return nil, ("input is %d bytes (max %d)"):format(#str, limits.maxBytes)
end
local state = { src = str, pos = 1, depth = 0, nodes = 0, limits = limits }
local ok, result = pcall(function()
skip(state)
local word = state.src:match("^[%a_][%w_]*", state.pos)
@@ -219,7 +272,9 @@ function SaveSerializer.decode(str)
return value
end)
if not ok then return nil, result end
if type(result) ~= "table" then return nil, "save root must be a table" end
if type(result) ~= "table" then
return nil, (limits and limits.rootName or "save") .. " root must be a table"
end
return result
end
+8 -1
View File
@@ -76,7 +76,14 @@ local function sdlFfi()
]])
if sdlCdefOk then
local okLoad, lib = pcall(ffi.load, "SDL2")
sdlLib = okLoad and lib or ffi.C
lib = okLoad and lib or ffi.C
local okSyms = pcall(function()
return lib.SDL_InitSubSystem, lib.SDL_NumSensors, lib.SDL_SensorGetDeviceType,
lib.SDL_SensorOpen, lib.SDL_SensorUpdate, lib.SDL_SensorGetData,
lib.SDL_GL_GetCurrentWindow, lib.SDL_GetWindowDisplayIndex,
lib.SDL_GetDisplayOrientation
end)
sdlLib = okSyms and lib or false
end
end
if not sdlCdefOk or not sdlLib then return nil end
+17 -1
View File
@@ -30,6 +30,8 @@ function SessionLifecycle.endMountedSession(version)
local Runtime = require("src.mods.Runtime")
if Runtime.reset then Runtime.reset() end
if Assets.installLoader then Assets.installLoader(nil) end
local Loader = package.loaded["src.mods.Loader"]
if Loader and Loader.endSession then Loader.endSession() end
local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat")
if okCompat and LegacyCompat.reset then LegacyCompat.reset() end
end
@@ -64,11 +66,18 @@ end
-- EXIT GAME / intent_game before dropping Game. Stops audio and resets the
-- live game instance so map/GPU holders are gone before endMountedSession.
--
-- ChipAudio's worker stays alive across game sessions (see tier comment
-- above). shutdown() joins the thread and is process-exit only -- calling
-- it here on every Android EXIT GAME has been observed to leave the Gen1
-- Game singleton unbootable (Game.load nil) on the next Play of a version
-- already opened this process, while a debug APK with a different liblove
-- did not reproduce.
function SessionLifecycle.endGameSession(game)
pcall(function() require("src.core.Music").stop() end)
pcall(function() require("src.core.Sound").stop() end)
if package.loaded["src.core.ChipAudio"] then
pcall(package.loaded["src.core.ChipAudio"].shutdown)
pcall(package.loaded["src.core.ChipAudio"].stopMusic)
end
if package.loaded["src.core.DiscordPresence"] then
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
@@ -87,6 +96,13 @@ function SessionLifecycle.endGameSession(game)
if game and game.reset then
pcall(function() game:reset() end)
end
-- Gen1 Game is the module singleton. Always drop the cached module after
-- a session that owned it so the next bootGame require rebuilds a clean
-- table. A type(game.load) check is not enough: a wrong table parked in
-- package.loaded (e.g. with __index) can still look like it has load.
if game and package.loaded["src.core.Game"] == game then
package.loaded["src.core.Game"] = nil
end
local Input = require("src.core.Input")
local TouchControls = require("src.core.TouchControls")
+7
View File
@@ -371,6 +371,13 @@ function Sound.waitSfxDone()
curSfx = nil
end
-- SFXChannelsOff (home/audio.asm:545)
function Sound.sfxChannelsOff()
if not curSfx then return end
pcall(curSfx.src.stop, curSfx.src)
curSfx = nil
end
local function startSfx(data, name, def)
local src = playPath(data, name, def)
if not src then return end
-4
View File
@@ -217,10 +217,6 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale)
local abW = dpadW * 0.46
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
local ok, GameVersion = pcall(require, "src.core.GameVersion")
if ok and GameVersion.generation and GameVersion.generation() == 2 then
margin = math.max(margin, math.min(ww * 0.10, 72))
end
return {
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
+16 -4
View File
@@ -18,14 +18,26 @@ local Version = {
-- exceeds the shell it provides.
modApi = 2, -- mod API major (manifest `api`)
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
saveFormat = 4, -- save.meta.format
saveFormat = 5, -- save.meta.format
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
}
-- "gen1recomp v0.0.0-dev" (or the stamped release version in shipped builds)
-- True for the working-tree placeholder and any stamped "-dev" pre-release.
-- Shipped builds get a bare X.Y.Z from CI and are not "dev" here.
function Version.isDev()
local engine = tostring(Version.engine or "")
return engine == "0.0.0-dev" or engine:find("%-dev", 1) ~= nil
end
-- Window / chrome title. Dev builds keep the version visible
-- ("gen1recomp v0.0.0-dev"); release builds are just the base name so Linux
-- window chrome and taskbars do not read "gen1recomp 0.1.73".
function Version.title(base)
return (base or "gen1recomp")
.. " v" .. Version.engine
base = base or "gen1recomp"
if Version.isDev() then
return base .. " v" .. Version.engine
end
return base
end
return Version
+8 -3
View File
@@ -89,10 +89,12 @@ local function handle(cmd)
return false
end
local idleWait = false
while true do
-- drain every pending command first, so a stop/new-play is seen promptly
local quit = false
local cmd = cmdCh:pop()
local cmd = idleWait and cmdCh:demand(0.05) or cmdCh:pop()
while cmd do
if handle(cmd) then quit = true end
cmd = cmdCh:pop()
@@ -100,6 +102,7 @@ while true do
if quit then break end
if engine and not finished and gen and outCh:getCount() < LOOKAHEAD then
idleWait = false
local activeGen = gen
local ok, sd = pcall(ChipSynth.soundData, engine, BUF, 2)
if not ok then
@@ -113,8 +116,10 @@ while true do
finished = true
end
end
elseif engine and not finished and gen then
idleWait = false
love.timer.sleep(0.005)
else
-- nothing to do (idle, or the look-ahead is full): yield the core
love.timer.sleep(0.001)
idleWait = true
end
end
+6 -8
View File
@@ -164,14 +164,12 @@ local function receiveItemToPc(save, id, data)
save.pcItems = save.pcItems or {}
local pc = save.pcItems
local held = pc[id] or 0
if held == 0 then
local cap = (data and data.field and data.field.pcItemCap) or PC_ITEM_CAPACITY
local stacks = 0
for _ in pairs(pc) do stacks = stacks + 1 end
if stacks >= cap then return false end
elseif held + 1 > MAX_STACK then
return false
end
local cap = (data and data.field and data.field.pcItemCap) or PC_ITEM_CAPACITY
local function stacksFor(n) return math.ceil((n or 0) / MAX_STACK) end
local used = 0
for _, count in pairs(pc) do used = used + stacksFor(count) end
-- engine/items/items.asm:156 PutItemInPocket
if used + stacksFor(held + 1) - stacksFor(held) > cap then return false end
pc[id] = held + 1
return true
end
+4 -6
View File
@@ -343,6 +343,7 @@ Save.OPTIONS_KEY = "gold"
local SHARED_KEYS = {
touchControls = true, haptics = true, screenPos = true,
videoMode = true,
mods = true, modsByVersion = true, modsGen2 = true,
modOptions = true, modProfiles = true, modProfilesSeeded = true,
activeProfile = true,
@@ -361,12 +362,9 @@ function Save.loadOptions(fs)
end
if type(loaded) == "table" then
for key in pairs(SHARED_KEYS) do
if loaded[key] ~= nil then options[key] = loaded[key] end
end
end
if type(stored) == "table" then
for key in pairs(SHARED_KEYS) do
if options[key] == nil and stored[key] ~= nil then
if loaded[key] ~= nil then
options[key] = loaded[key]
elseif type(stored) == "table" and stored[key] ~= nil then
options[key] = stored[key]
end
end
+127 -1
View File
@@ -9,6 +9,9 @@ local GameVersion = require("src.core.GameVersion")
local CacheContract = {}
CacheContract.FORMAT = "rom-cache-v10:"
CacheContract.VERSION_FORMAT = {
crystal = "rom-cache-v10-crystal3:",
}
CacheContract.MARKER_PATH = "rom-cache.complete"
CacheContract.REQUIRED_FILES = {
@@ -31,6 +34,11 @@ CacheContract.VERSION_REQUIRED_FILES = {
"assets/generated/battle/trainers/jessie_james.png",
"assets/generated/battle/profoakb.png",
"assets/generated/pikachu/pikapic_1.png",
"assets/generated/minigame/surf_1a.png",
"assets/generated/minigame/surf_1b.png",
"assets/generated/minigame/surf_1c.png",
"assets/generated/minigame/title_bg.png",
"assets/generated/minigame/intro_pika_0.png",
},
}
@@ -130,14 +138,77 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = {
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.silver =
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.gold
local SEMANTIC_MODULES = {
[1] = {
"constants", "maps", "tilesets", "text", "text_pointers",
"trainer_headers", "font", "sprites", "pokemon", "moves", "items",
"type_chart", "trainers", "encounters", "field", "battle_anims",
},
[2] = {
"pokemon", "moves", "items", "type_chart", "audio", "font", "maps",
"tilesets", "text", "trainers", "encounters", "sprites", "palettes",
"icons", "battle_anims", "constants", "landmarks",
},
}
local OPTIONAL_SEMANTIC_MODULES = {
[1] = { "audio", "palettes", "icons" },
[2] = {},
}
local function copy(values)
local out = {}
for index, value in ipairs(values or {}) do out[index] = value end
return out
end
function CacheContract.requiredFilesFor(version)
local override = CacheContract.VERSION_REQUIRED_FILES_OVERRIDE[version]
if override then return override, true end
return CacheContract.REQUIRED_FILES, false
end
-- A detached, version-specific inventory for read-only consumers. Semantic
-- consumers additionally require every generated module that backs the public
-- registry surface; optional semantic roots are discovered lazily.
function CacheContract.requiredFiles(version, semantic)
local base, isOverride = CacheContract.requiredFilesFor(version)
local files = copy(base)
if not isOverride then
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
files[#files + 1] = path
end
end
if semantic then
for _, name in ipairs(SEMANTIC_MODULES[GameVersion.generation(version)] or {}) do
files[#files + 1] = "data/generated/" .. name .. ".lua"
end
end
local seen, out = {}, {}
for _, path in ipairs(files) do
if not seen[path] then
seen[path] = true
out[#out + 1] = path
end
end
return out
end
function CacheContract.semanticModules(version)
return copy(SEMANTIC_MODULES[GameVersion.generation(version)])
end
function CacheContract.optionalSemanticModules(version)
return copy(OPTIONAL_SEMANTIC_MODULES[GameVersion.generation(version)])
end
function CacheContract.formatFor(version)
return CacheContract.VERSION_FORMAT[version] or CacheContract.FORMAT
end
function CacheContract.markerFor(version, sha1)
return CacheContract.FORMAT .. (sha1 or GameVersion.info(version).sha1)
return CacheContract.formatFor(version)
.. (sha1 or GameVersion.info(version).sha1)
end
function CacheContract.markerMatches(version, marker)
@@ -252,4 +323,59 @@ function CacheContract.sourceTreeHasData(version)
return true
end
-- Inspect another version without mutating CacheFs.prefix, GameVersion, mounts,
-- or the active Data table. The injected filesystem addresses exact paths.
local function readAt(fs, path)
if fs.readAt then return fs.readAt(path) end
if fs.read then return fs.read(path) end
return nil
end
local function isFileAt(fs, path)
if fs.getInfo then
local info = fs.getInfo(path, "file")
return info ~= nil and (info.type == nil or info.type == "file")
end
if fs.existsAt then return fs.existsAt(path) == true end
return false
end
local function hasExactFiles(version, fs, prefix, semantic)
for _, path in ipairs(CacheContract.requiredFiles(version, semantic)) do
if not isFileAt(fs, prefix .. path) then return false, path end
end
return true
end
local function sourceReady(version, fs, semantic)
if not (fs.getInfo and fs.getRealDirectory and fs.getSource) then return nil end
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
local complete = hasExactFiles(version, fs, prefix, semantic)
if not complete then return nil end
local first = prefix .. CacheContract.requiredFiles(version, semantic)[1]
if fs.getRealDirectory(first) ~= fs.getSource() then return nil end
return { kind = "source", prefix = prefix }
end
function CacheContract.inspect(version, fs, opts)
opts = opts or {}
if not (GameVersion.VERSIONS[version] and fs) then
return nil, "not_imported", "unsupported cache inspection"
end
if opts.allowSource then
local source = sourceReady(version, fs, opts.semantic)
if source then return source end
end
local prefix = GameVersion.cachePrefix(version)
local marker = readAt(fs, prefix .. CacheContract.MARKER_PATH)
if not CacheContract.markerMatches(version, marker) then
return nil, "not_imported", "completion marker is missing or stale"
end
local complete, missing = hasExactFiles(version, fs, prefix, opts.semantic)
if not complete then
return nil, "not_imported", "required cache file is missing: " .. tostring(missing)
end
return { kind = "cache", prefix = prefix, marker = marker }
end
return CacheContract
+14 -5
View File
@@ -324,9 +324,9 @@ function CacheFs.openWrite(rel)
return file
end
-- read cache-relative `rel`; returns the bytes or nil
function CacheFs.read(rel)
rel = withPrefix(rel)
-- Read an exact version-qualified path without consulting CacheFs.prefix.
-- Readiness checks use this to inspect another version without global state.
function CacheFs.readAt(rel)
local root = CacheFs.root()
if root then
local f = io.open(realPath(root, rel), "rb")
@@ -341,6 +341,11 @@ function CacheFs.read(rel)
return love.filesystem.read(rel)
end
-- read cache-relative `rel`; returns the bytes or nil
function CacheFs.read(rel)
return CacheFs.readAt(withPrefix(rel))
end
-- Read cache-relative `rel` for the active GameVersion when PhysFS may hide
-- prefixed Blue/Yellow/Gold trees (fused NX mount hole). Same order Data:load
-- already used: active version prefix with CacheFs.prefix cleared, then
@@ -386,8 +391,7 @@ function CacheFs.loadActive(rel)
end
-- does cache-relative `rel` exist as a file?
function CacheFs.exists(rel)
rel = withPrefix(rel)
function CacheFs.existsAt(rel)
local root = CacheFs.root()
if root then
local f = io.open(realPath(root, rel), "rb")
@@ -398,6 +402,11 @@ function CacheFs.exists(rel)
return love.filesystem.getInfo(rel, "file") ~= nil
end
function CacheFs.exists(rel)
return CacheFs.existsAt(withPrefix(rel))
end
-- remove a single cache-relative file
function CacheFs.remove(rel)
rel = withPrefix(rel)
+22
View File
@@ -7,6 +7,28 @@ local SHADES = {
{ 0, 0, 0, 1 },
}
ImageWriter.SHADES = SHADES
-- Title screen rOBP0 = %11100000 ($E0): OBJ shades 1 and 2 draw as white,
-- shade 3 as black (pokeyellow engine/movie/title.asm after PlacePikachu).
-- Eye OAM is baked into the MEWMON-colored BG PNG; without this remap the
-- shade-1 glints become body yellow under the title palette.
function ImageWriter.applyTitleObp0(image)
local mid, dark = SHADES[2][1], SHADES[3][1]
local w, h = image:getWidth(), image:getHeight()
for y = 0, h - 1 do
for x = 0, w - 1 do
local r, g, b, a = image:getPixel(x, y)
if a ~= 0 then
if math.abs(r - mid) < 0.02 or math.abs(r - dark) < 0.02 then
image:setPixel(x, y, 1, 1, 1, 1)
end
end
end
end
return image
end
local function assertDimensions(raw, width, height, bits)
assert(width % 8 == 0 and height % 8 == 0,
("%dbpp dimensions must be tile-aligned: %dx%d")
+12 -24
View File
@@ -23,6 +23,11 @@ local SaveData = require("src.core.SaveData")
local LauncherSettings = {}
local function bgLocked(opts)
return opts.battleLayout == "wide" and opts.battleFit == "fill"
and opts.battleHud == "extended"
end
local function wrapIndex(i, n)
i = i % n
if i < 0 then i = i + n end
@@ -156,8 +161,6 @@ local function coreRows(opts, hooks)
opts.battleLayout = opts.battleLayout == "wide" and "og" or "wide"
if opts.battleLayout ~= "wide" then
opts.battleHud = "standard"
elseif opts.battleFit == "fill" and opts.battleHud == "extended" then
opts.battleBg = "white"
end
return true
end)
@@ -167,10 +170,6 @@ local function coreRows(opts, hooks)
end,
function()
opts.battleFit = opts.battleFit == "fill" and "fixed" or "fill"
if opts.battleFit == "fill" and opts.battleLayout == "wide"
and opts.battleHud == "extended" then
opts.battleBg = "white"
end
return true
end)
add(Strings("BATTLE HUD"),
@@ -185,28 +184,17 @@ local function coreRows(opts, hooks)
return false
end
opts.battleHud = opts.battleHud == "extended" and "standard" or "extended"
if opts.battleHud == "extended" and opts.battleFit == "fill" then
opts.battleBg = "white"
end
return true
end)
add(Strings("BATTLE BG"),
function()
if opts.battleLayout == "wide" and opts.battleFit == "fill"
and opts.battleHud == "extended" then
opts.battleBg = "white"
return Strings("AUTO")
end
if bgLocked(opts) then return Strings("AUTO (FILL HUD)") end
if opts.battleBg == "black" then return Strings("BLACK") end
if opts.battleBg == "world" then return Strings("WORLD") end
return Strings("WHITE")
end,
function(dir)
if opts.battleLayout == "wide" and opts.battleFit == "fill"
and opts.battleHud == "extended" then
opts.battleBg = "white"
return false
end
if bgLocked(opts) then return false end
local order = { "white", "black", "world" }
local cur = 1
for i, mode in ipairs(order) do
@@ -563,7 +551,7 @@ end
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
local GEN2_KEY = "gold"
local function gen2Rows(opts, hooks)
local function gen2Rows(opts, hooks, shared)
local rows = {}
local function add(label, value, step)
rows[#rows + 1] = { label = label, value = value, step = step }
@@ -669,9 +657,9 @@ local function gen2Rows(opts, hooks)
local okVm, VideoMode = pcall(require, "src.core.VideoMode")
if okVm then
add(Strings("VIDEO MODE"),
function() return VideoMode.modeLabel(opts.videoMode) end,
function() return VideoMode.modeLabel(shared.videoMode) end,
function(dir)
opts.videoMode = VideoMode.cycle(opts.videoMode, dir)
shared.videoMode = VideoMode.cycle(shared.videoMode, dir)
return true
end)
end
@@ -690,7 +678,7 @@ local function gen2Rows(opts, hooks)
add(Strings("BATTLE BG"), ladder(opts, "battleBg",
{ { "white", "WHITE" }, { "black", "BLACK" } }, "white"))
addTouchRows(rows, add, opts, hooks)
addTouchRows(rows, add, shared, hooks)
return rows
end
@@ -718,7 +706,7 @@ function LauncherSettings.open(hooks, version)
opts[GEN2_KEY] = block
end
sections = {
{ title = Strings("OPTIONS"), rows = gen2Rows(block, hooks) },
{ title = Strings("OPTIONS"), rows = gen2Rows(block, hooks, opts) },
}
else
sections = {
+18 -7
View File
@@ -984,6 +984,17 @@ local function modScopeChipsWidth(options, gap, m)
return need
end
local function profileState(imp)
if imp._profileCache == nil then
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
local options = SaveData.loadOptions()
local list, cur = LauncherMods.getProfiles(options)
imp._profileCache = { options = options, list = list, active = cur }
end
return imp._profileCache
end
local function buildModScopeRow(imp, x, y, w, m)
local LauncherMods = require("src.mods.LauncherMods")
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
@@ -994,7 +1005,7 @@ local function buildModScopeRow(imp, x, y, w, m)
local options = modScopeOptions(imp)
-- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar
local _, activeProf = LauncherMods.getProfiles()
local activeProf = profileState(imp).active
local isCompact = (w < math.floor(500 * m.s))
local nameText = tostring(activeProf or "Default")
local profLabel = isCompact and nameText or Strings("Profile: %s", nameText)
@@ -3698,9 +3709,8 @@ local function buildSingleProfileActionsModal(imp, m)
if not pName then imp._singleProfileActions = nil return end
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
local options = SaveData.loadOptions()
local profiles, active = LauncherMods.getProfiles(options)
local prof = profileState(imp)
local options, profiles = prof.options, prof.list
local pad = math.floor(18 * m.s)
local w = math.min(math.floor(380 * m.s), m.w - 2 * m.pad)
@@ -3715,6 +3725,7 @@ local function buildSingleProfileActionsModal(imp, m)
action = function()
LauncherMods.duplicateProfile(pName, options)
imp._singleProfileActions = nil
if imp._refreshMods then imp:_refreshMods() end
end
},
{
@@ -3736,6 +3747,7 @@ local function buildSingleProfileActionsModal(imp, m)
imp:pressDelete("profile", pName, nil, function()
LauncherMods.deleteProfile(pName, options)
imp._singleProfileActions = nil
if imp._refreshMods then imp:_refreshMods() end
end)
end
}
@@ -3771,9 +3783,8 @@ end
-- Modal for Mod Profiles (#593) - interactive profile manager (switch, edit, duplicate, delete)
local function buildProfilesModal(imp, m)
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
local options = SaveData.loadOptions()
local profiles, active = LauncherMods.getProfiles(options)
local prof = profileState(imp)
local options, profiles, active = prof.options, prof.list, prof.active
local pad = math.floor(18 * m.s)
local w = math.min(math.floor(460 * m.s), m.w - 2 * m.pad)
+118 -2
View File
@@ -1535,6 +1535,14 @@ function RomExtractor:extractYellowTitleArt()
local bg = sheetTiles("TitlePikachuBGGraphics", 64)
local ob = sheetTiles("TitlePikachuOBGraphics", 12)
local obClear = sheetTiles("TitlePikachuOBGraphics", 12, true)
-- Title rOBP0=$E0: remap eye OAM shades 1/2 → white before baking into the
-- MEWMON BG composition (otherwise glints read as body yellow).
local eyeOb = {}
for i, tile in ipairs(obClear) do
local copy = ImageWriter.blank(8, 8, 0, 0, 0, 0)
ImageWriter.blit(copy, tile, 0, 0)
eyeOb[i] = ImageWriter.applyTitleObp0(copy)
end
local function tileFor(id)
if id < 0x80 then return logo[id + 1] end
if id < 0xF0 then return bg[id - 0x80 + 1] end
@@ -1623,13 +1631,13 @@ function RomExtractor:extractYellowTitleArt()
local overlay = ImageWriter.blank(48, 16, 1, 1, 1, 0)
ImageWriter.blit(overlay, pikachu, 0, 0, 24, 16, 48, 16)
for _, e in ipairs(EYE_LAYOUT) do
blitSprite(overlay, obClear[base + e[1]], e[2] - 24, e[3] - 16, e[4])
blitSprite(overlay, eyeOb[base + e[1]], e[2] - 24, e[3] - 16, e[4])
end
overlays[suffix] = overlay
end
-- open eyes bake into pikachu.png AFTER the blank-face crops
for _, e in ipairs(EYE_LAYOUT) do
blitSprite(pikachu, obClear[e[1]], e[2], e[3], e[4])
blitSprite(pikachu, eyeOb[e[1]], e[2], e[3], e[4])
end
self:save(pikachu, "title/pikachu.png")
for suffix, overlay in pairs(overlays) do
@@ -1637,6 +1645,113 @@ function RomExtractor:extractYellowTitleArt()
end
end
function RomExtractor:extractSurfingPikachuTitleArt()
-- engine/minigame/surfing_pikachu.asm
-- DrawSurfingPikachuMinigameIntroBackground: compose the 160x144
-- "Pikachu's Beach" title from SurfingMinigame_* tilemaps over
-- SurfingPikachu1Graphics3 tiles (mirrors tools/build_rom_data.py).
if not self.symbols["SurfingPikachu1Graphics3"] then return end
if not self.symbols["SurfingMinigame_BeachIntroTilemap"] then return end
local beachIntro = self:symbol("SurfingMinigame_BeachIntroTilemap")
local useCtrlPad = self:symbol("SurfingMinigame_UseControlPadTilemap")
local toSurfRad = self:symbol("SurfingMinigame_ToSurfRadTilemap")
local titleMap = self:symbol("SurfingMinigame_TitleTilemap")
local beachBytes = self.rom:bytes(
beachIntro.bank, beachIntro.address, 12 * 20)
local useCtrlBytes = self.rom:bytes(
useCtrlPad.bank, useCtrlPad.address, 15)
local toSurfRadBytes = self.rom:bytes(
toSurfRad.bank, toSurfRad.address, 13)
local titleMapBytes = self.rom:bytes(
titleMap.bank, titleMap.address, 6 * 12)
local screen = {}
for _ = 1, 20 * 18 do screen[#screen + 1] = 0xff end
for i = 1, #beachBytes do
screen[6 * 20 + i] = beachBytes[i]
end
for r = 0, 5 do
for c = 0, 11 do
screen[r * 20 + (4 + c) + 1] = titleMapBytes[r * 12 + c + 1]
end
end
for r = 0, 2 do
for c = 0, 14 do
screen[(7 + r) * 20 + (3 + c) + 1] = 0xff
end
end
for i = 1, #useCtrlBytes do
screen[7 * 20 + 3 + i] = useCtrlBytes[i]
end
for i = 1, #toSurfRadBytes do
screen[9 * 20 + 4 + i] = toSurfRadBytes[i]
end
local gfx3 = self:symbol("SurfingPikachu1Graphics3")
local rawGfx3 = self.rom:bytes(gfx3.bank, gfx3.address, 144 * 16)
local tiles = {}
for tile = 0, 143 do
local one = {}
for j = 1, 16 do one[j] = rawGfx3[tile * 16 + j] end
tiles[tile + 1] = ImageWriter.decode2bpp(one, 8, 8, false)
end
local blank = ImageWriter.blank(8, 8, 1, 1, 1, 1)
local titleBg = ImageWriter.blank(160, 144, 1, 1, 1, 1)
for r = 0, 17 do
for c = 0, 19 do
local tileId = screen[r * 20 + c + 1]
local tileImg = blank
if tileId ~= 0xff then
local idx = tileId >= 0x80 and (tileId - 0x80 + 1) or (128 + tileId + 1)
if idx >= 1 and idx <= 144 then tileImg = tiles[idx] end
end
ImageWriter.blit(titleBg, tileImg, c * 8, r * 8)
end
end
self:save(titleBg, "minigame/title_bg.png")
-- Intro paddling Pikachu frames (surfing_pikachu_oam.asm .IntroPikachu).
local INTRO_PIKA_FRAME_BASE = { 0x80, 0x84, 0x88, 0x8c }
local INTRO_PIKA_OAM = {
{ -12, -16, 0x03, true }, { -12, -8, 0x02, true },
{ -12, 0, 0x01, true }, { -12, 8, 0x00, true },
{ -4, -16, 0x13, true }, { -4, -8, 0x12, true },
{ -4, 0, 0x11, true }, { -4, 8, 0x10, true },
{ 4, -16, 0x23, true }, { 4, -8, 0x22, true },
{ 4, 0, 0x21, true }, { 4, 8, 0x20, true },
}
local function blitIntroTile(target, tile, tx, ty, flipX)
for y = 0, 7 do
for x = 0, 7 do
local sx = flipX and (7 - x) or x
local r, g, b, a = tile:getPixel(sx, y)
local px, py = tx + x, ty + y
if a ~= 0 and px >= 0 and py >= 0
and px < target:getWidth() and py < target:getHeight() then
target:setPixel(px, py, r, g, b, a)
end
end
end
end
for frame, vramBase in ipairs(INTRO_PIKA_FRAME_BASE) do
local pose = ImageWriter.blank(32, 24, 1, 1, 1, 0)
local sheetBase = vramBase - 0x80
for _, sp in ipairs(INTRO_PIKA_OAM) do
local dy, dx, rel, flipX = sp[1], sp[2], sp[3], sp[4]
local tileImg = tiles[sheetBase + rel + 1]
if tileImg then
blitIntroTile(pose, tileImg,
16 + dx + (flipX and 8 or 0), 12 + dy, flipX)
end
end
self:save(pose, ("minigame/intro_pika_%d.png"):format(frame - 1))
end
end
function RomExtractor:raw2bpp(label, width, height, relative, options)
options = options or {}
local expected = width * height / 4
@@ -1978,6 +2093,7 @@ function RomExtractor:extractField()
self:save(image, spec[5])
end
end
self:extractSurfingPikachuTitleArt()
-- Yellow-only: TalkToPikachu's framed portrait, one 5x5 base frame per
-- PikaPicAnimScript -- each script's FIRST pikapic_loadgfx in
+22 -2
View File
@@ -151,6 +151,19 @@ local TEXT_BUFFERS = {
[0xc602] = "wOTTrademonSpeciesName",
[0xc618] = "wOTTrademonSenderName",
}
-- ../pokecrystal/ram/wram.asm:1925,2333
local TEXT_BUFFERS_CRYSTAL = {
[0xd050] = "wMonOrItemNameBuffer",
[0xd073] = "wStringBuffer1",
[0xd086] = "wStringBuffer2",
[0xd099] = "wStringBuffer3",
[0xd0ac] = "wStringBuffer4",
[0xd0bf] = "wStringBuffer5",
[0xc6d1] = "wPlayerTrademonSpeciesName",
[0xc6e7] = "wPlayerTrademonSenderName",
[0xc703] = "wOTTrademonSpeciesName",
[0xc719] = "wOTTrademonSenderName",
}
-- The text commands that print nothing and carry no argument
-- (macros/scripts/text.asm, in TextCommands order): TX_LOW, TX_SCROLL,
-- TX_PAUSE, TX_WAIT_BUTTON, TX_DAY, and the six TX_SOUND_* jingles.
@@ -2295,6 +2308,8 @@ function RomExtractorGen2:extractTitle()
trailBobAmplitude = silver and 3 or 2,
trailPhaseStep = silver and 7 or 3,
trailPhase = silver and 0 or nil,
-- TitleScreenTimer (engine/menus/intro_menu.asm:951-966).
timeoutFrames = silver and (73 * 60 + 36) or (84 * 60 + 16),
}
self:write("title", data)
return data
@@ -2805,6 +2820,8 @@ end
-- is which alongside the text lets a caller fill them in order without
-- changing a marker every screen already reads.
function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers)
local textBuffers = (self.edition == "crystal") and TEXT_BUFFERS_CRYSTAL
or TEXT_BUFFERS
local out = {}
local i = 0
local hops = 0
@@ -2840,7 +2857,7 @@ function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers)
out[#out + 1] = "{STRBUF}"
if buffers then
local target = self.rom:word(bank, address + i + 1)
buffers[#buffers + 1] = TEXT_BUFFERS[target] or target
buffers[#buffers + 1] = textBuffers[target] or target
end
i = i + 2
elseif b == 0x4e or b == 0x4f then
@@ -2849,7 +2866,10 @@ function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers)
out[#out + 1] = "\f"
elseif b == 0x55 then
out[#out + 1] = "\v"
elseif b == 0x52 then
elseif b == 0x52
or (b == 0x14 and inString and self.edition == "crystal") then
-- ../pokecrystal/constants/charmap.asm:6 <PLAY_G>,
-- ../pokecrystal/home/text.asm:243,380 PlaceGenderedPlayerName
out[#out + 1] = "{PLAYER}"
elseif b == 0x53 then
out[#out + 1] = "{RIVAL}"
+151 -2
View File
@@ -9,6 +9,9 @@ local SafeArea = require("src.core.SafeArea")
local RomImporter = {}
RomImporter.__index = RomImporter
local PICK_COMPLETE_FILENAME = "pick_complete.flag"
local finishDirectRequiredImport
-- love.system.pickFile is a NATIVE BRIDGE, not part of LÖVE: it exists only on
-- builds that compiled one (Android, and iOS builds patched by
-- mobile/ios/patch_love_src.py). A build without it must fall back to the
@@ -1582,8 +1585,11 @@ function RomImporter:focus(f)
.. love.filesystem.getSaveDirectory()
local legacyRequiredPick = self.requiredImportLegacyRomPick
and self.pickerPendingKind == "required_import"
if pickError:find("picked_required_import", 1, true)
if self.pickerPendingKind == "required_import"
or pickError:find("picked_required_import", 1, true)
or pickError:find("picked_stadium", 1, true)
or pickError:find("/baseroms/", 1, true)
or pickError:find("\\baseroms\\", 1, true)
or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then
self.modNotice = { ok = false, text = text }
self.pickerPendingKind = nil
@@ -1606,6 +1612,22 @@ function RomImporter:focus(f)
end
return
end
-- Current mobile bridge: the native picker has already streamed a raw
-- dependency into mods/<id>/baseroms and published its digest/size marker.
local completedRequired = love.filesystem.getInfo(PICK_COMPLETE_FILENAME, "file")
and love.filesystem.read(PICK_COMPLETE_FILENAME)
if completedRequired then
love.filesystem.remove(PICK_COMPLETE_FILENAME)
self.pickPending = nil
finishDirectRequiredImport(self, completedRequired)
self.pickerPendingKind = nil
self.pickerPendingModId = nil
self.pickerPendingImportId = nil
self.requiredImportLegacyRomPick = nil
return
end
local requiredName = findPendingRequiredImport(self)
if requiredName then
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
@@ -2059,6 +2081,122 @@ local function requiredImportNotice(self, modId, importId, text)
}
end
-- Current Android/iOS bridges can stream a raw required import directly from
-- the system document provider into its engine-owned baseroms destination.
-- The bridge hashes/counts the same bytes and then publishes this tiny marker:
-- v1\n<relative destination>\n<md5>\n<byte count>\n
-- Older mobile builds still stage picked_required_import.bin, so this is an
-- additive completion path rather than a replacement for the legacy one.
local function directRequiredImportTarget(self, marker)
local fields = {}
for line in tostring(marker or ""):gmatch("[^\r\n]+") do
fields[#fields + 1] = line
if #fields > 4 then break end
end
local version, path, digest, count = fields[1], fields[2], fields[3], fields[4]
if version ~= "v1" or not path or not digest or not count or #digest ~= 32
or not digest:match("^%x+$") or not count:match("^%d+$") then
return nil, "The completed dependency marker was malformed."
end
digest = digest:lower()
count = tonumber(count)
if not count then return nil, "The completed dependency size was invalid." end
if not self.mods and self._refreshMods then pcall(self._refreshMods, self) end
local RequiredImports = require("src.mods.RequiredImports")
local function matchRow(row, wantedImportId)
local manifest = row and row.manifest
if not manifest then return nil end
for _, spec in ipairs(RequiredImports.specs(manifest)) do
if (not wantedImportId or spec.id == wantedImportId)
and RequiredImports.path(manifest, spec) == path then
return manifest, spec, row.id or manifest.id
end
end
return nil
end
-- Normal resume: bind the marker to exactly the request that opened picker.
if self.pickerPendingModId and self.pickerPendingImportId then
for _, row in ipairs(self.mods or {}) do
if row.id == self.pickerPendingModId then
local manifest, spec, modId = matchRow(row, self.pickerPendingImportId)
if manifest then return path, digest, count, manifest, spec, modId end
end
end
return nil, "The completed dependency did not match the pending import request."
end
-- Android may recreate GameActivity while DocumentsUI is open. If Lua was
-- restarted too, recover by the exact engine-owned destination. The path
-- includes the mod id and only a manifest-declared import can match it.
for _, row in ipairs(self.mods or {}) do
local manifest, spec, modId = matchRow(row)
if manifest then return path, digest, count, manifest, spec, modId end
end
return nil, "The completed dependency did not match an installed mod request."
end
finishDirectRequiredImport = function(self, marker)
local path, digestOrErr, nativeSize, manifest, spec, modId =
directRequiredImportTarget(self, marker)
if not path then
self.modNotice = { ok = false, text = tostring(digestOrErr) }
return nil
end
local digest = digestOrErr
local RequiredImports = require("src.mods.RequiredImports")
local info = love.filesystem.getInfo(path, "file")
if not info or type(info.size) ~= "number" then
requiredImportNotice(self, modId, spec.id,
"The completed dependency file was not found.")
self.modNotice = nil
return nil
end
if info.size ~= nativeSize then
love.filesystem.remove(path)
if RequiredImports.receiptPath then
love.filesystem.remove(RequiredImports.receiptPath(manifest, spec))
end
requiredImportNotice(self, modId, spec.id,
("Dependency copy size changed after completion (expected %d bytes, found %d).")
:format(nativeSize, info.size))
self.modNotice = nil
return nil
end
local sizeErr = RequiredImports.sizeError(spec, info.size, true)
if sizeErr then
love.filesystem.remove(path)
if RequiredImports.receiptPath then
love.filesystem.remove(RequiredImports.receiptPath(manifest, spec))
end
requiredImportNotice(self, modId, spec.id, sizeErr)
self.modNotice = nil
return nil
end
-- No second 665 MiB / 1.46 GiB read: native calculated this digest while
-- streaming the selected document into the one final copy.
local ok, detail = RequiredImports.acceptStoredDigest(
manifest, spec.id, digest, love.filesystem)
if not ok then
love.filesystem.remove(path)
if RequiredImports.receiptPath then
love.filesystem.remove(RequiredImports.receiptPath(manifest, spec))
end
requiredImportNotice(self, modId, spec.id, detail)
self.modNotice = nil
return nil
end
self.requiredImportNotice = nil
self.modNotice = { ok = true, text = "Imported " .. tostring(spec.id)
.. " for " .. tostring(manifest.name or manifest.id) .. "." }
self:_refreshMods()
return true
end
function RomImporter:_importRequiredData(modId, importId, data)
local manifest = requiredManifest(self, modId)
if not manifest then
@@ -2218,7 +2356,15 @@ function RomImporter:chooseRequiredImport(modId, importId)
self.pickerPendingModId = modId
self.pickerPendingImportId = importId
self.requiredImportLegacyRomPick = legacyAndroidPicker or nil
if not pickFile(legacyAndroidPicker and "rom" or "required_import") then
-- Raw imports can go straight to their final private destination on current
-- Android/iOS bridges. N64 stays on staging because the launcher still has
-- to canonicalize byte order/copier headers before storing it.
local directDestination = (self.mobileFileBridge and not legacyAndroidPicker
and spec.format ~= "n64"
and type(manifest.path) == "string" and manifest.path ~= "")
and require("src.mods.RequiredImports").path(manifest, spec) or nil
if not pickFile(legacyAndroidPicker and "rom" or "required_import",
directDestination) then
self.pickerPendingKind = nil
self.pickerPendingModId = nil
self.pickerPendingImportId = nil
@@ -2549,6 +2695,8 @@ function RomImporter:_pollPickedFiles(dt)
return
end
local found = love.filesystem.getInfo("export_done.flag", "file") ~= nil
or love.filesystem.getInfo("pick_error.flag", "file") ~= nil
or love.filesystem.getInfo(PICK_COMPLETE_FILENAME, "file") ~= nil
if not found then
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
local n = name:lower()
@@ -4803,6 +4951,7 @@ function RomImporter:_refreshMods()
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
self._cartPlan = nil
self._profileCache = nil
self.findInstalled = nil
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
-- Once per session, ahead of the first listing: pull in any mod the player
+34 -16
View File
@@ -184,6 +184,14 @@ local function adjacentSleepingSnorlax(save, ow)
return nil
end
local function itemUseLine(data, save, name)
return romText(data, "_ItemUseText001", "%s used\n%s!", save.player.name, name)
end
-- PrintItemUseTextAndRemoveItem (item_effects.asm): used-line + SFX_HEAL_AILMENT.
-- BagMenu plays Heal_Ailment via TextBox.soundOpts when extra.useJingle is set.
local USE_JINGLE = { useJingle = true }
-- Use an item on a target party mon (target may be nil for targetless
-- items). data = generated data tables; battle = BattleState when used
-- mid-battle; ow = the overworld (OverworldState), needed only to check
@@ -280,7 +288,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
"All sleeping\nPOKéMON woke up!") }
end
-- battle-only items
-- battle-only items (PrintItemUseTextAndRemoveItem + Heal_Ailment, #1635)
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
or itemId == "POKE_DOLL" then
if not battle then
@@ -293,43 +301,53 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
require("src.world.PikachuFollower")
.modifyHappiness(save, "USEDXITEM", b and b.mon)
end
local used = itemUseLine(data, save, name)
if itemId == "X_ACCURACY" then
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
-- (not an accuracy stage)
-- (not an accuracy stage); vanilla prints only the used line
b.xAccuracy = true
return "consumed", { Strings("%s's\nhits will never\nmiss!", b.name) }
return "consumed", { used }, USE_JINGLE
end
if X_ITEMS[itemId] then
local stat = X_ITEMS[itemId]
local cur = b.stages[stat] or 0
-- ItemUseXStat removes the item BEFORE running the stat-up
-- effect, so at +6 it is still consumed and StatModifierUpEffect
-- just prints "Nothing happened!"
-- ItemUseXStat: PrintItemUseTextAndRemoveItem, then StatModifierUpEffect
if cur >= 6 then
return "consumed", { romText(data, "_NothingHappenedText",
"Nothing happened!") }
return "consumed", { used }, {
useJingle = true,
afterMessages = { romText(data, "_NothingHappenedText",
"Nothing happened!") },
}
end
b.stages[stat] = cur + 1
return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) }
b.hazeStatReset = nil
if battle.ruleset and battle.ruleset.badgeBoostReapplyBug
and battle.kind ~= "link" then
require("src.battle.Damage").reapplyBadgeBoosts(b, stat)
end
return "consumed", { used }, {
useJingle = true,
afterMessages = { Strings("%s's\n%s rose!", b.name,
Strings(STAT_LABEL[stat])) },
}
end
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
-- the item, even when it is already active
-- the item, even when it is already active; vanilla prints only used
if itemId == "DIRE_HIT" then
b.focusEnergy = true
return "consumed", { romText(data, "_GettingPumpedText",
"%s's\ngetting pumped!", b.name) }
return "consumed", { used }, USE_JINGLE
end
if itemId == "GUARD_SPEC" then
b.mist = true
return "consumed", { Strings("%s's\nprotected against\nstat changes!", b.name) }
return "consumed", { used }, USE_JINGLE
end
if itemId == "POKE_DOLL" then
if battle.kind ~= "wild" then
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
return "failed", { notTime(data, save) }
end
return "consumed_escape", { romText(data, "_WildRanText",
"The wild POKéMON\nran away!", battle.enemy and battle.enemy.name) }
-- PrintItemUseTextAndRemoveItem then escape
return "consumed_escape", { used }, USE_JINGLE
end
end
@@ -608,7 +626,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if REPELS[itemId] then
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
save.repelSteps = steps
return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
return "consumed", { itemUseLine(data, save, name) }, USE_JINGLE
end
return "failed", { notTime(data, save) }
+5 -5
View File
@@ -163,8 +163,8 @@ end
-- a module the build dropped disables its registry rather than the game:
-- the consumer still reads its own table, so vanilla keeps working
local function load(path)
local ok, module = pcall(require, path)
local function load(path, moduleLoader)
local ok, module = pcall(moduleLoader or require, path)
if ok then return module end
Logger.warn("builtin registrations skipped for %s (%s)", path, tostring(module))
return nil
@@ -187,14 +187,14 @@ local function isolate(registry)
}, { __index = registry })
end
function Builtins.install(content, data, generation)
function Builtins.install(content, data, generation, moduleLoader)
for _, entry in ipairs(registrantsFor(generation)) do
local registry = content[entry.name] and isolate(content[entry.name])
if registry then
if entry.install then
local modules, complete = {}, true
for i, path in ipairs(entry.modules) do
modules[i] = load(path)
modules[i] = load(path, moduleLoader)
if modules[i] == nil then complete = false end
end
-- data is the fourth argument, not the second, so the existing
@@ -204,7 +204,7 @@ function Builtins.install(content, data, generation)
entry.install(registry, modules, Builtins.OWNER, data)
end
else
local module = load(entry.from)
local module = load(entry.from, moduleLoader)
-- entry.fn names the entry point for a module that owns more than one
-- registry (Gold's Battle owns statuses and move_effects); the default
-- is the registerInto every single-registry module exposes
+412
View File
@@ -0,0 +1,412 @@
-- Read-only, bounded views over version-scoped generated datasets.
local CacheContract = require("src.import.CacheContract")
local DatasetHydration = require("src.core.DatasetHydration")
local GameVersion = require("src.core.GameVersion")
local Logger = require("src.core.Logger")
local SaveSerializer = require("src.core.SaveSerializer")
local Builtins = require("src.mods.Builtins")
local Registry = require("src.mods.Registry")
local Schemas = require("src.mods.Schemas")
local DatasetViews = {}
DatasetViews.__index = DatasetViews
-- A generated module is ROM-sized data, never an arbitrary program. These
-- caps bound both one malicious file and the aggregate work one open performs.
local DECODE_LIMITS = {
allowArray = true,
allowComments = true,
maxBytes = 8 * 1024 * 1024,
maxDepth = 64,
maxNodes = 500000,
maxStringBytes = 2 * 1024 * 1024,
maxTableEntries = 250000,
rootName = "generated data",
}
local MAX_AGGREGATE_BYTES = 48 * 1024 * 1024
local GEN1_ROOTS = {
constants = "constants", maps = "maps", tilesets = "tilesets",
text = "text", text_pointers = "text_pointers",
trainer_headers = "trainer_headers", font = "font", sprites = "sprites",
pokemon = "pokemon", moves = "moves", items = "items",
type_chart = "type_chart", trainers = "trainers",
encounters = "encounters", field = "field",
battle_anims = "battle_anims", audio = "audio",
palettes = "palettes", icons = "icons",
}
local GEN2_ROOTS = {
pokemon = "pokemon", moves = "moves", items = "items",
type_chart = "type_chart", audio = "audio", font = "font",
gen2Maps = "maps", gen2Tilesets = "tilesets", gen2Text = "text",
gen2Trainers = "trainers", gen2Encounters = "encounters",
gen2Sprites = "sprites", gen2Palettes = "palettes",
gen2Icons = "icons", gen2BattleAnims = "battle_anims",
gen2Constants = "constants", gen2Landmarks = "landmarks",
}
local function resolvePath(root, suffix)
local node = root
for key in suffix:gmatch("[^.]+") do
if type(node) ~= "table" then return nil end
node = node[key]
end
return node
end
local function assetRelative(path)
if type(path) ~= "string" or path == "" then
error("dataset asset path is required", 3)
end
if path:find("\\", 1, true) or path:sub(1, 1) == "/"
or path:find("[%z\1-\31]") then
error("dataset asset path must be a generated relative path", 3)
end
if path:sub(1, 17) ~= "assets/generated/" then
error("dataset assets are limited to assets/generated/", 3)
end
for segment in path:gmatch("[^/]+") do
if segment == "." or segment == ".." then
error("dataset asset path may not traverse directories", 3)
end
end
return path
end
local function copyData(value, state, depth)
state = state or { seen = {}, nodes = 0 }
state.nodes = state.nodes + 1
if state.nodes > DECODE_LIMITS.maxNodes then return nil, false end
local kind = type(value)
if kind == "nil" or kind == "boolean" or kind == "number" then return value end
if kind == "string" then
if #value > DECODE_LIMITS.maxStringBytes then return nil, false end
return value
end
if kind ~= "table" or getmetatable(value) ~= nil then return nil, false end
depth = (depth or 0) + 1
if depth > DECODE_LIMITS.maxDepth or state.seen[value] then return nil, false end
state.seen[value] = true
local out, entries = {}, 0
for key, child in pairs(value) do
entries = entries + 1
if entries > DECODE_LIMITS.maxTableEntries then return nil, false end
local keyCopy, keyOk = copyData(key, state, depth)
local childCopy, childOk = copyData(child, state, depth)
if keyOk == false or childOk == false or keyCopy == nil then return nil, false end
out[keyCopy] = childCopy
end
state.seen[value] = nil
return out, true
end
local function isDataOnly(value, state, depth)
state = state or { seen = {}, nodes = 0 }
state.nodes = state.nodes + 1
if state.nodes > DECODE_LIMITS.maxNodes then return false end
local kind = type(value)
if kind == "nil" or kind == "boolean" or kind == "number" then return true end
if kind == "string" then return #value <= DECODE_LIMITS.maxStringBytes end
if kind ~= "table" or getmetatable(value) ~= nil then return false end
depth = (depth or 0) + 1
if depth > DECODE_LIMITS.maxDepth or state.seen[value] then return false end
state.seen[value] = true
local entries = 0
for key, child in pairs(value) do
entries = entries + 1
if entries > DECODE_LIMITS.maxTableEntries
or not isDataOnly(key, state, depth)
or not isDataOnly(child, state, depth) then
return false
end
end
state.seen[value] = nil
return true
end
function DatasetViews.new(fs, engineRequire, decoder)
assert(fs and fs.read and fs.getInfo,
"DatasetViews.new requires a readable filesystem")
return setmetatable({ fs = fs, engineRequire = engineRequire or require,
decoder = decoder or SaveSerializer.decode, datasets = {} }, DatasetViews)
end
function DatasetViews:_preflight(version, inspected)
local paths, aggregate = {}, 0
local modules = CacheContract.semanticModules(version)
for _, name in ipairs(CacheContract.optionalSemanticModules(version)) do
local path = inspected.prefix .. "data/generated/" .. name .. ".lua"
if self.fs.getInfo(path, "file") then modules[#modules + 1] = name end
end
for _, name in ipairs(modules) do
local path = inspected.prefix .. "data/generated/" .. name .. ".lua"
local info = self.fs.getInfo(path, "file")
local size = info and info.size
if size and size > DECODE_LIMITS.maxBytes then return nil, name .. ": size limit" end
aggregate = aggregate + (size or 0)
if aggregate > MAX_AGGREGATE_BYTES then return nil, "aggregate size limit" end
paths[name] = path
end
return { paths = paths, key = table.concat(modules, "\n") }
end
local function resetInternal(view)
view.moduleCache, view.data, view.registries = {}, nil, nil
end
function DatasetViews:_reject(view, moduleName, source, detail)
view.invalid = { module = moduleName, source = source, detail = detail }
view.data, view.registries = nil, nil
Logger.warn("dataset %s cache rejected: %s", view.version, tostring(detail))
return nil
end
function DatasetViews:_ready(view)
if view.invalid or view.unavailable then return false end
local inspected, _, detail = CacheContract.inspect(view.version, self.fs, {
allowSource = true, semantic = true,
})
if not inspected then
view.unavailable = true
if self.datasets[view.version] == view then self.datasets[view.version] = nil end
Logger.warn("dataset %s unavailable: %s", view.version, detail)
return false
end
local plan, invalid = self:_preflight(view.version, inspected)
if not plan then
self:_reject(view, nil, nil, invalid)
return false
end
if view.prefix ~= inspected.prefix or view.plan.key ~= plan.key then
view.prefix, view.plan = inspected.prefix, plan
resetInternal(view)
else
view.plan = plan
end
for name, cached in pairs(view.moduleCache) do
local source = self.fs.read(plan.paths[name])
if type(source) ~= "string" then
self:_reject(view, name, source, name .. ": unreadable generated module")
return false
end
if source ~= cached.source then
resetInternal(view)
break
end
end
return not view.invalid
end
function DatasetViews:_module(view, root)
local moduleName = view.modules[root]
if not moduleName then return nil end
local cached = view.moduleCache[moduleName]
if cached then return cached.value end
local path = view.plan.paths[moduleName]
if not path then return nil end
local source = self.fs.read(path)
if type(source) ~= "string" then
return self:_reject(view, moduleName, source,
moduleName .. ": unreadable generated module")
end
local aggregate = #source
for _, loaded in pairs(view.moduleCache) do aggregate = aggregate + #loaded.source end
if aggregate > MAX_AGGREGATE_BYTES then
return self:_reject(view, moduleName, source, "aggregate size limit")
end
local value, err = self.decoder(source, DECODE_LIMITS)
if type(value) ~= "table" then
return self:_reject(view, moduleName, source,
moduleName .. ": " .. tostring(err or "non-table root"))
end
view.moduleCache[moduleName] = { source = source, value = value }
return value
end
function DatasetViews:_data(view)
if view.data then return view.data end
local data = {}
setmetatable(data, {
__index = function(target, root)
local value = self:_module(view, root)
if value ~= nil then rawset(target, root, value) end
return value
end,
})
DatasetHydration.apply(data, view.version, self.engineRequire)
if view.invalid then error(view.invalid.detail, 0) end
view.data = data
return data
end
local function registryBase(data, target)
return function() return resolvePath(data, target) end
end
function DatasetViews:_registries(view)
if view.registries then return view.registries end
local data = self:_data(view)
local registries = {}
for name, catalogSpec in pairs(Schemas.REGISTRIES) do
local spec = Schemas.shapeFor(name, catalogSpec, view.generation)
local registry = Registry.new(name, spec)
local target = Schemas.targetFor(name, catalogSpec, view.generation)
if target then registry.base = registryBase(data, target) end
registries[name] = registry
end
Builtins.install(registries, data, view.generation, self.engineRequire)
for _, registry in pairs(registries) do registry:freeze() end
view.registries = registries
return registries
end
function DatasetViews:_registry(view, name)
local service = self
local function registryForRead()
if not service:_ready(view) then return nil end
local ok, registries = pcall(service._registries, service, view)
if not ok then
if not view.invalid then service:_reject(view, nil, nil, registries) end
return nil
end
if view.invalid then return nil end
return registries[name]
end
local function validate(registry, id, value)
if value == nil then return true end
local ok, detail = Schemas.check(registry.spec, registry.name, id,
value, "override")
if ok then return true end
local target = registry.spec.target
or Schemas.targetFor(registry.name, registry.spec, view.generation)
local root = target and target:match("^[^%.]+")
local moduleName = root and view.modules[root]
if root == "gen2HeldItems" then moduleName = "items" end
local cached = moduleName and view.moduleCache[moduleName]
service:_reject(view, moduleName, cached and cached.source,
"invalid " .. registry.name .. " record: " .. detail)
return false
end
local function rawAt(id)
local registry = registryForRead()
local value = registry and registry:get(id)
if value == nil or not validate(registry, id, value)
or not isDataOnly(value) then return nil end
return value
end
local function valueAt(id)
local value = rawAt(id)
if value == nil then return nil end
return (copyData(value))
end
return {
get = function(_, id)
if type(id) ~= "string" or id == "" then return nil end
return valueAt(id)
end,
has = function(_, id)
if type(id) ~= "string" or id == "" then return false end
return rawAt(id) ~= nil
end,
each = function()
local ids = {}
local registry = registryForRead()
if registry then
for id, value in registry:each() do
if not validate(registry, id, value) then
ids = {}
break
end
if type(id) == "string" and isDataOnly(value) then
ids[#ids + 1] = id
end
end
end
table.sort(ids)
local index = 0
return function()
index = index + 1
local id = ids[index]
if id == nil then return nil end
return id, valueAt(id)
end
end,
}
end
function DatasetViews:_assets(view)
local service = self
local assets = {}
function assets:path(path)
if not service:_ready(view) then return nil end
return view.prefix .. assetRelative(path)
end
function assets:info(path)
local full = self:path(path)
if not full then return nil end
local info = service.fs.getInfo and service.fs.getInfo(full, "file")
if not info or (info.type and info.type ~= "file") then return nil end
local out = { type = "file" }
if info.size ~= nil then out.size = info.size end
return out
end
return assets
end
function DatasetViews:open(version)
if type(version) ~= "string" or not GameVersion.VERSIONS[version] then
return nil, "unknown_version"
end
local inspected, reason = CacheContract.inspect(version, self.fs, {
allowSource = true, semantic = true,
})
if not inspected then
self.datasets[version] = nil
return nil, reason
end
local plan, invalid = self:_preflight(version, inspected)
if not plan then
Logger.warn("dataset %s cache rejected: %s", version, invalid)
return nil, "invalid_cache"
end
local internal = self.datasets[version]
if internal and internal.invalid then
local bad = internal.invalid
local path = bad.module and plan.paths[bad.module]
local source = path and self.fs.read(path)
if source == bad.source then return nil, "invalid_cache" end
internal = nil
self.datasets[version] = nil
end
local changed = not internal or internal.prefix ~= inspected.prefix
or internal.plan.key ~= plan.key
if changed then
if internal then internal.unavailable = true end
internal = {
version = version,
generation = GameVersion.generation(version),
prefix = inspected.prefix,
plan = plan,
modules = GameVersion.generation(version) == 2 and GEN2_ROOTS or GEN1_ROOTS,
moduleCache = {},
}
self.datasets[version] = internal
else
internal.plan = plan
end
local view = { version = version, generation = internal.generation, content = {} }
for name in pairs(Schemas.REGISTRIES) do
view.content[name] = self:_registry(internal, name)
end
for alias, canonical in pairs(Schemas.ALIASES) do
view.content[alias] = view.content[canonical]
end
view.assets = self:_assets(internal)
return view
end
return DatasetViews
+23 -2
View File
@@ -147,12 +147,19 @@ local SUPPORTED_REQUIRES = {
local ENGINE_PREFIX = (debug.getinfo(1, "S").source or "")
:gsub("^@", ""):gsub("mods[/\\]Loader%.lua$", "")
local ENGINE_CHUNKS = {
["main.lua"] = true,
["conf.lua"] = true,
}
local function callerIsMod(level)
if ENGINE_PREFIX == "" then return false end
local info = debug.getinfo(level, "S")
local source = info and info.source
if not source or source:sub(1, 1) ~= "@" then return false end
return source:sub(2, 1 + #ENGINE_PREFIX) ~= ENGINE_PREFIX
local path = source:sub(2)
if ENGINE_CHUNKS[path] then return false end
return path:sub(1, #ENGINE_PREFIX) ~= ENGINE_PREFIX
end
local function scanRequire(name)
@@ -207,6 +214,11 @@ local function engineRequire(name)
return module
end
function Loader.endSession()
devShim.generation = nil
devShim.errors = nil
end
function Loader:_installDevShim()
for id, mod in pairs(self.mods) do
devShim.permissions[id] = mod.manifest.permissionSet
@@ -234,7 +246,7 @@ function Loader:_installDevShim()
-- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it.
-- Engine code keeps the real module: src/render/PaletteFX.lua:776
-- requires src.core.Game on both generations and means it.
if devShim.generation ~= 1 and Gen2Compat.serves(name)
if devShim.generation == 2 and Gen2Compat.serves(name)
and (owner or callerIsMod(3)) then
local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
if adapter then
@@ -1154,6 +1166,15 @@ function Loader:_api(mod)
developer = loader.dev == true,
-- a deep copy: what a mod does to its own view never reaches the loader
manifest = Merge.deepCopy(mod.manifest),
datasets = {
open = function(_, version)
if not loader.datasetViews then
local DatasetViews = engineRequire("src.mods.DatasetViews")
loader.datasetViews = DatasetViews.new(loader.fs, engineRequire)
end
return loader.datasetViews:open(version)
end,
},
content = {},
exports = {},
DELETE = Registry.DELETE,
+7
View File
@@ -12,6 +12,12 @@ Registry.__index = Registry
-- exposed to mods as mod.DELETE: a patch value that unsets a field
Registry.DELETE = Merge.DELETE
local function assertWritable(self, id)
if self.spec.reservedIds and self.spec.reservedIds[id] then
error(("%s id is reserved engine metadata: %s"):format(self.name, id))
end
end
-- spec comes from Schemas.REGISTRIES[name]; bare Registry.new(name) keeps
-- the v1 record behavior for standalone use in tests and tools
function Registry.new(name, spec)
@@ -33,6 +39,7 @@ local function append(self, id, op, value, owner)
error(self.name .. ": content is frozen after load")
end
assert(type(id) == "string" and id ~= "", self.name .. " id is required")
assertWritable(self, id)
local list = self.ops[id]
if not list then
list = {}
+37 -3
View File
@@ -676,9 +676,10 @@ end
--
-- So beside `value` / `fields` / `keys` / `keyValue` a spec may carry
-- `gen2Value` / `gen2Fields` / `gen2Keys` / `gen2KeyValue`, and beside
-- `semantics` / `extra` / `write` / `baseAt` / `baseIds` / `example` /
-- `notes` the matching `gen2*`. Absent means "the Gen 1 shape is right here
-- too", which is the common case and why most registries carry none of this.
-- `semantics` / `extra` / `write` / `baseAt` / `baseIds` / `reservedIds` /
-- `example` / `notes` the matching `gen2*`. Absent means "the Gen 1 shape is
-- right here too", which is the common case and why most registries carry
-- none of this.
-- The registry NAME, the verbs and (wherever the id space allows it) the ids
-- stay shared, exactly as the routing table keeps them shared.
--
@@ -700,6 +701,7 @@ local GEN2_SHAPE = {
gen2KeyValue = "keyValue", gen2Extra = "extra",
gen2Semantics = "semantics", gen2Write = "write",
gen2BaseAt = "baseAt", gen2BaseIds = "baseIds",
gen2ReservedIds = "reservedIds",
gen2Example = "example", gen2Notes = "notes",
}
@@ -757,6 +759,32 @@ end
local R = {}
Schemas.REGISTRIES = R
-- Some generated Gen 2 modules keep extractor metadata beside their public
-- record maps. These callbacks are the registry normalization boundary: the
-- metadata remains available to engine consumers through Data, but is not an
-- id a mod can read or overwrite through the record registry.
local function recordMapExcept(...)
local excluded = {}
for index = 1, select("#", ...) do excluded[select(index, ...)] = true end
return function(base, id)
if excluded[id] then return nil end
return base[id]
end, function(base)
local ids = {}
for id in pairs(base) do
if not excluded[id] then ids[#ids + 1] = id end
end
return ids
end, excluded
end
local pokemonGen2BaseAt, pokemonGen2BaseIds, pokemonGen2ReservedIds =
recordMapExcept("growthRates", "tmhmMoves")
local movesGen2BaseAt, movesGen2BaseIds, movesGen2ReservedIds =
recordMapExcept("generation", "source")
local itemsGen2BaseAt, itemsGen2BaseIds, itemsGen2ReservedIds =
recordMapExcept("generation", "source", "pockets")
-- ------- shared Gen 2 leaves
--
-- The ROM name spaces Gold's tables key by. They are enums rather than
@@ -779,6 +807,8 @@ local gen2PaletteRow = f.list(gen2Color)
R.pokemon = {
semantics = "record", target = "pokemon",
gen2BaseAt = pokemonGen2BaseAt, gen2BaseIds = pokemonGen2BaseIds,
gen2ReservedIds = pokemonGen2ReservedIds,
fields = {
id = f.str, name = f.str, dex = f.int(1),
index = f.opt(f.int(0, 255)),
@@ -870,6 +900,8 @@ R.pokemon = {
R.moves = {
semantics = "record", target = "moves",
gen2BaseAt = movesGen2BaseAt, gen2BaseIds = movesGen2BaseIds,
gen2ReservedIds = movesGen2ReservedIds,
fields = {
id = f.str, name = f.str,
index = f.opt(f.int(0, 255)),
@@ -894,6 +926,8 @@ R.moves = {
R.items = {
semantics = "record", target = "items",
gen2BaseAt = itemsGen2BaseAt, gen2BaseIds = itemsGen2BaseIds,
gen2ReservedIds = itemsGen2ReservedIds,
fields = {
id = f.str, name = f.str,
index = f.opt(f.int(0, 255)),
+49 -13
View File
@@ -20,6 +20,8 @@ local PaletteFX = {}
local shader -- false = unavailable (headless / no shader support)
local gbcPack -- false = missing; nil = not loaded yet
local yellowPack -- false = missing; nil = not loaded yet
local gbcYellowPack -- Yellow Advanced deltas; false = missing; nil = not loaded yet
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
-- come first (OG RED/BLUE/YELLOW = GBC hardware, SGB = per-map Super Game Boy,
@@ -300,6 +302,38 @@ function PaletteFX.yellowPack()
return yellowPack or nil
end
-- Yellow-only Advanced deltas (BEACH_HOUSE, sprite remap, YELLOWMON). Never
-- consulted on Red/Blue (#1639).
function PaletteFX.gbcYellowPack()
if gbcYellowPack == nil then
local ok, pack = pcall(require, "data.palettes_gbc_yellow")
gbcYellowPack = ok and pack or false
end
return gbcYellowPack or nil
end
-- Active world bake tables for Advanced. Yellow merges gbc_yellow deltas
-- via __index so shared Red tilesets stay identical byte-for-byte.
function PaletteFX.worldPack()
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
if not w then return nil end
if not GameVersion.isYellow() then return w end
local y = PaletteFX.gbcYellowPack()
local yw = y and y.world
if not yw then return w end
return {
tileGroups = setmetatable(yw.tileGroups or {}, { __index = w.tileGroups }),
groupColors = setmetatable(yw.groupColors or {}, { __index = w.groupColors }),
roofGroup = w.roofGroup,
roofByMapIndex = w.roofByMapIndex,
spriteAssignment = yw.spriteAssignment or w.spriteAssignment,
spritePalettes = yw.spritePalettes
and setmetatable(yw.spritePalettes, { __index = w.spritePalettes })
or w.spritePalettes,
}
end
function PaletteFX.usesGbcPack(mode)
mode = mode or PaletteFX.mode
return mode == "redpp"
@@ -453,7 +487,10 @@ function PaletteFX.pal(data, name)
if fromRom then return fromRom end
end
if GameVersion.isYellow() then
if PaletteFX.usesYellowCgb() then
-- OG YELLOW and Advanced both use CGBBase for named pals: SuperPalettes
-- wash out yellows (title MEWMON/LOGO, YELLOWMON) to pale cream. World
-- tile bake still comes from the Advanced GBC pack via worldPack (#1639).
if PaletteFX.usesYellowCgb() or PaletteFX.usesGbcPack() then
local fromCgb = yellowCgbNamedPal(data, name)
if fromCgb then return fromCgb end
end
@@ -511,7 +548,8 @@ function PaletteFX.monPal(data, species, transformed)
if pal then return pal end
end
local name = p.pokemon[species] or "MEWMON"
if PaletteFX.usesYellowCgb() then
if PaletteFX.usesYellowCgb()
or (GameVersion.isYellow() and PaletteFX.usesGbcPack()) then
local yc = PaletteFX.pal(data, name)
if yc then return yc end
end
@@ -618,16 +656,14 @@ local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBel
-- (false for a mod tileset with no pokered-gbc counterpart, or when the
-- pack failed to load at all)
function PaletteFX.hasWorldTileset(tileset)
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
local w = PaletteFX.worldPack()
return (w and w.tileGroups[tileset]) ~= nil
end
-- the palette-group (0-7) a tile GRAPHIC id resolves to in this tileset,
-- with the current map's tile-id exceptions (if any) applied first
function PaletteFX.worldGroupAt(tileset, mapId, tileId)
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
local w = PaletteFX.worldPack()
local groups = w and w.tileGroups[tileset]
if not groups then return nil end
local exc = TILE_GROUP_EXCEPTIONS[mapId]
@@ -642,8 +678,7 @@ end
-- Saffron's roof colors while the player stands in its top 2 cell rows,
-- like pokered's wYCoord check -- data is Game.data, for the map lookup)
function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
local w = PaletteFX.worldPack()
local base = w and w.groupColors[tileset]
if not base then return nil end
if not w.roofGroup[tileset] then return darkGroups(base) end
@@ -678,17 +713,18 @@ end
-- here): a stable hash instead, so the same NPC instance always shows the
-- same one of the 4 SPR_PAL_* colors.
function PaletteFX.spriteObp(spriteDef, seed)
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
local w = PaletteFX.worldPack()
local src = spriteDef and (spriteDef.paletteSource or spriteDef.source)
if not (w and src) then return nil end
local idx = tonumber(src:match("%[(%d+)%]"))
-- RedBikeSprite and SurfingPikachuSprite load outside
-- SpriteSheetPointerTable, so their source has no bracketed index;
-- they wear the player's OBP palette (spriteAssignment[0]).
if not idx and (src:find("RedBikeSprite", 1, true)
or src:find("SurfingPikachuSprite", 1, true)) then
-- they wear the player's OBP palette (spriteAssignment[0]) on Red/Blue.
-- On Yellow, Surfing Pikachu uses the Pikachu yellow OBJ group (#1639).
if not idx and src:find("RedBikeSprite", 1, true) then
idx = 0
elseif not idx and src:find("SurfingPikachuSprite", 1, true) then
idx = GameVersion.isYellow() and 60 or 0
end
local group = idx and w.spriteAssignment[idx]
if group == nil then return nil end
+6 -3
View File
@@ -338,6 +338,7 @@ function Renderer:beginFrame(transparent)
-- warp-fade overlay from Transition (issue #121); cleared each frame so
-- a popped transition cannot leave a sticky black veil
self.worldFadeAlpha = nil
self.worldFadeColor = nil
-- battle-transition wipe, drawn over the whole surface (BattleTransition)
self.battleWipe = nil
-- whole-surface veil in screen space (battle-transition flash, the
@@ -817,7 +818,7 @@ function Renderer:frameRects()
-- GB pixel stops being a whole number of screen pixels, which is the trade
-- the setting exists to offer. Clamped on the horizontal too, so a narrow
-- window scales to fit instead of overflowing off both sides.
if self.uiFill then
if self.uiFill and not FaithfulRes.scaleCap() then
Up = math.min(ph / uih, pw / uiw)
end
if uiw * Up > pw or uih * Up > ph then
@@ -1045,7 +1046,8 @@ function Renderer:endFrame(zones, worldZones)
-- the screen-space overlays the flat path draws over its composite
local fade = self.worldFadeAlpha
if fade and fade > 0 then
love.graphics.setColor(0, 0, 0, fade)
local c = self.worldFadeColor or { 0, 0, 0 }
love.graphics.setColor(c[1], c[2], c[3], fade)
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
love.graphics.setColor(1, 1, 1, 1)
end
@@ -1132,7 +1134,8 @@ function Renderer:endFrame(zones, worldZones)
-- composite normally if one is ever stacked that way.
local fade = self.worldFadeAlpha
if fade and fade > 0 then
love.graphics.setColor(0, 0, 0, fade)
local c = self.worldFadeColor or { 0, 0, 0 }
love.graphics.setColor(c[1], c[2], c[3], fade)
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
love.graphics.setColor(1, 1, 1, 1)
end
+17 -3
View File
@@ -264,7 +264,9 @@ ShaderFX.BRIDGE_DIR = "tools/shaderfx-bridge"
local function libNames()
local osName = (love and love.system and love.system.getOS
and love.system.getOS()) or ""
if osName == "Windows" then
if osName == "iOS" then
return {}
elseif osName == "Windows" then
return { "librashader_bridge.dll" }
elseif osName == "OS X" then
return { "liblibrashader_bridge.dylib", "librashader_bridge.dylib" }
@@ -313,11 +315,13 @@ end
-- Every place the bridge may sit, most specific first.
local function libCandidates()
local names = libNames()
if #names == 0 then return {} end
local out = {}
local override = os.getenv("LIBRASHADER_BRIDGE_DLL")
if override and override ~= "" then out[#out + 1] = override end
local dirs, save = sourceDirs(), saveDir()
for _, name in ipairs(libNames()) do
for _, name in ipairs(names) do
for _, dir in ipairs(dirs) do
out[#out + 1] = dir .. "/" .. name
out[#out + 1] = dir .. "/" .. ShaderFX.BRIDGE_DIR .. "/target/release/" .. name
@@ -353,7 +357,17 @@ local function ensureLib()
end
tried[#tried + 1] = path
end
libError = "librashader bridge not found; looked in " .. table.concat(tried, ", ")
local okSym, sym = pcall(function()
return ffi.C and ffi.C.librashader_translate_preset
end)
if okSym and sym ~= nil then
lib = ffi.C
return lib
end
libError = "librashader bridge not found; ffi.C has no librashader_translate_preset"
if #tried > 0 then
libError = libError .. "; looked in " .. table.concat(tried, ", ")
end
return nil, libError
end
+14 -4
View File
@@ -65,16 +65,24 @@ end
-- (ViridianGym.asm .afterBeat, RocketHideoutB4F BeatGiovanniScript) call
-- GBFadeOutToBlack -> GBFadeInFromBlack instead, so the default keeps the
-- symmetric 32-frame fade back in (home/fade.asm:21, b = 4).
function Transition.new(game, onMidpoint, onDone, warp)
--
-- opts.color = {r,g,b} in 0..1 (default black). opts.frames / opts.framesIn
-- override duration. Fly/Teleport/Dig/Escape Rope use white
-- GBFadeOutToWhite / GBFadeInFromWhite (#1644).
function Transition.new(game, onMidpoint, onDone, warp, opts)
opts = opts or {}
local self = setmetatable({}, Transition)
self.game = game
self.onMidpoint = onMidpoint
self.onDone = onDone
self.t = 0
self.phase = "out"
self.color = opts.color or { 0, 0, 0 }
local style = styleOf(game, "warp_fade")
self.frames = style.frames or FRAMES
if warp then
self.frames = opts.frames or style.frames or FRAMES
if opts.framesIn ~= nil then
self.framesIn = opts.framesIn
elseif warp then
-- a style may still ask for a fade in (mods, and the record is
-- data-driven); the built-in warp is 0, matching hardware
self.framesIn = style.framesIn or FRAMES_IN
@@ -125,6 +133,7 @@ end
function Transition:draw()
local alpha = self:alpha()
local c = self.color or { 0, 0, 0 }
-- Survey zoom draws the overworld into a window-filling world canvas
-- while the UI pass stays the classic 160x144 letterbox. A rect on the
-- UI canvas only darkens that center box (issue #121); when the world
@@ -133,9 +142,10 @@ function Transition:draw()
local r = self.game and self.game.renderer
if r and r.worldActive then
r.worldFadeAlpha = alpha
r.worldFadeColor = c
return
end
love.graphics.setColor(0, 0, 0, alpha)
love.graphics.setColor(c[1], c[2], c[3], alpha)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
end
+24 -5
View File
@@ -529,6 +529,16 @@ function Commands.set_field(ctx, key, value)
ctx.save[key] = value
end
-- scripts/ChampionsRoom.asm:57
function Commands.set_option(ctx, key, value)
local o = ctx.save.options
if not o then
o = {}
ctx.save.options = o
end
o[key] = value
end
function Commands.load_player_starter_name(ctx)
local flags = ctx.save.flags or {}
local species = flags.EVENT_CHOSE_PIKACHU and "PIKACHU"
@@ -720,7 +730,8 @@ end
-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37).
-- skipNickname suppresses AskName for callers that name the gift themselves;
-- no vanilla script uses it (pokeyellow scripts/OaksLab.asm, #1013)
function Commands.give_pokemon(ctx, species, level, skipNickname)
-- gotText prints GotMonText ahead of AskName (give_pokemon.asm:46).
function Commands.give_pokemon(ctx, species, level, skipNickname, gotText)
-- Native mods can transform a gift before the Pokémon object is created.
-- This is intentionally an event rather than a special-case starter hook:
-- mods can use the same seam for story gifts, fossils, or custom scripts.
@@ -754,6 +765,11 @@ function Commands.give_pokemon(ctx, species, level, skipNickname)
ctx.lastCheck = true
ctx.addedToParty = addedToParty
ctx.boxNum = boxNum
-- engine/events/give_pokemon.asm:46
if gotText and ctx.runner then
Commands.text_sound(ctx, "Get_Item1")
Commands.show_text(ctx, "_GotMonText", { RAM = species })
end
-- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks
-- and callback-style callers with no script runner to yield on.
if not gift.nickname and not skipNickname and ctx.runner then
@@ -1303,11 +1319,14 @@ function FadeOverlay:update()
end
function FadeOverlay:draw()
if self.color == "white" then
love.graphics.setColor(1, 1, 1, self.alpha)
else
love.graphics.setColor(0, 0, 0, self.alpha)
local shade = (self.color == "white") and 1 or 0
-- home/fade.asm:26
local r = self.game and self.game.renderer
if r then
r.screenVeil = { shade, self.alpha }
return
end
love.graphics.setColor(shade, shade, shade, self.alpha)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
end
+63 -1
View File
@@ -120,6 +120,67 @@ CallAsm.SITES = {
["04:66d1"] = "TryReceiveItem",
}
-- pokegold-symbols/pokesilver.sym: bank $03 sits two bytes earlier in Silver.
CallAsm.SITES_SILVER = {
["03:4749"] = "GetPartyNickname",
["03:4853"] = "CutDownTreeOrGrass",
["03:4b47"] = "CheckContinueWaterfall",
["03:4d13"] = "SetStrengthFlag",
["03:4d79"] = "TryStrengthOW",
["03:4e1e"] = "DisappearWhirlpool",
["03:4f7d"] = "HasRockSmash",
["03:5094"] = "PutTheRodAway",
["03:506b"] = "Fishing_CheckFacingUp",
["03:51c5"] = "AskCutScript_CheckMap",
}
-- pokecrystal-symbols/pokecrystal.sym: Crystal's own addresses for the same sites.
CallAsm.SITES_CRYSTAL = {
["25:6f76"] = "GiveItemScript_DummyFunction",
["04:65cd"] = "StartMenu",
["04:7327"] = "SelectMenu",
["05:6f5e"] = "OverworldHatchEgg",
["25:6706"] = "EnableWildEncounters",
["24:426f"] = "RingTwice_StartCall",
["24:42eb"] = "HangUp",
["04:53e5"] = "InitCallReceiveDelay",
["24:425c"] = "LoadBillScript",
["24:426a"] = "LoadElmScript",
["3f:5017"] = "MomTriesToBuySomething_ASMFunction",
["04:6599"] = "ItemfinderSound",
["14:46ef"] = "SweetScentEncounter",
["02:431e"] = "TrainerWalkToPlayer",
["14:4753"] = "CheckCanUseSquirtbottle",
["04:764f"] = "SetMemEvent",
["14:4658"] = "PlayPoisonSFX",
["14:467b"] = "CheckWhitedOut",
["04:64fa"] = "OverworldBGMap",
["04:650a"] = "BattleBGMap",
["04:6513"] = "HalveMoney",
["04:6527"] = "GetWhiteoutSpawn",
["03:4706"] = "GetPartyNickname",
["03:4810"] = "CutDownTreeOrGrass",
["23:47e1"] = "BlindingFlash",
["00:3016"] = "HideSprites",
["23:4aed"] = "FlyFromAnim",
["05:54f1"] = "SkipUpdateMapSprites",
["23:4b33"] = "FlyToAnim",
["05:4157"] = "LoadWalkingSpritesGFX",
["03:4b38"] = "CheckContinueWaterfall",
["03:4d12"] = "SetStrengthFlag",
["03:4d78"] = "TryStrengthOW",
["03:4e1d"] = "DisappearWhirlpool",
["23:480a"] = "ShakeHeadbuttTree",
["03:4f7c"] = "HasRockSmash",
["03:5095"] = "PutTheRodAway",
["03:506c"] = "Fishing_CheckFacingUp",
["2e:44b3"] = "LoadFishingGFX",
["03:51ba"] = "AskCutScript_CheckMap",
["2e:41ea"] = "TreeMonEncounter",
["2e:4219"] = "RockMonEncounter",
["04:62f8"] = "TryReceiveItem",
}
-- The three WRAM addresses `memcall` / `memcallasm` / `memjump` take instead of
-- a routine. Not sites: the pointer AT the address is written at run time
-- (LoadMemScript for the queued script, the phone engine for the other two),
@@ -714,7 +775,8 @@ end
-- at import time), and the address pair is the fallback that always works.
function CallAsm.nameFor(label, bank, addr)
if label and CallAsm.ALL[label] then return label end
return CallAsm.SITES[CallAsm.key(bank, addr)]
local key = CallAsm.key(bank, addr)
return CallAsm.SITES[key] or CallAsm.SITES_SILVER[key] or CallAsm.SITES_CRYSTAL[key]
end
-- Run a routine by name. Returns the byte the asm leaves in wScriptVar, or
+19 -4
View File
@@ -54,6 +54,21 @@ local function showMessages(game, msgs, onDone, opts)
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone, opts))
end
-- PrintItemUseTextAndRemoveItem: used-line TextBox carries Heal_Ailment, then
-- optional afterMessages (X Stat effect line) before onDone (#1635).
local function showUseMessages(game, msgs, onDone, extra)
local opts = (extra and extra.useJingle)
and TextBox.soundOpts(game, "Heal_Ailment") or nil
local after = extra and extra.afterMessages
if after and #after > 0 then
showMessages(game, msgs, function()
showMessages(game, after, onDone)
end, opts)
else
showMessages(game, msgs, onDone, opts)
end
end
-- run the use-flow for an item on a chosen target. `picker` is the party
-- menu when it was opened with keepOpen (HP medicine only): it is still on
-- the stack, so every exit that prints has to close it afterwards. For
@@ -115,7 +130,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
if result == "consumed_escape" then -- Poké Doll
consume(game, id, list)
list:close()
showMessages(game, payload, function()
showUseMessages(game, payload, function()
-- ItemUsePokeDoll sets wEscapedFromBattle and never touches
-- wBattleResult, so a script that reads the result afterwards sees
-- 0 -- "defeated". The ghost MAROWAK's script keys on exactly that
@@ -125,7 +140,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
battle.result = "run"
battle.afterQueue = "finish"
battle.phase = "messages"
end)
end, extra)
return
end
@@ -403,9 +418,9 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
end
if battle then
list:close()
showMessages(game, payload, function() battle:itemUsed({}) end)
showUseMessages(game, payload, function() battle:itemUsed({}) end, extra)
else
showMessages(game, payload, closePicker)
showUseMessages(game, payload, closePicker, extra)
end
return
end
+35 -14
View File
@@ -8,8 +8,13 @@ local Assets = require("src.render.Assets")
local Font = require("src.render.Font")
local PaletteFX = require("src.render.PaletteFX")
local Sprites = require("src.pokemon.Sprites")
local SpriteRenderer = require("src.render.SpriteRenderer")
local Strings = require("src.core.Strings")
-- engine/events/diploma.asm:65
local OBP0_90 = { { 255, 255, 255 }, { 255, 255, 255 },
{ 170, 170, 170 }, { 85, 85, 85 } }
local Diploma = {}
Diploma.__index = Diploma
Diploma.isOpaque = true
@@ -49,20 +54,18 @@ local function drawFrameBox(frame, tx, ty, tw, th)
local img = frame.img
local q = frame.quads
love.graphics.setColor(1, 1, 1, 1)
-- corners
love.graphics.draw(img, q[0], tx * 8, ty * 8)
love.graphics.draw(img, q[2], (tx + tw - 1) * 8, ty * 8)
-- engine/link/cable_club.asm:937
love.graphics.draw(img, q[2], tx * 8, ty * 8)
love.graphics.draw(img, q[4], (tx + tw - 1) * 8, ty * 8)
love.graphics.draw(img, q[6], tx * 8, (ty + th - 1) * 8)
love.graphics.draw(img, q[8], (tx + tw - 1) * 8, (ty + th - 1) * 8)
-- horizontal edges
love.graphics.draw(img, q[7], (tx + tw - 1) * 8, (ty + th - 1) * 8)
for x = 1, tw - 2 do
love.graphics.draw(img, q[1], (tx + x) * 8, ty * 8)
love.graphics.draw(img, q[7], (tx + x) * 8, (ty + th - 1) * 8)
love.graphics.draw(img, q[3], (tx + x) * 8, ty * 8)
love.graphics.draw(img, q[0], (tx + x) * 8, (ty + th - 1) * 8)
end
-- vertical edges
for y = 1, th - 2 do
love.graphics.draw(img, q[3], tx * 8, (ty + y) * 8)
love.graphics.draw(img, q[5], (tx + tw - 1) * 8, (ty + y) * 8)
love.graphics.draw(img, q[5], tx * 8, (ty + y) * 8)
love.graphics.draw(img, q[1], (tx + tw - 1) * 8, (ty + y) * 8)
end
end
@@ -91,13 +94,31 @@ function Diploma.render(game)
drawFrameBox(frame, 0, 0, 20, 18)
-- 2. Draw Player character sprite: farcall DrawPlayerCharacter
-- Shifted +33 px right from title screen base (82 + 33 = 115, y = 80)
local picPath, picTrueColor = Sprites.playerPath(
game.data, "front", { kind = "diploma" })
local pic = tryImage(picPath)
-- engine/movie/title.asm:321
local title = (game.data and game.data.field and game.data.field.title) or {}
local titlePlayer = title.player
if type(titlePlayer) == "table" then titlePlayer = titlePlayer.path end
local picPath, picTrueColor = Sprites.playerPic(
titlePlayer or "assets/generated/title/player.png",
{ side = "front", kind = "diploma", data = game.data })
local pic
if picPath then
if picTrueColor then
pic = tryImage(picPath)
else
local ok, faded = pcall(SpriteRenderer.obpImage, picPath, OBP0_90,
"diploma")
pic = (ok and faded) or tryImage(picPath)
end
end
if pic then
love.graphics.setColor(1, 1, 1, 1)
local sx, sy, sw, sh = love.graphics.getScissor()
-- engine/events/diploma.asm:44
love.graphics.setScissor(8, 8, 144, 128)
love.graphics.draw(pic, 115, 80)
if sx then love.graphics.setScissor(sx, sy, sw, sh)
else love.graphics.setScissor() end
if picTrueColor then
PaletteFX.markTrueColor(115, 80, pic:getDimensions())
end
+18 -1
View File
@@ -95,11 +95,28 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via)
self.t = 0
self.done = false
self.canceled = false
Music.play(game.data, Music.special(game.data, "evolution"))
-- engine/movie/evolution.asm:41-46
Music.stop()
self.crySrc = require("src.core.Sound").playCry(game.data, mon.species)
self.cryT = 0
self.cryWait = self.crySrc ~= nil
if not self.cryWait then
Music.play(game.data, Music.special(game.data, "evolution"))
end
return self
end
function EvolutionState:update(dt)
if self.cryWait then
self.cryT = self.cryT + 1
local src = self.crySrc
local playing = src and src.isPlaying and src:isPlaying()
if self.cryT >= 3 and (not playing or self.cryT > 180) then
self.cryWait, self.crySrc = false, nil
Music.play(self.game.data, Music.special(self.game.data, "evolution"))
end
return
end
self.t = self.t + 1
if self.done then return end
local game = self.game
+7 -19
View File
@@ -157,6 +157,11 @@ end
-- new SHADER FX 2 row below it -- when both are set, ShaderFX.render() runs
-- main's chain into secondary's, same as stacking two RetroArch presets.
local function bgLocked(o)
return o.battleLayout == "wide" and o.battleFit == "fill"
and o.battleHud == "extended"
end
-- the vanilla rows as descriptors; each step body is the old per-index
-- ladder's, so the save.options mutations are unchanged
local function buildRows(game)
@@ -197,8 +202,6 @@ local function buildRows(game)
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
if o.battleLayout ~= "wide" then
o.battleHud = "standard"
elseif o.battleFit == "fill" and o.battleHud == "extended" then
o.battleBg = "white"
end
return true
end },
@@ -215,10 +218,6 @@ local function buildRows(game)
step = function(g)
local o = g.save.options
o.battleFit = o.battleFit == "fill" and "fixed" or "fill"
if o.battleFit == "fill" and o.battleLayout == "wide"
and o.battleHud == "extended" then
o.battleBg = "white"
end
return true
end },
{ id = "battleHud", label = Strings("BATTLE HUD"),
@@ -237,9 +236,6 @@ local function buildRows(game)
return false
end
o.battleHud = o.battleHud == "extended" and "standard" or "extended"
if o.battleHud == "extended" and o.battleFit == "fill" then
o.battleBg = "white"
end
return true
end },
-- What sits behind and around the battle. WHITE is the classic paper
@@ -249,11 +245,7 @@ local function buildRows(game)
{ id = "battleBg", label = Strings("BATTLE BG"),
value = function(g)
local o = g.save.options
if o.battleLayout == "wide" and o.battleFit == "fill"
and o.battleHud == "extended" then
o.battleBg = "white"
return Strings("AUTO")
end
if bgLocked(o) then return Strings("AUTO (FILL HUD)") end
local m = o.battleBg
if m == "black" then return Strings("BLACK") end
if m == "world" then return Strings("WORLD") end
@@ -261,11 +253,7 @@ local function buildRows(game)
end,
step = function(g, dir)
local o = g.save.options
if o.battleLayout == "wide" and o.battleFit == "fill"
and o.battleHud == "extended" then
o.battleBg = "white"
return false
end
if bgLocked(o) then return false end
local order = { "white", "black", "world" }
local cur = 1
for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end
+17 -15
View File
@@ -19,17 +19,22 @@ local function itemName(game, id)
return def and def.name or id
end
local function buildItems(game, store)
local function buildItems(game, store, order)
local items = {}
local ids = {}
for id in pairs(store) do table.insert(ids, id) end
table.sort(ids)
local ids = order
if not ids then
ids = {}
for id in pairs(store) do table.insert(ids, id) end
table.sort(ids)
end
for _, id in ipairs(ids) do
table.insert(items, {
value = id,
label = itemName(game, id),
right = "x" .. store[id],
})
if store[id] then
table.insert(items, {
value = id,
label = itemName(game, id),
right = "x" .. store[id],
})
end
end
return items
end
@@ -105,12 +110,9 @@ local function deposit(game)
local pc = game.save.pcItems
local inv = game.save.inventory
local Bag = require("src.inventory.Bag")
-- badges live in save.inventory alongside items but are not depositable
local depositable = {}
for id, count in pairs(inv) do
if not Bag.isBadge(id) then depositable[id] = count end
end
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
-- engine/menus/players_pc.asm:99 wListPointer = wNumBagItems, so deposit order == bag order
local order = Bag.order(game.save, game.data)
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, inv, order), {
kind = "pc_item_deposit",
messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
+3 -1
View File
@@ -687,8 +687,10 @@ function Studio.deleteEntry(id)
local opts = SaveData.loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
if tc.skin == id then
tc.enabled, tc.skin = false, nil
tc.enabled, tc.skin = true, nil
opts.touchControls = tc
SaveData.saveOptions(opts)
TouchControls:applyOptions(opts)
end
Studio.refreshAvailable()
setStatus("Deleted " .. id)
+701 -342
View File
File diff suppressed because it is too large Load Diff
+4 -9
View File
@@ -334,10 +334,9 @@ function TownMap:update(dt)
end
end
-- OG RED bakes the boot-ROM OBJ palette in, so the marker has to be replayed
-- over the screen-wide TOWNMAP zone pass the way every other OBJ is (#301)
-- OG RED and ADVANCED bake an OBJ palette in, so the marker replays over the TOWNMAP zone pass (#301)
function TownMap:markPlayerRedraw(x, y)
if not PaletteFX.usesSpriteObp() then return end
if not (PaletteFX.usesSpriteObp() or PaletteFX.usesGbcPack()) then return end
PaletteFX.markUiSpriteRedraw(self.playerSheet, self.playerQuad, x, y)
end
@@ -402,11 +401,7 @@ function TownMap:draw()
love.graphics.setColor(1, 1, 1, 1)
end
end
-- blinking cursor on the selected location. markerXY is the 8x8 cell's
-- top-left; the cursor asset is a 16x16 hollow frame centered on its own
-- (8,8), so draw it -4,-4 to enclose the cell (engine/menus/town_map.asm
-- draws the box cursor CENTERED on the selected location). Drawing it at
-- the cell top-left put the square in the frame's top-left quadrant (#152).
-- WriteTownMapSpriteOAM carry quirk: -4 X, -3 Y for cursor and player alike -- engine/items/town_map.asm:454
local showCursor = true
if GameVersion.generation() == 1 then
showCursor = self.blink < 25
@@ -416,7 +411,7 @@ function TownMap:draw()
if selected and showCursor then
local x, y = markerXY(selected)
if self.bg.cursor then
love.graphics.draw(self.bg.cursor, x - 4, y - 4)
love.graphics.draw(self.bg.cursor, x - 4, y - 3)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", x + 0.5, y + 0.5, 7, 7)
+25 -4
View File
@@ -826,7 +826,10 @@ function BattleState:drawPic(mon, back)
if trainerBack then
-- PAL_BATTLE_OB_PLAYER: the player's own colours, which are row 0 of
-- TrainerPalettes (Chris shares Cal's).
colors = Palettes.trainerColors(self.palettes, "PLAYER") or colors
-- engine/gfx/color.asm:683-696
local row = Gen2Save.isFemale(self.save) and "FALKNER" or "PLAYER"
colors = Palettes.trainerColors(self.palettes, row)
or Palettes.trainerColors(self.palettes, "PLAYER") or colors
elseif enemyTrainer then
-- The opponent's class row out of the same TrainerPalettes table.
colors = Palettes.trainerColors(self.palettes, self.enemyTrainerClass)
@@ -1258,7 +1261,15 @@ function BattleState:afterAnimFor(side)
return "ANIM_PLAYER_DAMAGE"
end
function BattleState:animForMove(moveId, side, param)
-- engine/battle_anims/anim_commands.asm:1200 PlayHitSound
function BattleState:playHitSound(effectiveness)
if not effectiveness or effectiveness == 0 then return end
if effectiveness > 10 then self:playSfx("Sfx_SuperEffective")
elseif effectiveness < 10 then self:playSfx("Sfx_NotVeryEffective")
else self:playSfx("Sfx_Damage") end
end
function BattleState:animForMove(moveId, side, param, effectiveness)
local key = self.anims and self.anims.moves and self.anims.moves[moveId]
local started = self:startAnim(key, {
turn = self:turnFor(side), animId = moveId, isMove = true, param = param,
@@ -1267,7 +1278,8 @@ function BattleState:animForMove(moveId, side, param)
-- BattleAnimRunScript (anim_commands.asm:55-72): after the move script
-- restores HUDs it immediately runs wBattleAfterAnim (the hit shake).
-- Queue it so stepAnim chains without waiting on the next event.
self.pendingAfterAnim = { name = self:afterAnimFor(side), side = side }
self.pendingAfterAnim = { name = self:afterAnimFor(side), side = side,
effectiveness = effectiveness }
end
return started
end
@@ -1278,6 +1290,7 @@ function BattleState:startPendingAfterAnim()
if not pending then return false end
self.pendingAfterAnim = nil
if self:animForId(pending.name, pending.side) then
self:playHitSound(pending.effectiveness)
-- dealDamage's default ANIM_x_DAMAGE is this same shake; skip it there.
self.afterAnimPlayed = true
return true
@@ -1575,6 +1588,11 @@ function BattleState:advanceQueue()
self:showPages(text)
return
end
-- data/text/battle.asm:184
if event.kind == "money" then
self:showPages(event.text or "")
return
end
-- The shiny sparkle: hBattleTurn 1 and wBattleAnimParam 1 pick
-- BattleAnim_SendOutMon's `.Shiny` arm on the enemy (core.asm:8708-8715).
if event.kind == "shiny-flash" then
@@ -1692,12 +1710,14 @@ function BattleState:advanceQueue()
if event.kind == "move" and not event.missed then
self.afterAnimPlayed = nil
self.pendingAfterAnim = nil
if not self:animForMove(event.move, event.side, event.animParam) then
if not self:animForMove(event.move, event.side, event.animParam,
event.effectiveness) then
-- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim
-- (anim_commands.asm:55-72 .disabled fallthrough).
local options = self.game and self.game.options
if options and options.battleScene == false then
if self:animForId(self:afterAnimFor(event.side), event.side) then
self:playHitSound(event.effectiveness)
self.afterAnimPlayed = true
end
end
@@ -1725,6 +1745,7 @@ function BattleState:advanceQueue()
and (hit == "ANIM_ENEMY_DAMAGE" or hit == "ANIM_PLAYER_DAMAGE") then
self.afterAnimPlayed = nil
else
self:playHitSound(event.effectiveness)
self:animForId(hit, from)
end
end
+27 -14
View File
@@ -441,15 +441,25 @@ end
-- Intro_ClearBGPals blacks all 16 palettes and burns two frames
-- (engine/movie/intro.asm:1554-1571); IntroScene26 clears to white instead
-- (engine/movie/intro.asm:1058, home/tilemap.asm:168-196).
local function setup(self, white, fn)
-- (engine/movie/intro.asm:1058, home/tilemap.asm:1-9,168-196).
-- Request2bpp (home/gfx.asm:1,190-260): TILES_PER_CYCLE tiles per frame,
-- then one more frame for the final short (or empty) request.
local function requestFrames(tiles)
local frames = 0
for _, count in ipairs(tiles) do
frames = frames + math.floor(count / 8) + 1
end
return frames
end
local function setup(self, white, tiles, fn)
if self.phase == 0 then
self.phase = 1
local color = white and WHITE or BLACK
self.bgPals = flatPals(color)
self.obPals = flatPals(color)
markDirty(self)
self.hold = 1
self.hold = (white and 4 or 2) + requestFrames(tiles) - 1
return
end
self.phase = 0
@@ -556,7 +566,7 @@ local Scenes = {}
-- IntroScene1 (engine/movie/intro.asm:96-146).
Scenes[1] = function(self)
setup(self, false, function()
setup(self, false, { 64, 128, 128, 64 }, function()
loadAct(self, "unownA")
clearAnims(self)
self.scx, self.scy = 0, 0
@@ -582,7 +592,7 @@ end
-- IntroScene3 (engine/movie/intro.asm:172-218).
Scenes[3] = function(self)
setup(self, false, function()
setup(self, false, { 64, 128, 64 }, function()
loadAct(self, "background")
resetLYOverrides(self)
self.scx, self.scy = 0, 0
@@ -602,7 +612,7 @@ end
-- IntroScene5 (engine/movie/intro.asm:234-285).
Scenes[5] = function(self)
setup(self, false, function()
setup(self, false, { 64, 128, 128, 64 }, function()
loadAct(self, "unownHI")
self.lyActive = false
clearAnims(self)
@@ -641,7 +651,7 @@ end
-- IntroScene7 (engine/movie/intro.asm:332-401).
Scenes[7] = function(self)
setup(self, false, function()
setup(self, false, { 64, 128, 255, 128, 64 }, function()
loadAct(self, "background")
resetLYOverrides(self)
clearAnims(self)
@@ -708,7 +718,7 @@ end
-- IntroScene11 (engine/movie/intro.asm:502-550).
Scenes[11] = function(self)
setup(self, false, function()
setup(self, false, { 64, 128, 64 }, function()
loadAct(self, "unowns")
self.lyActive = false
clearAnims(self)
@@ -729,7 +739,10 @@ local UNOWN_SOUNDS = {
Scenes[12] = function(self)
local a = self.counter
local sound = UNOWN_SOUNDS[a]
if sound then self:playSfx(sound) end
if sound then
Sound.sfxChannelsOff()
self:playSfx(sound)
end
self.counter = (a + 1) % 256
if a >= 0xc0 then
self.scene = 13
@@ -746,7 +759,7 @@ end
-- IntroScene13 (engine/movie/intro.asm:626-683).
Scenes[13] = function(self)
setup(self, false, function()
setup(self, false, { 64, 255, 128, 64 }, function()
loadAct(self, "background")
clearAnims(self)
self.anims:init("INTRO_SUICUNE", 11 * 8, 13 * 8 + 4)
@@ -783,7 +796,7 @@ end
-- IntroScene15 (engine/movie/intro.asm:730-792).
Scenes[15] = function(self)
setup(self, false, function()
setup(self, false, { 64, 128, 128, 1, 64 }, function()
loadAct(self, "suicuneJump")
clearAnims(self)
self.anims:init("INTRO_UNOWN_F", 5 * 8, 8 * 8)
@@ -809,7 +822,7 @@ end
-- IntroScene17 (engine/movie/intro.asm:812-859).
Scenes[17] = function(self)
setup(self, false, function()
setup(self, false, { 64, 255, 64 }, function()
loadAct(self, "suicuneClose")
clearAnims(self)
self.scx, self.scy = 0, 0
@@ -832,7 +845,7 @@ end
-- IntroScene19 (engine/movie/intro.asm:878-941).
Scenes[19] = function(self)
setup(self, false, function()
setup(self, false, { 64, 128, 128, 1, 64 }, function()
loadAct(self, "suicuneBack")
clearAnims(self)
self.anims:init("INTRO_SUICUNE_AWAY", 0, 12 * 8)
@@ -918,7 +931,7 @@ end
-- IntroScene26 (engine/movie/intro.asm:1056-1103).
Scenes[26] = function(self)
setup(self, true, function()
setup(self, true, { 64, 128, 64 }, function()
loadAct(self, "crystalUnowns")
clearAnims(self)
self.scx, self.scy = 0, 0
+22 -16
View File
@@ -41,6 +41,8 @@ ItemPcMenu.isOpaque = true
local PC_ITEM_CAPACITY = 50
local MAX_STACK = 99
local function stacksFor(n) return math.ceil((n or 0) / MAX_STACK) end
-- PlayersPCMenuData .PlayersPCMenuPointers strings, verbatim. .WhichPC picks
-- which rows a caller sees: PLAYERSPC_NORMAL ends on LOG OFF, PLAYERSPC_HOUSE
-- carries DECORATION and ends on TURN OFF.
@@ -167,18 +169,24 @@ function ItemPcMenu:rebuild()
for id, count in pairs(pc) do
if (count or 0) > 0 then
local def = self:def(id)
rows[#rows + 1] = {
id = id, count = count,
name = (def and def.name) or id,
index = def and def.index or math.huge,
}
local remaining = count
while remaining > 0 do
local n = math.min(remaining, MAX_STACK)
rows[#rows + 1] = {
id = id, count = n,
name = (def and def.name) or id,
index = def and def.index or math.huge,
}
remaining = remaining - n
end
end
end
-- wPCItems keeps acquisition order; without that recorded, item id order is
-- the stable choice, the same sort the PACK uses.
table.sort(rows, function(a, b)
if a.index ~= b.index then return a.index < b.index end
return a.id < b.id
if a.id ~= b.id then return a.id < b.id end
return a.count > b.count
end)
self.rows = rows
if self.listIndex > #rows + 1 then self.listIndex = #rows + 1 end
@@ -200,20 +208,18 @@ function ItemPcMenu:ensureVisible()
math.max(0, self:listTotal() - VISIBLE_ROWS)))
end
-- ReceiveItem over wPCItems: a new id needs one of the fifty stacks, a grown
-- one may not pass 99. False is the no-carry the deposit turns into
-- ReceiveItem over wPCItems: the add tops up every existing stack of that id
-- and spills the rest into a new one, so it only needs a free stack when the
-- room in place is short. False is the no-carry the deposit turns into
-- _PlayersPCNoRoomDepositText.
-- engine/items/items.asm:156 PutItemInPocket
function ItemPcMenu:pcAdd(id, qty)
local pc = self.save.pcItems
local held = pc[id] or 0
if held == 0 then
local stacks = 0
for _, count in pairs(pc) do
if (count or 0) > 0 then stacks = stacks + 1 end
end
if stacks >= PC_ITEM_CAPACITY then return false end
end
if held + qty > MAX_STACK then return false end
local used = 0
for _, count in pairs(pc) do used = used + stacksFor(count) end
local need = stacksFor(held + qty) - stacksFor(held)
if used + need > PC_ITEM_CAPACITY then return false end
pc[id] = held + qty
return true
end
+13 -6
View File
@@ -156,7 +156,9 @@ function TitleState.new(game, opts)
self.entranceHideBelow = entrance and tonumber(entrance.hideBelow) or nil
self.gemY = entrance and (tonumber(title.gemFromY) or -50) or self.gemRestY
self.entranceSfx = title.entranceSfx
-- engine/menus/intro_menu.asm:951-966
self.timeoutFrames = tonumber(title.timeoutFrames)
or ((self.trailMode == "silver") and (73 * 60 + 36) or (84 * 60 + 16))
self.onTimeout = opts.onTimeout
-- The copyright window line is pal 7 (engine/movie/title.asm:40-43); its
-- colour 0 backs the whole band.
@@ -295,12 +297,17 @@ function TitleState:update(_dt)
return
end
-- TitleScreenTimer / TitleScreenMain's run-down back into the attract
-- loop (engine/menus/intro_menu.asm:1125-1236).
if self.timeoutFrames and self.onTimeout
and self.frameCounter - (self.timeoutStart or 0) >= self.timeoutFrames then
self.onTimeout()
return
-- engine/menus/intro_menu.asm:1023-1059
if self.timeoutFrames and self.onTimeout then
if self.fadeStart then
if self.frameCounter - self.fadeStart >= 60 then self.onTimeout() end
return
end
if self.frameCounter - (self.timeoutStart or 0) >= self.timeoutFrames then
self.fadeStart = self.frameCounter
Music.fadeOut(8)
return
end
end
local input = self.game.input
+26 -5
View File
@@ -1232,6 +1232,10 @@ function OverworldState:update(dt)
self.player.inputLocked = false
end
end
if self.spinArrive and not self.player.spinFrames then
self.spinArrive = nil
self.player.inputLocked = false
end
-- EnterMapAnim's .done tail re-enables the companion once the swoop or the
-- spin-down has landed (player_animations.asm:40)
@@ -1311,7 +1315,7 @@ function OverworldState:update(dt)
local scripted = self.runner:isRunning() or #self.scriptMoves > 0
or (self.hopLand or 0) > 0
or self.engaging or self.emote or self.teleportOut
or self.flyAnim or self.flyArrive
or self.flyAnim or self.flyArrive or self.spinArrive
if not scripted and not self.transitioning then
self:checkTrainerSight()
-- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and
@@ -1321,7 +1325,7 @@ function OverworldState:update(dt)
scripted = self.runner:isRunning() or #self.scriptMoves > 0
or (self.hopLand or 0) > 0
or self.engaging or self.emote or self.teleportOut
or self.flyAnim or self.flyArrive
or self.flyAnim or self.flyArrive or self.spinArrive
end
-- a scriptMove's onDone can push a text box on the frame it retires, and
-- DisplayTextID owns the loop from there (home/text_script.asm:3)
@@ -4802,6 +4806,10 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
self.doorWarp = nil
local arriveWarp = self.arriveWarp
self.arriveWarp = nil
if self.spinArrive then
self.spinArrive = nil
self.player.inputLocked = false
end
-- PlayMapChangeSound (home/overworld.asm) plays before the tail-called
-- GBFadeOutToBlack, so the SFX starts with the fade (#961)
if doorWarp then
@@ -4810,6 +4818,15 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
require("src.core.Sound").play(Game.data,
outdoor and "Go_Outside" or "Go_Inside")
end
-- Fly/Teleport/Dig/Escape Rope: GBFadeOutToWhite / GBFadeInFromWhite
-- (player_animations.asm). Door warps stay black with no fade-in (#1644).
local Timing = require("src.core.Timing")
local specialWarp = arriveWarp == "fly" or arriveWarp == "teleport"
local fadeOpts = specialWarp and {
color = { 1, 1, 1 },
frames = Timing.FADE_OUT_TO_WHITE,
framesIn = Timing.FADE_IN_FROM_WHITE,
} or 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
@@ -4849,6 +4866,9 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
self.player.spinFrames = 48
self.player.spinTotal = 48
self.player.spinDrop = true
-- engine/overworld/player_animations.asm:19
self.spinArrive = true
self.player.inputLocked = true
end
if doorWarp then
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
@@ -4873,7 +4893,7 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
end, function()
self.transitioning = false
if onDone then onDone() end
end, true)) -- warp shape: no fade back in (LoadGBPal restores in one write)
end, not specialWarp, fadeOpts))
end
-- Re-read a map record after its data changed (WorldAPI:invalidateMap,
@@ -5329,8 +5349,9 @@ function OverworldState:drawWorld()
-- cry with no bubble still pauses the world for its beat)
if self.emote.bubble == false then return end
local npc = self.emote.npc
local ex = npc.px - cam.x + 4
local ey = npc.py - cam.y - 14
-- engine/overworld/emotion_bubbles.asm:41
local ex = npc.px - cam.x
local ey = npc.py - cam.y - 20
local bubble = Game.data.field.emotionBubbles
local drawn = false
if bubble and bubble.path then
+4 -4
View File
@@ -551,6 +551,7 @@ function World.new(game)
lastSfx = nil,
pokePic = nil,
pendingSceneScript = false,
startedOverworld = false,
-- GBC color state (engine/gfx/color.asm). `daytime` is the resolved
-- MORN/DAY/NITE/DARK the map is currently lit by; clockHour overrides
-- World:hour for drivers and tests, so the palette, the hour windows and
@@ -9045,10 +9046,9 @@ function World:setMap(mapId, cx, cy, facing, opts)
if not opts.seamless then
self.pendingSceneScript = true
end
-- StartMap (engine/overworld/events.asm): `farcall InitCallReceiveDelay` on
-- every map entry, connections included -- which is why a player who keeps
-- warping is never rung (src/core/gen2/Phone.lua's receive timer).
if self.game and self.game.save then
-- engine/overworld/events.asm:98
if not self.startedOverworld and self.game and self.game.save then
self.startedOverworld = true
require("src.core.gen2.Phone").onMapLoad(self.game.save,
self:stepContext().phone)
end
+32
View File
@@ -30,6 +30,7 @@ local MapOverview = require("src.world.MapOverview")
local Bike = require("src.world.gen2.Bike")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Permissions = require("src.world.gen2.Permissions")
local Mail = require("src.core.gen2.Mail")
local WorldAPI = {}
WorldAPI.__index = WorldAPI
@@ -49,6 +50,11 @@ local FIELD_ACTIONS = {
{ id = "teleport", move = "TELEPORT" },
}
local function validPartySlot(party, slot)
return type(slot) == "number" and slot == math.floor(slot)
and party[slot] ~= nil
end
function WorldAPI.new(game, modId)
return setmetatable({ game = game, modId = modId }, WorldAPI)
end
@@ -68,6 +74,32 @@ function WorldAPI:current()
facing = p and p.facing }
end
-- Keep the public party-ordering contract identical across generations.
-- Gen 2 stores mail by party slot, so it must move with the Pokemon just as
-- the native PartyMenu's SwitchPartyMons path does.
function WorldAPI:canReorderParty()
local world, game = self:overworld(), self.game
local party = game and game.save and game.save.party or {}
return #party > 1 and world ~= nil and world:acceptsMenuInput()
end
function WorldAPI:reorderParty(fromSlot, toSlot)
local world, game = self:overworld(), self.game
if not world then return nil, NO_OVERWORLD end
if not world:acceptsMenuInput() then return nil, "world is busy" end
local party = game.save and game.save.party or {}
if not validPartySlot(party, fromSlot)
or not validPartySlot(party, toSlot) then
return nil, "invalid party slot"
end
if fromSlot ~= toSlot then
party[fromSlot], party[toSlot] = party[toSlot], party[fromSlot]
Mail.swapSlots(game.save, fromSlot, toSlot)
require("src.core.Sound").play(game.data, "Sfx_SwitchPokemon")
end
return true
end
local function itemLabel(game, id)
local def = game and game.data and game.data.items
and game.data.items[id]
@@ -23,10 +23,10 @@ return function(game)
fitRow.step(game, 1)
assert(options.battleFit == "fill", "battle size switched to FILL")
assert(options.battleBg == "white", "FILL + EXTENDED normalized background to WHITE")
assert(bgRow.value(game) == "AUTO", "adaptive background is labeled AUTO")
assert(options.battleBg == "black", "FILL + EXTENDED preserves the stored background")
assert(bgRow.value(game) == "AUTO (FILL HUD)", "adaptive background is labeled AUTO")
assert(bgRow.step(game, 1) == false, "AUTO background row is locked")
assert(options.battleBg == "white", "locked AUTO retains the WHITE value")
assert(options.battleBg == "black", "locked AUTO does not overwrite the stored value")
menu.index = bgIndex
menu.scroll = math.max(0, bgIndex - 5)
@@ -37,11 +37,11 @@ return function(game)
fitRow.step(game, -1)
assert(options.battleFit == "fixed", "battle size switched back to FIXED")
assert(bgRow.value(game) == "WHITE", "FIXED exposes the stored WHITE choice")
assert(bgRow.step(game, 1) == true and options.battleBg == "black",
"FIXED can select BLACK")
assert(bgRow.value(game) == "BLACK", "FIXED exposes the stored BLACK choice")
assert(bgRow.step(game, 1) == true and options.battleBg == "world",
"FIXED can select WORLD")
assert(bgRow.step(game, 1) == true and options.battleBg == "white",
"FIXED can select WHITE")
U.wait(2)
local fixedPath = DIR .. "/fixed_extended_background_choices.png"
os.remove(fixedPath)
@@ -99,4 +99,45 @@ do
check(type(Game.load) == "function", "Game:reset keeps methods")
end
-- 6. Play-again after endGameSession: Game.load must still be callable
-- (Android crash: main.lua bootGame → Game:load with load == nil)
do
local Game = require("src.core.Game")
local SessionLifecycle = require("src.core.SessionLifecycle")
Game.save = {}
Game.stack = { clear = function() end }
-- Mimic a shared net module parked on the singleton (handle-style release).
Game.net = { release = function(id) end }
SessionLifecycle.endGameSession(Game)
check(package.loaded["src.core.Game"] == nil,
"endGameSession drops the Gen1 Game module cache")
local again = require("src.core.Game")
check(type(rawget(again, "load")) == "function",
"Play-again can call Game:load after endGameSession")
check(again ~= Game, "Play-again gets a fresh Gen1 Game module table")
end
-- 7. endGameSession must not join the ChipAudio worker (process-tier only).
-- Joining on every EXIT GAME correlates with release-APK Game.load nil
-- when reopening a version already played this process.
do
local SessionLifecycle = require("src.core.SessionLifecycle")
local ChipAudio = require("src.core.ChipAudio")
local shutdownCalls, stopCalls = 0, 0
local origShutdown, origStop = ChipAudio.shutdown, ChipAudio.stopMusic
ChipAudio.shutdown = function(...)
shutdownCalls = shutdownCalls + 1
return origShutdown(...)
end
ChipAudio.stopMusic = function(...)
stopCalls = stopCalls + 1
return origStop(...)
end
local game = { reset = function() end, load = function() end }
SessionLifecycle.endGameSession(game)
ChipAudio.shutdown, ChipAudio.stopMusic = origShutdown, origStop
eq(shutdownCalls, 0, "endGameSession does not ChipAudio.shutdown")
check(stopCalls >= 1, "endGameSession stops chip music (Music.stop and/or stopMusic)")
end
T.finish("android_exit_to_launcher_test")
+6 -1
View File
@@ -118,8 +118,13 @@ local systemFile = assert(io.open(systemPath, "rb"))
local system = systemFile:read("*a")
systemFile:close()
check(system:find('strcmp(kind, "required_import")', 1, true)
and system:find('dest = "picked_required_import.bin"', 1, true)
and system:find('destination != nullptr', 1, true)
and system:find('"picked_required_import.bin"', 1, true)
and system:find('return "rom,mod,sav,required_import"', 1, true),
"native Android bridge advertises and routes required imports")
check(source:find('normalized.startsWith("mods/")', 1, true)
and source:find('/baseroms/', 1, true)
and source:find('PICK_COMPLETE_FILENAME', 1, true),
"direct required imports stay inside mod baseroms and publish completion")
print("android_host_extension_test: ok")
@@ -0,0 +1,46 @@
-- Imported semantic modules are opened without eager reads and are decoded
-- once, on first use, through the same public facade mods receive.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Fixture = require("tests.modkit.dataset_view_fixture")
local DatasetViews = require("src.mods.DatasetViews")
local GameVersion = require("src.core.GameVersion")
local SaveSerializer = require("src.core.SaveSerializer")
local files = {}
Fixture.cache(files, "gold")
local fs = T.sdk.memfs(files)
local rawRead = fs.read
local reads = {}
fs.read = function(path)
reads[path] = (reads[path] or 0) + 1
return rawRead(path)
end
local decodes = 0
local service = DatasetViews.new(fs, require, function(source, limits)
decodes = decodes + 1
return SaveSerializer.decode(source, limits)
end)
local pokemonPath = GameVersion.cachePrefix("gold")
.. "data/generated/pokemon.lua"
local movesPath = GameVersion.cachePrefix("gold")
.. "data/generated/moves.lua"
local view, reason = service:open("gold")
T.eq(reason, nil, "marker-valid Gold dataset opens")
T.check(view ~= nil, "open returns the lazy view")
T.eq(reads[pokemonPath] or 0, 0, "open does not read an unused root")
T.eq(reads[movesPath] or 0, 0, "open does not read another unused root")
T.eq(decodes, 0, "open decodes no semantic root")
local first = view and view.content.pokemon:get("FIXMON")
T.eq(first and first.name, "FIXMON", "first access decodes a valid root")
T.check(decodes > 0, "first root access performs bounded decoding")
T.eq(reads[movesPath] or 0, 0, "pokemon access leaves moves unused")
local afterFirst = decodes
local second = view and view.content.pokemon:get("FIXMON")
T.eq(second and second.name, "FIXMON", "repeated access remains available")
T.eq(decodes, afterFirst, "repeated access does not re-decode cached roots")
T.finish("dataset_views_lazy_validation")
@@ -0,0 +1,22 @@
-- Dedicated Route B no-mod parity: the additive facade remains cold.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local reads = 0
local fs = T.sdk.memfs({})
local read = fs.read
fs.read = function(path)
if path:match("^[a-z]+/data/generated/")
or path:match("^[a-z]+/rom%-cache%.complete$") then
reads = reads + 1
end
return read(path)
end
local data = { pokemon = { ACTIVE = { id = "ACTIVE", nested = { n = 1 } } } }
local run = T.sdk.loadNone({ fs = fs, data = data, generation = 1 })
T.eq(#run.errors, 0, "no-mod load remains clean")
T.eq(run.loader.datasetViews, nil, "no-mod load does not allocate dataset service")
T.eq(reads, 0, "no-mod load performs no imported dataset reads")
T.eq(data.pokemon.ACTIVE.nested.n, 1, "no-mod data remains unchanged")
run.release()
T.finish("dataset_views_no_mod_parity")
@@ -0,0 +1,56 @@
-- Optional dataset absence is a handled API result, while a previously valid
-- view disappearing and malformed cache content remain actionable warnings.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Fixture = require("tests.modkit.dataset_view_fixture")
local DatasetViews = require("src.mods.DatasetViews")
local GameVersion = require("src.core.GameVersion")
local Logger = require("src.core.Logger")
local previousWarn = Logger.warn
local warnings = {}
Logger.warn = function(fmt, ...)
warnings[#warnings + 1] = select("#", ...) > 0
and string.format(fmt, ...) or fmt
end
local ok, err = xpcall(function()
local unavailable = DatasetViews.new(T.sdk.memfs({}))
local unknown, unknownReason = unavailable:open("missing-version")
T.eq(unknown, nil, "unknown version has no view")
T.eq(unknownReason, "unknown_version", "unknown version returns its reason")
local absent, absentReason = unavailable:open("gold")
T.eq(absent, nil, "missing optional Gold dataset has no view")
T.eq(absentReason, "not_imported", "missing optional Gold returns its reason")
T.eq(#warnings, 0, "unknown and initially absent datasets do not warn")
local files = {}
Fixture.cache(files, "gold")
local service = DatasetViews.new(T.sdk.memfs(files))
local view = assert(service:open("gold"))
files[GameVersion.cachePrefix("gold") .. "rom-cache.complete"] = nil
T.eq(view.assets:path("assets/generated/missing.png"), nil,
"a stale view closes when its imported dataset disappears")
T.eq(#warnings, 1, "a previously valid view disappearing still warns")
T.check(warnings[1]:find("dataset gold unavailable", 1, true) ~= nil,
"the stale-view warning identifies the unavailable dataset")
warnings = {}
local malformedFiles = {}
Fixture.cache(malformedFiles, "gold", { pokemon = "not generated data" })
local malformed = assert(DatasetViews.new(T.sdk.memfs(malformedFiles)):open("gold"))
T.eq(#warnings, 0, "lazy open does not warn before malformed data is read")
T.eq(malformed.content.pokemon:get("FIXMON"), nil,
"malformed generated data fails closed")
T.eq(#warnings, 1, "malformed generated data still warns")
T.check(warnings[1]:find("dataset gold cache rejected", 1, true) ~= nil,
"the malformed-cache warning identifies cache rejection")
T.check(warnings[1]:find("pokemon:", 1, true) ~= nil,
"the malformed-cache warning keeps actionable module detail")
end, debug.traceback)
Logger.warn = previousWarn
if not ok then error(err, 0) end
T.finish("dataset_views_warning_policy")
@@ -48,7 +48,7 @@ end
-- #1461: saves that passed through 0.1.82-0.1.9x already have the player's
-- own id written onto traded mons, so stamping correctly from now on does
-- not help them. The repair runs on every load, not behind a format gate.
-- not help them. The repair is a one-shot pre-format-5 migration.
do
local SaveData = require("src.core.SaveData")
local save = newSave()

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