mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b77dbe3ea | |||
| 0c6c813e87 | |||
| a6c914ecad | |||
| 1f12e4838f | |||
| 7fa762871f | |||
| 4d805abb9c | |||
| 51908faabf | |||
| 54c7a3aeae | |||
| 5cc33af354 | |||
| 93c7dff4d6 | |||
| b796c30278 | |||
| ec6b03f84b | |||
| 69c7ee89b5 | |||
| 28dc9b9bb0 | |||
| 2ca07cfd09 | |||
| b3159c19fd | |||
| 08121faa3d | |||
| bbe0f0d9ae | |||
| b35fab845a | |||
| d622e83a8e | |||
| 9dd38e06a5 | |||
| 6b4796b08f | |||
| ab61d08fe9 | |||
| c94ff016d9 | |||
| 85e47bc422 | |||
| 1850b50ee6 | |||
| ef62962010 | |||
| ba201aa4ad | |||
| 7d6566fa51 | |||
| 1ad6c810fa | |||
| 233082967c | |||
| d76d173b60 | |||
| bc7b96c963 | |||
| aea38240b2 | |||
| 56fc4653d7 | |||
| 0142468125 | |||
| e7d898625d | |||
| 68fc01dd1c | |||
| cec19db5e0 | |||
| 38d515f547 | |||
| 7e069df740 | |||
| ca700c44d7 | |||
| b286e6584f |
@@ -33,6 +33,8 @@ body:
|
||||
- Blue
|
||||
- Yellow
|
||||
- Gold
|
||||
- Silver
|
||||
- Crystal
|
||||
- N/A
|
||||
validations:
|
||||
required: true
|
||||
|
||||
+9
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
[](https://youtu.be/yi7LkWQPKKM)
|
||||
[](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,
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,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"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+4
-9
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user