mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
Compare commits
55 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 | |||
| 5ee4a3cd35 | |||
| 4794ddcfdf | |||
| 6503e982db | |||
| 72ce1fe33e | |||
| 493fca7297 | |||
| 7d5856f93b | |||
| ef43814efc | |||
| bed1062a5f | |||
| 190c03cfca | |||
| cec264ad81 | |||
| 041051e154 | |||
| 0d14980205 | |||
| 68fc01dd1c | |||
| cec19db5e0 | |||
| 38d515f547 | |||
| 7e069df740 | |||
| ca700c44d7 | |||
| b286e6584f |
@@ -33,6 +33,8 @@ body:
|
|||||||
- Blue
|
- Blue
|
||||||
- Yellow
|
- Yellow
|
||||||
- Gold
|
- Gold
|
||||||
|
- Silver
|
||||||
|
- Crystal
|
||||||
- N/A
|
- N/A
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
+9
-2
@@ -1,5 +1,9 @@
|
|||||||
# Generated from a user-provided Pokemon Red ROM.
|
# Generated from a user-provided Pokemon ROM (Red/Blue/Yellow/Gold/Silver/Crystal).
|
||||||
# Regenerate with: python3 tools/build_data.py --rom /path/to/pokemon-red.gb --clean
|
# 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/
|
data/generated/
|
||||||
assets/generated/
|
assets/generated/
|
||||||
|
|
||||||
@@ -62,6 +66,9 @@ mobile/dist/
|
|||||||
# ROM cache exactly like data/generated/, so it is never committable.
|
# ROM cache exactly like data/generated/, so it is never committable.
|
||||||
/build/tiled/
|
/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)
|
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
|
||||||
mobile/ios/bundle_id.local
|
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
|
### 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,
|
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||||
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold,
|
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold,
|
||||||
@@ -82,7 +82,8 @@ game data:
|
|||||||
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
||||||
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
|
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
|
||||||
- Silver: `49b163f7e57702bc939d642a18f591de55d92dae`
|
- Silver: `49b163f7e57702bc939d642a18f591de55d92dae`
|
||||||
- Crystal: `f4cd194bdee0d04ca4eac29e09b8e4e9d818c133`
|
- Crystal (1.0): `f4cd194bdee0d04ca4eac29e09b8e4e9d818c133`
|
||||||
|
- Crystal (1.1): `f2f52230b536214ef7c9924f483392993e226cfb`
|
||||||
|
|
||||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||||
sound effects, and cries are synthesized while the game runs from compact
|
sound effects, and cries are synthesized while the game runs from compact
|
||||||
|
|||||||
@@ -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 function linkCableHelp(game)
|
||||||
local text = game.data.text or {}
|
local text = game.data.text or {}
|
||||||
local items, showMenu, askHeading
|
local items, menu, openMenu
|
||||||
function showMenu()
|
local function closeAll()
|
||||||
game.stack:push(Menu.new(game, items,
|
game.stack:pop()
|
||||||
{ 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))
|
|
||||||
end
|
end
|
||||||
items = {}
|
items = {}
|
||||||
for i, label in ipairs(LINK_HEADINGS) do
|
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,
|
game.stack:push(TextBox.new(game,
|
||||||
text["_LinkCableInfoText" .. i] or label, askHeading))
|
text["_LinkCableInfoText" .. i] or label))
|
||||||
end }
|
end }
|
||||||
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,
|
game.stack:push(TextBox.new(game,
|
||||||
text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable",
|
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
|
end
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -84,21 +86,15 @@ return {
|
|||||||
{ "jump_if_false", 5 }, -- 2
|
{ "jump_if_false", 5 }, -- 2
|
||||||
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
||||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 3 (old saves)
|
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 3 (old saves)
|
||||||
{ "jump", 13 }, -- 4
|
{ "jump", 11 }, -- 4
|
||||||
{ "give_pokemon", "EEVEE", 25 }, -- 5
|
{ "give_pokemon", "EEVEE", 25, false, true }, -- 5
|
||||||
{ "jump_if_false", 12 }, -- 6 (party+box full)
|
{ "jump_if_false", 10 }, -- 6 (party+box full)
|
||||||
-- flag + HideObject before the jingle and GotMonText: the nickname
|
-- scripts/CeladonMansionRoofHouse.asm:17-20 (#426)
|
||||||
-- 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.
|
|
||||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
|
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
|
||||||
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
||||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
|
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
|
||||||
{ "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
|
{ "jump", 11 }, -- 9
|
||||||
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
|
{ "show_text", "_BoxIsFullText" }, -- 10
|
||||||
{ "jump", 13 }, -- 11
|
|
||||||
{ "show_text", "_BoxIsFullText" }, -- 12
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
return {
|
return {
|
||||||
CELADON_CITY = {
|
CELADON_CITY = {
|
||||||
talk = {
|
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 = {
|
TEXT_CELADONCITY_POLIWRATH = {
|
||||||
{"face_player"},
|
{"face_player"},
|
||||||
|
-- pokered/scripts/CeladonCity.asm:90
|
||||||
|
{"play_cry", "POLIWRATH", true},
|
||||||
{"show_text", "_CeladonCityPoliwrathText"},
|
{"show_text", "_CeladonCityPoliwrathText"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,30 +2,24 @@
|
|||||||
return {
|
return {
|
||||||
CELADON_MANSION_1F = {
|
CELADON_MANSION_1F = {
|
||||||
talk = {
|
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 = {
|
TEXT_CELADONMANSION1F_CLEFAIRY = {
|
||||||
{"face_player"},
|
{"face_player"},
|
||||||
|
-- pokered/scripts/CeladonMansion1F.asm:27
|
||||||
|
{"play_cry", "CLEFAIRY", true},
|
||||||
{"show_text", "_CeladonMansion1FClefairyText"},
|
{"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 = {
|
TEXT_CELADONMANSION1F_MEOWTH = {
|
||||||
{"face_player"},
|
{"face_player"},
|
||||||
|
-- pokered/scripts/CeladonMansion1F.asm:18
|
||||||
|
{"play_cry", "MEOWTH", true},
|
||||||
{"show_text", "_CeladonMansion1FMeowthText"},
|
{"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 = {
|
TEXT_CELADONMANSION1F_NIDORANF = {
|
||||||
{"face_player"},
|
{"face_player"},
|
||||||
|
-- pokered/scripts/CeladonMansion1F.asm:33
|
||||||
|
{"play_cry", "NIDORAN_F", true},
|
||||||
{"show_text", "_CeladonMansion1FNidoranFText"},
|
{"show_text", "_CeladonMansion1FNidoranFText"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,11 +4,10 @@
|
|||||||
return {
|
return {
|
||||||
LAVENDER_CUBONE_HOUSE = {
|
LAVENDER_CUBONE_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
-- LavenderCuboneHouseCuboneText: text_far _LavenderCuboneHouseCuboneText,
|
-- LavenderCuboneHouse.asm:10
|
||||||
-- then text_asm plays the CUBONE cry. Cry playback has no Commands
|
|
||||||
-- equivalent in this port, so just show the line.
|
|
||||||
TEXT_LAVENDERCUBONEHOUSE_CUBONE = {
|
TEXT_LAVENDERCUBONEHOUSE_CUBONE = {
|
||||||
{ "face_player" },
|
{ "face_player" },
|
||||||
|
{ "play_cry", "CUBONE", true },
|
||||||
{ "show_text", "_LavenderCuboneHouseCuboneText" },
|
{ "show_text", "_LavenderCuboneHouseCuboneText" },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -31,16 +31,15 @@ M.MR_FUJIS_HOUSE = {
|
|||||||
{ "show_text", "_MrFujisHouseLittleGirlPokemonAreNiceToHugText" }, -- 6
|
{ "show_text", "_MrFujisHouseLittleGirlPokemonAreNiceToHugText" }, -- 6
|
||||||
},
|
},
|
||||||
|
|
||||||
-- scripts/MrFujisHouse.asm MrFujisHousePsyduckText: text_far then
|
-- scripts/MrFujisHouse.asm:56
|
||||||
-- PlayCry(PSYDUCK). Cry playback isn't modeled by Commands, so just
|
|
||||||
-- show the flavor text.
|
|
||||||
TEXT_MRFUJISHOUSE_PSYDUCK = {
|
TEXT_MRFUJISHOUSE_PSYDUCK = {
|
||||||
|
{ "play_cry", "PSYDUCK", true },
|
||||||
{ "show_text", "_MrFujisHousePsyduckText" },
|
{ "show_text", "_MrFujisHousePsyduckText" },
|
||||||
},
|
},
|
||||||
|
|
||||||
-- scripts/MrFujisHouse.asm MrFujisHouseNidorinoText: text_far then
|
-- scripts/MrFujisHouse.asm:63
|
||||||
-- PlayCry(NIDORINO).
|
|
||||||
TEXT_MRFUJISHOUSE_NIDORINO = {
|
TEXT_MRFUJISHOUSE_NIDORINO = {
|
||||||
|
{ "play_cry", "NIDORINO", true },
|
||||||
{ "show_text", "_MrFujisHouseNidorinoText" },
|
{ "show_text", "_MrFujisHouseNidorinoText" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
return {
|
return {
|
||||||
ROUTE_16_FLY_HOUSE = {
|
ROUTE_16_FLY_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
-- Route16FlyHouseFearowText: text_asm just prints the one line and
|
-- scripts/Route16FlyHouse.asm:45-51
|
||||||
-- plays the FEAROW cry (no cry-playback command exists in this
|
|
||||||
-- port's Commands vocabulary, so only the text is ported).
|
|
||||||
TEXT_ROUTE16FLYHOUSE_FEAROW = {
|
TEXT_ROUTE16FLYHOUSE_FEAROW = {
|
||||||
|
{ "play_cry", "FEAROW", true },
|
||||||
{ "show_text", "_Route16FlyHouseFearowText" },
|
{ "show_text", "_Route16FlyHouseFearowText" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1047,6 +1047,7 @@ local championsRoomRivalScript = {
|
|||||||
-- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past
|
-- 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.
|
-- it, so a returning champion warped straight into the induction.
|
||||||
{ "jump_if_true", "end" }, -- 3
|
{ "jump_if_true", "end" }, -- 3
|
||||||
|
{ "set_option", "animations", true }, -- scripts/ChampionsRoom.asm:57
|
||||||
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
|
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
|
||||||
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
|
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
|
||||||
-- the intro text, before the battle itself (#706); pushBattle's wipe-time
|
-- 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)
|
TEXT_ROUTE16FLYHOUSE_BRUNETTE_GIRL = function(game, ow, npc, done)
|
||||||
local t = text(game)
|
local t = text(game)
|
||||||
if game.save.flags.EVENT_GOT_HM02 then
|
if game.save.flags.EVENT_GOT_HM02 then
|
||||||
push(game, t._Route16FlyHouseBrunetteGirlHm02ExplanationText
|
push(game, t._Route16FlyHouseBrunetteGirlHM02ExplanationText
|
||||||
or "HM02 is FLY!\fIt will whisk you\nback to any town!", done)
|
or "HM02 is FLY.\nIt will take you\vback to any town.\fPut it to good\nuse!", done)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
push(game, t._Route16FlyHouseBrunetteGirlText
|
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
|
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
|
return
|
||||||
end
|
end
|
||||||
game.save.flags.EVENT_GOT_HM02 = true
|
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)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
|||||||
+113
@@ -200,6 +200,60 @@ the adapter's own coverage table:
|
|||||||
python3 tools/modkit.py gen2check mods/my_mod
|
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
|
## Editing maps in Tiled
|
||||||
|
|
||||||
Maps are data, not assets, so they can be authored in a real map editor and
|
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
|
their mutable logic. Tutorial, link, forced, stale, and covered battle states
|
||||||
refuse core intents. Use `mod.input` for ordinary text advance.
|
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
|
## Rendering pipelines
|
||||||
|
|
||||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ accepted and merged as-is.
|
|||||||
| `source` | string |
|
| `source` | string |
|
||||||
| `swarmGrass` | map of string -> {map?, rates, slots} |
|
| `swarmGrass` | map of string -> {map?, rates, slots} |
|
||||||
| `swarmWater` | map of string -> {map?, rate, slots} |
|
| `swarmWater` | map of string -> {map?, rate, slots} |
|
||||||
|
| `timeFishGroups` | map of string | integer 0..255 -> {day, nite} |
|
||||||
| `treeSets` | map of string -> {common, rare} |
|
| `treeSets` | map of string -> {common, rare} |
|
||||||
| `trees` | map of string -> string |
|
| `trees` | map of string -> string |
|
||||||
| `water` | map of string -> {map?, rate, slots} |
|
| `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 })
|
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
|
## landmarks
|
||||||
|
|
||||||
- semantics: `record`
|
- semantics: `record`
|
||||||
@@ -598,7 +619,7 @@ mod.content.map_songs:override("PALLET_TOWN", "Music_Routes1")
|
|||||||
| `id` | string | yes |
|
| `id` | string | yes |
|
||||||
| `index` | integer >= 0 | no |
|
| `index` | integer >= 0 | no |
|
||||||
| `label` | string | no |
|
| `label` | string | no |
|
||||||
| `objects` | list of any value | no |
|
| `objects` | list of {pokemon?, ...} | no |
|
||||||
| `palette` | string | no |
|
| `palette` | string | no |
|
||||||
| `signs` | list of any value | no |
|
| `signs` | list of any value | no |
|
||||||
| `tileset` | tilesets id | yes |
|
| `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 })
|
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
|
## music
|
||||||
|
|
||||||
- semantics: `record`
|
- 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
|
and `ShaderFX.bridgeError()` says why not. Activating an already-converted
|
||||||
preset never needs any of this.
|
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
|
### Fixup
|
||||||
|
|
||||||
`ShaderFixup.lua` mechanically rewrites the emitted GLSL into something LOVE
|
`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
|
`bundle_shader_bridge`, building it with cargo when a prebuilt one is not
|
||||||
supplied through `SHADERFX_BRIDGE`. A build host without cargo produces a
|
supplied through `SHADERFX_BRIDGE`. A build host without cargo produces a
|
||||||
package that can run converted presets but cannot CONVERT new ones, and says
|
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
|
- **The buildbot shortlist is a temporary trim.** `KEPT_PRESETS` reflects one
|
||||||
manual pass over `handheld/` and is expected to change, most likely to shrink.
|
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
|
- **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 = require("src.core.Game2").new()
|
||||||
Game:load()
|
Game:load()
|
||||||
else
|
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()
|
Game:load()
|
||||||
if os.getenv("POKEPORT_AUTOPILOT") then
|
if os.getenv("POKEPORT_AUTOPILOT") then
|
||||||
autopilot = require("tests.autopilot")
|
autopilot = require("tests.autopilot")
|
||||||
@@ -1118,15 +1127,23 @@ function love.quit()
|
|||||||
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
|
-- 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
|
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
|
||||||
-- claims the hook) is exactly the condition below.
|
-- 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()
|
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
||||||
return Game and not Importer and not quitToLauncher and not scripted
|
return Game and not Importer and not quitToLauncher and not scripted
|
||||||
and (isAndroid or not launchedIntoGame)
|
and (inProcessReturn or not launchedIntoGame)
|
||||||
end)
|
end)
|
||||||
if wouldReturnToLauncher then
|
if wouldReturnToLauncher then
|
||||||
if isAndroid then
|
if inProcessReturn then
|
||||||
returnToLauncher()
|
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
|
end
|
||||||
quitToLauncher = true
|
quitToLauncher = true
|
||||||
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
-- 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.
|
-- per-frame sleep-granularity jitter.
|
||||||
local nextFrame = love.timer and love.timer.getTime() or 0
|
local nextFrame = love.timer and love.timer.getTime() or 0
|
||||||
local dt = 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()
|
return function()
|
||||||
-- process events
|
-- process events
|
||||||
@@ -1188,17 +1216,34 @@ function love.run()
|
|||||||
return a or 0
|
return a or 0
|
||||||
end
|
end
|
||||||
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)
|
love.handlers[name](a, b, c, d, e, f)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- update dt
|
-- update dt
|
||||||
if love.timer then dt = love.timer.step() end
|
if love.timer then dt = love.timer.step() end
|
||||||
|
idleFor = idleFor + dt
|
||||||
|
|
||||||
-- call update and draw
|
-- call update and draw
|
||||||
if love.update then love.update(dt) end
|
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.origin()
|
||||||
love.graphics.clear(love.graphics.getBackgroundColor())
|
love.graphics.clear(love.graphics.getBackgroundColor())
|
||||||
if love.draw then love.draw() end
|
if love.draw then love.draw() end
|
||||||
@@ -1209,9 +1254,9 @@ function love.run()
|
|||||||
if paced then
|
if paced then
|
||||||
-- Sleep out the remainder of the frame budget, measured from the
|
-- Sleep out the remainder of the frame budget, measured from the
|
||||||
-- carried deadline, in small chunks so the OS timer stays
|
-- carried deadline, in small chunks so the OS timer stays
|
||||||
-- responsive. vsync is untouched: when it already paces slower
|
-- responsive. The pacer yields to vsync inside a 1ms dead band, so
|
||||||
-- than the cap the remainder is <= 0 and this rounds to a no-op.
|
-- when the panel already paces at or below the cap it is a no-op.
|
||||||
local budget = 1 / FrameCap.current
|
local budget = 1 / cap
|
||||||
nextFrame = nextFrame + budget
|
nextFrame = nextFrame + budget
|
||||||
local now = love.timer.getTime()
|
local now = love.timer.getTime()
|
||||||
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
|
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
|
||||||
@@ -1222,8 +1267,8 @@ function love.run()
|
|||||||
end
|
end
|
||||||
while true do
|
while true do
|
||||||
local remaining = nextFrame - love.timer.getTime()
|
local remaining = nextFrame - love.timer.getTime()
|
||||||
if remaining <= 0 then break end
|
if remaining <= SLEEP_FLOOR then break end
|
||||||
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
|
love.timer.sleep(0.001)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
love.timer.sleep(0.001)
|
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
|
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
|
||||||
`local.properties` when it finds `~/Library/Android/sdk`.
|
`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).
|
Gradle flavor used: **`embedNoRecord`** (game fused into the APK, no microphone).
|
||||||
Build task: `assembleEmbedNoRecordDebug`.
|
Build task: `assembleEmbedNoRecordDebug`.
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ void System::vibrate(double seconds) const
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
bool System::pickFile(const char *kind) const
|
bool System::pickFile(const char *kind, const char *destination) const
|
||||||
{
|
{
|
||||||
#ifdef LOVE_ANDROID
|
#ifdef LOVE_ANDROID
|
||||||
const char *dest = "picked_rom.gb";
|
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)
|
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
|
||||||
dest = "picked_save.sav";
|
dest = "picked_save.sav";
|
||||||
else if (strcmp(kind, "required_import") == 0)
|
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)
|
else if (strcmp(kind, "rom") == 0)
|
||||||
dest = "picked_rom.gb";
|
dest = "picked_rom.gb";
|
||||||
// Unknown kinds used to fall through to the ROM destination. Refuse them
|
// Unknown kinds used to fall through to the ROM destination. Refuse them
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ public:
|
|||||||
* "required_import" -> picked_required_import.bin.
|
* "required_import" -> picked_required_import.bin.
|
||||||
* @return Whether the picker was shown.
|
* @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;
|
virtual const char *pickFileKinds() const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ int w_vibrate(lua_State *L)
|
|||||||
int w_pickFile(lua_State *L)
|
int w_pickFile(lua_State *L)
|
||||||
{
|
{
|
||||||
const char *kind = luaL_optstring(L, 1, nullptr);
|
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;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import java.util.HashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
|
||||||
import android.Manifest;
|
import android.Manifest;
|
||||||
import android.app.AlarmManager;
|
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
|
// basename as its body, so RomImporter:focus can say so in the launcher
|
||||||
// instead of leaving the player on "No ROM imported" (issue #442).
|
// instead of leaving the player on "No ROM imported" (issue #442).
|
||||||
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
|
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
|
// Step bridge (love.system.syncHealthSteps): pending-steps delivery
|
||||||
// consumed by the Pokéwalker mod, same contract as the iOS
|
// consumed by the Pokéwalker mod, same contract as the iOS
|
||||||
// GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
|
// 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_rom.gb, picked_mod.zip, picked_save.sav, or
|
||||||
* picked_required_import.bin)
|
* 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. */
|
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||||
@Keep
|
@Keep
|
||||||
public static boolean showFilePicker(String destFilename) {
|
public static boolean showFilePicker(String destFilename) {
|
||||||
@@ -593,14 +611,30 @@ public class GameActivity extends SDLActivity {
|
|||||||
// onActivityResult copies the pick there, not into a recomputed
|
// onActivityResult copies the pick there, not into a recomputed
|
||||||
// (possibly different-volume) root (#604, #839).
|
// (possibly different-volume) root (#604, #839).
|
||||||
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
|
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
|
||||||
// Reject path separators so a hostile JNI caller cannot escape the
|
// Basename destinations keep the historical ROM/mod/save staging path.
|
||||||
// save identity directory.
|
// A nested destination is accepted only for an engine-generated mod
|
||||||
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
|
// baseroms path, then canonicalized beneath LOVE's mounted save root.
|
||||||
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.pendingPickFilename = destFilename;
|
self.pendingPickFilename = normalizedDest;
|
||||||
if (android.os.Build.VERSION.SDK_INT >= 21) {
|
if (android.os.Build.VERSION.SDK_INT >= 21) {
|
||||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
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. */
|
/** Drops a small flag file in the save identity for Lua to consume on focus. */
|
||||||
private void writeSaveDirFlag(String name, String body) {
|
private void writeSaveDirFlag(String name, String body) {
|
||||||
try {
|
writeFlagFile(saveIdentityDir(), name, body);
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1506,9 +1534,26 @@ public class GameActivity extends SDLActivity {
|
|||||||
Log.d("GameActivity", "could not create " + destDir);
|
Log.d("GameActivity", "could not create " + destDir);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String destName = pendingPickFilename != null
|
final String destName = pendingPickFilename != null
|
||||||
? pendingPickFilename : PICKED_ROM_FILENAME;
|
? 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
|
// ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but
|
||||||
// some OEM shells (ColorOS) offer third-party file managers in a
|
// 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);
|
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||||
return;
|
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);
|
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();
|
destFile.delete();
|
||||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
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.
|
* 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);
|
assert (source != null && destination != null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
byte[] buf = new byte[1024];
|
byte[] buf = new byte[1024 * 1024];
|
||||||
chunk_read = source.read(buf);
|
while ((chunk_read = source.read(buf)) != -1) {
|
||||||
do {
|
if (chunk_read == 0) continue;
|
||||||
destination.write(buf, 0, chunk_read);
|
destination.write(buf, 0, chunk_read);
|
||||||
bytes_written += chunk_read;
|
bytes_written += chunk_read;
|
||||||
chunk_read = source.read(buf);
|
}
|
||||||
} while (chunk_read != -1);
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.d("GameActivity", "Copying failed:" + e.getMessage());
|
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 public Documents plist settings are present
|
||||||
- the native picker bridge is 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
|
- `game.love` exists and is non-empty
|
||||||
|
|
||||||
If the payload is missing, the build fails instead of producing a blank app.
|
If the payload is missing, the build fails instead of producing a blank app.
|
||||||
|
|||||||
@@ -12,6 +12,48 @@
|
|||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"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",
|
||||||
|
"size": 14060365,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.22/gen1recomp++-0.2.22-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @ShaneMcGovernIE\n- @thibautbus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.21",
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"size": 14058979,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.21/gen1recomp++-0.2.21-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1483 (Pokémon Gold) Missing HM/TM fanfare when receiving them\n- #1610 Wrong text when choosing Pokemon to trade\n- #1615 Fishing Guru dialogue issue\n- #1640 Sync doesnt work on XBOX build\n- #1646 Cashier dialogue + sound effects\n- #1649 Pokemon cries missing in Vermilion City\n- #1651 Ticket taker asks for ticket too much\n- #1653 Wrap animation is too fast and has no screen shake\n- #1656 Map visible in between end of fight & evolution\n- #1683 Music not changing when talking to trainer\n- #1685 Cancel option missing in item menu\n- #1686 Issues with teaching TMs & HMs\n- #1687 Pokemon cries missing on S.S. Anne\n- #1688 S.S. Anne rival dialogue\n- #1689 S.S. Anne departing cutscene broken\n- #1690 Wrong dialogue when taking back entrance to museum\n- #1693 (Gold) Issues not properly fixed\n- #1694 (Gold) Arrow when seeing items in the bag should be red, not black\n- #1695 (Gold) Wrong layout in TMs/HMs pocket\n- #1696 (Gold) Caught pokémon don't get healed when automatically sent to the PC\n- #1697 Standardize identification of mod-created options/settings screens\n- #1702 Lt. Surge's door opening isn't synced with the sound effect\n- #1704 Ledge jumping on the bike doesn't slow you down\n- #1705 The bike shouldn't have Use/Toss options\n- #1706 (Gold) Cursor wraps around the edge in the battle interface\n- #1707 (Gold) Pokémon sometimes wake up in the same turn they were put to sleep\n- #1708 (Gold) Missing rod when fishing\n- #1710 (Gold) You shouldn't be able to change boxes by pressing LEFT or RIGHT\n- #1711 (Gold) Missing animation for FLY in the overworld\n- #1712 (Gold) Wrong cursor in the map when using FLY\n- #1713 (Gold) Low framerate when jumping off ledges\n- #1716 (Gold) Player can go through a whirlpool even without having the HM\n- #1717 (Gold) Missing animation and SFX when using whirlpool in the overworld\n- #1718 (Gold) Can't pause in the Cycling Road while moving down\n- #1719 (Gold) Pokémon given by NPCs not being registered in the pokédex\n- #1720 Critical hit on multi-hit moves repeats the message\n- #1721 Rival not visible while talking if you lose first fight\n- #1724 Centralize ROM cache readiness and publication in an engine-owned contract\n- #1725 (Gold) Text not spaced nor centered correctly for secondary actions in the bag\n- #1732 Cursor doesn't stay on last used item\n- #1745 Viewing items makes ingame menu disappear\n- #1748 (Gold) Overworld pokémon sprite are stactic\n- #1749 (Gold) You shouldn't be able to surf on Cycling Road\n- #1751 (Gold) Synthesis and Morning Sun always recover 50% of max HP\n- #1754 Leaving building with bike speed\n- #1756 No cursor or gamepad controls in skin menu, locked out on Xbox\n\n## Contributors\n\n- @1Jamie\n- @BoLayerDev\n- @bryanthaboi\n- @campavao\n- @caorthann-celt\n- @dlloa\n- @syybott\n- @thibautbus\n- MaxTomahawk"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.2.20",
|
"version": "0.2.20",
|
||||||
"date": "2026-08-22",
|
"date": "2026-08-22",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
@objc(GRPickerBridge)
|
@objc(GRPickerBridge)
|
||||||
public final class GRPickerBridge: NSObject {
|
public final class GRPickerBridge: NSObject {
|
||||||
@@ -190,9 +191,17 @@ public final class GRPickerBridge: NSObject {
|
|||||||
@objc(presentPickerWithKind:saveDir:)
|
@objc(presentPickerWithKind:saveDir:)
|
||||||
public static func presentPicker(kind: UnsafePointer<CChar>?,
|
public static func presentPicker(kind: UnsafePointer<CChar>?,
|
||||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
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"
|
let kindStr = kind.map { String(cString: $0) } ?? "rom"
|
||||||
guard let dir = resolvedSaveDir(saveDir) else { return false }
|
guard let dir = resolvedSaveDir(saveDir) else { return false }
|
||||||
|
|
||||||
|
let requestedDestination = destination.map { String(cString: $0) }
|
||||||
let destName: String
|
let destName: String
|
||||||
var types: [UTType] = []
|
var types: [UTType] = []
|
||||||
switch kindStr {
|
switch kindStr {
|
||||||
@@ -202,7 +211,13 @@ public final class GRPickerBridge: NSObject {
|
|||||||
case "sav":
|
case "sav":
|
||||||
destName = "picked_save.sav"
|
destName = "picked_save.sav"
|
||||||
case "required_import":
|
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 --
|
// A Nintendo 64 cartridge, for mods that build assets out of one --
|
||||||
// the voxel mod's Pokemon Stadium battle models are the caller this
|
// 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
|
// 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)
|
types.append(.data)
|
||||||
if !types.contains(.item) { types.append(.item) }
|
if !types.contains(.item) { types.append(.item) }
|
||||||
|
|
||||||
|
let directRequired = kindStr == "required_import"
|
||||||
|
&& destName != "picked_required_import.bin"
|
||||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types,
|
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types,
|
||||||
asCopy: true)
|
asCopy: !directRequired)
|
||||||
picker.allowsMultipleSelection = false
|
picker.allowsMultipleSelection = false
|
||||||
let delegate = PickerDelegate { urls in
|
let delegate = PickerDelegate { urls in
|
||||||
guard let src = urls.first else { return }
|
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)
|
return present(picker, with: delegate)
|
||||||
}
|
}
|
||||||
@@ -410,6 +431,82 @@ public final class GRPickerBridge: NSObject {
|
|||||||
try? fm.moveItem(at: item, to: target)
|
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) {
|
private static func copyItem(at src: URL, into dir: URL, named name: String) {
|
||||||
let scoped = src.startAccessingSecurityScopedResource()
|
let scoped = src.startAccessingSecurityScopedResource()
|
||||||
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
|
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
|
||||||
@@ -426,7 +523,7 @@ public final class GRPickerBridge: NSObject {
|
|||||||
let report = "Could not copy \(src.lastPathComponent): " +
|
let report = "Could not copy \(src.lastPathComponent): " +
|
||||||
error.localizedDescription
|
error.localizedDescription
|
||||||
try? report.data(using: .utf8)?
|
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)
|
int w_pickFile(lua_State *L)
|
||||||
{
|
{
|
||||||
const char *kind = luaL_optstring(L, 1, "rom");
|
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
|
// 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"
|
APPLICATION_ID="$TEST_APPLICATION_ID"
|
||||||
APP_NAME="$APP_NAME (test)"
|
APP_NAME="$APP_NAME (test)"
|
||||||
fi
|
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
|
# --------------------------------------------------------------- preconditions
|
||||||
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
|
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
|
||||||
@@ -461,6 +468,115 @@ pack_game_love() {
|
|||||||
fi
|
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
|
# --------------------------------------------------------------- SDK check
|
||||||
require_android_sdk() {
|
require_android_sdk() {
|
||||||
local sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
|
local sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
|
||||||
@@ -575,5 +691,6 @@ if $PACKAGE_ONLY; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
require_android_sdk
|
require_android_sdk
|
||||||
|
bundle_shader_bridge_android
|
||||||
run_gradle
|
run_gradle
|
||||||
say "done"
|
say "done"
|
||||||
|
|||||||
+149
-3
@@ -615,6 +615,138 @@ verify_game_payload() {
|
|||||||
say "game.love present ($(du -h "$app/game.love" | cut -f1))"
|
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() {
|
run_xcodebuild() {
|
||||||
local config sdk destination
|
local config sdk destination
|
||||||
if $RELEASE; then
|
if $RELEASE; then
|
||||||
@@ -633,6 +765,8 @@ run_xcodebuild() {
|
|||||||
|
|
||||||
mkdir -p "$BUILD_DIR"
|
mkdir -p "$BUILD_DIR"
|
||||||
|
|
||||||
|
bundle_shader_bridge_ios "$(rust_targets_for_sdk "$sdk")" "$sdk"
|
||||||
|
|
||||||
# Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires
|
# Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires
|
||||||
# -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes.
|
# -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes.
|
||||||
# Always stamp both: the overlay plist expands $(MARKETING_VERSION) /
|
# Always stamp both: the overlay plist expands $(MARKETING_VERSION) /
|
||||||
@@ -661,6 +795,13 @@ run_xcodebuild() {
|
|||||||
ONLY_ACTIVE_ARCH=NO
|
ONLY_ACTIVE_ARCH=NO
|
||||||
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
|
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
|
if ! $DEVICE; then
|
||||||
# Simulator: ad-hoc signing (no certificate needed). A plain unsigned
|
# Simulator: ad-hoc signing (no certificate needed). A plain unsigned
|
||||||
# build would drop the entitlements file, and HealthKit refuses to run
|
# 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 products="$BUILD_DIR/Build/Products/${config}-${sdk}"
|
||||||
local app=""
|
local app=""
|
||||||
local candidate
|
local candidate newest=0 mtime
|
||||||
for candidate in "$products/$PRODUCT_NAME.app" "$products/$APP_NAME.app" "$products/love.app"; do
|
for candidate in "$products/$PRODUCT_NAME.app" "$products/$APP_NAME.app" "$products/love.app"; do
|
||||||
if [ -d "$candidate" ]; then
|
if [ -d "$candidate" ]; then
|
||||||
app="$candidate"
|
mtime="$(stat -f %m "$candidate" 2>/dev/null || echo 0)"
|
||||||
break
|
if [ "$mtime" -gt "$newest" ]; then
|
||||||
|
newest="$mtime"
|
||||||
|
app="$candidate"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ -z "$app" ]; then
|
if [ -z "$app" ]; then
|
||||||
@@ -736,6 +880,7 @@ run_xcodebuild() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if [ "$app" != "$products/$APP_NAME.app" ]; then
|
if [ "$app" != "$products/$APP_NAME.app" ]; then
|
||||||
|
rm -rf "$products/$APP_NAME.app"
|
||||||
mv "$app" "$products/$APP_NAME.app"
|
mv "$app" "$products/$APP_NAME.app"
|
||||||
app="$products/$APP_NAME.app"
|
app="$products/$APP_NAME.app"
|
||||||
fi
|
fi
|
||||||
@@ -754,6 +899,7 @@ run_xcodebuild() {
|
|||||||
|
|
||||||
verify_game_payload "$app"
|
verify_game_payload "$app"
|
||||||
verify_native_bridge "$app"
|
verify_native_bridge "$app"
|
||||||
|
verify_shader_bridge "$app"
|
||||||
|
|
||||||
local dist_dir="$DIST/${config}-${sdk}"
|
local dist_dir="$DIST/${config}-${sdk}"
|
||||||
rm -rf "$dist_dir"
|
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 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 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 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 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
|
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
|
||||||
|
|||||||
+161
-48
@@ -97,7 +97,7 @@ end
|
|||||||
-- space.
|
-- space.
|
||||||
function BattleState:extendedHUD()
|
function BattleState:extendedHUD()
|
||||||
local options = self.game and self.game.save and self.game.save.options
|
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()
|
return self:wideLayout()
|
||||||
and options and options.battleHud == "extended"
|
and options and options.battleHud == "extended"
|
||||||
and ((options.battleFit == "fixed"
|
and ((options.battleFit == "fixed"
|
||||||
@@ -108,12 +108,12 @@ end
|
|||||||
function BattleState:extendedWorldHUD()
|
function BattleState:extendedWorldHUD()
|
||||||
local options = self.game and self.game.save and self.game.save.options
|
local options = self.game and self.game.save and self.game.save.options
|
||||||
return self:extendedHUD() and options
|
return self:extendedHUD() and options
|
||||||
and options.battleFit == "fixed" and options.battleBg == "world"
|
and options.battleFit == "fixed" and self:bgMode() == "world"
|
||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:extendedBlackHUD()
|
function BattleState:extendedBlackHUD()
|
||||||
local options = self.game and self.game.save and self.game.save.options
|
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
|
end
|
||||||
|
|
||||||
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
|
-- 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.
|
-- opaque 160x144 field over the top, so only the surround changes.
|
||||||
function BattleState:bgMode()
|
function BattleState:bgMode()
|
||||||
local options = self.game and self.game.save and self.game.save.options
|
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
|
local mode = options and options.battleBg
|
||||||
if mode == "black" or mode == "world" then return mode end
|
if mode == "black" or mode == "world" then return mode end
|
||||||
return "white"
|
return "white"
|
||||||
@@ -212,6 +216,13 @@ local BALL_ANIMS = {
|
|||||||
SHAKE_ANIM = true, SHOWPIC_ANIM = true,
|
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 = {}
|
local imageCache = {}
|
||||||
-- The three tables below are keyed by the Image OBJECT, not by a path, and a
|
-- 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,
|
-- 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
|
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
|
||||||
-- rebuilt for any battle pic, whatever code loaded it
|
-- rebuilt for any battle pic, whatever code loaded it
|
||||||
local imageMeta = setmetatable({}, WEAK_KEYS)
|
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.
|
-- 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
|
-- trueColor art (14 §the 4-shade contract) opts out of the quantize
|
||||||
-- entirely, so its palette variant collapses back onto the plain path.
|
-- entirely, so its palette variant collapses back onto the plain path.
|
||||||
@@ -254,29 +270,31 @@ local function getImage(path, pal, trueColor)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
local w, h = id:getDimensions()
|
local w, h = id:getDimensions()
|
||||||
local bottom = h - 1
|
if mattedPic(Assets.resolve(path)) then
|
||||||
while bottom >= 0 do
|
local bottom = h - 1
|
||||||
local opaque = false
|
while bottom >= 0 do
|
||||||
for x = 0, w - 1 do
|
local opaque = false
|
||||||
local _, _, _, a = id:getPixel(x, bottom)
|
for x = 0, w - 1 do
|
||||||
if a > 0 then opaque = true break end
|
local _, _, _, a = id:getPixel(x, bottom)
|
||||||
|
if a > 0 then opaque = true break end
|
||||||
|
end
|
||||||
|
if opaque then break end
|
||||||
|
bottom = bottom - 1
|
||||||
end
|
end
|
||||||
if opaque then break end
|
local left = 0
|
||||||
bottom = bottom - 1
|
while left < w do
|
||||||
end
|
local opaque = false
|
||||||
local left = 0
|
for y = 0, h - 1 do
|
||||||
while left < w do
|
local _, _, _, a = id:getPixel(left, y)
|
||||||
local opaque = false
|
if a > 0 then opaque = true break end
|
||||||
for y = 0, h - 1 do
|
end
|
||||||
local _, _, _, a = id:getPixel(left, y)
|
if opaque then break end
|
||||||
if a > 0 then opaque = true break end
|
left = left + 1
|
||||||
end
|
end
|
||||||
if opaque then break end
|
pad = h - 1 - bottom
|
||||||
left = left + 1
|
padL = left
|
||||||
end
|
end
|
||||||
img = love.graphics.newImage(id)
|
img = love.graphics.newImage(id)
|
||||||
pad = h - 1 - bottom
|
|
||||||
padL = left
|
|
||||||
else
|
else
|
||||||
img = Assets.image(path) -- headless stub: no pixel access
|
img = Assets.image(path) -- headless stub: no pixel access
|
||||||
end
|
end
|
||||||
@@ -2214,6 +2232,9 @@ function BattleState:update(dt)
|
|||||||
self.waitFrames = nil
|
self.waitFrames = nil
|
||||||
if destination == "menu" then
|
if destination == "menu" then
|
||||||
self.introSlide = nil
|
self.introSlide = nil
|
||||||
|
-- engine/battle/core.asm:2007
|
||||||
|
self.msgHold = nil
|
||||||
|
self.shown = nil
|
||||||
self.phase = "menu"
|
self.phase = "menu"
|
||||||
elseif destination == "finish" then
|
elseif destination == "finish" then
|
||||||
self:finish()
|
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_WHITE = { [0] = 0, 0, 0, 0 } -- $00 (flash phase 2)
|
||||||
local BGP_DARK = { [0] = 3, 3, 2, 1 } -- $6f DarkScreenPalette
|
local BGP_DARK = { [0] = 3, 3, 2, 1 } -- $6f DarkScreenPalette
|
||||||
local BGP_LIGHT = { [0] = 0, 0, 1, 2 } -- $90 LightScreenPalette
|
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,
|
-- FlashScreenLongSGB (animations.asm:1010): 12 BGP values per cycle,
|
||||||
-- 3 cycles; the first cycle holds each for 2 frames, the rest for 1
|
-- 3 cycles; the first cycle holds each for 2 frames, the rest for 1
|
||||||
-- (FlashScreenLongDelay)
|
-- (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, 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, 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, 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 },
|
{ [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
|
-- the shade map in force this frame (a running flash wins over the
|
||||||
-- persistent palette)
|
-- persistent palette)
|
||||||
function BattleState:activeBgp()
|
function BattleState:activeBgp()
|
||||||
@@ -3186,7 +3222,7 @@ function BattleState:applyAnimEffect(ev)
|
|||||||
elseif e == "SE_LIGHT_SCREEN_PALETTE" then
|
elseif e == "SE_LIGHT_SCREEN_PALETTE" then
|
||||||
fx.bgp = BGP_LIGHT
|
fx.bgp = BGP_LIGHT
|
||||||
elseif e == "SE_DARKEN_MON_PALETTE" then
|
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
|
elseif e == "SE_RESET_SCREEN_PALETTE" then
|
||||||
fx.bgp = nil
|
fx.bgp = nil
|
||||||
elseif e == "SE_DARK_SCREEN_FLASH" then
|
elseif e == "SE_DARK_SCREEN_FLASH" then
|
||||||
@@ -3196,8 +3232,9 @@ function BattleState:applyAnimEffect(ev)
|
|||||||
idx = 1, left = 2 }
|
idx = 1, left = 2 }
|
||||||
elseif e == "SE_FLASH_SCREEN_LONG" then
|
elseif e == "SE_FLASH_SCREEN_LONG" then
|
||||||
local steps = {}
|
local steps = {}
|
||||||
|
local maps = onSgb() and FLASH_LONG_SGB or FLASH_LONG_MONO
|
||||||
for cycle = 1, 3 do
|
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 }
|
steps[#steps + 1] = { map = m, frames = (cycle == 1) and 2 or 1 }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -4139,6 +4176,12 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
|
|
||||||
-- pure status moves
|
-- pure status moves
|
||||||
if move.power == 0 and record and record.kind == "primary" and record.run then
|
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
|
-- accuracy-checked status effects run MoveHitTest, which has no
|
||||||
-- 100%-accuracy early-out (even Thunder Wave misses on the 255
|
-- 100%-accuracy early-out (even Thunder Wave misses on the 255
|
||||||
-- roll) and misses outright against a mid-Fly/Dig target; the
|
-- 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
|
-- new current HP (house convention: potions drain the bar too, see
|
||||||
-- itemUsed) so the bar grows instead. Only the active player battler
|
-- 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.
|
-- 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(
|
for _, moveId in ipairs(Experience.movesLearnedAt(
|
||||||
self.data.pokemon[mon.species], lv)) do
|
self.data.pokemon[mon.species], lv)) do
|
||||||
self:learnMove(mon, moveId)
|
self:learnMove(mon, moveId)
|
||||||
@@ -4484,8 +4531,7 @@ function BattleState:enemyMonFainted()
|
|||||||
-- remaining HP); SET / single-mon / fainted active skip the prompt.
|
-- remaining HP); SET / single-mon / fainted active skip the prompt.
|
||||||
local nextMon = self.enemyParty[self.enemyIndex]
|
local nextMon = self.enemyParty[self.enemyIndex]
|
||||||
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
|
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
|
||||||
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
|
local style = self:battleStyle()
|
||||||
:lower()
|
|
||||||
local partyCount = #self:playerPartyView()
|
local partyCount = #self:playerPartyView()
|
||||||
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
|
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
|
||||||
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
|
-- 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!")
|
return t._ItemUseBallText04 or self:romText("_ItemUseBallText04", "Shoot! It was so\nclose too!")
|
||||||
end
|
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,
|
-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
|
||||||
-- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party
|
-- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party
|
||||||
-- AddPartyMon and SendNewMonToBox (#172).
|
-- AddPartyMon and SendNewMonToBox (#172).
|
||||||
@@ -5086,6 +5183,22 @@ end
|
|||||||
-- "New POKéDEX data will be added" + the dex entry page, then
|
-- "New POKéDEX data will be added" + the dex entry page, then
|
||||||
-- AddPartyMon or SendNewMonToBox (both call AskName), then the PC
|
-- AddPartyMon or SendNewMonToBox (both call AskName), then the PC
|
||||||
-- transfer text when the party was full.
|
-- 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()
|
function BattleState:storeCaughtMon()
|
||||||
-- ItemUseBall reloads the caught mon via LoadEnemyMonData
|
-- ItemUseBall reloads the caught mon via LoadEnemyMonData
|
||||||
-- (item_effects.asm:472-501), regenerating its move list from the
|
-- (item_effects.asm:472-501), regenerating its move list from the
|
||||||
@@ -5117,28 +5230,26 @@ function BattleState:storeCaughtMon()
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
local function askCaughtNickname()
|
local function askCaughtNickname()
|
||||||
local caught = self.enemy.mon
|
self:offerNickname(self.enemy.mon, self.enemy.name)
|
||||||
local enemyName = self.enemy.name
|
|
||||||
self:uiNext(function()
|
|
||||||
return self:askNicknameUI(caught, enemyName)
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
if Party.add(game.save.party, self.enemy.mon) then
|
if Party.add(game.save.party, self.enemy.mon) then
|
||||||
askCaughtNickname()
|
askCaughtNickname()
|
||||||
else
|
else
|
||||||
destination = "box"
|
destination = self:partyFullDestination(self.enemy.mon)
|
||||||
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
|
if destination == "box" then
|
||||||
if boxNum then
|
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
|
||||||
askCaughtNickname()
|
if boxNum then
|
||||||
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
askCaughtNickname()
|
||||||
local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
|
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
||||||
self:sayNext(self:romText(
|
local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
|
||||||
metBill and "_ItemUseBallText07" or "_ItemUseBallText08",
|
self:sayNext(self:romText(
|
||||||
metBill and "%s was\ntransferred to\nBILL's PC!"
|
metBill and "_ItemUseBallText07" or "_ItemUseBallText08",
|
||||||
or "%s was\ntransferred to\nsomeone's PC!",
|
metBill and "%s was\ntransferred to\nBILL's PC!"
|
||||||
self.enemy.name))
|
or "%s was\ntransferred to\nsomeone's PC!",
|
||||||
else
|
self.enemy.name))
|
||||||
self:sayNext(Strings("But every BOX\nis full!"))
|
else
|
||||||
|
self:sayNext(Strings("But every BOX\nis full!"))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
Runtime.emit("pokemon.caught", {
|
Runtime.emit("pokemon.caught", {
|
||||||
@@ -5920,7 +6031,9 @@ function BattleState:animSpriteColors(s, px, py)
|
|||||||
local P
|
local P
|
||||||
-- engine/battle/animations.asm:551 (.notSGB)
|
-- engine/battle/animations.asm:551 (.notSGB)
|
||||||
if PaletteFX.usesSpriteObp() then
|
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
|
if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end
|
||||||
else
|
else
|
||||||
P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4))
|
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
|
return nil
|
||||||
end
|
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
|
-- the merged status record for a battler's persistent condition, or nil
|
||||||
local function statusRecord(battler)
|
local function statusRecord(battler)
|
||||||
return Status.recordFor(battler.statuses, battler.mon.status)
|
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:
|
-- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts:
|
||||||
-- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder),
|
-- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder),
|
||||||
-- Volcano -> special
|
-- Volcano -> special
|
||||||
local atkBoost = badgeBoost(attacker, atkStat)
|
atk = Damage.applyBadgeBoost(attacker, atkStat, atk)
|
||||||
if atkBoost then
|
dfn = Damage.applyBadgeBoost(defender, defStat, dfn)
|
||||||
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
|
|
||||||
-- burn halves physical attack (applied as part of the stat in Gen 1;
|
-- burn halves physical attack (applied as part of the stat in Gen 1;
|
||||||
-- the status record's statPenalty names the stat it cuts).
|
-- the status record's statPenalty names the stat it cuts).
|
||||||
-- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the
|
-- 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
|
-- is the registry view of all three -- the merged Data.move_effects a
|
||||||
-- battle dispatches on serves these same objects.
|
-- battle dispatches on serves these same objects.
|
||||||
|
|
||||||
|
local Damage = require("src.battle.Damage")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local StatusRegistry = require("src.battle.StatusRegistry")
|
local StatusRegistry = require("src.battle.StatusRegistry")
|
||||||
local TurnOrder = require("src.battle.TurnOrder")
|
local TurnOrder = require("src.battle.TurnOrder")
|
||||||
@@ -57,6 +58,10 @@ local function changeStage(battle, who, stat, delta, fromEnemy)
|
|||||||
-- recomputed and QuarterSpeedDueToParalysis/HalveAttackDueToBurn re-run,
|
-- recomputed and QuarterSpeedDueToParalysis/HalveAttackDueToBurn re-run,
|
||||||
-- re-baking the burn/para penalty and ending Haze's temporary lift.
|
-- re-baking the burn/para penalty and ending Haze's temporary lift.
|
||||||
who.hazeStatReset = nil
|
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
|
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
|
||||||
-- two-stage variants scroll "greatly" onto a third line
|
-- two-stage variants scroll "greatly" onto a third line
|
||||||
local label = Strings(STAT_LABEL[stat]) -- looked up here, not at require (#811)
|
local label = Strings(STAT_LABEL[stat]) -- looked up here, not at require (#811)
|
||||||
@@ -132,6 +137,11 @@ end
|
|||||||
|
|
||||||
local function statDownSide(stat)
|
local function statDownSide(stat)
|
||||||
return function(battle, user, target)
|
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 target.substituteHP then return {} end
|
||||||
if battle.rng(0, 255) >= 85 then return {} end -- 33 percent + 1 (85/256)
|
if battle.rng(0, 255) >= 85 then return {} end -- 33 percent + 1 (85/256)
|
||||||
-- StatModifierDownEffect's side-effect branch never runs MoveHitTest,
|
-- StatModifierDownEffect's side-effect branch never runs MoveHitTest,
|
||||||
@@ -254,6 +264,7 @@ MoveEffects.primary = {
|
|||||||
-- Attack-halving and paralysis Speed-quartering on BOTH battlers
|
-- Attack-halving and paralysis Speed-quartering on BOTH battlers
|
||||||
-- until the next stat recompute (a stage change or switch-in).
|
-- until the next stat recompute (a stage change or switch-in).
|
||||||
b.hazeStatReset = true
|
b.hazeStatReset = true
|
||||||
|
b.badgeExtraBoosts = nil
|
||||||
end
|
end
|
||||||
-- Gen 1 also removes the enemy's major status; if that cured sleep
|
-- Gen 1 also removes the enemy's major status; if that cured sleep
|
||||||
-- or freeze, the target forfeits its move this turn (haze.asm
|
-- 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)
|
battler.stages and battler.stages.speed or 0)
|
||||||
-- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from
|
-- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from
|
||||||
-- the battler's merged badgeBoosts with the vanilla list as fallback
|
-- the battler's merged badgeBoosts with the vanilla list as fallback
|
||||||
local badges = battler.badges
|
spd = Damage.applyBadgeBoost(battler, "speed", spd)
|
||||||
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
|
|
||||||
-- paralysis quarters speed (the status record's statPenalty);
|
-- paralysis quarters speed (the status record's statPenalty);
|
||||||
-- hazeStatReset suppresses it because Haze (haze.asm ResetStats)
|
-- hazeStatReset suppresses it because Haze (haze.asm ResetStats)
|
||||||
-- copied the unmodified speed over the quartered battle stat, lifting
|
-- 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
|
return not (stack and stack.top) or stack:top() == battle
|
||||||
end
|
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)
|
local function anchorHUD(battle, x, y, w, h, anchor)
|
||||||
if not battle:extendedHUD() or not battleIsTopState(battle) then return end
|
if not battle:extendedHUD() or not battleIsTopState(battle) then return end
|
||||||
local renderer = battle.game and battle.game.renderer
|
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)
|
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
|
||||||
end
|
end
|
||||||
-- AskName clears the field the same way the classic layout does
|
-- 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 fx = battle.fx
|
||||||
local sx = (fx and fx.shakeX) or 0
|
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
|
-- wPlayerSubStatus5 & SUBSTATUS_LOCK_ON: the enemy's OWN Lock-On, since
|
||||||
-- BattleCommand_LockOn sets the bit on the target it was aimed at.
|
-- BattleCommand_LockOn sets the bit on the target it was aimed at.
|
||||||
playerLockOn = playerState.lockOn or nil,
|
playerLockOn = playerState.lockOn or nil,
|
||||||
|
playerIdentified = playerState.identified or nil,
|
||||||
playerPhysicalMoves = physical,
|
playerPhysicalMoves = physical,
|
||||||
enemyRage = enemyState.rage,
|
enemyRage = enemyState.rage,
|
||||||
enemyRageCount = enemyState.rageCount,
|
enemyRageCount = enemyState.rageCount,
|
||||||
@@ -1082,7 +1083,7 @@ function Battle:hitOnce(attacker, defender, def, opts)
|
|||||||
local attackerStages = self.stages[self:sideOf(attacker)]
|
local attackerStages = self.stages[self:sideOf(attacker)]
|
||||||
local defenderStages = self.stages[self:sideOf(defender)]
|
local defenderStages = self.stages[self:sideOf(defender)]
|
||||||
local types = self.data.type_chart and self.data.type_chart.types
|
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")
|
local heldEffect, heldParam = self:heldEffect(attacker, "damage")
|
||||||
-- BattleCommand_Critical: SUBSTATUS_FOCUS_ENERGY (Focus Energy or a
|
-- 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
|
if def.effect == "EFFECT_FALSE_SWIPE" and damage >= (defender.hp or 0) then
|
||||||
damage = math.max(0, (defender.hp or 0) - 1)
|
damage = math.max(0, (defender.hp or 0) - 1)
|
||||||
end
|
end
|
||||||
|
-- engine/battle_anims/anim_commands.asm:1200
|
||||||
|
if self.moveEvent then self.moveEvent.effectiveness = info.effectiveness end
|
||||||
return self:dealDamage(attacker, defender, damage, {
|
return self:dealDamage(attacker, defender, damage, {
|
||||||
critical = critical, effectiveness = info.effectiveness,
|
critical = critical, effectiveness = info.effectiveness,
|
||||||
-- Counter answers physical damage and Mirror Coat special, so what kind
|
-- 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) })
|
text = ("Magnitude %d!"):format(number) })
|
||||||
end
|
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
|
if not sureHit
|
||||||
and not self:accuracyRoll(def, attacker, defender) then
|
and not self:accuracyRoll(def, attacker, defender) then
|
||||||
-- data/moves/effects.asm:148-151: `selfdestruct` sits between checkhit and
|
-- 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.
|
-- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG.
|
||||||
local defenderTypes = (self:speciesDef(defender) or {}).types
|
local defenderTypes = (self:speciesDef(defender) or {}).types
|
||||||
or defender.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
|
if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then
|
||||||
self:markMissed()
|
self:markMissed()
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
@@ -2119,6 +2129,21 @@ Battle.MOVE_EFFECTS.EFFECT_LOCK_ON = function(self, attacker, defender)
|
|||||||
text = self:monName(attacker) .. " took aim!" })
|
text = self:monName(attacker) .. " took aim!" })
|
||||||
end
|
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
|
-- 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
|
-- 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
|
-- 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
|
end
|
||||||
|
|
||||||
function Battle:vanillaAccuracyRoll(accuracy, attacker, defender)
|
function Battle:vanillaAccuracyRoll(accuracy, attacker, defender)
|
||||||
return Damage.rollHit(self:moveAccuracy(accuracy, defender),
|
local acc = self.stages[self:sideOf(attacker)].accuracy
|
||||||
self.stages[self:sideOf(attacker)].accuracy,
|
local eva = self.stages[self:sideOf(defender)].evasion
|
||||||
self.stages[self:sideOf(defender)].evasion, self.random)
|
-- 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
|
end
|
||||||
|
|
||||||
-- BattleCommand_StatDown's SUBSTATUS_MIST arm (a GUARD SPEC): a drop the FOE
|
-- 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)],
|
stages = self.stages[self:sideOf(defender)],
|
||||||
},
|
},
|
||||||
types = self.data.type_chart and self.data.type_chart.types,
|
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,
|
random = self.random,
|
||||||
})
|
})
|
||||||
state.futureSight = Effects.FUTURE_SIGHT_TURNS
|
state.futureSight = Effects.FUTURE_SIGHT_TURNS
|
||||||
@@ -3072,6 +3103,30 @@ function Battle:safeguarded(mon)
|
|||||||
return (self.screens[self:sideOf(mon)].safeguard or 0) > 0
|
return (self.screens[self:sideOf(mon)].safeguard or 0) > 0
|
||||||
end
|
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
|
-- `source` is the battler that inflicted it, carried only so
|
||||||
-- battle.status_inflicted can name it the way Gen 1's does.
|
-- battle.status_inflicted can name it the way Gen 1's does.
|
||||||
-- BattleCommand_Paralyze and BattleCommand_Poison refuse on a zero matchup,
|
-- BattleCommand_Paralyze and BattleCommand_Poison refuse on a zero matchup,
|
||||||
@@ -3084,7 +3139,7 @@ function Battle:statusRefusedByType(defender, moveType, status)
|
|||||||
end
|
end
|
||||||
local types = (self:speciesDef(defender) or {}).types or defender.types or {}
|
local types = (self:speciesDef(defender) or {}).types or defender.types or {}
|
||||||
if moveType then
|
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
|
if Damage.typeMultiplier(moveType, types, matchups) == 0 then return true end
|
||||||
end
|
end
|
||||||
if status == "poison" or status == "toxic" then
|
if status == "poison" or status == "toxic" then
|
||||||
@@ -3917,6 +3972,8 @@ end
|
|||||||
-- check. Split out because playerAttack needs the same answer.
|
-- check. Split out because playerAttack needs the same answer.
|
||||||
function Battle:lockedInMove(mon)
|
function Battle:lockedInMove(mon)
|
||||||
local state = self:volatile(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.rolloutLock then return state.rolloutLock end
|
||||||
if state.rampageMove and (state.rampageTurns or 0) > 0 then
|
if state.rampageMove and (state.rampageTurns or 0) > 0 then
|
||||||
return state.rampageMove
|
return state.rampageMove
|
||||||
|
|||||||
@@ -284,6 +284,14 @@ function Effects.magnitudePower(random)
|
|||||||
return last[2], last[3]
|
return last[2], last[3]
|
||||||
end
|
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
|
-- ------------------------------------------------------------------- weather
|
||||||
--
|
--
|
||||||
-- BattleCommand_StartRain / StartSun / StartSandstorm all set wWeatherCount to
|
-- 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
|
-- data/text/battle.asm. Declared here and formatted at the call site so
|
||||||
-- Strings.source is what registers them, the same way Decorations declares
|
-- 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
|
-- 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 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
|
-- 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
|
-- 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.
|
-- 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
|
-- (core.asm:426-464): poison/burn/leech seed tick before the slower
|
||||||
-- mon acts, not in an end-of-round sweep like Gen 3+.
|
-- mon acts, not in an end-of-round sweep like Gen 3+.
|
||||||
residualAfterMove = true,
|
residualAfterMove = true,
|
||||||
|
-- engine/battle/effects.asm:498,689
|
||||||
|
badgeBoostReapplyBug = true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ local BATTLER_FIELDS = {
|
|||||||
"chargeReady", "invulnerable", "mustRecharge",
|
"chargeReady", "invulnerable", "mustRecharge",
|
||||||
"thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded",
|
"thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded",
|
||||||
"lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched",
|
"lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched",
|
||||||
"skipMove", "hazeStatReset", "drainFloor", "drainHold", "trappingTurns",
|
"skipMove", "hazeStatReset", "badgeExtraBoosts", "drainFloor", "drainHold", "trappingTurns",
|
||||||
"trapMove", "trapDamage", "fainted",
|
"trapMove", "trapDamage", "fainted",
|
||||||
"aiLayer2",
|
"aiLayer2",
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-7
@@ -82,8 +82,9 @@ local function copy(value)
|
|||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
function Data:applyVersionedFieldData()
|
function Data:applyVersionedFieldData(version)
|
||||||
if require("src.core.GameVersion").isYellow() then
|
version = version or require("src.core.GameVersion").get()
|
||||||
|
if version == "yellow" then
|
||||||
self.field.trades = copy(YELLOW_TRADES)
|
self.field.trades = copy(YELLOW_TRADES)
|
||||||
-- The old man's catch demo is a RATTATA in Yellow
|
-- The old man's catch demo is a RATTATA in Yellow
|
||||||
-- (scripts/ViridianCity.asm ViridianCityOldManStartCatchTrainingScript
|
-- (scripts/ViridianCity.asm ViridianCityOldManStartCatchTrainingScript
|
||||||
@@ -106,7 +107,8 @@ end
|
|||||||
|
|
||||||
-- Fills only what the cache is missing, so an importer that learns to
|
-- Fills only what the cache is missing, so an importer that learns to
|
||||||
-- stamp one of these keys silently takes over from the engine.
|
-- stamp one of these keys silently takes over from the engine.
|
||||||
function Data:seedDefaults()
|
function Data:seedDefaults(version)
|
||||||
|
version = version or require("src.core.GameVersion").get()
|
||||||
local constants = self.constants or {}
|
local constants = self.constants or {}
|
||||||
self.constants = constants
|
self.constants = constants
|
||||||
self.field = self.field or {}
|
self.field = self.field or {}
|
||||||
@@ -131,7 +133,7 @@ function Data:seedDefaults()
|
|||||||
if constants.dexDigits == nil then
|
if constants.dexDigits == nil then
|
||||||
constants.dexDigits = math.max(3, #tostring(constants.dexSize))
|
constants.dexDigits = math.max(3, #tostring(constants.dexSize))
|
||||||
end
|
end
|
||||||
self:applyVersionedFieldData()
|
Data.applyVersionedFieldData(self, version)
|
||||||
local boot = self.field.boot
|
local boot = self.field.boot
|
||||||
if boot == nil then
|
if boot == nil then
|
||||||
boot = {}
|
boot = {}
|
||||||
@@ -144,7 +146,7 @@ function Data:seedDefaults()
|
|||||||
-- only the un-overridden default flips, so a total conversion that set
|
-- only the un-overridden default flips, so a total conversion that set
|
||||||
-- field.boot.screens.splash keeps its choice on any version.
|
-- field.boot.screens.splash keeps its choice on any version.
|
||||||
if boot.screens.splash == BOOT_DEFAULTS.screens.splash
|
if boot.screens.splash == BOOT_DEFAULTS.screens.splash
|
||||||
and require("src.core.GameVersion").isYellow() then
|
and version == "yellow" then
|
||||||
boot.screens.splash = "YellowIntro"
|
boot.screens.splash = "YellowIntro"
|
||||||
end
|
end
|
||||||
-- the naming screen presets the importer already extracts but nothing
|
-- 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_* /
|
-- extractor never writes headers for them. Seed the EVENT_BEAT_* /
|
||||||
-- after-battle rows so Blaine's SetEventRange deactivation and talk
|
-- after-battle rows so Blaine's SetEventRange deactivation and talk
|
||||||
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
|
-- 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
|
-- #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
|
-- 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).
|
-- 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)
|
-- #189: 1F cabin door order vs rooms map (survey zoom)
|
||||||
require("src.world.SsAnneLayout").apply(self.maps)
|
require("src.world.SsAnneLayout").apply(self.maps)
|
||||||
end
|
end
|
||||||
@@ -199,6 +205,25 @@ function Data:seedFightingDojoKarateMaster()
|
|||||||
}
|
}
|
||||||
end
|
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()
|
function Data:seedCinnabarGymTrainerHeaders()
|
||||||
local headers = self.trainer_headers
|
local headers = self.trainer_headers
|
||||||
if not headers or headers.CinnabarGym then return end
|
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
|
FixedStep.STEP = 1 / 60
|
||||||
local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall
|
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
|
-- 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
|
-- 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.accum = 0
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
self.suppressCatchup = false
|
self.suppressCatchup = false
|
||||||
|
self.dtHistory, self.dtSum = nil, 0
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
|
-- 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.
|
-- the burst it would otherwise release doesn't play out as a slide.
|
||||||
if self.suppressCatchup then
|
if self.suppressCatchup then
|
||||||
self.suppressCatchup = false
|
self.suppressCatchup = false
|
||||||
|
self.dtHistory, self.dtSum = nil, 0
|
||||||
self.accum = self.STEP * RESEED_PHASE
|
self.accum = self.STEP * RESEED_PHASE
|
||||||
self.callback(self.STEP)
|
self.callback(self.STEP)
|
||||||
return
|
return
|
||||||
end
|
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)
|
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.accum = self.accum - self.STEP
|
||||||
self.callback(self.STEP)
|
self.callback(self.STEP)
|
||||||
end
|
end
|
||||||
|
|||||||
+15
-12
@@ -1324,9 +1324,7 @@ function Game:restoreSave(loaded, recovered, opts)
|
|||||||
self:adoptSave(loaded)
|
self:adoptSave(loaded)
|
||||||
-- SaveData.load already attached the standalone options.lua table
|
-- SaveData.load already attached the standalone options.lua table
|
||||||
self:applyOptions(loaded.options)
|
self:applyOptions(loaded.options)
|
||||||
-- saves from before OT/ID stamping: backfill with the player's (after
|
-- saves from before OT/ID stamping: backfill with the player's
|
||||||
-- the scrub, so every mon the stamp loop sees is known)
|
|
||||||
SaveData.repairTradedOtIds(loaded)
|
|
||||||
local stamp = require("src.battle.BattleState").stampOT
|
local stamp = require("src.battle.BattleState").stampOT
|
||||||
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
|
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
|
||||||
for _, box in ipairs(loaded.boxes or {}) do
|
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
|
-- Drop every session-owned field so the next Game:load() starts clean when
|
||||||
-- the process returns to the launcher in-place (Android / intent_game).
|
-- 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()
|
function Game:reset()
|
||||||
if self.stack and self.stack.clear then
|
if self.stack and self.stack.clear then
|
||||||
pcall(function() self.stack:clear() end)
|
pcall(function() self.stack:clear() end)
|
||||||
@@ -1403,23 +1406,23 @@ function Game:reset()
|
|||||||
if canvas and canvas.release then pcall(canvas.release, canvas) end
|
if canvas and canvas.release then pcall(canvas.release, canvas) end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if self.renderer and self.renderer.releaseCanvases then
|
if self.renderer then
|
||||||
pcall(function() self.renderer:releaseCanvases() end)
|
local release = self.renderer.releaseCanvases or self.renderer.release
|
||||||
|
if release then pcall(release, self.renderer) end
|
||||||
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 = {}
|
local keys = {}
|
||||||
for key, value in pairs(self) do
|
for key, value in pairs(self) do
|
||||||
if type(value) ~= "function" then
|
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
|
keys[#keys + 1] = key
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
for _, key in ipairs(keys) do
|
for _, key in ipairs(keys) do
|
||||||
self[key] = nil
|
self[key] = nil
|
||||||
end
|
end
|
||||||
|
self.SKIN_FAST_FORWARD = skinFast or 4
|
||||||
end
|
end
|
||||||
|
|
||||||
return Game
|
return Game
|
||||||
|
|||||||
+9
-7
@@ -350,6 +350,10 @@ function Game2:showTitle()
|
|||||||
onContinue = function()
|
onContinue = function()
|
||||||
self:showMainMenu()
|
self:showMainMenu()
|
||||||
end,
|
end,
|
||||||
|
-- engine/menus/intro_menu.asm:848-889
|
||||||
|
onTimeout = function()
|
||||||
|
self:showCopyright()
|
||||||
|
end,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2204,6 +2208,8 @@ end
|
|||||||
-- In-process return-to-launcher (Android / intent_game): drop session fields
|
-- 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.
|
-- 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.
|
-- 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()
|
function Game2:reset()
|
||||||
if self.stack and self.stack.clear then
|
if self.stack and self.stack.clear then
|
||||||
pcall(function() self.stack:clear() end)
|
pcall(function() self.stack:clear() end)
|
||||||
@@ -2216,17 +2222,13 @@ function Game2:reset()
|
|||||||
if canvas and canvas.release then pcall(canvas.release, canvas) end
|
if canvas and canvas.release then pcall(canvas.release, canvas) end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if self.renderer and self.renderer.releaseCanvases then
|
if self.renderer then
|
||||||
pcall(function() self.renderer:releaseCanvases() end)
|
local release = self.renderer.releaseCanvases or self.renderer.release
|
||||||
|
if release then pcall(release, self.renderer) end
|
||||||
end
|
end
|
||||||
local keys = {}
|
local keys = {}
|
||||||
for key, value in pairs(self) do
|
for key, value in pairs(self) do
|
||||||
if type(value) ~= "function" then
|
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
|
keys[#keys + 1] = key
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ GameVersion.VERSIONS = {
|
|||||||
saveSuffix = "_crystal", -- save_crystal.lua / .bak / .tmp
|
saveSuffix = "_crystal", -- save_crystal.lua / .bak / .tmp
|
||||||
generation = 2,
|
generation = 2,
|
||||||
engine = "crystal",
|
engine = "crystal",
|
||||||
|
revisions = {
|
||||||
|
{ sha1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", label = "1.0" },
|
||||||
|
{ sha1 = "f2f52230b536214ef7c9924f483392993e226cfb", label = "1.1" },
|
||||||
|
},
|
||||||
fixes = {
|
fixes = {
|
||||||
-- pokegold/docs/bugs_and_glitches.md:61
|
-- pokegold/docs/bugs_and_glitches.md:61
|
||||||
luckyNumberBoxes = true,
|
luckyNumberBoxes = true,
|
||||||
@@ -168,10 +172,29 @@ function GameVersion.cachePrefix(id)
|
|||||||
return GameVersion.info(id).cachePrefix
|
return GameVersion.info(id).cachePrefix
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function GameVersion.revisions(id)
|
||||||
|
local info = GameVersion.info(id)
|
||||||
|
return info.revisions or { { sha1 = info.sha1 } }
|
||||||
|
end
|
||||||
|
|
||||||
|
function GameVersion.acceptsSha1(id, sha1)
|
||||||
|
for _, revision in ipairs(GameVersion.revisions(id)) do
|
||||||
|
if revision.sha1 == sha1 then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
function GameVersion.revisionLabel(id, sha1)
|
||||||
|
for _, revision in ipairs(GameVersion.revisions(id)) do
|
||||||
|
if revision.sha1 == sha1 then return revision.label end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
-- The version a ROM belongs to, by its SHA-1, or nil for an unknown ROM.
|
-- The version a ROM belongs to, by its SHA-1, or nil for an unknown ROM.
|
||||||
function GameVersion.forSha1(sha1)
|
function GameVersion.forSha1(sha1)
|
||||||
for id, info in pairs(GameVersion.VERSIONS) do
|
for id in pairs(GameVersion.VERSIONS) do
|
||||||
if info.sha1 == sha1 then return id end
|
if GameVersion.acceptsSha1(id, sha1) then return id end
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|||||||
+17
-1
@@ -158,7 +158,15 @@ end
|
|||||||
-- and the app dies. There we relaunch through the GameActivity.restartApp
|
-- and the app dies. There we relaunch through the GameActivity.restartApp
|
||||||
-- JNI bridge (love.system.restartApp), which schedules our launch intent
|
-- JNI bridge (love.system.restartApp), which schedules our launch intent
|
||||||
-- and kills the process so no native state can leak into the fresh run.
|
-- 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()
|
function HostShell.restart()
|
||||||
if not (love and love.event and love.event.quit) then return end
|
if not (love and love.event and love.event.quit) then return end
|
||||||
|
|
||||||
@@ -174,6 +182,14 @@ function HostShell.restart()
|
|||||||
love.event.quit()
|
love.event.quit()
|
||||||
return
|
return
|
||||||
end
|
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")
|
local appimage = os.getenv("APPIMAGE")
|
||||||
if not appimage then
|
if not appimage then
|
||||||
|
|||||||
@@ -227,7 +227,11 @@ local function metadata(options, context)
|
|||||||
end
|
end
|
||||||
if flags and flags.fullscreen == true then add("Fullscreen", "yes") end
|
if flags and flags.fullscreen == true then add("Fullscreen", "yes") end
|
||||||
local version = appVersion()
|
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())
|
add("LÖVE", loveVersion())
|
||||||
if safeMode then add("Safe mode", "on") end
|
if safeMode then add("Safe mode", "on") end
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1983,6 +1983,11 @@ SaveData.addCoreMigration(3, function(save)
|
|||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
-- #1461 repair, one-shot on pre-format-5 saves
|
||||||
|
SaveData.addCoreMigration(4, function(save)
|
||||||
|
SaveData.repairTradedOtIds(save)
|
||||||
|
end)
|
||||||
|
|
||||||
-- ------- write
|
-- ------- write
|
||||||
|
|
||||||
-- Game progress only; options are written separately via saveOptions.
|
-- Game progress only; options are written separately via saveOptions.
|
||||||
@@ -2394,8 +2399,8 @@ function SaveData.applyPostGameHome(save, boot)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- 0.1.82-0.1.9x loads stamped the player's own id onto traded mons and saved
|
-- 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
|
-- it, which reads back as home-caught. Run only as the pre-format-5
|
||||||
-- traded=true, so clearing that pair is safe on every load. #1461
|
-- migration: a same-id trade partner makes that pair legitimate. #1461
|
||||||
function SaveData.repairTradedOtIds(save)
|
function SaveData.repairTradedOtIds(save)
|
||||||
local playerId = save and save.player and save.player.id
|
local playerId = save and save.player and save.player.id
|
||||||
if playerId == nil then return 0 end
|
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)
|
error(("parse error at byte %d: %s"):format(state.pos, why), 0)
|
||||||
end
|
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 function skip(state)
|
||||||
local _, last = state.src:find("^[ \t\r\n]*", state.pos)
|
while true do
|
||||||
state.pos = last + 1
|
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
|
end
|
||||||
|
|
||||||
local function peek(state)
|
local function peek(state)
|
||||||
@@ -90,7 +108,10 @@ local function readString(state)
|
|||||||
fail(state, "unterminated string")
|
fail(state, "unterminated string")
|
||||||
elseif c == '"' then
|
elseif c == '"' then
|
||||||
state.pos = i + 1
|
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
|
elseif c == "\\" then
|
||||||
local nxt = src:sub(i + 1, i + 1)
|
local nxt = src:sub(i + 1, i + 1)
|
||||||
if nxt:match("%d") then
|
if nxt:match("%d") then
|
||||||
@@ -136,10 +157,14 @@ end
|
|||||||
local readValue
|
local readValue
|
||||||
|
|
||||||
local function readTable(state)
|
local function readTable(state)
|
||||||
|
bumpNode(state)
|
||||||
state.depth = state.depth + 1
|
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
|
state.pos = state.pos + 1
|
||||||
local out = {}
|
local out = {}
|
||||||
|
local entries, nextArray = 0, 1
|
||||||
skip(state)
|
skip(state)
|
||||||
if peek(state) == "}" then
|
if peek(state) == "}" then
|
||||||
state.pos = state.pos + 1
|
state.pos = state.pos + 1
|
||||||
@@ -148,7 +173,7 @@ local function readTable(state)
|
|||||||
end
|
end
|
||||||
while true do
|
while true do
|
||||||
skip(state)
|
skip(state)
|
||||||
local key
|
local key, value
|
||||||
local c = peek(state)
|
local c = peek(state)
|
||||||
if c == "[" then
|
if c == "[" then
|
||||||
state.pos = state.pos + 1
|
state.pos = state.pos + 1
|
||||||
@@ -156,15 +181,38 @@ local function readTable(state)
|
|||||||
skip(state)
|
skip(state)
|
||||||
if peek(state) ~= "]" then fail(state, "expected ]") end
|
if peek(state) ~= "]" then fail(state, "expected ]") end
|
||||||
state.pos = state.pos + 1
|
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
|
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
|
else
|
||||||
fail(state, "expected key")
|
fail(state, "expected key")
|
||||||
end
|
end
|
||||||
skip(state)
|
if key == nil then fail(state, "nil table key") end
|
||||||
if peek(state) ~= "=" then fail(state, "expected =") end
|
if out[key] ~= nil then fail(state, "duplicate table key") end
|
||||||
state.pos = state.pos + 1
|
entries = entries + 1
|
||||||
out[key] = readValue(state)
|
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)
|
skip(state)
|
||||||
local sep = peek(state)
|
local sep = peek(state)
|
||||||
if sep == "," then
|
if sep == "," then
|
||||||
@@ -189,25 +237,30 @@ readValue = function(state)
|
|||||||
skip(state)
|
skip(state)
|
||||||
local c = peek(state)
|
local c = peek(state)
|
||||||
if c == '"' then
|
if c == '"' then
|
||||||
|
bumpNode(state)
|
||||||
return readString(state)
|
return readString(state)
|
||||||
elseif c == "{" then
|
elseif c == "{" then
|
||||||
return readTable(state)
|
return readTable(state)
|
||||||
elseif c:match("[%a_]") then
|
elseif c:match("[%a_]") then
|
||||||
-- the only bare words in the grammar are the boolean literals
|
-- the only bare words in the grammar are the boolean literals
|
||||||
local word = readIdent(state)
|
local word = readIdent(state)
|
||||||
if word == "true" then return true end
|
if word == "true" then bumpNode(state); return true end
|
||||||
if word == "false" then return false end
|
if word == "false" then bumpNode(state); return false end
|
||||||
state.pos = state.pos - #word
|
state.pos = state.pos - #word
|
||||||
fail(state, "unexpected name '" .. word .. "'")
|
fail(state, "unexpected name '" .. word .. "'")
|
||||||
elseif c:match("[%-%d%.]") then
|
elseif c:match("[%-%d%.]") then
|
||||||
|
bumpNode(state)
|
||||||
return readNumber(state)
|
return readNumber(state)
|
||||||
end
|
end
|
||||||
fail(state, c == "" and "unexpected end of input" or "unexpected character")
|
fail(state, c == "" and "unexpected end of input" or "unexpected character")
|
||||||
end
|
end
|
||||||
|
|
||||||
function SaveSerializer.decode(str)
|
function SaveSerializer.decode(str, limits)
|
||||||
if type(str) ~= "string" then return nil, "save must be a string" end
|
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()
|
local ok, result = pcall(function()
|
||||||
skip(state)
|
skip(state)
|
||||||
local word = state.src:match("^[%a_][%w_]*", state.pos)
|
local word = state.src:match("^[%a_][%w_]*", state.pos)
|
||||||
@@ -219,7 +272,9 @@ function SaveSerializer.decode(str)
|
|||||||
return value
|
return value
|
||||||
end)
|
end)
|
||||||
if not ok then return nil, result 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
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,14 @@ local function sdlFfi()
|
|||||||
]])
|
]])
|
||||||
if sdlCdefOk then
|
if sdlCdefOk then
|
||||||
local okLoad, lib = pcall(ffi.load, "SDL2")
|
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
|
||||||
end
|
end
|
||||||
if not sdlCdefOk or not sdlLib then return nil 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")
|
local Runtime = require("src.mods.Runtime")
|
||||||
if Runtime.reset then Runtime.reset() end
|
if Runtime.reset then Runtime.reset() end
|
||||||
if Assets.installLoader then Assets.installLoader(nil) 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")
|
local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat")
|
||||||
if okCompat and LegacyCompat.reset then LegacyCompat.reset() end
|
if okCompat and LegacyCompat.reset then LegacyCompat.reset() end
|
||||||
end
|
end
|
||||||
@@ -64,11 +66,18 @@ end
|
|||||||
|
|
||||||
-- EXIT GAME / intent_game before dropping Game. Stops audio and resets the
|
-- EXIT GAME / intent_game before dropping Game. Stops audio and resets the
|
||||||
-- live game instance so map/GPU holders are gone before endMountedSession.
|
-- 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)
|
function SessionLifecycle.endGameSession(game)
|
||||||
pcall(function() require("src.core.Music").stop() end)
|
pcall(function() require("src.core.Music").stop() end)
|
||||||
pcall(function() require("src.core.Sound").stop() end)
|
pcall(function() require("src.core.Sound").stop() end)
|
||||||
if package.loaded["src.core.ChipAudio"] then
|
if package.loaded["src.core.ChipAudio"] then
|
||||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
pcall(package.loaded["src.core.ChipAudio"].stopMusic)
|
||||||
end
|
end
|
||||||
if package.loaded["src.core.DiscordPresence"] then
|
if package.loaded["src.core.DiscordPresence"] then
|
||||||
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
|
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
|
||||||
@@ -87,6 +96,13 @@ function SessionLifecycle.endGameSession(game)
|
|||||||
if game and game.reset then
|
if game and game.reset then
|
||||||
pcall(function() game:reset() end)
|
pcall(function() game:reset() end)
|
||||||
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 Input = require("src.core.Input")
|
||||||
local TouchControls = require("src.core.TouchControls")
|
local TouchControls = require("src.core.TouchControls")
|
||||||
|
|||||||
@@ -371,6 +371,13 @@ function Sound.waitSfxDone()
|
|||||||
curSfx = nil
|
curSfx = nil
|
||||||
end
|
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 function startSfx(data, name, def)
|
||||||
local src = playPath(data, name, def)
|
local src = playPath(data, name, def)
|
||||||
if not src then return end
|
if not src then return end
|
||||||
|
|||||||
@@ -217,10 +217,6 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale)
|
|||||||
local abW = dpadW * 0.46
|
local abW = dpadW * 0.46
|
||||||
local ssW = dpadW * 0.30
|
local ssW = dpadW * 0.30
|
||||||
local margin = dpadW * 0.12
|
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 {
|
return {
|
||||||
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
|
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 },
|
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.
|
-- exceeds the shell it provides.
|
||||||
modApi = 2, -- mod API major (manifest `api`)
|
modApi = 2, -- mod API major (manifest `api`)
|
||||||
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
|
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)
|
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)
|
function Version.title(base)
|
||||||
return (base or "gen1recomp")
|
base = base or "gen1recomp"
|
||||||
.. " v" .. Version.engine
|
if Version.isDev() then
|
||||||
|
return base .. " v" .. Version.engine
|
||||||
|
end
|
||||||
|
return base
|
||||||
end
|
end
|
||||||
|
|
||||||
return Version
|
return Version
|
||||||
|
|||||||
@@ -89,10 +89,12 @@ local function handle(cmd)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local idleWait = false
|
||||||
|
|
||||||
while true do
|
while true do
|
||||||
-- drain every pending command first, so a stop/new-play is seen promptly
|
-- drain every pending command first, so a stop/new-play is seen promptly
|
||||||
local quit = false
|
local quit = false
|
||||||
local cmd = cmdCh:pop()
|
local cmd = idleWait and cmdCh:demand(0.05) or cmdCh:pop()
|
||||||
while cmd do
|
while cmd do
|
||||||
if handle(cmd) then quit = true end
|
if handle(cmd) then quit = true end
|
||||||
cmd = cmdCh:pop()
|
cmd = cmdCh:pop()
|
||||||
@@ -100,6 +102,7 @@ while true do
|
|||||||
if quit then break end
|
if quit then break end
|
||||||
|
|
||||||
if engine and not finished and gen and outCh:getCount() < LOOKAHEAD then
|
if engine and not finished and gen and outCh:getCount() < LOOKAHEAD then
|
||||||
|
idleWait = false
|
||||||
local activeGen = gen
|
local activeGen = gen
|
||||||
local ok, sd = pcall(ChipSynth.soundData, engine, BUF, 2)
|
local ok, sd = pcall(ChipSynth.soundData, engine, BUF, 2)
|
||||||
if not ok then
|
if not ok then
|
||||||
@@ -113,8 +116,10 @@ while true do
|
|||||||
finished = true
|
finished = true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
elseif engine and not finished and gen then
|
||||||
|
idleWait = false
|
||||||
|
love.timer.sleep(0.005)
|
||||||
else
|
else
|
||||||
-- nothing to do (idle, or the look-ahead is full): yield the core
|
idleWait = true
|
||||||
love.timer.sleep(0.001)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -164,14 +164,12 @@ local function receiveItemToPc(save, id, data)
|
|||||||
save.pcItems = save.pcItems or {}
|
save.pcItems = save.pcItems or {}
|
||||||
local pc = save.pcItems
|
local pc = save.pcItems
|
||||||
local held = pc[id] or 0
|
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 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 stacks = 0
|
local used = 0
|
||||||
for _ in pairs(pc) do stacks = stacks + 1 end
|
for _, count in pairs(pc) do used = used + stacksFor(count) end
|
||||||
if stacks >= cap then return false end
|
-- engine/items/items.asm:156 PutItemInPocket
|
||||||
elseif held + 1 > MAX_STACK then
|
if used + stacksFor(held + 1) - stacksFor(held) > cap then return false end
|
||||||
return false
|
|
||||||
end
|
|
||||||
pc[id] = held + 1
|
pc[id] = held + 1
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -343,6 +343,7 @@ Save.OPTIONS_KEY = "gold"
|
|||||||
|
|
||||||
local SHARED_KEYS = {
|
local SHARED_KEYS = {
|
||||||
touchControls = true, haptics = true, screenPos = true,
|
touchControls = true, haptics = true, screenPos = true,
|
||||||
|
videoMode = true,
|
||||||
mods = true, modsByVersion = true, modsGen2 = true,
|
mods = true, modsByVersion = true, modsGen2 = true,
|
||||||
modOptions = true, modProfiles = true, modProfilesSeeded = true,
|
modOptions = true, modProfiles = true, modProfilesSeeded = true,
|
||||||
activeProfile = true,
|
activeProfile = true,
|
||||||
@@ -361,12 +362,9 @@ function Save.loadOptions(fs)
|
|||||||
end
|
end
|
||||||
if type(loaded) == "table" then
|
if type(loaded) == "table" then
|
||||||
for key in pairs(SHARED_KEYS) do
|
for key in pairs(SHARED_KEYS) do
|
||||||
if loaded[key] ~= nil then options[key] = loaded[key] end
|
if loaded[key] ~= nil then
|
||||||
end
|
options[key] = loaded[key]
|
||||||
end
|
elseif type(stored) == "table" and stored[key] ~= nil then
|
||||||
if type(stored) == "table" then
|
|
||||||
for key in pairs(SHARED_KEYS) do
|
|
||||||
if options[key] == nil and stored[key] ~= nil then
|
|
||||||
options[key] = stored[key]
|
options[key] = stored[key]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ local GameVersion = require("src.core.GameVersion")
|
|||||||
local CacheContract = {}
|
local CacheContract = {}
|
||||||
|
|
||||||
CacheContract.FORMAT = "rom-cache-v10:"
|
CacheContract.FORMAT = "rom-cache-v10:"
|
||||||
|
CacheContract.VERSION_FORMAT = {
|
||||||
|
crystal = "rom-cache-v10-crystal3:",
|
||||||
|
}
|
||||||
CacheContract.MARKER_PATH = "rom-cache.complete"
|
CacheContract.MARKER_PATH = "rom-cache.complete"
|
||||||
|
|
||||||
CacheContract.REQUIRED_FILES = {
|
CacheContract.REQUIRED_FILES = {
|
||||||
@@ -31,6 +34,11 @@ CacheContract.VERSION_REQUIRED_FILES = {
|
|||||||
"assets/generated/battle/trainers/jessie_james.png",
|
"assets/generated/battle/trainers/jessie_james.png",
|
||||||
"assets/generated/battle/profoakb.png",
|
"assets/generated/battle/profoakb.png",
|
||||||
"assets/generated/pikachu/pikapic_1.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,84 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = {
|
|||||||
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.silver =
|
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.silver =
|
||||||
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.gold
|
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)
|
function CacheContract.requiredFilesFor(version)
|
||||||
local override = CacheContract.VERSION_REQUIRED_FILES_OVERRIDE[version]
|
local override = CacheContract.VERSION_REQUIRED_FILES_OVERRIDE[version]
|
||||||
if override then return override, true end
|
if override then return override, true end
|
||||||
return CacheContract.REQUIRED_FILES, false
|
return CacheContract.REQUIRED_FILES, false
|
||||||
end
|
end
|
||||||
|
|
||||||
function CacheContract.markerFor(version)
|
-- A detached, version-specific inventory for read-only consumers. Semantic
|
||||||
return CacheContract.FORMAT .. GameVersion.info(version).sha1
|
-- 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.formatFor(version)
|
||||||
|
.. (sha1 or GameVersion.info(version).sha1)
|
||||||
|
end
|
||||||
|
|
||||||
|
function CacheContract.markerMatches(version, marker)
|
||||||
|
for _, revision in ipairs(GameVersion.revisions(version)) do
|
||||||
|
if marker == CacheContract.markerFor(version, revision.sha1) then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Keep the process-global CacheFs prefix isolated even when a filesystem
|
-- Keep the process-global CacheFs prefix isolated even when a filesystem
|
||||||
@@ -189,11 +267,11 @@ function CacheContract.isReady(version, fs)
|
|||||||
fs = fs or require("src.import.CacheFs")
|
fs = fs or require("src.import.CacheFs")
|
||||||
if CacheContract.sourceTreeHasData(version) then return true end
|
if CacheContract.sourceTreeHasData(version) then return true end
|
||||||
local marker, readError = CacheContract.readMarker(version, fs)
|
local marker, readError = CacheContract.readMarker(version, fs)
|
||||||
if readError or marker ~= CacheContract.markerFor(version) then return false end
|
if readError or not CacheContract.markerMatches(version, marker) then return false end
|
||||||
return CacheContract.allRequiredFilesExist(version, fs)
|
return CacheContract.allRequiredFilesExist(version, fs)
|
||||||
end
|
end
|
||||||
|
|
||||||
function CacheContract.publish(version, fs)
|
function CacheContract.publish(version, fs, sha1)
|
||||||
fs = fs or require("src.import.CacheFs")
|
fs = fs or require("src.import.CacheFs")
|
||||||
local complete, missing = CacheContract.allRequiredFilesExist(version, fs)
|
local complete, missing = CacheContract.allRequiredFilesExist(version, fs)
|
||||||
if not complete then
|
if not complete then
|
||||||
@@ -212,7 +290,7 @@ function CacheContract.publish(version, fs)
|
|||||||
return false, "cache is incomplete; missing " .. tostring(missing)
|
return false, "cache is incomplete; missing " .. tostring(missing)
|
||||||
end
|
end
|
||||||
local changed, ok, err = withVersionPrefix(version, fs, function()
|
local changed, ok, err = withVersionPrefix(version, fs, function()
|
||||||
return fs.write(CacheContract.MARKER_PATH, CacheContract.markerFor(version))
|
return fs.write(CacheContract.MARKER_PATH, CacheContract.markerFor(version, sha1))
|
||||||
end)
|
end)
|
||||||
if not changed then return false, tostring(ok) end
|
if not changed then return false, tostring(ok) end
|
||||||
return ok, err
|
return ok, err
|
||||||
@@ -245,4 +323,59 @@ function CacheContract.sourceTreeHasData(version)
|
|||||||
return true
|
return true
|
||||||
end
|
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
|
return CacheContract
|
||||||
|
|||||||
+14
-5
@@ -324,9 +324,9 @@ function CacheFs.openWrite(rel)
|
|||||||
return file
|
return file
|
||||||
end
|
end
|
||||||
|
|
||||||
-- read cache-relative `rel`; returns the bytes or nil
|
-- Read an exact version-qualified path without consulting CacheFs.prefix.
|
||||||
function CacheFs.read(rel)
|
-- Readiness checks use this to inspect another version without global state.
|
||||||
rel = withPrefix(rel)
|
function CacheFs.readAt(rel)
|
||||||
local root = CacheFs.root()
|
local root = CacheFs.root()
|
||||||
if root then
|
if root then
|
||||||
local f = io.open(realPath(root, rel), "rb")
|
local f = io.open(realPath(root, rel), "rb")
|
||||||
@@ -341,6 +341,11 @@ function CacheFs.read(rel)
|
|||||||
return love.filesystem.read(rel)
|
return love.filesystem.read(rel)
|
||||||
end
|
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
|
-- 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
|
-- prefixed Blue/Yellow/Gold trees (fused NX mount hole). Same order Data:load
|
||||||
-- already used: active version prefix with CacheFs.prefix cleared, then
|
-- already used: active version prefix with CacheFs.prefix cleared, then
|
||||||
@@ -386,8 +391,7 @@ function CacheFs.loadActive(rel)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- does cache-relative `rel` exist as a file?
|
-- does cache-relative `rel` exist as a file?
|
||||||
function CacheFs.exists(rel)
|
function CacheFs.existsAt(rel)
|
||||||
rel = withPrefix(rel)
|
|
||||||
local root = CacheFs.root()
|
local root = CacheFs.root()
|
||||||
if root then
|
if root then
|
||||||
local f = io.open(realPath(root, rel), "rb")
|
local f = io.open(realPath(root, rel), "rb")
|
||||||
@@ -398,6 +402,11 @@ function CacheFs.exists(rel)
|
|||||||
return love.filesystem.getInfo(rel, "file") ~= nil
|
return love.filesystem.getInfo(rel, "file") ~= nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
function CacheFs.exists(rel)
|
||||||
|
return CacheFs.existsAt(withPrefix(rel))
|
||||||
|
end
|
||||||
|
|
||||||
-- remove a single cache-relative file
|
-- remove a single cache-relative file
|
||||||
function CacheFs.remove(rel)
|
function CacheFs.remove(rel)
|
||||||
rel = withPrefix(rel)
|
rel = withPrefix(rel)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ require("love.math")
|
|||||||
require("love.system")
|
require("love.system")
|
||||||
require("love.timer")
|
require("love.timer")
|
||||||
|
|
||||||
local version, prefix, romData, progressName, resultName = ...
|
local version, prefix, romData, progressName, resultName, romSha1 = ...
|
||||||
|
|
||||||
local progressChannel = love.thread.getChannel(progressName)
|
local progressChannel = love.thread.getChannel(progressName)
|
||||||
local resultChannel = love.thread.getChannel(resultName)
|
local resultChannel = love.thread.getChannel(resultName)
|
||||||
@@ -44,7 +44,7 @@ local ok, err = pcall(function()
|
|||||||
current = current, stageTotal = stageTotal,
|
current = current, stageTotal = stageTotal,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
end)
|
end, romSha1)
|
||||||
extractor:run()
|
extractor:run()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,28 @@ local SHADES = {
|
|||||||
{ 0, 0, 0, 1 },
|
{ 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)
|
local function assertDimensions(raw, width, height, bits)
|
||||||
assert(width % 8 == 0 and height % 8 == 0,
|
assert(width % 8 == 0 and height % 8 == 0,
|
||||||
("%dbpp dimensions must be tile-aligned: %dx%d")
|
("%dbpp dimensions must be tile-aligned: %dx%d")
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ local SaveData = require("src.core.SaveData")
|
|||||||
|
|
||||||
local LauncherSettings = {}
|
local LauncherSettings = {}
|
||||||
|
|
||||||
|
local function bgLocked(opts)
|
||||||
|
return opts.battleLayout == "wide" and opts.battleFit == "fill"
|
||||||
|
and opts.battleHud == "extended"
|
||||||
|
end
|
||||||
|
|
||||||
local function wrapIndex(i, n)
|
local function wrapIndex(i, n)
|
||||||
i = i % n
|
i = i % n
|
||||||
if i < 0 then i = i + n end
|
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"
|
opts.battleLayout = opts.battleLayout == "wide" and "og" or "wide"
|
||||||
if opts.battleLayout ~= "wide" then
|
if opts.battleLayout ~= "wide" then
|
||||||
opts.battleHud = "standard"
|
opts.battleHud = "standard"
|
||||||
elseif opts.battleFit == "fill" and opts.battleHud == "extended" then
|
|
||||||
opts.battleBg = "white"
|
|
||||||
end
|
end
|
||||||
return true
|
return true
|
||||||
end)
|
end)
|
||||||
@@ -167,10 +170,6 @@ local function coreRows(opts, hooks)
|
|||||||
end,
|
end,
|
||||||
function()
|
function()
|
||||||
opts.battleFit = opts.battleFit == "fill" and "fixed" or "fill"
|
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
|
return true
|
||||||
end)
|
end)
|
||||||
add(Strings("BATTLE HUD"),
|
add(Strings("BATTLE HUD"),
|
||||||
@@ -185,28 +184,17 @@ local function coreRows(opts, hooks)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
opts.battleHud = opts.battleHud == "extended" and "standard" or "extended"
|
opts.battleHud = opts.battleHud == "extended" and "standard" or "extended"
|
||||||
if opts.battleHud == "extended" and opts.battleFit == "fill" then
|
|
||||||
opts.battleBg = "white"
|
|
||||||
end
|
|
||||||
return true
|
return true
|
||||||
end)
|
end)
|
||||||
add(Strings("BATTLE BG"),
|
add(Strings("BATTLE BG"),
|
||||||
function()
|
function()
|
||||||
if opts.battleLayout == "wide" and opts.battleFit == "fill"
|
if bgLocked(opts) then return Strings("AUTO (FILL HUD)") end
|
||||||
and opts.battleHud == "extended" then
|
|
||||||
opts.battleBg = "white"
|
|
||||||
return Strings("AUTO")
|
|
||||||
end
|
|
||||||
if opts.battleBg == "black" then return Strings("BLACK") end
|
if opts.battleBg == "black" then return Strings("BLACK") end
|
||||||
if opts.battleBg == "world" then return Strings("WORLD") end
|
if opts.battleBg == "world" then return Strings("WORLD") end
|
||||||
return Strings("WHITE")
|
return Strings("WHITE")
|
||||||
end,
|
end,
|
||||||
function(dir)
|
function(dir)
|
||||||
if opts.battleLayout == "wide" and opts.battleFit == "fill"
|
if bgLocked(opts) then return false end
|
||||||
and opts.battleHud == "extended" then
|
|
||||||
opts.battleBg = "white"
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
local order = { "white", "black", "world" }
|
local order = { "white", "black", "world" }
|
||||||
local cur = 1
|
local cur = 1
|
||||||
for i, mode in ipairs(order) do
|
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.
|
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
|
||||||
local GEN2_KEY = "gold"
|
local GEN2_KEY = "gold"
|
||||||
|
|
||||||
local function gen2Rows(opts, hooks)
|
local function gen2Rows(opts, hooks, shared)
|
||||||
local rows = {}
|
local rows = {}
|
||||||
local function add(label, value, step)
|
local function add(label, value, step)
|
||||||
rows[#rows + 1] = { label = label, value = value, step = 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")
|
local okVm, VideoMode = pcall(require, "src.core.VideoMode")
|
||||||
if okVm then
|
if okVm then
|
||||||
add(Strings("VIDEO MODE"),
|
add(Strings("VIDEO MODE"),
|
||||||
function() return VideoMode.modeLabel(opts.videoMode) end,
|
function() return VideoMode.modeLabel(shared.videoMode) end,
|
||||||
function(dir)
|
function(dir)
|
||||||
opts.videoMode = VideoMode.cycle(opts.videoMode, dir)
|
shared.videoMode = VideoMode.cycle(shared.videoMode, dir)
|
||||||
return true
|
return true
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -690,7 +678,7 @@ local function gen2Rows(opts, hooks)
|
|||||||
add(Strings("BATTLE BG"), ladder(opts, "battleBg",
|
add(Strings("BATTLE BG"), ladder(opts, "battleBg",
|
||||||
{ { "white", "WHITE" }, { "black", "BLACK" } }, "white"))
|
{ { "white", "WHITE" }, { "black", "BLACK" } }, "white"))
|
||||||
|
|
||||||
addTouchRows(rows, add, opts, hooks)
|
addTouchRows(rows, add, shared, hooks)
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
end
|
end
|
||||||
@@ -718,7 +706,7 @@ function LauncherSettings.open(hooks, version)
|
|||||||
opts[GEN2_KEY] = block
|
opts[GEN2_KEY] = block
|
||||||
end
|
end
|
||||||
sections = {
|
sections = {
|
||||||
{ title = Strings("OPTIONS"), rows = gen2Rows(block, hooks) },
|
{ title = Strings("OPTIONS"), rows = gen2Rows(block, hooks, opts) },
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
sections = {
|
sections = {
|
||||||
|
|||||||
+27
-15
@@ -984,6 +984,17 @@ local function modScopeChipsWidth(options, gap, m)
|
|||||||
return need
|
return need
|
||||||
end
|
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 function buildModScopeRow(imp, x, y, w, m)
|
||||||
local LauncherMods = require("src.mods.LauncherMods")
|
local LauncherMods = require("src.mods.LauncherMods")
|
||||||
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
|
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)
|
local options = modScopeOptions(imp)
|
||||||
|
|
||||||
-- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar
|
-- 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 isCompact = (w < math.floor(500 * m.s))
|
||||||
local nameText = tostring(activeProf or "Default")
|
local nameText = tostring(activeProf or "Default")
|
||||||
local profLabel = isCompact and nameText or Strings("Profile: %s", nameText)
|
local profLabel = isCompact and nameText or Strings("Profile: %s", nameText)
|
||||||
@@ -3056,8 +3067,8 @@ local function buildFindKindRow(imp, x, y, w, m)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function buildFindPanel(imp, x, y, w, availH, m)
|
local function buildFindPanel(imp, x, y, w, availH, m)
|
||||||
|
imp._findVisibleEntries = nil
|
||||||
imp:_ensureFind()
|
imp:_ensureFind()
|
||||||
imp:_ensureMods()
|
|
||||||
local ModIndex = require("src.mods.ModIndex")
|
local ModIndex = require("src.mods.ModIndex")
|
||||||
local ModUpdate = require("src.mods.ModUpdate")
|
local ModUpdate = require("src.mods.ModUpdate")
|
||||||
local sources = imp.findSources or {}
|
local sources = imp.findSources or {}
|
||||||
@@ -3126,7 +3137,9 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
|||||||
|
|
||||||
if #rows == 0 then
|
if #rows == 0 then
|
||||||
local empty
|
local empty
|
||||||
if carts then
|
if imp._findFetch then
|
||||||
|
empty = Strings("Loading mod index...")
|
||||||
|
elseif carts then
|
||||||
empty = (total == 0) and Strings("This index lists no carts yet.")
|
empty = (total == 0) and Strings("This index lists no carts yet.")
|
||||||
or Strings("No carts match that search.")
|
or Strings("No carts match that search.")
|
||||||
else
|
else
|
||||||
@@ -3191,6 +3204,11 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
|||||||
local listTop = cy
|
local listTop = cy
|
||||||
setPage(imp, "find", Kit.wheelPage(x, listTop, w, listH, cur, #rows, perPage))
|
setPage(imp, "find", Kit.wheelPage(x, listTop, w, listH, cur, #rows, perPage))
|
||||||
|
|
||||||
|
local visible = imp._findVisibleEntries or {}
|
||||||
|
for i = #visible, 1, -1 do visible[i] = nil end
|
||||||
|
for i = first, last do visible[#visible + 1] = rows[i] end
|
||||||
|
imp._findVisibleEntries = visible
|
||||||
|
|
||||||
for i = first, last do
|
for i = first, last do
|
||||||
local entry = rows[i]
|
local entry = rows[i]
|
||||||
local ry = listTop + (i - first) * (rowH + gap)
|
local ry = listTop + (i - first) * (rowH + gap)
|
||||||
@@ -3691,9 +3709,8 @@ local function buildSingleProfileActionsModal(imp, m)
|
|||||||
if not pName then imp._singleProfileActions = nil return end
|
if not pName then imp._singleProfileActions = nil return end
|
||||||
|
|
||||||
local LauncherMods = require("src.mods.LauncherMods")
|
local LauncherMods = require("src.mods.LauncherMods")
|
||||||
local SaveData = require("src.core.SaveData")
|
local prof = profileState(imp)
|
||||||
local options = SaveData.loadOptions()
|
local options, profiles = prof.options, prof.list
|
||||||
local profiles, active = LauncherMods.getProfiles(options)
|
|
||||||
|
|
||||||
local pad = math.floor(18 * m.s)
|
local pad = math.floor(18 * m.s)
|
||||||
local w = math.min(math.floor(380 * m.s), m.w - 2 * m.pad)
|
local w = math.min(math.floor(380 * m.s), m.w - 2 * m.pad)
|
||||||
@@ -3708,6 +3725,7 @@ local function buildSingleProfileActionsModal(imp, m)
|
|||||||
action = function()
|
action = function()
|
||||||
LauncherMods.duplicateProfile(pName, options)
|
LauncherMods.duplicateProfile(pName, options)
|
||||||
imp._singleProfileActions = nil
|
imp._singleProfileActions = nil
|
||||||
|
if imp._refreshMods then imp:_refreshMods() end
|
||||||
end
|
end
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3729,6 +3747,7 @@ local function buildSingleProfileActionsModal(imp, m)
|
|||||||
imp:pressDelete("profile", pName, nil, function()
|
imp:pressDelete("profile", pName, nil, function()
|
||||||
LauncherMods.deleteProfile(pName, options)
|
LauncherMods.deleteProfile(pName, options)
|
||||||
imp._singleProfileActions = nil
|
imp._singleProfileActions = nil
|
||||||
|
if imp._refreshMods then imp:_refreshMods() end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
}
|
}
|
||||||
@@ -3764,9 +3783,8 @@ end
|
|||||||
-- Modal for Mod Profiles (#593) - interactive profile manager (switch, edit, duplicate, delete)
|
-- Modal for Mod Profiles (#593) - interactive profile manager (switch, edit, duplicate, delete)
|
||||||
local function buildProfilesModal(imp, m)
|
local function buildProfilesModal(imp, m)
|
||||||
local LauncherMods = require("src.mods.LauncherMods")
|
local LauncherMods = require("src.mods.LauncherMods")
|
||||||
local SaveData = require("src.core.SaveData")
|
local prof = profileState(imp)
|
||||||
local options = SaveData.loadOptions()
|
local options, profiles, active = prof.options, prof.list, prof.active
|
||||||
local profiles, active = LauncherMods.getProfiles(options)
|
|
||||||
|
|
||||||
local pad = math.floor(18 * m.s)
|
local pad = math.floor(18 * m.s)
|
||||||
local w = math.min(math.floor(460 * m.s), m.w - 2 * m.pad)
|
local w = math.min(math.floor(460 * m.s), m.w - 2 * m.pad)
|
||||||
@@ -5764,12 +5782,6 @@ local function loaderSpec(imp)
|
|||||||
return { title = b.title, detail = b.detail, progress = b.progress,
|
return { title = b.title, detail = b.detail, progress = b.progress,
|
||||||
onCancel = b.cancel }
|
onCancel = b.cancel }
|
||||||
end
|
end
|
||||||
-- The boot prewarm runs without an overlay (the user did not ask for it and
|
|
||||||
-- must be able to use the launcher meanwhile), but if they reach the Find
|
|
||||||
-- Mods tab before it lands, THEN they are waiting on it and it earns one.
|
|
||||||
if imp.tab == "find" and imp._findFetch and not imp.findLoaded then
|
|
||||||
return { title = Strings("Loading mod index") }
|
|
||||||
end
|
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+118
-2
@@ -1535,6 +1535,14 @@ function RomExtractor:extractYellowTitleArt()
|
|||||||
local bg = sheetTiles("TitlePikachuBGGraphics", 64)
|
local bg = sheetTiles("TitlePikachuBGGraphics", 64)
|
||||||
local ob = sheetTiles("TitlePikachuOBGraphics", 12)
|
local ob = sheetTiles("TitlePikachuOBGraphics", 12)
|
||||||
local obClear = sheetTiles("TitlePikachuOBGraphics", 12, true)
|
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)
|
local function tileFor(id)
|
||||||
if id < 0x80 then return logo[id + 1] end
|
if id < 0x80 then return logo[id + 1] end
|
||||||
if id < 0xF0 then return bg[id - 0x80 + 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)
|
local overlay = ImageWriter.blank(48, 16, 1, 1, 1, 0)
|
||||||
ImageWriter.blit(overlay, pikachu, 0, 0, 24, 16, 48, 16)
|
ImageWriter.blit(overlay, pikachu, 0, 0, 24, 16, 48, 16)
|
||||||
for _, e in ipairs(EYE_LAYOUT) do
|
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
|
end
|
||||||
overlays[suffix] = overlay
|
overlays[suffix] = overlay
|
||||||
end
|
end
|
||||||
-- open eyes bake into pikachu.png AFTER the blank-face crops
|
-- open eyes bake into pikachu.png AFTER the blank-face crops
|
||||||
for _, e in ipairs(EYE_LAYOUT) do
|
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
|
end
|
||||||
self:save(pikachu, "title/pikachu.png")
|
self:save(pikachu, "title/pikachu.png")
|
||||||
for suffix, overlay in pairs(overlays) do
|
for suffix, overlay in pairs(overlays) do
|
||||||
@@ -1637,6 +1645,113 @@ function RomExtractor:extractYellowTitleArt()
|
|||||||
end
|
end
|
||||||
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)
|
function RomExtractor:raw2bpp(label, width, height, relative, options)
|
||||||
options = options or {}
|
options = options or {}
|
||||||
local expected = width * height / 4
|
local expected = width * height / 4
|
||||||
@@ -1978,6 +2093,7 @@ function RomExtractor:extractField()
|
|||||||
self:save(image, spec[5])
|
self:save(image, spec[5])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
self:extractSurfingPikachuTitleArt()
|
||||||
|
|
||||||
-- Yellow-only: TalkToPikachu's framed portrait, one 5x5 base frame per
|
-- Yellow-only: TalkToPikachu's framed portrait, one 5x5 base frame per
|
||||||
-- PikaPicAnimScript -- each script's FIRST pikapic_loadgfx in
|
-- PikaPicAnimScript -- each script's FIRST pikapic_loadgfx in
|
||||||
|
|||||||
@@ -56,9 +56,6 @@ local MAP_GROUP_COUNT = 26 -- constants/map_constants.asm NUM_MAP_GROUPS
|
|||||||
-- "bank13" (../pokecrystal/main.asm:192-195).
|
-- "bank13" (../pokecrystal/main.asm:192-195).
|
||||||
local PAL_MAP_BANK = 0x02
|
local PAL_MAP_BANK = 0x02
|
||||||
local PAL_MAP_BANK_CRYSTAL = 0x13
|
local PAL_MAP_BANK_CRYSTAL = 0x13
|
||||||
-- LoadBallIconGFX.gfx (engine/battle/trainer_huds.asm:225-232); bank $0b
|
|
||||||
-- carries no manifest symbol to resolve it through.
|
|
||||||
local BALL_ICON_GFX = { 0x0b, 0x41a4 }
|
|
||||||
-- A tileset sheet is 96 tiles (128x48 at 8x8), and its PalMap packs two
|
-- A tileset sheet is 96 tiles (128x48 at 8x8), and its PalMap packs two
|
||||||
-- tiles per byte: low nibble first tile, high nibble second (`dn` in the
|
-- tiles per byte: low nibble first tile, high nibble second (`dn` in the
|
||||||
-- tilepal macro). The high bit of each nibble is the VRAM bank, not colour.
|
-- tilepal macro). The high bit of each nibble is the VRAM bank, not colour.
|
||||||
@@ -154,6 +151,19 @@ local TEXT_BUFFERS = {
|
|||||||
[0xc602] = "wOTTrademonSpeciesName",
|
[0xc602] = "wOTTrademonSpeciesName",
|
||||||
[0xc618] = "wOTTrademonSenderName",
|
[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
|
-- The text commands that print nothing and carry no argument
|
||||||
-- (macros/scripts/text.asm, in TextCommands order): TX_LOW, TX_SCROLL,
|
-- (macros/scripts/text.asm, in TextCommands order): TX_LOW, TX_SCROLL,
|
||||||
-- TX_PAUSE, TX_WAIT_BUTTON, TX_DAY, and the six TX_SOUND_* jingles.
|
-- TX_PAUSE, TX_WAIT_BUTTON, TX_DAY, and the six TX_SOUND_* jingles.
|
||||||
@@ -201,14 +211,23 @@ local function copy(value)
|
|||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
function RomExtractorGen2.new(romData, manifest, progress)
|
function RomExtractorGen2.new(romData, manifest, progress, romSha1)
|
||||||
-- _GOLD / _SILVER: the labels are shared, the data behind a handful of
|
-- _GOLD / _SILVER: the labels are shared, the data behind a handful of
|
||||||
-- them is not (gfx/misc.asm:9-20 vs :46-57).
|
-- them is not (gfx/misc.asm:9-20 vs :46-57).
|
||||||
local edition = GameVersion.forSha1(manifest.romSha1) or "gold"
|
local edition = GameVersion.forSha1(manifest.romSha1) or "gold"
|
||||||
|
local symbols = manifest.symbols
|
||||||
|
local revision = romSha1 and manifest.symbolRevisions
|
||||||
|
and manifest.symbolRevisions[romSha1]
|
||||||
|
if revision then
|
||||||
|
local merged = {}
|
||||||
|
for name, location in pairs(manifest.symbols) do merged[name] = location end
|
||||||
|
for name, location in pairs(revision) do merged[name] = location end
|
||||||
|
symbols = merged
|
||||||
|
end
|
||||||
return setmetatable({
|
return setmetatable({
|
||||||
rom = Rom.new(romData),
|
rom = Rom.new(romData),
|
||||||
manifest = manifest,
|
manifest = manifest,
|
||||||
symbols = manifest.symbols,
|
symbols = symbols,
|
||||||
progress = progress,
|
progress = progress,
|
||||||
stage = 0,
|
stage = 0,
|
||||||
edition = edition,
|
edition = edition,
|
||||||
@@ -2289,6 +2308,8 @@ function RomExtractorGen2:extractTitle()
|
|||||||
trailBobAmplitude = silver and 3 or 2,
|
trailBobAmplitude = silver and 3 or 2,
|
||||||
trailPhaseStep = silver and 7 or 3,
|
trailPhaseStep = silver and 7 or 3,
|
||||||
trailPhase = silver and 0 or nil,
|
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)
|
self:write("title", data)
|
||||||
return data
|
return data
|
||||||
@@ -2799,6 +2820,8 @@ end
|
|||||||
-- is which alongside the text lets a caller fill them in order without
|
-- is which alongside the text lets a caller fill them in order without
|
||||||
-- changing a marker every screen already reads.
|
-- changing a marker every screen already reads.
|
||||||
function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers)
|
function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers)
|
||||||
|
local textBuffers = (self.edition == "crystal") and TEXT_BUFFERS_CRYSTAL
|
||||||
|
or TEXT_BUFFERS
|
||||||
local out = {}
|
local out = {}
|
||||||
local i = 0
|
local i = 0
|
||||||
local hops = 0
|
local hops = 0
|
||||||
@@ -2834,7 +2857,7 @@ function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers)
|
|||||||
out[#out + 1] = "{STRBUF}"
|
out[#out + 1] = "{STRBUF}"
|
||||||
if buffers then
|
if buffers then
|
||||||
local target = self.rom:word(bank, address + i + 1)
|
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
|
end
|
||||||
i = i + 2
|
i = i + 2
|
||||||
elseif b == 0x4e or b == 0x4f then
|
elseif b == 0x4e or b == 0x4f then
|
||||||
@@ -2843,7 +2866,10 @@ function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers)
|
|||||||
out[#out + 1] = "\f"
|
out[#out + 1] = "\f"
|
||||||
elseif b == 0x55 then
|
elseif b == 0x55 then
|
||||||
out[#out + 1] = "\v"
|
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}"
|
out[#out + 1] = "{PLAYER}"
|
||||||
elseif b == 0x53 then
|
elseif b == 0x53 then
|
||||||
out[#out + 1] = "{RIVAL}"
|
out[#out + 1] = "{RIVAL}"
|
||||||
@@ -5707,8 +5733,8 @@ function RomExtractorGen2:extractMenuGfx()
|
|||||||
|
|
||||||
-- Four OAM tiles at $31 -- normal, statused, fainted, empty -- and OBJ
|
-- Four OAM tiles at $31 -- normal, statused, fainted, empty -- and OBJ
|
||||||
-- colour 0 is transparent (engine/battle/trainer_huds.asm:47-99, :225-232).
|
-- colour 0 is transparent (engine/battle/trainer_huds.asm:47-99, :225-232).
|
||||||
local balls = self.symbols["LoadBallIconGFX.gfx"] or BALL_ICON_GFX
|
local balls = self:symbol("LoadBallIconGFX.gfx")
|
||||||
self:write2bpp(self.rom:bytes(balls[1], balls[2], 4 * 16), 32, 8,
|
self:write2bpp(self.rom:bytes(balls.bank, balls.address, 4 * 16), 32, 8,
|
||||||
"battle/hud/balls.png", true)
|
"battle/hud/balls.png", true)
|
||||||
hud.balls = "assets/generated/battle/hud/balls.png"
|
hud.balls = "assets/generated/battle/hud/balls.png"
|
||||||
hud.ballsFirstTile = 0x31
|
hud.ballsFirstTile = 0x31
|
||||||
|
|||||||
+232
-21
@@ -9,6 +9,9 @@ local SafeArea = require("src.core.SafeArea")
|
|||||||
local RomImporter = {}
|
local RomImporter = {}
|
||||||
RomImporter.__index = 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
|
-- 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
|
-- 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
|
-- mobile/ios/patch_love_src.py). A build without it must fall back to the
|
||||||
@@ -1390,8 +1393,9 @@ function RomImporter.new(onComplete, opts)
|
|||||||
-- player's index list from options; findIndex is the merged listing;
|
-- player's index list from options; findIndex is the merged listing;
|
||||||
-- _findThumbs caches one image per mod id (false = fetched and failed).
|
-- _findThumbs caches one image per mod id (false = fetched and failed).
|
||||||
findLoaded = false, findSources = nil, findIndex = nil,
|
findLoaded = false, findSources = nil, findIndex = nil,
|
||||||
findScroll = 0, findNotice = nil, findQuery = "", findCategory = nil,
|
findInstalled = nil, findScroll = 0, findNotice = nil, findQuery = "",
|
||||||
_findSearchFocus = false, _findThumbs = nil,
|
findCategory = nil, _findSearchFocus = false, _findThumbs = nil,
|
||||||
|
_findVisibleEntries = nil,
|
||||||
-- Which half of the feed the panel is browsing. Mods by default: carts
|
-- Which half of the feed the panel is browsing. Mods by default: carts
|
||||||
-- are the newer, much shorter list, and a feed may carry none at all.
|
-- are the newer, much shorter list, and a feed may carry none at all.
|
||||||
findKind = "mods", findBase = nil,
|
findKind = "mods", findBase = nil,
|
||||||
@@ -1432,7 +1436,7 @@ function RomImporter.new(onComplete, opts)
|
|||||||
-- "update required" (re-import) rather than a clean first-run choose
|
-- "update required" (re-import) rather than a clean first-run choose
|
||||||
local marker = CacheContract.readMarker(version, CacheFs)
|
local marker = CacheContract.readMarker(version, CacheFs)
|
||||||
self.returning[version] =
|
self.returning[version] =
|
||||||
(not ready) and marker ~= nil and marker ~= CacheContract.markerFor(version)
|
(not ready) and marker ~= nil and not CacheContract.markerMatches(version, marker)
|
||||||
self.romName[version] = "pokemon_" .. info.id
|
self.romName[version] = "pokemon_" .. info.id
|
||||||
.. ((info.id == "yellow" or GameVersion.generation(version) == 2)
|
.. ((info.id == "yellow" or GameVersion.generation(version) == 2)
|
||||||
and ".gbc" or ".gb")
|
and ".gbc" or ".gb")
|
||||||
@@ -1581,8 +1585,11 @@ function RomImporter:focus(f)
|
|||||||
.. love.filesystem.getSaveDirectory()
|
.. love.filesystem.getSaveDirectory()
|
||||||
local legacyRequiredPick = self.requiredImportLegacyRomPick
|
local legacyRequiredPick = self.requiredImportLegacyRomPick
|
||||||
and self.pickerPendingKind == "required_import"
|
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("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
|
or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then
|
||||||
self.modNotice = { ok = false, text = text }
|
self.modNotice = { ok = false, text = text }
|
||||||
self.pickerPendingKind = nil
|
self.pickerPendingKind = nil
|
||||||
@@ -1605,6 +1612,22 @@ function RomImporter:focus(f)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
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)
|
local requiredName = findPendingRequiredImport(self)
|
||||||
if requiredName then
|
if requiredName then
|
||||||
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
|
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
|
||||||
@@ -1721,6 +1744,7 @@ function RomImporter:startData(data, displayName)
|
|||||||
.. "(tagged [b] or [BF]) never verify."):format(actualHash, cartsProse()))
|
.. "(tagged [b] or [BF]) never verify."):format(actualHash, cartsProse()))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
self.romSha1 = actualHash
|
||||||
local info = GameVersion.info(version)
|
local info = GameVersion.info(version)
|
||||||
|
|
||||||
-- Bring the launcher to this version's tab so its progress bar is on screen
|
-- Bring the launcher to this version's tab so its progress bar is on screen
|
||||||
@@ -1775,7 +1799,7 @@ function RomImporter:_startExtractThread(version, prefix, data, displayName)
|
|||||||
love.thread.getChannel(progressName):clear()
|
love.thread.getChannel(progressName):clear()
|
||||||
love.thread.getChannel(resultName):clear()
|
love.thread.getChannel(resultName):clear()
|
||||||
local started = pcall(thread.start, thread, version, prefix, data,
|
local started = pcall(thread.start, thread, version, prefix, data,
|
||||||
progressName, resultName)
|
progressName, resultName, self.romSha1)
|
||||||
if not started then return false end
|
if not started then return false end
|
||||||
self._extract = {
|
self._extract = {
|
||||||
thread = thread, version = version, prefix = prefix,
|
thread = thread, version = version, prefix = prefix,
|
||||||
@@ -1804,7 +1828,7 @@ function RomImporter:_startExtractCoroutine(version, info, prefix, displayName)
|
|||||||
self.stageCurrent = current
|
self.stageCurrent = current
|
||||||
self.stageTotal = stageTotal
|
self.stageTotal = stageTotal
|
||||||
coroutine.yield()
|
coroutine.yield()
|
||||||
end)
|
end, self.romSha1)
|
||||||
extractor:run()
|
extractor:run()
|
||||||
CacheFs.prefix = "" -- restore the default so later writes stay at the root
|
CacheFs.prefix = "" -- restore the default so later writes stay at the root
|
||||||
self.romData = nil
|
self.romData = nil
|
||||||
@@ -1822,7 +1846,7 @@ function RomImporter:_completeImport(version, prefix, displayName)
|
|||||||
-- appear once every required file is in place.
|
-- appear once every required file is in place.
|
||||||
local savedPrefix = CacheFs.prefix
|
local savedPrefix = CacheFs.prefix
|
||||||
CacheFs.prefix = prefix
|
CacheFs.prefix = prefix
|
||||||
local ok, writeError = CacheContract.publish(version, CacheFs)
|
local ok, writeError = CacheContract.publish(version, CacheFs, self.romSha1)
|
||||||
CacheFs.prefix = savedPrefix
|
CacheFs.prefix = savedPrefix
|
||||||
if not ok then
|
if not ok then
|
||||||
error("could not finish the private cache: " .. tostring(writeError))
|
error("could not finish the private cache: " .. tostring(writeError))
|
||||||
@@ -2057,6 +2081,122 @@ local function requiredImportNotice(self, modId, importId, text)
|
|||||||
}
|
}
|
||||||
end
|
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)
|
function RomImporter:_importRequiredData(modId, importId, data)
|
||||||
local manifest = requiredManifest(self, modId)
|
local manifest = requiredManifest(self, modId)
|
||||||
if not manifest then
|
if not manifest then
|
||||||
@@ -2216,7 +2356,15 @@ function RomImporter:chooseRequiredImport(modId, importId)
|
|||||||
self.pickerPendingModId = modId
|
self.pickerPendingModId = modId
|
||||||
self.pickerPendingImportId = importId
|
self.pickerPendingImportId = importId
|
||||||
self.requiredImportLegacyRomPick = legacyAndroidPicker or nil
|
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.pickerPendingKind = nil
|
||||||
self.pickerPendingModId = nil
|
self.pickerPendingModId = nil
|
||||||
self.pickerPendingImportId = nil
|
self.pickerPendingImportId = nil
|
||||||
@@ -2547,6 +2695,8 @@ function RomImporter:_pollPickedFiles(dt)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
local found = love.filesystem.getInfo("export_done.flag", "file") ~= nil
|
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
|
if not found then
|
||||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||||
local n = name:lower()
|
local n = name:lower()
|
||||||
@@ -2589,6 +2739,7 @@ function RomImporter:update(dt)
|
|||||||
-- before a tab switch still completes.
|
-- before a tab switch still completes.
|
||||||
self:_pumpFindFetch()
|
self:_pumpFindFetch()
|
||||||
self:_pumpModInfoFetch()
|
self:_pumpModInfoFetch()
|
||||||
|
self:_queueFindEnrichment()
|
||||||
self:_pumpFindStats()
|
self:_pumpFindStats()
|
||||||
self:_pumpFindThumbs()
|
self:_pumpFindThumbs()
|
||||||
self:_pumpSkinFetch()
|
self:_pumpSkinFetch()
|
||||||
@@ -3226,6 +3377,7 @@ end
|
|||||||
-- offset persists inside the view's per-tab scroll container.
|
-- offset persists inside the view's per-tab scroll container.
|
||||||
function RomImporter:_switchTab(id)
|
function RomImporter:_switchTab(id)
|
||||||
self.tab = id
|
self.tab = id
|
||||||
|
if id ~= "find" then self._findVisibleEntries = nil end
|
||||||
self._findSearchFocus = false
|
self._findSearchFocus = false
|
||||||
self._skinUrlFocus = false
|
self._skinUrlFocus = false
|
||||||
self:_disarmTextInput()
|
self:_disarmTextInput()
|
||||||
@@ -3823,6 +3975,7 @@ function RomImporter:_toggleSafeMode()
|
|||||||
SaveData.saveOptions(options)
|
SaveData.saveOptions(options)
|
||||||
self.safeMode = enabled
|
self.safeMode = enabled
|
||||||
self.mods = nil
|
self.mods = nil
|
||||||
|
self.findInstalled = nil
|
||||||
self._modSortCache = nil
|
self._modSortCache = nil
|
||||||
self._modInfoFetch = nil
|
self._modInfoFetch = nil
|
||||||
self.modNotice = nil
|
self.modNotice = nil
|
||||||
@@ -4798,6 +4951,8 @@ function RomImporter:_refreshMods()
|
|||||||
local LauncherMods = require("src.mods.LauncherMods")
|
local LauncherMods = require("src.mods.LauncherMods")
|
||||||
local SaveData = require("src.core.SaveData")
|
local SaveData = require("src.core.SaveData")
|
||||||
self._cartPlan = nil
|
self._cartPlan = nil
|
||||||
|
self._profileCache = nil
|
||||||
|
self.findInstalled = nil
|
||||||
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
|
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
|
||||||
-- Once per session, ahead of the first listing: pull in any mod the player
|
-- Once per session, ahead of the first listing: pull in any mod the player
|
||||||
-- unzipped beside the executable, which an ordinary (non-portable) install
|
-- unzipped beside the executable, which an ordinary (non-portable) install
|
||||||
@@ -5718,7 +5873,8 @@ end
|
|||||||
-- as long as the slowest index took -- measured at over two minutes on a
|
-- as long as the slowest index took -- measured at over two minutes on a
|
||||||
-- cold open, with no spinner, because the frame that would have drawn one
|
-- cold open, with no spinner, because the frame that would have drawn one
|
||||||
-- never ran. The fetch now starts here and completes across later frames in
|
-- never ran. The fetch now starts here and completes across later frames in
|
||||||
-- _pumpFindFetch; the loader overlay is up for the whole flight.
|
-- _pumpFindFetch. Only an explicit Refresh is blocking; boot prewarm and the
|
||||||
|
-- first visit keep the launcher interactive while the listing arrives.
|
||||||
function RomImporter:_refreshFind(force)
|
function RomImporter:_refreshFind(force)
|
||||||
-- The notice is the fix, not the gate (#876). This branch used to return an
|
-- The notice is the fix, not the gate (#876). This branch used to return an
|
||||||
-- empty listing silently, and because the player had by then added a source,
|
-- empty listing silently, and because the player had by then added a source,
|
||||||
@@ -5756,9 +5912,11 @@ function RomImporter:_refreshFind(force)
|
|||||||
carts = {}, cartSeen = {}, bases = {}, baseSeen = {},
|
carts = {}, cartSeen = {}, bases = {}, baseSeen = {},
|
||||||
stale = false, oldest = nil, at = 1,
|
stale = false, oldest = nil, at = 1,
|
||||||
}
|
}
|
||||||
self:_setBusy(Strings("Fetching mod index"),
|
if force == true then
|
||||||
#sources == 1 and (sources[1].label or sources[1].feed)
|
self:_setBusy(Strings("Fetching mod index"),
|
||||||
or Strings("%d indexes", #sources))
|
#sources == 1 and (sources[1].label or sources[1].feed)
|
||||||
|
or Strings("%d indexes", #sources))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Drive the in-flight index fetch one frame at a time. Called from update().
|
-- Drive the in-flight index fetch one frame at a time. Called from update().
|
||||||
@@ -5958,9 +6116,17 @@ end
|
|||||||
|
|
||||||
function RomImporter:_findInstalledMap()
|
function RomImporter:_findInstalledMap()
|
||||||
if self:findingCarts() then return self:_findInstalledCarts() end
|
if self:findingCarts() then return self:_findInstalledCarts() end
|
||||||
local map = {}
|
if self.findInstalled then return self.findInstalled end
|
||||||
for _, m in ipairs(self.mods or {}) do map[m.id] = m.version or true end
|
local LauncherMods = require("src.mods.LauncherMods")
|
||||||
return map
|
if self.mods then
|
||||||
|
self.findInstalled = {}
|
||||||
|
for _, m in ipairs(self.mods) do
|
||||||
|
self.findInstalled[m.id] = m.version or true
|
||||||
|
end
|
||||||
|
else
|
||||||
|
self.findInstalled = LauncherMods.installedVersions() or {}
|
||||||
|
end
|
||||||
|
return self.findInstalled
|
||||||
end
|
end
|
||||||
|
|
||||||
-- id -> installed version for every cart on disk, whatever game it plays as.
|
-- id -> installed version for every cart on disk, whatever game it plays as.
|
||||||
@@ -5978,10 +6144,9 @@ function RomImporter:_findInstalledCarts()
|
|||||||
return map
|
return map
|
||||||
end
|
end
|
||||||
|
|
||||||
-- One thumbnail per frame, and only for a card actually on screen: the fetch
|
-- Read the cached thumbnail for a card. Starting a download is deliberately
|
||||||
-- is a blocking curl, so downloading a whole listing's worth on open would
|
-- separate: immediate-mode draw may call this for every visible row, but it
|
||||||
-- stall the launcher for as many seconds as there are mods. A failure is
|
-- must not mutate the fetch queue or perform network work.
|
||||||
-- remembered as `false` so a broken URL is tried once, not every frame.
|
|
||||||
function RomImporter:_findThumb(entry)
|
function RomImporter:_findThumb(entry)
|
||||||
self._findThumbs = self._findThumbs or {}
|
self._findThumbs = self._findThumbs or {}
|
||||||
local cached = self._findThumbs[entry.id]
|
local cached = self._findThumbs[entry.id]
|
||||||
@@ -5992,6 +6157,20 @@ function RomImporter:_findThumb(entry)
|
|||||||
self._findThumbs[entry.id] = false
|
self._findThumbs[entry.id] = false
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Queue one thumbnail after draw has recorded the visible rows. The fetch
|
||||||
|
-- pool runs off-thread; only the finished image decode stays in update().
|
||||||
|
function RomImporter:_startFindThumb(entry)
|
||||||
|
self._findThumbs = self._findThumbs or {}
|
||||||
|
if self._findThumbs[entry.id] ~= nil then return end
|
||||||
|
local ModIndex = require("src.mods.ModIndex")
|
||||||
|
local url = ModIndex.joinUrl(entry._base, entry.thumbnail)
|
||||||
|
if not url then
|
||||||
|
self._findThumbs[entry.id] = false
|
||||||
|
return
|
||||||
|
end
|
||||||
-- ASYNC (was one blocking download per frame). Only rows on the current
|
-- ASYNC (was one blocking download per frame). Only rows on the current
|
||||||
-- page ever ask, so pagination already bounds this to a page's worth of
|
-- page ever ask, so pagination already bounds this to a page's worth of
|
||||||
-- requests; the fetch pool runs them off-thread and the card shows its
|
-- requests; the fetch pool runs them off-thread and the card shows its
|
||||||
@@ -6112,9 +6291,9 @@ function RomImporter:_requestFindStats(entry)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Request-and-read, for a row that is being drawn and for the detail modal.
|
-- Read-only accessor for a row being drawn or shown in the detail modal.
|
||||||
|
-- Network work is scheduled by _queueFindEnrichment from update().
|
||||||
function RomImporter:_findStats(entry)
|
function RomImporter:_findStats(entry)
|
||||||
self:_requestFindStats(entry)
|
|
||||||
return self:_findStatsCached(entry)
|
return self:_findStatsCached(entry)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -6134,6 +6313,38 @@ function RomImporter:_findThumbPending(id)
|
|||||||
return (self._findThumbFetch and self._findThumbFetch[id]) ~= nil
|
return (self._findThumbFetch and self._findThumbFetch[id]) ~= nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Draw records the visible page in _findVisibleEntries. Queue only a small
|
||||||
|
-- batch from that snapshot during update(), keeping network scheduling out of
|
||||||
|
-- the immediate-mode render path and preventing a large index from creating a
|
||||||
|
-- burst of thumbnail/GitHub work in one frame.
|
||||||
|
local FIND_ENRICH_PER_FRAME = 2
|
||||||
|
|
||||||
|
function RomImporter:_queueFindEnrichment()
|
||||||
|
if self.tab ~= "find" or not self.findLoaded then return end
|
||||||
|
local visible = self._findVisibleEntries
|
||||||
|
if not visible then return end
|
||||||
|
local thumbnails, stats = 0, 0
|
||||||
|
for _, entry in ipairs(visible) do
|
||||||
|
if thumbnails < FIND_ENRICH_PER_FRAME
|
||||||
|
and self:_findThumb(entry) == nil
|
||||||
|
and not self:_findThumbPending(entry.id) then
|
||||||
|
self:_startFindThumb(entry)
|
||||||
|
thumbnails = thumbnails + 1
|
||||||
|
end
|
||||||
|
if stats < FIND_ENRICH_PER_FRAME
|
||||||
|
and self:_findStatsCached(entry) == nil
|
||||||
|
and entry.github and entry.github ~= ""
|
||||||
|
and not self:_findStatsPendingFor(entry.id) then
|
||||||
|
self:_requestFindStats(entry)
|
||||||
|
stats = stats + 1
|
||||||
|
end
|
||||||
|
if thumbnails >= FIND_ENRICH_PER_FRAME
|
||||||
|
and stats >= FIND_ENRICH_PER_FRAME then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Drive in-flight FIND MODS stats lookups. Called from update().
|
-- Drive in-flight FIND MODS stats lookups. Called from update().
|
||||||
function RomImporter:_pumpFindStats()
|
function RomImporter:_pumpFindStats()
|
||||||
local pending = self._findStatsPending
|
local pending = self._findStatsPending
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ function RomManifest.decode(version)
|
|||||||
if not manifest then
|
if not manifest then
|
||||||
error("ROM import metadata is invalid: " .. tostring(decodeError))
|
error("ROM import metadata is invalid: " .. tostring(decodeError))
|
||||||
end
|
end
|
||||||
assert(manifest.romSha1 == info.sha1, "ROM import metadata version mismatch")
|
assert(GameVersion.acceptsSha1(version, manifest.romSha1),
|
||||||
|
"ROM import metadata version mismatch")
|
||||||
return manifest
|
return manifest
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,14 @@ local function adjacentSleepingSnorlax(save, ow)
|
|||||||
return nil
|
return nil
|
||||||
end
|
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
|
-- Use an item on a target party mon (target may be nil for targetless
|
||||||
-- items). data = generated data tables; battle = BattleState when used
|
-- items). data = generated data tables; battle = BattleState when used
|
||||||
-- mid-battle; ow = the overworld (OverworldState), needed only to check
|
-- 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!") }
|
"All sleeping\nPOKéMON woke up!") }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- battle-only items
|
-- battle-only items (PrintItemUseTextAndRemoveItem + Heal_Ailment, #1635)
|
||||||
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
|
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
|
||||||
or itemId == "POKE_DOLL" then
|
or itemId == "POKE_DOLL" then
|
||||||
if not battle then
|
if not battle then
|
||||||
@@ -293,43 +301,53 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
require("src.world.PikachuFollower")
|
require("src.world.PikachuFollower")
|
||||||
.modifyHappiness(save, "USEDXITEM", b and b.mon)
|
.modifyHappiness(save, "USEDXITEM", b and b.mon)
|
||||||
end
|
end
|
||||||
|
local used = itemUseLine(data, save, name)
|
||||||
if itemId == "X_ACCURACY" then
|
if itemId == "X_ACCURACY" then
|
||||||
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
|
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
|
||||||
-- (not an accuracy stage)
|
-- (not an accuracy stage); vanilla prints only the used line
|
||||||
b.xAccuracy = true
|
b.xAccuracy = true
|
||||||
return "consumed", { Strings("%s's\nhits will never\nmiss!", b.name) }
|
return "consumed", { used }, USE_JINGLE
|
||||||
end
|
end
|
||||||
if X_ITEMS[itemId] then
|
if X_ITEMS[itemId] then
|
||||||
local stat = X_ITEMS[itemId]
|
local stat = X_ITEMS[itemId]
|
||||||
local cur = b.stages[stat] or 0
|
local cur = b.stages[stat] or 0
|
||||||
-- ItemUseXStat removes the item BEFORE running the stat-up
|
-- ItemUseXStat: PrintItemUseTextAndRemoveItem, then StatModifierUpEffect
|
||||||
-- effect, so at +6 it is still consumed and StatModifierUpEffect
|
|
||||||
-- just prints "Nothing happened!"
|
|
||||||
if cur >= 6 then
|
if cur >= 6 then
|
||||||
return "consumed", { romText(data, "_NothingHappenedText",
|
return "consumed", { used }, {
|
||||||
"Nothing happened!") }
|
useJingle = true,
|
||||||
|
afterMessages = { romText(data, "_NothingHappenedText",
|
||||||
|
"Nothing happened!") },
|
||||||
|
}
|
||||||
end
|
end
|
||||||
b.stages[stat] = cur + 1
|
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
|
end
|
||||||
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
|
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
|
||||||
-- the item, even when it is already active
|
-- the item, even when it is already active; vanilla prints only used
|
||||||
if itemId == "DIRE_HIT" then
|
if itemId == "DIRE_HIT" then
|
||||||
b.focusEnergy = true
|
b.focusEnergy = true
|
||||||
return "consumed", { romText(data, "_GettingPumpedText",
|
return "consumed", { used }, USE_JINGLE
|
||||||
"%s's\ngetting pumped!", b.name) }
|
|
||||||
end
|
end
|
||||||
if itemId == "GUARD_SPEC" then
|
if itemId == "GUARD_SPEC" then
|
||||||
b.mist = true
|
b.mist = true
|
||||||
return "consumed", { Strings("%s's\nprotected against\nstat changes!", b.name) }
|
return "consumed", { used }, USE_JINGLE
|
||||||
end
|
end
|
||||||
if itemId == "POKE_DOLL" then
|
if itemId == "POKE_DOLL" then
|
||||||
if battle.kind ~= "wild" then
|
if battle.kind ~= "wild" then
|
||||||
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
|
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
|
||||||
return "failed", { notTime(data, save) }
|
return "failed", { notTime(data, save) }
|
||||||
end
|
end
|
||||||
return "consumed_escape", { romText(data, "_WildRanText",
|
-- PrintItemUseTextAndRemoveItem then escape
|
||||||
"The wild POKéMON\nran away!", battle.enemy and battle.enemy.name) }
|
return "consumed_escape", { used }, USE_JINGLE
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -608,7 +626,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
if REPELS[itemId] then
|
if REPELS[itemId] then
|
||||||
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
|
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
|
||||||
save.repelSteps = steps
|
save.repelSteps = steps
|
||||||
return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
|
return "consumed", { itemUseLine(data, save, name) }, USE_JINGLE
|
||||||
end
|
end
|
||||||
|
|
||||||
return "failed", { notTime(data, save) }
|
return "failed", { notTime(data, save) }
|
||||||
|
|||||||
@@ -163,8 +163,8 @@ end
|
|||||||
|
|
||||||
-- a module the build dropped disables its registry rather than the game:
|
-- a module the build dropped disables its registry rather than the game:
|
||||||
-- the consumer still reads its own table, so vanilla keeps working
|
-- the consumer still reads its own table, so vanilla keeps working
|
||||||
local function load(path)
|
local function load(path, moduleLoader)
|
||||||
local ok, module = pcall(require, path)
|
local ok, module = pcall(moduleLoader or require, path)
|
||||||
if ok then return module end
|
if ok then return module end
|
||||||
Logger.warn("builtin registrations skipped for %s (%s)", path, tostring(module))
|
Logger.warn("builtin registrations skipped for %s (%s)", path, tostring(module))
|
||||||
return nil
|
return nil
|
||||||
@@ -187,14 +187,14 @@ local function isolate(registry)
|
|||||||
}, { __index = registry })
|
}, { __index = registry })
|
||||||
end
|
end
|
||||||
|
|
||||||
function Builtins.install(content, data, generation)
|
function Builtins.install(content, data, generation, moduleLoader)
|
||||||
for _, entry in ipairs(registrantsFor(generation)) do
|
for _, entry in ipairs(registrantsFor(generation)) do
|
||||||
local registry = content[entry.name] and isolate(content[entry.name])
|
local registry = content[entry.name] and isolate(content[entry.name])
|
||||||
if registry then
|
if registry then
|
||||||
if entry.install then
|
if entry.install then
|
||||||
local modules, complete = {}, true
|
local modules, complete = {}, true
|
||||||
for i, path in ipairs(entry.modules) do
|
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
|
if modules[i] == nil then complete = false end
|
||||||
end
|
end
|
||||||
-- data is the fourth argument, not the second, so the existing
|
-- 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)
|
entry.install(registry, modules, Builtins.OWNER, data)
|
||||||
end
|
end
|
||||||
else
|
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
|
-- 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
|
-- registry (Gold's Battle owns statuses and move_effects); the default
|
||||||
-- is the registerInto every single-registry module exposes
|
-- 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
|
||||||
@@ -471,6 +471,20 @@ discover = function()
|
|||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- installedVersions() -> id -> installed version. MOD INDEX only needs to
|
||||||
|
-- know whether a listing is already present; it does not need enablement,
|
||||||
|
-- dependency/conflict status, required-import validation, or migration. Keep
|
||||||
|
-- that cheap read separate from list(), whose richer work belongs to MODS.
|
||||||
|
function LauncherMods.installedVersions()
|
||||||
|
local out = {}
|
||||||
|
local ok, manifests = pcall(discover)
|
||||||
|
if not ok then return out end
|
||||||
|
for _, manifest in ipairs(manifests or {}) do
|
||||||
|
out[manifest.id] = manifest.version or true
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
-- list([version]) -> the mods-panel rows for the current install. Reads the
|
-- list([version]) -> the mods-panel rows for the current install. Reads the
|
||||||
-- same enable-state the loader persists, so a toggle here is what the game
|
-- same enable-state the loader persists, so a toggle here is what the game
|
||||||
-- sees on its next boot; `version` narrows that to one game's answers.
|
-- sees on its next boot; `version` narrows that to one game's answers.
|
||||||
|
|||||||
+23
-2
@@ -147,12 +147,19 @@ local SUPPORTED_REQUIRES = {
|
|||||||
local ENGINE_PREFIX = (debug.getinfo(1, "S").source or "")
|
local ENGINE_PREFIX = (debug.getinfo(1, "S").source or "")
|
||||||
:gsub("^@", ""):gsub("mods[/\\]Loader%.lua$", "")
|
:gsub("^@", ""):gsub("mods[/\\]Loader%.lua$", "")
|
||||||
|
|
||||||
|
local ENGINE_CHUNKS = {
|
||||||
|
["main.lua"] = true,
|
||||||
|
["conf.lua"] = true,
|
||||||
|
}
|
||||||
|
|
||||||
local function callerIsMod(level)
|
local function callerIsMod(level)
|
||||||
if ENGINE_PREFIX == "" then return false end
|
if ENGINE_PREFIX == "" then return false end
|
||||||
local info = debug.getinfo(level, "S")
|
local info = debug.getinfo(level, "S")
|
||||||
local source = info and info.source
|
local source = info and info.source
|
||||||
if not source or source:sub(1, 1) ~= "@" then return false end
|
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
|
end
|
||||||
|
|
||||||
local function scanRequire(name)
|
local function scanRequire(name)
|
||||||
@@ -207,6 +214,11 @@ local function engineRequire(name)
|
|||||||
return module
|
return module
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Loader.endSession()
|
||||||
|
devShim.generation = nil
|
||||||
|
devShim.errors = nil
|
||||||
|
end
|
||||||
|
|
||||||
function Loader:_installDevShim()
|
function Loader:_installDevShim()
|
||||||
for id, mod in pairs(self.mods) do
|
for id, mod in pairs(self.mods) do
|
||||||
devShim.permissions[id] = mod.manifest.permissionSet
|
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.
|
-- 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
|
-- Engine code keeps the real module: src/render/PaletteFX.lua:776
|
||||||
-- requires src.core.Game on both generations and means it.
|
-- 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
|
and (owner or callerIsMod(3)) then
|
||||||
local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
|
local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
|
||||||
if adapter then
|
if adapter then
|
||||||
@@ -1154,6 +1166,15 @@ function Loader:_api(mod)
|
|||||||
developer = loader.dev == true,
|
developer = loader.dev == true,
|
||||||
-- a deep copy: what a mod does to its own view never reaches the loader
|
-- a deep copy: what a mod does to its own view never reaches the loader
|
||||||
manifest = Merge.deepCopy(mod.manifest),
|
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 = {},
|
content = {},
|
||||||
exports = {},
|
exports = {},
|
||||||
DELETE = Registry.DELETE,
|
DELETE = Registry.DELETE,
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ Registry.__index = Registry
|
|||||||
-- exposed to mods as mod.DELETE: a patch value that unsets a field
|
-- exposed to mods as mod.DELETE: a patch value that unsets a field
|
||||||
Registry.DELETE = Merge.DELETE
|
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
|
-- spec comes from Schemas.REGISTRIES[name]; bare Registry.new(name) keeps
|
||||||
-- the v1 record behavior for standalone use in tests and tools
|
-- the v1 record behavior for standalone use in tests and tools
|
||||||
function Registry.new(name, spec)
|
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")
|
error(self.name .. ": content is frozen after load")
|
||||||
end
|
end
|
||||||
assert(type(id) == "string" and id ~= "", self.name .. " id is required")
|
assert(type(id) == "string" and id ~= "", self.name .. " id is required")
|
||||||
|
assertWritable(self, id)
|
||||||
local list = self.ops[id]
|
local list = self.ops[id]
|
||||||
if not list then
|
if not list then
|
||||||
list = {}
|
list = {}
|
||||||
|
|||||||
+37
-3
@@ -676,9 +676,10 @@ end
|
|||||||
--
|
--
|
||||||
-- So beside `value` / `fields` / `keys` / `keyValue` a spec may carry
|
-- So beside `value` / `fields` / `keys` / `keyValue` a spec may carry
|
||||||
-- `gen2Value` / `gen2Fields` / `gen2Keys` / `gen2KeyValue`, and beside
|
-- `gen2Value` / `gen2Fields` / `gen2Keys` / `gen2KeyValue`, and beside
|
||||||
-- `semantics` / `extra` / `write` / `baseAt` / `baseIds` / `example` /
|
-- `semantics` / `extra` / `write` / `baseAt` / `baseIds` / `reservedIds` /
|
||||||
-- `notes` the matching `gen2*`. Absent means "the Gen 1 shape is right here
|
-- `example` / `notes` the matching `gen2*`. Absent means "the Gen 1 shape is
|
||||||
-- too", which is the common case and why most registries carry none of this.
|
-- 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
|
-- The registry NAME, the verbs and (wherever the id space allows it) the ids
|
||||||
-- stay shared, exactly as the routing table keeps them shared.
|
-- stay shared, exactly as the routing table keeps them shared.
|
||||||
--
|
--
|
||||||
@@ -700,6 +701,7 @@ local GEN2_SHAPE = {
|
|||||||
gen2KeyValue = "keyValue", gen2Extra = "extra",
|
gen2KeyValue = "keyValue", gen2Extra = "extra",
|
||||||
gen2Semantics = "semantics", gen2Write = "write",
|
gen2Semantics = "semantics", gen2Write = "write",
|
||||||
gen2BaseAt = "baseAt", gen2BaseIds = "baseIds",
|
gen2BaseAt = "baseAt", gen2BaseIds = "baseIds",
|
||||||
|
gen2ReservedIds = "reservedIds",
|
||||||
gen2Example = "example", gen2Notes = "notes",
|
gen2Example = "example", gen2Notes = "notes",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,6 +759,32 @@ end
|
|||||||
local R = {}
|
local R = {}
|
||||||
Schemas.REGISTRIES = 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
|
-- ------- shared Gen 2 leaves
|
||||||
--
|
--
|
||||||
-- The ROM name spaces Gold's tables key by. They are enums rather than
|
-- 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 = {
|
R.pokemon = {
|
||||||
semantics = "record", target = "pokemon",
|
semantics = "record", target = "pokemon",
|
||||||
|
gen2BaseAt = pokemonGen2BaseAt, gen2BaseIds = pokemonGen2BaseIds,
|
||||||
|
gen2ReservedIds = pokemonGen2ReservedIds,
|
||||||
fields = {
|
fields = {
|
||||||
id = f.str, name = f.str, dex = f.int(1),
|
id = f.str, name = f.str, dex = f.int(1),
|
||||||
index = f.opt(f.int(0, 255)),
|
index = f.opt(f.int(0, 255)),
|
||||||
@@ -870,6 +900,8 @@ R.pokemon = {
|
|||||||
|
|
||||||
R.moves = {
|
R.moves = {
|
||||||
semantics = "record", target = "moves",
|
semantics = "record", target = "moves",
|
||||||
|
gen2BaseAt = movesGen2BaseAt, gen2BaseIds = movesGen2BaseIds,
|
||||||
|
gen2ReservedIds = movesGen2ReservedIds,
|
||||||
fields = {
|
fields = {
|
||||||
id = f.str, name = f.str,
|
id = f.str, name = f.str,
|
||||||
index = f.opt(f.int(0, 255)),
|
index = f.opt(f.int(0, 255)),
|
||||||
@@ -894,6 +926,8 @@ R.moves = {
|
|||||||
|
|
||||||
R.items = {
|
R.items = {
|
||||||
semantics = "record", target = "items",
|
semantics = "record", target = "items",
|
||||||
|
gen2BaseAt = itemsGen2BaseAt, gen2BaseIds = itemsGen2BaseIds,
|
||||||
|
gen2ReservedIds = itemsGen2ReservedIds,
|
||||||
fields = {
|
fields = {
|
||||||
id = f.str, name = f.str,
|
id = f.str, name = f.str,
|
||||||
index = f.opt(f.int(0, 255)),
|
index = f.opt(f.int(0, 255)),
|
||||||
|
|||||||
+49
-13
@@ -20,6 +20,8 @@ local PaletteFX = {}
|
|||||||
local shader -- false = unavailable (headless / no shader support)
|
local shader -- false = unavailable (headless / no shader support)
|
||||||
local gbcPack -- false = missing; nil = not loaded yet
|
local gbcPack -- false = missing; nil = not loaded yet
|
||||||
local yellowPack -- 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
|
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
|
||||||
-- come first (OG RED/BLUE/YELLOW = GBC hardware, SGB = per-map Super Game Boy,
|
-- 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
|
return yellowPack or nil
|
||||||
end
|
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)
|
function PaletteFX.usesGbcPack(mode)
|
||||||
mode = mode or PaletteFX.mode
|
mode = mode or PaletteFX.mode
|
||||||
return mode == "redpp"
|
return mode == "redpp"
|
||||||
@@ -453,7 +487,10 @@ function PaletteFX.pal(data, name)
|
|||||||
if fromRom then return fromRom end
|
if fromRom then return fromRom end
|
||||||
end
|
end
|
||||||
if GameVersion.isYellow() then
|
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)
|
local fromCgb = yellowCgbNamedPal(data, name)
|
||||||
if fromCgb then return fromCgb end
|
if fromCgb then return fromCgb end
|
||||||
end
|
end
|
||||||
@@ -511,7 +548,8 @@ function PaletteFX.monPal(data, species, transformed)
|
|||||||
if pal then return pal end
|
if pal then return pal end
|
||||||
end
|
end
|
||||||
local name = p.pokemon[species] or "MEWMON"
|
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)
|
local yc = PaletteFX.pal(data, name)
|
||||||
if yc then return yc end
|
if yc then return yc end
|
||||||
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
|
-- (false for a mod tileset with no pokered-gbc counterpart, or when the
|
||||||
-- pack failed to load at all)
|
-- pack failed to load at all)
|
||||||
function PaletteFX.hasWorldTileset(tileset)
|
function PaletteFX.hasWorldTileset(tileset)
|
||||||
local pack = PaletteFX.gbcPack()
|
local w = PaletteFX.worldPack()
|
||||||
local w = pack and pack.world
|
|
||||||
return (w and w.tileGroups[tileset]) ~= nil
|
return (w and w.tileGroups[tileset]) ~= nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- the palette-group (0-7) a tile GRAPHIC id resolves to in this tileset,
|
-- 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
|
-- with the current map's tile-id exceptions (if any) applied first
|
||||||
function PaletteFX.worldGroupAt(tileset, mapId, tileId)
|
function PaletteFX.worldGroupAt(tileset, mapId, tileId)
|
||||||
local pack = PaletteFX.gbcPack()
|
local w = PaletteFX.worldPack()
|
||||||
local w = pack and pack.world
|
|
||||||
local groups = w and w.tileGroups[tileset]
|
local groups = w and w.tileGroups[tileset]
|
||||||
if not groups then return nil end
|
if not groups then return nil end
|
||||||
local exc = TILE_GROUP_EXCEPTIONS[mapId]
|
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,
|
-- 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)
|
-- like pokered's wYCoord check -- data is Game.data, for the map lookup)
|
||||||
function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
|
function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
|
||||||
local pack = PaletteFX.gbcPack()
|
local w = PaletteFX.worldPack()
|
||||||
local w = pack and pack.world
|
|
||||||
local base = w and w.groupColors[tileset]
|
local base = w and w.groupColors[tileset]
|
||||||
if not base then return nil end
|
if not base then return nil end
|
||||||
if not w.roofGroup[tileset] then return darkGroups(base) 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
|
-- here): a stable hash instead, so the same NPC instance always shows the
|
||||||
-- same one of the 4 SPR_PAL_* colors.
|
-- same one of the 4 SPR_PAL_* colors.
|
||||||
function PaletteFX.spriteObp(spriteDef, seed)
|
function PaletteFX.spriteObp(spriteDef, seed)
|
||||||
local pack = PaletteFX.gbcPack()
|
local w = PaletteFX.worldPack()
|
||||||
local w = pack and pack.world
|
|
||||||
local src = spriteDef and (spriteDef.paletteSource or spriteDef.source)
|
local src = spriteDef and (spriteDef.paletteSource or spriteDef.source)
|
||||||
if not (w and src) then return nil end
|
if not (w and src) then return nil end
|
||||||
local idx = tonumber(src:match("%[(%d+)%]"))
|
local idx = tonumber(src:match("%[(%d+)%]"))
|
||||||
-- RedBikeSprite and SurfingPikachuSprite load outside
|
-- RedBikeSprite and SurfingPikachuSprite load outside
|
||||||
-- SpriteSheetPointerTable, so their source has no bracketed index;
|
-- SpriteSheetPointerTable, so their source has no bracketed index;
|
||||||
-- they wear the player's OBP palette (spriteAssignment[0]).
|
-- they wear the player's OBP palette (spriteAssignment[0]) on Red/Blue.
|
||||||
if not idx and (src:find("RedBikeSprite", 1, true)
|
-- On Yellow, Surfing Pikachu uses the Pikachu yellow OBJ group (#1639).
|
||||||
or src:find("SurfingPikachuSprite", 1, true)) then
|
if not idx and src:find("RedBikeSprite", 1, true) then
|
||||||
idx = 0
|
idx = 0
|
||||||
|
elseif not idx and src:find("SurfingPikachuSprite", 1, true) then
|
||||||
|
idx = GameVersion.isYellow() and 60 or 0
|
||||||
end
|
end
|
||||||
local group = idx and w.spriteAssignment[idx]
|
local group = idx and w.spriteAssignment[idx]
|
||||||
if group == nil then return nil end
|
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
|
-- warp-fade overlay from Transition (issue #121); cleared each frame so
|
||||||
-- a popped transition cannot leave a sticky black veil
|
-- a popped transition cannot leave a sticky black veil
|
||||||
self.worldFadeAlpha = nil
|
self.worldFadeAlpha = nil
|
||||||
|
self.worldFadeColor = nil
|
||||||
-- battle-transition wipe, drawn over the whole surface (BattleTransition)
|
-- battle-transition wipe, drawn over the whole surface (BattleTransition)
|
||||||
self.battleWipe = nil
|
self.battleWipe = nil
|
||||||
-- whole-surface veil in screen space (battle-transition flash, the
|
-- 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
|
-- 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
|
-- the setting exists to offer. Clamped on the horizontal too, so a narrow
|
||||||
-- window scales to fit instead of overflowing off both sides.
|
-- 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)
|
Up = math.min(ph / uih, pw / uiw)
|
||||||
end
|
end
|
||||||
if uiw * Up > pw or uih * Up > ph then
|
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
|
-- the screen-space overlays the flat path draws over its composite
|
||||||
local fade = self.worldFadeAlpha
|
local fade = self.worldFadeAlpha
|
||||||
if fade and fade > 0 then
|
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.rectangle("fill", vux, vuy, vuw, vuh)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
@@ -1132,7 +1134,8 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
-- composite normally if one is ever stacked that way.
|
-- composite normally if one is ever stacked that way.
|
||||||
local fade = self.worldFadeAlpha
|
local fade = self.worldFadeAlpha
|
||||||
if fade and fade > 0 then
|
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.rectangle("fill", vux, vuy, vuw, vuh)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|||||||
+30
-6
@@ -264,7 +264,9 @@ ShaderFX.BRIDGE_DIR = "tools/shaderfx-bridge"
|
|||||||
local function libNames()
|
local function libNames()
|
||||||
local osName = (love and love.system and love.system.getOS
|
local osName = (love and love.system and love.system.getOS
|
||||||
and love.system.getOS()) or ""
|
and love.system.getOS()) or ""
|
||||||
if osName == "Windows" then
|
if osName == "iOS" then
|
||||||
|
return {}
|
||||||
|
elseif osName == "Windows" then
|
||||||
return { "librashader_bridge.dll" }
|
return { "librashader_bridge.dll" }
|
||||||
elseif osName == "OS X" then
|
elseif osName == "OS X" then
|
||||||
return { "liblibrashader_bridge.dylib", "librashader_bridge.dylib" }
|
return { "liblibrashader_bridge.dylib", "librashader_bridge.dylib" }
|
||||||
@@ -313,11 +315,13 @@ end
|
|||||||
|
|
||||||
-- Every place the bridge may sit, most specific first.
|
-- Every place the bridge may sit, most specific first.
|
||||||
local function libCandidates()
|
local function libCandidates()
|
||||||
|
local names = libNames()
|
||||||
|
if #names == 0 then return {} end
|
||||||
local out = {}
|
local out = {}
|
||||||
local override = os.getenv("LIBRASHADER_BRIDGE_DLL")
|
local override = os.getenv("LIBRASHADER_BRIDGE_DLL")
|
||||||
if override and override ~= "" then out[#out + 1] = override end
|
if override and override ~= "" then out[#out + 1] = override end
|
||||||
local dirs, save = sourceDirs(), saveDir()
|
local dirs, save = sourceDirs(), saveDir()
|
||||||
for _, name in ipairs(libNames()) do
|
for _, name in ipairs(names) do
|
||||||
for _, dir in ipairs(dirs) do
|
for _, dir in ipairs(dirs) do
|
||||||
out[#out + 1] = dir .. "/" .. name
|
out[#out + 1] = dir .. "/" .. name
|
||||||
out[#out + 1] = dir .. "/" .. ShaderFX.BRIDGE_DIR .. "/target/release/" .. name
|
out[#out + 1] = dir .. "/" .. ShaderFX.BRIDGE_DIR .. "/target/release/" .. name
|
||||||
@@ -353,7 +357,17 @@ local function ensureLib()
|
|||||||
end
|
end
|
||||||
tried[#tried + 1] = path
|
tried[#tried + 1] = path
|
||||||
end
|
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
|
return nil, libError
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -371,9 +385,13 @@ end
|
|||||||
-- `es`: true for GLSL ES 1.00 (mobile), false for GLSL 1.20 (desktop). Only
|
-- `es`: true for GLSL ES 1.00 (mobile), false for GLSL 1.20 (desktop). Only
|
||||||
-- ever called from ShaderFX.convert().
|
-- ever called from ShaderFX.convert().
|
||||||
function ShaderFX.translate(fullPath, es)
|
function ShaderFX.translate(fullPath, es)
|
||||||
local ffi = require("ffi")
|
local okFfi, ffi = pcall(require, "ffi")
|
||||||
local ok, l = pcall(ensureLib)
|
if not okFfi or type(ffi) ~= "table" then
|
||||||
|
return nil, "this build has no ffi, so presets cannot be converted here"
|
||||||
|
end
|
||||||
|
local ok, l, lerr = pcall(ensureLib)
|
||||||
if not ok then return nil, "ffi.load failed: " .. tostring(l) end
|
if not ok then return nil, "ffi.load failed: " .. tostring(l) end
|
||||||
|
if not l then return nil, tostring(lerr or libError or "librashader bridge not available") end
|
||||||
local ptr = l.librashader_translate_preset(fullPath, es and 1 or 0)
|
local ptr = l.librashader_translate_preset(fullPath, es and 1 or 0)
|
||||||
if ptr == nil then return nil, "librashader_translate_preset returned NULL" end
|
if ptr == nil then return nil, "librashader_translate_preset returned NULL" end
|
||||||
local json = ffi.string(ptr)
|
local json = ffi.string(ptr)
|
||||||
@@ -780,7 +798,7 @@ end
|
|||||||
|
|
||||||
-- Translates `entry` via the bridge and writes ShaderFX.artifactPath(entry).
|
-- Translates `entry` via the bridge and writes ShaderFX.artifactPath(entry).
|
||||||
-- Sets entry.converted on success; an existing artifact survives a failure.
|
-- Sets entry.converted on success; an existing artifact survives a failure.
|
||||||
function ShaderFX.convert(entry, es)
|
local function doConvert(entry, es)
|
||||||
ShaderSourcePatches.apply(entry)
|
ShaderSourcePatches.apply(entry)
|
||||||
local preset, err = ShaderFX.translate(entry.fullPath, es == nil and defaultEs() or es)
|
local preset, err = ShaderFX.translate(entry.fullPath, es == nil and defaultEs() or es)
|
||||||
if not preset then return false, err end
|
if not preset then return false, err end
|
||||||
@@ -797,6 +815,12 @@ function ShaderFX.convert(entry, es)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function ShaderFX.convert(entry, es)
|
||||||
|
local ok, res, err = pcall(doConvert, entry, es)
|
||||||
|
if not ok then return false, tostring(res) end
|
||||||
|
return res, err
|
||||||
|
end
|
||||||
|
|
||||||
-- Two independent slots. "shaderfx" stays main's option key, so existing
|
-- Two independent slots. "shaderfx" stays main's option key, so existing
|
||||||
-- saves keep meaning what they already meant.
|
-- saves keep meaning what they already meant.
|
||||||
ShaderFX.SLOTS = { "main", "secondary" }
|
ShaderFX.SLOTS = { "main", "secondary" }
|
||||||
|
|||||||
@@ -65,16 +65,24 @@ end
|
|||||||
-- (ViridianGym.asm .afterBeat, RocketHideoutB4F BeatGiovanniScript) call
|
-- (ViridianGym.asm .afterBeat, RocketHideoutB4F BeatGiovanniScript) call
|
||||||
-- GBFadeOutToBlack -> GBFadeInFromBlack instead, so the default keeps the
|
-- GBFadeOutToBlack -> GBFadeInFromBlack instead, so the default keeps the
|
||||||
-- symmetric 32-frame fade back in (home/fade.asm:21, b = 4).
|
-- 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)
|
local self = setmetatable({}, Transition)
|
||||||
self.game = game
|
self.game = game
|
||||||
self.onMidpoint = onMidpoint
|
self.onMidpoint = onMidpoint
|
||||||
self.onDone = onDone
|
self.onDone = onDone
|
||||||
self.t = 0
|
self.t = 0
|
||||||
self.phase = "out"
|
self.phase = "out"
|
||||||
|
self.color = opts.color or { 0, 0, 0 }
|
||||||
local style = styleOf(game, "warp_fade")
|
local style = styleOf(game, "warp_fade")
|
||||||
self.frames = style.frames or FRAMES
|
self.frames = opts.frames or style.frames or FRAMES
|
||||||
if warp then
|
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
|
-- a style may still ask for a fade in (mods, and the record is
|
||||||
-- data-driven); the built-in warp is 0, matching hardware
|
-- data-driven); the built-in warp is 0, matching hardware
|
||||||
self.framesIn = style.framesIn or FRAMES_IN
|
self.framesIn = style.framesIn or FRAMES_IN
|
||||||
@@ -125,6 +133,7 @@ end
|
|||||||
|
|
||||||
function Transition:draw()
|
function Transition:draw()
|
||||||
local alpha = self:alpha()
|
local alpha = self:alpha()
|
||||||
|
local c = self.color or { 0, 0, 0 }
|
||||||
-- Survey zoom draws the overworld into a window-filling world canvas
|
-- Survey zoom draws the overworld into a window-filling world canvas
|
||||||
-- while the UI pass stays the classic 160x144 letterbox. A rect on the
|
-- 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
|
-- 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
|
local r = self.game and self.game.renderer
|
||||||
if r and r.worldActive then
|
if r and r.worldActive then
|
||||||
r.worldFadeAlpha = alpha
|
r.worldFadeAlpha = alpha
|
||||||
|
r.worldFadeColor = c
|
||||||
return
|
return
|
||||||
end
|
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.rectangle("fill", 0, 0, 160, 144)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|||||||
+24
-5
@@ -529,6 +529,16 @@ function Commands.set_field(ctx, key, value)
|
|||||||
ctx.save[key] = value
|
ctx.save[key] = value
|
||||||
end
|
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)
|
function Commands.load_player_starter_name(ctx)
|
||||||
local flags = ctx.save.flags or {}
|
local flags = ctx.save.flags or {}
|
||||||
local species = flags.EVENT_CHOSE_PIKACHU and "PIKACHU"
|
local species = flags.EVENT_CHOSE_PIKACHU and "PIKACHU"
|
||||||
@@ -720,7 +730,8 @@ end
|
|||||||
-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37).
|
-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37).
|
||||||
-- skipNickname suppresses AskName for callers that name the gift themselves;
|
-- skipNickname suppresses AskName for callers that name the gift themselves;
|
||||||
-- no vanilla script uses it (pokeyellow scripts/OaksLab.asm, #1013)
|
-- 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.
|
-- 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:
|
-- 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.
|
-- 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.lastCheck = true
|
||||||
ctx.addedToParty = addedToParty
|
ctx.addedToParty = addedToParty
|
||||||
ctx.boxNum = boxNum
|
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
|
-- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks
|
||||||
-- and callback-style callers with no script runner to yield on.
|
-- and callback-style callers with no script runner to yield on.
|
||||||
if not gift.nickname and not skipNickname and ctx.runner then
|
if not gift.nickname and not skipNickname and ctx.runner then
|
||||||
@@ -1303,11 +1319,14 @@ function FadeOverlay:update()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function FadeOverlay:draw()
|
function FadeOverlay:draw()
|
||||||
if self.color == "white" then
|
local shade = (self.color == "white") and 1 or 0
|
||||||
love.graphics.setColor(1, 1, 1, self.alpha)
|
-- home/fade.asm:26
|
||||||
else
|
local r = self.game and self.game.renderer
|
||||||
love.graphics.setColor(0, 0, 0, self.alpha)
|
if r then
|
||||||
|
r.screenVeil = { shade, self.alpha }
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
love.graphics.setColor(shade, shade, shade, self.alpha)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -120,6 +120,67 @@ CallAsm.SITES = {
|
|||||||
["04:66d1"] = "TryReceiveItem",
|
["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
|
-- The three WRAM addresses `memcall` / `memcallasm` / `memjump` take instead of
|
||||||
-- a routine. Not sites: the pointer AT the address is written at run time
|
-- 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),
|
-- (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.
|
-- at import time), and the address pair is the fallback that always works.
|
||||||
function CallAsm.nameFor(label, bank, addr)
|
function CallAsm.nameFor(label, bank, addr)
|
||||||
if label and CallAsm.ALL[label] then return label end
|
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
|
end
|
||||||
|
|
||||||
-- Run a routine by name. Returns the byte the asm leaves in wScriptVar, or
|
-- 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))
|
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone, opts))
|
||||||
end
|
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
|
-- 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
|
-- 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
|
-- 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
|
if result == "consumed_escape" then -- Poké Doll
|
||||||
consume(game, id, list)
|
consume(game, id, list)
|
||||||
list:close()
|
list:close()
|
||||||
showMessages(game, payload, function()
|
showUseMessages(game, payload, function()
|
||||||
-- ItemUsePokeDoll sets wEscapedFromBattle and never touches
|
-- ItemUsePokeDoll sets wEscapedFromBattle and never touches
|
||||||
-- wBattleResult, so a script that reads the result afterwards sees
|
-- wBattleResult, so a script that reads the result afterwards sees
|
||||||
-- 0 -- "defeated". The ghost MAROWAK's script keys on exactly that
|
-- 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.result = "run"
|
||||||
battle.afterQueue = "finish"
|
battle.afterQueue = "finish"
|
||||||
battle.phase = "messages"
|
battle.phase = "messages"
|
||||||
end)
|
end, extra)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -403,9 +418,9 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
|||||||
end
|
end
|
||||||
if battle then
|
if battle then
|
||||||
list:close()
|
list:close()
|
||||||
showMessages(game, payload, function() battle:itemUsed({}) end)
|
showUseMessages(game, payload, function() battle:itemUsed({}) end, extra)
|
||||||
else
|
else
|
||||||
showMessages(game, payload, closePicker)
|
showUseMessages(game, payload, closePicker, extra)
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|||||||
+35
-14
@@ -8,8 +8,13 @@ local Assets = require("src.render.Assets")
|
|||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
local Sprites = require("src.pokemon.Sprites")
|
local Sprites = require("src.pokemon.Sprites")
|
||||||
|
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||||
local Strings = require("src.core.Strings")
|
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 = {}
|
local Diploma = {}
|
||||||
Diploma.__index = Diploma
|
Diploma.__index = Diploma
|
||||||
Diploma.isOpaque = true
|
Diploma.isOpaque = true
|
||||||
@@ -49,20 +54,18 @@ local function drawFrameBox(frame, tx, ty, tw, th)
|
|||||||
local img = frame.img
|
local img = frame.img
|
||||||
local q = frame.quads
|
local q = frame.quads
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
-- corners
|
-- engine/link/cable_club.asm:937
|
||||||
love.graphics.draw(img, q[0], tx * 8, ty * 8)
|
love.graphics.draw(img, q[2], tx * 8, ty * 8)
|
||||||
love.graphics.draw(img, q[2], (tx + tw - 1) * 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[6], tx * 8, (ty + th - 1) * 8)
|
||||||
love.graphics.draw(img, q[8], (tx + tw - 1) * 8, (ty + th - 1) * 8)
|
love.graphics.draw(img, q[7], (tx + tw - 1) * 8, (ty + th - 1) * 8)
|
||||||
-- horizontal edges
|
|
||||||
for x = 1, tw - 2 do
|
for x = 1, tw - 2 do
|
||||||
love.graphics.draw(img, q[1], (tx + x) * 8, ty * 8)
|
love.graphics.draw(img, q[3], (tx + x) * 8, ty * 8)
|
||||||
love.graphics.draw(img, q[7], (tx + x) * 8, (ty + th - 1) * 8)
|
love.graphics.draw(img, q[0], (tx + x) * 8, (ty + th - 1) * 8)
|
||||||
end
|
end
|
||||||
-- vertical edges
|
|
||||||
for y = 1, th - 2 do
|
for y = 1, th - 2 do
|
||||||
love.graphics.draw(img, q[3], tx * 8, (ty + y) * 8)
|
love.graphics.draw(img, q[5], tx * 8, (ty + y) * 8)
|
||||||
love.graphics.draw(img, q[5], (tx + tw - 1) * 8, (ty + y) * 8)
|
love.graphics.draw(img, q[1], (tx + tw - 1) * 8, (ty + y) * 8)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -91,13 +94,31 @@ function Diploma.render(game)
|
|||||||
drawFrameBox(frame, 0, 0, 20, 18)
|
drawFrameBox(frame, 0, 0, 20, 18)
|
||||||
|
|
||||||
-- 2. Draw Player character sprite: farcall DrawPlayerCharacter
|
-- 2. Draw Player character sprite: farcall DrawPlayerCharacter
|
||||||
-- Shifted +33 px right from title screen base (82 + 33 = 115, y = 80)
|
-- engine/movie/title.asm:321
|
||||||
local picPath, picTrueColor = Sprites.playerPath(
|
local title = (game.data and game.data.field and game.data.field.title) or {}
|
||||||
game.data, "front", { kind = "diploma" })
|
local titlePlayer = title.player
|
||||||
local pic = tryImage(picPath)
|
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
|
if pic then
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
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)
|
love.graphics.draw(pic, 115, 80)
|
||||||
|
if sx then love.graphics.setScissor(sx, sy, sw, sh)
|
||||||
|
else love.graphics.setScissor() end
|
||||||
if picTrueColor then
|
if picTrueColor then
|
||||||
PaletteFX.markTrueColor(115, 80, pic:getDimensions())
|
PaletteFX.markTrueColor(115, 80, pic:getDimensions())
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -95,11 +95,28 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via)
|
|||||||
self.t = 0
|
self.t = 0
|
||||||
self.done = false
|
self.done = false
|
||||||
self.canceled = 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
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
function EvolutionState:update(dt)
|
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
|
self.t = self.t + 1
|
||||||
if self.done then return end
|
if self.done then return end
|
||||||
local game = self.game
|
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
|
-- 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.
|
-- 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
|
-- the vanilla rows as descriptors; each step body is the old per-index
|
||||||
-- ladder's, so the save.options mutations are unchanged
|
-- ladder's, so the save.options mutations are unchanged
|
||||||
local function buildRows(game)
|
local function buildRows(game)
|
||||||
@@ -197,8 +202,6 @@ local function buildRows(game)
|
|||||||
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
|
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
|
||||||
if o.battleLayout ~= "wide" then
|
if o.battleLayout ~= "wide" then
|
||||||
o.battleHud = "standard"
|
o.battleHud = "standard"
|
||||||
elseif o.battleFit == "fill" and o.battleHud == "extended" then
|
|
||||||
o.battleBg = "white"
|
|
||||||
end
|
end
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
@@ -215,10 +218,6 @@ local function buildRows(game)
|
|||||||
step = function(g)
|
step = function(g)
|
||||||
local o = g.save.options
|
local o = g.save.options
|
||||||
o.battleFit = o.battleFit == "fill" and "fixed" or "fill"
|
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
|
return true
|
||||||
end },
|
end },
|
||||||
{ id = "battleHud", label = Strings("BATTLE HUD"),
|
{ id = "battleHud", label = Strings("BATTLE HUD"),
|
||||||
@@ -237,9 +236,6 @@ local function buildRows(game)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
o.battleHud = o.battleHud == "extended" and "standard" or "extended"
|
o.battleHud = o.battleHud == "extended" and "standard" or "extended"
|
||||||
if o.battleHud == "extended" and o.battleFit == "fill" then
|
|
||||||
o.battleBg = "white"
|
|
||||||
end
|
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
-- What sits behind and around the battle. WHITE is the classic paper
|
-- 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"),
|
{ id = "battleBg", label = Strings("BATTLE BG"),
|
||||||
value = function(g)
|
value = function(g)
|
||||||
local o = g.save.options
|
local o = g.save.options
|
||||||
if o.battleLayout == "wide" and o.battleFit == "fill"
|
if bgLocked(o) then return Strings("AUTO (FILL HUD)") end
|
||||||
and o.battleHud == "extended" then
|
|
||||||
o.battleBg = "white"
|
|
||||||
return Strings("AUTO")
|
|
||||||
end
|
|
||||||
local m = o.battleBg
|
local m = o.battleBg
|
||||||
if m == "black" then return Strings("BLACK") end
|
if m == "black" then return Strings("BLACK") end
|
||||||
if m == "world" then return Strings("WORLD") end
|
if m == "world" then return Strings("WORLD") end
|
||||||
@@ -261,11 +253,7 @@ local function buildRows(game)
|
|||||||
end,
|
end,
|
||||||
step = function(g, dir)
|
step = function(g, dir)
|
||||||
local o = g.save.options
|
local o = g.save.options
|
||||||
if o.battleLayout == "wide" and o.battleFit == "fill"
|
if bgLocked(o) then return false end
|
||||||
and o.battleHud == "extended" then
|
|
||||||
o.battleBg = "white"
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
local order = { "white", "black", "world" }
|
local order = { "white", "black", "world" }
|
||||||
local cur = 1
|
local cur = 1
|
||||||
for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end
|
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
|
return def and def.name or id
|
||||||
end
|
end
|
||||||
|
|
||||||
local function buildItems(game, store)
|
local function buildItems(game, store, order)
|
||||||
local items = {}
|
local items = {}
|
||||||
local ids = {}
|
local ids = order
|
||||||
for id in pairs(store) do table.insert(ids, id) end
|
if not ids then
|
||||||
table.sort(ids)
|
ids = {}
|
||||||
|
for id in pairs(store) do table.insert(ids, id) end
|
||||||
|
table.sort(ids)
|
||||||
|
end
|
||||||
for _, id in ipairs(ids) do
|
for _, id in ipairs(ids) do
|
||||||
table.insert(items, {
|
if store[id] then
|
||||||
value = id,
|
table.insert(items, {
|
||||||
label = itemName(game, id),
|
value = id,
|
||||||
right = "x" .. store[id],
|
label = itemName(game, id),
|
||||||
})
|
right = "x" .. store[id],
|
||||||
|
})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
return items
|
return items
|
||||||
end
|
end
|
||||||
@@ -105,12 +110,9 @@ local function deposit(game)
|
|||||||
local pc = game.save.pcItems
|
local pc = game.save.pcItems
|
||||||
local inv = game.save.inventory
|
local inv = game.save.inventory
|
||||||
local Bag = require("src.inventory.Bag")
|
local Bag = require("src.inventory.Bag")
|
||||||
-- badges live in save.inventory alongside items but are not depositable
|
-- engine/menus/players_pc.asm:99 wListPointer = wNumBagItems, so deposit order == bag order
|
||||||
local depositable = {}
|
local order = Bag.order(game.save, game.data)
|
||||||
for id, count in pairs(inv) do
|
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, inv, order), {
|
||||||
if not Bag.isBadge(id) then depositable[id] = count end
|
|
||||||
end
|
|
||||||
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
|
|
||||||
kind = "pc_item_deposit",
|
kind = "pc_item_deposit",
|
||||||
messageBox = true,
|
messageBox = true,
|
||||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ function ShaderFXScreen.new(game, slot)
|
|||||||
item.entry.name, tostring(err))
|
item.entry.name, tostring(err))
|
||||||
end
|
end
|
||||||
applyRowState(item)
|
applyRowState(item)
|
||||||
|
if not ok then item.right = Strings("FAILED") end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -687,8 +687,10 @@ function Studio.deleteEntry(id)
|
|||||||
local opts = SaveData.loadOptions()
|
local opts = SaveData.loadOptions()
|
||||||
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
||||||
if tc.skin == id then
|
if tc.skin == id then
|
||||||
tc.enabled, tc.skin = false, nil
|
tc.enabled, tc.skin = true, nil
|
||||||
|
opts.touchControls = tc
|
||||||
SaveData.saveOptions(opts)
|
SaveData.saveOptions(opts)
|
||||||
|
TouchControls:applyOptions(opts)
|
||||||
end
|
end
|
||||||
Studio.refreshAvailable()
|
Studio.refreshAvailable()
|
||||||
setStatus("Deleted " .. id)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
-- OG RED bakes the boot-ROM OBJ palette in, so the marker has to be replayed
|
-- OG RED and ADVANCED bake an OBJ palette in, so the marker replays over the TOWNMAP zone pass (#301)
|
||||||
-- over the screen-wide TOWNMAP zone pass the way every other OBJ is (#301)
|
|
||||||
function TownMap:markPlayerRedraw(x, y)
|
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)
|
PaletteFX.markUiSpriteRedraw(self.playerSheet, self.playerQuad, x, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -402,11 +401,7 @@ function TownMap:draw()
|
|||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- blinking cursor on the selected location. markerXY is the 8x8 cell's
|
-- WriteTownMapSpriteOAM carry quirk: -4 X, -3 Y for cursor and player alike -- engine/items/town_map.asm:454
|
||||||
-- 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).
|
|
||||||
local showCursor = true
|
local showCursor = true
|
||||||
if GameVersion.generation() == 1 then
|
if GameVersion.generation() == 1 then
|
||||||
showCursor = self.blink < 25
|
showCursor = self.blink < 25
|
||||||
@@ -416,7 +411,7 @@ function TownMap:draw()
|
|||||||
if selected and showCursor then
|
if selected and showCursor then
|
||||||
local x, y = markerXY(selected)
|
local x, y = markerXY(selected)
|
||||||
if self.bg.cursor then
|
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
|
else
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
love.graphics.rectangle("line", x + 0.5, y + 0.5, 7, 7)
|
love.graphics.rectangle("line", x + 0.5, y + 0.5, 7, 7)
|
||||||
|
|||||||
@@ -826,7 +826,10 @@ function BattleState:drawPic(mon, back)
|
|||||||
if trainerBack then
|
if trainerBack then
|
||||||
-- PAL_BATTLE_OB_PLAYER: the player's own colours, which are row 0 of
|
-- PAL_BATTLE_OB_PLAYER: the player's own colours, which are row 0 of
|
||||||
-- TrainerPalettes (Chris shares Cal's).
|
-- 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
|
elseif enemyTrainer then
|
||||||
-- The opponent's class row out of the same TrainerPalettes table.
|
-- The opponent's class row out of the same TrainerPalettes table.
|
||||||
colors = Palettes.trainerColors(self.palettes, self.enemyTrainerClass)
|
colors = Palettes.trainerColors(self.palettes, self.enemyTrainerClass)
|
||||||
@@ -1258,7 +1261,15 @@ function BattleState:afterAnimFor(side)
|
|||||||
return "ANIM_PLAYER_DAMAGE"
|
return "ANIM_PLAYER_DAMAGE"
|
||||||
end
|
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 key = self.anims and self.anims.moves and self.anims.moves[moveId]
|
||||||
local started = self:startAnim(key, {
|
local started = self:startAnim(key, {
|
||||||
turn = self:turnFor(side), animId = moveId, isMove = true, param = param,
|
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
|
-- BattleAnimRunScript (anim_commands.asm:55-72): after the move script
|
||||||
-- restores HUDs it immediately runs wBattleAfterAnim (the hit shake).
|
-- restores HUDs it immediately runs wBattleAfterAnim (the hit shake).
|
||||||
-- Queue it so stepAnim chains without waiting on the next event.
|
-- 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
|
end
|
||||||
return started
|
return started
|
||||||
end
|
end
|
||||||
@@ -1278,6 +1290,7 @@ function BattleState:startPendingAfterAnim()
|
|||||||
if not pending then return false end
|
if not pending then return false end
|
||||||
self.pendingAfterAnim = nil
|
self.pendingAfterAnim = nil
|
||||||
if self:animForId(pending.name, pending.side) then
|
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.
|
-- dealDamage's default ANIM_x_DAMAGE is this same shake; skip it there.
|
||||||
self.afterAnimPlayed = true
|
self.afterAnimPlayed = true
|
||||||
return true
|
return true
|
||||||
@@ -1575,6 +1588,11 @@ function BattleState:advanceQueue()
|
|||||||
self:showPages(text)
|
self:showPages(text)
|
||||||
return
|
return
|
||||||
end
|
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
|
-- The shiny sparkle: hBattleTurn 1 and wBattleAnimParam 1 pick
|
||||||
-- BattleAnim_SendOutMon's `.Shiny` arm on the enemy (core.asm:8708-8715).
|
-- BattleAnim_SendOutMon's `.Shiny` arm on the enemy (core.asm:8708-8715).
|
||||||
if event.kind == "shiny-flash" then
|
if event.kind == "shiny-flash" then
|
||||||
@@ -1692,12 +1710,14 @@ function BattleState:advanceQueue()
|
|||||||
if event.kind == "move" and not event.missed then
|
if event.kind == "move" and not event.missed then
|
||||||
self.afterAnimPlayed = nil
|
self.afterAnimPlayed = nil
|
||||||
self.pendingAfterAnim = 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
|
-- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim
|
||||||
-- (anim_commands.asm:55-72 .disabled fallthrough).
|
-- (anim_commands.asm:55-72 .disabled fallthrough).
|
||||||
local options = self.game and self.game.options
|
local options = self.game and self.game.options
|
||||||
if options and options.battleScene == false then
|
if options and options.battleScene == false then
|
||||||
if self:animForId(self:afterAnimFor(event.side), event.side) then
|
if self:animForId(self:afterAnimFor(event.side), event.side) then
|
||||||
|
self:playHitSound(event.effectiveness)
|
||||||
self.afterAnimPlayed = true
|
self.afterAnimPlayed = true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1725,6 +1745,7 @@ function BattleState:advanceQueue()
|
|||||||
and (hit == "ANIM_ENEMY_DAMAGE" or hit == "ANIM_PLAYER_DAMAGE") then
|
and (hit == "ANIM_ENEMY_DAMAGE" or hit == "ANIM_PLAYER_DAMAGE") then
|
||||||
self.afterAnimPlayed = nil
|
self.afterAnimPlayed = nil
|
||||||
else
|
else
|
||||||
|
self:playHitSound(event.effectiveness)
|
||||||
self:animForId(hit, from)
|
self:animForId(hit, from)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -441,15 +441,25 @@ end
|
|||||||
|
|
||||||
-- Intro_ClearBGPals blacks all 16 palettes and burns two frames
|
-- 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:1554-1571); IntroScene26 clears to white instead
|
||||||
-- (engine/movie/intro.asm:1058, home/tilemap.asm:168-196).
|
-- (engine/movie/intro.asm:1058, home/tilemap.asm:1-9,168-196).
|
||||||
local function setup(self, white, fn)
|
-- 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
|
if self.phase == 0 then
|
||||||
self.phase = 1
|
self.phase = 1
|
||||||
local color = white and WHITE or BLACK
|
local color = white and WHITE or BLACK
|
||||||
self.bgPals = flatPals(color)
|
self.bgPals = flatPals(color)
|
||||||
self.obPals = flatPals(color)
|
self.obPals = flatPals(color)
|
||||||
markDirty(self)
|
markDirty(self)
|
||||||
self.hold = 1
|
self.hold = (white and 4 or 2) + requestFrames(tiles) - 1
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
self.phase = 0
|
self.phase = 0
|
||||||
@@ -556,7 +566,7 @@ local Scenes = {}
|
|||||||
|
|
||||||
-- IntroScene1 (engine/movie/intro.asm:96-146).
|
-- IntroScene1 (engine/movie/intro.asm:96-146).
|
||||||
Scenes[1] = function(self)
|
Scenes[1] = function(self)
|
||||||
setup(self, false, function()
|
setup(self, false, { 64, 128, 128, 64 }, function()
|
||||||
loadAct(self, "unownA")
|
loadAct(self, "unownA")
|
||||||
clearAnims(self)
|
clearAnims(self)
|
||||||
self.scx, self.scy = 0, 0
|
self.scx, self.scy = 0, 0
|
||||||
@@ -582,7 +592,7 @@ end
|
|||||||
|
|
||||||
-- IntroScene3 (engine/movie/intro.asm:172-218).
|
-- IntroScene3 (engine/movie/intro.asm:172-218).
|
||||||
Scenes[3] = function(self)
|
Scenes[3] = function(self)
|
||||||
setup(self, false, function()
|
setup(self, false, { 64, 128, 64 }, function()
|
||||||
loadAct(self, "background")
|
loadAct(self, "background")
|
||||||
resetLYOverrides(self)
|
resetLYOverrides(self)
|
||||||
self.scx, self.scy = 0, 0
|
self.scx, self.scy = 0, 0
|
||||||
@@ -602,7 +612,7 @@ end
|
|||||||
|
|
||||||
-- IntroScene5 (engine/movie/intro.asm:234-285).
|
-- IntroScene5 (engine/movie/intro.asm:234-285).
|
||||||
Scenes[5] = function(self)
|
Scenes[5] = function(self)
|
||||||
setup(self, false, function()
|
setup(self, false, { 64, 128, 128, 64 }, function()
|
||||||
loadAct(self, "unownHI")
|
loadAct(self, "unownHI")
|
||||||
self.lyActive = false
|
self.lyActive = false
|
||||||
clearAnims(self)
|
clearAnims(self)
|
||||||
@@ -641,7 +651,7 @@ end
|
|||||||
|
|
||||||
-- IntroScene7 (engine/movie/intro.asm:332-401).
|
-- IntroScene7 (engine/movie/intro.asm:332-401).
|
||||||
Scenes[7] = function(self)
|
Scenes[7] = function(self)
|
||||||
setup(self, false, function()
|
setup(self, false, { 64, 128, 255, 128, 64 }, function()
|
||||||
loadAct(self, "background")
|
loadAct(self, "background")
|
||||||
resetLYOverrides(self)
|
resetLYOverrides(self)
|
||||||
clearAnims(self)
|
clearAnims(self)
|
||||||
@@ -708,7 +718,7 @@ end
|
|||||||
|
|
||||||
-- IntroScene11 (engine/movie/intro.asm:502-550).
|
-- IntroScene11 (engine/movie/intro.asm:502-550).
|
||||||
Scenes[11] = function(self)
|
Scenes[11] = function(self)
|
||||||
setup(self, false, function()
|
setup(self, false, { 64, 128, 64 }, function()
|
||||||
loadAct(self, "unowns")
|
loadAct(self, "unowns")
|
||||||
self.lyActive = false
|
self.lyActive = false
|
||||||
clearAnims(self)
|
clearAnims(self)
|
||||||
@@ -729,7 +739,10 @@ local UNOWN_SOUNDS = {
|
|||||||
Scenes[12] = function(self)
|
Scenes[12] = function(self)
|
||||||
local a = self.counter
|
local a = self.counter
|
||||||
local sound = UNOWN_SOUNDS[a]
|
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
|
self.counter = (a + 1) % 256
|
||||||
if a >= 0xc0 then
|
if a >= 0xc0 then
|
||||||
self.scene = 13
|
self.scene = 13
|
||||||
@@ -746,7 +759,7 @@ end
|
|||||||
|
|
||||||
-- IntroScene13 (engine/movie/intro.asm:626-683).
|
-- IntroScene13 (engine/movie/intro.asm:626-683).
|
||||||
Scenes[13] = function(self)
|
Scenes[13] = function(self)
|
||||||
setup(self, false, function()
|
setup(self, false, { 64, 255, 128, 64 }, function()
|
||||||
loadAct(self, "background")
|
loadAct(self, "background")
|
||||||
clearAnims(self)
|
clearAnims(self)
|
||||||
self.anims:init("INTRO_SUICUNE", 11 * 8, 13 * 8 + 4)
|
self.anims:init("INTRO_SUICUNE", 11 * 8, 13 * 8 + 4)
|
||||||
@@ -783,7 +796,7 @@ end
|
|||||||
|
|
||||||
-- IntroScene15 (engine/movie/intro.asm:730-792).
|
-- IntroScene15 (engine/movie/intro.asm:730-792).
|
||||||
Scenes[15] = function(self)
|
Scenes[15] = function(self)
|
||||||
setup(self, false, function()
|
setup(self, false, { 64, 128, 128, 1, 64 }, function()
|
||||||
loadAct(self, "suicuneJump")
|
loadAct(self, "suicuneJump")
|
||||||
clearAnims(self)
|
clearAnims(self)
|
||||||
self.anims:init("INTRO_UNOWN_F", 5 * 8, 8 * 8)
|
self.anims:init("INTRO_UNOWN_F", 5 * 8, 8 * 8)
|
||||||
@@ -809,7 +822,7 @@ end
|
|||||||
|
|
||||||
-- IntroScene17 (engine/movie/intro.asm:812-859).
|
-- IntroScene17 (engine/movie/intro.asm:812-859).
|
||||||
Scenes[17] = function(self)
|
Scenes[17] = function(self)
|
||||||
setup(self, false, function()
|
setup(self, false, { 64, 255, 64 }, function()
|
||||||
loadAct(self, "suicuneClose")
|
loadAct(self, "suicuneClose")
|
||||||
clearAnims(self)
|
clearAnims(self)
|
||||||
self.scx, self.scy = 0, 0
|
self.scx, self.scy = 0, 0
|
||||||
@@ -832,7 +845,7 @@ end
|
|||||||
|
|
||||||
-- IntroScene19 (engine/movie/intro.asm:878-941).
|
-- IntroScene19 (engine/movie/intro.asm:878-941).
|
||||||
Scenes[19] = function(self)
|
Scenes[19] = function(self)
|
||||||
setup(self, false, function()
|
setup(self, false, { 64, 128, 128, 1, 64 }, function()
|
||||||
loadAct(self, "suicuneBack")
|
loadAct(self, "suicuneBack")
|
||||||
clearAnims(self)
|
clearAnims(self)
|
||||||
self.anims:init("INTRO_SUICUNE_AWAY", 0, 12 * 8)
|
self.anims:init("INTRO_SUICUNE_AWAY", 0, 12 * 8)
|
||||||
@@ -918,7 +931,7 @@ end
|
|||||||
|
|
||||||
-- IntroScene26 (engine/movie/intro.asm:1056-1103).
|
-- IntroScene26 (engine/movie/intro.asm:1056-1103).
|
||||||
Scenes[26] = function(self)
|
Scenes[26] = function(self)
|
||||||
setup(self, true, function()
|
setup(self, true, { 64, 128, 64 }, function()
|
||||||
loadAct(self, "crystalUnowns")
|
loadAct(self, "crystalUnowns")
|
||||||
clearAnims(self)
|
clearAnims(self)
|
||||||
self.scx, self.scy = 0, 0
|
self.scx, self.scy = 0, 0
|
||||||
|
|||||||
+22
-16
@@ -41,6 +41,8 @@ ItemPcMenu.isOpaque = true
|
|||||||
local PC_ITEM_CAPACITY = 50
|
local PC_ITEM_CAPACITY = 50
|
||||||
local MAX_STACK = 99
|
local MAX_STACK = 99
|
||||||
|
|
||||||
|
local function stacksFor(n) return math.ceil((n or 0) / MAX_STACK) end
|
||||||
|
|
||||||
-- PlayersPCMenuData .PlayersPCMenuPointers strings, verbatim. .WhichPC picks
|
-- PlayersPCMenuData .PlayersPCMenuPointers strings, verbatim. .WhichPC picks
|
||||||
-- which rows a caller sees: PLAYERSPC_NORMAL ends on LOG OFF, PLAYERSPC_HOUSE
|
-- which rows a caller sees: PLAYERSPC_NORMAL ends on LOG OFF, PLAYERSPC_HOUSE
|
||||||
-- carries DECORATION and ends on TURN OFF.
|
-- carries DECORATION and ends on TURN OFF.
|
||||||
@@ -167,18 +169,24 @@ function ItemPcMenu:rebuild()
|
|||||||
for id, count in pairs(pc) do
|
for id, count in pairs(pc) do
|
||||||
if (count or 0) > 0 then
|
if (count or 0) > 0 then
|
||||||
local def = self:def(id)
|
local def = self:def(id)
|
||||||
rows[#rows + 1] = {
|
local remaining = count
|
||||||
id = id, count = count,
|
while remaining > 0 do
|
||||||
name = (def and def.name) or id,
|
local n = math.min(remaining, MAX_STACK)
|
||||||
index = def and def.index or math.huge,
|
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
|
||||||
end
|
end
|
||||||
-- wPCItems keeps acquisition order; without that recorded, item id order is
|
-- wPCItems keeps acquisition order; without that recorded, item id order is
|
||||||
-- the stable choice, the same sort the PACK uses.
|
-- the stable choice, the same sort the PACK uses.
|
||||||
table.sort(rows, function(a, b)
|
table.sort(rows, function(a, b)
|
||||||
if a.index ~= b.index then return a.index < b.index end
|
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)
|
end)
|
||||||
self.rows = rows
|
self.rows = rows
|
||||||
if self.listIndex > #rows + 1 then self.listIndex = #rows + 1 end
|
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)))
|
math.max(0, self:listTotal() - VISIBLE_ROWS)))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ReceiveItem over wPCItems: a new id needs one of the fifty stacks, a grown
|
-- ReceiveItem over wPCItems: the add tops up every existing stack of that id
|
||||||
-- one may not pass 99. False is the no-carry the deposit turns into
|
-- 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.
|
-- _PlayersPCNoRoomDepositText.
|
||||||
|
-- engine/items/items.asm:156 PutItemInPocket
|
||||||
function ItemPcMenu:pcAdd(id, qty)
|
function ItemPcMenu:pcAdd(id, qty)
|
||||||
local pc = self.save.pcItems
|
local pc = self.save.pcItems
|
||||||
local held = pc[id] or 0
|
local held = pc[id] or 0
|
||||||
if held == 0 then
|
local used = 0
|
||||||
local stacks = 0
|
for _, count in pairs(pc) do used = used + stacksFor(count) end
|
||||||
for _, count in pairs(pc) do
|
local need = stacksFor(held + qty) - stacksFor(held)
|
||||||
if (count or 0) > 0 then stacks = stacks + 1 end
|
if used + need > PC_ITEM_CAPACITY then return false end
|
||||||
end
|
|
||||||
if stacks >= PC_ITEM_CAPACITY then return false end
|
|
||||||
end
|
|
||||||
if held + qty > MAX_STACK then return false end
|
|
||||||
pc[id] = held + qty
|
pc[id] = held + qty
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|||||||
+17
-10
@@ -38,9 +38,12 @@ local MON_HOLDING_MAIL = {
|
|||||||
Strings.source("Please remove the\nMAIL."),
|
Strings.source("Please remove the\nMAIL."),
|
||||||
}
|
}
|
||||||
|
|
||||||
-- _ChangeBoxSaveText (data/text/common_2.asm:1306) is three lines whose `cont`
|
-- _ChangeBoxSaveText (data/text/common_2.asm:1306) is three lines whose first
|
||||||
-- has already scrolled by the time YesNoBox goes up over its last two.
|
-- `cont` ("When you change a") has already scrolled by the time YesNoBox goes
|
||||||
local CHANGE_BOX_SAVE = { "#MON BOX, data", "will be saved. OK?" }
|
-- up over its last two -- confirmed against poke-corpus GoldSilver
|
||||||
|
-- en_msg.txt:4897. One \n-joined translatable key, same pattern as
|
||||||
|
-- SaveMenu.lua's OVERWRITE_PROMPT_SOURCE/SAVING_PROMPT_SOURCE.
|
||||||
|
local CHANGE_BOX_SAVE_SOURCE = Strings.source("#MON BOX, data\nwill be saved. OK?")
|
||||||
|
|
||||||
-- YesNoBox's own `lb bc, SCREEN_WIDTH - 6, 7` (home/menu.asm:382-383).
|
-- YesNoBox's own `lb bc, SCREEN_WIDTH - 6, 7` (home/menu.asm:382-383).
|
||||||
local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5
|
local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5
|
||||||
@@ -212,16 +215,20 @@ function PcMenu:writeChangeBox()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function PcMenu:savePrompt()
|
function PcMenu:savePrompt()
|
||||||
if self.savePhase == "overwrite" then return SaveMenu.OVERWRITE_PROMPT end
|
if self.savePhase == "overwrite" then
|
||||||
if self.savePhase == "saving" then return SaveMenu.SAVING_PROMPT end
|
return SaveMenu.twoLines(Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE))
|
||||||
|
end
|
||||||
|
if self.savePhase == "saving" then
|
||||||
|
return SaveMenu.twoLines(Strings(SaveMenu.SAVING_PROMPT_SOURCE))
|
||||||
|
end
|
||||||
if self.savePhase == "done" then
|
if self.savePhase == "done" then
|
||||||
if self.saved then
|
if self.saved then
|
||||||
local name = (self.save.player and self.save.player.name) or "GOLD"
|
local name = (self.save.player and self.save.player.name) or "GOLD"
|
||||||
return { name .. " saved", "the game." }
|
return SaveMenu.twoLines(Strings("%s saved\nthe game.", name))
|
||||||
end
|
end
|
||||||
return { "Could not save.", "" }
|
return SaveMenu.twoLines(Strings("Could not save."))
|
||||||
end
|
end
|
||||||
return CHANGE_BOX_SAVE
|
return SaveMenu.twoLines(Strings(CHANGE_BOX_SAVE_SOURCE))
|
||||||
end
|
end
|
||||||
|
|
||||||
function PcMenu:updateChangeBox()
|
function PcMenu:updateChangeBox()
|
||||||
@@ -404,8 +411,8 @@ function PcMenu:drawPanel()
|
|||||||
Chrome.print(lines[2] or "", 1, 16)
|
Chrome.print(lines[2] or "", 1, 16)
|
||||||
if self.savePhase == "confirm" or self.savePhase == "overwrite" then
|
if self.savePhase == "confirm" or self.savePhase == "overwrite" then
|
||||||
Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H)
|
Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H)
|
||||||
Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1)
|
Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1)
|
||||||
Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3)
|
Chrome.print(Strings("NO"), YESNO_X + 2, YESNO_Y + 3)
|
||||||
Chrome.cursor(YESNO_X + 1,
|
Chrome.cursor(YESNO_X + 1,
|
||||||
YESNO_Y + (self.saveChoice == 1 and 1 or 3))
|
YESNO_Y + (self.saveChoice == 1 and 1 or 3))
|
||||||
end
|
end
|
||||||
|
|||||||
+17
-22
@@ -57,27 +57,21 @@ local TIME_X, TIME_Y = 13, 8
|
|||||||
local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5
|
local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5
|
||||||
|
|
||||||
-- AlreadyASaveFileText (AskOverwriteSaveFile, engine/menus/save.asm:47) and
|
-- AlreadyASaveFileText (AskOverwriteSaveFile, engine/menus/save.asm:47) and
|
||||||
-- SavingDontTurnOffThePower's own line, shared with the PC's CHANGE BOX save
|
-- SavingDontTurnOffThePower's own line -- one \n-joined translatable key
|
||||||
-- (src/ui/gen2/PcMenu.lua:savePrompt() reads these two tables' lines[1]/
|
-- each, used both by this screen's own prompt() below and, through the
|
||||||
-- lines[2] directly, so their shape is a cross-file contract: keep them
|
-- SOURCE/twoLines() exports at the bottom of this file, by the PC's CHANGE
|
||||||
-- plain, untranslated tables).
|
-- BOX save (src/ui/gen2/PcMenu.lua:savePrompt()), which shares these exact
|
||||||
local OVERWRITE_PROMPT = { "There is already a", "save file. Is it" }
|
-- same two cart messages. One key per prompt lets a translation write one
|
||||||
local SAVING_PROMPT = { "SAVING… DON'T TURN", "OFF THE POWER." }
|
-- whole, freely reordered sentence instead of two fragments translated in
|
||||||
|
-- isolation, and lets a cart whose own text is a single line (German's
|
||||||
-- Translatable copies of the two prompts above, one \n-joined key each, used
|
-- SAVING prompt) say so directly by simply omitting the "\n" -- the
|
||||||
-- only by this screen's own prompt() below. One key per prompt lets a
|
-- per-line override style used elsewhere requires a non-empty value for
|
||||||
-- translation write one whole, freely reordered sentence instead of two
|
-- every line, so it can't express "this line is blank".
|
||||||
-- fragments translated in isolation, and lets a cart whose own text is a
|
|
||||||
-- single line (German's SAVING prompt) say so directly by simply omitting
|
|
||||||
-- the "\n" -- the per-line override style used elsewhere requires a
|
|
||||||
-- non-empty value for every line, so it can't express "this line is blank".
|
|
||||||
--
|
--
|
||||||
-- Written as literals, not `table.concat(OVERWRITE_PROMPT, "\n")`: the
|
-- Written as a literal, not built from a table: the translation tooling's
|
||||||
-- translation tooling's string harvester only recognizes a literal inside
|
-- string harvester only recognizes a literal inside Strings.source(...),
|
||||||
-- Strings.source(...), not a computed expression, so a concat call here
|
-- not a computed expression, so a concat call here would quietly never
|
||||||
-- would quietly never reach a translator. Keep byte-for-byte in sync with
|
-- reach a translator.
|
||||||
-- OVERWRITE_PROMPT/SAVING_PROMPT above (checked by
|
|
||||||
-- tests/engine/gen2_save_menu_translation_test.lua).
|
|
||||||
local OVERWRITE_PROMPT_SOURCE = Strings.source("There is already a\nsave file. Is it")
|
local OVERWRITE_PROMPT_SOURCE = Strings.source("There is already a\nsave file. Is it")
|
||||||
local SAVING_PROMPT_SOURCE = Strings.source("SAVING… DON'T TURN\nOFF THE POWER.")
|
local SAVING_PROMPT_SOURCE = Strings.source("SAVING… DON'T TURN\nOFF THE POWER.")
|
||||||
|
|
||||||
@@ -279,7 +273,8 @@ end
|
|||||||
SaveMenu.SFX_SAVE = SFX_SAVE
|
SaveMenu.SFX_SAVE = SFX_SAVE
|
||||||
SaveMenu.SAVING_FRAMES = SAVING_FRAMES
|
SaveMenu.SAVING_FRAMES = SAVING_FRAMES
|
||||||
SaveMenu.SAVED_FRAMES = SAVED_FRAMES
|
SaveMenu.SAVED_FRAMES = SAVED_FRAMES
|
||||||
SaveMenu.OVERWRITE_PROMPT = OVERWRITE_PROMPT
|
SaveMenu.OVERWRITE_PROMPT_SOURCE = OVERWRITE_PROMPT_SOURCE
|
||||||
SaveMenu.SAVING_PROMPT = SAVING_PROMPT
|
SaveMenu.SAVING_PROMPT_SOURCE = SAVING_PROMPT_SOURCE
|
||||||
|
SaveMenu.twoLines = twoLines
|
||||||
|
|
||||||
return SaveMenu
|
return SaveMenu
|
||||||
|
|||||||
@@ -156,7 +156,9 @@ function TitleState.new(game, opts)
|
|||||||
self.entranceHideBelow = entrance and tonumber(entrance.hideBelow) or nil
|
self.entranceHideBelow = entrance and tonumber(entrance.hideBelow) or nil
|
||||||
self.gemY = entrance and (tonumber(title.gemFromY) or -50) or self.gemRestY
|
self.gemY = entrance and (tonumber(title.gemFromY) or -50) or self.gemRestY
|
||||||
self.entranceSfx = title.entranceSfx
|
self.entranceSfx = title.entranceSfx
|
||||||
|
-- engine/menus/intro_menu.asm:951-966
|
||||||
self.timeoutFrames = tonumber(title.timeoutFrames)
|
self.timeoutFrames = tonumber(title.timeoutFrames)
|
||||||
|
or ((self.trailMode == "silver") and (73 * 60 + 36) or (84 * 60 + 16))
|
||||||
self.onTimeout = opts.onTimeout
|
self.onTimeout = opts.onTimeout
|
||||||
-- The copyright window line is pal 7 (engine/movie/title.asm:40-43); its
|
-- The copyright window line is pal 7 (engine/movie/title.asm:40-43); its
|
||||||
-- colour 0 backs the whole band.
|
-- colour 0 backs the whole band.
|
||||||
@@ -295,12 +297,17 @@ function TitleState:update(_dt)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
-- TitleScreenTimer / TitleScreenMain's run-down back into the attract
|
-- engine/menus/intro_menu.asm:1023-1059
|
||||||
-- loop (engine/menus/intro_menu.asm:1125-1236).
|
if self.timeoutFrames and self.onTimeout then
|
||||||
if self.timeoutFrames and self.onTimeout
|
if self.fadeStart then
|
||||||
and self.frameCounter - (self.timeoutStart or 0) >= self.timeoutFrames then
|
if self.frameCounter - self.fadeStart >= 60 then self.onTimeout() end
|
||||||
self.onTimeout()
|
return
|
||||||
return
|
end
|
||||||
|
if self.frameCounter - (self.timeoutStart or 0) >= self.timeoutFrames then
|
||||||
|
self.fadeStart = self.frameCounter
|
||||||
|
Music.fadeOut(8)
|
||||||
|
return
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local input = self.game.input
|
local input = self.game.input
|
||||||
|
|||||||
@@ -1232,6 +1232,10 @@ function OverworldState:update(dt)
|
|||||||
self.player.inputLocked = false
|
self.player.inputLocked = false
|
||||||
end
|
end
|
||||||
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
|
-- EnterMapAnim's .done tail re-enables the companion once the swoop or the
|
||||||
-- spin-down has landed (player_animations.asm:40)
|
-- 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
|
local scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
||||||
or (self.hopLand or 0) > 0
|
or (self.hopLand or 0) > 0
|
||||||
or self.engaging or self.emote or self.teleportOut
|
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
|
if not scripted and not self.transitioning then
|
||||||
self:checkTrainerSight()
|
self:checkTrainerSight()
|
||||||
-- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and
|
-- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and
|
||||||
@@ -1321,7 +1325,7 @@ function OverworldState:update(dt)
|
|||||||
scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
||||||
or (self.hopLand or 0) > 0
|
or (self.hopLand or 0) > 0
|
||||||
or self.engaging or self.emote or self.teleportOut
|
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
|
end
|
||||||
-- a scriptMove's onDone can push a text box on the frame it retires, and
|
-- 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)
|
-- 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
|
self.doorWarp = nil
|
||||||
local arriveWarp = self.arriveWarp
|
local arriveWarp = self.arriveWarp
|
||||||
self.arriveWarp = nil
|
self.arriveWarp = nil
|
||||||
|
if self.spinArrive then
|
||||||
|
self.spinArrive = nil
|
||||||
|
self.player.inputLocked = false
|
||||||
|
end
|
||||||
-- PlayMapChangeSound (home/overworld.asm) plays before the tail-called
|
-- PlayMapChangeSound (home/overworld.asm) plays before the tail-called
|
||||||
-- GBFadeOutToBlack, so the SFX starts with the fade (#961)
|
-- GBFadeOutToBlack, so the SFX starts with the fade (#961)
|
||||||
if doorWarp then
|
if doorWarp then
|
||||||
@@ -4810,6 +4818,15 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
|||||||
require("src.core.Sound").play(Game.data,
|
require("src.core.Sound").play(Game.data,
|
||||||
outdoor and "Go_Outside" or "Go_Inside")
|
outdoor and "Go_Outside" or "Go_Inside")
|
||||||
end
|
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()
|
Game.stack:push(Transition.new(Game, function()
|
||||||
self:setMap(mapId, x, y, facing or "down", opts)
|
self:setMap(mapId, x, y, facing or "down", opts)
|
||||||
-- the departure-side hide from flyAnim/teleportOut ends here, on the new
|
-- 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.spinFrames = 48
|
||||||
self.player.spinTotal = 48
|
self.player.spinTotal = 48
|
||||||
self.player.spinDrop = true
|
self.player.spinDrop = true
|
||||||
|
-- engine/overworld/player_animations.asm:19
|
||||||
|
self.spinArrive = true
|
||||||
|
self.player.inputLocked = true
|
||||||
end
|
end
|
||||||
if doorWarp then
|
if doorWarp then
|
||||||
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
|
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
|
||||||
@@ -4873,7 +4893,7 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
|||||||
end, function()
|
end, function()
|
||||||
self.transitioning = false
|
self.transitioning = false
|
||||||
if onDone then onDone() end
|
if onDone then onDone() end
|
||||||
end, true)) -- warp shape: no fade back in (LoadGBPal restores in one write)
|
end, not specialWarp, fadeOpts))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Re-read a map record after its data changed (WorldAPI:invalidateMap,
|
-- 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)
|
-- cry with no bubble still pauses the world for its beat)
|
||||||
if self.emote.bubble == false then return end
|
if self.emote.bubble == false then return end
|
||||||
local npc = self.emote.npc
|
local npc = self.emote.npc
|
||||||
local ex = npc.px - cam.x + 4
|
-- engine/overworld/emotion_bubbles.asm:41
|
||||||
local ey = npc.py - cam.y - 14
|
local ex = npc.px - cam.x
|
||||||
|
local ey = npc.py - cam.y - 20
|
||||||
local bubble = Game.data.field.emotionBubbles
|
local bubble = Game.data.field.emotionBubbles
|
||||||
local drawn = false
|
local drawn = false
|
||||||
if bubble and bubble.path then
|
if bubble and bubble.path then
|
||||||
|
|||||||
@@ -551,6 +551,7 @@ function World.new(game)
|
|||||||
lastSfx = nil,
|
lastSfx = nil,
|
||||||
pokePic = nil,
|
pokePic = nil,
|
||||||
pendingSceneScript = false,
|
pendingSceneScript = false,
|
||||||
|
startedOverworld = false,
|
||||||
-- GBC color state (engine/gfx/color.asm). `daytime` is the resolved
|
-- GBC color state (engine/gfx/color.asm). `daytime` is the resolved
|
||||||
-- MORN/DAY/NITE/DARK the map is currently lit by; clockHour overrides
|
-- 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
|
-- 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
|
if not opts.seamless then
|
||||||
self.pendingSceneScript = true
|
self.pendingSceneScript = true
|
||||||
end
|
end
|
||||||
-- StartMap (engine/overworld/events.asm): `farcall InitCallReceiveDelay` on
|
-- engine/overworld/events.asm:98
|
||||||
-- every map entry, connections included -- which is why a player who keeps
|
if not self.startedOverworld and self.game and self.game.save then
|
||||||
-- warping is never rung (src/core/gen2/Phone.lua's receive timer).
|
self.startedOverworld = true
|
||||||
if self.game and self.game.save then
|
|
||||||
require("src.core.gen2.Phone").onMapLoad(self.game.save,
|
require("src.core.gen2.Phone").onMapLoad(self.game.save,
|
||||||
self:stepContext().phone)
|
self:stepContext().phone)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ local MapOverview = require("src.world.MapOverview")
|
|||||||
local Bike = require("src.world.gen2.Bike")
|
local Bike = require("src.world.gen2.Bike")
|
||||||
local FieldMoves = require("src.world.gen2.FieldMoves")
|
local FieldMoves = require("src.world.gen2.FieldMoves")
|
||||||
local Permissions = require("src.world.gen2.Permissions")
|
local Permissions = require("src.world.gen2.Permissions")
|
||||||
|
local Mail = require("src.core.gen2.Mail")
|
||||||
|
|
||||||
local WorldAPI = {}
|
local WorldAPI = {}
|
||||||
WorldAPI.__index = WorldAPI
|
WorldAPI.__index = WorldAPI
|
||||||
@@ -49,6 +50,11 @@ local FIELD_ACTIONS = {
|
|||||||
{ id = "teleport", move = "TELEPORT" },
|
{ 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)
|
function WorldAPI.new(game, modId)
|
||||||
return setmetatable({ game = game, modId = modId }, WorldAPI)
|
return setmetatable({ game = game, modId = modId }, WorldAPI)
|
||||||
end
|
end
|
||||||
@@ -68,6 +74,32 @@ function WorldAPI:current()
|
|||||||
facing = p and p.facing }
|
facing = p and p.facing }
|
||||||
end
|
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 function itemLabel(game, id)
|
||||||
local def = game and game.data and game.data.items
|
local def = game and game.data and game.data.items
|
||||||
and game.data.items[id]
|
and game.data.items[id]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user