mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 12:40:21 +02:00
Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d03a38067 | |||
| 79ed37699e | |||
| abf0f9e98a | |||
| 3955721bfc | |||
| c3855cebec | |||
| 41e6507665 | |||
| 5482ac590d | |||
| 28440eb936 | |||
| f606840216 | |||
| 6a990bf79f | |||
| ab5fed60fe | |||
| e3a215e081 | |||
| 7fa758602a | |||
| bb979911c8 | |||
| f225f8a6e7 | |||
| ee891fb8fa | |||
| 9d73ff4110 | |||
| 0b7ecf1599 | |||
| aa45f05c63 | |||
| 9234f12ed0 | |||
| 26e0bd227e | |||
| 64ce66a619 | |||
| 9302bbcbf3 | |||
| 1855f838ef | |||
| 11af06a752 | |||
| 05668591ef | |||
| e74beec32d | |||
| 3efb2c9a6c | |||
| d3af63e013 | |||
| ca6bee64b9 | |||
| b52d183dba | |||
| 23e1877a62 | |||
| d8736fe33e | |||
| a74365a4fd | |||
| 1c2163bb5f | |||
| 12c2677dc2 | |||
| ae1b59ce7f | |||
| 02c4de897d | |||
| 3c6242717e | |||
| 3c71afb9fa | |||
| e79107c644 | |||
| 0afce96c8d | |||
| d881997acd | |||
| 8906133b93 | |||
| b9e3c4a689 | |||
| 81f18e244d | |||
| ff91481018 | |||
| fdb3184c71 | |||
| aa3b2a18ec | |||
| d498016a47 | |||
| 492e0344b5 | |||
| a710c64909 | |||
| 943ba5dcbf | |||
| 371edd1a02 | |||
| 9fc6ba5b01 | |||
| 983bea61aa | |||
| ab74f7adf7 | |||
| bdd0bf45bd | |||
| 4b950fc23d | |||
| af183d95ad | |||
| 1d4d5d2337 | |||
| 653b72d86a | |||
| 12b6ef6350 | |||
| c979685a1f | |||
| 5b1d0261ff | |||
| 5bffc89a2f | |||
| 2ce612d5b1 | |||
| 6999e5aecf | |||
| 44a7910c69 | |||
| 85c6fde443 | |||
| 67b6dcc293 | |||
| 24d9f279ec | |||
| 83f93c27a1 | |||
| d756b7cd43 | |||
| 1e6f504a84 | |||
| abb4f3f9f0 | |||
| 5b0209a7ce | |||
| c1e0685e97 | |||
| 3c57b28c6d | |||
| cab62ff7b3 | |||
| 5b6dfed359 | |||
| 91a85a065b | |||
| 32e2c51365 | |||
| 1992fdf398 | |||
| 59ac27971a | |||
| cd0ace2a39 | |||
| 274c728e77 | |||
| 8547127f54 | |||
| 45d799aa98 | |||
| 660c90626d | |||
| d9dc42895f | |||
| 02fd21bcdb | |||
| af00d6ad14 | |||
| 0d4a518a08 | |||
| 05b43ca258 | |||
| 9d6ea845d7 | |||
| bbca4e8e39 | |||
| 49954ec4ad | |||
| 6e94625f2a | |||
| 0399ad040f | |||
| 726ed1102e | |||
| 5bc7d203a5 | |||
| ce204aaf16 | |||
| ed8a89c5ce | |||
| 66e7d9432e | |||
| d59a9522ee | |||
| f1661b62b3 | |||
| a98562f46b | |||
| 365bd24740 | |||
| d50ea4d067 | |||
| 52c96437fa | |||
| 2a7c8ec81b | |||
| bd6c606303 | |||
| 112120e8fe | |||
| 00f6e3a7b4 | |||
| 9cc72f951b | |||
| 0f484682d8 | |||
| 493caee715 | |||
| 0ae29744c8 |
@@ -383,6 +383,9 @@ jobs:
|
|||||||
- name: install luajit
|
- name: install luajit
|
||||||
run: sudo apt-get update && sudo apt-get install -y luajit
|
run: sudo apt-get update && sudo apt-get install -y luajit
|
||||||
|
|
||||||
|
- name: install Pillow
|
||||||
|
run: python3 -m pip install --upgrade pillow
|
||||||
|
|
||||||
- name: interpreter version
|
- name: interpreter version
|
||||||
run: luajit -v
|
run: luajit -v
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ return {
|
|||||||
{ "stop_music" },
|
{ "stop_music" },
|
||||||
{ "play_music", "Music_MeetRival" },
|
{ "play_music", "Music_MeetRival" },
|
||||||
{ "show_text", "_OaksLabRivalGrampsText" },
|
{ "show_text", "_OaksLabRivalGrampsText" },
|
||||||
|
-- callfar OaksLabPikachuMovementScript, before ShowObject (#1021)
|
||||||
|
{ "pikachu_make_way" },
|
||||||
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
|
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
|
||||||
{ "place_npc", RIVAL, 4, 7, "up" },
|
{ "place_npc", RIVAL, 4, 7, "up" },
|
||||||
{ "move_npc_to", RIVAL, 4, 3 },
|
{ "move_npc_to", RIVAL, 4, 3 },
|
||||||
@@ -192,12 +194,13 @@ return {
|
|||||||
end
|
end
|
||||||
rows[#rows + 1] = { "face_player_dir", "up" }
|
rows[#rows + 1] = { "face_player_dir", "up" }
|
||||||
rows[#rows + 1] = { "face_object", OAK1, "down" }
|
rows[#rows + 1] = { "face_object", OAK1, "down" }
|
||||||
-- OaksLabPlayerReceivedMonText: no nickname prompt -- the starter
|
-- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013)
|
||||||
-- Pikachu keeps its species name
|
|
||||||
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" }
|
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" }
|
||||||
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
|
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
|
||||||
rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
|
rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
|
||||||
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5, true }
|
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 }
|
||||||
|
-- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009)
|
||||||
|
rows[#rows + 1] = { "set_field", "pikachuInBall", true }
|
||||||
rows[#rows + 1] = { "set_flag", "EVENT_GOT_STARTER" }
|
rows[#rows + 1] = { "set_flag", "EVENT_GOT_STARTER" }
|
||||||
rows[#rows + 1] = { "set_flag", "EVENT_CHOSE_PIKACHU" }
|
rows[#rows + 1] = { "set_flag", "EVENT_CHOSE_PIKACHU" }
|
||||||
ow.runner:run(rows, { npc = npc, onDone = done })
|
ow.runner:run(rows, { npc = npc, onDone = done })
|
||||||
@@ -296,9 +299,9 @@ return {
|
|||||||
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
|
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
|
||||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||||
-- OaksLabPikachuEscapesPokeballScript: Pikachu hates its ball.
|
-- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009)
|
||||||
-- The overworld follower itself is still an open port
|
table.insert(rows, { "face_player_dir", "up" })
|
||||||
-- (docs/yellow-version.md runtime backlog); the story beat plays.
|
table.insert(rows, { "set_field", "pikachuInBall", false })
|
||||||
table.insert(rows, { "play_cry", "PIKACHU" })
|
table.insert(rows, { "play_cry", "PIKACHU" })
|
||||||
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
|
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
|
||||||
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
|
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
|
||||||
|
|||||||
+10
-15
@@ -1067,23 +1067,18 @@ local championsRoomRivalScript = {
|
|||||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
|
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
|
||||||
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
|
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
|
||||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
|
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
|
||||||
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement
|
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement.
|
||||||
-- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the
|
-- The player walks out after Oak instead of the screen just fading on the
|
||||||
-- screen just fading on the spot (#704). The entrance walk leaves the
|
-- spot (#704). Route one tile right before walking north so the player
|
||||||
-- player at (4,3) and both north-wall warps sit on row 0, so the original
|
-- reaches the north-wall HALL_OF_FAME warp without sharing the rival's
|
||||||
-- only ever spends three of those simulated steps -- CheckWarpsNoCollision
|
-- (4,2) cell. The original simulated movement bypasses entity collision,
|
||||||
-- takes the HALL_OF_FAME warp the moment the walk lands on (4,0) and the
|
-- but this scene should not visibly walk through the defeated rival.
|
||||||
-- trailing UP/LEFT are dropped. Scripted steps ignore collision here just
|
{ "move_player", "right", 1 }, -- 26
|
||||||
-- as they do in the original (CollisionCheckOnLand skips its checks while
|
{ "move_player", "up", 3 }, -- 27
|
||||||
-- wSimulatedJoypadStatesIndex is non-zero), so stepping through the
|
|
||||||
-- rival's cell at (4,2) is the ported behavior, not a clip. Re-reported
|
|
||||||
-- as a clip in #847 and re-checked against home/overworld.asm
|
|
||||||
-- CollisionCheckOnLand, which is still the authority: do not "fix" it.
|
|
||||||
{ "move_player", "up", 3 }, -- 26
|
|
||||||
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
||||||
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
||||||
{ "set_field", "pendingHallOfFame", true }, -- 27
|
{ "set_field", "pendingHallOfFame", true }, -- 28
|
||||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 28
|
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 29
|
||||||
}
|
}
|
||||||
|
|
||||||
M.CHAMPIONS_ROOM = {
|
M.CHAMPIONS_ROOM = {
|
||||||
|
|||||||
+16
-7
@@ -400,15 +400,24 @@ M.POKEMON_FAN_CLUB = {
|
|||||||
TEXT_POKEMONFANCLUB_CHAIRMAN = {
|
TEXT_POKEMONFANCLUB_CHAIRMAN = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 2
|
{ "check_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 2
|
||||||
{ "jump_if_true", 9 }, -- 3
|
{ "jump_if_true", "nothing_left" }, -- 3
|
||||||
{ "show_text", "_PokemonFanClubChairmanIntroText" }, -- 4
|
-- YesNoChoice (scripts/PokemonFanClub.asm): NO forfeits the voucher (#1050)
|
||||||
{ "show_text", "_PokemonFanClubChairmanStoryText" }, -- 5
|
{ "ask", "_PokemonFanClubChairmanIntroText" }, -- 4
|
||||||
|
{ "jump_if_false", "no_story" }, -- 5
|
||||||
|
{ "show_text", "_PokemonFanClubChairmanStoryText" }, -- 6
|
||||||
-- give-then-print like scripts/PokemonFanClub.asm (GiveItem
|
-- give-then-print like scripts/PokemonFanClub.asm (GiveItem
|
||||||
-- fills wStringBuffer; the received text reads it)
|
-- fills wStringBuffer; the received text reads it)
|
||||||
{ "give_item", "BIKE_VOUCHER", 1, false }, -- 6
|
{ "give_item", "BIKE_VOUCHER", 1, false }, -- 7
|
||||||
{ "show_text", "_PokemonFanClubReceivedBikeVoucherText" }, -- 7
|
{ "show_text", "_PokemonFanClubReceivedBikeVoucherText" }, -- 8
|
||||||
{ "set_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 8
|
{ "set_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 9
|
||||||
{ "show_text", "_PokemonFanClubExplainBikeVoucherText" }, -- 9
|
{ "show_text", "_PokemonFanClubExplainBikeVoucherText" }, -- 10
|
||||||
|
{ "jump", "end" }, -- 11
|
||||||
|
{ "label", "no_story" }, -- 12
|
||||||
|
{ "show_text", "_PokemonFanClubNoStoryText" }, -- 13
|
||||||
|
{ "jump", "end" }, -- 14
|
||||||
|
-- .nothingleft: the gift is done, he only reminisces now
|
||||||
|
{ "label", "nothing_left" }, -- 15
|
||||||
|
{ "show_text", "_PokemonFanClubChairFinalText" }, -- 16
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-25
@@ -27,11 +27,16 @@ local function ask(game, s, cb)
|
|||||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- fill the extracted text placeholders ({NUM:...}, {RAM:...}, {PLAYER})
|
-- fill text placeholders; key on the hram/wram symbol first, since one
|
||||||
|
-- string can carry two different NUM slots (#1006)
|
||||||
local function fill(s, subs)
|
local function fill(s, subs)
|
||||||
s = s:gsub("{PLAYER}", subs.player or "")
|
s = s:gsub("{PLAYER}", subs.player or "")
|
||||||
s = s:gsub("{NUM:[^}]*}", function() return tostring(subs.num or "") end)
|
s = s:gsub("{NUM:([%w_]*)[^}]*}", function(name)
|
||||||
s = s:gsub("{RAM:[^}]*}", function() return subs.ram or "" end)
|
return tostring(subs[name] or subs.num or "")
|
||||||
|
end)
|
||||||
|
s = s:gsub("{RAM:([%w_]*)[^}]*}", function(name)
|
||||||
|
return subs[name] or subs.ram or ""
|
||||||
|
end)
|
||||||
return s
|
return s
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -82,9 +87,12 @@ local function oaksAide(threshold, itemId, repeatText)
|
|||||||
{ ram = itemName, player = game.save.player.name }), done)
|
{ ram = itemName, player = game.save.player.name }), done)
|
||||||
end)
|
end)
|
||||||
else
|
else
|
||||||
|
-- .notEnoughOwnedMons prints owned then requirement, two counts
|
||||||
push(game, fill(t._OaksAideUhOhText or
|
push(game, fill(t._OaksAideUhOhText or
|
||||||
"You have only\ncaught {NUM:}!",
|
"You have only\ncaught {NUM:}!",
|
||||||
{ num = owned, ram = itemName }), done)
|
{ num = owned, ram = itemName,
|
||||||
|
hOaksAideNumMonsOwned = owned,
|
||||||
|
hOaksAideRequirement = threshold }), done)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -245,27 +253,32 @@ M.FIGHTING_DOJO = {
|
|||||||
|
|
||||||
M.SILPH_CO_7F = {
|
M.SILPH_CO_7F = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_SILPHCO7F_SILPH_WORKER_M1 = function(game, ow, npc, done)
|
-- command rows, not a Lua handler: give_pokemon needs a runner to AskName (#1049)
|
||||||
local t = text(game)
|
TEXT_SILPHCO7F_SILPH_WORKER_M1 = {
|
||||||
if game.save.flags.EVENT_GOT_LAPRAS then
|
{ "face_player" },
|
||||||
push(game, t._SilphCo7FSilphWorkerM1LaprasDescriptionText
|
{ "check_flag", "EVENT_GOT_LAPRAS" },
|
||||||
or "How is LAPRAS\ndoing?", done)
|
{ "jump_if_true", "has_lapras" },
|
||||||
return
|
{ "show_text", "_SilphCo7FSilphWorkerM1HaveThisPokemonText" },
|
||||||
end
|
{ "give_pokemon", "LAPRAS", 15 },
|
||||||
push(game, t._SilphCo7FSilphWorkerM1ThankYouText
|
{ "jump_if_false", "box_full" },
|
||||||
or "Thank you for\nsaving us!\fI want you to\nhave this LAPRAS!",
|
-- flag ahead of the jingle, like the Celadon EEVEE (#426)
|
||||||
function()
|
{ "set_flag", "EVENT_GOT_LAPRAS" },
|
||||||
game.save.flags.EVENT_GOT_LAPRAS = true
|
{ "play_sound", "Get_Item1" },
|
||||||
local Commands = require("src.script.Commands")
|
{ "show_text", "_GotMonText", { RAM = "LAPRAS" } },
|
||||||
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
|
{ "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
|
||||||
"LAPRAS", 15)
|
{ "jump", "end" },
|
||||||
push(game, ("%s got\nLAPRAS!"):format(game.save.player.name),
|
{ "label", "box_full" },
|
||||||
function()
|
{ "show_text", "_BoxIsFullText" },
|
||||||
push(game, t._SilphCo7FSilphWorkerM1LaprasDescriptionText
|
{ "jump", "end" },
|
||||||
or "It's a good\nswimmer!", done)
|
-- SilphCo7F.asm .saved_silph gates the thanks on Giovanni
|
||||||
end)
|
{ "label", "has_lapras" },
|
||||||
end)
|
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||||
end,
|
{ "jump_if_true", "saved" },
|
||||||
|
{ "show_text", "_SilphCo7FSilphWorkerM1IsOurPresidentOkText" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
{ "label", "saved" },
|
||||||
|
{ "show_text", "_SilphCo7FSilphWorkerM1SavedText" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ the same core data and graphics into the source tree for verification.
|
|||||||
| | `src/core/SaveData.lua` | Lua-serialized save in the LÖVE save dir |
|
| | `src/core/SaveData.lua` | Lua-serialized save in the LÖVE save dir |
|
||||||
| render | `src/render/Renderer.lua` | 160x144 canvas, integer nearest scaling |
|
| render | `src/render/Renderer.lua` | 160x144 canvas, integer nearest scaling |
|
||||||
| | `src/render/TileRenderer.lua` | one SpriteBatch per map (8x8 quads) + border-block ring |
|
| | `src/render/TileRenderer.lua` | one SpriteBatch per map (8x8 quads) + border-block ring |
|
||||||
| | `src/render/SpriteRenderer.lua` | 6-frame walker sheets, flipped right facing |
|
| | `src/render/SpriteRenderer.lua` | variable-size anchored sprite sheets, 6-frame walkers and flipped right facing |
|
||||||
| | `src/render/Font.lua` | glyph rendering via charmap (greedy longest match) |
|
| | `src/render/Font.lua` | glyph rendering via charmap (greedy longest match) |
|
||||||
| | `src/render/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page |
|
| | `src/render/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page |
|
||||||
| | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades |
|
| | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades |
|
||||||
|
|||||||
+195
-4
@@ -35,6 +35,16 @@ An edited vanilla map becomes a `mod.content.maps:patch` carrying only the
|
|||||||
fields that moved; a new map becomes a `:register`. See
|
fields that moved; a new map becomes a `:register`. See
|
||||||
`docs/new-features.md` and the extension's own README.
|
`docs/new-features.md` and the extension's own README.
|
||||||
|
|
||||||
|
## Read-only map overviews
|
||||||
|
|
||||||
|
`mod.world:mapOverview()` returns collision `rows` at map-cell resolution,
|
||||||
|
optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
||||||
|
4x resolution. Visual rows contain Game Boy shades from `"0"` (lightest) to
|
||||||
|
`"3"` (darkest); their matching width and height fields describe the grid.
|
||||||
|
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for
|
||||||
|
`warp`, visible `item`, and untaken `hidden` locations. All fields are
|
||||||
|
read-only snapshots; mods choose which layers to render.
|
||||||
|
|
||||||
## Rendering pipelines
|
## Rendering pipelines
|
||||||
|
|
||||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||||
@@ -100,6 +110,44 @@ Three rules worth knowing:
|
|||||||
Returning `nil` from `drawWorld` is a normal answer meaning "not this
|
Returning `nil` from `drawWorld` is a normal answer meaning "not this
|
||||||
frame"; the engine draws the vanilla world instead.
|
frame"; the engine draws the vanilla world instead.
|
||||||
|
|
||||||
|
## Variable-size overworld sprites
|
||||||
|
|
||||||
|
The `sprites` registry keeps the vanilla 16x16 grounded walker as its default,
|
||||||
|
but a mod can describe any frame rectangle and anchor for player characters,
|
||||||
|
NPCs, followers, mounts, vehicles, bosses, or other field actors:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.content.sprites:register("SPRITE_COMPANION", {
|
||||||
|
image = "mods/example/companion.png", -- one frame per row
|
||||||
|
frames = 6,
|
||||||
|
walker = true,
|
||||||
|
frameWidth = 32,
|
||||||
|
frameHeight = 32,
|
||||||
|
anchorX = 16, -- frame-relative bottom-center anchor
|
||||||
|
anchorY = 32,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`frameWidth` and `frameHeight` are sheet pixels. `anchorX` and `anchorY` are
|
||||||
|
measured from each frame's top-left; when omitted they default to the frame's
|
||||||
|
horizontal center and bottom edge, so a larger sprite grows upward while its
|
||||||
|
feet stay on the same world cell. Omitting all four fields is exactly the
|
||||||
|
vanilla 16x16 placement. The normal player/NPC/follower draw paths consume
|
||||||
|
these values automatically, including horizontal flips and the fishing pose.
|
||||||
|
|
||||||
|
Custom render pipelines can use the same geometry without reproducing the
|
||||||
|
pose rules:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local geometry = sprite:getPoseGeometry(facing, walkPhase, stepFlip)
|
||||||
|
-- geometry.quad, .x/.y/.width/.height, .anchorX/.anchorY, .mirror
|
||||||
|
local originX, originY = sprite:getScreenOrigin(px, py, camX, camY)
|
||||||
|
```
|
||||||
|
|
||||||
|
`getFrameGeometry(frame)` is the corresponding accessor for a specific
|
||||||
|
zero-based sheet frame. Both accessors return fresh tables and share the
|
||||||
|
renderer’s frame selection and mirror conventions.
|
||||||
|
|
||||||
## Battle sprite scaling
|
## Battle sprite scaling
|
||||||
|
|
||||||
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
|
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
|
||||||
@@ -140,6 +188,90 @@ default** (1x front, 2x back).
|
|||||||
ball-to-pic grow multiplies your scale through each stage, so a rescaled
|
ball-to-pic grow multiplies your scale through each stage, so a rescaled
|
||||||
mon still grows into place from the ball, grounded the whole way.
|
mon still grows into place from the ball, grounded the whole way.
|
||||||
|
|
||||||
|
## Durable tool storage and runtime checkpoints
|
||||||
|
|
||||||
|
`mod.save` remains the right place for state that should travel with the next
|
||||||
|
normal Pokémon SAVE. Tools that need independently written, larger data-only
|
||||||
|
records can use `mod.storage`; the engine scopes every logical key by game
|
||||||
|
version, opaque playthrough identity, and mod id, and routes it through the same
|
||||||
|
standard or portable persistence backend as saves:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local context, code, message = mod.storage:context(game)
|
||||||
|
local ok, code, message = mod.storage:write(game, "history/quick/q0001", {
|
||||||
|
format = 1, createdAt = os.time(), payload = { money = 3000 },
|
||||||
|
})
|
||||||
|
local value, code, message = mod.storage:read(game, "history/quick/q0001")
|
||||||
|
local keys, code, message = mod.storage:list(game, "history/quick")
|
||||||
|
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
|
||||||
|
```
|
||||||
|
|
||||||
|
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
|
||||||
|
version is compatibility metadata; physical launcher-slot and path identity stays
|
||||||
|
private.
|
||||||
|
|
||||||
|
Values must be tables containing serializable data only. Keys are conservative
|
||||||
|
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
|
||||||
|
handles are never exposed. Writes are staged and decode-verified, reads recover
|
||||||
|
from a valid staged/backup generation, and methods return structured errors for
|
||||||
|
normal data or I/O failures. The playthrough identity is allocated lazily on the
|
||||||
|
first storage/checkpoint call, so an unused API changes no save bytes.
|
||||||
|
|
||||||
|
`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local capability = mod.checkpoints:inspect(game)
|
||||||
|
if capability.canCapture then
|
||||||
|
local checkpoint, code, message = mod.checkpoints:capture(game)
|
||||||
|
-- Store the detached data-only checkpoint through mod.storage.
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, code, message = mod.checkpoints:restore(game, checkpoint)
|
||||||
|
```
|
||||||
|
|
||||||
|
Checkpoint format 1 supports settled overworld control and proven battle
|
||||||
|
player-decision safe points. Battle checkpoints are limited to ordinary
|
||||||
|
single-player wild/trainer origins with no suspended script; link, Safari,
|
||||||
|
ghost, demo, scripted, animation, message, queue, and forced-action phases fail
|
||||||
|
closed. New checkpoints preserve gameplay RNG, while legacy overworld records
|
||||||
|
without RNG remain loadable. Capture excludes global options and runtime
|
||||||
|
objects. Restore validates format, game/playthrough identity, content,
|
||||||
|
coordinates, battle relationships, continuation, and RNG before mutation;
|
||||||
|
preserves current options; suppresses normal map-entry/save-load/intro side
|
||||||
|
effects; verifies a recapture; and rolls back runtime plus RNG in memory if
|
||||||
|
reconstruction fails. Callers that need crash recovery should durably capture
|
||||||
|
their own recovery checkpoint before restore.
|
||||||
|
|
||||||
|
Checkpoint ownership follows the persistence model rather than mod identity:
|
||||||
|
|
||||||
|
- canonical `game.save` progress, including every mod's `save.modData` /
|
||||||
|
`mod.save` bucket and data-only fields added to saved Pokémon, rewinds;
|
||||||
|
- global and per-mod options remain at their current values;
|
||||||
|
- independently written `mod.storage` records do not rewind; and
|
||||||
|
- mod-owned runtime objects, references, and caches are never serialized.
|
||||||
|
|
||||||
|
Successful restore emits `checkpoint.restored` only after reconstruction and
|
||||||
|
differential recapture have committed. Mods that cache rewound progress or hold
|
||||||
|
references to reconstructed runtime objects can re-read their own public state
|
||||||
|
and rebuild at that point:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.events:on("checkpoint.restored", function(ev)
|
||||||
|
-- ev.kind is "overworld" or "battle"; ev.game is fully reconstructed.
|
||||||
|
cachedQuestStage = mod.save:get("quest_stage", 0)
|
||||||
|
rebuildRuntimeFor(ev.game, ev.kind)
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
The event is not emitted for validation failure, failed reconstruction, or a
|
||||||
|
successful rollback. Its payload contains no checkpoint data or other mod's
|
||||||
|
private state. A mod that deliberately stores progress-coupled truth in
|
||||||
|
`mod.storage` must version and reconcile that relationship itself; the engine
|
||||||
|
cannot distinguish it safely from independent history, configuration, or cache
|
||||||
|
data.
|
||||||
|
|
||||||
|
See RFC 0003, RFC 0004, and RFC 0005 for exact contracts and error codes.
|
||||||
|
|
||||||
## Developer console
|
## Developer console
|
||||||
|
|
||||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||||
@@ -221,10 +353,11 @@ the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
|
|||||||
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
|
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
|
||||||
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
|
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
|
||||||
palette-correct blit of either canvas into an arbitrary screen rect, and the
|
palette-correct blit of either canvas into an arbitrary screen rect, and the
|
||||||
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`)
|
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `pollTouch()` /
|
||||||
for driving a second physical display. This is what lets a mod lay the two
|
`setEnabled`) for driving a second physical display. `pollTouch()` returns the
|
||||||
passes out as two stacked Game Boy screens, or push one onto a second screen,
|
oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
|
||||||
without the engine knowing the layout.
|
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
|
||||||
|
or push one onto a second screen, without the engine knowing the layout.
|
||||||
|
|
||||||
`screen.render_visible` receives `(next, state)` while the main screen is being
|
`screen.render_visible` receives `(next, state)` while the main screen is being
|
||||||
composed. Return `false` to omit that state from drawing, opacity selection and
|
composed. Return `false` to omit that state from drawing, opacity selection and
|
||||||
@@ -233,5 +366,63 @@ update and input ownership, so a mod can mirror a native menu on another
|
|||||||
display without reimplementing it. The default is `true`. Treat the wrapper as
|
display without reimplementing it. The default is `true`. Treat the wrapper as
|
||||||
a pure predicate: the renderer may ask it more than once per frame.
|
a pure predicate: the renderer may ask it more than once per frame.
|
||||||
|
|
||||||
|
Scrollable list states expose `state.kind` for use with this hook. Generic
|
||||||
|
lists fall back to their title; PC lists use stable, localization-independent
|
||||||
|
identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
|
||||||
|
`pc_box_change`, `pc_item_withdraw`, `pc_item_deposit`, and `pc_item_toss`.
|
||||||
|
|
||||||
|
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
|
||||||
|
control the battle text/menu layer and the HP/status panels. Both receive
|
||||||
|
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
|
||||||
|
Pushed text boxes also pass through `battle.bottom_ui_visible`; a wrapper that
|
||||||
|
only owns battle presentation should return `false` only for its active battle
|
||||||
|
or text-box state.
|
||||||
|
|
||||||
|
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
|
||||||
|
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
|
||||||
|
option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever
|
||||||
|
category `Game.speedCategoryInStack` says is active right now. A mod may
|
||||||
|
call `next(game)` and return its result to pass that resolution through, or
|
||||||
|
return a different number outright to override it for that frame (a bot mod
|
||||||
|
forcing 1X for one route segment, say, regardless of the category or saved
|
||||||
|
option). The result is clamped to the nearest valid `GameSpeed.LEVELS` entry
|
||||||
|
regardless of what a subscriber returns, so a bad value (0, negative, `nil`)
|
||||||
|
cannot destabilize the fixed-step accumulator. This hook runs *after* link
|
||||||
|
play's 1X lock and the `--speed`/equivalent run-argument override, both of
|
||||||
|
which stay unconditional and are never visible to a subscriber.
|
||||||
|
|
||||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||||
that reach outside their permission set.
|
that reach outside their permission set.
|
||||||
|
|
||||||
|
## Process-lifecycle hooks
|
||||||
|
|
||||||
|
These exist so a platform-specific launcher integration (a native shell
|
||||||
|
that embeds this engine and wraps its window in platform UI) can live
|
||||||
|
entirely in a mod instead of hand-patching `main.lua`, which every other
|
||||||
|
engine change also touches.
|
||||||
|
|
||||||
|
`core.update` receives `(next, game, dt)` once per frame from
|
||||||
|
`love.update`. Vanilla behavior is `game:update(dt)`, unconditionally. A
|
||||||
|
mod may skip calling `next(game, dt)` to pause the simulation for that
|
||||||
|
frame (e.g. while a native settings sheet is on top), and may run
|
||||||
|
additional per-frame polling before or after that call regardless of
|
||||||
|
whether it calls `next` -- useful for one-shot flags that must be observed
|
||||||
|
every frame even while paused.
|
||||||
|
|
||||||
|
`core.quit_to_launcher` receives `(next)` once from `love.quit()`. `next()`
|
||||||
|
returns the engine's own decision for whether closing the window should
|
||||||
|
return to the Lua launcher instead of exiting; a mod may return `false`
|
||||||
|
outright, without ever calling `next`, to veto that and let the process
|
||||||
|
really quit -- for a platform host that owns its own "return to launcher"
|
||||||
|
UI and would otherwise get looped straight back into the game it just
|
||||||
|
quit.
|
||||||
|
|
||||||
|
A manifest may also declare `force_enable_env`, an environment variable
|
||||||
|
name that re-enables the mod regardless of a saved disable in
|
||||||
|
`options.mods` when that variable is set to `"1"`. This is for a mod that
|
||||||
|
cannot function disabled on the one build where its env var is set (a
|
||||||
|
platform-bridge mod bundled only with that build's launcher, for example).
|
||||||
|
|
||||||
|
Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call`
|
||||||
|
already falls straight through to the vanilla function when no mod has
|
||||||
|
wrapped the name, at negligible cost.
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# RFC 0003 — Add a reusable multiplayer session layer
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed. Engine: `Session.lua`, `Net.lua`, `LinkState.lua`,
|
||||||
|
`Tournament.lua`. Tests: `link_session.lua`.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Link play and tournaments currently own transport lifecycle details and
|
||||||
|
temporarily remove and reinsert packets in `Net.inbox` when a handshake or
|
||||||
|
battle starts. That makes packet ownership fragile and gives a future
|
||||||
|
shared-world mode no stable host/guest-aware boundary to reuse.
|
||||||
|
|
||||||
|
The engine needs one small layer that preserves today's wire protocol while
|
||||||
|
owning received-packet order and terminal cleanup. Pokémon, battle, tournament,
|
||||||
|
save, and overworld rules remain outside that layer.
|
||||||
|
|
||||||
|
## The decision it extends
|
||||||
|
|
||||||
|
Extends the existing split between `Net` (backend setup, framing, and relay
|
||||||
|
controls), `Handshake`/`Protocol` (mode payloads), and the states that
|
||||||
|
interpret those payloads. It does not replace any of those components.
|
||||||
|
|
||||||
|
## The exact API delta
|
||||||
|
|
||||||
|
Backward-compatible and internal-only.
|
||||||
|
|
||||||
|
### `Session.new(transport, options)`
|
||||||
|
|
||||||
|
Wraps one successfully configured Net-compatible transport. `options.role`
|
||||||
|
is exactly `"host"` or `"guest"`; `options.kind` is a non-empty
|
||||||
|
local label such as `"link"` or `"tournament"`. Role and kind are
|
||||||
|
immutable session metadata selected locally and are never inferred from peer
|
||||||
|
packets.
|
||||||
|
|
||||||
|
The facade forwards the narrow fields current consumers need:
|
||||||
|
`paired`, `code`, `address`, `target`,
|
||||||
|
`error`, and `closed`.
|
||||||
|
It forwards valid outbound tables unchanged through `send(message)`.
|
||||||
|
|
||||||
|
### Receive and lifecycle methods
|
||||||
|
|
||||||
|
- `update()` pumps the transport, validates decoded inbound values, and
|
||||||
|
appends accepted messages to a private FIFO.
|
||||||
|
- `pollOne()` removes the oldest queued message.
|
||||||
|
- `poll()` removes every queued message in order.
|
||||||
|
- `take(type)` removes the first queued message with that type without
|
||||||
|
disturbing any other message.
|
||||||
|
- `hasPending()` reports whether the FIFO is non-empty.
|
||||||
|
- `getRole()`, `getKind()`, `getStatus()`, and
|
||||||
|
`getFailure()` expose local metadata and lifecycle.
|
||||||
|
- `close()` closes the underlying transport once and is safe to repeat.
|
||||||
|
|
||||||
|
Statuses are `connecting`, `paired`, `draining`, `closed`,
|
||||||
|
and `failed`. A transport close or failure becomes `draining` while
|
||||||
|
accepted packets remain queued. The terminal `closed`/`error`
|
||||||
|
compatibility projection appears only after that FIFO drains, so a last packet
|
||||||
|
travelling with a disconnect remains observable.
|
||||||
|
|
||||||
|
An inbound value is structurally valid only when it is a table with a string
|
||||||
|
`type`. Invalid decoded values end the session with a protocol failure.
|
||||||
|
Unknown but structurally valid types remain queued for the owning mode; the
|
||||||
|
session does not contain a packet allowlist.
|
||||||
|
|
||||||
|
## Authority direction
|
||||||
|
|
||||||
|
A later `WorldSession` may compose this facade. In that mode the host
|
||||||
|
will own the world snapshot, map state, NPC state, event results, and shared
|
||||||
|
progression. A guest will bring a trainer identity plus their Pokémon party,
|
||||||
|
inventory, and other explicitly selected profile snapshot.
|
||||||
|
|
||||||
|
Guest profile data and commands will be untrusted input. The host must validate
|
||||||
|
them and must authorize every world mutation before rebroadcasting the result.
|
||||||
|
The concrete snapshot schema, command vocabulary, conflict rules, and
|
||||||
|
persistence policy require a separate RFC and are not introduced here.
|
||||||
|
|
||||||
|
## Compatibility and security
|
||||||
|
|
||||||
|
No packet envelope, message name, payload shape, framing rule, relay protocol,
|
||||||
|
save schema, or engine protocol version changes. Existing valid outbound
|
||||||
|
messages encode exactly as before, and existing link and tournament screens
|
||||||
|
keep their current player-facing behavior.
|
||||||
|
|
||||||
|
The layer does not authenticate players or encrypt traffic. Existing LAN and
|
||||||
|
relay access assumptions remain unchanged; knowledge of a join address or code
|
||||||
|
still grants the same access it grants today. Authentication, reconnect
|
||||||
|
identity, rate limits, and abuse controls remain future protocol decisions.
|
||||||
|
|
||||||
|
## Migration note for players, mods, and peers
|
||||||
|
|
||||||
|
**Nothing.** `LinkState` and `Tournament` adopt the facade
|
||||||
|
internally. Existing peers receive the same messages, mods gain no new API, and
|
||||||
|
players do not migrate saves or settings.
|
||||||
|
|
||||||
|
## Parity tests
|
||||||
|
|
||||||
|
- **ROM-free facade:** constructor validation, immutable role/kind, unchanged
|
||||||
|
send shape, FIFO ordering, typed retrieval, unknown typed packets, draining,
|
||||||
|
terminal failure latching, protected transport calls, and decoded-value
|
||||||
|
rejection.
|
||||||
|
- **Existing modes:** source guards prohibit direct inbox mutation; headless
|
||||||
|
module loads and the complete engine tier cover both migrated states.
|
||||||
|
- **ROM-backed link play:** run the existing link driver when generated ROM
|
||||||
|
data is available; the normal quick suite remains the required baseline.
|
||||||
|
|
||||||
|
## Deprecation etiquette and non-goals
|
||||||
|
|
||||||
|
Nothing deprecated. This RFC adds an internal facade and removes no transport
|
||||||
|
method.
|
||||||
|
|
||||||
|
It does not add shared-world packets, co-op screens, a remote actor, save
|
||||||
|
transfer, server persistence, matchmaking, reconnect, or a protocol-version
|
||||||
|
bump. Those changes require the world-specific layer and its own review.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# RFC 0003 — Playthrough-scoped mod storage
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed. Engine: `SaveData.lua`, `SaveSerializer.lua`, `Storage.lua`,
|
||||||
|
`Loader.lua`. Tests: `playthrough_identity.lua`, `storage.lua`, the existing
|
||||||
|
save-slot and mod-save suites.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
`mod.save` intentionally lives inside the normal progress record. That is the
|
||||||
|
right home for quest state, but not for independent tool data such as replay
|
||||||
|
captures, checkpoint histories, or recovery records: writing it would require a
|
||||||
|
normal Pokémon SAVE, and storing copies of progress beneath `save.modData` would
|
||||||
|
recursively embed the save that contains them.
|
||||||
|
|
||||||
|
Mods also cannot safely infer which launcher slot or portable filesystem backs
|
||||||
|
the active playthrough. Direct filesystem access would expose private paths and
|
||||||
|
make isolation dependent on engine implementation details.
|
||||||
|
|
||||||
|
## The decision it extends
|
||||||
|
|
||||||
|
Extends the per-mod persistence contract documented in `docs/modding.md` and the
|
||||||
|
wiki's Save Model. `mod.save` and `mod.options` keep their existing behavior.
|
||||||
|
|
||||||
|
## The exact API delta
|
||||||
|
|
||||||
|
Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage`
|
||||||
|
facade to the calling mod id. Mods receive logical keys and decoded values, never
|
||||||
|
filesystem handles or physical paths.
|
||||||
|
|
||||||
|
### Lazy opaque playthrough identity
|
||||||
|
|
||||||
|
`SaveData.ensurePlaythroughId(save[, fs]) -> id | nil` allocates an opaque
|
||||||
|
32-hex-character identity without consuming gameplay RNG. It is called only when
|
||||||
|
`mod.storage` or `mod.checkpoints` first needs a scope; New Game, ordinary SAVE,
|
||||||
|
and ordinary load remain byte-compatible when no caller uses either API.
|
||||||
|
|
||||||
|
The id is stored in `save.meta.playthroughId` after allocation. Until the next
|
||||||
|
ordinary SAVE writes it into progress, a mapping in `options.lua` keeps legacy
|
||||||
|
saves stable by game version and active launcher slot (or the legacy flat-save
|
||||||
|
scope). A newly created playthrough never adopts the previous playthrough's
|
||||||
|
mapping for that slot.
|
||||||
|
|
||||||
|
`SaveData.persistenceFs([fs])` is engine-only routing used by the storage
|
||||||
|
implementation. It follows the same standard/portable backend as progress and
|
||||||
|
honors injected test filesystems; it is not exposed on the mod object.
|
||||||
|
|
||||||
|
### `mod.storage:context(game)`
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
{ engineVersion = "0.9.0", gameVersion = "red", playthroughId = "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
or `nil, code, message`. `engineVersion` is warning-grade compatibility metadata;
|
||||||
|
the context intentionally omits launcher slot ids and paths.
|
||||||
|
|
||||||
|
### `mod.storage:write(game, key, value)`
|
||||||
|
|
||||||
|
Accepts a data-only table and returns `true`, or
|
||||||
|
`false, code, message`. Keys are nonempty slash-separated segments containing
|
||||||
|
letters, digits, underscore, or dash. Empty segments, leading/trailing slash,
|
||||||
|
`.`/`..`, and other characters are rejected.
|
||||||
|
|
||||||
|
The engine encodes deterministically, stages and decodes a `.tmp` witness,
|
||||||
|
preserves the previous valid generation, writes and decodes the main record,
|
||||||
|
then rolls the verified bytes to `.bak`. A failed stage or replacement leaves a
|
||||||
|
verified prior generation readable.
|
||||||
|
|
||||||
|
### `mod.storage:read(game, key)`
|
||||||
|
|
||||||
|
Returns a freshly decoded table, or `nil, code, message`. It tries main, staged,
|
||||||
|
then backup data. A valid staged/backup value is returned and promoted
|
||||||
|
best-effort; corrupt bytes are never executed.
|
||||||
|
|
||||||
|
### `mod.storage:list(game[, prefix])`
|
||||||
|
|
||||||
|
Returns sorted logical keys beneath a valid prefix, an exact key when the prefix
|
||||||
|
names one, or `nil, code, message`. Physical witness filenames are hidden.
|
||||||
|
|
||||||
|
### `mod.storage:delete(game, key)`
|
||||||
|
|
||||||
|
Deletes only that key's main, backup, and staged witnesses. Returns `true`, or
|
||||||
|
`false, code, message`.
|
||||||
|
|
||||||
|
### Scope and errors
|
||||||
|
|
||||||
|
Physical records are scoped as:
|
||||||
|
|
||||||
|
`persistence root / mod_storage / game version / playthrough id / mod id`
|
||||||
|
|
||||||
|
Stable error codes are `not_in_playthrough`, `storage_unavailable`,
|
||||||
|
`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and
|
||||||
|
`not_found`. Ordinary data and I/O failures are return values, not callback-
|
||||||
|
terminating errors.
|
||||||
|
|
||||||
|
The restricted serializer's recursive writer runs outside LuaJIT traces. A
|
||||||
|
1,000-process GC stress regression found compiled recursion could intermittently
|
||||||
|
drop a newly inserted nested identity entry and produce undecodable bytes; save
|
||||||
|
encoding is infrequent and I/O-bound, so interpreter execution is the safe
|
||||||
|
boundary.
|
||||||
|
|
||||||
|
## Migration note for existing mods
|
||||||
|
|
||||||
|
**Nothing.** No API is removed, no manifest field changes, and no storage path or
|
||||||
|
playthrough id is created unless a mod invokes `mod.storage` or
|
||||||
|
`mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path.
|
||||||
|
|
||||||
|
## Parity tests
|
||||||
|
|
||||||
|
- **No-mod:** New Game plus ordinary save/load creates no identity or storage
|
||||||
|
file; the existing save-slot and mod-save suites remain green.
|
||||||
|
- **Engine identity:** lazy allocation, save/load preservation, stable legacy
|
||||||
|
mapping, fresh-playthrough replacement, and version/slot isolation.
|
||||||
|
- **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip,
|
||||||
|
deterministic listing, key rejection, mod/game/playthrough isolation,
|
||||||
|
corrupt-main recovery, failure retention, exact delete, and no-mod no-write.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing deprecated. The additions are one bound public facade and engine-private
|
||||||
|
persistence/identity helpers.
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# RFC 0004 — Stable runtime checkpoints for mods
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed. Engine: `Checkpoint.lua`, `Game.lua`, `OverworldController.lua`,
|
||||||
|
`Loader.lua`. Tests: `checkpoints.lua`, existing world and engine suites.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Mods can observe world events and request semantic actions, but no supported API
|
||||||
|
can capture canonical progress at a proven-safe runtime boundary or reconstruct
|
||||||
|
the overworld without replaying map-entry scripts. Reaching into the state stack,
|
||||||
|
controller, ScriptRunner, or save restore internals would bind distributable mods
|
||||||
|
to private objects and can duplicate story side effects.
|
||||||
|
|
||||||
|
The engine is the only component that can authoritatively decide whether the
|
||||||
|
runtime is settled and rebuild its controller objects. A generic checkpoint seam
|
||||||
|
lets tools store data-only records while keeping those responsibilities private.
|
||||||
|
|
||||||
|
## The decision it extends
|
||||||
|
|
||||||
|
Extends the public world/tool surfaces in `docs/modding.md`. It does not change
|
||||||
|
`mod.world`, normal CONTINUE, vanilla SAVE, or save lifecycle hooks/events.
|
||||||
|
|
||||||
|
## The exact API delta
|
||||||
|
|
||||||
|
Backward-compatible, additive-only. `Loader:_api` binds `mod.checkpoints`; mods
|
||||||
|
never receive `Game`, StateStack, controller, coroutine, renderer, or filesystem
|
||||||
|
internals inside a checkpoint.
|
||||||
|
|
||||||
|
### `mod.checkpoints:inspect(game)`
|
||||||
|
|
||||||
|
Returns a capability record. Stable overworld control returns:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
{ canCapture = true, canRestore = true, kind = "overworld" }
|
||||||
|
```
|
||||||
|
|
||||||
|
A refusal returns the same booleans as `false` plus `kind`, `reason`, and a
|
||||||
|
player-readable `message`. Format-1 supports only an overworld whose controller
|
||||||
|
is topmost, player movement has settled on a tile, and no transition, foreground
|
||||||
|
or parallel ScriptRunner, queued script, scripted move, engagement, emote,
|
||||||
|
teleport, field animation, or similar partial controller mutation is active.
|
||||||
|
|
||||||
|
Refusal reasons are `not_in_playthrough`, `not_overworld`, `screen_busy`,
|
||||||
|
`transition_busy`, `script_busy`, `animation_busy`, and `movement_busy`.
|
||||||
|
Identity allocation is lazy and happens only after an active topmost overworld
|
||||||
|
has been established.
|
||||||
|
|
||||||
|
### `mod.checkpoints:capture(game)`
|
||||||
|
|
||||||
|
Returns a detached data-only format-1 checkpoint, or
|
||||||
|
`nil, code, message`:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
{
|
||||||
|
format = 1,
|
||||||
|
kind = "overworld",
|
||||||
|
identity = {
|
||||||
|
engineVersion = "...", gameVersion = "red", playthroughId = "...",
|
||||||
|
},
|
||||||
|
save = { -- canonical dynamic progress, excluding global options },
|
||||||
|
runtime = { overworld = {
|
||||||
|
map = "PALLET_TOWN", x = 5, y = 6,
|
||||||
|
facing = "down", surfing = false,
|
||||||
|
} },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`engineVersion` is metadata for caller compatibility warnings; the engine does
|
||||||
|
not reject patch/minor mismatches on restore. Capture deep-copies through the restricted serializer before and after
|
||||||
|
`OverworldController:captureSave` synchronizes live map, tile, facing, and surf
|
||||||
|
state. It excludes `save.options`, functions, userdata, threads, metatables as
|
||||||
|
behavior, controller instances, and static content registries. Failure code
|
||||||
|
`capture_failed` covers non-data progress and synchronization errors.
|
||||||
|
|
||||||
|
### `mod.checkpoints:restore(game, checkpoint)`
|
||||||
|
|
||||||
|
Returns `true`, or `false, code, message`. Before mutation it requires the current
|
||||||
|
runtime to be capturable and validates a detached copy of the complete record:
|
||||||
|
format, kind, internal identity consistency, current game/playthrough identity,
|
||||||
|
map availability, integral in-bounds tile, facing, surfing, and synchronized save
|
||||||
|
position.
|
||||||
|
|
||||||
|
Validation codes are `invalid_checkpoint`, `invalid_content`, `unsupported_format`,
|
||||||
|
`unsupported_runtime_kind`, `wrong_game`, `wrong_playthrough`, `invalid_map`, and
|
||||||
|
`invalid_position`, in addition to the capability refusal reasons.
|
||||||
|
|
||||||
|
The canonical save validator runs against the detached record. Unlike ordinary
|
||||||
|
CONTINUE, a checkpoint never accepts a quarantine, remap, reclaim, clamp, or
|
||||||
|
repair: any such content change returns `invalid_content` before live mutation.
|
||||||
|
|
||||||
|
The engine captures an in-memory rollback checkpoint, preserves current global
|
||||||
|
options, then reconstructs semantic overworld state through
|
||||||
|
`Game:restoreCheckpointSave`. Checkpoint entry suppresses normal map exit/entry
|
||||||
|
events, `onEnter` scripts, forced-movement/current checks, and last-map rewrites;
|
||||||
|
it does not emit normal `save.loading`/`save.loaded` lifecycle events. After
|
||||||
|
reconstruction, the engine recaptures and byte-compares normalized data. A failed
|
||||||
|
apply rolls back and returns `restore_failed`; failure of that rollback returns
|
||||||
|
`rollback_failed`. Only after a successful comparison does the engine emit
|
||||||
|
`checkpoint.restored` with `{ game = game, kind = "overworld" }`. Validation
|
||||||
|
failure, failed apply, and successful rollback emit nothing.
|
||||||
|
|
||||||
|
Durable recovery remains a caller responsibility: in-memory rollback handles a
|
||||||
|
runtime exception, not process termination.
|
||||||
|
|
||||||
|
## Runtime boundary and future kinds
|
||||||
|
|
||||||
|
This RFC's original Level A contract intentionally rejects battles, menus,
|
||||||
|
transitions, animations, and suspended/queued scripts. RFC 0005 subsequently
|
||||||
|
adds a separately inventoried `battle` kind with deterministic RNG and
|
||||||
|
differential reconstruction tests; it does not broaden script or arbitrary-frame
|
||||||
|
support implied here.
|
||||||
|
|
||||||
|
## Migration note for existing mods
|
||||||
|
|
||||||
|
**Nothing required.** No existing hook, save, controller, or world action changes
|
||||||
|
when `mod.checkpoints` is unused. The reconstruction path is called only by a
|
||||||
|
successful public restore after validation. Mods whose runtime caches derive from
|
||||||
|
rewound `game.save` or `mod.save` state may optionally subscribe to
|
||||||
|
`checkpoint.restored` and rebuild from their own public state.
|
||||||
|
|
||||||
|
## Parity tests
|
||||||
|
|
||||||
|
- **No-mod:** the complete ROM-free engine suite and existing world behavior stay
|
||||||
|
green; ordinary New Game/save/load allocates no checkpoint identity.
|
||||||
|
- **Public Mod API:** a real API-2 entry chunk proves stable inspection and every
|
||||||
|
unsafe refusal, detached data-only capture, exact map/tile/facing/surf sync,
|
||||||
|
`A -> mutate B -> restore A -> recapture A2` equality across representative
|
||||||
|
progress, settings preservation, compatibility rejection without mutation,
|
||||||
|
map-side-effect suppression, injected reconstruction rollback, mod-owned
|
||||||
|
metadata and `mod.save` rewind, independent `mod.storage`/options preservation,
|
||||||
|
and success-only runtime-cache reconciliation through `checkpoint.restored`.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing deprecated. This adds one public facade and a checkpoint-only semantic
|
||||||
|
reconstruction route.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# RFC 0005 — Persistent battle safe-point checkpoints
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed. Extends RFC 0004. Engine: `BattleCheckpoint.lua`, `Checkpoint.lua`,
|
||||||
|
`Game.lua`, `BattleState.lua`, and `OverworldController.lua`. Tests:
|
||||||
|
`battle_checkpoint_*.lua`, `checkpoints.lua`, and the existing no-mod suites.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
RFC 0004 lets a tool capture and reconstruct settled overworld progress without
|
||||||
|
private engine access. A battle is a different runtime: its queue can hold Lua
|
||||||
|
functions and UI factories, its controller contains renderer objects and live
|
||||||
|
references, completion is currently an `onFinish` closure, and scripted battles
|
||||||
|
resume a suspended `ScriptRunner` coroutine. Copying the controller would create
|
||||||
|
a record that is neither data-only nor process-independent.
|
||||||
|
|
||||||
|
The engine can instead expose a narrow semantic safe point. This gives all mods
|
||||||
|
the strongest persistent battle checkpoint the current architecture can prove,
|
||||||
|
without claiming mid-animation or suspended-script support.
|
||||||
|
|
||||||
|
## API delta
|
||||||
|
|
||||||
|
No new facade is added. The existing additive `mod.checkpoints` API gains a
|
||||||
|
second format-1 runtime kind.
|
||||||
|
|
||||||
|
### Capability
|
||||||
|
|
||||||
|
`mod.checkpoints:inspect(game)` returns this only when an ordinary single-player
|
||||||
|
wild or trainer battle is settled at the player command menu:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
{ canCapture = true, canRestore = true, kind = "battle" }
|
||||||
|
```
|
||||||
|
|
||||||
|
The action/message queue, waits, UI, animations, HP/status presentation, and
|
||||||
|
faint processing must be settled. The player must actually control the menu.
|
||||||
|
The underlying overworld must have no running/queued script or scripted move,
|
||||||
|
and the battle must carry an engine-owned semantic continuation descriptor.
|
||||||
|
|
||||||
|
Additional refusal codes are `battle_phase_busy`, `battle_origin_unsupported`,
|
||||||
|
`battle_variant_unsupported`, and `link_battle_unsupported`. Link, Safari,
|
||||||
|
ghost, old-man/demo, fishing, static-object, script-suspended, and mod-created
|
||||||
|
closure continuations remain rejected.
|
||||||
|
|
||||||
|
### Capture
|
||||||
|
|
||||||
|
A battle checkpoint remains detached and data-only:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
{
|
||||||
|
format = 1,
|
||||||
|
kind = "battle",
|
||||||
|
identity = { engineVersion = "...", gameVersion = "red",
|
||||||
|
playthroughId = "..." },
|
||||||
|
save = { -- canonical dynamic progress, excluding global options },
|
||||||
|
runtime = {
|
||||||
|
overworld = { map = "ROUTE_1", x = 7, y = 8,
|
||||||
|
facing = "left", surfing = false },
|
||||||
|
battle = { -- normalized semantic model and continuation },
|
||||||
|
},
|
||||||
|
rng = { love = "..." },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The model carries player/enemy roster indices, dynamic enemy Pokémon, turn and
|
||||||
|
escape state, HP/PP/status/stages/volatiles, participants, level-up tracking,
|
||||||
|
trainer AI state, battle ruleset identity, side/field extension data, and
|
||||||
|
normalized pointer relationships such as multi-turn move slots and Mimic
|
||||||
|
restoration entries. Definitions, sprites, canvases, queues, callbacks, and
|
||||||
|
controller objects are reconstructed or excluded.
|
||||||
|
|
||||||
|
Callback-bearing battle extension tokens fail with `battle_extension_unsafe`;
|
||||||
|
invalid live reference relationships fail with `battle_state_invalid`. Nothing
|
||||||
|
is silently stripped.
|
||||||
|
|
||||||
|
New overworld checkpoints also carry the LÖVE gameplay RNG state. Legacy
|
||||||
|
format-1 overworld checkpoints without `rng` remain loadable and leave the
|
||||||
|
current stream untouched.
|
||||||
|
|
||||||
|
### Restore
|
||||||
|
|
||||||
|
Battle restore validates the detached save, map, content references, ruleset,
|
||||||
|
roster indices, move references, continuation identity, and RNG before live
|
||||||
|
mutation. The engine then:
|
||||||
|
|
||||||
|
1. reconstructs the saved overworld return point without entry side effects;
|
||||||
|
2. creates a fresh `BattleState` from current content registries;
|
||||||
|
3. applies the normalized battle model and rebuilds object-reference relations;
|
||||||
|
4. binds an engine-owned wild/trainer completion continuation;
|
||||||
|
5. installs the battle directly at the settled menu without replaying its intro;
|
||||||
|
6. restores the RNG after reconstruction has finished; and
|
||||||
|
7. recaptures and compares the complete checkpoint; and
|
||||||
|
8. emits `checkpoint.restored` with `{ game = game, kind = "battle" }` after the
|
||||||
|
comparison succeeds.
|
||||||
|
|
||||||
|
The pre-operation checkpoint is the transaction rollback. A failed post-install
|
||||||
|
RNG restore is covered: both battle runtime and RNG are reconstructed back to
|
||||||
|
their original values. Validation failure, failed reconstruction, and successful
|
||||||
|
rollback emit no checkpoint lifecycle event.
|
||||||
|
|
||||||
|
## Continuation decision
|
||||||
|
|
||||||
|
Ordinary random wild battles resume through `OverworldState:afterBattle`.
|
||||||
|
Ordinary trainer battles use a descriptor containing map id, stable NPC id,
|
||||||
|
trainer class/party, and optional header event; a win reapplies the same defeated
|
||||||
|
flag, event, reward, and `afterBattle` path. Reconstructed overworld input and
|
||||||
|
NPC freeze state are normalized instead of reviving the old closure.
|
||||||
|
|
||||||
|
`Commands.start_battle` is deliberately unsupported: its completion closure
|
||||||
|
mutates script context and resumes a coroutine whose program counter and Lua
|
||||||
|
stack cannot be serialized. Existing script rejection remains the correct safe
|
||||||
|
contract until a separate semantic ScriptRunner checkpoint RFC exists.
|
||||||
|
|
||||||
|
## Migration note
|
||||||
|
|
||||||
|
**Existing mods require no changes.** The facade and format number are unchanged;
|
||||||
|
the new kind, RNG field, and success-only lifecycle event are additive.
|
||||||
|
Overworld-only callers may continue to filter `capability.kind`. Mods with derived
|
||||||
|
runtime caches may rebuild them from restored public state when the event fires.
|
||||||
|
No-mod behavior is unchanged when checkpoints are unused.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- settled/unsafe boundary and every variant refusal;
|
||||||
|
- data-only wild and trainer capture, including callback-bearing extension
|
||||||
|
rejection;
|
||||||
|
- process-independent controller and continuation reconstruction;
|
||||||
|
- exact differential recapture for wild and trainer states;
|
||||||
|
- HP, PP, status/stages/volatiles, AI layer, participants, enemy roster,
|
||||||
|
multi-turn move references, and Mimic restore pointers;
|
||||||
|
- exact damage, critical, accuracy, random AI, escape, next encounter, and next
|
||||||
|
raw RNG result after reload;
|
||||||
|
- corrupt content/continuation rejection before mutation;
|
||||||
|
- injected post-install failure with full runtime and RNG rollback;
|
||||||
|
- mod-added Pokémon metadata and `mod.save` rewind while independent
|
||||||
|
`mod.storage` and options remain current;
|
||||||
|
- exactly one post-verification `checkpoint.restored` event and none on failure;
|
||||||
|
- legacy overworld checkpoint compatibility;
|
||||||
|
- complete ROM-free engine and public mod-API suites.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# RFC 0006 — Generic process-lifecycle hooks for platform launcher integrations
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed. Engine: `PlatformHooks.lua` (new), `main.lua`, `Manifest.lua`,
|
||||||
|
`Loader.lua`. Tests: `tests/modkit/cases/platform_lifecycle_hooks.lua`,
|
||||||
|
`tests/mod_loader_tests.lua`, `tests/mod_manifest_tests.lua`.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
A platform-specific launcher wrapper -- a native shell that embeds this
|
||||||
|
engine and owns its own UI around the game window (a mobile app shell,
|
||||||
|
say, presenting its own settings/import/save screens and only handing
|
||||||
|
control to the LÖVE window once play starts) needs three things no
|
||||||
|
current hook covers:
|
||||||
|
|
||||||
|
1. Pause the simulation while its own UI is on top of the game window.
|
||||||
|
2. Live-reload options it wrote from outside any Lua UI.
|
||||||
|
3. Veto `main.lua`'s "closing the window returns to the Lua launcher"
|
||||||
|
behavior when the platform shell owns that job itself -- without this,
|
||||||
|
a shell that re-fronts its own launcher UI on quit gets looped straight
|
||||||
|
back into `HostShell.restart()`'s in-process reboot instead.
|
||||||
|
|
||||||
|
Implementing this by hand-patching `main.lua`'s `love.update`/`love.quit`
|
||||||
|
directly ties every such integration to editing the one file every other
|
||||||
|
engine change also touches, guaranteeing merge conflicts for any second
|
||||||
|
platform integration (or any unrelated engine PR landing around the same
|
||||||
|
time). No existing hook covers "should the per-frame simulation step run"
|
||||||
|
or "should closing the window return to the Lua launcher."
|
||||||
|
|
||||||
|
## The decision it extends
|
||||||
|
|
||||||
|
No prior D-number. Extends the hook-contract section of `docs/modding.md`
|
||||||
|
alongside `input.step`, `render.hud`, `screen.render_visible`, etc.
|
||||||
|
|
||||||
|
## The exact API delta
|
||||||
|
|
||||||
|
Backward-compatible, additive-only.
|
||||||
|
|
||||||
|
### `core.update`
|
||||||
|
|
||||||
|
New hook, `(game, dt) -> nil` through the public wrapper signature
|
||||||
|
`(next, game, dt)`, called once per frame from `love.update` via
|
||||||
|
`src/core/PlatformHooks.lua`'s `PlatformHooks.update(game, dt)`. Vanilla
|
||||||
|
behavior (used when no mod claims the hook) is `game:update(dt)`,
|
||||||
|
unconditionally -- identical to `love.update`'s behavior before this hook
|
||||||
|
existed. A subscriber may skip calling `next(game, dt)` to pause the
|
||||||
|
simulation for that frame, or do additional per-frame work before/after
|
||||||
|
calling it regardless of whether it calls `next`.
|
||||||
|
|
||||||
|
### `core.quit_to_launcher`
|
||||||
|
|
||||||
|
New hook, `() -> boolean` through the public wrapper signature `(next)`,
|
||||||
|
called once from `love.quit()` via
|
||||||
|
`PlatformHooks.quitToLauncher(vanilla)`. `vanilla` is the pre-existing
|
||||||
|
non-platform-specific decision (`Game and not Importer and not
|
||||||
|
quitToLauncher and not scripted and not launchedIntoGame`). A subscriber
|
||||||
|
may return `false` outright to veto returning to the Lua launcher (without
|
||||||
|
ever calling `next`, so the vanilla condition is never evaluated), or call
|
||||||
|
`next()` and return its result to pass the vanilla decision through
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
Neither hook is guarded by `Runtime.wantsHook` -- both fire unconditionally
|
||||||
|
every call, matching the existing `input.step` precedent
|
||||||
|
(`src/core/Game.lua`), since `Hooks:call` already fast-paths to a bare
|
||||||
|
`vanilla(...)` call when no mod has wrapped the name.
|
||||||
|
|
||||||
|
### `Manifest.force_enable_env`
|
||||||
|
|
||||||
|
New optional manifest field, a bare env-var name. `Loader:load` re-enables
|
||||||
|
a mod carrying this field whenever that variable is set to `"1"`,
|
||||||
|
regardless of a saved disable in `options.mods`. This exists for exactly
|
||||||
|
the mod class this RFC is for: a platform-bridge mod that ships only with
|
||||||
|
one build and cannot function disabled there, but must still behave like
|
||||||
|
every other mod (a manifest opt-in, not an engine special case) on every
|
||||||
|
build that doesn't set its variable.
|
||||||
|
|
||||||
|
## Migration note for existing mods
|
||||||
|
|
||||||
|
**Nothing.** With no subscriber, `love.update` still calls `Game:update(dt)`
|
||||||
|
unconditionally every frame and `love.quit()`'s restart-to-launcher
|
||||||
|
decision is exactly the pre-existing condition -- bit-identical to today's
|
||||||
|
behavior on every platform where no mod wraps either hook. A manifest with
|
||||||
|
no `force_enable_env` field behaves exactly as before.
|
||||||
|
|
||||||
|
## Parity tests
|
||||||
|
|
||||||
|
- **No-mod:** `core.update`'s vanilla runs exactly once per call with the
|
||||||
|
hook chain empty; `core.quit_to_launcher`'s vanilla return value passes
|
||||||
|
through unchanged. Both hooks are picked up automatically by the
|
||||||
|
catalog-driven no-mod gate (`tests/engine/gate_hooks.lua`, which scans
|
||||||
|
for `Runtime.call("...")` call sites), so neither needs a dedicated
|
||||||
|
no-mod test file.
|
||||||
|
- **Mod-API:** `tests/modkit/cases/platform_lifecycle_hooks.lua` proves,
|
||||||
|
through a fixture mod loaded via the public loader (not the engine's
|
||||||
|
internals), that a subscriber can skip the vanilla update call (pause),
|
||||||
|
run extra per-frame polling regardless of pause state, and veto the
|
||||||
|
quit-to-launcher decision without the vanilla condition ever running.
|
||||||
|
- `tests/mod_loader_tests.lua` and `tests/mod_manifest_tests.lua` cover
|
||||||
|
`force_enable_env`: a matching env var re-enables a mod saved as
|
||||||
|
disabled, and an unset one leaves the saved disable alone.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing deprecated. These are two additive hooks and one additive manifest
|
||||||
|
field; `main.lua`'s only footprint is one `require` and two call sites
|
||||||
|
into `src/core/PlatformHooks.lua`.
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# RFC 0007 — Per-category GAME SPEED and the `core.logic_speed` hook
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed. Engine: `GameSpeed.lua`, `Game.lua`, `BattleState.lua`,
|
||||||
|
`OptionsMenu.lua`, `SaveData.lua`, `LauncherSettings.lua`. Tests:
|
||||||
|
`tests/engine/game_speed_categories_test.lua`,
|
||||||
|
`tests/engine/gate_hooks.lua` (structural, automatic), `tests/run_tests.lua`
|
||||||
|
(OptionsMenu row walk), `tests/mod_ui_tests.lua` (row id/order).
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
`GameSpeed` (`src/core/GameSpeed.lua`) is a single fast-forward multiplier
|
||||||
|
applied uniformly to the whole logic clock in `Game:logicSpeed()` /
|
||||||
|
`Game:update()` -- overworld walking, menu navigation and battle turns all
|
||||||
|
scale together. A player who wants 4X battles (grinding, a long gym fight)
|
||||||
|
but 1X overworld (so a scripted cutscene or NPC dialogue doesn't blur past)
|
||||||
|
has no way to get both; the one GAME SPEED row is a single ladder that
|
||||||
|
applies everywhere at once.
|
||||||
|
|
||||||
|
This needs to be an engine change, not a mod: there is no per-frame seam a
|
||||||
|
mod can use to swap the multiplier mid-step, and no public event granular
|
||||||
|
enough to say "which category is active" (`screen.pushed`/`screen.popped`
|
||||||
|
and `battle.started`/`battle.ended` are the closest and are not enough --
|
||||||
|
see Decisions below). The engine's own speed resolution has to become
|
||||||
|
category-aware.
|
||||||
|
|
||||||
|
A category-aware speed resolution is also the general seam a
|
||||||
|
platform-launcher integration or automation tool needs to read or override
|
||||||
|
the effective multiplier for a given frame without caring which category
|
||||||
|
produced it -- this RFC's `core.logic_speed` hook is written for that case
|
||||||
|
alongside the player-facing Options rows.
|
||||||
|
|
||||||
|
## The decision it extends
|
||||||
|
|
||||||
|
No prior D-number. Extends `GameSpeed.lua`'s multiplier ladder (unchanged)
|
||||||
|
with per-category resolution.
|
||||||
|
|
||||||
|
## The exact API delta
|
||||||
|
|
||||||
|
Backward-compatible except for one save-data field rename, which ships with
|
||||||
|
an automatic migration (see below) -- nothing in the public mod API (hooks,
|
||||||
|
events, registries, `mod.*`) is renamed or removed.
|
||||||
|
|
||||||
|
### `save.options`: `speed` -> `speedOverworld` / `speedBattle` / `speedMenu`
|
||||||
|
|
||||||
|
`GameSpeed.CATEGORIES = { "overworld", "battle", "menu" }` is the new list
|
||||||
|
of categories, and `GameSpeed.optionKey(category)` maps a category to its
|
||||||
|
`save.options` field name (`"overworld"` -> `"speedOverworld"`, etc.).
|
||||||
|
`GameSpeed.LEVELS`, `.DEFAULT`, `.levelLabel`, `.clamp` and `.cycle` are
|
||||||
|
unchanged -- the ladder and its behavior are exactly what they were, just
|
||||||
|
applied three times instead of once.
|
||||||
|
|
||||||
|
`SaveData.defaultOptions()` drops `speed = 1` and adds `speedOverworld = 1`,
|
||||||
|
`speedBattle = 1`, `speedMenu = 1`. `SaveData.mergeOptions()` migrates: a
|
||||||
|
loaded options table that still has `speed` and none of the three new
|
||||||
|
fields seeds all three from it, so an existing player's fast-forward
|
||||||
|
preference carries over instead of two of the three categories silently
|
||||||
|
resetting to 1X. `speed` is dropped on the way out (not carried forward),
|
||||||
|
so a re-save never re-triggers the migration.
|
||||||
|
|
||||||
|
### `Game.speedCategoryInStack(stack)`
|
||||||
|
|
||||||
|
New static helper, `(stack) -> "battle" | "overworld" | "menu"`. Walks the
|
||||||
|
whole state stack top-down -- the same idiom `Game.wideBattleInStack` and
|
||||||
|
`Game.fillScaleInStack` already use -- looking for `state.isBattle` (new
|
||||||
|
marker, `BattleState.isBattle = true`, covering every battle: wild,
|
||||||
|
trainer, link, safari, the old-man demo) or `state.isOverworld` (existing
|
||||||
|
marker, `OverworldController`'s `OverworldState.isOverworld = true`). The
|
||||||
|
first match wins; a state with neither marker (a menu, a text box, a
|
||||||
|
naming screen, a cutscene) is transparent to the walk and falls through to
|
||||||
|
whatever is under it. Nothing in the stack matching either falls back to
|
||||||
|
`"menu"`.
|
||||||
|
|
||||||
|
### `Game:logicSpeed()` / `Game:_resolveLogicSpeed()`
|
||||||
|
|
||||||
|
`Game:_resolveLogicSpeed()` is new: it resolves `Game.speedCategoryInStack`
|
||||||
|
against the live stack, maps the category to its `save.options` key via
|
||||||
|
`GameSpeed.optionKey`, and returns `GameSpeed.clamp` of that option (or
|
||||||
|
`GameSpeed.DEFAULT`). This is the exact category-resolution logic the new
|
||||||
|
hook wraps.
|
||||||
|
|
||||||
|
`Game:logicSpeed()` keeps its existing early returns -- link play forces
|
||||||
|
`1`, a run-argument speed override wins over the saved option -- unchanged,
|
||||||
|
and in the same order, before ever calling the hook. Only once neither
|
||||||
|
applies does it call the `core.logic_speed` hook.
|
||||||
|
|
||||||
|
### `Game:_cycleSpeed(dir)`
|
||||||
|
|
||||||
|
The keyboard hotkey and the gamepad shoulders/triggers that used to cycle
|
||||||
|
the single `speed` option now cycle whichever category
|
||||||
|
`Game.speedCategoryInStack` says is active: pressing the hotkey during a
|
||||||
|
battle speeds up just the battle, on the overworld just the walk, in a menu
|
||||||
|
just the menu. This is the natural per-category answer for a control that
|
||||||
|
used to have one option to reach and now has three -- see Decisions below
|
||||||
|
for why this reading was chosen over, say, always cycling `overworld`.
|
||||||
|
|
||||||
|
### `core.logic_speed`
|
||||||
|
|
||||||
|
New hook, `(game) -> number` through the public wrapper signature
|
||||||
|
`(next, game)`, called once per `Game:logicSpeed()` (i.e. once per frame).
|
||||||
|
Vanilla behavior (used when no mod claims the hook) is
|
||||||
|
`Game:_resolveLogicSpeed()` -- exactly the category resolution above,
|
||||||
|
nothing else. A subscriber may call `next(game)` and return its result to
|
||||||
|
pass the vanilla multiplier through, or return a different number outright
|
||||||
|
to override it for that frame (e.g. a bot mod forcing `1` during one route
|
||||||
|
segment regardless of what category or option is active).
|
||||||
|
|
||||||
|
This intentionally sits *after* the link and speed-override checks in
|
||||||
|
`Game:logicSpeed()`, not around them: link play staying locked to 1X "no
|
||||||
|
matter what either player set this to" is exactly the invariant that would
|
||||||
|
break if a mod's hook could override it, and the run-argument override
|
||||||
|
exists so a bot/screenshot run's speed does not depend on a mod any more
|
||||||
|
than on the player's saved option. Both stay unconditional early returns a
|
||||||
|
mod never sees.
|
||||||
|
|
||||||
|
Not guarded by `Runtime.wantsHook`: `Hooks:call` already fast-paths to a
|
||||||
|
bare `vanilla(...)` call when no mod has wrapped the name, and this hook
|
||||||
|
fires every frame regardless.
|
||||||
|
|
||||||
|
## Decisions on the issue's open questions
|
||||||
|
|
||||||
|
**1. Overlays on top of another category's state (a party menu, a choice
|
||||||
|
box, a naming screen opened mid-battle or mid-overworld).** Resolved by
|
||||||
|
making the category a property of stack *position*, not of the overlay's
|
||||||
|
own type: an overlay with no `isBattle`/`isOverworld` marker is transparent
|
||||||
|
to `Game.speedCategoryInStack`'s walk and inherits whatever is under it. A
|
||||||
|
party swap opened mid-battle reads as `"battle"`; a bag opened while
|
||||||
|
walking reads as `"overworld"`. This was chosen over giving every UI state
|
||||||
|
its own fixed category (which would make a fast-forwarded battle visibly
|
||||||
|
stutter back to 1X every time its party menu opens) because it matches
|
||||||
|
what the player is actually doing moment to moment, and it reuses a
|
||||||
|
pattern the codebase already leans on for exactly this "menus opened over
|
||||||
|
X should behave like X" class of problem (`Game.fillScaleInStack`,
|
||||||
|
`Game.wideBattleInStack`).
|
||||||
|
|
||||||
|
**2. Cutscenes/scripts.** No fourth category. A scripted sequence runs
|
||||||
|
through the owning state's own machinery -- the overworld's script runner
|
||||||
|
or a battle's message queue -- rather than pushing a state of its own, so
|
||||||
|
it is already covered by decision 1: it inherits whatever category the
|
||||||
|
state driving it resolves to. A cutscene state that genuinely has nothing
|
||||||
|
under it (a pre-game intro) falls to `"menu"`, the default for anything
|
||||||
|
that is not battle or overworld gameplay -- consistent with those being
|
||||||
|
pre-game presentation, not something a player is likely to want scaled
|
||||||
|
differently from menu navigation.
|
||||||
|
|
||||||
|
**3. Category granularity (splitting "menu" further).** Deferred. Start
|
||||||
|
with the three named here; `GameSpeed.CATEGORIES` and `GameSpeed.optionKey`
|
||||||
|
are written so adding a fourth later (a Pokédex/Bag category, say) is one
|
||||||
|
entry plus one new `save.options` field, not a resolution-logic rewrite.
|
||||||
|
No current request motivates it.
|
||||||
|
|
||||||
|
**4. The GAME SPEED hotkey/shoulder buttons, once "the" speed is three
|
||||||
|
things.** `Game:_cycleSpeed` now cycles whichever category is currently
|
||||||
|
active (`Game.speedCategoryInStack`), rather than, say, always cycling
|
||||||
|
`overworld` or requiring a modifier key to pick a category. A single
|
||||||
|
physical control that means "speed up whatever I'm looking at right now"
|
||||||
|
is the reading that needs no new UI and matches what a player pressing it
|
||||||
|
mid-battle almost certainly wants.
|
||||||
|
|
||||||
|
## Migration note for existing mods
|
||||||
|
|
||||||
|
**Nothing**, for the mod API surface: `content.X:register/override/get`,
|
||||||
|
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields are
|
||||||
|
untouched, and `GameSpeed.LEVELS`/`.DEFAULT`/`.levelLabel`/`.clamp`/`.cycle`
|
||||||
|
keep their exact signatures and behavior.
|
||||||
|
|
||||||
|
**One save-data field**, for anything that read `save.options.speed`
|
||||||
|
directly (not a formal registry/hook surface, but worth naming): it is
|
||||||
|
superseded by `speedOverworld`/`speedBattle`/`speedMenu`, migrated
|
||||||
|
automatically on load (see above) so a save from before this RFC keeps its
|
||||||
|
player's chosen speed. A mod reading `save.options.speed` after this change
|
||||||
|
sees `nil` (the key is dropped on migration, not kept as a stale alias) and
|
||||||
|
should read the per-category fields, or hook `core.logic_speed` to observe
|
||||||
|
the resolved multiplier directly regardless of which category produced it.
|
||||||
|
|
||||||
|
## Parity tests
|
||||||
|
|
||||||
|
- **No-mod:** `core.logic_speed` needs no dedicated no-mod test file --
|
||||||
|
`tests/engine/gate_hooks.lua` walks the live hook catalog (which scans
|
||||||
|
`src` for `Runtime.call("...")` call sites), so the new
|
||||||
|
`Runtime.call("core.logic_speed", ...)` site is picked up and gated
|
||||||
|
automatically: vanilla runs exactly once with an empty hook chain, an
|
||||||
|
unsubscribed-but-live bus passes values and multiple returns through
|
||||||
|
unchanged, and `Runtime.wantsHook` reads `false`.
|
||||||
|
- **Mod-API:** `tests/engine/game_speed_categories_test.lua` exercises the
|
||||||
|
hook through the public API (`Hooks.new()` + `bus:wrap("core.logic_speed",
|
||||||
|
...)` + `Runtime.call`, the same idiom other hooks' tests use) -- a
|
||||||
|
subscriber can read the vanilla category resolution via `next(game)` and
|
||||||
|
can override it outright -- plus direct coverage of
|
||||||
|
`Game.speedCategoryInStack` (battle-on-top, overworld-on-top, an overlay
|
||||||
|
inheriting each, an empty/unmatched stack falling to `"menu"`) and
|
||||||
|
`Game:logicSpeed()`'s precedence (link forces 1X over all three
|
||||||
|
categories and over a hook override; the run-argument override wins over
|
||||||
|
the category resolution).
|
||||||
|
- `tests/run_tests.lua`'s OptionsMenu walk exercises the three new rows
|
||||||
|
(OVERWORLD SPEED / BATTLE SPEED / MENU SPEED) cycling and wrapping
|
||||||
|
independently, in place of the old single GAME SPEED row.
|
||||||
|
- `tests/mod_ui_tests.lua`'s row-id/order check and hardcoded row-index
|
||||||
|
activations (MODS, CONTROLS) are updated for the two extra rows.
|
||||||
|
- A link-play driver should set all three per-category speeds high before
|
||||||
|
asserting `game:logicSpeed()` reads `1` during a real link session,
|
||||||
|
proving the lock wins over every category at once, not just whichever
|
||||||
|
one happens to be active.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing deprecated in the mod-facing hook/event/registry catalog -- this
|
||||||
|
adds one hook, additive. The `save.options.speed` field is superseded with
|
||||||
|
an automatic migration rather than a deprecation notice, since it was never
|
||||||
|
a registered mod-API surface (no schema entry, no registry) -- the same
|
||||||
|
treatment any other `save.options` field would get if it needed reshaping.
|
||||||
@@ -13,6 +13,7 @@ local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE =
|
|||||||
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
||||||
local LaunchOptions = require("src.core.LaunchOptions")
|
local LaunchOptions = require("src.core.LaunchOptions")
|
||||||
local NxDisplay = require("src.core.NxDisplay")
|
local NxDisplay = require("src.core.NxDisplay")
|
||||||
|
local PlatformHooks = require("src.core.PlatformHooks")
|
||||||
|
|
||||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||||
do
|
do
|
||||||
@@ -426,7 +427,11 @@ function love.update(dt)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
Game:update(dt)
|
-- Mods may wrap or veto the per-frame simulation step (pause it, react
|
||||||
|
-- to external platform state, etc.) -- see docs/modding.md's core.update
|
||||||
|
-- entry. Vanilla behavior (used when no mod claims the hook) is just
|
||||||
|
-- Game:update(dt), unconditionally, exactly as before this hook existed.
|
||||||
|
PlatformHooks.update(Game, dt)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.draw()
|
function love.draw()
|
||||||
@@ -819,8 +824,16 @@ function love.quit()
|
|||||||
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or os.getenv("POKEPORT_IMPORT_ROM")
|
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or os.getenv("POKEPORT_IMPORT_ROM")
|
||||||
-- #887: a shortcut session (--game / POKEPORT_GAME) has no launcher to go
|
-- #887: a shortcut session (--game / POKEPORT_GAME) has no launcher to go
|
||||||
-- back to and the restart would re-read the shortcut, so it exits instead.
|
-- back to and the restart would re-read the shortcut, so it exits instead.
|
||||||
if Game and not Importer and not quitToLauncher and not scripted
|
--
|
||||||
and not launchedIntoGame then
|
-- A platform launcher that owns "return to launcher" itself (see
|
||||||
|
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
|
||||||
|
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
|
||||||
|
-- claims the hook) is exactly the condition below.
|
||||||
|
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
||||||
|
return Game and not Importer and not quitToLauncher and not scripted
|
||||||
|
and not launchedIntoGame
|
||||||
|
end)
|
||||||
|
if wouldReturnToLauncher then
|
||||||
quitToLauncher = true
|
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
|
||||||
-- once, so the restart really does land in the launcher (#887). A failed
|
-- once, so the restart really does land in the launcher (#887). A failed
|
||||||
|
|||||||
@@ -1003,4 +1003,33 @@ void love_android_secondary_enable(int on)
|
|||||||
env->DeleteLocalRef(activity);
|
env->DeleteLocalRef(activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extern "C" __attribute__((visibility("default")))
|
||||||
|
const char *love_android_poll_secondary_touch()
|
||||||
|
{
|
||||||
|
static thread_local std::string event;
|
||||||
|
event.clear();
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
|
jmethodID method = env->GetStaticMethodID(activity, "pollSecondaryDisplayTouch",
|
||||||
|
"()Ljava/lang/String;");
|
||||||
|
if (!method)
|
||||||
|
env->ExceptionClear();
|
||||||
|
else
|
||||||
|
{
|
||||||
|
jstring value = (jstring) env->CallStaticObjectMethod(activity, method);
|
||||||
|
if (value)
|
||||||
|
{
|
||||||
|
const char *utf = env->GetStringUTFChars(value, nullptr);
|
||||||
|
if (utf)
|
||||||
|
{
|
||||||
|
event = utf;
|
||||||
|
env->ReleaseStringUTFChars(value, utf);
|
||||||
|
}
|
||||||
|
env->DeleteLocalRef(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return event.empty() ? nullptr : event.c_str();
|
||||||
|
}
|
||||||
|
|
||||||
#endif // LOVE_ANDROID
|
#endif // LOVE_ANDROID
|
||||||
|
|||||||
@@ -1325,6 +1325,9 @@ public class GameActivity extends SDLActivity {
|
|||||||
// in src/jni/love/src/common/android.cpp.
|
// in src/jni/love/src/common/android.cpp.
|
||||||
private static volatile SecondaryPresentation secondaryPresentation;
|
private static volatile SecondaryPresentation secondaryPresentation;
|
||||||
private static volatile boolean secondaryEnabled = false;
|
private static volatile boolean secondaryEnabled = false;
|
||||||
|
private static final int MAX_SECONDARY_TOUCHES = 32;
|
||||||
|
private static final java.util.ArrayDeque<String> secondaryTouches =
|
||||||
|
new java.util.ArrayDeque<>();
|
||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
public static void setSecondaryEnabled(final boolean on) {
|
public static void setSecondaryEnabled(final boolean on) {
|
||||||
@@ -1377,6 +1380,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
private static void teardownSecondaryDisplay() {
|
private static void teardownSecondaryDisplay() {
|
||||||
SecondaryPresentation p = secondaryPresentation;
|
SecondaryPresentation p = secondaryPresentation;
|
||||||
secondaryPresentation = null;
|
secondaryPresentation = null;
|
||||||
|
synchronized (secondaryTouches) { secondaryTouches.clear(); }
|
||||||
if (p != null) {
|
if (p != null) {
|
||||||
try { p.dismiss(); } catch (Throwable t) {}
|
try { p.dismiss(); } catch (Throwable t) {}
|
||||||
}
|
}
|
||||||
@@ -1395,6 +1399,13 @@ public class GameActivity extends SDLActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
public static String pollSecondaryDisplayTouch() {
|
||||||
|
synchronized (secondaryTouches) {
|
||||||
|
return secondaryTouches.pollFirst();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static class SecondaryPresentation extends android.app.Presentation {
|
private static class SecondaryPresentation extends android.app.Presentation {
|
||||||
private final FrameView frameView;
|
private final FrameView frameView;
|
||||||
|
|
||||||
@@ -1461,6 +1472,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
private final android.graphics.Paint paint = new android.graphics.Paint();
|
private final android.graphics.Paint paint = new android.graphics.Paint();
|
||||||
private final Object lock = new Object();
|
private final Object lock = new Object();
|
||||||
private int fw, fh;
|
private int fw, fh;
|
||||||
|
private int activePointer = -1;
|
||||||
|
|
||||||
FrameView(Context context) {
|
FrameView(Context context) {
|
||||||
super(context);
|
super(context);
|
||||||
@@ -1482,6 +1494,52 @@ public class GameActivity extends SDLActivity {
|
|||||||
postInvalidate();
|
postInvalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void enqueueTouch(String event) {
|
||||||
|
synchronized (secondaryTouches) {
|
||||||
|
if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) {
|
||||||
|
secondaryTouches.clear();
|
||||||
|
secondaryTouches.addLast("cancel,0,0");
|
||||||
|
} else {
|
||||||
|
secondaryTouches.addLast(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int logicalX(float x) {
|
||||||
|
return Math.min(fw - 1, Math.max(0,
|
||||||
|
(int) ((x - dst.left) * fw / dst.width())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int logicalY(float y) {
|
||||||
|
return Math.min(fh - 1, Math.max(0,
|
||||||
|
(int) ((y - dst.top) * fh / dst.height())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onTouchEvent(android.view.MotionEvent event) {
|
||||||
|
synchronized (lock) {
|
||||||
|
int action = event.getActionMasked();
|
||||||
|
if (action == android.view.MotionEvent.ACTION_DOWN && fw > 0
|
||||||
|
&& dst.contains((int) event.getX(), (int) event.getY())) {
|
||||||
|
activePointer = event.getPointerId(0);
|
||||||
|
enqueueTouch("down," + logicalX(event.getX()) + ","
|
||||||
|
+ logicalY(event.getY()));
|
||||||
|
} else if (action == android.view.MotionEvent.ACTION_UP
|
||||||
|
&& activePointer >= 0) {
|
||||||
|
int index = event.findPointerIndex(activePointer);
|
||||||
|
if (index >= 0 && fw > 0) {
|
||||||
|
enqueueTouch("up," + logicalX(event.getX(index)) + ","
|
||||||
|
+ logicalY(event.getY(index)));
|
||||||
|
}
|
||||||
|
activePointer = -1;
|
||||||
|
} else if (action == android.view.MotionEvent.ACTION_CANCEL) {
|
||||||
|
activePointer = -1;
|
||||||
|
enqueueTouch("cancel,0,0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onDraw(android.graphics.Canvas canvas) {
|
protected void onDraw(android.graphics.Canvas canvas) {
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
|
|||||||
+87
-104
@@ -1,130 +1,113 @@
|
|||||||
# iOS build (LÖVE 12.0)
|
# iOS build
|
||||||
|
|
||||||
> **Native ROM/mod/save import.** The iOS build ships a Swift
|
This directory contains the macOS/Xcode build used to package Gen1 Recomp as
|
||||||
> document-picker bridge (`native/GRPickerBridge.swift` + `GRBootstrap.m`)
|
an iOS app with LÖVE 12.0.
|
||||||
> that `patch_love_src.py` wires into the LÖVE tree on every build:
|
|
||||||
>
|
|
||||||
> - `love.system.pickFile("rom"|"mod"|"sav")` and `love.system.createFile`
|
|
||||||
> are exposed to Lua on iOS (same contract as love-android's SAF picker:
|
|
||||||
> picks land in the save dir as `picked_rom.gb` / `picked_mod.zip` /
|
|
||||||
> `picked_save.sav`; exports signal via `export_done.flag`).
|
|
||||||
> - The Info.plist overlay enables `UIFileSharingEnabled` +
|
|
||||||
> `LSSupportsOpeningDocumentsInPlace`, and `GRBootstrap.m` sweeps
|
|
||||||
> `.gb/.gbc/.zip/.sav` files dropped in Documents (Files app / Finder)
|
|
||||||
> into the LÖVE save dir on every activation — drop a ROM, open the app,
|
|
||||||
> and it imports with no taps.
|
|
||||||
> - `src/import/RomImporter.lua` treats iOS as a mobile platform and polls
|
|
||||||
> for picker results (iOS pickers are in-process modals, so Android's
|
|
||||||
> refocus rescan never fires).
|
|
||||||
>
|
|
||||||
> The note below about a missing "UIDocumentPicker handoff" is
|
|
||||||
> resolved by this bridge.
|
|
||||||
|
|
||||||
macOS + Xcode only. Fetches the **LÖVE 12.0** source tree and matching Apple
|
## User data location
|
||||||
dependencies from the official [LÖVE source](https://github.com/love2d/love)
|
|
||||||
and [Apple dependencies](https://github.com/love2d/love-apple-dependencies)
|
|
||||||
repositories. `conf.lua` declares LÖVE 12.0 on iOS and 11.5 elsewhere.
|
|
||||||
|
|
||||||
Pin file: [`LOVE_VERSION`](./LOVE_VERSION) → `12.0`.
|
The app uses the public iOS Documents directory as its LÖVE save directory.
|
||||||
|
There is no `pokemon-love2d` subdirectory and the app does not create a
|
||||||
|
README file there. When browsing `On My iPhone > gen1recomp` in Files, the
|
||||||
|
directory contains the app's runtime data directly, including:
|
||||||
|
|
||||||
## Quick start (simulator)
|
- installed mods and downloaded ROMs
|
||||||
|
- save files and save-state data
|
||||||
|
- options, caches, logs, and other files created by the game
|
||||||
|
|
||||||
|
The build enables `UIFileSharingEnabled` and
|
||||||
|
`LSSupportsOpeningDocumentsInPlace`, so the same directory is available in
|
||||||
|
Files and Finder. Files copied into the app's Documents directory are used by
|
||||||
|
the game on its next activation.
|
||||||
|
|
||||||
|
Existing installations are migrated automatically. Files from the old
|
||||||
|
private `Application Support/pokemon-love2d` directory are merged into
|
||||||
|
Documents on launch; conflicts are retained with a `.legacy` suffix.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Run these commands from the repository root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Fetch LÖVE 12.0 iOS sources and dependencies (once) + build for Simulator
|
|
||||||
scripts/build_ios.sh --fetch
|
scripts/build_ios.sh --fetch
|
||||||
|
scripts/build_ios.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The embedded `game.love` contains no ROM or generated game data. The current
|
`--fetch` downloads the pinned LÖVE source and matching Apple dependencies
|
||||||
first-boot importer has desktop file pickers only, so a production iOS release
|
into the gitignored `love-src/` directory. It is only needed when that tree is
|
||||||
still needs a UIDocumentPicker handoff that passes the selected ROM to LÖVE.
|
missing. The default build targets the iOS Simulator in Debug configuration.
|
||||||
|
|
||||||
Default output: an unsigned Simulator `.app` under `mobile/ios/build/`
|
For a physical device or a release build:
|
||||||
(no Apple Developer account required). A convenience copy also lands under
|
|
||||||
`dist/ios/<Config>-<sdk>/`.
|
|
||||||
|
|
||||||
Install on a booted simulator (example):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xcrun simctl install booted mobile/ios/build/Build/Products/Debug-iphonesimulator/PokemonRed.app
|
scripts/build_ios.sh --device --install
|
||||||
xcrun simctl launch booted com.theboisclub.pokemonred
|
scripts/build_ios.sh --device --release --install
|
||||||
```
|
```
|
||||||
|
|
||||||
Or open `mobile/ios/love-src/platform/xcode/love.xcodeproj` in Xcode,
|
Device builds require a paired, unlocked device and a valid Apple signing
|
||||||
select the `love-ios` target, and Run on a Simulator after
|
identity. Set `DEVELOPMENT_TEAM` or `CODE_SIGN_IDENTITY` when automatic
|
||||||
`scripts/build_ios.sh --package-only` (or a full build) has placed `game.love`.
|
signing cannot select the intended account. Add `--ipa` to create
|
||||||
|
`dist/ios/gen1recomp.ipa`.
|
||||||
|
|
||||||
## Device / Release
|
The script verifies the final app before packaging it:
|
||||||
|
|
||||||
```bash
|
- the public Documents plist settings are present
|
||||||
scripts/build_ios.sh --device # Debug, physical device SDK
|
- the native picker bridge is present
|
||||||
scripts/build_ios.sh --device --release # Release configuration
|
- `game.love` exists and is non-empty
|
||||||
|
|
||||||
|
If the payload is missing, the build fails instead of producing a blank app.
|
||||||
|
|
||||||
|
## Useful options
|
||||||
|
|
||||||
|
| Option | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `--fetch` | Fetch LÖVE 12.0 and Apple dependencies when `love-src/` is missing |
|
||||||
|
| `--device` | Build for `iphoneos` instead of the Simulator |
|
||||||
|
| `--release` | Use the Release configuration |
|
||||||
|
| `--install` | Install a device build on the first connected device |
|
||||||
|
| `--ipa` | Create an IPA after a device build |
|
||||||
|
| `--version X.Y.Z` | Stamp the engine and app version |
|
||||||
|
| `--package-only` | Package `game.love` and apply the iOS plist overlay without Xcode |
|
||||||
|
|
||||||
|
`scripts/build.sh ios` delegates to this script and forwards the iOS release
|
||||||
|
option.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Simulator and device app bundles are copied to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/ios/Debug-iphonesimulator/gen1recomp.app
|
||||||
|
dist/ios/Release-iphonesimulator/gen1recomp.app
|
||||||
|
dist/ios/Debug-iphoneos/gen1recomp.app
|
||||||
|
dist/ios/Release-iphoneos/gen1recomp.app
|
||||||
```
|
```
|
||||||
|
|
||||||
Device builds need a signing identity and provisioning profile configured in
|
The intermediate Xcode products are under `mobile/ios/build/`. Both locations
|
||||||
Xcode (or via `DEVELOPMENT_TEAM` / `CODE_SIGN_IDENTITY` env vars). This repo
|
are gitignored.
|
||||||
does **not** store certificates, profiles, or App Store Connect secrets.
|
|
||||||
|
|
||||||
Manual out-of-band steps:
|
The bundled game payload is staged at
|
||||||
|
`love-src/platform/xcode/ios/resources/game.love` and copied into the final
|
||||||
1. Apple Developer account + App ID for `com.theboisclub.pokemonred`
|
app bundle. The payload contains the game, not user-generated ROMs, mods, or
|
||||||
2. Development or Distribution certificate + provisioning profile
|
saves; those are created at runtime in Documents.
|
||||||
3. In Xcode: open `love.xcodeproj` → target `love-ios` → Signing & Capabilities
|
|
||||||
→ select your Team (or set `DEVELOPMENT_TEAM=XXXXXXXXXX` when invoking
|
|
||||||
`scripts/build_ios.sh --device`)
|
|
||||||
4. Archive / export an `.ipa` from Xcode Organizer for TestFlight / Ad Hoc
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
| Path | Role |
|
|
||||||
|------|------|
|
|
||||||
| `LOVE_VERSION` | Engine pin (`12.0`) |
|
|
||||||
| `overlays/love-ios.plist` | Portrait-only Info.plist + display name **Pokemon Red** (copied over the upstream plist every build) |
|
|
||||||
| `love-src/` | Downloaded LÖVE 12.0 source tree (**gitignored**, do not commit) |
|
|
||||||
| `cache/` | Temporary source and dependency checkout data (**gitignored**) |
|
|
||||||
| `build/` | `xcodebuild` derived data (**gitignored**) |
|
|
||||||
|
|
||||||
Game payload lands at:
|
|
||||||
|
|
||||||
`love-src/platform/xcode/ios/resources/game.love`
|
|
||||||
|
|
||||||
and is fused into the built `.app` (LÖVE auto-runs any bundled `*.love`).
|
|
||||||
|
|
||||||
## Apple libraries dependency
|
|
||||||
|
|
||||||
`scripts/build_ios.sh --fetch` retrieves the matching iOS libraries and the
|
|
||||||
SDL3 framework from
|
|
||||||
[love-apple-dependencies](https://github.com/love2d/love-apple-dependencies).
|
|
||||||
Re-run it if either dependency directory is absent.
|
|
||||||
|
|
||||||
## App identity
|
## App identity
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Default |
|
||||||
|-------|--------|
|
| --- | --- |
|
||||||
| Display name | Pokemon Red |
|
| Display name | `gen1recomp` |
|
||||||
| `PRODUCT_NAME` | PokemonRed |
|
| Product name | `gen1recomp` |
|
||||||
| Bundle ID | `com.theboisclub.pokemonred` |
|
| Bundle identifier | `com.theboisclub.gen1recomp` |
|
||||||
| Orientations | Portrait only (`UIInterfaceOrientationPortrait`) |
|
| Save directory | Public `Documents` root |
|
||||||
|
| Orientation | Portrait |
|
||||||
|
|
||||||
Overrides are applied by the build script (`xcodebuild` settings + plist overlay)
|
Set `GEN1_BUNDLE_ID` to use a different bundle identifier for local device
|
||||||
so refreshing `love-src/` does not lose branding.
|
builds.
|
||||||
|
|
||||||
## Flags (`scripts/build_ios.sh`)
|
## Prerequisites
|
||||||
|
|
||||||
| Flag | Meaning |
|
- macOS with Xcode and `xcodebuild`
|
||||||
|------|---------|
|
- the iOS and iOS Simulator platforms installed in Xcode
|
||||||
| *(default)* | Simulator, Debug, no signing |
|
- a fetched `love-src/` tree, or the `--fetch` option
|
||||||
| `--fetch` | Fetch the LÖVE 12.0 source tree and Apple dependencies if `love-src/` is missing |
|
- the matching iOS libraries and SDL3 framework under `love-src/`
|
||||||
| `--device` | Build against `iphoneos` instead of `iphonesimulator` |
|
|
||||||
| `--release` | `Release` configuration instead of `Debug` |
|
|
||||||
| `--package-only` | Zip `game.love` + apply plist overlay; skip `xcodebuild` |
|
|
||||||
|
|
||||||
Also: `scripts/build.sh ios` delegates here (`--release` is forwarded).
|
Use `xcodebuild -showsdks` to confirm that the required SDKs are installed.
|
||||||
|
|
||||||
## Preconditions
|
|
||||||
|
|
||||||
- macOS (Darwin) with Xcode + `xcodebuild` on `PATH`
|
|
||||||
- iOS platform installed in Xcode (Settings → Platforms). `xcodebuild -showsdks`
|
|
||||||
should list `iphonesimulator` / `iphoneos`. A partial install can fail IB/xib
|
|
||||||
compiles with `iOS … Platform Not Installed` even when the SDK name appears.
|
|
||||||
- `love-src/` present (`--fetch`)
|
|
||||||
- iOS libraries under `love-src/platform/xcode/ios/libraries/` and SDL3 under `love-src/platform/xcode/shared/Frameworks/`
|
|
||||||
|
|||||||
@@ -12,6 +12,20 @@
|
|||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.1.76",
|
||||||
|
"date": "2026-08-10",
|
||||||
|
"size": 9610081,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.76/gen1recomp-0.1.76-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #617 Old Man in Viridian\n- #889 Pokemon yellow can't export save file right\n- #915 wrong sprite used in opening\n- #916 Trainer sprite at end of fly animation\n- #931 Mod update checks fail\n- #932 Bugs reset settings\n- #933 Add TitleState override for player\n- #945 Cannot edit Trainer Class's Battle Theme\n- #969 Linux x86_64 AppImage Shows up as \"LOVE\" instead of \"gen1recomp\"\n- #1016 Mod API: support variable-size overworld sprites\n- #1039 Encounter rate grace period not working\n- #1040 Player sprite walks right through rival after defeating him at the end of the game\n\n## Contributors\n\n- @ArmstrongThomas\n- @AverageConsumer\n- @Bortlesboat\n- @bryanthaboi\n- @crusty\n- @dlloa\n- @jherediagu\n- @KikiManjaro\n- @martin2844\n- @MaxTomahawk\n- @ShaneMcGovernIE\n- @steve1337\n- @swuff-star\n- @thibautbus\n- @Yukitty\n- hernan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.1.75",
|
||||||
|
"date": "2026-08-06",
|
||||||
|
"size": 9574086,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.75/gen1recomp-0.1.75-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #826 \"Super effective\" and \"not very effective\" SFX are reversed\n- #848 Roms not showing up\n- #876 Remote mod download is unavailable on this platform\n- #899 Importing Pokémon red leads to the contents extracted without a folder called 'red'\n- #902 Audio is wrong!\n\n## Contributors\n\n- @andrewqsantos\n- @bryanthaboi\n- @caorthann-celt\n- @johnjohto\n- @ShaneMcGovernIE"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.1.74",
|
"version": "0.1.74",
|
||||||
"date": "2026-08-06",
|
"date": "2026-08-06",
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ static void GRBootstrapInstall(void)
|
|||||||
queue:[NSOperationQueue mainQueue]
|
queue:[NSOperationQueue mainQueue]
|
||||||
usingBlock:^(NSNotification *note) {
|
usingBlock:^(NSNotification *note) {
|
||||||
Class bridge = NSClassFromString(@"GRPickerBridge");
|
Class bridge = NSClassFromString(@"GRPickerBridge");
|
||||||
|
if ([bridge respondsToSelector:@selector(preparePublicDocuments)]) {
|
||||||
|
[bridge performSelector:@selector(preparePublicDocuments)];
|
||||||
|
}
|
||||||
if ([bridge respondsToSelector:@selector(sweepInbox)]) {
|
if ([bridge respondsToSelector:@selector(sweepInbox)]) {
|
||||||
[bridge performSelector:@selector(sweepInbox)];
|
[bridge performSelector:@selector(sweepInbox)];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ public final class GRPickerBridge: NSObject {
|
|||||||
// silently doing nothing.
|
// silently doing nothing.
|
||||||
private static var liveDelegates: [PickerDelegate] = []
|
private static var liveDelegates: [PickerDelegate] = []
|
||||||
|
|
||||||
// conf.lua t.identity — where LÖVE puts the fused save directory on iOS
|
|
||||||
// (<sandbox>/Library/Application Support/<identity>).
|
|
||||||
private static let loveIdentity = "pokemon-love2d"
|
private static let loveIdentity = "pokemon-love2d"
|
||||||
|
|
||||||
@objc(httpDownloadWithUrl:destination:userAgent:accept:)
|
@objc(httpDownloadWithUrl:destination:userAgent:accept:)
|
||||||
@@ -85,11 +83,30 @@ public final class GRPickerBridge: NSObject {
|
|||||||
types = [.zip]
|
types = [.zip]
|
||||||
case "sav":
|
case "sav":
|
||||||
destName = "picked_save.sav"
|
destName = "picked_save.sav"
|
||||||
default:
|
// A Nintendo 64 cartridge, for mods that build assets out of one --
|
||||||
|
// the voxel mod's Pokemon Stadium battle models are the caller this
|
||||||
|
// was added for. Its own filename on purpose: an N64 ROM landing on
|
||||||
|
// picked_rom.gb is swept up by the Game Boy importer, deleted, and
|
||||||
|
// reported to the player as a broken cartridge.
|
||||||
|
case "stadium":
|
||||||
|
destName = "picked_stadium.z64"
|
||||||
|
for ext in ["z64", "n64", "v64"] {
|
||||||
|
if let t = UTType(filenameExtension: ext) { types.append(t) }
|
||||||
|
}
|
||||||
|
case "rom", "":
|
||||||
destName = "picked_rom.gb"
|
destName = "picked_rom.gb"
|
||||||
for ext in ["gb", "gbc"] {
|
for ext in ["gb", "gbc"] {
|
||||||
if let t = UTType(filenameExtension: ext) { types.append(t) }
|
if let t = UTType(filenameExtension: ext) { types.append(t) }
|
||||||
}
|
}
|
||||||
|
// An unknown kind is REFUSED rather than treated as a Game Boy ROM.
|
||||||
|
//
|
||||||
|
// It used to fall through to picked_rom.gb, so a caller asking for a
|
||||||
|
// kind this build had never heard of got its file deleted and
|
||||||
|
// reported as a broken cartridge -- the worst possible answer to
|
||||||
|
// "I do not know that one". Returning false lets the caller find out
|
||||||
|
// and offer its own fallback.
|
||||||
|
default:
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
// .gb/.gbc/.sav resolve to dynamic UTTypes on most devices; offering
|
// .gb/.gbc/.sav resolve to dynamic UTTypes on most devices; offering
|
||||||
// .data as well keeps every real file selectable. The importer
|
// .data as well keeps every real file selectable. The importer
|
||||||
@@ -107,6 +124,20 @@ public final class GRPickerBridge: NSObject {
|
|||||||
return present(picker, with: delegate)
|
return present(picker, with: delegate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which kinds presentPicker understands, comma separated.
|
||||||
|
//
|
||||||
|
// So a CALLER can ask before it calls. A mod that wants a kind this build
|
||||||
|
// predates cannot otherwise tell "refused" from "the picker would not
|
||||||
|
// open", and guessing wrong used to cost the player their ROM (see the
|
||||||
|
// default case above). Asking first turns that into a fallback the caller
|
||||||
|
// chooses rather than a file it loses.
|
||||||
|
//
|
||||||
|
// Kept beside the switch it describes, because the two drifting apart is
|
||||||
|
// the only way this can lie.
|
||||||
|
@objc public static func supportedPickerKinds() -> NSString {
|
||||||
|
return "rom,mod,sav,stadium" as NSString
|
||||||
|
}
|
||||||
|
|
||||||
@objc(presentExportWithName:saveDir:)
|
@objc(presentExportWithName:saveDir:)
|
||||||
public static func presentExport(name: UnsafePointer<CChar>?,
|
public static func presentExport(name: UnsafePointer<CChar>?,
|
||||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||||
@@ -144,10 +175,10 @@ public final class GRPickerBridge: NSObject {
|
|||||||
// UIApplicationDidBecomeActive (see GRBootstrap.m).
|
// UIApplicationDidBecomeActive (see GRBootstrap.m).
|
||||||
@objc public static func sweepInbox() {
|
@objc public static func sweepInbox() {
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
guard let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first,
|
migrateLegacySaveDirectory()
|
||||||
let appSupport = fm.urls(for: .applicationSupportDirectory,
|
guard let docs = documentsDirectory(),
|
||||||
in: .userDomainMask).first else { return }
|
let saveDir = publicSaveDirectory() else { return }
|
||||||
let saveDir = appSupport.appendingPathComponent(loveIdentity, isDirectory: true)
|
guard docs.standardizedFileURL != saveDir.standardizedFileURL else { return }
|
||||||
let wanted: Set<String> = ["gb", "gbc", "zip", "sav"]
|
let wanted: Set<String> = ["gb", "gbc", "zip", "sav"]
|
||||||
guard let items = try? fm.contentsOfDirectory(at: docs,
|
guard let items = try? fm.contentsOfDirectory(at: docs,
|
||||||
includingPropertiesForKeys: nil) else { return }
|
includingPropertiesForKeys: nil) else { return }
|
||||||
@@ -164,15 +195,21 @@ public final class GRPickerBridge: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc public static func preparePublicDocuments() {
|
||||||
|
migrateLegacySaveDirectory()
|
||||||
|
if let saveDir = publicSaveDirectory() {
|
||||||
|
ensureDirectory(saveDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private static func resolvedSaveDir(_ cstr: UnsafePointer<CChar>?) -> URL? {
|
private static func resolvedSaveDir(_ cstr: UnsafePointer<CChar>?) -> URL? {
|
||||||
var dir = cstr.map { String(cString: $0) } ?? ""
|
var dir = cstr.map { String(cString: $0) } ?? ""
|
||||||
if dir.isEmpty {
|
if dir.isEmpty {
|
||||||
guard let appSupport = FileManager.default
|
migrateLegacySaveDirectory()
|
||||||
.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
guard let saveDir = publicSaveDirectory() else { return nil }
|
||||||
else { return nil }
|
dir = saveDir.path
|
||||||
dir = appSupport.appendingPathComponent(loveIdentity).path
|
|
||||||
}
|
}
|
||||||
let url = URL(fileURLWithPath: dir, isDirectory: true)
|
let url = URL(fileURLWithPath: dir, isDirectory: true)
|
||||||
ensureDirectory(url)
|
ensureDirectory(url)
|
||||||
@@ -184,6 +221,75 @@ public final class GRPickerBridge: NSObject {
|
|||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func documentsDirectory() -> URL? {
|
||||||
|
FileManager.default.urls(for: .documentDirectory,
|
||||||
|
in: .userDomainMask).first
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func publicSaveDirectory() -> URL? {
|
||||||
|
documentsDirectory()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func legacySaveDirectory() -> URL? {
|
||||||
|
FileManager.default.urls(for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask).first?
|
||||||
|
.appendingPathComponent(loveIdentity, isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func migrateLegacySaveDirectory() {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard let destination = publicSaveDirectory(),
|
||||||
|
let legacy = legacySaveDirectory(),
|
||||||
|
fm.fileExists(atPath: legacy.path) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureDirectory(destination)
|
||||||
|
mergeDirectory(from: legacy, to: destination)
|
||||||
|
try? fm.removeItem(at: legacy)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mergeDirectory(from source: URL, to destination: URL) {
|
||||||
|
let fm = FileManager.default
|
||||||
|
ensureDirectory(destination)
|
||||||
|
guard let items = try? fm.contentsOfDirectory(at: source,
|
||||||
|
includingPropertiesForKeys: nil)
|
||||||
|
else { return }
|
||||||
|
for item in items {
|
||||||
|
let target = destination.appendingPathComponent(item.lastPathComponent)
|
||||||
|
var sourceIsDirectory = ObjCBool(false)
|
||||||
|
fm.fileExists(atPath: item.path, isDirectory: &sourceIsDirectory)
|
||||||
|
var targetIsDirectory = ObjCBool(false)
|
||||||
|
let targetExists = fm.fileExists(atPath: target.path,
|
||||||
|
isDirectory: &targetIsDirectory)
|
||||||
|
if sourceIsDirectory.boolValue && targetExists && targetIsDirectory.boolValue {
|
||||||
|
mergeDirectory(from: item, to: target)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if targetExists {
|
||||||
|
if !sourceIsDirectory.boolValue && !targetIsDirectory.boolValue &&
|
||||||
|
fm.contentsEqual(atPath: item.path, andPath: target.path) {
|
||||||
|
try? fm.removeItem(at: item)
|
||||||
|
} else {
|
||||||
|
moveToLegacyName(item, in: destination)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try? fm.moveItem(at: item, to: target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func moveToLegacyName(_ item: URL, in destination: URL) {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let base = item.lastPathComponent + ".legacy"
|
||||||
|
var target = destination.appendingPathComponent(base)
|
||||||
|
var suffix = 2
|
||||||
|
while fm.fileExists(atPath: target.path) {
|
||||||
|
target = destination.appendingPathComponent("\(base).\(suffix)")
|
||||||
|
suffix += 1
|
||||||
|
}
|
||||||
|
try? fm.moveItem(at: item, to: target)
|
||||||
|
}
|
||||||
|
|
||||||
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() } }
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ NATIVE_SRC = IOS_DIR / "native"
|
|||||||
NATIVE_DST = LOVE_SRC / "platform" / "xcode" / "ios" / "native"
|
NATIVE_DST = LOVE_SRC / "platform" / "xcode" / "ios" / "native"
|
||||||
WRAP_SYSTEM = LOVE_SRC / "src" / "modules" / "system" / "wrap_System.cpp"
|
WRAP_SYSTEM = LOVE_SRC / "src" / "modules" / "system" / "wrap_System.cpp"
|
||||||
PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj"
|
PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj"
|
||||||
|
APPLE_MM = LOVE_SRC / "src" / "common" / "apple.mm"
|
||||||
|
FILESYSTEM_CPP = LOVE_SRC / "src" / "modules" / "filesystem" / "physfs" / "Filesystem.cpp"
|
||||||
ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements"
|
ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements"
|
||||||
|
|
||||||
NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m")
|
NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m")
|
||||||
@@ -84,6 +86,49 @@ int w_pickFile(lua_State *L)
|
|||||||
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// love.system.pickFileKinds() -> "rom,mod,sav,stadium", or nil off iOS.
|
||||||
|
//
|
||||||
|
// So a caller can ask what this build's picker understands BEFORE opening it.
|
||||||
|
// An unknown kind is refused (GRPickerBridge), and a refusal looks exactly
|
||||||
|
// like a picker that would not open -- so a caller with a fallback worth
|
||||||
|
// showing needs to know which it is facing. A mod that guesses instead has
|
||||||
|
// no way back: before the refusal landed, an unrecognised kind wrote
|
||||||
|
// picked_rom.gb and the ROM importer deleted it.
|
||||||
|
//
|
||||||
|
// nil where there is no bridge at all, which reads the same as "no kinds".
|
||||||
|
int w_pickFileKinds(lua_State *L)
|
||||||
|
{
|
||||||
|
Class cls = objc_getClass("GRPickerBridge");
|
||||||
|
if (cls == nullptr)
|
||||||
|
{
|
||||||
|
lua_pushnil(L);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// Fetched through the runtime: wrap_System.cpp is compiled as C++ rather
|
||||||
|
// than Objective-C++, so no Foundation type may be NAMED here -- writing
|
||||||
|
// `NSString` alone breaks the whole translation unit. objc_msgSend is a
|
||||||
|
// plain C entry point and `id` comes from objc/runtime.h, so the string
|
||||||
|
// is asked for its UTF8 bytes without ever being typed.
|
||||||
|
typedef id (*GRObj)(Class, SEL);
|
||||||
|
id kinds = ((GRObj)objc_msgSend)(cls,
|
||||||
|
sel_registerName("supportedPickerKinds"));
|
||||||
|
if (kinds == nullptr)
|
||||||
|
{
|
||||||
|
lua_pushnil(L);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
typedef const char *(*GRUTF8)(id, SEL);
|
||||||
|
const char *bytes = ((GRUTF8)objc_msgSend)(kinds,
|
||||||
|
sel_registerName("UTF8String"));
|
||||||
|
if (bytes == nullptr || bytes[0] == '\0')
|
||||||
|
{
|
||||||
|
lua_pushnil(L);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
lua_pushstring(L, bytes);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
int w_createFile(lua_State *L)
|
int w_createFile(lua_State *L)
|
||||||
{
|
{
|
||||||
const char *name = luaL_optstring(L, 1, "export.sav");
|
const char *name = luaL_optstring(L, 1, "export.sav");
|
||||||
@@ -101,6 +146,7 @@ int w_syncHealthSteps(lua_State *L)
|
|||||||
|
|
||||||
WRAP_REGISTRATION = """#ifdef LOVE_IOS
|
WRAP_REGISTRATION = """#ifdef LOVE_IOS
|
||||||
{ "pickFile", w_pickFile },
|
{ "pickFile", w_pickFile },
|
||||||
|
{ "pickFileKinds", w_pickFileKinds },
|
||||||
{ "createFile", w_createFile },
|
{ "createFile", w_createFile },
|
||||||
{ "syncHealthSteps", w_syncHealthSteps },
|
{ "syncHealthSteps", w_syncHealthSteps },
|
||||||
{ "httpDownload", w_httpDownload },
|
{ "httpDownload", w_httpDownload },
|
||||||
@@ -201,19 +247,20 @@ def fail(msg):
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def pristine(path: Path) -> str:
|
def pristine(path: Path, patched_markers=None) -> str:
|
||||||
"""Text of `path` before any of our patching: backed by a `.orig` stash.
|
"""Text of `path` before any of our patching: backed by a `.orig` stash.
|
||||||
|
|
||||||
The stash is only trusted if it is itself unpatched; that protects
|
The stash is only trusted if it is itself unpatched; that protects
|
||||||
against a stash accidentally taken after an earlier patch run.
|
against a stash accidentally taken after an earlier patch run.
|
||||||
"""
|
"""
|
||||||
|
patched_markers = tuple(patched_markers or (MARKER, ID_FILE_PICKER))
|
||||||
orig = path.with_suffix(path.suffix + ".orig")
|
orig = path.with_suffix(path.suffix + ".orig")
|
||||||
if orig.is_file():
|
if orig.is_file():
|
||||||
text = orig.read_text()
|
text = orig.read_text()
|
||||||
if MARKER not in text and ID_FILE_PICKER not in text:
|
if not any(marker in text for marker in patched_markers):
|
||||||
return text
|
return text
|
||||||
text = path.read_text()
|
text = path.read_text()
|
||||||
if MARKER in text or ID_FILE_PICKER in text:
|
if any(marker in text for marker in patched_markers):
|
||||||
fail(f"{path} is already patched and no pristine .orig stash exists;\n"
|
fail(f"{path} is already patched and no pristine .orig stash exists;\n"
|
||||||
f" delete {LOVE_SRC} and re-run scripts/build_ios.sh --fetch")
|
f" delete {LOVE_SRC} and re-run scripts/build_ios.sh --fetch")
|
||||||
orig.write_text(text)
|
orig.write_text(text)
|
||||||
@@ -255,6 +302,62 @@ def patch_wrap_system():
|
|||||||
"(pickFile/createFile/syncHealthSteps/httpDownload)")
|
"(pickFile/createFile/syncHealthSteps/httpDownload)")
|
||||||
|
|
||||||
|
|
||||||
|
def patch_public_documents():
|
||||||
|
text = pristine(
|
||||||
|
APPLE_MM,
|
||||||
|
("#ifdef LOVE_IOS\n"
|
||||||
|
"\t\t\tnsdir = NSDocumentDirectory;\n"
|
||||||
|
"#else\n",),
|
||||||
|
)
|
||||||
|
original = (
|
||||||
|
"\t\tcase USER_DIRECTORY_APPSUPPORT:\n"
|
||||||
|
"\t\t\tnsdir = NSApplicationSupportDirectory;\n"
|
||||||
|
"\t\t\tbreak;"
|
||||||
|
)
|
||||||
|
replacement = (
|
||||||
|
"\t\tcase USER_DIRECTORY_APPSUPPORT:\n"
|
||||||
|
"#ifdef LOVE_IOS\n"
|
||||||
|
"\t\t\tnsdir = NSDocumentDirectory;\n"
|
||||||
|
"#else\n"
|
||||||
|
"\t\t\tnsdir = NSApplicationSupportDirectory;\n"
|
||||||
|
"#endif\n"
|
||||||
|
"\t\t\tbreak;"
|
||||||
|
)
|
||||||
|
if original not in text:
|
||||||
|
fail(f"iOS app-support path anchor not found in {APPLE_MM}")
|
||||||
|
APPLE_MM.write_text(text.replace(original, replacement, 1))
|
||||||
|
|
||||||
|
filesystem_text = pristine(
|
||||||
|
FILESYSTEM_CPP,
|
||||||
|
("#ifdef LOVE_IOS\n"
|
||||||
|
"\t\t\tsuffix.clear();\n"
|
||||||
|
"#else\n",),
|
||||||
|
)
|
||||||
|
filesystem_original = (
|
||||||
|
"\t\tstd::string suffix;\n"
|
||||||
|
"\t\tif (isFused())\n"
|
||||||
|
"\t\t\tsuffix = std::string(LOVE_PATH_SEPARATOR) + saveIdentity;\n"
|
||||||
|
"\t\telse\n"
|
||||||
|
"\t\t\tsuffix = std::string(LOVE_PATH_SEPARATOR LOVE_APPDATA_FOLDER LOVE_PATH_SEPARATOR) + saveIdentity;"
|
||||||
|
)
|
||||||
|
filesystem_replacement = (
|
||||||
|
"\t\tstd::string suffix;\n"
|
||||||
|
"#ifdef LOVE_IOS\n"
|
||||||
|
"\t\t\tsuffix.clear();\n"
|
||||||
|
"#else\n"
|
||||||
|
"\t\tif (isFused())\n"
|
||||||
|
"\t\t\tsuffix = std::string(LOVE_PATH_SEPARATOR) + saveIdentity;\n"
|
||||||
|
"\t\telse\n"
|
||||||
|
"\t\t\tsuffix = std::string(LOVE_PATH_SEPARATOR LOVE_APPDATA_FOLDER LOVE_PATH_SEPARATOR) + saveIdentity;\n"
|
||||||
|
"#endif"
|
||||||
|
)
|
||||||
|
if filesystem_original not in filesystem_text:
|
||||||
|
fail(f"iOS save directory suffix anchor not found in {FILESYSTEM_CPP}")
|
||||||
|
FILESYSTEM_CPP.write_text(filesystem_text.replace(filesystem_original,
|
||||||
|
filesystem_replacement, 1))
|
||||||
|
print("patch_love_src: iOS save directory routed to Documents root")
|
||||||
|
|
||||||
|
|
||||||
def patch_pbxproj():
|
def patch_pbxproj():
|
||||||
text = pristine(PBXPROJ)
|
text = pristine(PBXPROJ)
|
||||||
|
|
||||||
@@ -318,6 +421,7 @@ def main():
|
|||||||
if not LOVE_SRC.is_dir():
|
if not LOVE_SRC.is_dir():
|
||||||
fail("love-src/ missing; run scripts/build_ios.sh --fetch first")
|
fail("love-src/ missing; run scripts/build_ios.sh --fetch first")
|
||||||
copy_native_files()
|
copy_native_files()
|
||||||
|
patch_public_documents()
|
||||||
patch_wrap_system()
|
patch_wrap_system()
|
||||||
patch_pbxproj()
|
patch_pbxproj()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# spanish_ui
|
||||||
|
|
||||||
|
A Espanol translation of the game.
|
||||||
|
|
||||||
|
Generated with `python3 tools/modkit.py translation spanish_ui`. See
|
||||||
|
`TRANSLATING.md` for how to work on it.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Nothing is translated yet: 601 strings are waiting in `lang/`.
|
||||||
|
|
||||||
|
| Catalog | Entries |
|
||||||
|
|---|---|
|
||||||
|
| `lang/dialogue.lua` | 6 |
|
||||||
|
| `lang/strings.lua` | 577 |
|
||||||
|
| `lang/species_names.lua` | 3 |
|
||||||
|
| `lang/move_names.lua` | 4 |
|
||||||
|
| `lang/item_names.lua` | 5 |
|
||||||
|
| `lang/trainer_names.lua` | 1 |
|
||||||
|
| `lang/status_labels.lua` | 5 |
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `manifest.json` - identity and the engine version range
|
||||||
|
- `main.lua` - registers whatever is filled in and skips whatever is not
|
||||||
|
- `lang/` - the catalogs; this is the whole job
|
||||||
|
- `assets/font/` - your glyph sheet
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Translating into Espanol
|
||||||
|
|
||||||
|
Everything the player can read is one of two kinds of string, and they live
|
||||||
|
in different places for a reason.
|
||||||
|
|
||||||
|
| lang/ file | What it is | Key |
|
||||||
|
|---|---|---|
|
||||||
|
| `dialogue.lua` | Every line of extracted script text | the original label, e.g. `_PalletTownText1` |
|
||||||
|
| `strings.lua` | Text the engine itself writes: battle messages, menus, link play | the English source string |
|
||||||
|
| `species.lua` `moves.lua` `items.lua` `trainers.lua` | Names | the vanilla id |
|
||||||
|
| `statuses.lua` | `PSN`, `BRN`, ... as they appear in the HUD | the status id |
|
||||||
|
| `font.lua` `charmap.lua` | Your glyph sheet and what draws what | see below |
|
||||||
|
| `naming.lua` | The letter grid for entering names | - |
|
||||||
|
|
||||||
|
Fill in a value and it takes effect. Leave it `""` and that string stays in
|
||||||
|
English, so the game is playable at every point along the way.
|
||||||
|
|
||||||
|
## Where the English is
|
||||||
|
|
||||||
|
The catalogs hold keys and *your* text, never the original English. The
|
||||||
|
English lives next door, in `spanish_ui-worksheet/`, one tab-separated file per
|
||||||
|
catalog:
|
||||||
|
|
||||||
|
```
|
||||||
|
"_AbandonLearningText" "Abandon learning\n{RAM:wStringBuffer}?"
|
||||||
|
```
|
||||||
|
|
||||||
|
That directory is deliberately outside the mod. Extracted script text and
|
||||||
|
the vanilla names are ROM content, and `modkit pack` zips everything under
|
||||||
|
the mod directory, so a worksheet kept inside would end up in your release
|
||||||
|
whatever a `.gitignore` said. Keep it beside the mod, never in it.
|
||||||
|
|
||||||
|
`lang/strings.lua` is the exception: those sources are the engine's own Lua
|
||||||
|
rather than anything out of the ROM, so there the key *is* the English and
|
||||||
|
you can translate straight from it.
|
||||||
|
|
||||||
|
## Start with the font, not the text
|
||||||
|
|
||||||
|
The engine draws from **glyph pages**: an image of 8x8 cells plus a charmap
|
||||||
|
saying which byte sequence draws which cell. The vanilla pages sit at `$60`
|
||||||
|
and `$80`. Anything from `0x100` up is free, so a new alphabet is added
|
||||||
|
rather than swapped in:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- lang/font.lua
|
||||||
|
return {
|
||||||
|
spanish_ui = {
|
||||||
|
image = "assets/font/spanish_ui.png",
|
||||||
|
base = 0x100, -- first code this page owns
|
||||||
|
glyphsPerRow = 16,
|
||||||
|
-- advance = 8, -- set this if your glyphs are not 8px wide
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- lang/charmap.lua: sequence -> code, in the same order as the sheet
|
||||||
|
return {
|
||||||
|
["A"] = 0x100,
|
||||||
|
["B"] = 0x101,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The sheet is a plain PNG, 16 glyphs to a row by default, each cell 8x8,
|
||||||
|
black on white like `assets/generated/font.png`. Codes run left to right,
|
||||||
|
top to bottom from `base`.
|
||||||
|
|
||||||
|
Sequences are matched **longest first**, so a multi-byte character and a
|
||||||
|
multi-character ligature both work and neither shadows the other:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
["\u{3042}"] = 0x120, -- one 3-byte character, one glyph
|
||||||
|
["ch"] = 0x121, -- two ASCII letters, one glyph
|
||||||
|
```
|
||||||
|
|
||||||
|
## Line length is counted in glyphs
|
||||||
|
|
||||||
|
The dialogue box fits 18 glyphs a line, not 18 bytes. A 3-byte character
|
||||||
|
costs one column, and the engine will never cut a character in half. Your
|
||||||
|
own `\n` line breaks are respected exactly as written, so break lines where
|
||||||
|
they read best rather than where they fit English.
|
||||||
|
|
||||||
|
If your glyphs are not 8px wide, set `advance` on the page and the box
|
||||||
|
re-measures.
|
||||||
|
|
||||||
|
## Format directives must survive
|
||||||
|
|
||||||
|
Some sources carry `%s` or `%d`:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
["Wild %s\nappeared!"] = "...",
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep every directive, in a count that matches. Word order is yours to
|
||||||
|
change; the engine substitutes in the order the directives appear, so if
|
||||||
|
your language needs the name last, write the sentence with the `%s` last.
|
||||||
|
A translation whose directive count does not match the English is refused
|
||||||
|
at runtime and the English is drawn instead, with a line in the log saying
|
||||||
|
so - it will not crash a battle.
|
||||||
|
|
||||||
|
## Checking your work
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/modkit.py validate spanish_ui --base imported
|
||||||
|
python3 tools/modkit.py translation spanish_ui --refresh # pick up new engine strings
|
||||||
|
POKEPORT_DEV=1 scripts/run.sh # F5 hot-reloads lang/
|
||||||
|
```
|
||||||
|
|
||||||
|
`--refresh` rewrites the catalogs from the current engine, keeping every
|
||||||
|
translation you have already written and reporting what changed. Run it
|
||||||
|
after pulling a new engine version.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Put your glyph sheet here.
|
||||||
|
|
||||||
|
A page is a PNG of 8x8 cells, 16 per row by default, black on white. Codes
|
||||||
|
run left to right and top to bottom starting at the page's `base`, so the
|
||||||
|
first cell is `base`, the second `base + 1`, and so on.
|
||||||
|
|
||||||
|
`assets/generated/font.png` in the player's cache is the vanilla sheet at
|
||||||
|
the same scale; open it alongside yours to match weight and baseline.
|
||||||
|
|
||||||
|
Declare the sheet in `lang/font.lua` and map sequences to codes in
|
||||||
|
`lang/charmap.lua`.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Which byte sequence draws which glyph code.
|
||||||
|
--
|
||||||
|
-- Sequences are matched longest-first, so a multi-byte character and a
|
||||||
|
-- multi-character ligature both work: "ch" can be one glyph even though
|
||||||
|
-- "c" is also mapped. Codes here must land inside a page declared in
|
||||||
|
-- lang/font.lua.
|
||||||
|
return {
|
||||||
|
-- ["A"] = 0x100,
|
||||||
|
-- ["B"] = 0x101,
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Script text
|
||||||
|
--
|
||||||
|
-- Keyed by the original text label. The English is in the comment.
|
||||||
|
|
||||||
|
return {
|
||||||
|
["_FixMartText"] = "",
|
||||||
|
["_FixRouteTrainerAfterText"] = "",
|
||||||
|
["_FixRouteTrainerBattleText"] = "",
|
||||||
|
["_FixRouteTrainerEndText"] = "",
|
||||||
|
["_FixTownGreeterText"] = "",
|
||||||
|
["_FixTownSignText"] = "",
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Glyph pages this translation adds. Delete the entry if the vanilla
|
||||||
|
-- alphabet already covers your language.
|
||||||
|
--
|
||||||
|
-- base is the first glyph code the page owns. 0x100 and up is free space
|
||||||
|
-- above the vanilla $60/$80 pages, so this adds an alphabet rather than
|
||||||
|
-- replacing one. Set `advance` if your glyphs are not 8px wide.
|
||||||
|
return {
|
||||||
|
-- spanish_ui = {
|
||||||
|
-- image = "assets/font/spanish_ui.png",
|
||||||
|
-- base = 0x100,
|
||||||
|
-- glyphsPerRow = 16,
|
||||||
|
-- },
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Item names
|
||||||
|
--
|
||||||
|
-- Item names for Espanol.
|
||||||
|
|
||||||
|
return {
|
||||||
|
["FIX_BADGE_1"] = "",
|
||||||
|
["FIX_BADGE_2"] = "",
|
||||||
|
["FIX_BALL"] = "",
|
||||||
|
["FIX_POTION"] = "",
|
||||||
|
["FIX_TM"] = "",
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Move names
|
||||||
|
--
|
||||||
|
-- Move names for Espanol.
|
||||||
|
|
||||||
|
return {
|
||||||
|
["FIX_CUT"] = "",
|
||||||
|
["FIX_EMBERISH"] = "",
|
||||||
|
["FIX_SCRATCH"] = "",
|
||||||
|
["FIX_TACKLE"] = "",
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- The naming screen's letter grid. Return an empty table to keep the
|
||||||
|
-- English alphabet.
|
||||||
|
--
|
||||||
|
-- Each entry is a row of cells; a cell is whatever sequence your charmap
|
||||||
|
-- maps, so a multi-byte character is one cell. The row holding a single
|
||||||
|
-- "lower case" / "UPPER CASE" cell is the case switch, and the cell
|
||||||
|
-- spelled "ED" is the confirm.
|
||||||
|
--
|
||||||
|
-- The screen is 160x144 and NamingScreen draws cell `c` of row `r` at
|
||||||
|
-- (c * 16, 32 + r * 16), so the grid is capped at **9 columns and 6 rows**:
|
||||||
|
-- a 10th column lands at x=160 and a 7th row at y=144, both off screen.
|
||||||
|
-- That leaves 44 usable cells, exactly what vanilla uses, so Spanish
|
||||||
|
-- letters have to displace something rather than being added.
|
||||||
|
--
|
||||||
|
-- What gives way is vanilla's `× ( ) : ; [ ]` row. Those are legal in a
|
||||||
|
-- Gen-1 nickname but nobody reaches for them, whereas Ñ is not optional in
|
||||||
|
-- Spanish -- and here it sits in its alphabetical place after N, which is
|
||||||
|
-- where a Spanish speaker will look for it. Space, <PK> and <MN> are kept.
|
||||||
|
--
|
||||||
|
-- These glyphs exist in the Spanish cartridge's font ($CA Ñ, $BF Á, $C7 É,
|
||||||
|
-- $C9 Í, $CC Ó, $CE Ú, $C2 Ü and their lowercase). On an English ROM they
|
||||||
|
-- do not, so main.lua checks the running game's charmap first and keeps the
|
||||||
|
-- English grid rather than drawing blank cells.
|
||||||
|
return {
|
||||||
|
upper = {
|
||||||
|
{ "A", "B", "C", "D", "E", "F", "G", "H", "I" },
|
||||||
|
{ "J", "K", "L", "M", "N", "Ñ", "O", "P", "Q" },
|
||||||
|
{ "R", "S", "T", "U", "V", "W", "X", "Y", "Z" },
|
||||||
|
{ "Á", "É", "Í", "Ó", "Ú", "Ü", " ", "<PK>", "<MN>" },
|
||||||
|
{ "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" },
|
||||||
|
{ "lower case" },
|
||||||
|
},
|
||||||
|
lower = {
|
||||||
|
{ "a", "b", "c", "d", "e", "f", "g", "h", "i" },
|
||||||
|
{ "j", "k", "l", "m", "n", "ñ", "o", "p", "q" },
|
||||||
|
{ "r", "s", "t", "u", "v", "w", "x", "y", "z" },
|
||||||
|
{ "á", "é", "í", "ó", "ú", "ü", " ", "<PK>", "<MN>" },
|
||||||
|
{ "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" },
|
||||||
|
{ "UPPER CASE" },
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Species names
|
||||||
|
--
|
||||||
|
-- Species names for Espanol.
|
||||||
|
|
||||||
|
return {
|
||||||
|
["FIXMON_A"] = "",
|
||||||
|
["FIXMON_B"] = "",
|
||||||
|
["FIXMON_C"] = "",
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Status labels
|
||||||
|
--
|
||||||
|
-- Short enough for the battle HUD: the vanilla ones are three glyphs.
|
||||||
|
|
||||||
|
return {
|
||||||
|
["BRN"] = "",
|
||||||
|
["FRZ"] = "",
|
||||||
|
["PAR"] = "",
|
||||||
|
["PSN"] = "",
|
||||||
|
["SLP"] = "",
|
||||||
|
}
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
-- Engine text
|
||||||
|
--
|
||||||
|
-- Keyed by the English source, which is also what draws if you leave
|
||||||
|
-- an entry empty. Keep any %s / %d directives.
|
||||||
|
|
||||||
|
return {
|
||||||
|
["%s\nflew up high!"] = "¡%s\nvoló muy alto!",
|
||||||
|
["%s\ndug a hole!"] = "¡%s\ncavó un hoyo!",
|
||||||
|
["%s\nmade a whirlwind!"] = "¡%s\ncreó un torbellino!",
|
||||||
|
["%s\ntook in sunlight!"] = "¡%s\nabsorbió luz!",
|
||||||
|
["%s\nlowered its head!"] = "¡%s\nbajó la cabeza!",
|
||||||
|
["%s\nis glowing!"] = "¡%s\nestá brillando!",
|
||||||
|
["The hooked\n%s\nattacked!"] = "¡El %s\nenganchado atacó!",
|
||||||
|
["Wild %s\nappeared!"] = "¡Un %s\nsalvaje apareció!",
|
||||||
|
["%s wants\nto fight!"] = "¡%s\nquiere luchar!",
|
||||||
|
["The GHOST\nappeared!"] = "¡Apareció el\nFANTASMA!",
|
||||||
|
["Go! %s!"] = "¡Ve, %s!",
|
||||||
|
["Do it! %s!"] = "¡Hazlo, %s!",
|
||||||
|
["Get'm! %s!"] = "¡A por él, %s!",
|
||||||
|
["The enemy's weak!\nGet'm! %s!"] = "¡Está débil!\n¡A por él, %s!",
|
||||||
|
["%s is out of\nuseable POKéMON!"] = "¡%s no tiene\nPOKéMON útiles!",
|
||||||
|
["%s blacked\nout!"] = "¡%s se\ndebilitó!",
|
||||||
|
["%s sent\nout %s!"] = "¡%s envió\na %s!",
|
||||||
|
["PA: You're out of\nSAFARI BALLs!\nGame over!"] = "AV: ¡No te quedan\nSAFARI BALLs!\n¡Fin del juego!",
|
||||||
|
["%s is too\nscared to move!"] = "¡%s tiene\ndemasiado miedo!",
|
||||||
|
["%s has no\nmoves left!"] = "¡%s no tiene\nmovimientos!",
|
||||||
|
["The move is\ndisabled!"] = "¡El movimiento\nestá anulado!",
|
||||||
|
["No PP left for\nthis move!"] = "¡No quedan PP para\neste movimiento!",
|
||||||
|
["But, it failed!"] = "¡Pero falló!",
|
||||||
|
["%s\nlearned\n%s!"] = "¡%s\naprendió\n%s!",
|
||||||
|
["POKé BALL"] = "POKé BALL",
|
||||||
|
["%s used\nPOKé BALL!"] = "¡%s usó\nPOKé BALL!",
|
||||||
|
["All right!\n%s was\ncaught!"] = "¡Bien!\n¡%s fue\ncapturado!",
|
||||||
|
["GHOST: Get out...\nGet out..."] = "FANTASMA: Fuera...\nFuera...",
|
||||||
|
["%s with-\ndrew %s!"] = "¡%s retiró\na %s!",
|
||||||
|
["%s\nmust recharge!"] = "¡%s debe\nrecargarse!",
|
||||||
|
["%s\nis fast asleep!"] = "¡%s está\nprofundamente dormido!",
|
||||||
|
["%s\nis confused!"] = "¡%s está\nconfuso!",
|
||||||
|
["%s\nwoke up!"] = "¡%s se\ndespertó!",
|
||||||
|
["%s\nis frozen solid!"] = "¡%s está\ncongelado!",
|
||||||
|
["%s\ncan't move!"] = "¡%s no\npuede moverse!",
|
||||||
|
["%s\nflinched!"] = "¡%s se\namedrentó!",
|
||||||
|
["It hurt itself in\nits confusion!"] = "¡Se hirió a sí\nmismo por confusión!",
|
||||||
|
["%s\nused %s!"] = "¡%s usó\n%s!",
|
||||||
|
["%s\nis charging up!"] = "¡%s está\ncargando energía!",
|
||||||
|
["%s's\nattack missed!"] = "¡El ataque de %s\nfalló!",
|
||||||
|
["%s's\nattack continues!"] = "¡El ataque de %s\ncontinúa!",
|
||||||
|
["%s\nis storing energy!"] = "¡%s está\nacumulando energía!",
|
||||||
|
["%s\nunleashed energy!"] = "¡%s liberó\nsu energía!",
|
||||||
|
["%s's\nSUBSTITUTE broke!"] = "¡El SUSTITUTO de\n%s se rompió!",
|
||||||
|
["The SUBSTITUTE\ntook damage for\n%s!"] = "¡El SUSTITUTO\nrecibió el daño\nde %s!",
|
||||||
|
["%s's\nRAGE is building!"] = "¡La FURIA de %s\nva creciendo!",
|
||||||
|
["%s\nfainted!"] = "¡%s se\ndebilitó!",
|
||||||
|
["%s gained\n%d EXP. Points!"] = "¡%s ganó\n%d P. EXP.!",
|
||||||
|
["%s gained\nwith EXP.ALL,\v%d EXP. Points!"] = "¡%s ganó\ncon EXP.TODOS,\v%d P. EXP.!",
|
||||||
|
["%s gained\na boosted\v%d EXP. Points!"] = "¡%s ganó\nun extra de\v%d P. EXP.!",
|
||||||
|
["%s grew\nto level %d!"] = "¡%s subió\nal nivel %d!",
|
||||||
|
["%s is\nabout to use"] = "%s va a usar",
|
||||||
|
["%s!"] = "¡%s!",
|
||||||
|
["Will %s\nchange POKéMON?"] = "¿%s va a\ncambiar de POKéMON?",
|
||||||
|
["%s defeated\n%s!"] = "¡%s venció\na %s!",
|
||||||
|
["%s got ¥%d\nfor winning!"] = "¡%s ganó\n¥%d!",
|
||||||
|
["%s learned\n%s!"] = "¡%s aprendió\n%s!",
|
||||||
|
["{RIVAL}: Yeah! Am\nI great or what?"] = "{RIVAL}: ¡Sí! ¿Soy\ngenial o qué?",
|
||||||
|
["Use next POKéMON?"] = "¿Sacar al siguiente?",
|
||||||
|
["Got away safely!"] = "¡Escapaste!",
|
||||||
|
["Can't escape!"] = "¡No puedes escapar!",
|
||||||
|
["There's no will\nto fight!"] = "¡No hay ganas de\nluchar!",
|
||||||
|
["%s used\nSAFARI BALL!"] = "¡%s usó\nSAFARI BALL!",
|
||||||
|
["%s threw some\nBAIT."] = "%s echó\nCEBO.",
|
||||||
|
["%s threw a\nROCK."] = "%s tiró una\nPIEDRA.",
|
||||||
|
["Wild %s\nis eating!"] = "¡El %s\nsalvaje come!",
|
||||||
|
["Wild %s\nis angry!"] = "¡El %s\nsalvaje se enfadó!",
|
||||||
|
["Wild %s\nran!"] = "¡El %s\nsalvaje huyó!",
|
||||||
|
["No! There's no\nrunning from a\vtrainer battle!"] = "¡No! ¡No puedes\nhuir de un combate\vcontra un entrenador!",
|
||||||
|
["You missed the\nPOKéMON!"] = "¡Fallaste el tiro!",
|
||||||
|
["Darn! The POKéMON\nbroke free!"] = "¡Vaya! ¡El POKéMON\nse escapó!",
|
||||||
|
["Aww! It appeared\nto be caught!"] = "¡Oh! ¡Parecía que\nestaba capturado!",
|
||||||
|
["Shoot! It was so\nclose too!"] = "¡Vaya! ¡Estuvo\nmuy cerca!",
|
||||||
|
["Do you want to\ngive a nickname\nto %s?"] = "¿Quieres poner un\nmote a\n%s?",
|
||||||
|
["NICKNAME?"] = "MOTE?",
|
||||||
|
["New POKéDEX data\nwill be added for\n%s!"] = "¡Se añadirán datos\nnuevos a la POKéDEX\nde %s!",
|
||||||
|
["someone's PC"] = "el PC de alguien",
|
||||||
|
["%s was\ntransferred to\n%s!"] = "¡%s fue\ntransferido a\n%s!",
|
||||||
|
["But every BOX\nis full!"] = "¡Pero todas las\nCAJAS están llenas!",
|
||||||
|
["%s used\n%s!"] = "¡%s usó\n%s!",
|
||||||
|
["The trainer\nblocked the BALL!"] = "¡El entrenador\nbloqueó la BALL!",
|
||||||
|
["Don't be a thief!"] = "¡No seas ladrón!",
|
||||||
|
["It dodged the\nthrown BALL!"] = "¡Esquivó la BALL!",
|
||||||
|
["This POKéMON\ncan't be caught!"] = "¡Este POKéMON no\nse puede capturar!",
|
||||||
|
["%s is\nalready out!"] = "¡%s ya\nestá fuera!",
|
||||||
|
["%s picked up\n¥%d!"] = "¡%s recogió\n¥%d!",
|
||||||
|
["FIGHT"] = "LUCHAR",
|
||||||
|
["ITEM"] = "OBJETO",
|
||||||
|
["RUN"] = "HUIR",
|
||||||
|
["BALLx"] = "BALLx",
|
||||||
|
["BAIT"] = "CEBO",
|
||||||
|
["THROW ROCK"] = "TIRAR PIEDRA",
|
||||||
|
["disabled!"] = "¡anulado!",
|
||||||
|
["TYPE/"] = "TIPO/",
|
||||||
|
["It doesn't affect\n%s!"] = "¡No afecta a\n%s!",
|
||||||
|
["Critical hit!"] = "¡Golpe crítico!",
|
||||||
|
["One-hit KO!"] = "¡KO en un golpe!",
|
||||||
|
["It's super\neffective!"] = "¡Es muy eficaz!",
|
||||||
|
["It's not very\neffective..."] = "¡No es muy\neficaz...",
|
||||||
|
["Hit the enemy\n%d times!"] = "¡Golpeó al enemigo\n%d veces!",
|
||||||
|
["Hit %d times!"] = "¡Golpeó %d veces!",
|
||||||
|
["%s's\nhit with recoil!"] = "¡%s sufrió\nel retroceso!",
|
||||||
|
["%s is\nprotected by MIST!"] = "¡%s está\nprotegido por NIEBLA!",
|
||||||
|
["Nothing happened!"] = "¡No pasó nada!",
|
||||||
|
["%s's\n%s\ngreatly rose!"] = "¡%s\nmejoró mucho su\n%s!",
|
||||||
|
["%s's\n%s rose!"] = "¡%s mejoró\nsu %s!",
|
||||||
|
["%s's\n%s fell!"] = "¡%s bajó\nsu %s!",
|
||||||
|
["%s's\n%s\ngreatly fell!"] = "¡%s\nbajó mucho su\n%s!",
|
||||||
|
["Fire defrosted\n%s!"] = "¡El fuego descongeló\na %s!",
|
||||||
|
["%s\nbecame confused!"] = "¡%s se\nconfundió!",
|
||||||
|
["%s\nwas seeded!"] = "¡%s recibió\nla DRENADORA!",
|
||||||
|
["%s\nstarted sleeping!"] = "¡%s se\nquedó dormido!",
|
||||||
|
["%s\nregained health!"] = "¡%s recuperó\nsalud!",
|
||||||
|
["%s's\nprotected against\nspecial attacks!"] = "¡%s está\nprotegido de los\nataques especiales!",
|
||||||
|
["%s\ngained armor!"] = "¡%s ganó\narmadura!",
|
||||||
|
["%s's\nshrouded in mist!"] = "¡%s se\ncubrió de niebla!",
|
||||||
|
["%s's\ngetting pumped!"] = "¡%s se\nestá animando!",
|
||||||
|
["All STATUS changes\nare eliminated!"] = "¡Los cambios de\nESTADO desaparecen!",
|
||||||
|
["%s\nhas a SUBSTITUTE!"] = "¡%s tiene\nun SUSTITUTO!",
|
||||||
|
["Too weak to make\na SUBSTITUTE!"] = "¡Muy débil para\nhacer un SUSTITUTO!",
|
||||||
|
["It created a\nSUBSTITUTE!"] = "¡Creó un SUSTITUTO!",
|
||||||
|
["Converted type to\n%s's!"] = "¡Cambió su tipo al\nde %s!",
|
||||||
|
["%s\ntransformed into\n%s!"] = "¡%s se\ntransformó en\n%s!",
|
||||||
|
["%s's\n%s was\ndisabled!"] = "¡El %s\nde %s\nfue anulado!",
|
||||||
|
["No effect!"] = "¡Sin efecto!",
|
||||||
|
["Sucked health from\n%s!"] = "¡Absorbió salud de\n%s!",
|
||||||
|
["%s's\ndream was eaten!"] = "¡Devoró el sueño\nde %s!",
|
||||||
|
["%s\nkept going and\ncrashed!"] = "¡%s siguió\nadelante y se\nestrelló!",
|
||||||
|
["Coins scattered\neverywhere!"] = "¡Las monedas se\ndesparramaron!",
|
||||||
|
["%s\nran away scared!"] = "¡%s huyó\nasustado!",
|
||||||
|
["%s\nwas blown away!"] = "¡%s salió\nvolando!",
|
||||||
|
["%s\nran from battle!"] = "¡%s huyó\ndel combate!",
|
||||||
|
["It didn't affect\n%s!"] = "¡No afectó a\n%s!",
|
||||||
|
["%s\nis unaffected!"] = "¡%s no se\nvio afectado!",
|
||||||
|
["The MIRROR MOVE\nfailed!"] = "¡El MOVIMIENTO\nESPEJO falló!",
|
||||||
|
["%s\nfell asleep!"] = "¡%s se\nquedó dormido!",
|
||||||
|
["%s\nwas frozen solid!"] = "¡%s se\ncongeló!",
|
||||||
|
["%s's\nhurt by poison!"] = "¡El veneno hiere a\n%s!",
|
||||||
|
["%s's\nbadly poisoned!"] = "¡%s está\ngravemente envenenado!",
|
||||||
|
["%s\nwas poisoned!"] = "¡%s fue\nenvenenado!",
|
||||||
|
["%s's\nhurt by the burn!"] = "¡La quemadura hiere\na %s!",
|
||||||
|
["%s\nwas burned!"] = "¡%s se\nquemó!",
|
||||||
|
["%s's\nfully paralyzed!"] = "¡%s está\ntotalmente paralizado!",
|
||||||
|
["%s's\nparalyzed! It may\nnot attack!"] = "¡%s está\nparalizado! ¡Puede\nque no ataque!",
|
||||||
|
["%s's\ndisabled no more!"] = "¡%s ya no\nestá anulado!",
|
||||||
|
["%s\nsnapped out of\nconfusion!"] = "¡%s salió\nde su confusión!",
|
||||||
|
["LEECH SEED saps\n%s!"] = "¡La DRENADORA\nabsorbe a %s!",
|
||||||
|
["%s\nwas afflicted\nby %s!"] = "¡%s sufre\n%s!",
|
||||||
|
["%s's\nprotected against\nstat changes!"] = "¡%s está\nprotegido de los\ncambios de estado!",
|
||||||
|
["What will"] = "¿Qué va a hacer",
|
||||||
|
[" do?"] = "?",
|
||||||
|
["You can't get off\nhere."] = "No puedes bajarte\naquí.",
|
||||||
|
["%s got off\nthe BICYCLE."] = "%s se bajó\nde la BICICLETA.",
|
||||||
|
["%s got on\nthe BICYCLE!"] = "¡%s se subió\na la BICICLETA!",
|
||||||
|
["No cycling\nallowed here."] = "No se puede montar\naquí.",
|
||||||
|
["No good! It's not\neven near water."] = "¡No sirve! No hay\nagua cerca.",
|
||||||
|
["OAK: %s!\nThis isn't the\ntime to use that!"] = "OAK: ¡%s!\n¡No es momento\nde usar eso!",
|
||||||
|
["The TOWN MAP is\nunreadable here."] = "El MAPA PUEBLO no\nse puede leer aquí.",
|
||||||
|
["Yes! ITEMFINDER\nindicates there's\nan item nearby."] = "¡Sí! El BUSCAOBJ.\nindica que hay algo\ncerca.",
|
||||||
|
["Nope! ITEMFINDER\nisn't responding."] = "¡No! El BUSCAOBJ.\nno responde.",
|
||||||
|
["Booted up a TM!"] = "¡Se activó una MT!",
|
||||||
|
["It contained\n%s!"] = "¡Contenía\n%s!",
|
||||||
|
["USE"] = "USAR",
|
||||||
|
["TOSS"] = "TIRAR",
|
||||||
|
["That's too impor-\ntant to toss!"] = "¡Es demasiado\nimportante!",
|
||||||
|
["Threw away\n%s."] = "Tiraste\n%s.",
|
||||||
|
["PRESS A BUTTON"] = "PULSA UN BOTON",
|
||||||
|
["ESC TO CANCEL"] = "ESC PARA CANCELAR",
|
||||||
|
["%s :L%d"] = "%s :N%d",
|
||||||
|
["STATS"] = "DATOS",
|
||||||
|
["CANCEL"] = "CANCELAR",
|
||||||
|
["What? There are\nno POKéMON here!"] = "¿Qué? ¡Aquí no hay\nningún POKéMON!",
|
||||||
|
["You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."] = "No puedes llevar\nmás POKéMON.\fGuarda alguno\nprimero.",
|
||||||
|
["BOX %d (WITHDRAW)"] = "CAJA %d (RETIRAR)",
|
||||||
|
["%s is\ntaken out.\vGot %s."] = "Retirado\n%s.\vRecibes %s.",
|
||||||
|
["You can't deposit\nthe last POKéMON!"] = "¡No puedes guardar\nel último POKéMON!",
|
||||||
|
["Oops! This Box is\nfull of POKéMON."] = "¡Uups! Esta CAJA\nestá llena.",
|
||||||
|
["You need at least\none POKéMON!"] = "¡Necesitas al menos\nun POKéMON!",
|
||||||
|
["BOX %d is full!"] = "¡La CAJA %d está\nllena!",
|
||||||
|
["%s was\nstored in Box %s."] = "%s se\nguardó en la CAJA %s.",
|
||||||
|
["BOX %d (RELEASE)"] = "CAJA %d (SOLTAR)",
|
||||||
|
["Once released,\n%s is\ngone forever. OK?"] = "Si lo sueltas,\n%s se\nirá para siempre. ¿OK?",
|
||||||
|
["%s was\nreleased outside.\fBye %s!"] = "%s fue\nliberado.\f¡Adiós, %s!",
|
||||||
|
["%sBOX %2d"] = "%sCAJA %2d",
|
||||||
|
["When you change a\nPOKéMON BOX, data\nwill be saved. OK?"] = "Al cambiar de CAJA\nse guardarán los\ndatos. ¿OK?",
|
||||||
|
["What?"] = "¿Qué?",
|
||||||
|
["BOX No."] = "CAJA No.",
|
||||||
|
["BOX No.%d"] = "CAJA No.%d",
|
||||||
|
["Empty."] = "Vacía.",
|
||||||
|
[":L%d No.%03d"] = ":N%d No.%03d",
|
||||||
|
["Printed BOX %d!\fSaved as\n%s\vin the save\nfolder."] = "¡CAJA %d impresa!\fGuardada como\n%s\ven la carpeta de\nguardado.",
|
||||||
|
["Printer error!\n%s"] = "¡Error de impresión!\n%s",
|
||||||
|
["WITHDRAW <PK><MN>"] = "RETIRAR <PK><MN>",
|
||||||
|
["DEPOSIT <PK><MN>"] = "GUARDAR <PK><MN>",
|
||||||
|
["RELEASE <PK><MN>"] = "SOLTAR <PK><MN>",
|
||||||
|
["CHANGE BOX"] = "CAMBIAR CAJA",
|
||||||
|
["PRINT BOX"] = "IMPRIMIR CAJA",
|
||||||
|
["SEE YA!"] = "HASTA LUEGO!",
|
||||||
|
["YES"] = "SI",
|
||||||
|
["NO"] = "NO",
|
||||||
|
["GAME FREAK"] = "",
|
||||||
|
["Nintendo"] = "",
|
||||||
|
["Creatures inc."] = "",
|
||||||
|
["GAME FREAK inc."] = "",
|
||||||
|
["T H E E N D"] = "F I N",
|
||||||
|
["HT %d′%02d″"] = "AL %d′%02d″",
|
||||||
|
["WT %.1flb"] = "PE %.1flb",
|
||||||
|
["Data unknown."] = "Datos desconocidos.",
|
||||||
|
["<Diploma>"] = "<Diploma>",
|
||||||
|
["Player"] = "Jugador",
|
||||||
|
["Huh? %s\nstopped evolving!"] = "¿Eh? ¡%s\ndejó de evolucionar!",
|
||||||
|
["Congratulations!\nYour %s\nevolved into\n%s!"] = "¡Enhorabuena!\n¡Tu %s\nevolucionó a\n%s!",
|
||||||
|
["evolving!"] = "evolucionando",
|
||||||
|
["POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"] = "POKéDEX Vistos:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Capturados:{NUM:wDexRatingNumMonsOwned, 1, 3}",
|
||||||
|
["POKéDEX Rating{COLON}"] = "Nota POKéDEX{COLON}",
|
||||||
|
["Keep it up!"] = "¡Sigue así!",
|
||||||
|
["LEVEL/"] = "NIVEL/",
|
||||||
|
["TYPE1/"] = "TIPO1/",
|
||||||
|
["TYPE2/"] = "TIPO2/",
|
||||||
|
["HALL OF FAME"] = "SALON DE LA FAMA",
|
||||||
|
["PLAY TIME"] = "TIEMPO",
|
||||||
|
["MONEY"] = "DINERO",
|
||||||
|
["bois club games"] = "bois club games",
|
||||||
|
["GENGAR VS NIDORINO"] = "",
|
||||||
|
["bois club"] = "bois club",
|
||||||
|
["Nothing here."] = "Aquí no hay nada.",
|
||||||
|
["%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f"] = "¡%s está\nintentando aprender\v%s!\f¡Pero %s\nno puede aprender\vmás de 4!\f",
|
||||||
|
["Delete an older\nmove to make room\vfor %s?"] = "¿Borrar un movi-\nmiento antiguo para\vaprender %s?",
|
||||||
|
["HM techniques\ncan't be deleted!"] = "¡Los movimientos MO\nno se pueden borrar!",
|
||||||
|
["Abandon learning\n%s?"] = "¿Dejar de aprender\n%s?",
|
||||||
|
["1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!"] = "¡1, 2 y... plaf!\f¡%s olvidó\n%s!\f¡Y...\f%s aprendió\n%s!",
|
||||||
|
["%s\ndid not learn\v%s!"] = "¡%s no\naprendió\v%s!",
|
||||||
|
["Which move should"] = "¿Qué movimiento",
|
||||||
|
["be forgotten?"] = "hay que olvidar?",
|
||||||
|
["YOUR NAME?"] = "TU NOMBRE?",
|
||||||
|
["NEW NAME"] = "NUEVO NOMBRE",
|
||||||
|
["Hello there!\nWelcome to the\vworld of POKéMON!\fMy name is OAK!\nPeople call me\vthe POKéMON PROF!"] = "¡Hola!\n¡Bienvenido al\vmundo POKéMON!\fMe llamo OAK.\nMe llaman el\vPROF. POKéMON.",
|
||||||
|
["This world is\ninhabited by\vcreatures called\vPOKéMON!"] = "¡Este mundo está\nhabitado por unas\vcriaturas llamadas\vPOKéMON!",
|
||||||
|
["\fFor some people,\nPOKéMON are\vpets. Others use\vthem for fights.\fMyself...\fI study POKéMON\nas a profession."] = "\fPara algunos, los\nPOKéMON son masco-\vtas. Otros luchan\vcon ellos.\fYo...\fEstudio los POKéMON\ncomo profesión.",
|
||||||
|
["{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!"] = "¡{PLAYER}!\f¡Tu propia leyenda\nPOKéMON está a\vpunto de comenzar!\f¡Un mundo de sueños\ny aventuras con\vPOKéMON te espera!\v¡Vamos!",
|
||||||
|
["First, what is\nyour name?"] = "¿Cómo te llamas?",
|
||||||
|
["This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?"] = "Este es mi nieto.\nHa sido tu rival\vdesde que erais\vbebés.\f...Mmm, ¿cómo se\nllamaba?",
|
||||||
|
["HIS NAME?"] = "SU NOMBRE?",
|
||||||
|
["_OakSpeechText2A"] = "",
|
||||||
|
["TEXT SPEED"] = "VEL TEXTO",
|
||||||
|
["BATTLE ANIMATION"] = "ANIMACIONES",
|
||||||
|
["OFF"] = "NO",
|
||||||
|
["ON"] = "SI",
|
||||||
|
["BATTLE STYLE"] = "ESTILO COMBATE",
|
||||||
|
["SET"] = "FIJO",
|
||||||
|
["SHIFT"] = "CAMBIO",
|
||||||
|
["BATTLE LAYOUT"] = "DISENO COMBATE",
|
||||||
|
["WIDE"] = "ANCHO",
|
||||||
|
["OG"] = "OG",
|
||||||
|
["RULESET"] = "REGLAS",
|
||||||
|
["MUSIC VOL"] = "VOL MUSICA",
|
||||||
|
["SFX VOL"] = "VOL SONIDO",
|
||||||
|
["PIKACHU VOL"] = "VOL PIKACHU",
|
||||||
|
["MUSIC FILTER"] = "FILTRO MUSICA",
|
||||||
|
["COLORS"] = "COLORES",
|
||||||
|
["TILT"] = "INCLINACION",
|
||||||
|
["GBC FX"] = "EFECTO GBC",
|
||||||
|
["ZOOM"] = "ZOOM",
|
||||||
|
["VOID FILL"] = "RELLENO VACIO",
|
||||||
|
["VIDEO MODE"] = "MODO VIDEO",
|
||||||
|
["MAX FPS"] = "FPS MAXIMO",
|
||||||
|
["GAME SPEED"] = "VELOCIDAD JUEGO",
|
||||||
|
["MODS"] = "MODS",
|
||||||
|
["%d INSTALLED"] = "%d INSTALADOS",
|
||||||
|
["CONTROLS"] = "CONTROLES",
|
||||||
|
["TOUCH PAD"] = "CONTROL TACTIL",
|
||||||
|
["SURE? AGAIN"] = "SEGURO? OTRA VEZ",
|
||||||
|
["AUTO HIDE PAD"] = "OCULTAR AUTO",
|
||||||
|
["A blinding FLASH\nlights the area!"] = "¡Un DESTELLO\nilumina la zona!",
|
||||||
|
["No SURFing here!"] = "¡Aquí no se puede\nSURFEAR!",
|
||||||
|
["Nothing to CUT!"] = "¡Nada que CORTAR!",
|
||||||
|
["{RAM:wNameBuffer} used\nSTRENGTH."] = "{RAM:wNameBuffer} usó\nFUERZA.",
|
||||||
|
["{RAM:wNameBuffer} can\nmove boulders."] = "{RAM:wNameBuffer} puede\nmover rocas.",
|
||||||
|
["It won't have\nany effect."] = "No tendrá ningún\nefecto.",
|
||||||
|
["%s's HP\nwas restored!"] = "¡Los PS de %s\nse recuperaron!",
|
||||||
|
["SWITCH"] = "CAMBIAR",
|
||||||
|
["FLY"] = "VUELO",
|
||||||
|
["FLASH"] = "DESTELLO",
|
||||||
|
["CUT"] = "CORTE",
|
||||||
|
["SURF"] = "SURF",
|
||||||
|
["STRENGTH"] = "FUERZA",
|
||||||
|
["SOFTBOILED"] = "HUEVO SUERTE",
|
||||||
|
["TELEPORT"] = "TELETRANSPORTE",
|
||||||
|
["DIG"] = "EXCAVAR",
|
||||||
|
["Use TM on which\nPOKéMON?"] = "¿Usar la MT en qué\nPOKéMON?",
|
||||||
|
["Bring out which\nPOKéMON?"] = "¿Qué POKéMON\nquieres sacar?",
|
||||||
|
["Choose a POKéMON."] = "Elige un POKéMON.",
|
||||||
|
["No POKéMON!"] = "¡Ningún POKéMON!",
|
||||||
|
["ABLE"] = "PUEDE",
|
||||||
|
["NOT ABLE"] = "NO PUEDE",
|
||||||
|
["FNT"] = "DEB",
|
||||||
|
["Move to where?"] = "¿Mover a dónde?",
|
||||||
|
["Use on which one?"] = "¿Usar en cuál?",
|
||||||
|
["You can't carry\nany more items."] = "No puedes llevar\nmás objetos.",
|
||||||
|
["Withdrew\n%s."] = "Retirado\n%s.",
|
||||||
|
["No room left to\nstore items."] = "No queda sitio para\nguardar objetos.",
|
||||||
|
["%s was\nstored via PC."] = "%s se\nguardó en el PC.",
|
||||||
|
["Toss %s?"] = "¿Tirar %s?",
|
||||||
|
["Threw away %s."] = "Tiraste %s.",
|
||||||
|
["WITHDRAW ITEM"] = "RETIRAR OBJETO",
|
||||||
|
["DEPOSIT ITEM"] = "GUARDAR OBJETO",
|
||||||
|
["TOSS ITEM"] = "TIRAR OBJETO",
|
||||||
|
["LOG OFF"] = "SALIR",
|
||||||
|
["SEEN %d OWNED %d"] = "VISTOS %d CAPT. %d",
|
||||||
|
["DATA"] = "DATOS",
|
||||||
|
["CRY"] = "VOZ",
|
||||||
|
["AREA"] = "ZONA",
|
||||||
|
["PRNT"] = "IMPR",
|
||||||
|
["Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder."] = "¡Datos de %s\nimpresos!\fGuardado como\n%s\ven la carpeta de\nguardado.",
|
||||||
|
["QUIT"] = "SALIR",
|
||||||
|
["%s (%s)"] = "%s (%s)",
|
||||||
|
["%s x%d"] = "%s x%d",
|
||||||
|
["%s to box %d"] = "%s a caja %d",
|
||||||
|
["LOAD REPORT"] = "CARGAR PARTIDA",
|
||||||
|
["A:CONTINUE"] = "A:CONTINUAR",
|
||||||
|
["You don't have\nenough money."] = "No tienes dinero\nsuficiente.",
|
||||||
|
["%s?\nThat will be\n¥%d. OK?"] = "¿%s?\nSon ¥%d.\n¿OK?",
|
||||||
|
["Here you are!\nThank you!"] = "¡Aquí tienes!\n¡Gracias!",
|
||||||
|
["I can't put a\nprice on that."] = "No puedo ponerle\nprecio a eso.",
|
||||||
|
["I can pay you\n¥%d for that."] = "Te doy ¥%d\npor eso.",
|
||||||
|
["BUY"] = "COMPRAR",
|
||||||
|
["SELL"] = "VENDER",
|
||||||
|
["%s lined up!\nScored %d coins!"] = "¡%s alineados!\n¡%d fichas!",
|
||||||
|
["Darn!\nRan out of coins!"] = "¡Vaya!\n¡Sin fichas!",
|
||||||
|
["Not enough\ncoins!"] = "¡Fichas\ninsuficientes!",
|
||||||
|
["SLOT MACHINE"] = "MAQUINA TRAGAPERRAS",
|
||||||
|
["COINS %4d"] = "FICHAS %4d",
|
||||||
|
["POKéDEX"] = "POKéDEX",
|
||||||
|
["POKéMON"] = "POKéMON",
|
||||||
|
["SAVE"] = "GUARDAR",
|
||||||
|
["PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d"] = "JUGADOR %s\nMEDALLAS %d\nPOKéDEX %3d\nTIEMPO %6d:%02d",
|
||||||
|
["\fWould you like to\nSAVE the game?"] = "\f¿Quieres GUARDAR\nla partida?",
|
||||||
|
["Now saving..."] = "Guardando...",
|
||||||
|
["%s saved\nthe game!"] = "¡%s guardó\nla partida!",
|
||||||
|
["OPTION"] = "OPCION",
|
||||||
|
["LINK"] = "LINK",
|
||||||
|
["RETURN TO MAIN\nMENU?"] = "¿VOLVER AL MENU\nPRINCIPAL?",
|
||||||
|
["BALL"] = "BALL",
|
||||||
|
["STATUS/"] = "ESTADO/",
|
||||||
|
["OT/"] = "EO/",
|
||||||
|
["EXP POINTS"] = "P. EXP.",
|
||||||
|
["LEVEL UP"] = "SUBE NIVEL",
|
||||||
|
["PP"] = "PP",
|
||||||
|
["SCORE %d"] = "PUNTOS %d",
|
||||||
|
["New record!"] = "¡Nuevo récord!",
|
||||||
|
["HI %d"] = "MAX %d",
|
||||||
|
["A: done"] = "A: listo",
|
||||||
|
["PLAYER"] = "JUGADOR",
|
||||||
|
["BADGES"] = "MEDALLAS",
|
||||||
|
["TIME"] = "TIEMPO",
|
||||||
|
["CONTINUE"] = "CONTINUAR",
|
||||||
|
["NEW GAME"] = "NUEVA PARTIDA",
|
||||||
|
["EXIT GAME"] = "SALIR DEL JUEGO",
|
||||||
|
["POKéMON RED"] = "",
|
||||||
|
["2026 bois club games"] = "",
|
||||||
|
["OT/%s"] = "EO/%s",
|
||||||
|
["NAME/%s"] = "NOMBRE/%s",
|
||||||
|
["In battle"] = "En combate",
|
||||||
|
["Wild battle"] = "Combate salvaje",
|
||||||
|
["Trainer battle"] = "Combate entrenador",
|
||||||
|
["Link battle"] = "Combate link",
|
||||||
|
["Title screen"] = "Pantalla de título",
|
||||||
|
["Level %d"] = "Nivel %d",
|
||||||
|
["What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!"] = "¿Qué?\n¡%s está\nevolucionando!\f¡Enhorabuena!\n¡Tu %s\nevolucionó a\n%s!",
|
||||||
|
["Not even a nibble!"] = "¡Ni un mordisco!",
|
||||||
|
["Oh!\nIt's a bite!"] = "¡Oh!\n¡Ha picado!",
|
||||||
|
["It's a sculpture\nof DIGLETT."] = "Es una escultura\nde DIGLETT.",
|
||||||
|
["Crammed full of\nPOKéMON books!"] = "¡Repleto de libros\nsobre POKéMON!",
|
||||||
|
["There's a slew of\nPOKéMON stuff!"] = "¡Hay un montón de\ncosas POKéMON!",
|
||||||
|
["An elevator!"] = "¡Un ascensor!",
|
||||||
|
["INDIGO PLATEAU"] = "MESETA ANIL",
|
||||||
|
["POKéMON LEAGUE HQ"] = "SEDE DE LA LIGA\nPOKéMON",
|
||||||
|
["You can't carry\nany more items!"] = "¡No puedes llevar\nmás objetos!",
|
||||||
|
["%s found\n%s!"] = "¡%s encontró\n%s!",
|
||||||
|
["%s found\n%d coins!"] = "¡%s encontró\n%d fichas!",
|
||||||
|
["OUT OF ORDER\nThis is broken."] = "FUERA DE SERVICIO\nEsto está roto.",
|
||||||
|
["OUT TO LUNCH\nThis is reserved."] = "CERRADO POR COMIDA\nEsto está reservado.",
|
||||||
|
["Someone's keys!\nThey'll be back."] = "¡Las llaves de\nalguien! Volverá.",
|
||||||
|
["A COIN CASE is\nrequired!"] = "¡Se necesita un\nMONEDERO!",
|
||||||
|
["You don't have\nany coins!"] = "¡No tienes fichas!",
|
||||||
|
["{RAM}\nPOKéMON GYM\nLEADER: {RAM}"] = "{RAM}\nGIMNASIO POKéMON\nLIDER: {RAM}",
|
||||||
|
["Nope, there's\nonly trash here."] = "No, aquí solo hay\nbasura.",
|
||||||
|
["Darn! It needs a\nCARD KEY!"] = "¡Vaya! ¡Necesita\nuna LLAVE MAGNET.!",
|
||||||
|
["Bingo!"] = "¡Bingo!",
|
||||||
|
["\nThe CARD KEY\nopened the door!"] = "\n¡La LLAVE MAGNET.\nabrió la puerta!",
|
||||||
|
["Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!"] = "¡Hay un interruptor\nbajo la basura!\f¡Se abrió el 1er\ncierre eléctrico!",
|
||||||
|
["The 2nd electric\nlock opened!\fThe motorized door\nopened!"] = "¡Se abrió el 2o\ncierre eléctrico!\f¡La puerta se\nabrió!",
|
||||||
|
["Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!"] = "¡No! Aquí solo hay\nbasura.\f¡Los cierres se\nreiniciaron!",
|
||||||
|
["TELEPORTER is\ndisplayed on the\nPC monitor."] = "El TELETRANSPORTE\naparece en el\nmonitor del PC.",
|
||||||
|
["{PLAYER} initiated\nTELEPORTER's Cell\nSeparator!"] = "¡{PLAYER} activó el\nSeparador de Células\ndel TELETRANSPORTE!",
|
||||||
|
["BILL's favorite\nPOKéMON list!"] = "¡La lista de POKéMON\nfavoritos de BILL!",
|
||||||
|
["{PLAYER} got on\n{RAM:wNameBuffer}!"] = "¡{PLAYER} se subió\na {RAM:wNameBuffer}!",
|
||||||
|
["{RAM:wNameBuffer} hacked\naway with CUT!"] = "¡{RAM:wNameBuffer} cortó\ncon CORTE!",
|
||||||
|
["Gyaoo!"] = "¡Gyaoo!",
|
||||||
|
["Hi there!\nMay I help you?"] = "¡Hola!\n¿Puedo ayudarte?",
|
||||||
|
["SOMEONE'S PC"] = "EL PC DE ALGUIEN",
|
||||||
|
["PROF.OAK's PC"] = "EL PC DEL PROF.OAK",
|
||||||
|
["POKéDEX comp-\nletion is:\f{NUM:hDexRatingNumMonsSeen} POKéMON seen\n{NUM:hDexRatingNumMonsOwned} POKéMON owned\fPROF.OAK's\nRating:"] = "La POKéDEX está\nasí:\f{NUM:hDexRatingNumMonsSeen} POKéMON vistos\n{NUM:hDexRatingNumMonsOwned} POKéMON capturados\fNota del\nPROF.OAK:",
|
||||||
|
["We hope to see\nyou again!"] = "¡Esperamos verte\nde nuevo!",
|
||||||
|
["Welcome to our\nPOKéMON CENTER!"] = "¡Bienvenido a\nnuestro CENTRO\nPOKéMON!",
|
||||||
|
["Shall we heal your\nPOKéMON?"] = "¿Curamos a tus\nPOKéMON?",
|
||||||
|
["OK. We'll need\nyour POKéMON."] = "Bien. Necesitamos\ntus POKéMON.",
|
||||||
|
["Your POKéMON are\nfighting fit!"] = "¡Tus POKéMON están\nen plena forma!",
|
||||||
|
["Welcome to the\nCable Club!"] = "¡Bienvenido al Club\nde Cable!",
|
||||||
|
["We're making\npreparations.\vPlease wait."] = "Estamos preparando\ntodo.\vEspera un momento.",
|
||||||
|
["Please apply here.\fBefore opening\nthe link, we have\vto save the game."] = "Solicítalo aquí.\fAntes de abrir el\nlink hay que\vguardar la partida.",
|
||||||
|
["Please come\nagain!"] = "¡Vuelve pronto!",
|
||||||
|
["I like shorts!\nThey're comfy and\neasy to wear!"] = "¡Me gustan los\npantalones cortos!\n¡Son cómodos!",
|
||||||
|
["%s received\nthe %s!"] = "¡%s recibió\nel %s!",
|
||||||
|
["%s received\n%s!"] = "¡%s recibió\n%s!",
|
||||||
|
["REPEL's effect\nwore off."] = "El efecto del REPEL\nse ha pasado.",
|
||||||
|
["Go right ahead!"] = "¡Adelante!",
|
||||||
|
["You don't have the\nBOULDERBADGE yet!"] = "¡Aún no tienes la\nMEDALLA ROCA!",
|
||||||
|
["Oh! That is the\n{RAM}!"] = "¡Oh! ¡Eso es el\n{RAM}!",
|
||||||
|
["You don't have the\n{RAM} yet!"] = "¡Aún no tienes el\n{RAM}!",
|
||||||
|
["You need a\nBICYCLE for the\nCycling Road!"] = "¡Necesitas una\nBICICLETA para el\nCarril Bici!",
|
||||||
|
["The boulder fell\nthrough the hole!"] = "¡La roca cayó por\nel agujero!",
|
||||||
|
["PA: Ding-dong!\nTime's up!"] = "AV: ¡Ding-dong!\n¡Se acabó el tiempo!",
|
||||||
|
["PA: Your SAFARI\nGAME is over!"] = "AV: ¡Tu JUEGO\nSAFARI ha terminado!",
|
||||||
|
["PA: You're out of\nSAFARI BALLs!"] = "AV: ¡No te quedan\nSAFARI BALLs!",
|
||||||
|
["{PLAYER} got\n%s!"] = "¡{PLAYER} consiguió\n%s!",
|
||||||
|
["There's no more\nroom for POKéMON!\v%s was\vsent to POKéMON\vBOX %s on PC!"] = "¡No hay sitio para\nmás POKéMON!\v¡%s fue\venviado a la CAJA\vPOKéMON %s del PC!",
|
||||||
|
["contribution is not a table"] = "",
|
||||||
|
[" [%s %s.%s]"] = " [%s %s.%s]",
|
||||||
|
["Link battle needs\nthe same mods on\nboth games."] = "El combate link\nnecesita los mismos\nmods en los dos\njuegos.",
|
||||||
|
["Your %s can't\nbattle on the\nother game."] = "Tu %s no puede\nluchar en el otro\njuego.",
|
||||||
|
["Their %s isn't\nin this game.\n(%s)"] = "Su %s no está\nen este juego.\n(%s)",
|
||||||
|
["%s wants\nto battle!"] = "¡%s quiere\nluchar!",
|
||||||
|
["Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?"] = "¡Link desincroni-\nzado!\n%s difiere.\f¿Están los dos\njuegos con los\nmismos mods?",
|
||||||
|
["%s ran from\nthe battle!"] = "¡%s huyó del\ncombate!",
|
||||||
|
["Items can't be\nused in a link\nbattle!"] = "¡No se pueden usar\nobjetos en un\ncombate link!",
|
||||||
|
["%s is out of\nPOKéMON!\f%s wins!"] = "¡%s no tiene\nPOKéMON!\f¡%s gana!",
|
||||||
|
["%s left the\nbattle."] = "%s dejó el\ncombate.",
|
||||||
|
["%s ran out of\ntime!"] = "¡%s se quedó\nsin tiempo!",
|
||||||
|
["Time's up! You\nforfeit the match."] = "¡Se acabó el tiempo!\nPierdes el combate.",
|
||||||
|
["%s's %s can't\nbattle on this\ngame."] = "El %s de %s\nno puede luchar en\neste juego.",
|
||||||
|
["%s's %s can't\nbattle on this\ngame.\n(%s)"] = "El %s de %s\nno puede luchar en\neste juego.\n(%s)",
|
||||||
|
["%s vs %s!"] = "¡%s contra %s!",
|
||||||
|
["Link error:\n%s"] = "Error de link:\n%s",
|
||||||
|
["Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?"] = "El juego en línea\nva sin mods para\nlos dos jugadores.\f¿Desactivar %s\ny reiniciar?",
|
||||||
|
["The link was\nbroken."] = "Se ha perdido el\nlink.",
|
||||||
|
["Link battle\ncan't start."] = "El combate link no\npuede empezar.",
|
||||||
|
["The trade stopped:\n%s."] = "El intercambio se\ndetuvo:\n%s.",
|
||||||
|
["The trade was\ncancelled."] = "El intercambio se\nha cancelado.",
|
||||||
|
["Trade completed!\f%s received\n%s!"] = "¡Intercambio hecho!\f¡%s recibió\n%s!",
|
||||||
|
["LINK CABLE (LAN)"] = "CABLE LINK (LAN)",
|
||||||
|
["ONLINE MATCH"] = "PARTIDA EN LINEA",
|
||||||
|
["TOURNAMENT"] = "TORNEO",
|
||||||
|
["HOST A GAME"] = "CREAR PARTIDA",
|
||||||
|
["JOIN A GAME"] = "UNIRSE A PARTIDA",
|
||||||
|
["UDP port %s"] = "Puerto UDP %s",
|
||||||
|
["HOST ONLINE"] = "CREAR EN LINEA",
|
||||||
|
["JOIN ONLINE"] = "UNIRSE EN LINEA",
|
||||||
|
["Tell your friend"] = "Dile a tu amigo",
|
||||||
|
["the code:"] = "el código:",
|
||||||
|
["Waiting for join..."] = "Esperando...",
|
||||||
|
["A: connect B: back"] = "A: conectar B: atrás",
|
||||||
|
["Calling..."] = "Llamando...",
|
||||||
|
["Friend joins at:"] = "Tu amigo entra en:",
|
||||||
|
["Port: %s"] = "Puerto: %s",
|
||||||
|
["TRADE"] = "INTERCAMBIO",
|
||||||
|
["BATTLE"] = "COMBATE",
|
||||||
|
["LEVELS:"] = "NIVELES:",
|
||||||
|
["A: continue B: back"] = "A: seguir B: atrás",
|
||||||
|
["Checking the"] = "Comprobando el",
|
||||||
|
["other game..."] = "otro juego...",
|
||||||
|
["Waiting for the"] = "Esperando a que",
|
||||||
|
["host to choose..."] = "el anfitrión elija...",
|
||||||
|
["A: trade anyway"] = "A: intercambiar igual",
|
||||||
|
["YOURS"] = "TUYO",
|
||||||
|
["THEIRS"] = "SUYO",
|
||||||
|
["X: not on theirs"] = "X: no en el suyo",
|
||||||
|
["A: trade B: cancel"] = "A: cambiar B: cancelar",
|
||||||
|
["Exchanging data..."] = "Intercambiando...",
|
||||||
|
["can't reach relay %s:%d\n(%s)"] = "",
|
||||||
|
["That code wasn't\nfound."] = "Ese código no se\nha encontrado.",
|
||||||
|
["That game already\nhas two players."] = "Esa partida ya\ntiene dos jugadores.",
|
||||||
|
["That code has\nexpired."] = "Ese código ha\ncaducado.",
|
||||||
|
["Couldn't join:\n%s"] = "No se pudo unir:\n%s",
|
||||||
|
["no answer from\n%s"] = "",
|
||||||
|
["That tournament\nhas already begun."] = "Ese torneo ya ha\nempezado.",
|
||||||
|
["Can't host:\nneed %d Pokemon\nLv %s-%s."] = "No puedes crearlo:\nnecesitas %d Pokemon\nNv %s-%s.",
|
||||||
|
["Couldn't host\nthat tournament."] = "No se pudo crear\nese torneo.",
|
||||||
|
["Your party needs\n%d Pokemon, Lv\n%s-%s."] = "Tu equipo necesita\n%d Pokemon, Nv\n%s-%s.",
|
||||||
|
["Couldn't join\nthat tournament."] = "No se pudo unir a\nese torneo.",
|
||||||
|
["Link error:\nversion mismatch\nwith opponent."] = "Error de link:\nversión distinta a\nla del rival.",
|
||||||
|
["The tournament\nconnection was\nlost."] = "Se perdió la\nconexión del torneo.",
|
||||||
|
["Can't watch this\nmatch."] = "No se puede ver\neste combate.",
|
||||||
|
["HOST"] = "CREAR",
|
||||||
|
["JOIN"] = "UNIRSE",
|
||||||
|
["START: create"] = "START: crear",
|
||||||
|
["A: join B: back"] = "A: unirse B: atrás",
|
||||||
|
["B: cancel"] = "B: cancelar",
|
||||||
|
["TOURNAMENT %s"] = "TORNEO %s",
|
||||||
|
["ROUND %d"] = "RONDA %d",
|
||||||
|
["%s (bye)"] = "%s (pasa)",
|
||||||
|
["%s%s vs %s%s"] = "%s%s contra %s%s",
|
||||||
|
["(organizing --"] = "(organizando --",
|
||||||
|
["not playing)"] = "no juega)",
|
||||||
|
["Waiting for"] = "Esperando a que",
|
||||||
|
["players to join:"] = "entren jugadores:",
|
||||||
|
["A: START B: cancel"] = "A: START B: cancelar",
|
||||||
|
["%s is the"] = "¡%s es el",
|
||||||
|
["champion!"] = "campeón!",
|
||||||
|
["A: continue"] = "A: continuar",
|
||||||
|
["{PLAYER} played the\nPOKé FLUTE."] = "{PLAYER} tocó la\nFLAUTA POKé.",
|
||||||
|
["Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!"] = "Tocaste la FLAUTA\nPOKé.\f¡Qué melodía tan\npegadiza!",
|
||||||
|
["%s played the\nPOKé FLUTE."] = "%s tocó la\nFLAUTA POKé.",
|
||||||
|
["All sleeping\nPOKéMON woke up!"] = "¡Todos los POKéMON\ndormidos despertaron!",
|
||||||
|
["%s's\nhits will never\nmiss!"] = "¡Los golpes de %s\nnunca fallarán!",
|
||||||
|
["The wild POKéMON\nran away!"] = "¡El POKéMON salvaje\nhuyó!",
|
||||||
|
["%s's PP\nwas restored!"] = "¡Los PP de %s\nse recuperaron!",
|
||||||
|
["%s's\nstatus returned\nto normal!"] = "¡El estado de %s\nvolvió a la\nnormalidad!",
|
||||||
|
["%s\nis revitalized!"] = "¡%s se ha\nrevitalizado!",
|
||||||
|
["%s\nis refusing!"] = "¡%s se\nniega!",
|
||||||
|
["%s's %s\nrose!"] = "¡El %s de %s\nsubió!",
|
||||||
|
["%s's PP\nincreased!"] = "¡Los PP de %s\naumentaron!",
|
||||||
|
["%s can't\nlearn that move!"] = "¡%s no puede\naprender ese\nmovimiento!",
|
||||||
|
["It knows that\nmove already!"] = "¡Ya conoce ese\nmovimiento!",
|
||||||
|
["Coin count:\n%d"] = "Fichas:\n%d",
|
||||||
|
["NO MODS INSTALLED"] = "NO HAY MODS",
|
||||||
|
["SAVE CURRENT AS.."] = "GUARDAR ACTUAL..",
|
||||||
|
["OPTIONS.."] = "OPCIONES..",
|
||||||
|
["PERMISSIONS.."] = "PERMISOS..",
|
||||||
|
["VIEW ERROR.."] = "VER ERROR..",
|
||||||
|
["BACK"] = "ATRAS",
|
||||||
|
["APPLY & RESTART"] = "APLICAR Y REINICIAR",
|
||||||
|
["DISCARD CHANGES"] = "DESCARTAR CAMBIOS",
|
||||||
|
["DATA & API ONLY"] = "SOLO DATOS Y API",
|
||||||
|
["DISABLE BOTH?"] = "DESACTIVAR AMBOS?",
|
||||||
|
["PROFILE NAME?"] = "NOMBRE DEL PERFIL?",
|
||||||
|
["RENAME?"] = "RENOMBRAR?",
|
||||||
|
["RESET DEFAULTS"] = "VALORES POR DEFECTO",
|
||||||
|
["NO CHANGES"] = "SIN CAMBIOS",
|
||||||
|
["A:OK"] = "A:OK",
|
||||||
|
["B:DONE (NO RESTART)"] = "B:LISTO (SIN REINICIAR)",
|
||||||
|
["MOD MANAGER"] = "GESTOR DE MODS",
|
||||||
|
["Choose a mod .zip"] = "Elige un .zip de mod",
|
||||||
|
["Choose a .sav save file"] = "Elige un archivo .sav",
|
||||||
|
["An update is available"] = "Hay una actualización",
|
||||||
|
["Name save slot"] = "Nombra la ranura",
|
||||||
|
["Enter to save - Esc to cancel - empty clears"] = "Enter para guardar - Esc para cancelar - vacío la borra",
|
||||||
|
["Add a mod index"] = "Añadir un índice de mods",
|
||||||
|
["Paste the index URL, or its owner/repo."] = "Pega la URL del índice, o su owner/repo.",
|
||||||
|
["Enter to add - Esc to cancel"] = "Enter para añadir - Esc para cancelar",
|
||||||
|
["Import a ROM to play"] = "Importa una ROM para jugar",
|
||||||
|
["RED"] = "ROJO",
|
||||||
|
["BLUE"] = "AZUL",
|
||||||
|
["YELLOW"] = "AMARILLO",
|
||||||
|
["FIND MODS"] = "BUSCAR MODS",
|
||||||
|
["%d of 3 ready"] = "%d de 3 listos",
|
||||||
|
["Or drop the .gb/.gbc file here."] = "O arrastra aquí el archivo .gb/.gbc.",
|
||||||
|
["ROM imported"] = "ROM importada",
|
||||||
|
["That ROM could not be imported."] = "No se pudo importar esa ROM.",
|
||||||
|
["Open folder"] = "Abrir carpeta",
|
||||||
|
["%d badges - %s - %d caught"] = "%d medallas - %s - %d capturados",
|
||||||
|
["%d of %d enabled"] = "%d de %d activados",
|
||||||
|
["Or drop a mod .zip onto the window."] = "O arrastra un .zip de mod a la ventana.",
|
||||||
|
["No mods installed - drop a mod .zip here to add one."] = "No hay mods - arrastra aquí un .zip para añadir uno.",
|
||||||
|
["Refreshed - %d mods listed"] = "Actualizado - %d mods listados",
|
||||||
|
["Added %s"] = "Añadido %s",
|
||||||
|
["Index removed"] = "Índice eliminado",
|
||||||
|
["Downloading %s..."] = "Descargando %s...",
|
||||||
|
["Installed %s %s"] = "Instalado %s %s",
|
||||||
|
["%d mods listed"] = "%d mods listados",
|
||||||
|
["%d of %d mods"] = "%d de %d mods",
|
||||||
|
["Mods here are listed, not reviewed - read the source and trust the author."] = "Los mods aquí se listan, no se revisan - lee el código y confía en el autor.",
|
||||||
|
["No mod index added"] = "No hay índice de mods",
|
||||||
|
["Add an index to browse mods. An index is a published list; paste its URL or its owner/repo."] = "Añade un índice para explorar mods. Un índice es una lista publicada; pega su URL o su owner/repo.",
|
||||||
|
["Search mods"] = "Buscar mods",
|
||||||
|
["This index lists no mods yet."] = "Este índice aún no lista mods.",
|
||||||
|
["No mods match that search."] = "Ningún mod coincide con esa búsqueda.",
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Trainer class names
|
||||||
|
--
|
||||||
|
-- Trainer class names for Espanol.
|
||||||
|
|
||||||
|
return {
|
||||||
|
["OPP_FIX_YOUNGSTER"] = "",
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
-- spanish_ui: a translation of the game into Espanol.
|
||||||
|
--
|
||||||
|
-- Nothing here is translated yet. Every table under lang/ starts with
|
||||||
|
-- empty strings; fill one in and it takes effect on the next boot, and
|
||||||
|
-- anything still empty keeps rendering in English. That means a
|
||||||
|
-- half-finished translation is always playable, so you can ship early and
|
||||||
|
-- fill the long tail in later.
|
||||||
|
--
|
||||||
|
-- Read TRANSLATING.md before the first edit; the font is the part people
|
||||||
|
-- get wrong.
|
||||||
|
return function(mod)
|
||||||
|
-- mod:read is the supported way into your own directory; the catalogs are
|
||||||
|
-- plain Lua tables, so read and run them rather than require()ing them.
|
||||||
|
local function catalog(name)
|
||||||
|
local rel = "lang/" .. name .. ".lua"
|
||||||
|
local body = mod:read(rel)
|
||||||
|
if not body then return {} end
|
||||||
|
local chunk, err = loadstring(body, rel)
|
||||||
|
if not chunk then
|
||||||
|
mod.log:warn("%s has a syntax error: %s", rel, tostring(err))
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
local ok, table_ = pcall(chunk)
|
||||||
|
if not ok or type(table_) ~= "table" then
|
||||||
|
mod.log:warn("%s did not return a table: %s", rel, tostring(table_))
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
return table_
|
||||||
|
end
|
||||||
|
|
||||||
|
-- An empty value means "not translated yet", never "translate to blank".
|
||||||
|
local function each(name, apply)
|
||||||
|
local n = 0
|
||||||
|
for key, value in pairs(catalog(name)) do
|
||||||
|
if type(value) == "string" and value ~= "" then
|
||||||
|
apply(key, value)
|
||||||
|
n = n + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- glyphs -------------------------------------------------------
|
||||||
|
-- Register the sheet BEFORE anything asks for a glyph on it. base is
|
||||||
|
-- the first code the page owns; 0x100 and up is free space above the
|
||||||
|
-- vanilla pages, so a new alphabet never collides with them.
|
||||||
|
for id, page in pairs(catalog("font")) do
|
||||||
|
mod.content.font:register(id, page)
|
||||||
|
end
|
||||||
|
-- charmap: which byte sequence draws which code
|
||||||
|
for seq, code in pairs(catalog("charmap")) do
|
||||||
|
mod.content.font:register("charmap:" .. seq, { seq = seq, code = code })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- text ---------------------------------------------------------
|
||||||
|
local counts = {}
|
||||||
|
counts.dialogue = each("dialogue", function(id, value)
|
||||||
|
mod.content.text:override(id, value)
|
||||||
|
end)
|
||||||
|
counts.strings = each("strings", function(source, value)
|
||||||
|
mod.content.strings:override(source, value)
|
||||||
|
end)
|
||||||
|
counts.species = each("species_names", function(id, value)
|
||||||
|
mod.content.pokemon:patch(id, { name = value })
|
||||||
|
end)
|
||||||
|
counts.moves = each("move_names", function(id, value)
|
||||||
|
mod.content.moves:patch(id, { name = value })
|
||||||
|
end)
|
||||||
|
counts.items = each("item_names", function(id, value)
|
||||||
|
mod.content.items:patch(id, { name = value })
|
||||||
|
end)
|
||||||
|
counts.trainers = each("trainer_names", function(id, value)
|
||||||
|
mod.content.trainers:patch(id, { name = value })
|
||||||
|
end)
|
||||||
|
counts.statuses = each("status_labels", function(id, value)
|
||||||
|
mod.content.statuses:patch(id, { label = value })
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- ---- name entry ---------------------------------------------------
|
||||||
|
-- The naming screen's letter grid. Leave lang/naming.lua returning nil
|
||||||
|
-- to keep the English alphabet.
|
||||||
|
local grid = catalog("naming")
|
||||||
|
if grid.upper then
|
||||||
|
-- Only offer the accented cells when the running cartridge can actually
|
||||||
|
-- draw them. A Spanish ROM has Ñ and the accented vowels in its font
|
||||||
|
-- and the manifest maps them; an English one does not, and an
|
||||||
|
-- unmappable cell renders blank -- a naming screen with six empty keys
|
||||||
|
-- is worse than an English one. So check the charmap and fall back.
|
||||||
|
local function drawable(cells, ctx)
|
||||||
|
local font = ((ctx.game or {}).data or {}).font
|
||||||
|
local charmap = font and font.charmap
|
||||||
|
if not charmap then return false end
|
||||||
|
local have = {}
|
||||||
|
for _, entry in ipairs(charmap) do have[entry.seq] = true end
|
||||||
|
for _, row in ipairs(cells) do
|
||||||
|
for _, cell in ipairs(row) do
|
||||||
|
-- Only the non-ASCII cells are at risk; A-Z and punctuation are
|
||||||
|
-- on every page.
|
||||||
|
if cell:byte(1) and cell:byte(1) > 127 and not have[cell] then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local warned = false
|
||||||
|
mod.hooks:on("ui.naming.grid", function(base, ctx)
|
||||||
|
local want = ctx.lower and grid.lower or grid.upper
|
||||||
|
if not want then return base end
|
||||||
|
if not drawable(want, ctx) then
|
||||||
|
if not warned then
|
||||||
|
warned = true
|
||||||
|
mod.log:info("naming grid: this ROM has no accented glyphs, "
|
||||||
|
.. "keeping the English alphabet")
|
||||||
|
end
|
||||||
|
return base
|
||||||
|
end
|
||||||
|
return want
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
mod.events:on("game.ready", function()
|
||||||
|
local total = 0
|
||||||
|
for _, n in pairs(counts) do total = total + n end
|
||||||
|
mod.log:info("Espanol: %d strings translated", total)
|
||||||
|
end)
|
||||||
|
end
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"id": "spanish_ui",
|
||||||
|
"name": "Espanol (interfaz)",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"api": 2,
|
||||||
|
"entry": "main.lua",
|
||||||
|
"profile": "content",
|
||||||
|
"game_version": ">=0.0.0-dev <1.0.0",
|
||||||
|
"category": "LANGUAGE",
|
||||||
|
"priority": 100,
|
||||||
|
"dependencies": [],
|
||||||
|
"optional_dependencies": [],
|
||||||
|
"conflicts": [],
|
||||||
|
"incompatible": [],
|
||||||
|
"experimental": false,
|
||||||
|
"description": "Spanish for the app's own settings and menus. The game's text comes from your ROM and is untouched, so an English cartridge stays an English adventure with Spanish menus."
|
||||||
|
}
|
||||||
+37
-6
@@ -357,13 +357,44 @@ build_linux() {
|
|||||||
|
|
||||||
cp "$LOVE_FILE" "$appdir/game.love"
|
cp "$LOVE_FILE" "$appdir/game.love"
|
||||||
|
|
||||||
# The .desktop's Icon=love resolves against the AppDir root by basename,
|
# Replace LÖVE's own desktop entry rather than keeping it: it says
|
||||||
# so drop the stock love.svg and provide our PNG under the same name;
|
# Name=LÖVE / Icon=love, which is what appimaged, app menus and file
|
||||||
# .DirIcon is what appimaged/thumbnailers show for the file itself.
|
# managers displayed this image as. Same file as the arm64 build writes,
|
||||||
|
# so both architectures integrate under the game's name.
|
||||||
|
local stock_desktop
|
||||||
|
stock_desktop="$(find "$appdir" -maxdepth 1 -name '*.desktop' | wc -l | tr -d ' ')"
|
||||||
|
[ "$stock_desktop" = 1 ] \
|
||||||
|
|| fail "expected exactly one .desktop at the AppDir root, found $stock_desktop"
|
||||||
|
rm -f "$appdir"/*.desktop
|
||||||
|
|
||||||
|
# share/ carries a second, NoDisplay copy of the same entry plus the .love
|
||||||
|
# file-type icons and mime rule, all left over from LÖVE's `make install`
|
||||||
|
# (its Exec even points at the CI runner that built it). Nothing at runtime
|
||||||
|
# reads them -- only share/lua and share/luajit-* are on LUA_PATH -- but
|
||||||
|
# AppRun puts $APPDIR/share on XDG_DATA_DIRS, so anyone extracting the image
|
||||||
|
# gets a "LÖVE" entry back. The arm64 AppDir never had them.
|
||||||
|
rm -rf "$appdir/share/applications" "$appdir/share/pixmaps" \
|
||||||
|
"$appdir/share/mime" "$appdir/share/icons"
|
||||||
|
|
||||||
|
cat > "$appdir/$APP_NAME.desktop" <<EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=gen1recomp
|
||||||
|
Comment=Pokémon Gen 1 recompilation
|
||||||
|
Exec=$APP_NAME
|
||||||
|
Icon=$APP_NAME
|
||||||
|
StartupWMClass=love
|
||||||
|
Categories=Game;
|
||||||
|
Terminal=false
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Icon= resolves against the AppDir root by basename, so the PNG has to be
|
||||||
|
# named after the desktop entry; .DirIcon is what appimaged and
|
||||||
|
# file-manager thumbnailers show for the file itself.
|
||||||
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
|
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
|
||||||
rm -f "$appdir/love.svg" "$appdir/.DirIcon"
|
rm -f "$appdir/love.svg" "$appdir/love.png" "$appdir/.DirIcon"
|
||||||
sips -z 512 512 "$ICON_SRC" --out "$appdir/love.png" >/dev/null
|
sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null
|
||||||
cp "$appdir/love.png" "$appdir/.DirIcon"
|
cp "$appdir/$APP_NAME.png" "$appdir/.DirIcon"
|
||||||
|
|
||||||
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
|
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
|
||||||
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|
||||||
|
|||||||
@@ -217,6 +217,14 @@ apply_ios_branding() {
|
|||||||
cp "$OVERLAY_PLIST" "$dest"
|
cp "$OVERLAY_PLIST" "$dest"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
verify_documents_overlay() {
|
||||||
|
local sharing in_place
|
||||||
|
sharing="$(/usr/libexec/PlistBuddy -c 'Print :UIFileSharingEnabled' "$OVERLAY_PLIST" 2>/dev/null || true)"
|
||||||
|
in_place="$(/usr/libexec/PlistBuddy -c 'Print :LSSupportsOpeningDocumentsInPlace' "$OVERLAY_PLIST" 2>/dev/null || true)"
|
||||||
|
[ "$sharing" = "true" ] && [ "$in_place" = "true" ] \
|
||||||
|
|| fail "iOS plist overlay must enable UIFileSharingEnabled and LSSupportsOpeningDocumentsInPlace"
|
||||||
|
}
|
||||||
|
|
||||||
apply_ios_icon() {
|
apply_ios_icon() {
|
||||||
local source="$ROOT/assets/logo/gen1recomp_cover.png"
|
local source="$ROOT/assets/logo/gen1recomp_cover.png"
|
||||||
local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset"
|
local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset"
|
||||||
@@ -578,6 +586,25 @@ verify_native_bridge() {
|
|||||||
say "native bridge present (pickFile, createFile)"
|
say "native bridge present (pickFile, createFile)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
verify_documents_configuration() {
|
||||||
|
local app="$1"
|
||||||
|
local plist="$app/Info.plist"
|
||||||
|
local sharing in_place
|
||||||
|
[ -f "$plist" ] || fail "built iOS app is missing Info.plist: $plist"
|
||||||
|
sharing="$(/usr/libexec/PlistBuddy -c 'Print :UIFileSharingEnabled' "$plist" 2>/dev/null || true)"
|
||||||
|
in_place="$(/usr/libexec/PlistBuddy -c 'Print :LSSupportsOpeningDocumentsInPlace' "$plist" 2>/dev/null || true)"
|
||||||
|
[ "$sharing" = "true" ] && [ "$in_place" = "true" ] \
|
||||||
|
|| fail "built iOS app does not expose its Documents folder in $(basename "$app")"
|
||||||
|
say "public Documents exposure present (file sharing + in-place access)"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_game_payload() {
|
||||||
|
local app="$1"
|
||||||
|
[ -s "$app/game.love" ] \
|
||||||
|
|| fail "built iOS app is missing game.love: $app"
|
||||||
|
say "game.love present ($(du -h "$app/game.love" | cut -f1))"
|
||||||
|
}
|
||||||
|
|
||||||
run_xcodebuild() {
|
run_xcodebuild() {
|
||||||
local config sdk destination
|
local config sdk destination
|
||||||
if $RELEASE; then
|
if $RELEASE; then
|
||||||
@@ -618,6 +645,8 @@ run_xcodebuild() {
|
|||||||
PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID"
|
PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID"
|
||||||
MARKETING_VERSION="$marketing_version"
|
MARKETING_VERSION="$marketing_version"
|
||||||
CURRENT_PROJECT_VERSION="$project_version"
|
CURRENT_PROJECT_VERSION="$project_version"
|
||||||
|
INFOPLIST_KEY_UIFileSharingEnabled=YES
|
||||||
|
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace=YES
|
||||||
ONLY_ACTIVE_ARCH=NO
|
ONLY_ACTIVE_ARCH=NO
|
||||||
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
|
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
|
||||||
)
|
)
|
||||||
@@ -693,6 +722,8 @@ run_xcodebuild() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
verify_documents_configuration "$app"
|
||||||
|
|
||||||
# Fuse even if the pbxproj wire-up failed, LÖVE runs any bundled *.love.
|
# Fuse even if the pbxproj wire-up failed, LÖVE runs any bundled *.love.
|
||||||
# Byte-compare, never just existence: xcodebuild's incremental Copy Bundle
|
# Byte-compare, never just existence: xcodebuild's incremental Copy Bundle
|
||||||
# Resources can leave a previous build's game.love in a surviving .app, and
|
# Resources can leave a previous build's game.love in a surviving .app, and
|
||||||
@@ -703,6 +734,7 @@ run_xcodebuild() {
|
|||||||
cp "$LOVE_FILE" "$app/game.love"
|
cp "$LOVE_FILE" "$app/game.love"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
verify_game_payload "$app"
|
||||||
verify_native_bridge "$app"
|
verify_native_bridge "$app"
|
||||||
|
|
||||||
local dist_dir="$DIST/${config}-${sdk}"
|
local dist_dir="$DIST/${config}-${sdk}"
|
||||||
@@ -774,6 +806,7 @@ install_to_device() {
|
|||||||
|
|
||||||
# --------------------------------------------------------------- main
|
# --------------------------------------------------------------- main
|
||||||
apply_ios_branding
|
apply_ios_branding
|
||||||
|
verify_documents_overlay
|
||||||
apply_ios_icon
|
apply_ios_icon
|
||||||
say "applying iOS native bridge patches (picker/Files support)"
|
say "applying iOS native bridge patches (picker/Files support)"
|
||||||
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
|
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ Name=gen1recomp
|
|||||||
Comment=Pokémon Gen 1 recompilation
|
Comment=Pokémon Gen 1 recompilation
|
||||||
Exec=$APP_NAME
|
Exec=$APP_NAME
|
||||||
Icon=$APP_NAME
|
Icon=$APP_NAME
|
||||||
|
StartupWMClass=love
|
||||||
Categories=Game;
|
Categories=Game;
|
||||||
Terminal=false
|
Terminal=false
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -142,7 +142,10 @@ download_pinned() {
|
|||||||
rm -f "$dest"
|
rm -f "$dest"
|
||||||
fi
|
fi
|
||||||
say "downloading $(basename "$dest")"
|
say "downloading $(basename "$dest")"
|
||||||
curl -fL --progress-bar "$url" -o "$dest.tmp" || fail "download failed: $url"
|
# --retry-all-errors because plain --retry skips TLS handshake failures,
|
||||||
|
# which is how xiph.org drops these tarballs; the pin below still gates it.
|
||||||
|
curl -fL --retry 5 --retry-delay 2 --retry-all-errors --progress-bar \
|
||||||
|
"$url" -o "$dest.tmp" || fail "download failed: $url"
|
||||||
got="$(sha256_file "$dest.tmp")"
|
got="$(sha256_file "$dest.tmp")"
|
||||||
[ "$got" = "$want" ] || fail "$(printf '%s\n expected %s\n got %s' \
|
[ "$got" = "$want" ] || fail "$(printf '%s\n expected %s\n got %s' \
|
||||||
"checksum mismatch for $(basename "$dest")" "$want" "$got")"
|
"checksum mismatch for $(basename "$dest")" "$want" "$got")"
|
||||||
|
|||||||
+7
-4
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Run the LÖVE2D Pokémon Red port (macOS-friendly).
|
# Run the LÖVE2D Pokémon Red port (macOS-friendly).
|
||||||
#
|
#
|
||||||
# Assumes scripts/setup.sh has been run once (generated data present and
|
# Assumes scripts/setup.sh has been run once for at least one game (generated
|
||||||
# LÖVE installed). Extra arguments are passed through to LÖVE.
|
# data present and LÖVE installed). Extra arguments are passed through to LÖVE.
|
||||||
#
|
#
|
||||||
# Link play is peer-to-peer (lua-enet, bundled with LÖVE): one player
|
# Link play is peer-to-peer (lua-enet, bundled with LÖVE): one player
|
||||||
# picks HOST A GAME in START > LINK and reads out the address shown;
|
# picks HOST A GAME in START > LINK and reads out the address shown;
|
||||||
@@ -15,8 +15,11 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|||||||
|
|
||||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
[ -f "$ROOT/data/generated/maps.lua" ] \
|
if [ ! -f "$ROOT/data/generated/maps.lua" ] \
|
||||||
|| fail "generated data missing, run scripts/setup.sh first"
|
&& [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \
|
||||||
|
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ]; then
|
||||||
|
fail "generated data missing, run scripts/setup.sh first"
|
||||||
|
fi
|
||||||
|
|
||||||
find_love() {
|
find_love() {
|
||||||
command -v love >/dev/null 2>&1 && { echo "love"; return; }
|
command -v love >/dev/null 2>&1 && { echo "love"; return; }
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ run_tier() {
|
|||||||
|
|
||||||
# ------- ROM-free tiers: these are what CI runs
|
# ------- ROM-free tiers: these are what CI runs
|
||||||
|
|
||||||
|
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
|
||||||
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
|
run_tier "T0 switch 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
|
||||||
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
|
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
|
||||||
|
|||||||
+74
-13
@@ -37,6 +37,12 @@ local BattleState = {}
|
|||||||
BattleState.__index = BattleState
|
BattleState.__index = BattleState
|
||||||
BattleState.isOpaque = true
|
BattleState.isOpaque = true
|
||||||
|
|
||||||
|
-- Category identity for per-category GAME SPEED (RFC 0007), the same
|
||||||
|
-- style OverworldController.isOverworld already uses. Every battle --
|
||||||
|
-- wild, trainer, link, safari, the old-man demo -- is this metatable, so
|
||||||
|
-- Game.speedCategoryInStack needs no special-casing beyond this one flag.
|
||||||
|
BattleState.isBattle = true
|
||||||
|
|
||||||
function BattleState:romText(label, fallback, ...)
|
function BattleState:romText(label, fallback, ...)
|
||||||
return romText(self.data, label, fallback, ...)
|
return romText(self.data, label, fallback, ...)
|
||||||
end
|
end
|
||||||
@@ -98,6 +104,15 @@ function BattleState:bgMode()
|
|||||||
return "white"
|
return "white"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Resume a semantic checkpoint directly at the command menu. Unlike enter(),
|
||||||
|
-- this deliberately does not replay the battle transition, intro queues,
|
||||||
|
-- cries, happiness changes, or battle-start events.
|
||||||
|
function BattleState:resumeCheckpoint()
|
||||||
|
self.isOpaque = self:bgMode() ~= "world"
|
||||||
|
require("src.core.Music").playBattle(self.data,
|
||||||
|
self.musicKind or self:computeMusicKind())
|
||||||
|
end
|
||||||
|
|
||||||
-- How far to dim the overworld behind a "world" background, 0..1. Enough
|
-- How far to dim the overworld behind a "world" background, 0..1. Enough
|
||||||
-- that the battle reads as the foreground rather than competing with a fully
|
-- that the battle reads as the foreground rather than competing with a fully
|
||||||
-- lit map behind it.
|
-- lit map behind it.
|
||||||
@@ -118,6 +133,25 @@ function BattleState:sgbPalettes()
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function BattleState:bottomUIVisible()
|
||||||
|
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
|
||||||
|
return Runtime.call("battle.bottom_ui_visible", function() return true end,
|
||||||
|
self) ~= false
|
||||||
|
end
|
||||||
|
|
||||||
|
function BattleState:statusHUDVisible()
|
||||||
|
if not Runtime.wantsHook("battle.status_hud_visible") then return true end
|
||||||
|
return Runtime.call("battle.status_hud_visible", function() return true end,
|
||||||
|
self) ~= false
|
||||||
|
end
|
||||||
|
|
||||||
|
function BattleState:moveGridNavigation()
|
||||||
|
if self:wideLayout() then return true end
|
||||||
|
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
|
||||||
|
return Runtime.call("battle.move_grid_navigation", function() return false end,
|
||||||
|
self) == true
|
||||||
|
end
|
||||||
|
|
||||||
local Rulesets = {
|
local Rulesets = {
|
||||||
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
|
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
|
||||||
modern_clean = require("src.battle.rulesets.modern_clean"),
|
modern_clean = require("src.battle.rulesets.modern_clean"),
|
||||||
@@ -424,7 +458,7 @@ function StatBox:draw()
|
|||||||
{ Strings("SPEED"), s.speed },
|
{ Strings("SPEED"), s.speed },
|
||||||
{ Strings("SPECIAL"), s.special } }
|
{ Strings("SPECIAL"), s.special } }
|
||||||
for i, r in ipairs(rows) do
|
for i, r in ipairs(rows) do
|
||||||
Font.draw(r[1], 88, 24 + (i - 1) * 16)
|
Font.draw(Strings(r[1]), 88, 24 + (i - 1) * 16)
|
||||||
Font.draw(("%3d"):format(r[2]), 128, 32 + (i - 1) * 16)
|
Font.draw(("%3d"):format(r[2]), 128, 32 + (i - 1) * 16)
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
@@ -1411,6 +1445,26 @@ function BattleState:computeMusicKind()
|
|||||||
return "wild"
|
return "wild"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- a mod-set per-trainer battle theme (trainers.battleTheme, an audio.songs
|
||||||
|
-- id); nil for vanilla trainers, so the kind default is untouched (#782)
|
||||||
|
function BattleState:battleTheme()
|
||||||
|
local trainer = self.trainer
|
||||||
|
if trainer and trainer.battleTheme then return trainer.battleTheme end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the battle-theme cue for this battle: the mod-set trainer battleTheme
|
||||||
|
-- when the class has one, else the kind default. The single choke point
|
||||||
|
-- both the transition-wipe start (OverworldController:pushBattle) and
|
||||||
|
-- enter() route through, so a per-trainer override can't drift between
|
||||||
|
-- them. self.musicKind is set by enter(); pushBattle runs before that,
|
||||||
|
-- so compute it here when absent.
|
||||||
|
function BattleState:playBattleTheme()
|
||||||
|
require("src.core.Music").playBattle(self.data,
|
||||||
|
self.musicKind or self:computeMusicKind(),
|
||||||
|
self.trainer and self.trainer.id, self:battleTheme())
|
||||||
|
end
|
||||||
|
|
||||||
-- side tables mirror the singles battlers; called before every
|
-- side tables mirror the singles battlers; called before every
|
||||||
-- battler-switch notification so sides[i].battlers[1] stays honest
|
-- battler-switch notification so sides[i].battlers[1] stays honest
|
||||||
function BattleState:syncSides()
|
function BattleState:syncSides()
|
||||||
@@ -1462,7 +1516,6 @@ function BattleState:enter()
|
|||||||
.. Strings("%s blacked\nout!", name), blackedOut))
|
.. Strings("%s blacked\nout!", name), blackedOut))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local Music = require("src.core.Music")
|
|
||||||
self.musicKind = self:computeMusicKind()
|
self.musicKind = self:computeMusicKind()
|
||||||
if self.isGymLeader then
|
if self.isGymLeader then
|
||||||
require("src.world.PikachuFollower")
|
require("src.world.PikachuFollower")
|
||||||
@@ -1472,7 +1525,7 @@ function BattleState:enter()
|
|||||||
-- (audio/play_battle_music.asm runs before the transition, and
|
-- (audio/play_battle_music.asm runs before the transition, and
|
||||||
-- Music.play no-ops on the same song); this covers battles pushed
|
-- Music.play no-ops on the same song); this covers battles pushed
|
||||||
-- without a transition (link battles, scripted pushes)
|
-- without a transition (link battles, scripted pushes)
|
||||||
Music.playBattle(self.data, self.musicKind)
|
self:playBattleTheme()
|
||||||
-- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both
|
-- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both
|
||||||
-- sides slide in; the trainer pics stay up until the send-outs
|
-- sides slide in; the trainer pics stay up until the send-outs
|
||||||
-- BATTLE BG "world" drops this battle's opacity so StateStack keeps drawing
|
-- BATTLE BG "world" drops this battle's opacity so StateStack keeps drawing
|
||||||
@@ -1865,6 +1918,7 @@ function BattleState:update(dt)
|
|||||||
end
|
end
|
||||||
self.menuIndex = row * 2 + col + 1
|
self.menuIndex = row * 2 + col + 1
|
||||||
if input:wasPressed("a") then
|
if input:wasPressed("a") then
|
||||||
|
require("src.core.Sound").play(self.data, "Press_AB")
|
||||||
self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
|
self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
@@ -1901,6 +1955,7 @@ function BattleState:update(dt)
|
|||||||
end
|
end
|
||||||
self.menuIndex = row * 2 + col + 1
|
self.menuIndex = row * 2 + col + 1
|
||||||
if input:wasPressed("a") then
|
if input:wasPressed("a") then
|
||||||
|
require("src.core.Sound").play(self.data, "Press_AB")
|
||||||
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex]
|
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex]
|
||||||
if choice == "fight" and self.ghost then
|
if choice == "fight" and self.ghost then
|
||||||
self:say(Strings("%s is too\nscared to move!", self.player.name))
|
self:say(Strings("%s is too\nscared to move!", self.player.name))
|
||||||
@@ -1946,7 +2001,7 @@ function BattleState:update(dt)
|
|||||||
-- The widescreen layout lays the four slots out as a 2x2 grid, so all
|
-- The widescreen layout lays the four slots out as a 2x2 grid, so all
|
||||||
-- four directions navigate it; nil means no direction was pressed and
|
-- four directions navigate it; nil means no direction was pressed and
|
||||||
-- A / B / SELECT below behave the same in either layout.
|
-- A / B / SELECT below behave the same in either layout.
|
||||||
local grid = self:wideLayout()
|
local grid = self:moveGridNavigation()
|
||||||
and WideBattle.navigate(self.moveIndex, #moves, input)
|
and WideBattle.navigate(self.moveIndex, #moves, input)
|
||||||
if grid then
|
if grid then
|
||||||
self.moveIndex = grid
|
self.moveIndex = grid
|
||||||
@@ -1962,9 +2017,11 @@ function BattleState:update(dt)
|
|||||||
self.moveSwapIndex = self.moveIndex
|
self.moveSwapIndex = self.moveIndex
|
||||||
end
|
end
|
||||||
elseif input:wasPressed("b") then
|
elseif input:wasPressed("b") then
|
||||||
|
require("src.core.Sound").play(self.data, "Press_AB")
|
||||||
self.moveSwapIndex = nil
|
self.moveSwapIndex = nil
|
||||||
self.phase = "menu"
|
self.phase = "menu"
|
||||||
elseif input:wasPressed("a") then
|
elseif input:wasPressed("a") then
|
||||||
|
require("src.core.Sound").play(self.data, "Press_AB")
|
||||||
if self.moveSwapIndex then
|
if self.moveSwapIndex then
|
||||||
self:swapMoves(self.moveSwapIndex, self.moveIndex)
|
self:swapMoves(self.moveSwapIndex, self.moveIndex)
|
||||||
self.moveSwapIndex = nil
|
self.moveSwapIndex = nil
|
||||||
@@ -1994,7 +2051,7 @@ function BattleState:update(dt)
|
|||||||
local moves = self.mimicMoves
|
local moves = self.mimicMoves
|
||||||
-- the copy menu shares the widescreen move grid, so it navigates the
|
-- the copy menu shares the widescreen move grid, so it navigates the
|
||||||
-- same way there (the classic layout keeps the vertical list)
|
-- same way there (the classic layout keeps the vertical list)
|
||||||
local grid = self:wideLayout()
|
local grid = self:moveGridNavigation()
|
||||||
and WideBattle.navigate(self.mimicIndex, #moves, input)
|
and WideBattle.navigate(self.mimicIndex, #moves, input)
|
||||||
if grid then
|
if grid then
|
||||||
self.mimicIndex = grid
|
self.mimicIndex = grid
|
||||||
@@ -2003,6 +2060,7 @@ function BattleState:update(dt)
|
|||||||
elseif input:wasPressed("down") then
|
elseif input:wasPressed("down") then
|
||||||
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
|
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
|
||||||
elseif input:wasPressed("a") then
|
elseif input:wasPressed("a") then
|
||||||
|
require("src.core.Sound").play(self.data, "Press_AB")
|
||||||
local pick = moves[self.mimicIndex]
|
local pick = moves[self.mimicIndex]
|
||||||
local ctx = self.mimicCtx
|
local ctx = self.mimicCtx
|
||||||
self.mimicMoves, self.mimicCtx = nil, nil
|
self.mimicMoves, self.mimicCtx = nil, nil
|
||||||
@@ -5319,7 +5377,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
|||||||
-- .mimicmenu) wipes rows 7+. The port draws pics above the menu
|
-- .mimicmenu) wipes rows 7+. The port draws pics above the menu
|
||||||
-- layer in the colorized pipeline, so clip them to the visible rows.
|
-- layer in the colorized pipeline, so clip them to the visible rows.
|
||||||
local g = love.graphics
|
local g = love.graphics
|
||||||
local clipY = not skipMenuClip
|
local clipY = not skipMenuClip and self:bottomUIVisible()
|
||||||
and (self.phase == "mimicSelect" and 56
|
and (self.phase == "mimicSelect" and 56
|
||||||
or self.phase == "moveSelect" and 64)
|
or self.phase == "moveSelect" and 64)
|
||||||
or nil
|
or nil
|
||||||
@@ -5431,6 +5489,7 @@ function BattleState:drawHUDs(slide)
|
|||||||
-- per-pixel tint (grayFill) -- otherwise GREENBAR's red-channel-0 fill
|
-- per-pixel tint (grayFill) -- otherwise GREENBAR's red-channel-0 fill
|
||||||
-- double-applies and the zone shade shader maps the whole bar to black (#229).
|
-- double-applies and the zone shade shader maps the whole bar to black (#229).
|
||||||
local grayFill = self:colorMode()
|
local grayFill = self:colorMode()
|
||||||
|
local showStatus = self:statusHUDVisible()
|
||||||
local barData = self.data
|
local barData = self.data
|
||||||
local fx = self.fx
|
local fx = self.fx
|
||||||
local hudShake = (fx and fx.hudShakeX) or 0
|
local hudShake = (fx and fx.hudShakeX) or 0
|
||||||
@@ -5440,7 +5499,8 @@ function BattleState:drawHUDs(slide)
|
|||||||
-- DrawEnemyHUDAndHPBar is called from _InitBattleCommon (core.asm:6763)
|
-- DrawEnemyHUDAndHPBar is called from _InitBattleCommon (core.asm:6763)
|
||||||
-- AFTER PrintBeginningBattleText returns, so "Wild X appeared!" shows the
|
-- AFTER PrintBeginningBattleText returns, so "Wild X appeared!" shows the
|
||||||
-- player's ball row with no enemy HUD beside it (#317)
|
-- player's ball row with no enemy HUD beside it (#317)
|
||||||
if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut
|
if showStatus and self.enemy and not self.showEnemyTrainer
|
||||||
|
and not self.enemySendingOut
|
||||||
and not self:growInScale(self.enemy) and slide == 0
|
and not self:growInScale(self.enemy) and slide == 0
|
||||||
and not self.introBalls and not self.enemy.fainted then
|
and not self.introBalls and not self.enemy.fainted then
|
||||||
-- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1),
|
-- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1),
|
||||||
@@ -5527,7 +5587,7 @@ function BattleState:drawHUDs(slide)
|
|||||||
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
|
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
|
||||||
end
|
end
|
||||||
local hidePlayer = self.safari or self.demo
|
local hidePlayer = self.safari or self.demo
|
||||||
if self.player and not hidePlayer and not self.showPlayerBack
|
if showStatus and self.player and not hidePlayer and not self.showPlayerBack
|
||||||
and slide == 0 then
|
and slide == 0 then
|
||||||
-- player HUD (DrawPlayerHUDAndHPBar): name (10,7), <LV>+level
|
-- player HUD (DrawPlayerHUDAndHPBar): name (10,7), <LV>+level
|
||||||
-- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with
|
-- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with
|
||||||
@@ -5552,6 +5612,7 @@ function BattleState:drawHUDs(slide)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:drawTextArea()
|
function BattleState:drawTextArea()
|
||||||
|
if not self:bottomUIVisible() then return end
|
||||||
Font.drawBox(0, 12, 20, 6)
|
Font.drawBox(0, 12, 20, 6)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
if self.phase == "messages"
|
if self.phase == "messages"
|
||||||
@@ -5587,9 +5648,9 @@ function BattleState:drawTextArea()
|
|||||||
-- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16)
|
-- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16)
|
||||||
Font.drawBox(8, 12, 12, 6)
|
Font.drawBox(8, 12, 12, 6)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(Strings("FIGHT"), 80, 112)
|
Font.draw(Strings("FIGHT", "battle"), 80, 112)
|
||||||
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
|
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
|
||||||
Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("RUN"), 128, 128)
|
Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128)
|
||||||
Font.drawCode(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128)
|
Font.drawCode(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128)
|
||||||
elseif self.phase == "menu" then
|
elseif self.phase == "menu" then
|
||||||
local col = (self.menuIndex - 1) % 2
|
local col = (self.menuIndex - 1) % 2
|
||||||
@@ -5599,7 +5660,7 @@ function BattleState:drawTextArea()
|
|||||||
-- THROW ROCK RUN" from (2,14)
|
-- THROW ROCK RUN" from (2,14)
|
||||||
Font.drawBox(0, 12, 20, 6)
|
Font.drawBox(0, 12, 20, 6)
|
||||||
Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112)
|
Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112)
|
||||||
Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN"), 112, 128)
|
Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN", "battle"), 112, 128)
|
||||||
-- DisplayBattleMenu .safariLeftColumn / .safariRightColumn print
|
-- DisplayBattleMenu .safariLeftColumn / .safariRightColumn print
|
||||||
-- wNumSafariBalls at hlcoord 7,14 with `lb bc, 1, 2` -- one byte, two
|
-- wNumSafariBalls at hlcoord 7,14 with `lb bc, 1, 2` -- one byte, two
|
||||||
-- digits, space padded -- right after the "BALLx" label at columns
|
-- digits, space padded -- right after the "BALLx" label at columns
|
||||||
@@ -5610,9 +5671,9 @@ function BattleState:drawTextArea()
|
|||||||
-- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT <PK><MN> /
|
-- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT <PK><MN> /
|
||||||
-- ITEM RUN" from (10,14); cursor columns 9 / 15
|
-- ITEM RUN" from (10,14); cursor columns 9 / 15
|
||||||
Font.drawBox(8, 12, 12, 6)
|
Font.drawBox(8, 12, 12, 6)
|
||||||
Font.draw(Strings("FIGHT"), 80, 112)
|
Font.draw(Strings("FIGHT", "battle"), 80, 112)
|
||||||
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
|
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
|
||||||
Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("RUN"), 128, 128)
|
Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128)
|
||||||
Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16)
|
Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16)
|
||||||
end
|
end
|
||||||
elseif self.phase == "moveSelect" then
|
elseif self.phase == "moveSelect" then
|
||||||
|
|||||||
@@ -281,11 +281,11 @@ MoveEffects.primary = {
|
|||||||
failed = true }
|
failed = true }
|
||||||
end
|
end
|
||||||
local cost = math.floor(user.mon.stats.hp / 4)
|
local cost = math.floor(user.mon.stats.hp / 4)
|
||||||
-- substitute.asm only fails on subtraction underflow (current HP
|
-- A Substitute costs one quarter of max HP, rounded down. Do not let
|
||||||
-- strictly below maxHP/4); at equality the substitute is built and
|
-- the cost consume the user's last HP: the move must fail at the exact
|
||||||
-- the user is left standing on exactly 0 HP (it faints only when
|
-- boundary as well as below it, or the next turn's HP guard can leave a
|
||||||
-- the engine next checks HP, not here)
|
-- trainer battle unable to progress.
|
||||||
if user.mon.hp < cost then
|
if user.mon.hp <= cost then
|
||||||
return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!"),
|
return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!"),
|
||||||
failed = true }
|
failed = true }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ local function drawIntroBalls(battle)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function drawHUDs(battle, slide)
|
local function drawHUDs(battle, slide)
|
||||||
if battle.enemy and not battle.showEnemyTrainer
|
local showStatus = battle:statusHUDVisible()
|
||||||
|
if showStatus and battle.enemy and not battle.showEnemyTrainer
|
||||||
and not battle.enemySendingOut and not battle:growInScale(battle.enemy)
|
and not battle.enemySendingOut and not battle:growInScale(battle.enemy)
|
||||||
and slide == 0 and not battle.introBalls and not battle.enemy.fainted then
|
and slide == 0 and not battle.introBalls and not battle.enemy.fainted then
|
||||||
drawStatusPanel(battle, battle.enemy, 0, 0, false)
|
drawStatusPanel(battle, battle.enemy, 0, 0, false)
|
||||||
@@ -139,7 +140,7 @@ local function drawHUDs(battle, slide)
|
|||||||
-- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside
|
-- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside
|
||||||
-- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in
|
-- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in
|
||||||
-- drawCommandMenu below like the classic layout's (#540).
|
-- drawCommandMenu below like the classic layout's (#540).
|
||||||
if not battle.safari and battle.player and not battle.demo
|
if showStatus and not battle.safari and battle.player and not battle.demo
|
||||||
and not battle.showPlayerBack and slide == 0 then
|
and not battle.showPlayerBack and slide == 0 then
|
||||||
drawStatusPanel(battle, battle.player, 184, 56, true)
|
drawStatusPanel(battle, battle.player, 184, 56, true)
|
||||||
end
|
end
|
||||||
@@ -255,6 +256,7 @@ local function drawMoveMenu(battle)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function drawTextArea(battle)
|
local function drawTextArea(battle)
|
||||||
|
if not battle:bottomUIVisible() then return end
|
||||||
if battle.phase == "messages" and (battle.current or battle.animPlaying) then
|
if battle.phase == "messages" and (battle.current or battle.animPlaying) then
|
||||||
drawMessageBox(battle)
|
drawMessageBox(battle)
|
||||||
elseif battle.phase == "menu" then
|
elseif battle.phase == "menu" then
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
-- Semantic, data-only capture for settled single-player battle checkpoints.
|
||||||
|
-- Reconstruction lives here too; public mods only see the opaque checkpoint
|
||||||
|
-- facade in Loader.
|
||||||
|
|
||||||
|
local BattleCheckpoint = {}
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local BUILTIN_RULESETS = {
|
||||||
|
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
|
||||||
|
modern_clean = require("src.battle.rulesets.modern_clean"),
|
||||||
|
}
|
||||||
|
|
||||||
|
local function rulesets(game)
|
||||||
|
return game.data.rulesets or BUILTIN_RULESETS
|
||||||
|
end
|
||||||
|
|
||||||
|
local function rulesetId(game, record)
|
||||||
|
for id, candidate in pairs(rulesets(game)) do
|
||||||
|
if candidate == record then return id end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local BATTLER_FIELDS = {
|
||||||
|
"shownHP", "shownStatus", "stages", "curStats", "curTypes", "curMoves",
|
||||||
|
"sleepTurns", "confusedTurns", "disabledSlot", "disabledTurns",
|
||||||
|
"toxicCounter", "substituteHP", "bideDamage", "bideTurns", "boundTurns",
|
||||||
|
"chargeReady", "invulnerable", "mustRecharge",
|
||||||
|
"thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded",
|
||||||
|
"lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched",
|
||||||
|
"skipMove", "hazeStatReset", "drainFloor", "drainHold", "trappingTurns",
|
||||||
|
"trapMove", "trapDamage", "fainted",
|
||||||
|
"aiLayer2",
|
||||||
|
}
|
||||||
|
|
||||||
|
local MOVE_REFERENCE_FIELDS = {
|
||||||
|
charging = "chargingSlot",
|
||||||
|
thrashMove = "thrashMoveSlot",
|
||||||
|
rageMove = "rageMoveSlot",
|
||||||
|
}
|
||||||
|
|
||||||
|
local BATTLE_FIELDS = {
|
||||||
|
"oppClass", "partyIndex", "enemyIndex", "turnCount", "menuIndex",
|
||||||
|
"moveIndex", "moveSwapIndex", "aiUses", "runAttempts", "payDay",
|
||||||
|
"sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall",
|
||||||
|
"lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed",
|
||||||
|
"endBattleText",
|
||||||
|
}
|
||||||
|
|
||||||
|
local function partyIndex(party, mon)
|
||||||
|
for index, candidate in ipairs(party or {}) do
|
||||||
|
if candidate == mon then return index end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function indexSet(set, party)
|
||||||
|
local out = {}
|
||||||
|
for mon, present in pairs(set or {}) do
|
||||||
|
if present then
|
||||||
|
local index = partyIndex(party, mon)
|
||||||
|
if index then out[#out + 1] = index end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(out)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function captureBattler(battler, index, copy)
|
||||||
|
local out = {
|
||||||
|
index = index,
|
||||||
|
curStatsFromMon = battler.curStats == battler.mon.stats,
|
||||||
|
curTypesFromDefinition = battler.curTypes == battler.def.types,
|
||||||
|
curMovesFromMon = battler.curMoves == battler.mon.moves,
|
||||||
|
}
|
||||||
|
for _, field in ipairs(BATTLER_FIELDS) do
|
||||||
|
if battler[field] ~= nil then out[field] = battler[field] end
|
||||||
|
end
|
||||||
|
for field, slotField in pairs(MOVE_REFERENCE_FIELDS) do
|
||||||
|
local reference = battler[field]
|
||||||
|
if reference ~= nil then
|
||||||
|
for slot, move in ipairs(battler.curMoves or {}) do
|
||||||
|
if move == reference then out[slotField] = slot break end
|
||||||
|
end
|
||||||
|
if out[slotField] == nil then return nil end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return copy(out)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function integer(value, min, max)
|
||||||
|
return type(value) == "number" and value % 1 == 0
|
||||||
|
and value >= (min or -math.huge) and value <= (max or math.huge)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function validateMoveList(data, moves)
|
||||||
|
if type(moves) ~= "table" then return false end
|
||||||
|
for _, move in ipairs(moves) do
|
||||||
|
if type(move) ~= "table" or type(move.id) ~= "string"
|
||||||
|
or type(data.moves[move.id]) ~= "table" or type(move.pp) ~= "number" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function validateMon(data, mon)
|
||||||
|
return type(mon) == "table" and type(mon.species) == "string"
|
||||||
|
and type(data.pokemon[mon.species]) == "table" and integer(mon.level, 1, 100)
|
||||||
|
and type(mon.hp) == "number" and type(mon.stats) == "table"
|
||||||
|
and validateMoveList(data, mon.moves)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function validateBattler(data, battler, maxIndex)
|
||||||
|
if type(battler) ~= "table" or not integer(battler.index, 1, maxIndex) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if type(battler.curMoves) ~= "table" then return false end
|
||||||
|
if battler.stages ~= nil then
|
||||||
|
if type(battler.stages) ~= "table" then return false end
|
||||||
|
for _, stage in pairs(battler.stages) do
|
||||||
|
if not integer(stage, -6, 6) then return false end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, slotField in pairs(MOVE_REFERENCE_FIELDS) do
|
||||||
|
if battler[slotField] ~= nil
|
||||||
|
and not integer(battler[slotField], 1, #battler.curMoves) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return validateMoveList(data, battler.curMoves)
|
||||||
|
and type(battler.curStats) == "table" and type(battler.curTypes) == "table"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clone(value, copy)
|
||||||
|
if type(value) ~= "table" then return value end
|
||||||
|
return assert(copy(value))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function captureMimicRestores(battle)
|
||||||
|
local out = {}
|
||||||
|
for _, restore in ipairs(battle.mimicRestores or {}) do
|
||||||
|
local side = restore.battler == battle.player and "player"
|
||||||
|
or restore.battler == battle.enemy and "enemy" or nil
|
||||||
|
local slot
|
||||||
|
for index, move in ipairs(restore.battler and restore.battler.curMoves or {}) do
|
||||||
|
if move == restore.entry then slot = index break end
|
||||||
|
end
|
||||||
|
if not side or not slot or type(restore.id) ~= "string" then return nil end
|
||||||
|
out[#out + 1] = { side = side, slot = slot, id = restore.id }
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function BattleCheckpoint.validate(game, checkpoint)
|
||||||
|
local model = checkpoint.runtime and checkpoint.runtime.battle
|
||||||
|
local rngState = checkpoint.rng and checkpoint.rng.love
|
||||||
|
if type(model) ~= "table" or type(model.origin) ~= "table"
|
||||||
|
or type(rngState) ~= "string" or rngState == "" then
|
||||||
|
return nil, "invalid_checkpoint", "Battle checkpoint data or RNG is missing."
|
||||||
|
end
|
||||||
|
local expectedOrigin = model.kind == "wild" and "wild_encounter"
|
||||||
|
or model.kind == "trainer" and "trainer_encounter" or nil
|
||||||
|
if not expectedOrigin or model.origin.kind ~= expectedOrigin
|
||||||
|
or model.origin.map ~= checkpoint.runtime.overworld.map then
|
||||||
|
return nil, "battle_origin_unsupported",
|
||||||
|
"Battle continuation data is unsupported or inconsistent."
|
||||||
|
end
|
||||||
|
if type(model.rulesetId) ~= "string"
|
||||||
|
or type(rulesets(game)[model.rulesetId]) ~= "table" then
|
||||||
|
return nil, "invalid_content", "Battle ruleset is unavailable."
|
||||||
|
end
|
||||||
|
if model.kind == "trainer" and (type(model.origin.npcId) ~= "string"
|
||||||
|
or model.origin.trainerClass ~= model.oppClass
|
||||||
|
or model.origin.partyIndex ~= (model.partyIndex or 1)) then
|
||||||
|
return nil, "battle_origin_unsupported",
|
||||||
|
"Trainer continuation data is incomplete or inconsistent."
|
||||||
|
end
|
||||||
|
local party = checkpoint.save.party
|
||||||
|
if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then
|
||||||
|
return nil, "invalid_content", "Player battle state is invalid."
|
||||||
|
end
|
||||||
|
if model.kind == "wild" then
|
||||||
|
if not validateMon(game.data, model.enemyMon)
|
||||||
|
or not validateBattler(game.data, model.enemy, 1) then
|
||||||
|
return nil, "invalid_content", "Wild opponent state is invalid."
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local trainer = game.data.trainers and game.data.trainers[model.oppClass]
|
||||||
|
if type(trainer) ~= "table" or not integer(model.partyIndex, 1)
|
||||||
|
or type(model.enemyParty) ~= "table" or #model.enemyParty == 0
|
||||||
|
or not integer(model.enemyIndex, 1, #model.enemyParty)
|
||||||
|
or not validateBattler(game.data, model.enemy, #model.enemyParty) then
|
||||||
|
return nil, "invalid_content", "Trainer battle identity or roster is invalid."
|
||||||
|
end
|
||||||
|
for _, mon in ipairs(model.enemyParty) do
|
||||||
|
if not validateMon(game.data, mon) then
|
||||||
|
return nil, "invalid_content", "Trainer opponent state is invalid."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, indices in ipairs({ model.participants, model.leveledUp }) do
|
||||||
|
if type(indices) ~= "table" then
|
||||||
|
return nil, "invalid_checkpoint", "Battle party reference set is missing."
|
||||||
|
end
|
||||||
|
for _, index in ipairs(indices) do
|
||||||
|
if not integer(index, 1, #party) then
|
||||||
|
return nil, "invalid_checkpoint", "Battle party reference is invalid."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if type(model.mimicRestores) ~= "table" then
|
||||||
|
return nil, "invalid_checkpoint", "Mimic restore state is missing."
|
||||||
|
end
|
||||||
|
for _, restore in ipairs(model.mimicRestores) do
|
||||||
|
local battler = restore.side == "player" and model.player
|
||||||
|
or restore.side == "enemy" and model.enemy or nil
|
||||||
|
if not battler or not integer(restore.slot, 1, #battler.curMoves)
|
||||||
|
or type(restore.id) ~= "string"
|
||||||
|
or type(game.data.moves[restore.id]) ~= "table" then
|
||||||
|
return nil, "invalid_content", "Mimic restore state is invalid."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function applyBattler(target, captured, copy)
|
||||||
|
for _, field in ipairs(BATTLER_FIELDS) do
|
||||||
|
if field ~= "curStats" and field ~= "curTypes" and field ~= "curMoves" then
|
||||||
|
if captured[field] ~= nil then
|
||||||
|
target[field] = clone(captured[field], copy)
|
||||||
|
else
|
||||||
|
target[field] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
target.curStats = captured.curStatsFromMon and target.mon.stats
|
||||||
|
or assert(copy(captured.curStats))
|
||||||
|
target.curTypes = captured.curTypesFromDefinition and target.def.types
|
||||||
|
or assert(copy(captured.curTypes))
|
||||||
|
target.curMoves = captured.curMovesFromMon and target.mon.moves
|
||||||
|
or assert(copy(captured.curMoves))
|
||||||
|
for field, slotField in pairs(MOVE_REFERENCE_FIELDS) do
|
||||||
|
target[field] = captured[slotField] and target.curMoves[captured[slotField]] or nil
|
||||||
|
end
|
||||||
|
return target
|
||||||
|
end
|
||||||
|
|
||||||
|
local function restoreIndexSet(indices, party)
|
||||||
|
local out = {}
|
||||||
|
for _, index in ipairs(indices or {}) do out[party[index]] = true end
|
||||||
|
return next(out) and out or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function BattleCheckpoint.restore(game, checkpoint, copy)
|
||||||
|
local model = checkpoint.runtime.battle
|
||||||
|
local battle
|
||||||
|
if model.kind == "trainer" then
|
||||||
|
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex)
|
||||||
|
battle.enemyParty = assert(copy(model.enemyParty))
|
||||||
|
battle.enemyIndex = model.enemyIndex
|
||||||
|
else
|
||||||
|
battle = BattleState.newWild(game, model.enemyMon.species, model.enemyMon.level)
|
||||||
|
end
|
||||||
|
|
||||||
|
battle.player = BattleState.makeBattler(game.data,
|
||||||
|
game.save.party[model.player.index], true, game.save)
|
||||||
|
applyBattler(battle.player, model.player, copy)
|
||||||
|
local enemyMon
|
||||||
|
if model.kind == "trainer" then
|
||||||
|
enemyMon = battle.enemyParty[model.enemy.index]
|
||||||
|
else
|
||||||
|
enemyMon = assert(copy(model.enemyMon))
|
||||||
|
end
|
||||||
|
battle.enemy = BattleState.makeBattler(game.data, enemyMon, false)
|
||||||
|
applyBattler(battle.enemy, model.enemy, copy)
|
||||||
|
|
||||||
|
battle.mimicRestores = {}
|
||||||
|
for _, restore in ipairs(model.mimicRestores or {}) do
|
||||||
|
local battler = restore.side == "player" and battle.player or battle.enemy
|
||||||
|
battle.mimicRestores[#battle.mimicRestores + 1] = {
|
||||||
|
battler = battler,
|
||||||
|
entry = battler.curMoves[restore.slot],
|
||||||
|
id = restore.id,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
if #battle.mimicRestores == 0 then battle.mimicRestores = nil end
|
||||||
|
|
||||||
|
for _, field in ipairs(BATTLE_FIELDS) do
|
||||||
|
if model[field] ~= nil then
|
||||||
|
battle[field] = clone(model[field], copy)
|
||||||
|
else
|
||||||
|
battle[field] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
battle.kind = model.kind
|
||||||
|
battle.ruleset = rulesets(game)[model.rulesetId]
|
||||||
|
battle.checkpointOrigin = assert(copy(model.origin))
|
||||||
|
battle.participants = restoreIndexSet(model.participants, game.save.party)
|
||||||
|
battle.leveledUp = restoreIndexSet(model.leveledUp, game.save.party)
|
||||||
|
battle.sides = assert(copy(model.sides))
|
||||||
|
battle.sides[1].battlers = { battle.player }
|
||||||
|
battle.sides[2].battlers = { battle.enemy }
|
||||||
|
battle.field = assert(copy(model.field))
|
||||||
|
battle.field.sides = battle.sides
|
||||||
|
battle.phase, battle.queue = "menu", {}
|
||||||
|
battle.frame = 0
|
||||||
|
battle.current, battle.afterQueue, battle.nextInsert = nil, nil, nil
|
||||||
|
battle.pendingHit, battle.waitingUI, battle.waitingSound = nil, nil, nil
|
||||||
|
battle.waitFrames, battle.draining, battle.animPlaying = nil, nil, nil
|
||||||
|
battle.introText, battle.introBalls, battle.introSlide = nil, nil, nil
|
||||||
|
battle.showPlayerBack, battle.showEnemyTrainer, battle.showEnemyBalls = nil, nil, nil
|
||||||
|
battle.player.shownHP, battle.player.shownStatus =
|
||||||
|
battle.player.mon.hp, battle.player.mon.status
|
||||||
|
battle.enemy.shownHP, battle.enemy.shownStatus =
|
||||||
|
battle.enemy.mon.hp, battle.enemy.mon.status
|
||||||
|
|
||||||
|
local ow = game.overworld
|
||||||
|
if not ow or type(ow.restoreBattleContinuation) ~= "function"
|
||||||
|
or ow:restoreBattleContinuation(battle, battle.checkpointOrigin) ~= true then
|
||||||
|
error("battle continuation reconstruction is unavailable", 0)
|
||||||
|
end
|
||||||
|
if type(game.restoreCheckpointBattle) ~= "function" then
|
||||||
|
error("game has no battle checkpoint reconstruction path", 0)
|
||||||
|
end
|
||||||
|
game:restoreCheckpointBattle(battle)
|
||||||
|
local setState = love and love.math and love.math.setRandomState
|
||||||
|
if type(setState) ~= "function" then error("battle RNG restore is unavailable", 0) end
|
||||||
|
setState(checkpoint.rng.love)
|
||||||
|
return battle
|
||||||
|
end
|
||||||
|
|
||||||
|
local function captureExtensions(battle, copy)
|
||||||
|
local sides = {}
|
||||||
|
for i = 1, 2 do
|
||||||
|
local side = battle.sides and battle.sides[i] or {}
|
||||||
|
local encoded, err = copy({
|
||||||
|
index = i,
|
||||||
|
screens = side.screens or {},
|
||||||
|
hazards = side.hazards or {},
|
||||||
|
tokens = side.tokens or {},
|
||||||
|
})
|
||||||
|
if not encoded then return nil, err end
|
||||||
|
sides[i] = encoded
|
||||||
|
end
|
||||||
|
local field, err = copy({
|
||||||
|
weather = battle.field and battle.field.weather or nil,
|
||||||
|
tokens = battle.field and battle.field.tokens or {},
|
||||||
|
})
|
||||||
|
if not field then return nil, err end
|
||||||
|
return sides, field
|
||||||
|
end
|
||||||
|
|
||||||
|
function BattleCheckpoint.capture(game, battle, progress, copy)
|
||||||
|
local getState = love and love.math and love.math.getRandomState
|
||||||
|
local setState = love and love.math and love.math.setRandomState
|
||||||
|
if type(getState) ~= "function" or type(setState) ~= "function" then
|
||||||
|
return nil, "rng_state_unavailable",
|
||||||
|
"This runtime cannot preserve deterministic battle randomness."
|
||||||
|
end
|
||||||
|
local ok, rngState = pcall(getState)
|
||||||
|
if not ok or type(rngState) ~= "string" or rngState == "" then
|
||||||
|
return nil, "rng_state_unavailable",
|
||||||
|
"The gameplay random-number state could not be captured."
|
||||||
|
end
|
||||||
|
|
||||||
|
local origin, originErr = copy(battle.checkpointOrigin)
|
||||||
|
if not origin then
|
||||||
|
return nil, "battle_origin_unsupported",
|
||||||
|
"The battle completion path is not data-only: " .. tostring(originErr)
|
||||||
|
end
|
||||||
|
local sides, fieldOrErr = captureExtensions(battle, copy)
|
||||||
|
if not sides then
|
||||||
|
return nil, "battle_extension_unsafe",
|
||||||
|
"Battle extension state is not data-only: " .. tostring(fieldOrErr)
|
||||||
|
end
|
||||||
|
local field = fieldOrErr
|
||||||
|
|
||||||
|
local liveParty = game.save.party
|
||||||
|
local playerIndex = partyIndex(liveParty, battle.player.mon)
|
||||||
|
if not playerIndex then
|
||||||
|
return nil, "battle_state_invalid",
|
||||||
|
"The active player battler is not in the current party."
|
||||||
|
end
|
||||||
|
|
||||||
|
local model = {
|
||||||
|
kind = battle.kind,
|
||||||
|
rulesetId = rulesetId(game, battle.ruleset),
|
||||||
|
origin = origin,
|
||||||
|
player = captureBattler(battle.player, playerIndex, copy),
|
||||||
|
participants = indexSet(battle.participants, liveParty),
|
||||||
|
leveledUp = indexSet(battle.leveledUp, liveParty),
|
||||||
|
sides = sides,
|
||||||
|
field = field,
|
||||||
|
mimicRestores = captureMimicRestores(battle),
|
||||||
|
}
|
||||||
|
if not model.rulesetId then
|
||||||
|
return nil, "battle_state_invalid", "Battle ruleset identity is unavailable."
|
||||||
|
end
|
||||||
|
if not model.player then
|
||||||
|
return nil, "battle_state_invalid", "Player move references are inconsistent."
|
||||||
|
end
|
||||||
|
if not model.mimicRestores then
|
||||||
|
return nil, "battle_state_invalid", "Mimic restore state is inconsistent."
|
||||||
|
end
|
||||||
|
if battle.kind == "trainer" then
|
||||||
|
model.enemyParty = copy(battle.enemyParty)
|
||||||
|
model.enemy = captureBattler(battle.enemy, battle.enemyIndex, copy)
|
||||||
|
else
|
||||||
|
model.enemyMon = copy(battle.enemy.mon)
|
||||||
|
model.enemy = captureBattler(battle.enemy, 1, copy)
|
||||||
|
end
|
||||||
|
if not model.enemy then
|
||||||
|
return nil, "battle_state_invalid", "Enemy move references are inconsistent."
|
||||||
|
end
|
||||||
|
for _, fieldName in ipairs(BATTLE_FIELDS) do
|
||||||
|
if battle[fieldName] ~= nil then model[fieldName] = battle[fieldName] end
|
||||||
|
end
|
||||||
|
|
||||||
|
model = copy(model)
|
||||||
|
if not model then
|
||||||
|
return nil, "battle_state_invalid",
|
||||||
|
"Battle state contains non-serializable runtime data."
|
||||||
|
end
|
||||||
|
local player = progress.player
|
||||||
|
return {
|
||||||
|
overworld = {
|
||||||
|
map = player.map, x = player.x, y = player.y,
|
||||||
|
facing = player.facing, surfing = player.surfing and true or false,
|
||||||
|
},
|
||||||
|
battle = model,
|
||||||
|
}, { love = rngState }
|
||||||
|
end
|
||||||
|
|
||||||
|
return BattleCheckpoint
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
-- Public runtime checkpoint implementation. Loader exposes bound forwarding
|
||||||
|
-- methods; mods never receive controller or state-stack internals from here.
|
||||||
|
|
||||||
|
local SaveSerializer = require("src.core.SaveSerializer")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local Version = require("src.core.Version")
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local BattleCheckpoint = require("src.core.BattleCheckpoint")
|
||||||
|
local ModRuntime = require("src.mods.Runtime")
|
||||||
|
|
||||||
|
local Checkpoint = {}
|
||||||
|
|
||||||
|
Checkpoint.FORMAT = 1
|
||||||
|
|
||||||
|
local function refusal(kind, reason, message)
|
||||||
|
return {
|
||||||
|
canCapture = false,
|
||||||
|
canRestore = false,
|
||||||
|
kind = kind or "unknown",
|
||||||
|
reason = reason,
|
||||||
|
message = message,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function running(runner)
|
||||||
|
return runner and runner.isRunning and runner:isRunning()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function nonempty(value)
|
||||||
|
return type(value) == "table" and next(value) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function scriptsBusy(ow)
|
||||||
|
return running(ow.runner) or nonempty(ow.parallelRunners)
|
||||||
|
or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue)
|
||||||
|
or nonempty(ow.scriptMoves)
|
||||||
|
end
|
||||||
|
|
||||||
|
local BATTLE_BUSY_FIELDS = {
|
||||||
|
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
||||||
|
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
||||||
|
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
||||||
|
}
|
||||||
|
|
||||||
|
local function inspectBattle(ow, battle)
|
||||||
|
if battle.kind == "link" then
|
||||||
|
return refusal("battle", "link_battle_unsupported",
|
||||||
|
"Network battles cannot be checkpointed.")
|
||||||
|
end
|
||||||
|
if battle.safari or battle.ghost or battle.scopeReveal or battle.demo
|
||||||
|
or battle.noCatch then
|
||||||
|
return refusal("battle", "battle_variant_unsupported",
|
||||||
|
"This battle variant does not have a checkpoint contract.")
|
||||||
|
end
|
||||||
|
if battle.kind ~= "wild" and battle.kind ~= "trainer" then
|
||||||
|
return refusal("battle", "battle_variant_unsupported",
|
||||||
|
"This battle kind does not have a checkpoint contract.")
|
||||||
|
end
|
||||||
|
local origin = battle.checkpointOrigin
|
||||||
|
local expectedOrigin = battle.kind == "wild" and "wild_encounter"
|
||||||
|
or "trainer_encounter"
|
||||||
|
if type(origin) ~= "table" or origin.kind ~= expectedOrigin then
|
||||||
|
return refusal("battle", "battle_origin_unsupported",
|
||||||
|
"The battle completion path cannot be reconstructed safely.")
|
||||||
|
end
|
||||||
|
if scriptsBusy(ow) then
|
||||||
|
return refusal("battle", "script_busy",
|
||||||
|
"A suspended or queued script cannot be checkpointed.")
|
||||||
|
end
|
||||||
|
if battle.phase ~= "menu" or nonempty(battle.queue) then
|
||||||
|
return refusal("battle", "battle_phase_busy",
|
||||||
|
"Wait for the player command menu before creating a checkpoint.")
|
||||||
|
end
|
||||||
|
for _, field in ipairs(BATTLE_BUSY_FIELDS) do
|
||||||
|
if battle[field] ~= nil and battle[field] ~= false then
|
||||||
|
return refusal("battle", "battle_phase_busy",
|
||||||
|
"Wait for the current battle action to finish.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not battle.player or not battle.enemy or battle.player.mon.hp <= 0
|
||||||
|
or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then
|
||||||
|
return refusal("battle", "battle_phase_busy",
|
||||||
|
"Wait for an ordinary player decision before creating a checkpoint.")
|
||||||
|
end
|
||||||
|
for _, battler in ipairs({ battle.player, battle.enemy }) do
|
||||||
|
if battler.shownHP ~= battler.mon.hp
|
||||||
|
or battler.shownStatus ~= battler.mon.status
|
||||||
|
or battler.drainFloor ~= nil or battler.drainHold ~= nil
|
||||||
|
or battler.faintQueued then
|
||||||
|
return refusal("battle", "battle_phase_busy",
|
||||||
|
"Wait for battle status and HP presentation to settle.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return { canCapture = true, canRestore = true, kind = "battle" }
|
||||||
|
end
|
||||||
|
|
||||||
|
function Checkpoint.inspect(game)
|
||||||
|
local save = game and game.save
|
||||||
|
if type(save) ~= "table" or type(save.version) ~= "string" then
|
||||||
|
return refusal("unknown", "not_in_playthrough",
|
||||||
|
"A checkpoint requires an identified active playthrough.")
|
||||||
|
end
|
||||||
|
|
||||||
|
local ow = game.overworld
|
||||||
|
if type(ow) ~= "table" or type(ow.map) ~= "table"
|
||||||
|
or type(ow.map.id) ~= "string" or type(ow.player) ~= "table" then
|
||||||
|
return refusal("unknown", "not_overworld",
|
||||||
|
"Only a settled overworld can be checkpointed.")
|
||||||
|
end
|
||||||
|
local top = game.stack and game.stack.top and game.stack:top()
|
||||||
|
if getmetatable(top) == BattleState then
|
||||||
|
return inspectBattle(ow, top)
|
||||||
|
end
|
||||||
|
if top ~= ow then
|
||||||
|
return refusal("overworld", "screen_busy",
|
||||||
|
"Close the active menu or screen before creating a checkpoint.")
|
||||||
|
end
|
||||||
|
local identity = save.meta and save.meta.playthroughId
|
||||||
|
if type(identity) ~= "string" or identity == "" then
|
||||||
|
identity = SaveData.ensurePlaythroughId(save)
|
||||||
|
end
|
||||||
|
if type(identity) ~= "string" or identity == "" then
|
||||||
|
return refusal("overworld", "not_in_playthrough",
|
||||||
|
"The active playthrough could not be identified.")
|
||||||
|
end
|
||||||
|
if ow.transitioning then
|
||||||
|
return refusal("overworld", "transition_busy",
|
||||||
|
"Wait for the map transition to finish.")
|
||||||
|
end
|
||||||
|
if scriptsBusy(ow) then
|
||||||
|
return refusal("overworld", "script_busy",
|
||||||
|
"Wait for the active or queued script to finish.")
|
||||||
|
end
|
||||||
|
|
||||||
|
local animationFields = {
|
||||||
|
"engaging", "emote", "teleportOut", "dustAnim", "cutAnim", "fishPose",
|
||||||
|
"pikaHop", "healAnim", "flyAnim", "flyArrive",
|
||||||
|
}
|
||||||
|
for _, field in ipairs(animationFields) do
|
||||||
|
if ow[field] then
|
||||||
|
return refusal("overworld", "animation_busy",
|
||||||
|
"Wait for the overworld animation to finish.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if ow.player.moving or ow.player.targetX ~= nil or ow.player.targetY ~= nil then
|
||||||
|
return refusal("overworld", "movement_busy",
|
||||||
|
"Wait for movement to settle on a tile.")
|
||||||
|
end
|
||||||
|
return { canCapture = true, canRestore = true, kind = "overworld" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function dataCopy(value)
|
||||||
|
local ok, encoded = pcall(SaveSerializer.encode, value)
|
||||||
|
if not ok then return nil, tostring(encoded) end
|
||||||
|
local decoded, err = SaveSerializer.decode(encoded)
|
||||||
|
if not decoded then return nil, err end
|
||||||
|
return decoded
|
||||||
|
end
|
||||||
|
|
||||||
|
local function captureRng()
|
||||||
|
local getState = love and love.math and love.math.getRandomState
|
||||||
|
local setState = love and love.math and love.math.setRandomState
|
||||||
|
if type(getState) ~= "function" or type(setState) ~= "function" then return nil end
|
||||||
|
local ok, state = pcall(getState)
|
||||||
|
if ok and type(state) == "string" and state ~= "" then
|
||||||
|
return { love = state }
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function restoreRng(rng)
|
||||||
|
if rng == nil then return end -- legacy format-1 overworld checkpoint
|
||||||
|
local setState = love and love.math and love.math.setRandomState
|
||||||
|
if type(rng) ~= "table" or type(rng.love) ~= "string"
|
||||||
|
or type(setState) ~= "function" then
|
||||||
|
error("checkpoint RNG restore is unavailable", 0)
|
||||||
|
end
|
||||||
|
setState(rng.love)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Checkpoint.capture(game)
|
||||||
|
local capability = Checkpoint.inspect(game)
|
||||||
|
if not capability.canCapture then
|
||||||
|
return nil, capability.reason, capability.message
|
||||||
|
end
|
||||||
|
|
||||||
|
local progress = {}
|
||||||
|
for key, value in pairs(game.save) do
|
||||||
|
if key ~= "options" then progress[key] = value end
|
||||||
|
end
|
||||||
|
progress = dataCopy(progress)
|
||||||
|
if not progress then
|
||||||
|
return nil, "capture_failed", "Progress contains non-serializable runtime data."
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, err = pcall(game.overworld.captureSave, game.overworld, progress)
|
||||||
|
if not ok then
|
||||||
|
return nil, "capture_failed", "Could not synchronize overworld progress: "
|
||||||
|
.. tostring(err)
|
||||||
|
end
|
||||||
|
progress, err = dataCopy(progress)
|
||||||
|
if not progress then
|
||||||
|
return nil, "capture_failed", "Synchronized progress is not data-only: "
|
||||||
|
.. tostring(err)
|
||||||
|
end
|
||||||
|
|
||||||
|
if capability.kind == "battle" then
|
||||||
|
local battle = game.stack:top()
|
||||||
|
local runtime, rngOrCode, battleMessage =
|
||||||
|
BattleCheckpoint.capture(game, battle, progress, dataCopy)
|
||||||
|
if not runtime then return nil, rngOrCode, battleMessage end
|
||||||
|
return {
|
||||||
|
format = Checkpoint.FORMAT,
|
||||||
|
kind = "battle",
|
||||||
|
identity = {
|
||||||
|
engineVersion = Version.engine,
|
||||||
|
gameVersion = game.save.version,
|
||||||
|
playthroughId = game.save.meta.playthroughId,
|
||||||
|
},
|
||||||
|
save = progress,
|
||||||
|
runtime = runtime,
|
||||||
|
rng = rngOrCode,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local player = game.overworld.player
|
||||||
|
return {
|
||||||
|
format = Checkpoint.FORMAT,
|
||||||
|
kind = "overworld",
|
||||||
|
identity = {
|
||||||
|
engineVersion = Version.engine,
|
||||||
|
gameVersion = game.save.version,
|
||||||
|
playthroughId = game.save.meta.playthroughId,
|
||||||
|
},
|
||||||
|
save = progress,
|
||||||
|
runtime = { overworld = {
|
||||||
|
map = game.overworld.map.id,
|
||||||
|
x = player.cellX,
|
||||||
|
y = player.cellY,
|
||||||
|
facing = player.facing,
|
||||||
|
surfing = player.surfing and true or false,
|
||||||
|
} },
|
||||||
|
rng = captureRng(),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local FACINGS = { up = true, down = true, left = true, right = true }
|
||||||
|
|
||||||
|
local function validate(game, checkpoint)
|
||||||
|
if type(checkpoint) ~= "table" then
|
||||||
|
return nil, "invalid_checkpoint", "Checkpoint root must be a table."
|
||||||
|
end
|
||||||
|
if checkpoint.format ~= Checkpoint.FORMAT then
|
||||||
|
return nil, "unsupported_format", "This checkpoint format is not supported."
|
||||||
|
end
|
||||||
|
if checkpoint.kind ~= "overworld" and checkpoint.kind ~= "battle" then
|
||||||
|
return nil, "unsupported_runtime_kind", "This checkpoint runtime kind is not supported."
|
||||||
|
end
|
||||||
|
|
||||||
|
local copy, copyErr = dataCopy(checkpoint)
|
||||||
|
if not copy then
|
||||||
|
return nil, "invalid_checkpoint", "Checkpoint is not data-only: "
|
||||||
|
.. tostring(copyErr)
|
||||||
|
end
|
||||||
|
local identity = copy.identity
|
||||||
|
local current = game and game.save
|
||||||
|
local currentId = current and current.meta and current.meta.playthroughId
|
||||||
|
if type(identity) ~= "table" or type(identity.engineVersion) ~= "string"
|
||||||
|
or type(identity.gameVersion) ~= "string"
|
||||||
|
or type(identity.playthroughId) ~= "string" then
|
||||||
|
return nil, "invalid_checkpoint", "Checkpoint identity is missing or corrupt."
|
||||||
|
end
|
||||||
|
if identity.gameVersion ~= current.version then
|
||||||
|
return nil, "wrong_game", "Checkpoint belongs to another game version."
|
||||||
|
end
|
||||||
|
if identity.playthroughId ~= currentId then
|
||||||
|
return nil, "wrong_playthrough", "Checkpoint belongs to another playthrough."
|
||||||
|
end
|
||||||
|
|
||||||
|
local save = copy.save
|
||||||
|
local runtime = copy.runtime and copy.runtime.overworld
|
||||||
|
if type(save) ~= "table" or type(save.player) ~= "table"
|
||||||
|
or type(runtime) ~= "table" then
|
||||||
|
return nil, "invalid_checkpoint", "Checkpoint progress or runtime data is missing."
|
||||||
|
end
|
||||||
|
if save.version ~= identity.gameVersion
|
||||||
|
or not save.meta or save.meta.playthroughId ~= identity.playthroughId then
|
||||||
|
return nil, "invalid_checkpoint", "Checkpoint progress identity is inconsistent."
|
||||||
|
end
|
||||||
|
if copy.rng ~= nil and (type(copy.rng) ~= "table"
|
||||||
|
or type(copy.rng.love) ~= "string" or copy.rng.love == "") then
|
||||||
|
return nil, "invalid_checkpoint", "Checkpoint RNG state is corrupt."
|
||||||
|
end
|
||||||
|
if type(runtime.map) ~= "string" or type(runtime.x) ~= "number"
|
||||||
|
or type(runtime.y) ~= "number" or runtime.x % 1 ~= 0 or runtime.y % 1 ~= 0
|
||||||
|
or not FACINGS[runtime.facing] or type(runtime.surfing) ~= "boolean" then
|
||||||
|
return nil, "invalid_checkpoint", "Overworld position is missing or corrupt."
|
||||||
|
end
|
||||||
|
if save.player.map ~= runtime.map or save.player.x ~= runtime.x
|
||||||
|
or save.player.y ~= runtime.y or save.player.facing ~= runtime.facing
|
||||||
|
or (save.player.surfing and true or false) ~= runtime.surfing then
|
||||||
|
return nil, "invalid_checkpoint", "Progress and runtime position disagree."
|
||||||
|
end
|
||||||
|
|
||||||
|
local map = game.data and game.data.maps and game.data.maps[runtime.map]
|
||||||
|
if type(map) ~= "table" then
|
||||||
|
return nil, "invalid_map", "Checkpoint references a map that is unavailable."
|
||||||
|
end
|
||||||
|
local width, height = tonumber(map.width), tonumber(map.height)
|
||||||
|
if not width or not height or runtime.x < 0 or runtime.y < 0
|
||||||
|
or runtime.x >= width * 2 or runtime.y >= height * 2 then
|
||||||
|
return nil, "invalid_position", "Checkpoint position is outside the map."
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A checkpoint is a strict restoration record, not an ordinary CONTINUE
|
||||||
|
-- migration. Reuse the canonical save validator on the detached copy, but
|
||||||
|
-- reject any quarantine, remap, reclaim, clamp, or content repair it would
|
||||||
|
-- perform instead of silently changing the state the caller selected.
|
||||||
|
local beforeContent = SaveSerializer.encode(copy.save)
|
||||||
|
local validOk, report = pcall(SaveData.validate, copy.save, game.data)
|
||||||
|
local afterOk, afterContent = pcall(SaveSerializer.encode, copy.save)
|
||||||
|
if not validOk or not afterOk or not SaveData.emptyReport(report)
|
||||||
|
or afterContent ~= beforeContent then
|
||||||
|
return nil, "invalid_content",
|
||||||
|
"Checkpoint references unavailable or invalid game content."
|
||||||
|
end
|
||||||
|
if copy.kind == "battle" then
|
||||||
|
local battleOk, battleCode, battleMessage = BattleCheckpoint.validate(game, copy)
|
||||||
|
if not battleOk then return nil, battleCode, battleMessage end
|
||||||
|
elseif copy.runtime.battle ~= nil then
|
||||||
|
return nil, "invalid_checkpoint",
|
||||||
|
"Overworld checkpoint contains unexpected battle state."
|
||||||
|
end
|
||||||
|
return copy
|
||||||
|
end
|
||||||
|
|
||||||
|
local function apply(game, checkpoint, options)
|
||||||
|
local save, err = dataCopy(checkpoint.save)
|
||||||
|
if not save then error("checkpoint progress decode failed: " .. tostring(err), 0) end
|
||||||
|
local runtime = checkpoint.runtime.overworld
|
||||||
|
save.options = options
|
||||||
|
save.player.map = runtime.map
|
||||||
|
save.player.x = runtime.x
|
||||||
|
save.player.y = runtime.y
|
||||||
|
save.player.facing = runtime.facing
|
||||||
|
save.player.surfing = runtime.surfing
|
||||||
|
if type(game.restoreCheckpointSave) ~= "function" then
|
||||||
|
error("game has no checkpoint reconstruction path", 0)
|
||||||
|
end
|
||||||
|
game:restoreCheckpointSave(save)
|
||||||
|
if checkpoint.kind == "battle" then
|
||||||
|
BattleCheckpoint.restore(game, checkpoint, dataCopy)
|
||||||
|
else
|
||||||
|
restoreRng(checkpoint.rng)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function equalData(a, b)
|
||||||
|
local okA, encodedA = pcall(SaveSerializer.encode, a)
|
||||||
|
local okB, encodedB = pcall(SaveSerializer.encode, b)
|
||||||
|
return okA and okB and encodedA == encodedB
|
||||||
|
end
|
||||||
|
|
||||||
|
local function firstDifference(a, b, path)
|
||||||
|
path = path or "$"
|
||||||
|
if type(a) ~= type(b) then return path .. " (type)" end
|
||||||
|
if type(a) ~= "table" then
|
||||||
|
if a ~= b then return path end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
for key, value in pairs(a) do
|
||||||
|
if b[key] == nil and value ~= nil then
|
||||||
|
return path .. "." .. tostring(key) .. " (missing)"
|
||||||
|
end
|
||||||
|
local found = firstDifference(value, b[key], path .. "." .. tostring(key))
|
||||||
|
if found then return found end
|
||||||
|
end
|
||||||
|
for key, value in pairs(b) do
|
||||||
|
if a[key] == nil and value ~= nil then
|
||||||
|
return path .. "." .. tostring(key) .. " (unexpected)"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function Checkpoint.restore(game, checkpoint)
|
||||||
|
local capability = Checkpoint.inspect(game)
|
||||||
|
if not capability.canRestore then
|
||||||
|
return false, capability.reason, capability.message
|
||||||
|
end
|
||||||
|
local validated, code, message = validate(game, checkpoint)
|
||||||
|
if not validated then return false, code, message end
|
||||||
|
|
||||||
|
local rollback, captureCode, captureMessage = Checkpoint.capture(game)
|
||||||
|
if not rollback then return false, captureCode, captureMessage end
|
||||||
|
local options = game.save.options
|
||||||
|
|
||||||
|
local ok, err = pcall(apply, game, validated, options)
|
||||||
|
if ok then
|
||||||
|
local restored, verifyCode = Checkpoint.capture(game)
|
||||||
|
if restored and validated.rng == nil then restored.rng = nil end
|
||||||
|
if restored and equalData(restored, validated) then
|
||||||
|
if ModRuntime.wants("checkpoint.restored") then
|
||||||
|
ModRuntime.emit("checkpoint.restored", {
|
||||||
|
game = game,
|
||||||
|
kind = validated.kind,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
err = restored and ("restored state differed at "
|
||||||
|
.. tostring(firstDifference(validated, restored) or "canonical encoding"))
|
||||||
|
or ("restored state could not be captured: " .. tostring(verifyCode))
|
||||||
|
end
|
||||||
|
|
||||||
|
local rolledBack, rollbackErr = pcall(apply, game, rollback, options)
|
||||||
|
if not rolledBack then
|
||||||
|
return false, "rollback_failed",
|
||||||
|
"Checkpoint restore and rollback both failed: " .. tostring(rollbackErr)
|
||||||
|
end
|
||||||
|
return false, "restore_failed", "Checkpoint restoration failed: " .. tostring(err)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Checkpoint
|
||||||
+82
-29
@@ -111,6 +111,11 @@ local NOISE_DIVISORS = {
|
|||||||
[4] = 64, [5] = 80, [6] = 96, [7] = 112,
|
[4] = 64, [5] = 80, [6] = 96, [7] = 112,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
local HPF_CHARGE = 0.999958 ^ (GB_CLOCK / SAMPLE_RATE)
|
||||||
|
local LPF_ALPHA = 0.8
|
||||||
|
local MIX_SCALE = 0.5
|
||||||
|
|
||||||
local function snapTicks(ticks)
|
local function snapTicks(ticks)
|
||||||
return math.floor((ticks * 1470 + 256) / 512)
|
return math.floor((ticks * 1470 + 256) / 512)
|
||||||
end
|
end
|
||||||
@@ -269,6 +274,7 @@ function Channel.new(engine, spec, options)
|
|||||||
phase = 0,
|
phase = 0,
|
||||||
noiseLfsr = 0x7FFF,
|
noiseLfsr = 0x7FFF,
|
||||||
noiseClock = 0,
|
noiseClock = 0,
|
||||||
|
drumTail = nil,
|
||||||
timeTicks = 0,
|
timeTicks = 0,
|
||||||
}, Channel)
|
}, Channel)
|
||||||
end
|
end
|
||||||
@@ -527,6 +533,25 @@ local function envelopeVolume(volume, fade, elapsed)
|
|||||||
return math.min(15, volume + steps)
|
return math.min(15, volume + steps)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
local function envelopeRingSamples(volume, fade)
|
||||||
|
if not fade or fade <= 0 or not volume or volume <= 0 then return 0 end
|
||||||
|
return math.floor(volume * (fade / 64) * SAMPLE_RATE + 0.5)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function extendDrumEnvelope(segments)
|
||||||
|
local last = segments and segments[#segments]
|
||||||
|
if not last then return segments end
|
||||||
|
local ringEnd = last.startSample + envelopeRingSamples(last.volume, last.fade)
|
||||||
|
if ringEnd > last.endSample then last.endSample = ringEnd end
|
||||||
|
return segments
|
||||||
|
end
|
||||||
|
|
||||||
|
local function drumAudioEnd(drum)
|
||||||
|
local last = drum and drum[#drum]
|
||||||
|
return last and last.endSample or 0
|
||||||
|
end
|
||||||
|
|
||||||
function Channel:resetNoise()
|
function Channel:resetNoise()
|
||||||
self.noiseLfsr = 0x7FFF
|
self.noiseLfsr = 0x7FFF
|
||||||
self.noiseClock = 0
|
self.noiseClock = 0
|
||||||
@@ -566,8 +591,7 @@ function Channel:sampleNoise(parameter)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- LuaGB: instantaneous inverted LFSR LSB (high when bit0 == 0)
|
return bit.band(self.noiseLfsr, 1) == 0 and 1 or 0
|
||||||
return bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function sweepCalculation(register, sweep)
|
local function sweepCalculation(register, sweep)
|
||||||
@@ -611,21 +635,51 @@ end
|
|||||||
function Channel:sample()
|
function Channel:sample()
|
||||||
while not self.ended
|
while not self.ended
|
||||||
and (not self.event or self.event.sample >= self.event.samples) do
|
and (not self.event or self.event.sample >= self.event.samples) do
|
||||||
|
local prev = self.event
|
||||||
self.event = self:nextEvent()
|
self.event = self:nextEvent()
|
||||||
self.phase = 0
|
self.phase = 0
|
||||||
|
if self.event and self.event.drum then
|
||||||
|
self.drumTail = nil
|
||||||
|
self:resetNoise()
|
||||||
|
elseif prev and prev.drum and prev.sample < drumAudioEnd(prev.drum) then
|
||||||
|
-- ..(audio/engine_1.asm ln 197)
|
||||||
|
self.drumTail = prev
|
||||||
|
elseif not (self.event and self.event.silence and self.drumTail) then
|
||||||
|
self.drumTail = nil
|
||||||
self:resetNoise()
|
self:resetNoise()
|
||||||
end
|
end
|
||||||
|
end
|
||||||
local event = self.event
|
local event = self.event
|
||||||
if not event then return 0 end
|
local gain = channelVolume[self.hardware] or 1
|
||||||
|
if not event then
|
||||||
|
local tail = self.drumTail
|
||||||
|
if not tail then return 0 end
|
||||||
|
local sampleIndex = tail.sample
|
||||||
|
tail.sample = sampleIndex + 1
|
||||||
|
if sampleIndex >= drumAudioEnd(tail.drum) then
|
||||||
|
self.drumTail = nil
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
return self:sampleDrum(tail, sampleIndex) * gain
|
||||||
|
end
|
||||||
local sampleIndex = event.sample
|
local sampleIndex = event.sample
|
||||||
event.elapsed = sampleIndex / SAMPLE_RATE
|
event.elapsed = sampleIndex / SAMPLE_RATE
|
||||||
event.sample = sampleIndex + 1
|
event.sample = sampleIndex + 1
|
||||||
if event.silence then return 0 end
|
if event.silence then
|
||||||
|
local tail = self.drumTail
|
||||||
local gain = channelVolume[self.hardware] or 1
|
if not tail then return 0 end
|
||||||
|
local tailIndex = tail.sample
|
||||||
|
tail.sample = tailIndex + 1
|
||||||
|
if tailIndex >= drumAudioEnd(tail.drum) then
|
||||||
|
self.drumTail = nil
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
return self:sampleDrum(tail, tailIndex) * gain
|
||||||
|
end
|
||||||
if event.drum then
|
if event.drum then
|
||||||
return self:sampleDrum(event, sampleIndex) * gain
|
return self:sampleDrum(event, sampleIndex) * gain
|
||||||
end
|
end
|
||||||
|
self.drumTail = nil
|
||||||
local volume = envelopeVolume(
|
local volume = envelopeVolume(
|
||||||
event.volume or 0, event.fade or 0, event.elapsed)
|
event.volume or 0, event.fade or 0, event.elapsed)
|
||||||
if event.noise then
|
if event.noise then
|
||||||
@@ -665,7 +719,8 @@ function Channel:sample()
|
|||||||
-- a def-local program may omit its wave table entirely
|
-- a def-local program may omit its wave table entirely
|
||||||
if not wave then return 0 end
|
if not wave then return 0 end
|
||||||
local index = math.min(32, math.floor(phase * 32) + 1)
|
local index = math.min(32, math.floor(phase * 32) + 1)
|
||||||
return wave[index] * event.waveLevel * gain
|
local nibble = math.max(0, math.min(15, wave[index] * 8 + 8))
|
||||||
|
return (nibble / 15) * event.waveLevel * gain
|
||||||
end
|
end
|
||||||
local duty = event.duty
|
local duty = event.duty
|
||||||
if type(duty) == "table" then
|
if type(duty) == "table" then
|
||||||
@@ -674,7 +729,7 @@ function Channel:sample()
|
|||||||
local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2]
|
local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2]
|
||||||
local step = math.floor(phase * 8) % 8
|
local step = math.floor(phase * 8) % 8
|
||||||
if pattern[step + 1] == 0 then
|
if pattern[step + 1] == 0 then
|
||||||
return -volume / 15 * gain
|
return 0
|
||||||
end
|
end
|
||||||
return volume / 15 * gain
|
return volume / 15 * gain
|
||||||
end
|
end
|
||||||
@@ -685,7 +740,7 @@ Engine.__index = Engine
|
|||||||
function Engine:noiseInstrument(number)
|
function Engine:noiseInstrument(number)
|
||||||
-- a def-local drum wins over the ROM engine's table for that id
|
-- a def-local drum wins over the ROM engine's table for that id
|
||||||
local custom = self.customDrums and self.customDrums[number]
|
local custom = self.customDrums and self.customDrums[number]
|
||||||
if custom then return custom end
|
if custom then return extendDrumEnvelope(custom) end
|
||||||
local cached = self.noiseInstruments[number]
|
local cached = self.noiseInstruments[number]
|
||||||
if cached then return cached end
|
if cached then return cached end
|
||||||
|
|
||||||
@@ -718,6 +773,7 @@ function Engine:noiseInstrument(number)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
extendDrumEnvelope(segments)
|
||||||
self.noiseInstruments[number] = segments
|
self.noiseInstruments[number] = segments
|
||||||
return segments
|
return segments
|
||||||
end
|
end
|
||||||
@@ -792,6 +848,8 @@ function Engine.new(data, header, options)
|
|||||||
customDrums = chip and chip.drums or nil,
|
customDrums = chip and chip.drums or nil,
|
||||||
noiseInstruments = {},
|
noiseInstruments = {},
|
||||||
channels = {},
|
channels = {},
|
||||||
|
hpfCap = 0, hpfCapLeft = 0, hpfCapRight = 0,
|
||||||
|
lpf = 0, lpfLeft = 0, lpfRight = 0,
|
||||||
}, Engine)
|
}, Engine)
|
||||||
-- header.tempo: the Music_*AlternateTempo override Music.play stamps onto
|
-- header.tempo: the Music_*AlternateTempo override Music.play stamps onto
|
||||||
-- a copy of the song def (audio/alternate_tempo.asm) (#847)
|
-- a copy of the song def (audio/alternate_tempo.asm) (#847)
|
||||||
@@ -826,10 +884,20 @@ function Engine:finished()
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function analogOut(engine, input, hpfField, lpfField)
|
||||||
|
local cap = engine[hpfField]
|
||||||
|
local hp = input - cap
|
||||||
|
engine[hpfField] = input - hp * HPF_CHARGE
|
||||||
|
local prev = engine[lpfField]
|
||||||
|
local lp = prev + LPF_ALPHA * (hp - prev)
|
||||||
|
engine[lpfField] = lp
|
||||||
|
return math.max(-1, math.min(1, lp * MIX_SCALE))
|
||||||
|
end
|
||||||
|
|
||||||
function Engine:sample()
|
function Engine:sample()
|
||||||
local value = 0
|
local value = 0
|
||||||
for _, channel in ipairs(self.channels) do value = value + channel:sample() end
|
for _, channel in ipairs(self.channels) do value = value + channel:sample() end
|
||||||
return math.max(-1, math.min(1, value / 4))
|
return analogOut(self, value, "hpfCap", "lpf")
|
||||||
end
|
end
|
||||||
|
|
||||||
function Engine:sampleStereo()
|
function Engine:sampleStereo()
|
||||||
@@ -840,8 +908,8 @@ function Engine:sampleStereo()
|
|||||||
if not event or event.panLeft ~= false then left = left + value end
|
if not event or event.panLeft ~= false then left = left + value end
|
||||||
if not event or event.panRight ~= false then right = right + value end
|
if not event or event.panRight ~= false then right = right + value end
|
||||||
end
|
end
|
||||||
return math.max(-1, math.min(1, left / 4)),
|
return analogOut(self, left, "hpfCapLeft", "lpfLeft"),
|
||||||
math.max(-1, math.min(1, right / 4))
|
analogOut(self, right, "hpfCapRight", "lpfRight")
|
||||||
end
|
end
|
||||||
|
|
||||||
function Engine:sampleChannel(number)
|
function Engine:sampleChannel(number)
|
||||||
@@ -850,7 +918,7 @@ function Engine:sampleChannel(number)
|
|||||||
local value = channel:sample()
|
local value = channel:sample()
|
||||||
if channel.number == number then selected = value end
|
if channel.number == number then selected = value end
|
||||||
end
|
end
|
||||||
return math.max(-1, math.min(1, selected / 4))
|
return analogOut(self, selected, "hpfCap", "lpf")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
|
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
|
||||||
@@ -870,22 +938,7 @@ local function soundData(engine, samples, channels)
|
|||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Render a one-shot effect (SFX/cry) to a two-channel SoundData, or nil when
|
|
||||||
-- it is too short to be audible. The caller wraps it in a static
|
|
||||||
-- love.audio.Source (a playback concern, hence not done here).
|
|
||||||
--
|
|
||||||
-- The synthesis is mono (one summed value per frame, unlike the music path's
|
|
||||||
-- sampleStereo), but the buffer is written stereo on purpose: OpenAL only
|
|
||||||
-- spatializes 1-channel Sources, and a Source left at the default (0,0,0)
|
|
||||||
-- position, exactly where the listener sits, is rendered as an ambient sound
|
|
||||||
-- spread over EVERY output channel the device exposes at gains that differ
|
|
||||||
-- from the front pair. On an interface with more than two outputs that put
|
|
||||||
-- the SFX on outputs 5+6 as well, while the 2-channel music source
|
|
||||||
-- (ChipAudio.playMusic) stayed on 1+2 (#626). Multi-channel buffers skip
|
|
||||||
-- spatialization entirely and map onto the front pair, so duplicating the
|
|
||||||
-- sample costs one buffer's memory and makes effects route exactly like
|
|
||||||
-- music. Deliberately not sampleStereo: that honors the NR51 panning byte
|
|
||||||
-- and would newly hard-pan any effect whose header issues command 0xEE.
|
|
||||||
local function renderEffectData(data, header, options)
|
local function renderEffectData(data, header, options)
|
||||||
if not header then return nil end
|
if not header then return nil end
|
||||||
options = options or {}
|
options = options or {}
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ function Data:applyVersionedFieldData()
|
|||||||
-- Yellow caches carry the wrong demo species too. The fixed import
|
-- Yellow caches carry the wrong demo species too. The fixed import
|
||||||
-- manifest below stamps RATTATA for fresh imports.
|
-- manifest below stamps RATTATA for fresh imports.
|
||||||
self.field.oldManBattle = { species = "RATTATA", level = 5 }
|
self.field.oldManBattle = { species = "RATTATA", level = 5 }
|
||||||
|
-- The Oak-speech show-off mon is the player's Pikachu in Yellow
|
||||||
|
-- (engine/battle/core.asm BATTLE_TYPE_PIKACHU / the ProfOak demo)
|
||||||
|
-- but caches imported before the manifest carried demoSpecies fell
|
||||||
|
-- back to Red's NIDORINO (#915). The fixed import manifest below
|
||||||
|
-- stamps PIKACHU for fresh imports; fill it here for stale caches.
|
||||||
|
local oakSpeech = self.field.oakSpeech
|
||||||
|
if type(oakSpeech) == "table" and not oakSpeech.demoSpecies then
|
||||||
|
oakSpeech.demoSpecies = "PIKACHU"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+82
-9
@@ -221,24 +221,44 @@ function Game:step(dt)
|
|||||||
-- it on its own real-time 60Hz accumulator instead.
|
-- it on its own real-time 60Hz accumulator instead.
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The per-category (RFC 0007) save.options multiplier for whichever of
|
||||||
|
-- "battle"/"overworld"/"menu" Game.speedCategoryInStack says is active
|
||||||
|
-- right now. This is the "vanilla" the core.logic_speed hook wraps below
|
||||||
|
-- -- Game:logicSpeed calls it AFTER the link and speedOverride checks, so
|
||||||
|
-- neither a mod nor the category resolution ever has a seam to defeat them.
|
||||||
|
function Game:_resolveLogicSpeed()
|
||||||
|
local GameSpeed = require("src.core.GameSpeed")
|
||||||
|
local category = Game.speedCategoryInStack(self.stack)
|
||||||
|
local key = GameSpeed.optionKey(category)
|
||||||
|
local opts = self.save and self.save.options
|
||||||
|
return GameSpeed.clamp(opts and opts[key] or GameSpeed.DEFAULT)
|
||||||
|
end
|
||||||
|
|
||||||
-- The logic multiplier for this frame. Read live rather than cached so the
|
-- The logic multiplier for this frame. Read live rather than cached so the
|
||||||
-- Options row takes effect immediately; speedOverride is the --speed /
|
-- Options rows take effect immediately; speedOverride is the --speed /
|
||||||
-- POKEPORT_SPEED run argument, which wins over the saved option so a bot
|
-- POKEPORT_SPEED run argument, which wins over the saved option so a bot
|
||||||
-- or screenshot run does not depend on whatever the player last chose.
|
-- or screenshot run does not depend on whatever the player last chose.
|
||||||
function Game:logicSpeed()
|
function Game:logicSpeed()
|
||||||
local GameSpeed = require("src.core.GameSpeed")
|
local GameSpeed = require("src.core.GameSpeed")
|
||||||
-- Link play is always 1X on both machines, and this wins over every other
|
-- Link play is always 1X on both machines, and this wins over every other
|
||||||
-- source including POKEPORT_SPEED. Fast-forward multiplies the logic
|
-- source including POKEPORT_SPEED and every per-category option.
|
||||||
-- clock, so a peer at 10X burned a tournament shot clock ten times faster
|
-- Fast-forward multiplies the logic clock, so a peer at 10X burned a
|
||||||
-- than the opponent it is racing, and drove its own animation/message
|
-- tournament shot clock ten times faster than the opponent it is racing,
|
||||||
-- queue at a different rate than the peer it is locked to. Nothing about
|
-- and drove its own animation/message queue at a different rate than the
|
||||||
-- a match should depend on what either player set this to.
|
-- peer it is locked to. Nothing about a match should depend on what
|
||||||
|
-- either player set this to -- checked here, before the core.logic_speed
|
||||||
|
-- hook ever runs, so a mod cannot defeat it either.
|
||||||
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
|
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
|
||||||
return 1
|
return 1
|
||||||
end
|
end
|
||||||
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
||||||
local opts = self.save and self.save.options
|
-- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's
|
||||||
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
|
-- core.logic_speed hook can return anything (0, negative, nil, NaN) and
|
||||||
|
-- Hooks:call only guards against a hook that throws, not one that
|
||||||
|
-- returns a bad value, so an unclamped result would flow straight into
|
||||||
|
-- the FixedStep accumulator math below and freeze or destabilize logic.
|
||||||
|
return GameSpeed.clamp(ModRuntime.call("core.logic_speed",
|
||||||
|
function(g) return g:_resolveLogicSpeed() end, self))
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:update(dt)
|
function Game:update(dt)
|
||||||
@@ -337,6 +357,28 @@ function Game.wideBattleInStack(stack)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Which of "battle"/"overworld"/"menu" per-category GAME SPEED (RFC 0007)
|
||||||
|
-- applies right now. Whole-stack, the same idiom as fillScaleInStack/
|
||||||
|
-- wideBattleInStack above: an overlay with neither marker (PartyMenu,
|
||||||
|
-- ChoiceBox, a NamingScreen, a text box) is transparent to the walk and
|
||||||
|
-- inherits whatever is under it, making the category a property of the
|
||||||
|
-- STACK POSITION the overlay sits over, not of the overlay itself. A
|
||||||
|
-- scripted sequence (script.started/ended) never pushes a state of its
|
||||||
|
-- own either -- it runs through the owning overworld/battle state's own
|
||||||
|
-- script runner or message queue -- so it inherits the same way. Nothing
|
||||||
|
-- identifying as either (the title screen, credits, an intro cutscene
|
||||||
|
-- with nothing under it) falls to "menu", the bucket every non-gameplay
|
||||||
|
-- screen gets; see the RFC's Decisions section for the full reasoning.
|
||||||
|
function Game.speedCategoryInStack(stack)
|
||||||
|
local states = stack and stack.states
|
||||||
|
for i = #(states or {}), 1, -1 do
|
||||||
|
local state = states[i]
|
||||||
|
if state and state.isBattle then return "battle" end
|
||||||
|
if state and state.isOverworld then return "overworld" end
|
||||||
|
end
|
||||||
|
return "menu"
|
||||||
|
end
|
||||||
|
|
||||||
-- Whether a state on the stack composes its own screen and so wants the
|
-- Whether a state on the stack composes its own screen and so wants the
|
||||||
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
|
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
|
||||||
-- everything else here: the text box and YES/NO a battle puts up are states
|
-- everything else here: the text box and YES/NO a battle puts up are states
|
||||||
@@ -556,8 +598,15 @@ function Game:_cycleSpeed(dir)
|
|||||||
or ow.engaging or ow.emote))
|
or ow.engaging or ow.emote))
|
||||||
end
|
end
|
||||||
if busy then return end
|
if busy then return end
|
||||||
|
-- Cycles whichever category Game.speedCategoryInStack says is active
|
||||||
|
-- right now (RFC 0007) -- pressing the hotkey during a battle speeds up
|
||||||
|
-- just the battle, on the overworld just the walk, in a menu just the
|
||||||
|
-- menu. A single physical control that means "speed up whatever I'm
|
||||||
|
-- looking at right now" needs no new UI and matches what a player
|
||||||
|
-- pressing it mid-battle almost certainly wants.
|
||||||
local GameSpeed = require("src.core.GameSpeed")
|
local GameSpeed = require("src.core.GameSpeed")
|
||||||
self.save.options.speed = GameSpeed.cycle(self.save.options.speed, dir)
|
local key = GameSpeed.optionKey(Game.speedCategoryInStack(self.stack))
|
||||||
|
self.save.options[key] = GameSpeed.cycle(self.save.options[key], dir)
|
||||||
self:writeOptions()
|
self:writeOptions()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1130,4 +1179,28 @@ function Game:restoreSave(loaded, recovered)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Reconstruct a previously validated runtime checkpoint without replaying the
|
||||||
|
-- ordinary CONTINUE lifecycle. In particular, map onEnter scripts and
|
||||||
|
-- save.loading/save.loaded events must not run a second time. Validation,
|
||||||
|
-- identity checks and transactional rollback live in Checkpoint.lua.
|
||||||
|
function Game:restoreCheckpointSave(loaded)
|
||||||
|
self.save = loaded
|
||||||
|
self:adoptSave(loaded)
|
||||||
|
while self.stack:top() do self.stack:pop() end
|
||||||
|
self.stack:push(self.overworld, loaded.player.map,
|
||||||
|
loaded.player.x, loaded.player.y, loaded.player.facing,
|
||||||
|
{ via = "checkpoint", checkpoint = true })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Install a reconstructed battle without calling BattleState:enter(), whose
|
||||||
|
-- transition, intro queues and battle-start side effects already happened in
|
||||||
|
-- the checkpointed timeline.
|
||||||
|
function Game:restoreCheckpointBattle(battle)
|
||||||
|
if self.stack:top() ~= self.overworld then
|
||||||
|
error("battle checkpoint requires a reconstructed overworld base", 0)
|
||||||
|
end
|
||||||
|
self.stack.states[#self.stack.states + 1] = battle
|
||||||
|
if battle.resumeCheckpoint then battle:resumeCheckpoint() end
|
||||||
|
end
|
||||||
|
|
||||||
return Game
|
return Game
|
||||||
|
|||||||
@@ -51,4 +51,19 @@ function GameSpeed.cycle(v, dir)
|
|||||||
return levels[nextIdx]
|
return levels[nextIdx]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Per-category speed (RFC 0007): overworld walking, battle turns and menu
|
||||||
|
-- navigation each cycle their own multiplier instead of one global "speed"
|
||||||
|
-- value. This list is the single source of truth for which categories
|
||||||
|
-- exist and the order the Options rows/save.options keys follow;
|
||||||
|
-- Game.lua's stack-walk (Game.speedCategoryInStack) decides WHICH category
|
||||||
|
-- is active on a given frame, this module only knows the category names.
|
||||||
|
GameSpeed.CATEGORIES = { "overworld", "battle", "menu" }
|
||||||
|
|
||||||
|
-- the save.options field name a category's multiplier lives under, e.g.
|
||||||
|
-- "overworld" -> "speedOverworld". Centralized so Game.lua, OptionsMenu.lua,
|
||||||
|
-- LauncherSettings.lua and the SaveData migration never hand-spell the key.
|
||||||
|
function GameSpeed.optionKey(category)
|
||||||
|
return "speed" .. category:sub(1, 1):upper() .. category:sub(2)
|
||||||
|
end
|
||||||
|
|
||||||
return GameSpeed
|
return GameSpeed
|
||||||
|
|||||||
+41
-42
@@ -1,12 +1,3 @@
|
|||||||
-- Music playback supports compact ROM channel programs synthesized live by
|
|
||||||
-- ChipAudio, def-local chip programs (ChipAsm), and file definitions. The
|
|
||||||
-- branch is chosen per song definition, never by a global import flag, so a
|
|
||||||
-- file-backed song and a chip song coexist in one dataset. Songs with split
|
|
||||||
-- files chain def.file into def.loopFile in Music.update().
|
|
||||||
-- Map themes switch on map change; battles override with the battle
|
|
||||||
-- theme and restore afterwards; riding the bike overrides outdoor map
|
|
||||||
-- themes with the bike song until dismount.
|
|
||||||
|
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
@@ -14,20 +5,10 @@ local Music = {}
|
|||||||
|
|
||||||
local VOLUME = 0.7
|
local VOLUME = 0.7
|
||||||
|
|
||||||
-- port additions driven by OptionsMenu / save.options: musicVol scales
|
|
||||||
-- VOLUME (0-7 level like the GB's NR50 master volume) and musicFilter
|
|
||||||
-- low-passes the song. Each filter step keeps 40% of the previous
|
|
||||||
-- step's treble (highgain 0.4^level), so 2X/3X are the 1X filter
|
|
||||||
-- applied twice/three times over.
|
|
||||||
local volumeScale = 1
|
local volumeScale = 1
|
||||||
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
|
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
|
||||||
local filterLevel = 0
|
local filterLevel = 0
|
||||||
|
|
||||||
-- Forward-declared here so applyVolume (below) closes over the real playback
|
|
||||||
-- state rather than a nil global: the table literal is assigned further down,
|
|
||||||
-- but a `local state = {}` there would leave every reference above it bound
|
|
||||||
-- to the global `state`. Before this, registering the `music.volume` mod
|
|
||||||
-- hook crashed applyVolume on `state.current` (a nil index).
|
|
||||||
local state
|
local state
|
||||||
|
|
||||||
local function applyVolume(src)
|
local function applyVolume(src)
|
||||||
@@ -115,11 +96,6 @@ function Music.duckForFanfare(src)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Overworld themes where the bike can be ridden (outdoor maps plus the
|
|
||||||
-- caves/dungeons where gen-1 allows cycling). Indoor themes such as
|
|
||||||
-- Pokecenter/Gym/SilphCo never get replaced by the bike theme.
|
|
||||||
-- data.audio.outdoorSongs supersedes this; the copy stays as the fallback
|
|
||||||
-- for caches built before the importer wrote the table.
|
|
||||||
local OUTDOOR = {
|
local OUTDOOR = {
|
||||||
Music_PalletTown = true,
|
Music_PalletTown = true,
|
||||||
Music_Cities1 = true,
|
Music_Cities1 = true,
|
||||||
@@ -218,6 +194,7 @@ end
|
|||||||
-- the single choke point every song choice passes through, so one hook
|
-- the single choke point every song choice passes through, so one hook
|
||||||
-- covers map themes, battle themes, jingles and scene music
|
-- covers map themes, battle themes, jingles and scene music
|
||||||
local function selectSong(song, ctx)
|
local function selectSong(song, ctx)
|
||||||
|
if ctx and ctx.selected then return song end
|
||||||
if not Runtime.wantsHook("music.select") then return song end
|
if not Runtime.wantsHook("music.select") then return song end
|
||||||
return Runtime.call("music.select", function(chosen) return chosen end, song, {
|
return Runtime.call("music.select", function(chosen) return chosen end, song, {
|
||||||
reason = ctx and ctx.reason or "direct",
|
reason = ctx and ctx.reason or "direct",
|
||||||
@@ -234,17 +211,28 @@ end
|
|||||||
function Music.play(data, song, loop, ctx)
|
function Music.play(data, song, loop, ctx)
|
||||||
if not song then return end
|
if not song then return end
|
||||||
if not love.audio then return end -- headless test stub
|
if not love.audio then return end -- headless test stub
|
||||||
|
ctx = ctx or {}
|
||||||
song = selectSong(song, ctx)
|
song = selectSong(song, ctx)
|
||||||
-- ctx.tempo is a Music_*AlternateTempo cue (audio/alternate_tempo.asm):
|
|
||||||
-- the same song restarted with channel 1 re-pointed at a stub whose only
|
|
||||||
-- difference is its `tempo`, so the same label at a different tempo is a
|
|
||||||
-- different cue and must not be deduped away (#847)
|
|
||||||
local tempo = ctx and ctx.tempo or nil
|
local tempo = ctx and ctx.tempo or nil
|
||||||
-- a hook may silence the cue outright, or swap in a label the dedupe
|
-- a hook may silence the cue outright, or swap in a label the dedupe
|
||||||
-- below has to compare against
|
-- below has to compare against
|
||||||
if not song or (song == state.current and tempo == state.tempo) then return end
|
if not song or (song == state.current and tempo == state.tempo) then return end
|
||||||
local def = songDef(data, song)
|
local def = songDef(data, song)
|
||||||
if not def or state.failed[song] then return end
|
if not def or state.failed[song] then return end
|
||||||
|
|
||||||
|
if ctx.fade and state.source then
|
||||||
|
local queued = {}
|
||||||
|
for key, value in pairs(ctx) do queued[key] = value end
|
||||||
|
queued.fade, queued.selected = nil, true
|
||||||
|
local pending = { data = data, song = song, loop = loop, ctx = queued }
|
||||||
|
if state.fade then
|
||||||
|
state.fade.pending = pending
|
||||||
|
else
|
||||||
|
Music.fadeOut(ctx.fade, pending)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
if tempo then
|
if tempo then
|
||||||
-- shallow copy: the registry def is shared, only this playback is slowed
|
-- shallow copy: the registry def is shared, only this playback is slowed
|
||||||
local slowed = {}
|
local slowed = {}
|
||||||
@@ -317,24 +305,26 @@ function Music.reload()
|
|||||||
Music.stop()
|
Music.stop()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Ramp the current song's volume to silence, then stop it, mirroring the
|
function Music.fadeOut(control, pending)
|
||||||
-- Game Boy's audio fade-out (home/fade_audio.asm FadeOutAudio +
|
if not state.source then
|
||||||
-- home/audio.asm's .fadeOut): rAUDVOL's master volume steps 7 -> 0 in
|
Music.stop()
|
||||||
-- integer levels, one level every `control` frames, and the music stops
|
if pending then
|
||||||
-- when it reaches 0. `control` is the wAudioFadeOutControl value the ROM
|
Music.play(pending.data, pending.song, pending.loop, pending.ctx)
|
||||||
-- writes (oak_speech.asm sets 10 at the shrink beat -> 7*10 = 70 frames
|
end
|
||||||
-- to silence). Ticked once per frame from Music.update().
|
return
|
||||||
function Music.fadeOut(control)
|
end
|
||||||
if not state.source then Music.stop() return end
|
|
||||||
control = math.max(1, control or 10)
|
control = math.max(1, control or 10)
|
||||||
state.fade = {
|
state.fade = {
|
||||||
control = control,
|
control = control,
|
||||||
counter = control, -- frames until the next volume step
|
counter = control, -- frames until the next volume step
|
||||||
level = 7, -- current master-volume level (rAUDVOL nibble)
|
level = 7, -- current master-volume level (rAUDVOL nibble)
|
||||||
from = VOLUME * volumeScale, -- level-7 (full) source volume
|
from = VOLUME * volumeScale, -- level-7 (full) source volume
|
||||||
|
pending = pending,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Music.MAP_FADE = 10
|
||||||
|
|
||||||
-- the song a map should currently play, honoring the bike/surf overrides
|
-- the song a map should currently play, honoring the bike/surf overrides
|
||||||
local function effectiveMapSong(data, song)
|
local function effectiveMapSong(data, song)
|
||||||
if not song or not outdoorSongs(data)[song] then return song end
|
if not song or not outdoorSongs(data)[song] then return song end
|
||||||
@@ -351,14 +341,17 @@ end
|
|||||||
|
|
||||||
-- overworld map theme; onBike/surfing override outdoor themes with the
|
-- overworld map theme; onBike/surfing override outdoor themes with the
|
||||||
-- bike/surf songs and restore the map theme when they end
|
-- bike/surf songs and restore the map theme when they end
|
||||||
function Music.playMap(data, mapId, onBike, surfing)
|
function Music.playMap(data, mapId, onBike, surfing, fade)
|
||||||
local song = data and data.audio and data.audio.mapSongs
|
local song = data and data.audio and data.audio.mapSongs
|
||||||
and mapId and data.audio.mapSongs[mapId] or nil
|
and mapId and data.audio.mapSongs[mapId] or nil
|
||||||
state.mapSong = song
|
state.mapSong = song
|
||||||
state.onBike = not not onBike
|
state.onBike = not not onBike
|
||||||
state.surfing = not not surfing
|
state.surfing = not not surfing
|
||||||
local play = effectiveMapSong(data, song)
|
local play = effectiveMapSong(data, song)
|
||||||
if play then Music.play(data, play, nil, { reason = "map", mapId = mapId }) end
|
if play then
|
||||||
|
Music.play(data, play, nil,
|
||||||
|
{ reason = "map", mapId = mapId, fade = fade })
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- toggle the surf override mid-map (starting/ending a surf)
|
-- toggle the surf override mid-map (starting/ending a surf)
|
||||||
@@ -368,11 +361,12 @@ function Music.setSurfing(data, surfing)
|
|||||||
if play then Music.play(data, play, nil, { reason = "map" }) end
|
if play then Music.play(data, play, nil, { reason = "map" }) end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- battle themes; kind = "wild"|"trainer"|"gym"|"final"
|
-- battle themes; kind = "wild"|"trainer"|"gym"|"final". `song`, when
|
||||||
function Music.playBattle(data, kind, trainerId)
|
-- given, overrides the kind's default -- a mod-set trainer battleTheme.
|
||||||
|
function Music.playBattle(data, kind, trainerId, song)
|
||||||
local b = data.audio and data.audio.battle
|
local b = data.audio and data.audio.battle
|
||||||
if b then
|
if b then
|
||||||
Music.play(data, b[kind] or b.wild, nil,
|
Music.play(data, song or b[kind] or b.wild, nil,
|
||||||
{ reason = "battle", kind = kind, trainerId = trainerId })
|
{ reason = "battle", kind = kind, trainerId = trainerId })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -474,8 +468,13 @@ function Music.update(data)
|
|||||||
f.counter = f.control
|
f.counter = f.control
|
||||||
f.level = f.level - 1
|
f.level = f.level - 1
|
||||||
if f.level <= 0 then
|
if f.level <= 0 then
|
||||||
|
-- ..(home/fade_audio.asm ln 36)
|
||||||
state.fade = nil
|
state.fade = nil
|
||||||
|
local pending = f.pending
|
||||||
Music.stop()
|
Music.stop()
|
||||||
|
if pending then
|
||||||
|
Music.play(pending.data, pending.song, pending.loop, pending.ctx)
|
||||||
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local vol = f.from * f.level / 7
|
local vol = f.from * f.level / 7
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Generic process-lifecycle mod hooks so a platform-specific launcher
|
||||||
|
-- integration (a native shell embedding this engine, e.g. wrapping the
|
||||||
|
-- window in a platform UI) can live entirely in a mod instead of
|
||||||
|
-- hand-patching main.lua, which every other engine change also touches.
|
||||||
|
-- See docs/modding.md's "Process-lifecycle hooks" section.
|
||||||
|
local ModRuntime = require("src.mods.Runtime")
|
||||||
|
|
||||||
|
local PlatformHooks = {}
|
||||||
|
|
||||||
|
function PlatformHooks.update(game, dt)
|
||||||
|
return ModRuntime.call("core.update", function(g, d) g:update(d) end, game, dt)
|
||||||
|
end
|
||||||
|
|
||||||
|
function PlatformHooks.quitToLauncher(vanilla)
|
||||||
|
return ModRuntime.call("core.quit_to_launcher", vanilla)
|
||||||
|
end
|
||||||
|
|
||||||
|
return PlatformHooks
|
||||||
+170
-7
@@ -216,6 +216,13 @@ local function persistFs(fs)
|
|||||||
return SaveData.portableFs() or fs or (love and love.filesystem)
|
return SaveData.portableFs() or fs or (love and love.filesystem)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Engine-owned persistence routing for subsystems that must follow the same
|
||||||
|
-- standard/portable root as saves without exposing raw filesystem access to a
|
||||||
|
-- mod. An explicitly injected headless filesystem still wins for tests.
|
||||||
|
function SaveData.persistenceFs(fs)
|
||||||
|
return persistFs(fs)
|
||||||
|
end
|
||||||
|
|
||||||
-- Port + original Options menu defaults. Missing keys on load are filled
|
-- Port + original Options menu defaults. Missing keys on load are filled
|
||||||
-- from this table so old options.lua files stay compatible.
|
-- from this table so old options.lua files stay compatible.
|
||||||
function SaveData.defaultOptions()
|
function SaveData.defaultOptions()
|
||||||
@@ -254,8 +261,12 @@ function SaveData.defaultOptions()
|
|||||||
-- (the PIKACHU VOL row appears only on Yellow; see Sound.lua)
|
-- (the PIKACHU VOL row appears only on Yellow; see Sound.lua)
|
||||||
pikaVol = 7,
|
pikaVol = 7,
|
||||||
musicFilter = 0,
|
musicFilter = 0,
|
||||||
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
|
-- Per-category logic fast-forward multiplier (RFC 0007); audio is
|
||||||
speed = 1,
|
-- unaffected (GameSpeed.lua). Superseded from a single "speed" field --
|
||||||
|
-- mergeOptions migrates an old save's value into all three below.
|
||||||
|
speedOverworld = 1,
|
||||||
|
speedBattle = 1,
|
||||||
|
speedMenu = 1,
|
||||||
-- port display options (OptionsMenu / hotkeys 2/3/4/5)
|
-- port display options (OptionsMenu / hotkeys 2/3/4/5)
|
||||||
colors = "gbc",
|
colors = "gbc",
|
||||||
tilt = 0,
|
tilt = 0,
|
||||||
@@ -332,6 +343,19 @@ function SaveData.mergeOptions(loaded)
|
|||||||
for k, v in pairs(loaded) do
|
for k, v in pairs(loaded) do
|
||||||
opts[k] = v
|
opts[k] = v
|
||||||
end
|
end
|
||||||
|
-- RFC 0007 migration: a save from before per-category GAME SPEED still
|
||||||
|
-- has a single "speed" and none of the three new fields, so seed all
|
||||||
|
-- three from it -- an existing player's fast-forward preference
|
||||||
|
-- carries over instead of two of the three categories silently
|
||||||
|
-- resetting to 1X. "speed" is dropped on the way out (not kept as a
|
||||||
|
-- stale alias), so a re-save never re-triggers this migration.
|
||||||
|
if loaded.speed ~= nil and loaded.speedOverworld == nil
|
||||||
|
and loaded.speedBattle == nil and loaded.speedMenu == nil then
|
||||||
|
opts.speedOverworld = loaded.speed
|
||||||
|
opts.speedBattle = loaded.speed
|
||||||
|
opts.speedMenu = loaded.speed
|
||||||
|
end
|
||||||
|
opts.speed = nil
|
||||||
end
|
end
|
||||||
return opts
|
return opts
|
||||||
end
|
end
|
||||||
@@ -351,6 +375,22 @@ local function readTable(fs, name)
|
|||||||
return SaveSerializer.decode(body)
|
return SaveSerializer.decode(body)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Deep-copy a value folded in from the on-disk decode so the returned
|
||||||
|
-- options table never aliases the file's nested tables (SaveData must not
|
||||||
|
-- depend on src/mods/Merge.lua for this). Options data is plain tables of
|
||||||
|
-- strings/numbers/booleans/tables, so a cycle guard is belt-and-braces.
|
||||||
|
local function deepCopy(v, seen)
|
||||||
|
if type(v) ~= "table" then return v end
|
||||||
|
seen = seen or {}
|
||||||
|
if seen[v] then return seen[v] end
|
||||||
|
local copy = {}
|
||||||
|
seen[v] = copy
|
||||||
|
for k, val in pairs(v) do
|
||||||
|
copy[deepCopy(k, seen)] = deepCopy(val, seen)
|
||||||
|
end
|
||||||
|
return copy
|
||||||
|
end
|
||||||
|
|
||||||
-- the stub filesystem some headless harnesses inject has no remove; a
|
-- the stub filesystem some headless harnesses inject has no remove; a
|
||||||
-- lingering tmp/bak there is harmless
|
-- lingering tmp/bak there is harmless
|
||||||
local function remove(fs, name)
|
local function remove(fs, name)
|
||||||
@@ -364,13 +404,48 @@ end
|
|||||||
-- options round-trip headless (no love global).
|
-- options round-trip headless (no love global).
|
||||||
function SaveData.saveOptions(opts, fs)
|
function SaveData.saveOptions(opts, fs)
|
||||||
fs = persistFs(fs)
|
fs = persistFs(fs)
|
||||||
|
-- #932: options.lua is a WHOLE-FILE rewrite, so a caller that hands over a
|
||||||
|
-- PARTIAL table (just the keys it changed) would silently drop every key it
|
||||||
|
-- does not mention -- launcher-only keys like lastVersion, and keys the
|
||||||
|
-- launcher set (battleBg, tilt...) all fall back to defaults. Read the
|
||||||
|
-- on-disk file FIRST and fold caller-absent values underneath, so a delta
|
||||||
|
-- write changes only what it names.
|
||||||
|
--
|
||||||
|
-- A table holding EVERY defaultOptions key is a full snapshot
|
||||||
|
-- (loadOptions() results, game.save.options, the RESET REBINDS /
|
||||||
|
-- activeProfile-drop paths) and stays authoritative: its absent keys are
|
||||||
|
-- deliberate deletions, so nothing folds for it. Partial tables get every
|
||||||
|
-- on-disk key they do not provide folded in (deep-copied so the caller's
|
||||||
|
-- table is never aliased). This is the reconciling rule: bindings and
|
||||||
|
-- activeProfile -- not defaultOptions members -- can be deleted by their
|
||||||
|
-- sites precisely because those sites always write full tables.
|
||||||
|
local onDisk = readTable(fs, OPTIONS_FILENAME)
|
||||||
|
local isFull = type(opts) == "table"
|
||||||
|
if isFull then
|
||||||
|
for k in pairs(SaveData.defaultOptions()) do
|
||||||
|
if opts[k] == nil then isFull = false break end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not isFull then
|
||||||
|
local merged = {}
|
||||||
|
if type(opts) == "table" then
|
||||||
|
for k, v in pairs(opts) do merged[k] = v end
|
||||||
|
end
|
||||||
|
if type(onDisk) == "table" then
|
||||||
|
for k, v in pairs(onDisk) do
|
||||||
|
if k ~= "modOptions" and merged[k] == nil then
|
||||||
|
merged[k] = deepCopy(v)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
opts = merged
|
||||||
|
end
|
||||||
opts = SaveData.mergeOptions(opts)
|
opts = SaveData.mergeOptions(opts)
|
||||||
-- modOptions is per-mod nested state: fold the on-disk sub-tree
|
-- modOptions is per-mod nested state: fold the on-disk sub-tree
|
||||||
-- underneath (newest value winning per key) so one caller's partial
|
-- underneath (newest value winning per key) so one caller's partial
|
||||||
-- write cannot clobber another mod's persisted keys. Every other
|
-- write cannot clobber another mod's persisted keys. Every other
|
||||||
-- option stays on the shallow path.
|
-- option stays on the shallow path.
|
||||||
local onDisk = readTable(fs, OPTIONS_FILENAME)
|
if type(onDisk) == "table" and type(onDisk.modOptions) == "table" then
|
||||||
if onDisk and type(onDisk.modOptions) == "table" then
|
|
||||||
local merged = {}
|
local merged = {}
|
||||||
for modId, bucket in pairs(onDisk.modOptions) do
|
for modId, bucket in pairs(onDisk.modOptions) do
|
||||||
merged[modId] = bucket
|
merged[modId] = bucket
|
||||||
@@ -479,6 +554,10 @@ end
|
|||||||
-- working unchanged.
|
-- working unchanged.
|
||||||
local activeSlotCache = {} -- version -> slotId in use, or false when none
|
local activeSlotCache = {} -- version -> slotId in use, or false when none
|
||||||
local slotsChecked = {} -- version -> true once resolved this process
|
local slotsChecked = {} -- version -> true once resolved this process
|
||||||
|
-- At most one New Game can be the live candidate for a first public tool
|
||||||
|
-- request. A single strong reference models that runtime fact without adding
|
||||||
|
-- marker data to the save or retaining abandoned playthrough tables.
|
||||||
|
local freshPlaythrough
|
||||||
|
|
||||||
local function slotDir(version) return "saves/" .. version end
|
local function slotDir(version) return "saves/" .. version end
|
||||||
|
|
||||||
@@ -815,6 +894,77 @@ end
|
|||||||
function SaveData.resetSlotState()
|
function SaveData.resetSlotState()
|
||||||
for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end
|
for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end
|
||||||
for k in pairs(slotsChecked) do slotsChecked[k] = nil end
|
for k in pairs(slotsChecked) do slotsChecked[k] = nil end
|
||||||
|
freshPlaythrough = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- opaque playthrough identity
|
||||||
|
|
||||||
|
-- An id must never perturb the engine's gameplay RNG: savestate tools need
|
||||||
|
-- repeatable random outcomes, and allocating persistence scope is not gameplay.
|
||||||
|
-- Combine wall/process time, a process-local sequence and a fresh table address
|
||||||
|
-- into four hex words. This is an opaque collision-resistant identifier, not a
|
||||||
|
-- secret or a player-visible value.
|
||||||
|
local playthroughSeq = 0
|
||||||
|
|
||||||
|
local function word(n)
|
||||||
|
return math.floor(tonumber(n) or 0) % 4294967296
|
||||||
|
end
|
||||||
|
|
||||||
|
function SaveData.newPlaythroughId()
|
||||||
|
playthroughSeq = playthroughSeq + 1
|
||||||
|
local address = tostring({}):match("0x(%x+)") or "0"
|
||||||
|
local addressLo = tonumber(address:sub(-8), 16) or 0
|
||||||
|
local clock = math.floor((os.clock() or 0) * 1000000)
|
||||||
|
return ("%08x%08x%08x%08x"):format(
|
||||||
|
word(os.time()), word(clock), word(addressLo), word(playthroughSeq))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function playthroughScope(version, injectedFs)
|
||||||
|
version = version or GameVersion.get()
|
||||||
|
local fs = persistFs(injectedFs)
|
||||||
|
ensureVersionSlots(version, fs)
|
||||||
|
return activeSlotCache[version] or "legacy"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function rememberPlaythroughId(save, opts, injectedFs)
|
||||||
|
local meta = type(save) == "table" and save.meta
|
||||||
|
local id = type(meta) == "table" and meta.playthroughId
|
||||||
|
if type(id) ~= "string" or id == "" then return opts, false end
|
||||||
|
local version = save.version or GameVersion.get()
|
||||||
|
local scope = playthroughScope(version, injectedFs)
|
||||||
|
opts = opts or SaveData.loadOptions(injectedFs)
|
||||||
|
opts.playthroughIds = opts.playthroughIds or {}
|
||||||
|
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
||||||
|
local changed = opts.playthroughIds[version][scope] ~= id
|
||||||
|
opts.playthroughIds[version][scope] = id
|
||||||
|
return opts, changed
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Return an existing save identity or give a pre-identity save a stable one.
|
||||||
|
-- Legacy backfill lives in options.lua until the next normal SAVE stamps the id
|
||||||
|
-- into progress, so installing a tool mod never rewrites the player's checkpoint.
|
||||||
|
function SaveData.ensurePlaythroughId(save, injectedFs)
|
||||||
|
if type(save) ~= "table" then return nil end
|
||||||
|
save.meta = type(save.meta) == "table" and save.meta or {}
|
||||||
|
local id = save.meta.playthroughId
|
||||||
|
if type(id) == "string" and id ~= "" then return id end
|
||||||
|
|
||||||
|
local version = save.version or GameVersion.get()
|
||||||
|
local scope = playthroughScope(version, injectedFs)
|
||||||
|
local opts = SaveData.loadOptions(injectedFs)
|
||||||
|
local isFresh = save == freshPlaythrough
|
||||||
|
if isFresh then freshPlaythrough = nil end
|
||||||
|
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
|
||||||
|
id = not isFresh and byVersion and byVersion[scope] or nil
|
||||||
|
if type(id) ~= "string" or id == "" then
|
||||||
|
id = SaveData.newPlaythroughId()
|
||||||
|
opts.playthroughIds = opts.playthroughIds or {}
|
||||||
|
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
||||||
|
opts.playthroughIds[version][scope] = id
|
||||||
|
SaveData.saveOptions(opts, injectedFs)
|
||||||
|
end
|
||||||
|
save.meta.playthroughId = id
|
||||||
|
return id
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ------- meta
|
-- ------- meta
|
||||||
@@ -838,6 +988,7 @@ function SaveData.buildMeta(mods, previous)
|
|||||||
format = Version.saveFormat,
|
format = Version.saveFormat,
|
||||||
engine = Version.engine,
|
engine = Version.engine,
|
||||||
savedAt = os.time(),
|
savedAt = os.time(),
|
||||||
|
playthroughId = type(previous) == "table" and previous.playthroughId or nil,
|
||||||
mods = list,
|
mods = list,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -1048,7 +1199,15 @@ function SaveData.save(data, mods)
|
|||||||
-- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua
|
-- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua
|
||||||
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version)
|
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version)
|
||||||
if data.options then
|
if data.options then
|
||||||
SaveData.saveOptions(data.options)
|
local opts = data.options
|
||||||
|
if data.meta and data.meta.playthroughId then
|
||||||
|
opts = rememberPlaythroughId(data, data.options)
|
||||||
|
end
|
||||||
|
data.options = opts
|
||||||
|
SaveData.saveOptions(opts)
|
||||||
|
elseif data.meta and data.meta.playthroughId then
|
||||||
|
local opts, changed = rememberPlaythroughId(data)
|
||||||
|
if changed then SaveData.saveOptions(opts) end
|
||||||
end
|
end
|
||||||
if mods ~= nil or data.meta == nil then
|
if mods ~= nil or data.meta == nil then
|
||||||
data.meta = SaveData.buildMeta(mods, data.meta)
|
data.meta = SaveData.buildMeta(mods, data.meta)
|
||||||
@@ -1470,8 +1629,12 @@ function SaveData.newGame(boot)
|
|||||||
options = SaveData.loadOptions(),
|
options = SaveData.loadOptions(),
|
||||||
}
|
}
|
||||||
-- a total conversion reshapes the skeleton (spawn, party, money)
|
-- a total conversion reshapes the skeleton (spawn, party, money)
|
||||||
-- before anything reads it; unhooked this returns save unchanged
|
-- before anything reads it; unhooked this returns save unchanged. Keep the
|
||||||
return Runtime.call("save.new_game", function(s) return s end, save)
|
-- "fresh playthrough" marker outside the serialized table so a later tool
|
||||||
|
-- request can distinguish two unsaved New Games sharing one vanilla slot.
|
||||||
|
save = Runtime.call("save.new_game", function(s) return s end, save)
|
||||||
|
freshPlaythrough = save
|
||||||
|
return save
|
||||||
end
|
end
|
||||||
|
|
||||||
return SaveData
|
return SaveData
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ local function serialize(v, indent)
|
|||||||
error("cannot serialize " .. t)
|
error("cannot serialize " .. t)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- LuaJIT 2.1 can lose a just-added nested table entry when a GC step lands
|
||||||
|
-- inside a compiled recursive serialization trace. The symptom is valid input
|
||||||
|
-- becoming `{" followed by only the trailing comma, which then cannot be read
|
||||||
|
-- back. Save encoding is infrequent and I/O-bound, so keep this correctness-
|
||||||
|
-- critical recursion in the interpreter while leaving the game JIT enabled.
|
||||||
|
if jit and jit.off then jit.off(serialize, true) end
|
||||||
|
|
||||||
function SaveSerializer.encode(data)
|
function SaveSerializer.encode(data)
|
||||||
return "return " .. serialize(data) .. "\n"
|
return "return " .. serialize(data) .. "\n"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -214,10 +214,24 @@ local function coreRows(opts, hooks)
|
|||||||
|
|
||||||
local okSpd, GameSpeed = pcall(require, "src.core.GameSpeed")
|
local okSpd, GameSpeed = pcall(require, "src.core.GameSpeed")
|
||||||
if okSpd then
|
if okSpd then
|
||||||
add(Strings("GAME SPEED"),
|
-- Per-category (RFC 0007): overworld/battle/menu each cycle their own
|
||||||
function() return GameSpeed.levelLabel(opts.speed) end,
|
-- multiplier, mirroring OptionsMenu.lua's three rows.
|
||||||
|
add(Strings("OVERWORLD SPEED"),
|
||||||
|
function() return GameSpeed.levelLabel(opts.speedOverworld) end,
|
||||||
function(dir)
|
function(dir)
|
||||||
opts.speed = GameSpeed.cycle(opts.speed, dir)
|
opts.speedOverworld = GameSpeed.cycle(opts.speedOverworld, dir)
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
add(Strings("BATTLE SPEED"),
|
||||||
|
function() return GameSpeed.levelLabel(opts.speedBattle) end,
|
||||||
|
function(dir)
|
||||||
|
opts.speedBattle = GameSpeed.cycle(opts.speedBattle, dir)
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
add(Strings("MENU SPEED"),
|
||||||
|
function() return GameSpeed.levelLabel(opts.speedMenu) end,
|
||||||
|
function(dir)
|
||||||
|
opts.speedMenu = GameSpeed.cycle(opts.speedMenu, dir)
|
||||||
return true
|
return true
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -198,6 +198,12 @@ function RomExtractor:extractTilesets()
|
|||||||
out[constName] = {
|
out[constName] = {
|
||||||
id = constName,
|
id = constName,
|
||||||
source = ("ROM:Tilesets[%d]"):format(index - 1),
|
source = ("ROM:Tilesets[%d]"):format(index - 1),
|
||||||
|
-- The raw Tilesets row, verbatim. A .sav export has to reproduce what
|
||||||
|
-- LoadTilesetHeader (engine/overworld/tilesets.asm) would have left in
|
||||||
|
-- wTilesetBank..wGrassTile, because a Continue never re-runs it -- see
|
||||||
|
-- src/save_convert/MapContext.lua (#889). Byte 12 is the tile
|
||||||
|
-- animation id, which rides in sTileAnimations.
|
||||||
|
header = self.rom:bytes(headers.bank, rowAddress, 12),
|
||||||
image = "assets/generated/tilesets/" .. base .. ".png",
|
image = "assets/generated/tilesets/" .. base .. ".png",
|
||||||
imageWidth = spec.imageWidth,
|
imageWidth = spec.imageWidth,
|
||||||
imageHeight = spec.imageHeight,
|
imageHeight = spec.imageHeight,
|
||||||
@@ -268,6 +274,11 @@ function RomExtractor:extractMaps()
|
|||||||
assert(tilesetId < #tilesets, constName .. ": unknown tileset id")
|
assert(tilesetId < #tilesets, constName .. ": unknown tileset id")
|
||||||
local blockPointer = self.rom:word(header.bank, address + 3)
|
local blockPointer = self.rom:word(header.bank, address + 3)
|
||||||
local connectionFlags = self.rom:byte(header.bank, address + 9)
|
local connectionFlags = self.rom:byte(header.bank, address + 9)
|
||||||
|
-- wCurMapHeader verbatim (tileset, height, width, data/text/script
|
||||||
|
-- pointers, connection flags). A save restores this window instead of
|
||||||
|
-- rebuilding it, so an export has to carry the real bytes (#889).
|
||||||
|
local headerBytes = self.rom:bytes(header.bank, address, 10)
|
||||||
|
local connectionStart = address + 10
|
||||||
address = address + 10
|
address = address + 10
|
||||||
|
|
||||||
local connections = {}
|
local connections = {}
|
||||||
@@ -289,6 +300,8 @@ function RomExtractor:extractMaps()
|
|||||||
end
|
end
|
||||||
assert(bit.band(connectionFlags, 0xF0) == 0,
|
assert(bit.band(connectionFlags, 0xF0) == 0,
|
||||||
constName .. ": unknown connection flags")
|
constName .. ": unknown connection flags")
|
||||||
|
local connectionBytes = self.rom:bytes(
|
||||||
|
header.bank, connectionStart, address - connectionStart)
|
||||||
local objectPointer = self.rom:word(header.bank, address)
|
local objectPointer = self.rom:word(header.bank, address)
|
||||||
local objectAddress = objectPointer
|
local objectAddress = objectPointer
|
||||||
local borderBlock = self.rom:byte(header.bank, objectAddress)
|
local borderBlock = self.rom:byte(header.bank, objectAddress)
|
||||||
@@ -376,6 +389,17 @@ function RomExtractor:extractMaps()
|
|||||||
width = width, height = height, blocks = blocks,
|
width = width, height = height, blocks = blocks,
|
||||||
borderBlock = borderBlock, connections = connections,
|
borderBlock = borderBlock, connections = connections,
|
||||||
warps = warps, signs = signs, objects = objects,
|
warps = warps, signs = signs, objects = objects,
|
||||||
|
-- Raw ROM bytes a .sav export replays through LoadMapHeader's WRAM
|
||||||
|
-- writes (src/save_convert/MapContext.lua, #889). Kept as the original
|
||||||
|
-- bytes rather than re-encoded from the decoded tables above: the
|
||||||
|
-- pointers in them (wCurMapDataPtr, the connection strip src/dest
|
||||||
|
-- addresses, sign text ids) have no equivalent in the port's own model.
|
||||||
|
sram = {
|
||||||
|
header = headerBytes,
|
||||||
|
connections = connectionBytes,
|
||||||
|
objects = self.rom:bytes(
|
||||||
|
header.bank, objectPointer, objectAddress - objectPointer),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
self:tick("Maps", mapIndex, #keys)
|
self:tick("Maps", mapIndex, #keys)
|
||||||
end
|
end
|
||||||
@@ -1732,7 +1756,7 @@ end
|
|||||||
|
|
||||||
function RomExtractor:extractField()
|
function RomExtractor:extractField()
|
||||||
self:beginStage("Interface artwork")
|
self:beginStage("Interface artwork")
|
||||||
local done, total = 0, 50
|
local done, total = 0, 51
|
||||||
local function tick()
|
local function tick()
|
||||||
done = done + 1
|
done = done + 1
|
||||||
self:tick("Interface artwork", math.min(done, total), total)
|
self:tick("Interface artwork", math.min(done, total), total)
|
||||||
@@ -1748,6 +1772,16 @@ function RomExtractor:extractField()
|
|||||||
"title/copyright.png"); tick()
|
"title/copyright.png"); tick()
|
||||||
self:raw2bpp("GameFreakLogoGraphics", 72, 8,
|
self:raw2bpp("GameFreakLogoGraphics", 72, 8,
|
||||||
"title/gamefreak_inc.png"); tick()
|
"title/gamefreak_inc.png"); tick()
|
||||||
|
|
||||||
|
do
|
||||||
|
local gf = self.symbols["GameFreakLogoGraphics"]
|
||||||
|
local tb = self.symbols["TextBoxGraphics"]
|
||||||
|
if gf and tb and tb[2] == gf[2] + 9 * 16 + 16 then
|
||||||
|
local raw = self.rom:bytes(gf[1], gf[2] + 9 * 16, 16)
|
||||||
|
self:save(ImageWriter.decode2bpp(raw, 8, 8, false), "title/nine.png")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
tick()
|
||||||
-- Yellow fixed Pikachu title art (no-op on Red/Blue manifests).
|
-- Yellow fixed Pikachu title art (no-op on Red/Blue manifests).
|
||||||
self:extractYellowTitleArt(); tick()
|
self:extractYellowTitleArt(); tick()
|
||||||
|
|
||||||
|
|||||||
+20
-14
@@ -30,7 +30,11 @@ end
|
|||||||
-- Cache generation tag; bump to force every imported version to re-extract.
|
-- Cache generation tag; bump to force every imported version to re-extract.
|
||||||
-- v9: Yellow audio re-anchored on pokeyellow.sym (#522) -- stale caches
|
-- v9: Yellow audio re-anchored on pokeyellow.sym (#522) -- stale caches
|
||||||
-- carry Red's bank $1f header, wave-table, and CryData offsets.
|
-- carry Red's bank $1f header, wave-table, and CryData offsets.
|
||||||
local CACHE_FORMAT = "rom-cache-v9:"
|
-- v10: maps carry their raw map-header/connection/object bytes and tilesets
|
||||||
|
-- their Tilesets row (#889), which a .sav export replays so a Continue on
|
||||||
|
-- real hardware has a map to load; a v9 cache has none of them and exports
|
||||||
|
-- the same unbootable save as before.
|
||||||
|
local CACHE_FORMAT = "rom-cache-v10:"
|
||||||
-- The completion marker is written under each version's cache prefix
|
-- The completion marker is written under each version's cache prefix
|
||||||
-- (red/rom-cache.complete, blue/rom-cache.complete, ...).
|
-- (red/rom-cache.complete, blue/rom-cache.complete, ...).
|
||||||
local MARKER_PATH = "rom-cache.complete"
|
local MARKER_PATH = "rom-cache.complete"
|
||||||
@@ -152,18 +156,22 @@ local function allRequiredFilesExist(version)
|
|||||||
return ok
|
return ok
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A developer checkout / Python build leaves Red's generated data in the
|
-- A developer checkout / Python build leaves generated data in the physfs
|
||||||
-- physfs SOURCE at the un-prefixed root (the checked-out data/generated and
|
-- source: Red at the historical root, Blue/Yellow in their versioned trees.
|
||||||
-- assets/generated); it is always current and never moves into red/. Only
|
-- Imported Red caches still live under red/. Check source paths directly so
|
||||||
-- Red ships this way (Blue/Yellow are import-only). The check goes through
|
-- that cache prefix cannot hide Red's source tree, and keep save-dir caches
|
||||||
-- love.filesystem directly so the red/ cache prefix cannot hide the source
|
-- from counting as current source data.
|
||||||
-- tree, and the realDirectory test keeps a save-dir cache from counting.
|
local function sourceTreeHasData(version)
|
||||||
local function sourceTreeHasData()
|
|
||||||
if not love.filesystem.getRealDirectory then return false end
|
if not love.filesystem.getRealDirectory then return false end
|
||||||
|
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
|
||||||
for _, path in ipairs(REQUIRED_FILES) do
|
for _, path in ipairs(REQUIRED_FILES) do
|
||||||
if love.filesystem.getInfo(path, "file") == nil then return false end
|
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
|
||||||
end
|
end
|
||||||
local real = love.filesystem.getRealDirectory(REQUIRED_FILES[1])
|
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
|
||||||
|
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
|
||||||
|
end
|
||||||
|
local path = prefix .. REQUIRED_FILES[1]
|
||||||
|
local real = love.filesystem.getRealDirectory(path)
|
||||||
return real == love.filesystem.getSource()
|
return real == love.filesystem.getSource()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -240,10 +248,8 @@ function RomImporter.isReady(version)
|
|||||||
-- save-directory copy that would otherwise shadow it at runtime.
|
-- save-directory copy that would otherwise shadow it at runtime.
|
||||||
purgeSaveDirCache()
|
purgeSaveDirCache()
|
||||||
end
|
end
|
||||||
-- Red generated data in the physfs source (developer checkout / Python
|
-- Generated data in a developer checkout / Python build is always current.
|
||||||
-- build) is always current; Blue is import-only and falls through to the
|
if sourceTreeHasData(version) then return true end
|
||||||
-- version-marker gate.
|
|
||||||
if version == "red" and sourceTreeHasData() then return true end
|
|
||||||
local saved = CacheFs.prefix
|
local saved = CacheFs.prefix
|
||||||
CacheFs.prefix = GameVersion.cachePrefix(version)
|
CacheFs.prefix = GameVersion.cachePrefix(version)
|
||||||
local marker = CacheFs.read(MARKER_PATH)
|
local marker = CacheFs.read(MARKER_PATH)
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ local function noEffect(data)
|
|||||||
return romText(data, "_ItemUseNoEffectText", "It won't have\nany effect.")
|
return romText(data, "_ItemUseNoEffectText", "It won't have\nany effect.")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function registeredEffect(data, itemDef)
|
||||||
|
if not data or not itemDef or not itemDef.effect then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if not data.item_effects then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return data.item_effects[itemDef.effect]
|
||||||
|
end
|
||||||
|
|
||||||
local HEAL_AMOUNT = {
|
local HEAL_AMOUNT = {
|
||||||
POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200,
|
POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200,
|
||||||
FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80,
|
FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80,
|
||||||
@@ -72,7 +84,18 @@ function ItemEffects.healsHP(id)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Does this item need a party-member target?
|
-- Does this item need a party-member target?
|
||||||
function ItemEffects.needsTarget(id, itemDef)
|
-- 'data' is optional for compat purposes; targeting falls back to itemDef/vanilla detection
|
||||||
|
function ItemEffects.needsTarget(id, itemDef, data)
|
||||||
|
if itemDef and itemDef.needsTarget ~= nil then
|
||||||
|
return itemDef.needsTarget
|
||||||
|
end
|
||||||
|
|
||||||
|
local effect = registeredEffect(data, itemDef)
|
||||||
|
|
||||||
|
if effect and effect.needsTarget ~= nil then
|
||||||
|
return effect.needsTarget
|
||||||
|
end
|
||||||
|
|
||||||
return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION"
|
return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION"
|
||||||
or id == "FULL_RESTORE" or id == "REVIVE" or id == "MAX_REVIVE"
|
or id == "FULL_RESTORE" or id == "REVIVE" or id == "MAX_REVIVE"
|
||||||
or id == "RARE_CANDY" or STONES[id]
|
or id == "RARE_CANDY" or STONES[id]
|
||||||
@@ -147,6 +170,28 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
local itemDef = data.items[itemId]
|
local itemDef = data.items[itemId]
|
||||||
local name = itemDef and itemDef.name or itemId
|
local name = itemDef and itemDef.name or itemId
|
||||||
|
|
||||||
|
local effectDef = registeredEffect(data, itemDef)
|
||||||
|
if effectDef then
|
||||||
|
if battle and effectDef.battle == false then
|
||||||
|
return "failed", { notTime(data, save) }
|
||||||
|
end
|
||||||
|
|
||||||
|
if not battle and effectDef.field == false then
|
||||||
|
return "failed", { notTime(data, save) }
|
||||||
|
end
|
||||||
|
|
||||||
|
return effectDef.use({
|
||||||
|
data = data,
|
||||||
|
save = save,
|
||||||
|
itemId = itemId,
|
||||||
|
item = itemDef,
|
||||||
|
target = target,
|
||||||
|
battle = battle,
|
||||||
|
moveIndex = moveIndex,
|
||||||
|
overworld = ow,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase /
|
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase /
|
||||||
-- ItemUseTMHM / ItemUseRepelCommon all refuse mid-battle
|
-- ItemUseTMHM / ItemUseRepelCommon all refuse mid-battle
|
||||||
-- (jp nz, ItemUseNotTime)
|
-- (jp nz, ItemUseNotTime)
|
||||||
|
|||||||
@@ -171,4 +171,26 @@ function Json.decode(s)
|
|||||||
return nil, v
|
return nil, v
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- For an HTTP response that was meant to carry JSON but did not. Returns nil
|
||||||
|
-- when `s` starts like a JSON object or array (the only shapes the update and
|
||||||
|
-- index endpoints publish), otherwise a short message naming what the server
|
||||||
|
-- actually sent -- so callers surface "the response was an HTML page/plain
|
||||||
|
-- text, not JSON (it starts with ...)" instead of leaking the decoder's
|
||||||
|
-- low-level "unexpected character 'E'" assert at the first byte of an error
|
||||||
|
-- page or plain-text outage message.
|
||||||
|
function Json.describeUnexpected(s)
|
||||||
|
if type(s) ~= "string" then
|
||||||
|
return "the response had no body to decode"
|
||||||
|
end
|
||||||
|
local first = s:match("^%s*(.)")
|
||||||
|
if first == "{" or first == "[" then return nil end
|
||||||
|
local preview = s:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
if preview == "" then
|
||||||
|
return "the response was empty, not JSON"
|
||||||
|
end
|
||||||
|
if #preview > 60 then preview = preview:sub(1, 57) .. "..." end
|
||||||
|
local kind = (first == "<") and "an HTML page" or "plain text"
|
||||||
|
return ("the response was %s, not JSON (it starts with %q)"):format(kind, preview)
|
||||||
|
end
|
||||||
|
|
||||||
return Json
|
return Json
|
||||||
|
|||||||
+66
-60
@@ -10,6 +10,7 @@ local Net = require("src.link.Net")
|
|||||||
local Protocol = require("src.link.Protocol")
|
local Protocol = require("src.link.Protocol")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
|
local Session = require("src.link.Session")
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
@@ -44,10 +45,8 @@ local function forceLevelLabel(v)
|
|||||||
return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v))
|
return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- stages before any Net object is meaningfully "this session's link" --
|
-- stages before a successful transport has become this link's Session;
|
||||||
-- .net can still be a leftover failed attempt sitting on self, so error/
|
-- terminal checks skip them rather than keying off self.net's presence
|
||||||
-- closed checks below skip these rather than keying off self.net's
|
|
||||||
-- presence alone
|
|
||||||
local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = true }
|
local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = true }
|
||||||
|
|
||||||
-- how long the host waits for a v2 hello before deciding the peer predates
|
-- how long the host waits for a v2 hello before deciding the peer predates
|
||||||
@@ -70,6 +69,16 @@ local function ipDigits(ip)
|
|||||||
return digits
|
return digits
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function openSession(role, connect)
|
||||||
|
local transport = Net.new()
|
||||||
|
if connect(transport) then
|
||||||
|
return Session.new(transport, { role = role, kind = "link" })
|
||||||
|
end
|
||||||
|
local detail = transport.error or "?"
|
||||||
|
transport:close()
|
||||||
|
return nil, detail
|
||||||
|
end
|
||||||
|
|
||||||
function LinkState.new(game)
|
function LinkState.new(game)
|
||||||
local self = setmetatable({}, LinkState)
|
local self = setmetatable({}, LinkState)
|
||||||
self.game = game
|
self.game = game
|
||||||
@@ -89,12 +98,15 @@ end
|
|||||||
-- "connecting with this code", same as if the player had typed it in
|
-- "connecting with this code", same as if the player had typed it in
|
||||||
function LinkState.newJoinOnline(game, code)
|
function LinkState.newJoinOnline(game, code)
|
||||||
local self = LinkState.new(game)
|
local self = LinkState.new(game)
|
||||||
self.net = Net.new()
|
local session, detail = openSession("guest", function(transport)
|
||||||
if self.net:joinOnline(nil, code) then
|
return transport:joinOnline(nil, code)
|
||||||
|
end)
|
||||||
|
if session then
|
||||||
|
self.net = session
|
||||||
self.stage = "onlineJoining"
|
self.stage = "onlineJoining"
|
||||||
else
|
else
|
||||||
self.stage = "menu" -- exitWith below needs a real stage to unwind from
|
self.stage = "menu" -- exitWith below needs a real stage to unwind from
|
||||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
self:exitWith(Strings("Link error:\n%s", detail))
|
||||||
end
|
end
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
@@ -163,21 +175,13 @@ end
|
|||||||
-- take the peer's hello out of the inbox without eating anything that
|
-- take the peer's hello out of the inbox without eating anything that
|
||||||
-- shares the batch with it
|
-- shares the batch with it
|
||||||
function LinkState:pollHello()
|
function LinkState:pollHello()
|
||||||
local msgs = self.net:poll()
|
local message
|
||||||
local keep, got = {}, false
|
if not self.peerHello then message = self.net:take("hello") end
|
||||||
for _, msg in ipairs(msgs) do
|
if message then
|
||||||
if msg.type == "hello" and not self.peerHello then
|
self.peerHello = message
|
||||||
self.peerHello = msg
|
self.peerName = message.name
|
||||||
self.peerName = msg.name
|
|
||||||
got = true
|
|
||||||
else
|
|
||||||
keep[#keep + 1] = msg
|
|
||||||
end
|
end
|
||||||
end
|
return message ~= nil, self.net:hasPending()
|
||||||
for i = #keep, 1, -1 do
|
|
||||||
table.insert(self.net.inbox, 1, keep[i])
|
|
||||||
end
|
|
||||||
return got, #keep > 0
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function LinkState:sendHello(mode)
|
function LinkState:sendHello(mode)
|
||||||
@@ -220,13 +224,15 @@ function LinkState:update(dt)
|
|||||||
local input = self.game.input
|
local input = self.game.input
|
||||||
if self.net then
|
if self.net then
|
||||||
self.net:update()
|
self.net:update()
|
||||||
if self.net.error and not PRE_CONNECT_STAGES[self.stage] then
|
local status = self.net:getStatus()
|
||||||
self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
|
if status == "failed" and not PRE_CONNECT_STAGES[self.stage] then
|
||||||
|
self:exitWith(Strings("Link error:\n%s",
|
||||||
|
(self.net.error or "?"):sub(1, 60)))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- the peer vanished without a bye (only once the inbox is drained,
|
-- the peer vanished without a bye (only once the session FIFO drains,
|
||||||
-- so a final message travelling with the disconnect still counts)
|
-- so a final message travelling with the disconnect still counts)
|
||||||
if self.net.closed and #self.net.inbox == 0
|
if status == "closed"
|
||||||
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
|
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
|
||||||
and self.stage ~= "codeEntry" and self.stage ~= "notice"
|
and self.stage ~= "codeEntry" and self.stage ~= "notice"
|
||||||
and self.stage ~= "battleRunning" then
|
and self.stage ~= "battleRunning" then
|
||||||
@@ -269,12 +275,15 @@ function LinkState:update(dt)
|
|||||||
self.stage = "menu"
|
self.stage = "menu"
|
||||||
self.index = 1
|
self.index = 1
|
||||||
elseif input:wasPressed("a") then
|
elseif input:wasPressed("a") then
|
||||||
self.net = Net.new()
|
|
||||||
if self.index == 1 then
|
if self.index == 1 then
|
||||||
if self.net:host() then
|
local session, detail = openSession("host", function(transport)
|
||||||
|
return transport:host()
|
||||||
|
end)
|
||||||
|
if session then
|
||||||
|
self.net = session
|
||||||
self.stage = "hosting"
|
self.stage = "hosting"
|
||||||
else
|
else
|
||||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
self:exitWith(Strings("Link error:\n%s", detail))
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
self.stage = "addrEntry"
|
self.stage = "addrEntry"
|
||||||
@@ -289,11 +298,14 @@ function LinkState:update(dt)
|
|||||||
self.index = 2
|
self.index = 2
|
||||||
elseif input:wasPressed("a") then
|
elseif input:wasPressed("a") then
|
||||||
if self.index == 1 then
|
if self.index == 1 then
|
||||||
self.net = Net.new()
|
local session, detail = openSession("host", function(transport)
|
||||||
if self.net:hostOnline() then
|
return transport:hostOnline()
|
||||||
|
end)
|
||||||
|
if session then
|
||||||
|
self.net = session
|
||||||
self.stage = "onlineHosting"
|
self.stage = "onlineHosting"
|
||||||
else
|
else
|
||||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
self:exitWith(Strings("Link error:\n%s", detail))
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
self.stage = "codeEntry"
|
self.stage = "codeEntry"
|
||||||
@@ -327,11 +339,14 @@ function LinkState:update(dt)
|
|||||||
CodeEntry.right(self.codeEntry)
|
CodeEntry.right(self.codeEntry)
|
||||||
elseif input:wasPressed("a") then
|
elseif input:wasPressed("a") then
|
||||||
local code = CodeEntry.text(self.codeEntry)
|
local code = CodeEntry.text(self.codeEntry)
|
||||||
self.net = Net.new()
|
local session, detail = openSession("guest", function(transport)
|
||||||
if self.net:joinOnline(nil, code) then
|
return transport:joinOnline(nil, code)
|
||||||
|
end)
|
||||||
|
if session then
|
||||||
|
self.net = session
|
||||||
self.stage = "onlineJoining"
|
self.stage = "onlineJoining"
|
||||||
else
|
else
|
||||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
self:exitWith(Strings("Link error:\n%s", detail))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -367,10 +382,15 @@ function LinkState:update(dt)
|
|||||||
+ self.addr[base + 2] * 10
|
+ self.addr[base + 2] * 10
|
||||||
+ self.addr[base + 3])
|
+ self.addr[base + 3])
|
||||||
end
|
end
|
||||||
if self.net:join(table.concat(octets, ".")) then
|
local address = table.concat(octets, ".")
|
||||||
|
local session, detail = openSession("guest", function(transport)
|
||||||
|
return transport:join(address)
|
||||||
|
end)
|
||||||
|
if session then
|
||||||
|
self.net = session
|
||||||
self.stage = "joining"
|
self.stage = "joining"
|
||||||
else
|
else
|
||||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
self:exitWith(Strings("Link error:\n%s", detail))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -428,19 +448,11 @@ function LinkState:update(dt)
|
|||||||
|
|
||||||
elseif self.stage == "waitMode" then -- guest waits for host's pick
|
elseif self.stage == "waitMode" then -- guest waits for host's pick
|
||||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||||
local msgs = self.net:poll()
|
local message = self.net:take("hello")
|
||||||
for i, msg in ipairs(msgs) do
|
if message then
|
||||||
if msg.type == "hello" then
|
self.peerHello = message
|
||||||
self.peerHello = msg
|
self.peerName = message.name
|
||||||
self.peerName = msg.name
|
self:decideCompat(message.mode, false)
|
||||||
-- the host's next messages (party, ...) can share this batch;
|
|
||||||
-- put them back so the new stage's poll sees them
|
|
||||||
for j = #msgs, i + 1, -1 do
|
|
||||||
table.insert(self.net.inbox, 1, msgs[j])
|
|
||||||
end
|
|
||||||
self:decideCompat(msg.mode, false)
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif self.stage == "notice" then
|
elseif self.stage == "notice" then
|
||||||
@@ -456,18 +468,17 @@ function LinkState:update(dt)
|
|||||||
|
|
||||||
elseif self.stage == "battleWait" then
|
elseif self.stage == "battleWait" then
|
||||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||||
local msgs = self.net:poll()
|
local message = self.net:take("party")
|
||||||
for i, msg in ipairs(msgs) do
|
if message then
|
||||||
if msg.type == "party" then
|
|
||||||
-- the host owns this rule (same as mode); the guest only learns
|
-- the host owns this rule (same as mode); the guest only learns
|
||||||
-- it here, off the host's own party message
|
-- it here, off the host's own party message
|
||||||
if not self.isHost then self.forceLevel = msg.forceLevel end
|
if not self.isHost then self.forceLevel = message.forceLevel end
|
||||||
local LinkBattle = require("src.link.LinkBattle")
|
local LinkBattle = require("src.link.LinkBattle")
|
||||||
local opts = {
|
local opts = {
|
||||||
myParty = Protocol.packParty(self.game.save.party),
|
myParty = Protocol.packParty(self.game.save.party),
|
||||||
theirParty = msg.mons,
|
theirParty = message.mons,
|
||||||
theirName = self.peerName or "FOE",
|
theirName = self.peerName or "FOE",
|
||||||
seed = self.isHost and self.linkSeed or msg.seed,
|
seed = self.isHost and self.linkSeed or message.seed,
|
||||||
verdict = self.verdict,
|
verdict = self.verdict,
|
||||||
strict = Handshake.strict(self.verdict),
|
strict = Handshake.strict(self.verdict),
|
||||||
forceLevel = self.forceLevel,
|
forceLevel = self.forceLevel,
|
||||||
@@ -485,11 +496,6 @@ function LinkState:update(dt)
|
|||||||
end
|
end
|
||||||
self.game.stack:push(battle)
|
self.game.stack:push(battle)
|
||||||
self.stage = "battleRunning"
|
self.stage = "battleRunning"
|
||||||
for j = #msgs, i + 1, -1 do
|
|
||||||
table.insert(self.net.inbox, 1, msgs[j])
|
|
||||||
end
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif self.stage == "battleRunning" then
|
elseif self.stage == "battleRunning" then
|
||||||
|
|||||||
+4
-5
@@ -207,7 +207,7 @@ function Net:send(msg)
|
|||||||
end
|
end
|
||||||
if self.peerEnd then -- loopback: re-encode through json like the wire
|
if self.peerEnd then -- loopback: re-encode through json like the wire
|
||||||
local decoded = Json.decode(Json.encode(msg))
|
local decoded = Json.decode(Json.encode(msg))
|
||||||
if decoded and not self.peerEnd.closed then
|
if decoded ~= nil and not self.peerEnd.closed then
|
||||||
table.insert(self.peerEnd.inbox, decoded)
|
table.insert(self.peerEnd.inbox, decoded)
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
@@ -256,13 +256,12 @@ end
|
|||||||
|
|
||||||
function Net:handleTCPLine(line)
|
function Net:handleTCPLine(line)
|
||||||
local msg = Json.decode(line)
|
local msg = Json.decode(line)
|
||||||
if not msg then
|
if msg == nil then
|
||||||
Logger.warn("link: bad relay message %q", line:sub(1, 60))
|
Logger.warn("link: bad relay message %q", line:sub(1, 60))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if not handleGenericRelayControl(self, msg) then
|
if type(msg) == "table" and handleGenericRelayControl(self, msg) then return end
|
||||||
table.insert(self.inbox, msg)
|
table.insert(self.inbox, msg)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- pulls every complete "\n"-terminated line out of rxBuf (leaving a
|
-- pulls every complete "\n"-terminated line out of rxBuf (leaving a
|
||||||
@@ -352,7 +351,7 @@ function Net:update()
|
|||||||
end
|
end
|
||||||
elseif event.type == "receive" then
|
elseif event.type == "receive" then
|
||||||
local msg = Json.decode(event.data)
|
local msg = Json.decode(event.data)
|
||||||
if msg then
|
if msg ~= nil then
|
||||||
table.insert(self.inbox, msg)
|
table.insert(self.inbox, msg)
|
||||||
else
|
else
|
||||||
Logger.warn("link: bad message %q", tostring(event.data):sub(1, 60))
|
Logger.warn("link: bad message %q", tostring(event.data):sub(1, 60))
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
local Session = {}
|
||||||
|
Session.__index = Session
|
||||||
|
|
||||||
|
local VALID_ROLES = { host = true, guest = true }
|
||||||
|
local REQUIRED_METHODS = { "update", "poll", "send", "close" }
|
||||||
|
|
||||||
|
function Session.new(transport, options)
|
||||||
|
assert(type(transport) == "table", "Session.new requires a transport")
|
||||||
|
assert(type(options) == "table", "Session.new requires options")
|
||||||
|
assert(VALID_ROLES[options.role], "Session role must be host or guest")
|
||||||
|
assert(type(options.kind) == "string" and options.kind ~= "",
|
||||||
|
"Session kind must be a non-empty string")
|
||||||
|
for _, method in ipairs(REQUIRED_METHODS) do
|
||||||
|
assert(type(transport[method]) == "function",
|
||||||
|
"Session transport requires " .. method)
|
||||||
|
end
|
||||||
|
|
||||||
|
local self = setmetatable({
|
||||||
|
_transport = transport,
|
||||||
|
_role = options.role,
|
||||||
|
_kind = options.kind,
|
||||||
|
_inbox = {},
|
||||||
|
_status = "connecting",
|
||||||
|
_terminal = nil,
|
||||||
|
_transportCloseCalled = false,
|
||||||
|
paired = false,
|
||||||
|
closed = false,
|
||||||
|
error = nil,
|
||||||
|
code = nil,
|
||||||
|
address = nil,
|
||||||
|
target = nil,
|
||||||
|
}, Session)
|
||||||
|
self:_syncMetadata()
|
||||||
|
self:_refreshStatus()
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
function Session:_syncMetadata()
|
||||||
|
local transport = self._transport
|
||||||
|
self.paired = transport.paired == true
|
||||||
|
self.code = transport.code
|
||||||
|
self.address = transport.address
|
||||||
|
self.target = transport.target
|
||||||
|
end
|
||||||
|
|
||||||
|
function Session:_refreshStatus()
|
||||||
|
if not self._terminal then
|
||||||
|
self._status = self.paired and "paired" or "connecting"
|
||||||
|
self.closed = false
|
||||||
|
self.error = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if #self._inbox > 0 then
|
||||||
|
self._status = "draining"
|
||||||
|
self.closed = false
|
||||||
|
self.error = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self._status = self._terminal.status
|
||||||
|
self.closed = true
|
||||||
|
self.error = self._terminal.status == "failed"
|
||||||
|
and (self._terminal.detail or self._terminal.reason) or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function Session:_latchTerminal(status, reason, detail)
|
||||||
|
if self._terminal then return false end
|
||||||
|
self._terminal = { status = status, reason = reason, detail = detail }
|
||||||
|
self:_refreshStatus()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function Session:_closeTransport()
|
||||||
|
if self._transportCloseCalled then return true end
|
||||||
|
self._transportCloseCalled = true
|
||||||
|
local ok, detail = pcall(self._transport.close, self._transport)
|
||||||
|
return ok, ok and nil or tostring(detail)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Session:getRole() return self._role end
|
||||||
|
function Session:getKind() return self._kind end
|
||||||
|
function Session:getStatus() return self._status end
|
||||||
|
function Session:getFailure()
|
||||||
|
if not self._terminal or self._terminal.status ~= "failed" then
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
return self._terminal.reason, self._terminal.detail
|
||||||
|
end
|
||||||
|
function Session:hasPending() return #self._inbox > 0 end
|
||||||
|
|
||||||
|
function Session:send(message)
|
||||||
|
if self._terminal then return nil end
|
||||||
|
return self._transport:send(message)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Session:update()
|
||||||
|
if self._terminal then
|
||||||
|
self:_refreshStatus()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local failureReason, failureDetail
|
||||||
|
local updateOk, updateDetail = pcall(self._transport.update, self._transport)
|
||||||
|
self:_syncMetadata()
|
||||||
|
if not updateOk then
|
||||||
|
failureReason, failureDetail = "transport_error", tostring(updateDetail)
|
||||||
|
elseif self._transport.error then
|
||||||
|
failureReason = "transport_error"
|
||||||
|
failureDetail = tostring(self._transport.error)
|
||||||
|
end
|
||||||
|
|
||||||
|
local pollOk, messages = pcall(self._transport.poll, self._transport)
|
||||||
|
if not pollOk then
|
||||||
|
if not failureReason then
|
||||||
|
failureReason, failureDetail = "transport_error", tostring(messages)
|
||||||
|
end
|
||||||
|
elseif type(messages) ~= "table" then
|
||||||
|
if not failureReason then
|
||||||
|
failureReason, failureDetail = "transport_error",
|
||||||
|
"transport poll returned non-table"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
for index = 1, #messages do
|
||||||
|
local message = messages[index]
|
||||||
|
if type(message) ~= "table" or type(message.type) ~= "string" then
|
||||||
|
if not failureReason then
|
||||||
|
failureReason = "protocol_error"
|
||||||
|
failureDetail = ("message %d must be a table with string type")
|
||||||
|
:format(index)
|
||||||
|
end
|
||||||
|
break
|
||||||
|
end
|
||||||
|
self._inbox[#self._inbox + 1] = message
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
self:_syncMetadata()
|
||||||
|
if failureReason then
|
||||||
|
self:_latchTerminal("failed", failureReason, failureDetail)
|
||||||
|
self:_closeTransport()
|
||||||
|
elseif self._transport.closed then
|
||||||
|
local closeOk, closeDetail = self:_closeTransport()
|
||||||
|
if closeOk then
|
||||||
|
self:_latchTerminal("closed")
|
||||||
|
else
|
||||||
|
self:_latchTerminal("failed", "transport_error", closeDetail)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
self:_refreshStatus()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function finishRead(self)
|
||||||
|
self:_refreshStatus()
|
||||||
|
end
|
||||||
|
|
||||||
|
function Session:take(messageType)
|
||||||
|
assert(type(messageType) == "string", "Session.take requires a message type")
|
||||||
|
for index, message in ipairs(self._inbox) do
|
||||||
|
if message.type == messageType then
|
||||||
|
local found = table.remove(self._inbox, index)
|
||||||
|
finishRead(self)
|
||||||
|
return found
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function Session:pollOne()
|
||||||
|
if #self._inbox == 0 then return nil end
|
||||||
|
local message = table.remove(self._inbox, 1)
|
||||||
|
finishRead(self)
|
||||||
|
return message
|
||||||
|
end
|
||||||
|
|
||||||
|
function Session:poll()
|
||||||
|
local messages = self._inbox
|
||||||
|
self._inbox = {}
|
||||||
|
finishRead(self)
|
||||||
|
return messages
|
||||||
|
end
|
||||||
|
|
||||||
|
function Session:close()
|
||||||
|
if self._status == "closed" or self._status == "failed" then return end
|
||||||
|
if self._terminal then
|
||||||
|
self:_closeTransport()
|
||||||
|
self:_refreshStatus()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local ok, detail = self:_closeTransport()
|
||||||
|
if ok then
|
||||||
|
self:_latchTerminal("closed")
|
||||||
|
else
|
||||||
|
self:_latchTerminal("failed", "transport_error", detail)
|
||||||
|
end
|
||||||
|
self:_syncMetadata()
|
||||||
|
self:_refreshStatus()
|
||||||
|
end
|
||||||
|
|
||||||
|
return Session
|
||||||
+50
-51
@@ -14,6 +14,7 @@ local Font = require("src.render.Font")
|
|||||||
local Handshake = require("src.link.Handshake")
|
local Handshake = require("src.link.Handshake")
|
||||||
local LinkBattle = require("src.link.LinkBattle")
|
local LinkBattle = require("src.link.LinkBattle")
|
||||||
local Net = require("src.link.Net")
|
local Net = require("src.link.Net")
|
||||||
|
local Session = require("src.link.Session")
|
||||||
local Protocol = require("src.link.Protocol")
|
local Protocol = require("src.link.Protocol")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
@@ -132,12 +133,23 @@ end
|
|||||||
-- host / join
|
-- host / join
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function openSession(role)
|
||||||
|
local transport = Net.new()
|
||||||
|
if transport:connectTCP(Net.defaultRelayAddress()) then
|
||||||
|
return Session.new(transport, { role = role, kind = "tournament" })
|
||||||
|
end
|
||||||
|
local detail = transport.error or "?"
|
||||||
|
transport:close()
|
||||||
|
return nil, detail
|
||||||
|
end
|
||||||
|
|
||||||
function Tournament:startHosting()
|
function Tournament:startHosting()
|
||||||
self.net = Net.new()
|
local session, detail = openSession("host")
|
||||||
if not self.net:connectTCP(Net.defaultRelayAddress()) then
|
if not session then
|
||||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
self:exitWith(Strings("Link error:\n%s", detail))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
self.net = session
|
||||||
local size, minL, maxL = partyStats(self.game.save.party)
|
local size, minL, maxL = partyStats(self.game.save.party)
|
||||||
self.isCreator = true
|
self.isCreator = true
|
||||||
self.participating = self.settings.participating
|
self.participating = self.settings.participating
|
||||||
@@ -156,11 +168,12 @@ function Tournament:startHosting()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Tournament:startJoining(code)
|
function Tournament:startJoining(code)
|
||||||
self.net = Net.new()
|
local session, detail = openSession("guest")
|
||||||
if not self.net:connectTCP(Net.defaultRelayAddress()) then
|
if not session then
|
||||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
self:exitWith(Strings("Link error:\n%s", detail))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
self.net = session
|
||||||
local size, minL, maxL = partyStats(self.game.save.party)
|
local size, minL, maxL = partyStats(self.game.save.party)
|
||||||
self.isCreator = false
|
self.isCreator = false
|
||||||
self.participating = true -- joining is always to compete; only hosting can opt out
|
self.participating = true -- joining is always to compete; only hosting can opt out
|
||||||
@@ -256,22 +269,14 @@ function Tournament:sendHello(mode)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Tournament:pollHello()
|
function Tournament:pollHello()
|
||||||
local msgs = self.net:poll()
|
local message
|
||||||
local keep, got = {}, false
|
if not self.peerHello then message = self.net:take("hello") end
|
||||||
for _, msg in ipairs(msgs) do
|
if message then self.peerHello = message end
|
||||||
if msg.type == "hello" and not self.peerHello then
|
return message ~= nil
|
||||||
self.peerHello = msg
|
|
||||||
got = true
|
|
||||||
else
|
|
||||||
keep[#keep + 1] = msg
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for i = #keep, 1, -1 do
|
|
||||||
table.insert(self.net.inbox, 1, keep[i])
|
|
||||||
end
|
|
||||||
return got
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The relay assigns a side for each match; this can differ from the immutable
|
||||||
|
-- tournament creator/joiner role held by Session.
|
||||||
function Tournament:enterMatch(msg)
|
function Tournament:enterMatch(msg)
|
||||||
self.isHost = (msg.role == "host")
|
self.isHost = (msg.role == "host")
|
||||||
self.opponentName = msg.opponent
|
self.opponentName = msg.opponent
|
||||||
@@ -356,12 +361,13 @@ function Tournament:update(dt)
|
|||||||
|
|
||||||
if self.net then
|
if self.net then
|
||||||
self.net:update()
|
self.net:update()
|
||||||
if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings"
|
local status = self.net:getStatus()
|
||||||
|
if status == "failed" and self.stage ~= "menu" and self.stage ~= "hostSettings"
|
||||||
and self.stage ~= "codeEntry" then
|
and self.stage ~= "codeEntry" then
|
||||||
self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
|
self:exitWith(Strings("Link error:\n%s", (self.net.error or "?"):sub(1, 60)))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if self.net.closed and self.stage ~= "done" then
|
if status == "closed" and self.stage ~= "done" then
|
||||||
self:exitWith(Strings("The tournament\nconnection was\nlost."))
|
self:exitWith(Strings("The tournament\nconnection was\nlost."))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -370,24 +376,23 @@ function Tournament:update(dt)
|
|||||||
if self.stage == "matchHello" then
|
if self.stage == "matchHello" then
|
||||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||||
self:pollHello()
|
self:pollHello()
|
||||||
if self.peerHello then self:beginMatchBattle() end
|
if self.peerHello then
|
||||||
for _, msg in ipairs(self.net:poll()) do self:handleMessage(msg) end
|
self:beginMatchBattle()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
for _, message in ipairs(self.net:poll()) do self:handleMessage(message) end
|
||||||
return
|
return
|
||||||
elseif self.stage == "matchWaitParty" then
|
elseif self.stage == "matchWaitParty" then
|
||||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||||
local msgs = self.net:poll()
|
while self.net:hasPending() do
|
||||||
for i, msg in ipairs(msgs) do
|
local message = self.net:pollOne()
|
||||||
if msg.type == "party" then
|
if message.type == "party" then
|
||||||
self.pendingBattleOpts.theirParty = msg.mons
|
self.pendingBattleOpts.theirParty = message.mons
|
||||||
if self.isHost then
|
if self.isHost then
|
||||||
self.pendingBattleOpts.seed = self.pendingBattleOpts.seed or self.linkSeed
|
self.pendingBattleOpts.seed = self.pendingBattleOpts.seed or self.linkSeed
|
||||||
else
|
else
|
||||||
self.pendingBattleOpts.seed = msg.seed
|
self.pendingBattleOpts.seed = message.seed
|
||||||
end
|
end
|
||||||
-- Split rather than `cond and newHost() or newGuest()`: the and/or
|
|
||||||
-- idiom truncates a call to its first result, so the second return
|
|
||||||
-- (the specific reason) was always dropped and every failure showed
|
|
||||||
-- the generic fallback instead of "same mods on both games" etc.
|
|
||||||
local battle, why
|
local battle, why
|
||||||
if self.isHost then
|
if self.isHost then
|
||||||
battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
||||||
@@ -398,27 +403,22 @@ function Tournament:update(dt)
|
|||||||
self:exitWith(why or Strings("Link battle\ncan't start."))
|
self:exitWith(why or Strings("Link battle\ncan't start."))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- anything after `party` in this same batch belongs to the
|
|
||||||
-- battle now, not to Tournament -- put it back for its own poll()
|
|
||||||
for j = #msgs, i + 1, -1 do
|
|
||||||
table.insert(self.net.inbox, 1, msgs[j])
|
|
||||||
end
|
|
||||||
self.activeBattle = battle
|
self.activeBattle = battle
|
||||||
self.game.stack:push(battle)
|
self.game.stack:push(battle)
|
||||||
self.stage = "matchRunning"
|
self.stage = "matchRunning"
|
||||||
return
|
return
|
||||||
else
|
else
|
||||||
self:handleMessage(msg)
|
self:handleMessage(message)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
elseif self.stage == "spectateWait" then
|
elseif self.stage == "spectateWait" then
|
||||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||||
local msgs = self.net:poll()
|
while self.net:hasPending() do
|
||||||
for i, msg in ipairs(msgs) do
|
local message = self.net:pollOne()
|
||||||
if msg.type == "spectate" and msg.msg.type == "party" then
|
if message.type == "spectate" and message.msg.type == "party" then
|
||||||
local inner = msg.msg
|
local inner = message.msg
|
||||||
if msg.side == "host" then
|
if message.side == "host" then
|
||||||
self.spectate.hostParty = inner.mons
|
self.spectate.hostParty = inner.mons
|
||||||
self.spectate.seed = inner.seed
|
self.spectate.seed = inner.seed
|
||||||
else
|
else
|
||||||
@@ -426,8 +426,10 @@ function Tournament:update(dt)
|
|||||||
end
|
end
|
||||||
if self.spectate.hostParty and self.spectate.guestParty then
|
if self.spectate.hostParty and self.spectate.guestParty then
|
||||||
local battle, why = LinkBattle.newSpectator(self.game, self.net, {
|
local battle, why = LinkBattle.newSpectator(self.game, self.net, {
|
||||||
hostParty = self.spectate.hostParty, guestParty = self.spectate.guestParty,
|
hostParty = self.spectate.hostParty,
|
||||||
hostName = self.spectate.hostName, guestName = self.spectate.guestName,
|
guestParty = self.spectate.guestParty,
|
||||||
|
hostName = self.spectate.hostName,
|
||||||
|
guestName = self.spectate.guestName,
|
||||||
seed = self.spectate.seed,
|
seed = self.spectate.seed,
|
||||||
forceLevel = levelForWire(self.settings.forceLevel),
|
forceLevel = levelForWire(self.settings.forceLevel),
|
||||||
})
|
})
|
||||||
@@ -435,16 +437,13 @@ function Tournament:update(dt)
|
|||||||
self:exitWith(why or Strings("Can't watch this\nmatch."))
|
self:exitWith(why or Strings("Can't watch this\nmatch."))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
for j = #msgs, i + 1, -1 do
|
|
||||||
table.insert(self.net.inbox, 1, msgs[j])
|
|
||||||
end
|
|
||||||
self.activeBattle = battle
|
self.activeBattle = battle
|
||||||
self.game.stack:push(battle)
|
self.game.stack:push(battle)
|
||||||
self.stage = "spectateRunning"
|
self.stage = "spectateRunning"
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
self:handleMessage(msg)
|
self:handleMessage(message)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -569,6 +569,9 @@ end
|
|||||||
function Loader:_api(mod)
|
function Loader:_api(mod)
|
||||||
local loader = self
|
local loader = self
|
||||||
local modId = mod.manifest.id
|
local modId = mod.manifest.id
|
||||||
|
local Storage = engineRequire("src.mods.Storage")
|
||||||
|
local storage = Storage and Storage.new(modId, loader.fs)
|
||||||
|
local Checkpoint = engineRequire("src.core.Checkpoint")
|
||||||
local api = {
|
local api = {
|
||||||
id = modId,
|
id = modId,
|
||||||
version = mod.manifest.version,
|
version = mod.manifest.version,
|
||||||
@@ -658,6 +661,25 @@ function Loader:_api(mod)
|
|||||||
bucket[key] = value
|
bucket[key] = value
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
-- Data-only state independent of the vanilla progress checkpoint. The
|
||||||
|
-- engine binds version/playthrough/mod scope and portable persistence;
|
||||||
|
-- callers never receive paths or a raw filesystem handle.
|
||||||
|
storage = {
|
||||||
|
context = function(_, game) return storage:context(game) end,
|
||||||
|
write = function(_, game, key, value) return storage:write(game, key, value) end,
|
||||||
|
read = function(_, game, key) return storage:read(game, key) end,
|
||||||
|
list = function(_, game, prefix) return storage:list(game, prefix) end,
|
||||||
|
delete = function(_, game, key) return storage:delete(game, key) end,
|
||||||
|
},
|
||||||
|
-- Runtime safety and reconstruction stay engine-owned. Checkpoints contain
|
||||||
|
-- data only; no controller, stack, coroutine or renderer object crosses out.
|
||||||
|
checkpoints = {
|
||||||
|
inspect = function(_, game) return Checkpoint.inspect(game) end,
|
||||||
|
capture = function(_, game) return Checkpoint.capture(game) end,
|
||||||
|
restore = function(_, game, checkpoint)
|
||||||
|
return Checkpoint.restore(game, checkpoint)
|
||||||
|
end,
|
||||||
|
},
|
||||||
options = {
|
options = {
|
||||||
define = function(_, schema)
|
define = function(_, schema)
|
||||||
assert(type(schema) == "table", "options schema must be a table of rows")
|
assert(type(schema) == "table", "options schema must be a table of rows")
|
||||||
@@ -929,6 +951,16 @@ function Loader:load(data)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- A manifest may name an env var that force-enables it regardless of a
|
||||||
|
-- saved disable in options.mods -- generic, not tied to any mod id, for
|
||||||
|
-- a mod (e.g. a native-launcher bridge) that cannot function disabled on
|
||||||
|
-- the one build where its env var is set.
|
||||||
|
for id, mod in pairs(self.mods) do
|
||||||
|
local envName = mod.manifest.force_enable_env
|
||||||
|
if envName and os.getenv(envName) == "1" then
|
||||||
|
self.disabled[id] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
for id, mod in pairs(self.mods) do
|
for id, mod in pairs(self.mods) do
|
||||||
mod.enabled = not self.disabled[id]
|
mod.enabled = not self.disabled[id]
|
||||||
mod.state = mod.enabled and "pending" or "disabled"
|
mod.state = mod.enabled and "pending" or "disabled"
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ function Manifest.validate(raw, path)
|
|||||||
permissionSet = permissionSet,
|
permissionSet = permissionSet,
|
||||||
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
||||||
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
|
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
|
||||||
|
force_enable_env = optionalFile(raw.force_enable_env, "force_enable_env"),
|
||||||
path = path,
|
path = path,
|
||||||
raw = raw,
|
raw = raw,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,8 +191,10 @@ end
|
|||||||
-- Never throws: a truncated download, an HTML error page, or a feed from a
|
-- Never throws: a truncated download, an HTML error page, or a feed from a
|
||||||
-- future schema all come back as a message the panel can print.
|
-- future schema all come back as a message the panel can print.
|
||||||
function ModIndex.parse(jsonText, Json)
|
function ModIndex.parse(jsonText, Json)
|
||||||
local ok, result, err = pcall(function()
|
|
||||||
Json = Json or require("src.link.Json")
|
Json = Json or require("src.link.Json")
|
||||||
|
local notJson = Json.describeUnexpected(jsonText)
|
||||||
|
if notJson then return nil, notJson end
|
||||||
|
local ok, result, err = pcall(function()
|
||||||
local doc, decodeErr = Json.decode(jsonText)
|
local doc, decodeErr = Json.decode(jsonText)
|
||||||
if type(doc) ~= "table" then
|
if type(doc) ~= "table" then
|
||||||
return nil, decodeErr or "index.json is not an object"
|
return nil, decodeErr or "index.json is not an object"
|
||||||
|
|||||||
@@ -140,8 +140,10 @@ end
|
|||||||
-- Decode a releases array (GET /repos/.../releases) into a sorted list
|
-- Decode a releases array (GET /repos/.../releases) into a sorted list
|
||||||
-- (newest first). Releases without a .zip asset are dropped. Never throws.
|
-- (newest first). Releases without a .zip asset are dropped. Never throws.
|
||||||
function ModUpdate.parseReleases(jsonText, modId, Json)
|
function ModUpdate.parseReleases(jsonText, modId, Json)
|
||||||
local ok, result, err = pcall(function()
|
|
||||||
Json = Json or require("src.link.Json")
|
Json = Json or require("src.link.Json")
|
||||||
|
local notJson = Json.describeUnexpected(jsonText)
|
||||||
|
if notJson then return nil, notJson end
|
||||||
|
local ok, result, err = pcall(function()
|
||||||
local doc, decodeErr = Json.decode(jsonText)
|
local doc, decodeErr = Json.decode(jsonText)
|
||||||
if type(doc) ~= "table" then
|
if type(doc) ~= "table" then
|
||||||
return nil, decodeErr or "releases json is not an array"
|
return nil, decodeErr or "releases json is not an array"
|
||||||
|
|||||||
@@ -577,6 +577,9 @@ R.trainers = {
|
|||||||
aiMods = f.opt(f.any),
|
aiMods = f.opt(f.any),
|
||||||
aiClass = f.opt(f.id("ai_classes")),
|
aiClass = f.opt(f.id("ai_classes")),
|
||||||
brain = f.opt(f.fn),
|
brain = f.opt(f.fn),
|
||||||
|
-- Per-trainer battle theme (an audio.songs id): overrides the
|
||||||
|
-- kind-based default (wild/trainer/gym/final) for this trainer's
|
||||||
|
-- battles. The victory jingle stays kind-based.
|
||||||
battleTheme = f.opt(f.id("music")),
|
battleTheme = f.opt(f.id("music")),
|
||||||
},
|
},
|
||||||
example = 'mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })',
|
example = 'mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })',
|
||||||
@@ -589,6 +592,13 @@ R.sprites = {
|
|||||||
image = f.path,
|
image = f.path,
|
||||||
frames = f.int(1),
|
frames = f.int(1),
|
||||||
walker = f.opt(f.bool),
|
walker = f.opt(f.bool),
|
||||||
|
-- Optional sheet geometry for mod actors. Defaults match the vanilla
|
||||||
|
-- 16x16 grounded walker; anchors are measured from each frame's
|
||||||
|
-- top-left in pixels (default: bottom-center).
|
||||||
|
frameWidth = f.opt(f.int(1)),
|
||||||
|
frameHeight = f.opt(f.int(1)),
|
||||||
|
anchorX = f.opt(f.num),
|
||||||
|
anchorY = f.opt(f.num),
|
||||||
trueColor = f.opt(f.bool),
|
trueColor = f.opt(f.bool),
|
||||||
-- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ
|
-- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ
|
||||||
-- palette assignment without claiming that the image itself came from
|
-- palette assignment without claiming that the image itself came from
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
-- Data-only per-mod persistence, scoped by game version and opaque playthrough.
|
||||||
|
-- This module is engine-private; Loader exposes only the bound facade methods.
|
||||||
|
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local SaveSerializer = require("src.core.SaveSerializer")
|
||||||
|
local Version = require("src.core.Version")
|
||||||
|
|
||||||
|
local Storage = {}
|
||||||
|
Storage.__index = Storage
|
||||||
|
|
||||||
|
local ROOT = "mod_storage"
|
||||||
|
|
||||||
|
local function failure(code, message)
|
||||||
|
return nil, code, message
|
||||||
|
end
|
||||||
|
|
||||||
|
local function validSegment(value)
|
||||||
|
return type(value) == "string" and value ~= ""
|
||||||
|
and value:match("^[%w_-]+$") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function validKey(key, allowEmpty)
|
||||||
|
if type(key) ~= "string" or (key == "" and not allowEmpty) then return false end
|
||||||
|
if key == "" then return true end
|
||||||
|
if key:sub(1, 1) == "/" or key:sub(-1) == "/" or key:find("//", 1, true) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
for segment in key:gmatch("[^/]+") do
|
||||||
|
if not validSegment(segment) then return false end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensureParent(fs, path)
|
||||||
|
local dir = path:match("^(.*)/[^/]+$")
|
||||||
|
if dir and fs.createDirectory then fs.createDirectory(dir) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function remove(fs, path)
|
||||||
|
if fs.remove then fs.remove(path) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function decodeAt(fs, path)
|
||||||
|
if not (fs.getInfo and fs.getInfo(path)) then return nil end
|
||||||
|
local body = fs.read and fs.read(path)
|
||||||
|
if type(body) ~= "string" then return nil end
|
||||||
|
local data = SaveSerializer.decode(body)
|
||||||
|
if not data then return nil end
|
||||||
|
return data, body
|
||||||
|
end
|
||||||
|
|
||||||
|
function Storage.new(modId, fs)
|
||||||
|
assert(validSegment(modId), "Storage.new needs a safe mod id")
|
||||||
|
return setmetatable({ modId = modId, injectedFs = fs }, Storage)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Storage:_scope(game)
|
||||||
|
local save = game and game.save
|
||||||
|
local meta = save and save.meta
|
||||||
|
local version = save and save.version
|
||||||
|
if not (save and validSegment(version)) then
|
||||||
|
return failure("not_in_playthrough",
|
||||||
|
"Storage is available only inside an identified playthrough.")
|
||||||
|
end
|
||||||
|
local playthroughId = meta and meta.playthroughId
|
||||||
|
if not validSegment(playthroughId) then
|
||||||
|
playthroughId = SaveData.ensurePlaythroughId(save, self.injectedFs)
|
||||||
|
end
|
||||||
|
if not validSegment(playthroughId) then
|
||||||
|
return failure("not_in_playthrough",
|
||||||
|
"Storage could not identify the active playthrough.")
|
||||||
|
end
|
||||||
|
local fs = SaveData.persistenceFs(self.injectedFs)
|
||||||
|
if not (fs and fs.read and fs.write and fs.getInfo) then
|
||||||
|
return failure("storage_unavailable", "The persistence backend is unavailable.")
|
||||||
|
end
|
||||||
|
local base = table.concat({ ROOT, version, playthroughId, self.modId }, "/")
|
||||||
|
return { gameVersion = version, playthroughId = playthroughId,
|
||||||
|
base = base, fs = fs }
|
||||||
|
end
|
||||||
|
|
||||||
|
function Storage:context(game)
|
||||||
|
local scope, code, message = self:_scope(game)
|
||||||
|
if not scope then return nil, code, message end
|
||||||
|
return {
|
||||||
|
engineVersion = Version.engine,
|
||||||
|
gameVersion = scope.gameVersion,
|
||||||
|
playthroughId = scope.playthroughId,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
function Storage:_names(game, key, allowEmpty)
|
||||||
|
if not validKey(key, allowEmpty) then
|
||||||
|
return failure("invalid_key",
|
||||||
|
"Storage keys use nonempty letters, numbers, underscore, dash and slash segments.")
|
||||||
|
end
|
||||||
|
local scope, code, message = self:_scope(game)
|
||||||
|
if not scope then return nil, code, message end
|
||||||
|
local path = scope.base .. (key ~= "" and ("/" .. key) or "")
|
||||||
|
return scope, path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp"
|
||||||
|
end
|
||||||
|
|
||||||
|
function Storage:write(game, key, value)
|
||||||
|
local scope, main, bak, tmp = self:_names(game, key, false)
|
||||||
|
if not scope then return false, main, bak end
|
||||||
|
if type(value) ~= "table" then
|
||||||
|
return false, "encode_failed", "Storage values must be data-only tables."
|
||||||
|
end
|
||||||
|
local encodedOk, encoded = pcall(SaveSerializer.encode, value)
|
||||||
|
if not encodedOk then
|
||||||
|
return false, "encode_failed", "Storage value is not serializable data: "
|
||||||
|
.. tostring(encoded)
|
||||||
|
end
|
||||||
|
|
||||||
|
local fs = scope.fs
|
||||||
|
ensureParent(fs, main)
|
||||||
|
local _, previous = decodeAt(fs, main)
|
||||||
|
if not previous then _, previous = decodeAt(fs, bak) end
|
||||||
|
|
||||||
|
local ok, err = fs.write(tmp, encoded)
|
||||||
|
if not ok then
|
||||||
|
return false, "write_failed", "Could not stage storage data: " .. tostring(err)
|
||||||
|
end
|
||||||
|
local staged = decodeAt(fs, tmp)
|
||||||
|
if not staged then
|
||||||
|
remove(fs, tmp)
|
||||||
|
return false, "verify_failed", "Staged storage data could not be verified."
|
||||||
|
end
|
||||||
|
|
||||||
|
if previous then fs.write(bak, previous) end
|
||||||
|
ok, err = fs.write(main, encoded)
|
||||||
|
if not ok then
|
||||||
|
remove(fs, tmp)
|
||||||
|
return false, "write_failed", "Could not replace storage data: " .. tostring(err)
|
||||||
|
end
|
||||||
|
local verified = decodeAt(fs, main)
|
||||||
|
if not verified then
|
||||||
|
remove(fs, main)
|
||||||
|
remove(fs, tmp)
|
||||||
|
return false, "verify_failed", "Replacement storage data could not be verified."
|
||||||
|
end
|
||||||
|
|
||||||
|
-- At rest both main and backup hold the newest verified record. If a later
|
||||||
|
-- write dies after rolling this copy aside, one verified generation remains.
|
||||||
|
fs.write(bak, encoded)
|
||||||
|
remove(fs, tmp)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function Storage:read(game, key)
|
||||||
|
local scope, main, bak, tmp = self:_names(game, key, false)
|
||||||
|
if not scope then return nil, main, bak end
|
||||||
|
local fs = scope.fs
|
||||||
|
local data, body = decodeAt(fs, main)
|
||||||
|
if data then return data end
|
||||||
|
|
||||||
|
data, body = decodeAt(fs, tmp)
|
||||||
|
if not data then data, body = decodeAt(fs, bak) end
|
||||||
|
if not data then
|
||||||
|
return nil, "not_found", "No valid stored value exists for this key."
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Best-effort healing. The recovered copy remains in tmp/bak if promotion
|
||||||
|
-- cannot land, so returning it is still safe and the next read can retry.
|
||||||
|
ensureParent(fs, main)
|
||||||
|
if fs.write(main, body) then fs.write(bak, body) end
|
||||||
|
remove(fs, tmp)
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
|
||||||
|
function Storage:list(game, prefix)
|
||||||
|
prefix = prefix or ""
|
||||||
|
local scope, main, codeOrBak = self:_names(game, prefix, true)
|
||||||
|
if not scope then return nil, main, codeOrBak end
|
||||||
|
local fs = scope.fs
|
||||||
|
if not fs.getDirectoryItems then
|
||||||
|
return nil, "storage_unavailable", "The persistence backend cannot enumerate keys."
|
||||||
|
end
|
||||||
|
|
||||||
|
local base = scope.base
|
||||||
|
local start = prefix == "" and base or (base .. "/" .. prefix)
|
||||||
|
local out = {}
|
||||||
|
|
||||||
|
local function walk(path, logical)
|
||||||
|
local info = fs.getInfo(path)
|
||||||
|
if not info then return end
|
||||||
|
if info.type == "file" then
|
||||||
|
if path:sub(-4) == ".lua" then out[#out + 1] = logical:sub(1, -5) end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
for _, child in ipairs(fs.getDirectoryItems(path) or {}) do
|
||||||
|
local childLogical = logical == "" and child or (logical .. "/" .. child)
|
||||||
|
walk(path .. "/" .. child, childLogical)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A prefix may identify one exact key or a directory of keys.
|
||||||
|
if fs.getInfo(start .. ".lua") then
|
||||||
|
out[#out + 1] = prefix
|
||||||
|
else
|
||||||
|
walk(start, prefix)
|
||||||
|
end
|
||||||
|
table.sort(out)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function Storage:delete(game, key)
|
||||||
|
local scope, main, bak, tmp = self:_names(game, key, false)
|
||||||
|
if not scope then return false, main, bak end
|
||||||
|
local fs = scope.fs
|
||||||
|
if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)) then
|
||||||
|
return false, "not_found", "No stored value exists for this key."
|
||||||
|
end
|
||||||
|
remove(fs, main)
|
||||||
|
remove(fs, bak)
|
||||||
|
remove(fs, tmp)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return Storage
|
||||||
+4
-2
@@ -137,8 +137,10 @@ function Font.load(data)
|
|||||||
-- typo'd path degrades exactly like a missing page image does above.
|
-- typo'd path degrades exactly like a missing page image does above.
|
||||||
if type(def.ttf) == "table" then
|
if type(def.ttf) == "table" then
|
||||||
local file = def.ttf.file or Font.PLAINPIXEL
|
local file = def.ttf.file or Font.PLAINPIXEL
|
||||||
local ok, obj = pcall(love.graphics.newFont, file,
|
local size = def.ttf.size or Font.PLAINPIXEL_SIZE
|
||||||
def.ttf.size or Font.PLAINPIXEL_SIZE, "mono")
|
-- The game renders into a pixel-exact canvas, so keep the TTF rasterizer
|
||||||
|
-- on that same 1x grid instead of inheriting the window DPI on mobile.
|
||||||
|
local ok, obj = pcall(love.graphics.newFont, file, size, "mono", 1)
|
||||||
if ok and obj then
|
if ok and obj then
|
||||||
-- nearest keeps the pixel font crisp under the integer UI scale
|
-- nearest keeps the pixel font crisp under the integer UI scale
|
||||||
if obj.setFilter then pcall(obj.setFilter, obj, "nearest", "nearest") end
|
if obj.setFilter then pcall(obj.setFilter, obj, "nearest", "nearest") end
|
||||||
|
|||||||
@@ -5,13 +5,15 @@
|
|||||||
|
|
||||||
local SecondScreen = {}
|
local SecondScreen = {}
|
||||||
local C = nil
|
local C = nil
|
||||||
|
local ffi = nil
|
||||||
|
|
||||||
local function log(msg)
|
local function log(msg)
|
||||||
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
|
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
|
||||||
end
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
local ok, ffi = pcall(require, "ffi")
|
local ok
|
||||||
|
ok, ffi = pcall(require, "ffi")
|
||||||
if not (ok and ffi) then
|
if not (ok and ffi) then
|
||||||
log("ffi unavailable (not LuaJIT); second display disabled")
|
log("ffi unavailable (not LuaJIT); second display disabled")
|
||||||
else
|
else
|
||||||
@@ -19,6 +21,7 @@ do
|
|||||||
int love_android_secondary_ready();
|
int love_android_secondary_ready();
|
||||||
void love_android_push_secondary(const void *rgba, int w, int h);
|
void love_android_push_secondary(const void *rgba, int w, int h);
|
||||||
void love_android_secondary_enable(int on);
|
void love_android_secondary_enable(int on);
|
||||||
|
const char *love_android_poll_secondary_touch();
|
||||||
]])
|
]])
|
||||||
local okLib, lib = pcall(ffi.load, "love")
|
local okLib, lib = pcall(ffi.load, "love")
|
||||||
if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then
|
if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then
|
||||||
@@ -51,6 +54,17 @@ function SecondScreen.push(imageData, w, h)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Returns the oldest queued secondary-display event as "action,x,y", where
|
||||||
|
-- coordinates are in the submitted frame's pixel space.
|
||||||
|
function SecondScreen.pollTouch()
|
||||||
|
if not C then return nil end
|
||||||
|
local ok, event = pcall(function()
|
||||||
|
return C.love_android_poll_secondary_touch()
|
||||||
|
end)
|
||||||
|
if not ok or event == nil or event == ffi.NULL then return nil end
|
||||||
|
return ffi.string(event)
|
||||||
|
end
|
||||||
|
|
||||||
function SecondScreen.setEnabled(on)
|
function SecondScreen.setEnabled(on)
|
||||||
if not C then return end
|
if not C then return end
|
||||||
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
|
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
|
||||||
|
|||||||
+137
-34
@@ -1,7 +1,8 @@
|
|||||||
-- Overworld character sprites. A 12-tile sheet (16x96 PNG) holds 6 16x16
|
-- Overworld character sprites. The vanilla 12-tile sheet (16x96 PNG) holds
|
||||||
-- frames: stand down/up/left, walk down/up/left (data/sprites/facings.asm).
|
-- 6 16x16 frames: stand down/up/left, walk down/up/left
|
||||||
|
-- (data/sprites/facings.asm). Mod records may opt into another frame size
|
||||||
|
-- and anchor; the defaults below preserve the original grounded placement.
|
||||||
-- Right-facing frames are horizontal flips of the left frames.
|
-- Right-facing frames are horizontal flips of the left frames.
|
||||||
-- Sprites draw 4px above their cell, like the GB engine.
|
|
||||||
|
|
||||||
local Assets = require("src.render.Assets")
|
local Assets = require("src.render.Assets")
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
@@ -76,6 +77,58 @@ local WALK = { down = 3, up = 4, left = 5, right = 5 }
|
|||||||
SpriteRenderer.STAND = STAND
|
SpriteRenderer.STAND = STAND
|
||||||
SpriteRenderer.WALK = WALK
|
SpriteRenderer.WALK = WALK
|
||||||
|
|
||||||
|
-- Sprite records are anchored at the point where the actor stands in the
|
||||||
|
-- world. In the vanilla renderer that point is the bottom-center of a
|
||||||
|
-- 16x16 frame: the frame starts at (px, py - 4), so the ground point is
|
||||||
|
-- (px + 8, py + 12). Custom anchors are measured from the frame's top-left
|
||||||
|
-- in sheet pixels and may be fractional for a sub-pixel art style.
|
||||||
|
local DEFAULT_FRAME_WIDTH = 16
|
||||||
|
local DEFAULT_FRAME_HEIGHT = 16
|
||||||
|
local DEFAULT_ANCHOR_X = 8
|
||||||
|
local DEFAULT_ANCHOR_Y = 16
|
||||||
|
local WORLD_ANCHOR_X = 8
|
||||||
|
local WORLD_ANCHOR_Y = 12
|
||||||
|
SpriteRenderer.DEFAULT_FRAME_WIDTH = DEFAULT_FRAME_WIDTH
|
||||||
|
SpriteRenderer.DEFAULT_FRAME_HEIGHT = DEFAULT_FRAME_HEIGHT
|
||||||
|
SpriteRenderer.DEFAULT_ANCHOR_X = DEFAULT_ANCHOR_X
|
||||||
|
SpriteRenderer.DEFAULT_ANCHOR_Y = DEFAULT_ANCHOR_Y
|
||||||
|
|
||||||
|
local function finiteNumber(value)
|
||||||
|
if type(value) ~= "number" or value ~= value
|
||||||
|
or value == math.huge or value == -math.huge then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
local function positiveInteger(value, fallback)
|
||||||
|
value = finiteNumber(value)
|
||||||
|
if value and value >= 1 then return math.floor(value) end
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
local function numberOr(value, fallback)
|
||||||
|
return finiteNumber(value) or fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
local function pose(self, facing, walkPhase, stepFlip)
|
||||||
|
if self.frameCount <= 1 then return 0, false end
|
||||||
|
local frame = (self.def.walker and walkPhase == 1)
|
||||||
|
and WALK[facing] or STAND[facing]
|
||||||
|
frame = frame or 0
|
||||||
|
-- Preserve the old fallback for a short custom sheet whose pose table
|
||||||
|
-- names a frame it does not provide.
|
||||||
|
if not self.frames[frame] then frame = 0 end
|
||||||
|
local flip = false
|
||||||
|
if facing == "right" then
|
||||||
|
flip = true
|
||||||
|
elseif (facing == "down" or facing == "up")
|
||||||
|
and walkPhase == 1 and stepFlip then
|
||||||
|
flip = true
|
||||||
|
end
|
||||||
|
return frame, flip
|
||||||
|
end
|
||||||
|
|
||||||
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
|
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
|
||||||
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
|
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
|
||||||
function SpriteRenderer.new(spriteDef, seed)
|
function SpriteRenderer.new(spriteDef, seed)
|
||||||
@@ -83,14 +136,62 @@ function SpriteRenderer.new(spriteDef, seed)
|
|||||||
self.def = spriteDef
|
self.def = spriteDef
|
||||||
self.seed = seed
|
self.seed = seed
|
||||||
self.image = getImage(spriteDef.image)
|
self.image = getImage(spriteDef.image)
|
||||||
|
self.frameCount = positiveInteger(spriteDef.frames, 1)
|
||||||
|
self.frameWidth = positiveInteger(spriteDef.frameWidth, DEFAULT_FRAME_WIDTH)
|
||||||
|
self.frameHeight = positiveInteger(spriteDef.frameHeight, DEFAULT_FRAME_HEIGHT)
|
||||||
|
self.anchorX = numberOr(spriteDef.anchorX, self.frameWidth / 2)
|
||||||
|
self.anchorY = numberOr(spriteDef.anchorY, self.frameHeight)
|
||||||
local iw, ih = self.image:getDimensions()
|
local iw, ih = self.image:getDimensions()
|
||||||
self.frames = {}
|
self.frames = {}
|
||||||
for f = 0, spriteDef.frames - 1 do
|
for f = 0, self.frameCount - 1 do
|
||||||
self.frames[f] = love.graphics.newQuad(0, f * 16, 16, 16, iw, ih)
|
self.frames[f] = love.graphics.newQuad(0, f * self.frameHeight,
|
||||||
|
self.frameWidth, self.frameHeight,
|
||||||
|
iw, ih)
|
||||||
end
|
end
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Return the sheet rectangle and top-left-relative anchor for a frame. The
|
||||||
|
-- result is a fresh table so a custom render pipeline may annotate it without
|
||||||
|
-- changing the renderer's shared definition.
|
||||||
|
function SpriteRenderer:getFrameGeometry(frame)
|
||||||
|
frame = math.floor(finiteNumber(frame) or 0)
|
||||||
|
if frame < 0 then frame = 0 end
|
||||||
|
if frame >= self.frameCount then frame = self.frameCount - 1 end
|
||||||
|
return {
|
||||||
|
frame = frame,
|
||||||
|
x = 0,
|
||||||
|
y = frame * self.frameHeight,
|
||||||
|
width = self.frameWidth,
|
||||||
|
height = self.frameHeight,
|
||||||
|
anchorX = self.anchorX,
|
||||||
|
anchorY = self.anchorY,
|
||||||
|
quad = self.frames[frame],
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Return the frame geometry selected by the ordinary 2D pose rules, plus the
|
||||||
|
-- horizontal mirror state that :draw applies. This is the supported hook for
|
||||||
|
-- custom render pipelines that need to draw actors with the same pose/flip.
|
||||||
|
function SpriteRenderer:getPoseGeometry(facing, walkPhase, stepFlip)
|
||||||
|
local frame, flip = pose(self, facing, walkPhase, stepFlip)
|
||||||
|
local geometry = self:getFrameGeometry(frame)
|
||||||
|
geometry.facing = facing
|
||||||
|
geometry.walkPhase = walkPhase
|
||||||
|
geometry.stepFlip = stepFlip
|
||||||
|
geometry.mirror = flip
|
||||||
|
return geometry
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Screen-space top-left for the actor's current world anchor. World-facing
|
||||||
|
-- effects such as fishing can use this instead of assuming a 16x16 frame.
|
||||||
|
function SpriteRenderer:getScreenOrigin(px, py, camX, camY)
|
||||||
|
local baseX = math.floor(px - camX) + WORLD_ANCHOR_X
|
||||||
|
local baseY = math.floor(py - camY) + WORLD_ANCHOR_Y
|
||||||
|
return math.floor(baseX - self.anchorX),
|
||||||
|
math.floor(baseY - self.anchorY)
|
||||||
|
end
|
||||||
|
|
||||||
-- The image this sprite would draw from right now: the plain sheet, or the
|
-- The image this sprite would draw from right now: the plain sheet, or the
|
||||||
-- OBP-recolored bake of it. Exposed so a render pipeline can texture its
|
-- OBP-recolored bake of it. Exposed so a render pipeline can texture its
|
||||||
-- own geometry from the very same image -- the geometry carries sheet pixel
|
-- own geometry from the very same image -- the geometry carries sheet pixel
|
||||||
@@ -125,27 +226,32 @@ end
|
|||||||
|
|
||||||
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
|
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
|
||||||
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
|
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
|
||||||
local function blitFrame(image, quad, x, y, flip, redraw)
|
local function blitFrame(image, quad, x, y, flip, redraw, frameWidth)
|
||||||
|
frameWidth = frameWidth or DEFAULT_FRAME_WIDTH
|
||||||
if flip then
|
if flip then
|
||||||
love.graphics.draw(image, quad, x + 16, y, 0, -1, 1)
|
love.graphics.draw(image, quad, x + frameWidth, y, 0, -1, 1)
|
||||||
if redraw then PaletteFX.markSpriteRedraw(image, quad, x + 16, y, -1) end
|
if redraw then
|
||||||
|
PaletteFX.markSpriteRedraw(image, quad, x + frameWidth, y, -1)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
love.graphics.draw(image, quad, x, y)
|
love.graphics.draw(image, quad, x, y)
|
||||||
if redraw then PaletteFX.markSpriteRedraw(image, quad, x, y, 1) end
|
if redraw then PaletteFX.markSpriteRedraw(image, quad, x, y, 1) end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- topHalf blits only the upper 8 rows of the frame: FishingAnim overwrites the
|
-- topHalf blits everything above the bottom 8-pixel tile row: FishingAnim
|
||||||
-- bottom tile row of the standing frames with the fishing pose art, which the
|
-- overwrites that row of the standing frames with fishing pose art, which the
|
||||||
-- caller then draws itself through :drawTile (Player:draw, #384)
|
-- caller then draws itself through :drawTile (Player:draw, #384). Vanilla
|
||||||
|
-- frames therefore still draw 8 rows, while taller frames keep their larger
|
||||||
|
-- body and reserve only the overlay row.
|
||||||
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf)
|
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf)
|
||||||
local x = math.floor(px - camX)
|
local x, y = self:getScreenOrigin(px, py, camX, camY)
|
||||||
local y = math.floor(py - camY) - 4
|
|
||||||
local image = self.image
|
local image = self.image
|
||||||
local redraw = false
|
local redraw = false
|
||||||
-- full-color art claims its 16x16 cell out of the shade-remap pass
|
-- True-color sheets bypass every palette bake; the screen-space exemption
|
||||||
|
-- is recorded below once the final frame/height is known.
|
||||||
if self.def.trueColor then
|
if self.def.trueColor then
|
||||||
PaletteFX.markTrueColor(x, y, 16, 16)
|
image = self.image
|
||||||
elseif PaletteFX.usesGbcPack() then
|
elseif PaletteFX.usesGbcPack() then
|
||||||
-- RED++: the world canvas is already true-color (TileRenderer bakes
|
-- RED++: the world canvas is already true-color (TileRenderer bakes
|
||||||
-- terrain, this bakes the sprite) and the world pass runs unshaded
|
-- terrain, this bakes the sprite) and the world pass runs unshaded
|
||||||
@@ -177,31 +283,28 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, to
|
|||||||
-- being colorized by the zone IS the point (#301).
|
-- being colorized by the zone IS the point (#301).
|
||||||
image = getObpImage(self.def.image, PaletteFX.dmgObj())
|
image = getObpImage(self.def.image, PaletteFX.dmgObj())
|
||||||
end
|
end
|
||||||
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
-- Single-frame sprites (item balls, fossils...) have one fixed pose;
|
||||||
-- still 3-frame sprites turn to face (the nurse at her machine,
|
-- still 3-frame sprites turn to face (the nurse at her machine,
|
||||||
-- facePlayer on STAY NPCs) but never show walk frames
|
-- facePlayer on STAY NPCs) but never show walk frames.
|
||||||
if self.def.frames <= 1 then
|
local frame, flip = pose(self, facing, walkPhase, stepFlip)
|
||||||
blitFrame(image, self.frames[0], x, y, false, redraw)
|
local quad = self.frames[frame]
|
||||||
return
|
local drawHeight = self.frameHeight
|
||||||
end
|
if topHalf and self.frameCount > 1 then
|
||||||
local frame = (self.def.walker and walkPhase == 1)
|
|
||||||
and WALK[facing] or STAND[facing]
|
|
||||||
local flip = false
|
|
||||||
if facing == "right" then
|
|
||||||
flip = true
|
|
||||||
elseif (facing == "down" or facing == "up") and walkPhase == 1 and stepFlip then
|
|
||||||
flip = true
|
|
||||||
end
|
|
||||||
local quad = self.frames[frame] or self.frames[0]
|
|
||||||
if topHalf then
|
|
||||||
self.halfFrames = self.halfFrames or {}
|
self.halfFrames = self.halfFrames or {}
|
||||||
if not self.halfFrames[frame] then
|
if not self.halfFrames[frame] then
|
||||||
local iw, ih = self.image:getDimensions()
|
local iw, ih = self.image:getDimensions()
|
||||||
self.halfFrames[frame] = love.graphics.newQuad(0, frame * 16, 16, 8, iw, ih)
|
local topHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight))
|
||||||
|
self.halfFrames[frame] = love.graphics.newQuad(
|
||||||
|
0, frame * self.frameHeight, self.frameWidth, topHeight, iw, ih)
|
||||||
end
|
end
|
||||||
quad = self.halfFrames[frame]
|
quad = self.halfFrames[frame]
|
||||||
|
drawHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight))
|
||||||
end
|
end
|
||||||
blitFrame(image, quad, x, y, flip, redraw)
|
-- Full-color art claims exactly the portion of the frame that was drawn.
|
||||||
|
if self.def.trueColor then
|
||||||
|
PaletteFX.markTrueColor(x, y, self.frameWidth, drawHeight)
|
||||||
|
end
|
||||||
|
blitFrame(image, quad, x, y, flip, redraw, self.frameWidth)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Blit a loose 16-wide fx tile at screen (x, y) wearing THIS sprite's OBJ
|
-- Blit a loose 16-wide fx tile at screen (x, y) wearing THIS sprite's OBJ
|
||||||
@@ -225,7 +328,7 @@ function SpriteRenderer:drawTile(path, x, y, flip)
|
|||||||
self.tileQuads = self.tileQuads or {}
|
self.tileQuads = self.tileQuads or {}
|
||||||
self.tileQuads[path] = self.tileQuads[path]
|
self.tileQuads[path] = self.tileQuads[path]
|
||||||
or love.graphics.newQuad(0, 0, iw, ih, iw, ih)
|
or love.graphics.newQuad(0, 0, iw, ih, iw, ih)
|
||||||
blitFrame(image, self.tileQuads[path], x, y, flip, redraw)
|
blitFrame(image, self.tileQuads[path], x, y, flip, redraw, iw)
|
||||||
end
|
end
|
||||||
|
|
||||||
return SpriteRenderer
|
return SpriteRenderer
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
-- the text is exhausted and A is pressed, then calls onDone.
|
-- the text is exhausted and A is pressed, then calls onDone.
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Theme = require("src.ui.Theme")
|
local Theme = require("src.ui.Theme")
|
||||||
local Timing = require("src.core.Timing")
|
local Timing = require("src.core.Timing")
|
||||||
|
|
||||||
local TextBox = {}
|
local TextBox = {}
|
||||||
TextBox.__index = TextBox
|
TextBox.__index = TextBox
|
||||||
|
TextBox.isTextBox = true
|
||||||
|
|
||||||
-- theme-free fallbacks; geometry resolves against Theme.textBox at
|
-- theme-free fallbacks; geometry resolves against Theme.textBox at
|
||||||
-- construction time, so an unthemed boot stays byte-identical
|
-- construction time, so an unthemed boot stays byte-identical
|
||||||
@@ -344,6 +346,11 @@ function TextBox:update(dt)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function TextBox:draw()
|
function TextBox:draw()
|
||||||
|
if Runtime.wantsHook("battle.bottom_ui_visible")
|
||||||
|
and Runtime.call("battle.bottom_ui_visible", function() return true end,
|
||||||
|
self) == false then
|
||||||
|
return
|
||||||
|
end
|
||||||
-- The dialogue box belongs against the bottom of the screen, not floating
|
-- The dialogue box belongs against the bottom of the screen, not floating
|
||||||
-- in the middle of a zoomed-out letterbox. Declared per frame; the
|
-- in the middle of a zoomed-out letterbox. Declared per frame; the
|
||||||
-- renderer blits this region to the screen edge and the rest of the UI
|
-- renderer blits this region to the screen edge and the rest of the UI
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
-- game regenerates all of it from wCurMap on the next map load anyway.
|
-- game regenerates all of it from wCurMap on the next map load anyway.
|
||||||
|
|
||||||
local bit = require("bit")
|
local bit = require("bit")
|
||||||
|
local MapContext = require("src.save_convert.MapContext")
|
||||||
|
|
||||||
local GenSave = {}
|
local GenSave = {}
|
||||||
|
|
||||||
@@ -1023,6 +1024,44 @@ function GenSave.encode(save, data, template)
|
|||||||
setByte(buf, O.lastMap, cw.mapsIndex[save.lastOutdoor.id] or 0)
|
setByte(buf, O.lastMap, cw.mapsIndex[save.lastOutdoor.id] or 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Current-map engine state (see src/save_convert/MapContext.lua). A
|
||||||
|
-- Continue restores this window from the save and never rebuilds it, so a
|
||||||
|
-- zero-filled one boots into a garbled map on a silent hang (#889).
|
||||||
|
--
|
||||||
|
-- Rebuilt when there is no template at all (a save that began as a New Game
|
||||||
|
-- in this port), and when the template was saved on a DIFFERENT map than the
|
||||||
|
-- one the player is standing on now -- an imported save that has since been
|
||||||
|
-- played carries the old map's header, which is just as unbootable. A
|
||||||
|
-- template still on its own map keeps its bytes untouched: they are the
|
||||||
|
-- game's own, including live NPC positions, and preserving them is what
|
||||||
|
-- makes import -> export byte-identical.
|
||||||
|
local mapId = save.player and save.player.map
|
||||||
|
if mapId then
|
||||||
|
local rebuild = true
|
||||||
|
if src then
|
||||||
|
-- compare the way the byte was written (masked), and rebuild when the
|
||||||
|
-- map has no index at all rather than trusting a stale template
|
||||||
|
local index = cw.mapsIndex[mapId]
|
||||||
|
rebuild = index == nil or u8(src, O.curMap) ~= bit.band(index, 0xFF)
|
||||||
|
end
|
||||||
|
if rebuild then
|
||||||
|
local ctx = MapContext.build(data, mapId,
|
||||||
|
(save.player and save.player.x) or 0, (save.player and save.player.y) or 0)
|
||||||
|
if ctx then
|
||||||
|
for offset, values in pairs(ctx.writes) do
|
||||||
|
for i, value in ipairs(values) do
|
||||||
|
setByte(buf, O.mainData + offset + i - 1, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for i, value in ipairs(ctx.spriteData) do
|
||||||
|
setByte(buf, O.spriteData + i - 1, value)
|
||||||
|
end
|
||||||
|
-- sTileAnimations, the byte between sCurBoxData and the checksum
|
||||||
|
setByte(buf, O.checksumEnd - 1, ctx.tileAnimations)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- play time: split save.playTime (seconds) back into H/M/S/F. The real
|
-- play time: split save.playTime (seconds) back into H/M/S/F. The real
|
||||||
-- game freezes the clock at 255h and sets wPlayTimeMaxed once past it, so
|
-- game freezes the clock at 255h and sets wPlayTimeMaxed once past it, so
|
||||||
-- mirror that cap rather than letting hours overflow a single byte.
|
-- mirror that cap rather than letting hours overflow a single byte.
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
-- MapContext -- the current map's engine state, as a vanilla Gen1 save has to
|
||||||
|
-- carry it (#889).
|
||||||
|
--
|
||||||
|
-- Continuing a real save never re-loads the map header. LoadMainData
|
||||||
|
-- (engine/menus/save.asm) copies sMainData back into WRAM and then sets
|
||||||
|
-- BIT_NO_PREVIOUS_MAP in wCurMapTileset; LoadMapHeader (home/overworld.asm)
|
||||||
|
-- clears that bit and RETURNS IMMEDIATELY when it was set, so every byte it
|
||||||
|
-- would otherwise have written -- the map header, connections, warps, signs,
|
||||||
|
-- sprites, the tileset header, the map's music -- comes straight out of the
|
||||||
|
-- save file. A cartridge save always has them because the game saved them.
|
||||||
|
--
|
||||||
|
-- An export from this port did not: GenSave models the player's progress, not
|
||||||
|
-- the engine scratch around it, and left that whole window zero-filled unless
|
||||||
|
-- it had an imported SRAM image to copy from. The game then continued into
|
||||||
|
-- tileset 0 with a $0000 map-data pointer and sound id 0, which is the
|
||||||
|
-- garbled overworld / silent hang users reported after exporting a save that
|
||||||
|
-- began as a New Game in the port.
|
||||||
|
--
|
||||||
|
-- This module rebuilds the window from the extracted ROM data, replaying the
|
||||||
|
-- same writes LoadMapHeader makes, so a port-origin save boots on hardware.
|
||||||
|
-- The offsets are all relative to wMainDataStart (= sMainData in a .sav) and
|
||||||
|
-- were summed from ram/wram.asm, then checked byte-for-byte against a real
|
||||||
|
-- cartridge save: rebuilding its current map reproduces the bytes it already
|
||||||
|
-- held, everywhere except the stale tails of disabled connection slots (which
|
||||||
|
-- the engine never reads once the connected-map byte is $FF).
|
||||||
|
--
|
||||||
|
-- Pure Lua, no love.*: shared by the runtime exporter, the CLI and the tests.
|
||||||
|
|
||||||
|
local MapContext = {}
|
||||||
|
|
||||||
|
-- wMainDataStart-relative offsets. Anchors: wCurMap is +103 (the offset
|
||||||
|
-- GenSave already decodes the player's map from), and the run from there is
|
||||||
|
-- wCurMap, wCurrentTileBlockMapViewPointer(2), wYCoord, wXCoord, wYBlockCoord,
|
||||||
|
-- wXBlockCoord, wLastMap, wUnusedLastMapWidth, wCurMapHeader -- so the header
|
||||||
|
-- lands at +112, and every later label follows from the declaration sizes in
|
||||||
|
-- ram/wram.asm (MAX_WARP_EVENTS 32, MAX_BG_EVENTS 16, MAX_OBJECT_EVENTS 16,
|
||||||
|
-- SPRITE_SET_LENGTH 11).
|
||||||
|
local O = {
|
||||||
|
mapMusicSoundID = 100, -- wMapMusicSoundID
|
||||||
|
mapMusicROMBank = 101, -- wMapMusicROMBank
|
||||||
|
viewPointer = 104, -- wCurrentTileBlockMapViewPointer (2)
|
||||||
|
yCoord = 106,
|
||||||
|
xCoord = 107,
|
||||||
|
yBlockCoord = 108,
|
||||||
|
xBlockCoord = 109,
|
||||||
|
curMapHeader = 112, -- wCurMapHeader (10)
|
||||||
|
connectionHeaders = 122, -- wNorth/South/West/EastConnectionHeader (4 x 11)
|
||||||
|
mapBackgroundTile = 182,
|
||||||
|
numberOfWarps = 183,
|
||||||
|
warpEntries = 184, -- MAX_WARP_EVENTS x 4
|
||||||
|
numSigns = 441,
|
||||||
|
signCoords = 442, -- MAX_BG_EVENTS x 2
|
||||||
|
signTextIDs = 474, -- MAX_BG_EVENTS
|
||||||
|
numSprites = 490,
|
||||||
|
mapSpriteData = 493, -- MAX_OBJECT_EVENTS x 2
|
||||||
|
mapSpriteExtra = 525, -- MAX_OBJECT_EVENTS x 2
|
||||||
|
currentMapHeight2 = 557,
|
||||||
|
currentMapWidth2 = 558,
|
||||||
|
tilesetHeader = 564, -- wTilesetBank .. wGrassTile (11)
|
||||||
|
}
|
||||||
|
MapContext.OFFSETS = O
|
||||||
|
|
||||||
|
local MAX_WARP_EVENTS = 32
|
||||||
|
local MAX_BG_EVENTS = 16
|
||||||
|
local MAX_OBJECT_EVENTS = 16
|
||||||
|
local CONNECTION_STRUCT = 11 -- map_connection_struct (macros/ram.asm)
|
||||||
|
local SPRITE_STRUCT = 16 -- one wSpriteStateData1/2 entry
|
||||||
|
local NUM_SPRITE_STRUCTS = 16
|
||||||
|
|
||||||
|
-- wOverworldMap, the tile-block map the view pointer indexes. Recovered from
|
||||||
|
-- the event_displacement formula below applied to a real save's coordinates,
|
||||||
|
-- and it is the same $C6E8 every Gen1 reference quotes.
|
||||||
|
local OVERWORLD_MAP = 0xC6E8
|
||||||
|
|
||||||
|
-- Audio header tables start at $4000 in each of the three audio banks, and
|
||||||
|
-- constants/music_constants.asm derives every song id arithmetically from
|
||||||
|
-- that base ("Song ids are calculated by address", music_const:
|
||||||
|
-- (Music_X - SFX_Headers_1) / 3). So the extracted header address IS the id,
|
||||||
|
-- and no separate MapSongBanks extraction is needed.
|
||||||
|
local SFX_HEADERS_BASE = 0x4000
|
||||||
|
|
||||||
|
local function soundId(address)
|
||||||
|
if type(address) ~= "number" then return nil end
|
||||||
|
local delta = address - SFX_HEADERS_BASE
|
||||||
|
if delta < 0 or delta % 3 ~= 0 then return nil end
|
||||||
|
local id = delta / 3
|
||||||
|
if id > 0xFF then return nil end
|
||||||
|
return id
|
||||||
|
end
|
||||||
|
|
||||||
|
-- LoadMapHeader parses the map's object data (data/maps/objects/*.asm) into
|
||||||
|
-- five separate WRAM arrays. Walk it exactly as the engine does; `bytes` is
|
||||||
|
-- the 1-based array of raw object-data bytes the extractor captured.
|
||||||
|
local function parseObjects(bytes)
|
||||||
|
local pos = 1
|
||||||
|
local function take()
|
||||||
|
local b = bytes[pos]
|
||||||
|
pos = pos + 1
|
||||||
|
return b
|
||||||
|
end
|
||||||
|
local out = { warps = {}, signCoords = {}, signTexts = {},
|
||||||
|
spriteData = {}, spriteExtra = {}, sprites = {} }
|
||||||
|
|
||||||
|
out.backgroundTile = take()
|
||||||
|
|
||||||
|
local warpCount = take()
|
||||||
|
if warpCount == nil then return nil end
|
||||||
|
out.warpCount = warpCount
|
||||||
|
for _ = 1, math.min(warpCount, MAX_WARP_EVENTS) * 4 do
|
||||||
|
out.warps[#out.warps + 1] = take()
|
||||||
|
end
|
||||||
|
|
||||||
|
local signCount = take()
|
||||||
|
if signCount == nil then return nil end
|
||||||
|
out.signCount = signCount
|
||||||
|
for _ = 1, math.min(signCount, MAX_BG_EVENTS) do
|
||||||
|
out.signCoords[#out.signCoords + 1] = take() -- Y
|
||||||
|
out.signCoords[#out.signCoords + 1] = take() -- X
|
||||||
|
out.signTexts[#out.signTexts + 1] = take()
|
||||||
|
end
|
||||||
|
|
||||||
|
local spriteCount = take()
|
||||||
|
if spriteCount == nil then return nil end
|
||||||
|
out.spriteCount = spriteCount
|
||||||
|
for _ = 1, math.min(spriteCount, MAX_OBJECT_EVENTS) do
|
||||||
|
local picture, mapY, mapX = take(), take(), take()
|
||||||
|
local movement1, movement2, textId = take(), take(), take()
|
||||||
|
if textId == nil then return nil end
|
||||||
|
out.sprites[#out.sprites + 1] = {
|
||||||
|
picture = picture, mapY = mapY, mapX = mapX, movement1 = movement1,
|
||||||
|
}
|
||||||
|
-- wMapSpriteData: movement byte 2, then the text id with its trainer/item
|
||||||
|
-- flag bits masked off (LoadMapHeader's `and $3f`).
|
||||||
|
out.spriteData[#out.spriteData + 1] = movement2
|
||||||
|
out.spriteData[#out.spriteData + 1] = textId % 0x40
|
||||||
|
-- BIT_TRAINER ($40) is tested before BIT_ITEM ($80), as LoadMapHeader does
|
||||||
|
if textId % 0x80 >= 0x40 then -- trainer: class, then party id
|
||||||
|
out.spriteExtra[#out.spriteExtra + 1] = take()
|
||||||
|
out.spriteExtra[#out.spriteExtra + 1] = take()
|
||||||
|
elseif textId >= 0x80 then -- item ball: item id, second byte unused
|
||||||
|
out.spriteExtra[#out.spriteExtra + 1] = take()
|
||||||
|
out.spriteExtra[#out.spriteExtra + 1] = 0
|
||||||
|
else
|
||||||
|
out.spriteExtra[#out.spriteExtra + 1] = 0
|
||||||
|
out.spriteExtra[#out.spriteExtra + 1] = 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- build(data, mapId, x, y) -> ctx, err
|
||||||
|
--
|
||||||
|
-- ctx.writes [wMainDataStart-relative offset] = array of bytes
|
||||||
|
-- ctx.spriteData 512 bytes for sSpriteData (wSpriteStateData1 then 2)
|
||||||
|
-- ctx.tileAnimations the byte sTileAnimations holds
|
||||||
|
--
|
||||||
|
-- Returns nil plus a reason when the data set predates the extractor fields
|
||||||
|
-- this needs (an older ROM cache), so callers can fall back rather than fail.
|
||||||
|
function MapContext.build(data, mapId, x, y)
|
||||||
|
local map = data and data.maps and data.maps[mapId]
|
||||||
|
if not map then return nil, "unknown map " .. tostring(mapId) end
|
||||||
|
local sram = map.sram
|
||||||
|
if not (sram and sram.header and sram.objects) then
|
||||||
|
return nil, "map cache has no saved-map bytes (re-import the ROM)"
|
||||||
|
end
|
||||||
|
x, y = math.floor(tonumber(x) or 0), math.floor(tonumber(y) or 0)
|
||||||
|
|
||||||
|
local writes = {}
|
||||||
|
local header = sram.header
|
||||||
|
writes[O.curMapHeader] = header
|
||||||
|
local height, width = header[2], header[3]
|
||||||
|
local connectionFlags = header[10]
|
||||||
|
|
||||||
|
-- Connections: LoadMapHeader writes $FF over all four connected-map bytes
|
||||||
|
-- and then copies 11 bytes per present direction, north/south/west/east.
|
||||||
|
-- The disabled slots' remaining bytes are never read.
|
||||||
|
local conn = {}
|
||||||
|
for i = 1, 4 * CONNECTION_STRUCT do conn[i] = 0 end
|
||||||
|
for slot = 0, 3 do conn[slot * CONNECTION_STRUCT + 1] = 0xFF end
|
||||||
|
local pos = 1
|
||||||
|
for slot, flag in ipairs({ 0x08, 0x04, 0x02, 0x01 }) do
|
||||||
|
if math.floor(connectionFlags / flag) % 2 == 1 then
|
||||||
|
for i = 0, CONNECTION_STRUCT - 1 do
|
||||||
|
conn[(slot - 1) * CONNECTION_STRUCT + 1 + i] = (sram.connections or {})[pos + i] or 0
|
||||||
|
end
|
||||||
|
pos = pos + CONNECTION_STRUCT
|
||||||
|
end
|
||||||
|
end
|
||||||
|
writes[O.connectionHeaders] = conn
|
||||||
|
|
||||||
|
local parsed = parseObjects(sram.objects)
|
||||||
|
if not parsed then return nil, "malformed object data for " .. tostring(mapId) end
|
||||||
|
writes[O.mapBackgroundTile] = { parsed.backgroundTile }
|
||||||
|
writes[O.numberOfWarps] = { parsed.warpCount }
|
||||||
|
writes[O.warpEntries] = parsed.warps
|
||||||
|
writes[O.numSigns] = { parsed.signCount }
|
||||||
|
writes[O.signCoords] = parsed.signCoords
|
||||||
|
writes[O.signTextIDs] = parsed.signTexts
|
||||||
|
writes[O.numSprites] = { parsed.spriteCount }
|
||||||
|
writes[O.mapSpriteData] = parsed.spriteData
|
||||||
|
writes[O.mapSpriteExtra] = parsed.spriteExtra
|
||||||
|
|
||||||
|
-- "map height/width in 2x2 tile blocks", doubled at the end of LoadMapHeader
|
||||||
|
writes[O.currentMapHeight2] = { (height * 2) % 256 }
|
||||||
|
writes[O.currentMapWidth2] = { (width * 2) % 256 }
|
||||||
|
|
||||||
|
-- The tileset header, as predef LoadTilesetHeader would have copied it.
|
||||||
|
local tilesets = data.tilesets or {}
|
||||||
|
local tilesetDef = tilesets[map.tileset]
|
||||||
|
local tileAnimations = 0
|
||||||
|
if tilesetDef and tilesetDef.header then
|
||||||
|
local row = {}
|
||||||
|
for i = 1, 11 do row[i] = tilesetDef.header[i] end
|
||||||
|
writes[O.tilesetHeader] = row
|
||||||
|
tileAnimations = tilesetDef.header[12] or 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- MapSongBanks: without it the game continues with sound id 0 and audio
|
||||||
|
-- bank 0, which is what actually hangs a Continue on a white screen.
|
||||||
|
local audio = data.audio
|
||||||
|
local songLabel = audio and audio.mapSongs and audio.mapSongs[mapId]
|
||||||
|
local song = songLabel and audio.songs and audio.songs[songLabel]
|
||||||
|
local id = song and soundId(song.address)
|
||||||
|
if id and song.bank then
|
||||||
|
writes[O.mapMusicSoundID] = { id }
|
||||||
|
writes[O.mapMusicROMBank] = { song.bank % 256 }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Player position within its block, and the upper-left corner of the view.
|
||||||
|
-- The pointer is the same expression the warp_to tables are assembled with
|
||||||
|
-- (macros/coords.asm event_displacement).
|
||||||
|
writes[O.yBlockCoord] = { y % 2 }
|
||||||
|
writes[O.xBlockCoord] = { x % 2 }
|
||||||
|
local view = OVERWORLD_MAP + 7 + width
|
||||||
|
+ (width + 6) * math.floor(y / 2) + math.floor(x / 2)
|
||||||
|
writes[O.viewPointer] = { view % 256, math.floor(view / 256) % 256 }
|
||||||
|
|
||||||
|
-- sSpriteData. LoadMapHeader zeroes structs 1-15, sets their image index to
|
||||||
|
-- $ff (off screen until the engine places them), then fills in each map
|
||||||
|
-- object's picture id and its map Y/X plus movement byte 1. Struct 0 is the
|
||||||
|
-- player, which SpecialEnterMap rebuilds through ResetPlayerSpriteData.
|
||||||
|
local spriteData = {}
|
||||||
|
for i = 1, NUM_SPRITE_STRUCTS * SPRITE_STRUCT * 2 do spriteData[i] = 0 end
|
||||||
|
local data2 = NUM_SPRITE_STRUCTS * SPRITE_STRUCT
|
||||||
|
for slot = 1, NUM_SPRITE_STRUCTS - 1 do
|
||||||
|
spriteData[slot * SPRITE_STRUCT + 2 + 1] = 0xFF
|
||||||
|
end
|
||||||
|
for index, sprite in ipairs(parsed.sprites) do
|
||||||
|
local base = index * SPRITE_STRUCT
|
||||||
|
spriteData[base + 1] = sprite.picture
|
||||||
|
spriteData[data2 + base + 4 + 1] = sprite.mapY
|
||||||
|
spriteData[data2 + base + 5 + 1] = sprite.mapX
|
||||||
|
spriteData[data2 + base + 6 + 1] = sprite.movement1
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
writes = writes,
|
||||||
|
spriteData = spriteData,
|
||||||
|
tileAnimations = tileAnimations,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return MapContext
|
||||||
@@ -42,11 +42,17 @@ local DATA_MODULES = {
|
|||||||
moves = { "data.generated.moves", "data/generated/moves.lua" },
|
moves = { "data.generated.moves", "data/generated/moves.lua" },
|
||||||
items = { "data.generated.items", "data/generated/items.lua" },
|
items = { "data.generated.items", "data/generated/items.lua" },
|
||||||
maps = { "data.generated.maps", "data/generated/maps.lua" },
|
maps = { "data.generated.maps", "data/generated/maps.lua" },
|
||||||
|
-- tilesets/audio are only read by src/save_convert/MapContext.lua, to
|
||||||
|
-- rebuild the current map's engine state on export (#889)
|
||||||
|
tilesets = { "data.generated.tilesets", "data/generated/tilesets.lua" },
|
||||||
|
audio = { "data.generated.audio", "data/generated/audio.lua" },
|
||||||
charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" },
|
charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" },
|
||||||
eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" },
|
eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" },
|
||||||
toggleObjects = { "src.save_convert.data.toggle_objects", "src/save_convert/data/toggle_objects.lua" },
|
toggleObjects = { "src.save_convert.data.toggle_objects", "src/save_convert/data/toggle_objects.lua" },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
local OPTIONAL_MODULES = { tilesets = true, audio = true }
|
||||||
|
|
||||||
-- Yellow renumbers wEventFlags bits: pokeyellow's constants/event_constants.asm
|
-- Yellow renumbers wEventFlags bits: pokeyellow's constants/event_constants.asm
|
||||||
-- inserts events pokered does not have (the Jessie & James fights, catch
|
-- inserts events pokered does not have (the Jessie & James fights, catch
|
||||||
-- training, the Officer Jenny Squirtle) and shifts the Mt Moon 3 / Silph Co
|
-- training, the Officer Jenny Squirtle) and shifts the Mt Moon 3 / Silph Co
|
||||||
@@ -132,7 +138,10 @@ local function ensureData(gameVersion)
|
|||||||
if not mod then
|
if not mod then
|
||||||
local e
|
local e
|
||||||
mod, e = loadTable(spec[1], spec[2])
|
mod, e = loadTable(spec[1], spec[2])
|
||||||
if not mod then return nil, e end
|
-- tilesets/audio only sharpen the export (MapContext); a cache
|
||||||
|
-- without them still imports and exports, just without the
|
||||||
|
-- rebuilt map window, so they must not fail the whole load
|
||||||
|
if not mod and not OPTIONAL_MODULES[name] then return nil, e end
|
||||||
end
|
end
|
||||||
data[name] = mod
|
data[name] = mod
|
||||||
end
|
end
|
||||||
|
|||||||
+14
-4
@@ -661,9 +661,8 @@ end
|
|||||||
-- AskName runs for party (AddPartyMon) and box (SendNewMonToBox) when a
|
-- AskName runs for party (AddPartyMon) and box (SendNewMonToBox) when a
|
||||||
-- script runner is present; mods that pre-set gift.nickname skip it.
|
-- script runner is present; mods that pre-set gift.nickname skip it.
|
||||||
-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37).
|
-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37).
|
||||||
-- skipNickname suppresses the AskName prompt: Yellow's lab Pikachu is
|
-- skipNickname suppresses AskName for callers that name the gift themselves;
|
||||||
-- added straight through AddPartyMon (pokeyellow scripts/OaksLab.asm
|
-- no vanilla script uses it (pokeyellow scripts/OaksLab.asm, #1013)
|
||||||
-- OaksLabPlayerReceivedMonText) -- the starter Pikachu keeps its name.
|
|
||||||
function Commands.give_pokemon(ctx, species, level, skipNickname)
|
function Commands.give_pokemon(ctx, species, level, skipNickname)
|
||||||
-- 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:
|
||||||
@@ -1097,6 +1096,17 @@ function Commands.march_in_place(ctx, objIndex, on)
|
|||||||
ow.marchers[npc] = on and true or nil
|
ow.marchers[npc] = on and true or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- pikachu_make_way: callfar OaksLabPikachuMovementScript (pokeyellow
|
||||||
|
-- scripts/OaksLab_2.asm); a no-op without a Yellow follower (#1021)
|
||||||
|
function Commands.pikachu_make_way(ctx)
|
||||||
|
local ow = ctx.overworld
|
||||||
|
if not ow then return end
|
||||||
|
local runner = ctx.runner
|
||||||
|
local started = require("src.world.PikachuFollower")
|
||||||
|
.oaksLabMakeWay(ctx.game, ow, function() runner:resume() end)
|
||||||
|
if started then runner:yield() end
|
||||||
|
end
|
||||||
|
|
||||||
-- play_music <songId> [opts]: switch map music now; opts.keep marks it
|
-- play_music <songId> [opts]: switch map music now; opts.keep marks it
|
||||||
-- to survive the next warp (the story files' keepMusic idiom).
|
-- to survive the next warp (the story files' keepMusic idiom).
|
||||||
-- opts.tempo is the Music_*AlternateTempo override (audio/alternate_tempo.asm
|
-- opts.tempo is the Music_*AlternateTempo override (audio/alternate_tempo.asm
|
||||||
@@ -1359,7 +1369,7 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
|
|||||||
"old_man_demo", "static_battle", "rival_battle", "give_item",
|
"old_man_demo", "static_battle", "rival_battle", "give_item",
|
||||||
"give_pokemon", "wait",
|
"give_pokemon", "wait",
|
||||||
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
|
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
|
||||||
"emote", "fade", "pan_camera", "play_once" }) do
|
"emote", "fade", "pan_camera", "play_once", "pikachu_make_way" }) do
|
||||||
local meta = Commands.meta[verb] or {}
|
local meta = Commands.meta[verb] or {}
|
||||||
Commands.meta[verb] = meta
|
Commands.meta[verb] = meta
|
||||||
meta.blocking = true
|
meta.blocking = true
|
||||||
|
|||||||
+13
-1
@@ -253,6 +253,18 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if result == "kept" then
|
||||||
|
if battle then
|
||||||
|
list:close()
|
||||||
|
showMessages(game, payload, function()
|
||||||
|
battle:itemUsed({})
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
showMessages(game, payload, closePicker)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
if result == "consumed" then
|
if result == "consumed" then
|
||||||
consume(game, id)
|
consume(game, id)
|
||||||
-- refresh counts in the list
|
-- refresh counts in the list
|
||||||
@@ -404,7 +416,7 @@ local function useItem(game, battle, id, list)
|
|||||||
showMessages(game, payload)
|
showMessages(game, payload)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
|
if ItemEffects.needsTarget(id, def, game.data) and not ItemEffects.isBall(id) then
|
||||||
-- TMs/HMs boot up and announce their move before the target picker
|
-- TMs/HMs boot up and announce their move before the target picker
|
||||||
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
|
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
|
||||||
if def and def.machine then
|
if def and def.machine then
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ local function withdraw(game)
|
|||||||
game.stack:push(ListMenu.new(game,
|
game.stack:push(ListMenu.new(game,
|
||||||
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
|
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
|
||||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||||
|
kind = "pc_box_withdraw",
|
||||||
onChoose = function(item, list)
|
onChoose = function(item, list)
|
||||||
local mon = box[item.value]
|
local mon = box[item.value]
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
@@ -114,6 +115,7 @@ local function deposit(game)
|
|||||||
end
|
end
|
||||||
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
|
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
|
||||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||||
|
kind = "pc_box_deposit",
|
||||||
onChoose = function(item, list)
|
onChoose = function(item, list)
|
||||||
local mon = game.save.party[item.value]
|
local mon = game.save.party[item.value]
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
@@ -160,6 +162,7 @@ local function release(game)
|
|||||||
game.stack:push(ListMenu.new(game,
|
game.stack:push(ListMenu.new(game,
|
||||||
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
|
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
|
||||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||||
|
kind = "pc_box_release",
|
||||||
onChoose = function(_, list)
|
onChoose = function(_, list)
|
||||||
local mon = box[list.index]
|
local mon = box[list.index]
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
@@ -193,6 +196,7 @@ local function changeBox(game)
|
|||||||
end
|
end
|
||||||
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
|
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
|
||||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||||
|
kind = "pc_box_change",
|
||||||
onChoose = function(item, list)
|
onChoose = function(item, list)
|
||||||
-- the original asks BEFORE switching ("When you change a #MON
|
-- the original asks BEFORE switching ("When you change a #MON
|
||||||
-- BOX, data will be saved. OK?"); declining aborts the change
|
-- BOX, data will be saved. OK?"); declining aborts the change
|
||||||
@@ -203,6 +207,8 @@ local function changeBox(game)
|
|||||||
if not yes then return end
|
if not yes then return end
|
||||||
game.save.currentBox = item.value
|
game.save.currentBox = item.value
|
||||||
if game.writeSave then game:writeSave() end
|
if game.writeSave then game:writeSave() end
|
||||||
|
-- ChangeBox (engine/menus/save.asm) rings SFX_SAVE after SaveGameData (#1044)
|
||||||
|
require("src.core.Sound").play(game.data, "Save")
|
||||||
list:close()
|
list:close()
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
|
|||||||
+27
-8
@@ -31,6 +31,7 @@
|
|||||||
-- to just THE END.
|
-- to just THE END.
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
@@ -64,11 +65,12 @@ local MON_PREP_FRAMES = 9
|
|||||||
|
|
||||||
-- LoadCopyrightTiles (engine/movie/title.asm CopyrightTextString): tile
|
-- LoadCopyrightTiles (engine/movie/title.asm CopyrightTextString): tile
|
||||||
-- sequences into the extracted title/copyright.png strip (tiles $60-$72:
|
-- sequences into the extracted title/copyright.png strip (tiles $60-$72:
|
||||||
-- (c)'95.'96.'98 + Nintendo + Creatures inc.); the GAME FREAK inc. row is
|
-- Red/Blue (c)'95.'96.'98, Yellow (c)1995-1999 + NineTile) + Nintendo +
|
||||||
-- the title/gamefreak_inc.png strip (GameFreakLogoGraphics, tiles
|
-- Creatures inc.; the GAME FREAK inc. row is title/gamefreak_inc.png
|
||||||
-- $73-$7B), with the intro's composed gamefreak_text.png as a fallback
|
-- (GameFreakLogoGraphics, tiles $73-$7B), with the intro's composed
|
||||||
-- for pre-regeneration data.
|
-- gamefreak_text.png as a fallback for pre-regeneration data.
|
||||||
local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 } -- (c)'95.'96.'98
|
local COPY_PREFIX_RB = { 0, 1, 2, 1, 3, 1, 4 } -- (c)'95.'96.'98
|
||||||
|
local COPY_PREFIX_YELLOW = { 0, 1, 2, 3, 1, 2 } -- (c)1995-199
|
||||||
local COPY_NINTENDO = { 5, 6, 7, 8, 9, 10 } -- Nintendo
|
local COPY_NINTENDO = { 5, 6, 7, 8, 9, 10 } -- Nintendo
|
||||||
local COPY_CREATURES = { 11, 12, 13, 14, 15, 16, 17, 18 } -- Creatures inc.
|
local COPY_CREATURES = { 11, 12, 13, 14, 15, 16, 17, 18 } -- Creatures inc.
|
||||||
|
|
||||||
@@ -140,6 +142,12 @@ function Credits.new(game, onDone, onTheEnd)
|
|||||||
and title.gamefreakInc.path)
|
and title.gamefreakInc.path)
|
||||||
or tryImage(intro and intro.gamefreakText
|
or tryImage(intro and intro.gamefreakText
|
||||||
and intro.gamefreakText.path)
|
and intro.gamefreakText.path)
|
||||||
|
self.yellowCopy = GameVersion.isYellow()
|
||||||
|
or (title and title.layout == "yellow_pikachu")
|
||||||
|
self.copyPrefix = self.yellowCopy and COPY_PREFIX_YELLOW or COPY_PREFIX_RB
|
||||||
|
self.nineImg = self.yellowCopy and tryImage(
|
||||||
|
title and title.nine and title.nine.path
|
||||||
|
or "assets/generated/title/nine.png") or nil
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -255,6 +263,17 @@ function Credits:drawPage(screen, xoff, shade)
|
|||||||
if screen.copyright then self:drawCopyright(xoff) end
|
if screen.copyright then self:drawCopyright(xoff) end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Credits:drawCopyPrefix(x, y)
|
||||||
|
local img = self.copyImg
|
||||||
|
for _, t in ipairs(self.copyPrefix) do
|
||||||
|
love.graphics.draw(img, self.copyQuads[t], x, y)
|
||||||
|
x = x + 8
|
||||||
|
end
|
||||||
|
if self.nineImg then
|
||||||
|
love.graphics.draw(self.nineImg, x, y)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function Credits:drawCopyright(xoff)
|
function Credits:drawCopyright(xoff)
|
||||||
local img = self.copyImg
|
local img = self.copyImg
|
||||||
if img then
|
if img then
|
||||||
@@ -267,11 +286,11 @@ function Credits:drawCopyright(xoff)
|
|||||||
end
|
end
|
||||||
return x
|
return x
|
||||||
end
|
end
|
||||||
row(COPY_PREFIX, xoff + 16, 56)
|
self:drawCopyPrefix(xoff + 16, 56)
|
||||||
row(COPY_NINTENDO, xoff + 80, 56)
|
row(COPY_NINTENDO, xoff + 80, 56)
|
||||||
row(COPY_PREFIX, xoff + 16, 72)
|
self:drawCopyPrefix(xoff + 16, 72)
|
||||||
row(COPY_CREATURES, xoff + 80, 72)
|
row(COPY_CREATURES, xoff + 80, 72)
|
||||||
row(COPY_PREFIX, xoff + 16, 88)
|
self:drawCopyPrefix(xoff + 16, 88)
|
||||||
if self.gfImg then
|
if self.gfImg then
|
||||||
love.graphics.draw(self.gfImg, xoff + 80, 88)
|
love.graphics.draw(self.gfImg, xoff + 80, 88)
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
|
|||||||
-- same number width as the list (constants.dexDigits), so a dex past 999
|
-- same number width as the list (constants.dexDigits), so a dex past 999
|
||||||
-- prints the extra digit everywhere at once
|
-- prints the extra digit everywhere at once
|
||||||
local digits = (game.data.constants or {}).dexDigits or 3
|
local digits = (game.data.constants or {}).dexDigits or 3
|
||||||
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
|
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
|
||||||
local owned = forceOwned
|
local owned = forceOwned
|
||||||
or (game.save.pokedex and game.save.pokedex.owned[def.id])
|
or (game.save.pokedex and game.save.pokedex.owned[def.id])
|
||||||
-- height/weight print only once owned, like the description
|
-- height/weight print only once owned, like the description
|
||||||
@@ -105,8 +105,8 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
|
|||||||
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
|
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
|
||||||
-- engine/gfx/load_pokedex_tiles.asm)
|
-- engine/gfx/load_pokedex_tiles.asm)
|
||||||
if e.heightM then
|
if e.heightM then
|
||||||
Font.draw((("GR. %.1fm"):format(e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 64, 44)
|
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 44)
|
||||||
Font.draw((("GEW. %.1fkg"):format(e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 64, 54)
|
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 54)
|
||||||
else
|
else
|
||||||
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44)
|
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44)
|
||||||
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
|
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
-- flashes back and forth with the evolved form, speeding up, then the
|
-- flashes back and forth with the evolved form, speeding up, then the
|
||||||
-- new form appears with its cry and the congratulations text.
|
-- new form appears with its cry and the congratulations text.
|
||||||
-- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the
|
-- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the
|
||||||
-- joypad during the flash: holding B aborts the evolution -- the mon keeps
|
-- joypad during the flash: a fresh B press aborts the evolution -- the mon
|
||||||
-- its species and _StoppedEvolvingText ("Huh? MON stopped evolving!")
|
-- keeps its species and _StoppedEvolvingText ("Huh? MON stopped evolving!")
|
||||||
-- prints. Two kinds are exempt: trade evolutions, which evos_moves.asm
|
-- prints. Two kinds are exempt: trade evolutions, which evos_moves.asm
|
||||||
-- routes past the poll entirely (wLinkState == LINK_STATE_TRADING, #213),
|
-- routes past the poll entirely (wLinkState == LINK_STATE_TRADING, #213),
|
||||||
-- and stone evolutions, where the B press is read but thrown away because
|
-- and stone evolutions, where the B press is read but thrown away because
|
||||||
@@ -47,6 +47,8 @@ function EvolutionState:sgbPalettes(game)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local FLASH_FRAMES = 220
|
local FLASH_FRAMES = 220
|
||||||
|
-- evolution.asm EvolveMon delays 80 frames before .animLoop, polling nothing (#968, #1031)
|
||||||
|
local CANCEL_GRACE_FRAMES = 80
|
||||||
|
|
||||||
local function frontSprite(game, species, mon)
|
local function frontSprite(game, species, mon)
|
||||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||||
@@ -83,10 +85,10 @@ function EvolutionState:update(dt)
|
|||||||
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
|
||||||
-- evos_moves.asm EvolveMon: each flash iteration polls hJoyHeld and, for
|
-- evolution.asm Evolution_CheckForCancel reads hJoy5, a fresh edge rather
|
||||||
-- a cancelable evolution, aborts when B is held -- the mon keeps its
|
-- than a hold, so B held from the level-up box must not cancel (#968, #1031)
|
||||||
-- species (Evolution.apply never runs) and _StoppedEvolvingText prints.
|
if self.cancelable and self.t > CANCEL_GRACE_FRAMES
|
||||||
if self.cancelable and game.input:isDown("b") then
|
and game.input:wasPressed("b") then
|
||||||
self.done = true
|
self.done = true
|
||||||
self.canceled = true
|
self.canceled = true
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|||||||
+87
-59
@@ -1,31 +1,8 @@
|
|||||||
-- Boot splash + attract movie, a faithful port of PlayIntro
|
-- ..(engine/movie/intro.asm ln 8)
|
||||||
-- (engine/movie/intro.asm) and AnimateShootingStar (engine/movie/splash.asm)
|
-- ..(engine/movie/splash.asm ln 27)
|
||||||
-- using the real extracted art (data/generated/field.lua `intro` manifest).
|
|
||||||
--
|
|
||||||
-- Three frame-counted phases:
|
|
||||||
-- 1. copyright card, 180 frames (intro.asm:311-312).
|
|
||||||
-- 2. shooting star: 64 frames of empty letterbox (intro.asm:323-324), then
|
|
||||||
-- the big star streaks down-left for 40 frames while the studio logo
|
|
||||||
-- sits centered in the letterbox band (the GAME FREAK logo + letter
|
|
||||||
-- row it replaces sat at (72,56)/(40,80); splash.asm:27-60, 211-228),
|
|
||||||
-- the logo flashes 3x10 frames (splash.asm:72-82), 4 waves of small
|
|
||||||
-- stars rain from the logo -- 6x24 frames, +1px every 3 frames, lower
|
|
||||||
-- star blinking (splash.asm:97-146, 163-209) -- and a 40 frame hold
|
|
||||||
-- (intro.asm:329-331).
|
|
||||||
-- 3. the Gengar/Nidorino fight (PlayIntroScene, intro.asm:23-141), played
|
|
||||||
-- from FIGHT_SCRIPT below: Music_IntroBattle starts, Gengar (56x56 BG
|
|
||||||
-- pose from a gengar_N.tilemap, at tile 13,7 = x104,y56) scrolls left
|
|
||||||
-- while Nidorino (48x48 OAM at x-8,y72) walks right, then the scripted
|
|
||||||
-- hip/hop hops, Gengar's raise + slash lunge, Nidorino's dodge leap,
|
|
||||||
-- retreat, crouch and final lunge, ending in a 24-frame fade to white
|
|
||||||
-- (GBFadeOutToWhite, home/fade.asm:26-40).
|
|
||||||
--
|
|
||||||
-- Any of A/B/START skips the whole movie (CheckForUserInterruption).
|
|
||||||
-- Pops itself and calls onDone() when finished or skipped. All art loads
|
|
||||||
-- through pcall and every missing graphic degrades to a text/rect
|
|
||||||
-- fallback, so the movie stays headless-safe.
|
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
@@ -76,15 +53,20 @@ local WAVE_FRAMES = 24 -- 8 substeps x 3 frames (splash.asm:186-209)
|
|||||||
local WAVES_END = WAVES_START + 6 * WAVE_FRAMES -- 4 waves + 2 empty
|
local WAVES_END = WAVES_START + 6 * WAVE_FRAMES -- 4 waves + 2 empty
|
||||||
local SPLASH_FRAMES = WAVES_END + 40 -- ld c, 40 (intro.asm:329-331)
|
local SPLASH_FRAMES = WAVES_END + 40 -- ld c, 40 (intro.asm:329-331)
|
||||||
|
|
||||||
-- logo 16x24 at grid (10,9), letters row at grid y=12 cols 6..15
|
-- ..(engine/movie/splash.asm ln 211)
|
||||||
-- (GameFreakLogoOAMData, splash.asm:211-228; screen = grid*8, OAM offsets
|
|
||||||
-- cancel)
|
|
||||||
local LOGO_X, LOGO_Y = 72, 56
|
local LOGO_X, LOGO_Y = 72, 56
|
||||||
|
local TEXT_X, TEXT_Y = 40, 80
|
||||||
|
|
||||||
|
-- CopyrightTextString (engine/movie/title.asm). Red/Blue years are
|
||||||
|
-- (c)'95.'96.'98; Yellow's sheet spells (c)1995-1999 and finishes the
|
||||||
|
-- last digit with NineTile (title screen). Intro originally overflows
|
||||||
|
-- into the font "A" tile for that digit; we draw NineTile so both
|
||||||
|
-- screens read 1999.
|
||||||
|
local COPY_PREFIX_RB = { 0, 1, 2, 1, 3, 1, 4 }
|
||||||
|
local COPY_PREFIX_YELLOW = { 0, 1, 2, 3, 1, 2 }
|
||||||
|
local COPY_NINTENDO = { 5, 6, 7, 8, 9, 10 }
|
||||||
|
local COPY_CREATURES = { 11, 12, 13, 14, 15, 16, 17, 18 }
|
||||||
|
|
||||||
-- The studio logo (assets/logo/minilogo.png) stands in for both the
|
|
||||||
-- GAME FREAK logo and its letter row, so it gets the whole band between
|
|
||||||
-- the letterbox bars (y 32..112) down to where the star waves spawn
|
|
||||||
-- (y=88): fit it inside this box, centered, aspect preserved.
|
|
||||||
local STUDIO_BOX = { w = 128, h = 52, cx = 80, cy = 60 }
|
local STUDIO_BOX = { w = 128, h = 52, cx = 80, cy = 60 }
|
||||||
|
|
||||||
-- the 4 waves of small stars: screen X positions, all spawning at y=88
|
-- the 4 waves of small stars: screen X positions, all spawning at y=88
|
||||||
@@ -155,10 +137,25 @@ function IntroMovie.new(game, onDone)
|
|||||||
self.studio = intro.studio or {}
|
self.studio = intro.studio or {}
|
||||||
self.skipAll = intro.skip and true or false
|
self.skipAll = intro.skip and true or false
|
||||||
local function img(e) return tryImage(e and e.path) end
|
local function img(e) return tryImage(e and e.path) end
|
||||||
self.copyright = tryImage("assets/generated/title/copyright.png")
|
local titleCfg = game.data.field and game.data.field.title or {}
|
||||||
-- studio mark replaces the GAME FREAK logo + splash text entirely; the
|
self.copyright = img(titleCfg.copyright)
|
||||||
-- extracted logo stays as the fallback if the asset is missing
|
or tryImage("assets/generated/title/copyright.png")
|
||||||
self.studioLogo = tryImage(self.studio.logo or "assets/logo/minilogo.png")
|
self.copyQuads = {}
|
||||||
|
if self.copyright then
|
||||||
|
local iw, ih = self.copyright:getDimensions()
|
||||||
|
for t = 0, 18 do
|
||||||
|
self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
self.gfInc = img(titleCfg.gamefreakInc)
|
||||||
|
or tryImage("assets/generated/title/gamefreak_inc.png")
|
||||||
|
-- ..(pokeyellow engine/movie/title.asm CopyrightTextString / NineTile)
|
||||||
|
self.yellowCopy = GameVersion.isYellow()
|
||||||
|
or titleCfg.layout == "yellow_pikachu"
|
||||||
|
self.copyPrefix = self.yellowCopy and COPY_PREFIX_YELLOW or COPY_PREFIX_RB
|
||||||
|
self.nineImg = self.yellowCopy and (
|
||||||
|
img(titleCfg.nine) or tryImage("assets/generated/title/nine.png")) or nil
|
||||||
|
self.studioLogo = tryImage(self.studio.logo)
|
||||||
if self.studioLogo then
|
if self.studioLogo then
|
||||||
self.studioLogo:setFilter("nearest", "nearest")
|
self.studioLogo:setFilter("nearest", "nearest")
|
||||||
local iw, ih = self.studioLogo:getDimensions()
|
local iw, ih = self.studioLogo:getDimensions()
|
||||||
@@ -306,23 +303,11 @@ function IntroMovie:drawSplash()
|
|||||||
if self.studioLogo then
|
if self.studioLogo then
|
||||||
love.graphics.draw(self.studioLogo, self.studioX, self.studioY,
|
love.graphics.draw(self.studioLogo, self.studioX, self.studioY,
|
||||||
0, self.studioScale, self.studioScale)
|
0, self.studioScale, self.studioScale)
|
||||||
elseif self.logo then
|
|
||||||
love.graphics.draw(self.logo, LOGO_X, LOGO_Y)
|
|
||||||
end
|
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
|
||||||
end
|
|
||||||
if t >= STAR_START and t < FLASH_START then
|
|
||||||
-- big star: from OAM (160,0) moving +4Y/-4X per frame
|
|
||||||
-- (GameFreakShootingStarOAMData + .bigStarLoop, splash.asm:32-60)
|
|
||||||
local n = t - STAR_START + 1
|
|
||||||
local sx, sy = 152 - 4 * n, -16 + 4 * n
|
|
||||||
if self.bigStar then
|
|
||||||
love.graphics.draw(self.bigStar, sx, sy)
|
|
||||||
else
|
else
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
if self.logo then love.graphics.draw(self.logo, LOGO_X, LOGO_Y) end
|
||||||
love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4)
|
if self.gfText then love.graphics.draw(self.gfText, TEXT_X, TEXT_Y) end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
|
||||||
end
|
end
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
if t >= WAVES_START then
|
if t >= WAVES_START then
|
||||||
-- small stars: wave w spawns at y=88 every 24 frames, everything falls
|
-- small stars: wave w spawns at y=88 every 24 frames, everything falls
|
||||||
@@ -351,6 +336,18 @@ function IntroMovie:drawSplash()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
drawBars()
|
drawBars()
|
||||||
|
if t >= STAR_START and t < FLASH_START then
|
||||||
|
-- ..(engine/movie/splash.asm ln 32)
|
||||||
|
local n = t - STAR_START + 1
|
||||||
|
local sx, sy = 152 - 4 * n, -16 + 4 * n
|
||||||
|
if self.bigStar then
|
||||||
|
love.graphics.draw(self.bigStar, sx, sy)
|
||||||
|
else
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function IntroMovie:drawFight()
|
function IntroMovie:drawFight()
|
||||||
@@ -379,17 +376,48 @@ function IntroMovie:drawFight()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function IntroMovie:drawCopyPrefix(x, y)
|
||||||
|
for _, t in ipairs(self.copyPrefix) do
|
||||||
|
love.graphics.draw(self.copyright, self.copyQuads[t], x, y)
|
||||||
|
x = x + 8
|
||||||
|
end
|
||||||
|
if self.nineImg then
|
||||||
|
love.graphics.draw(self.nineImg, x, y)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function IntroMovie:drawCopyright()
|
||||||
|
if self.studio.card or self.studio.credit then
|
||||||
|
local card = self.studio.card or ""
|
||||||
|
local credit = self.studio.credit or ""
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw(card, (160 - #card * 8) / 2, 64)
|
||||||
|
Font.draw(credit, (160 - #credit * 8) / 2, 80)
|
||||||
|
elseif self.copyright and self.gfInc then
|
||||||
|
local function row(seq, x, y)
|
||||||
|
for _, t in ipairs(seq) do
|
||||||
|
love.graphics.draw(self.copyright, self.copyQuads[t], x, y)
|
||||||
|
x = x + 8
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, y in ipairs({ 56, 72, 88 }) do self:drawCopyPrefix(16, y) end
|
||||||
|
row(COPY_NINTENDO, 80, 56)
|
||||||
|
row(COPY_CREATURES, 80, 72)
|
||||||
|
love.graphics.draw(self.gfInc, 80, 88)
|
||||||
|
else
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw(Strings("Nintendo"), 80, 56)
|
||||||
|
Font.draw(Strings("Creatures inc."), 80, 72)
|
||||||
|
Font.draw(Strings("GAME FREAK inc."), 16, 88)
|
||||||
|
end
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
function IntroMovie:draw()
|
function IntroMovie:draw()
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
if self.phase == 1 then
|
if self.phase == 1 then
|
||||||
-- custom boot card (replaces the Nintendo / GAME FREAK copyright
|
self:drawCopyright()
|
||||||
-- card; no (c) glyph in the charmap, keep it ASCII-safe)
|
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
|
||||||
local credit = self.studio.credit or Strings("bois club")
|
|
||||||
Font.draw("2026", (160 - 4 * 8) / 2, 48)
|
|
||||||
Font.draw(credit, (160 - #credit * 8) / 2, 64)
|
|
||||||
Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80)
|
|
||||||
elseif self.phase == 2 then
|
elseif self.phase == 2 then
|
||||||
self:drawSplash()
|
self:drawSplash()
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ function ListMenu.new(game, title, items, opts)
|
|||||||
local self = setmetatable({}, ListMenu)
|
local self = setmetatable({}, ListMenu)
|
||||||
self.game = game
|
self.game = game
|
||||||
self.title = title
|
self.title = title
|
||||||
|
self.kind = opts.kind or title
|
||||||
self.items = items
|
self.items = items
|
||||||
self.index = 1
|
self.index = 1
|
||||||
self.scroll = 0
|
self.scroll = 0
|
||||||
|
|||||||
@@ -265,7 +265,8 @@ function OakSpeech.new(game, onDone)
|
|||||||
or "assets/generated/intro/shrink2.png")
|
or "assets/generated/intro/shrink2.png")
|
||||||
-- RedSprite: the walking sprite the pic shrinks into (frame 0 =
|
-- RedSprite: the walking sprite the pic shrinks into (frame 0 =
|
||||||
-- standing, facing down)
|
-- standing, facing down)
|
||||||
local red = game.data.sprites and game.data.sprites.SPRITE_RED
|
local playerSprites = (game.data.field and game.data.field.playerSprites) or {}
|
||||||
|
local red = game.data.sprites and game.data.sprites[playerSprites.walk or "SPRITE_RED"] or game.data.sprites.SPRITE_RED
|
||||||
self.walkSheet = tryImage(red and red.image)
|
self.walkSheet = tryImage(red and red.image)
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|||||||
+32
-5
@@ -392,14 +392,35 @@ local function buildRows(game)
|
|||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
-- fast-forward the logic clock only; music and sfx keep their tempo
|
-- fast-forward the logic clock only; music and sfx keep their tempo
|
||||||
-- (src/core/GameSpeed.lua), so this is safe to leave on
|
-- (src/core/GameSpeed.lua), so this is safe to leave on. Per-category
|
||||||
{ id = "speed", label = Strings("GAME SPEED"),
|
-- (RFC 0007): overworld walking, battle turns and menu navigation each
|
||||||
|
-- cycle their own multiplier -- GameSpeed.CATEGORIES is the single
|
||||||
|
-- source of truth for which three rows exist.
|
||||||
|
{ id = "speedOverworld", label = Strings("OVERWORLD SPEED"),
|
||||||
value = function(g)
|
value = function(g)
|
||||||
return GameSpeed.levelLabel(g.save.options.speed)
|
return GameSpeed.levelLabel(g.save.options.speedOverworld)
|
||||||
end,
|
end,
|
||||||
step = function(g, dir)
|
step = function(g, dir)
|
||||||
local o = g.save.options
|
local o = g.save.options
|
||||||
o.speed = GameSpeed.cycle(o.speed, dir)
|
o.speedOverworld = GameSpeed.cycle(o.speedOverworld, dir)
|
||||||
|
return true
|
||||||
|
end },
|
||||||
|
{ id = "speedBattle", label = Strings("BATTLE SPEED"),
|
||||||
|
value = function(g)
|
||||||
|
return GameSpeed.levelLabel(g.save.options.speedBattle)
|
||||||
|
end,
|
||||||
|
step = function(g, dir)
|
||||||
|
local o = g.save.options
|
||||||
|
o.speedBattle = GameSpeed.cycle(o.speedBattle, dir)
|
||||||
|
return true
|
||||||
|
end },
|
||||||
|
{ id = "speedMenu", label = Strings("MENU SPEED"),
|
||||||
|
value = function(g)
|
||||||
|
return GameSpeed.levelLabel(g.save.options.speedMenu)
|
||||||
|
end,
|
||||||
|
step = function(g, dir)
|
||||||
|
local o = g.save.options
|
||||||
|
o.speedMenu = GameSpeed.cycle(o.speedMenu, dir)
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
-- the manager's discoverable home (18-mod-manager-ux); inert until
|
-- the manager's discoverable home (18-mod-manager-ux); inert until
|
||||||
@@ -585,8 +606,14 @@ function OptionsMenu:update(dt)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function OptionsMenu:draw()
|
function OptionsMenu:draw()
|
||||||
|
-- Through Strings, like every other label on this menu. CANCEL is
|
||||||
|
-- appended AFTER the rows hook (see the header), which is what keeps a mod
|
||||||
|
-- from orphaning the exit -- but it also means a translation mod never sees
|
||||||
|
-- this string, and cannot: there is no row for it to rewrite. So the one
|
||||||
|
-- word a Spanish player could not read on a fully translated OPTIONS menu
|
||||||
|
-- was the way out of it.
|
||||||
OptionRows.draw(self.game, self.rows, self.index, self.scroll or 0,
|
OptionRows.draw(self.game, self.rows, self.index, self.scroll or 0,
|
||||||
"CANCEL", #self.rows + 1)
|
Strings("CANCEL"), #self.rows + 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
return OptionsMenu
|
return OptionsMenu
|
||||||
|
|||||||
+36
-1
@@ -107,6 +107,23 @@ PartyMenu.iconFrames = {
|
|||||||
PIKACHU = { rest = 0, alt = 3 }, -- Yellow: PikachuSprite tile 0 <-> 12
|
PIKACHU = { rest = 0, alt = 3 }, -- Yellow: PikachuSprite tile 0 <-> 12
|
||||||
}
|
}
|
||||||
|
|
||||||
|
local function gridIndex(index, count, direction)
|
||||||
|
if count < 1 then return nil end
|
||||||
|
local row, col = math.floor((index - 1) / 2), (index - 1) % 2
|
||||||
|
if direction == "left" or direction == "right" then
|
||||||
|
local other = row * 2 + (1 - col) + 1
|
||||||
|
return other <= count and other or index
|
||||||
|
end
|
||||||
|
local step = direction == "up" and -1 or direction == "down" and 1
|
||||||
|
if not step then return nil end
|
||||||
|
local rows = math.ceil(count / 2)
|
||||||
|
for offset = 1, rows do
|
||||||
|
local other = ((row + step * offset) % rows) * 2 + col + 1
|
||||||
|
if other <= count then return other end
|
||||||
|
end
|
||||||
|
return index
|
||||||
|
end
|
||||||
|
|
||||||
-- Which 16x16 frame of `name`'s sheet to draw; `ih` (sheet pixel
|
-- Which 16x16 frame of `name`'s sheet to draw; `ih` (sheet pixel
|
||||||
-- height) only matters for the fallback, which keeps the old uniform
|
-- height) only matters for the fallback, which keeps the old uniform
|
||||||
-- behavior for icons outside the table (BALL/HELIX y-bob instead).
|
-- behavior for icons outside the table (BALL/HELIX y-bob instead).
|
||||||
@@ -307,6 +324,13 @@ function PartyMenu:close()
|
|||||||
if self.game.stack:top() == self then self.game.stack:pop() end
|
if self.game.stack:top() == self then self.game.stack:pop() end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function PartyMenu:gridNavigation()
|
||||||
|
if not self.battle
|
||||||
|
or not Runtime.wantsHook("ui.party.grid_navigation") then return false end
|
||||||
|
return Runtime.call("ui.party.grid_navigation", function() return false end,
|
||||||
|
self) == true
|
||||||
|
end
|
||||||
|
|
||||||
function PartyMenu:update(dt)
|
function PartyMenu:update(dt)
|
||||||
-- icon animation counter; 320 = a whole cycle at every HP speed
|
-- icon animation counter; 320 = a whole cycle at every HP speed
|
||||||
self.blink = ((self.blink or 0) + 1) % 320
|
self.blink = ((self.blink or 0) + 1) % 320
|
||||||
@@ -531,7 +555,18 @@ function PartyMenu:update(dt)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if input:wasPressed("up") then
|
local grid
|
||||||
|
if self:gridNavigation() then
|
||||||
|
local direction = input:wasPressed("left") and "left"
|
||||||
|
or input:wasPressed("right") and "right"
|
||||||
|
or input:wasPressed("up") and "up"
|
||||||
|
or input:wasPressed("down") and "down"
|
||||||
|
grid = gridIndex(self.index, #party, direction)
|
||||||
|
end
|
||||||
|
if grid then
|
||||||
|
self.index = grid
|
||||||
|
self.game.partyMenuSavedIndex = self.index
|
||||||
|
elseif input:wasPressed("up") then
|
||||||
self.index = self.index > 1 and self.index - 1 or math.max(1, #party)
|
self.index = self.index > 1 and self.index - 1 or math.max(1, #party)
|
||||||
self.game.partyMenuSavedIndex = self.index -- HandlePartyMenuInput #768
|
self.game.partyMenuSavedIndex = self.index -- HandlePartyMenuInput #768
|
||||||
elseif input:wasPressed("down") then
|
elseif input:wasPressed("down") then
|
||||||
|
|||||||
+13
-4
@@ -44,7 +44,7 @@ local function askQuantity(game, list, count, id, cb)
|
|||||||
cb(1)
|
cb(1)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
list.footer = "How many?"
|
list.footer = Strings("How many?")
|
||||||
local QuantityBox = require("src.ui.QuantityBox")
|
local QuantityBox = require("src.ui.QuantityBox")
|
||||||
game.stack:push(QuantityBox.new(game, {
|
game.stack:push(QuantityBox.new(game, {
|
||||||
max = count,
|
max = count,
|
||||||
@@ -72,6 +72,7 @@ end
|
|||||||
local function withdraw(game)
|
local function withdraw(game)
|
||||||
local pc = game.save.pcItems
|
local pc = game.save.pcItems
|
||||||
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
|
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
|
||||||
|
kind = "pc_item_withdraw",
|
||||||
messageBox = true,
|
messageBox = true,
|
||||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||||
onChoose = function(item, list)
|
onChoose = function(item, list)
|
||||||
@@ -110,6 +111,7 @@ local function deposit(game)
|
|||||||
if not Bag.isBadge(id) then depositable[id] = count end
|
if not Bag.isBadge(id) then depositable[id] = count end
|
||||||
end
|
end
|
||||||
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
|
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
|
||||||
|
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)
|
||||||
onChoose = function(item, list)
|
onChoose = function(item, list)
|
||||||
@@ -131,6 +133,7 @@ end
|
|||||||
local function toss(game)
|
local function toss(game)
|
||||||
local pc = game.save.pcItems
|
local pc = game.save.pcItems
|
||||||
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
|
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
|
||||||
|
kind = "pc_item_toss",
|
||||||
messageBox = true,
|
messageBox = true,
|
||||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||||
onChoose = function(item, list)
|
onChoose = function(item, list)
|
||||||
@@ -161,8 +164,14 @@ local function toss(game)
|
|||||||
}))
|
}))
|
||||||
end
|
end
|
||||||
|
|
||||||
function PlayerPC.new(game)
|
-- opts.direct marks the bedroom PC (OpenRedsPC), the one entry outside the main menu
|
||||||
|
function PlayerPC.new(game, opts)
|
||||||
game.save.pcItems = game.save.pcItems or {}
|
game.save.pcItems = game.save.pcItems or {}
|
||||||
|
-- ExitPlayerPC (players_pc.asm) rings SFX_TURN_OFF_PC only while
|
||||||
|
-- BIT_USING_GENERIC_PC is clear (#960)
|
||||||
|
local logOff = function()
|
||||||
|
if opts and opts.direct then Sound.play(game.data, "Turn_Off_PC") end
|
||||||
|
end
|
||||||
return Menu.new(game, {
|
return Menu.new(game, {
|
||||||
-- keepOpen so B in the item lists returns here instead of dropping the
|
-- keepOpen so B in the item lists returns here instead of dropping the
|
||||||
-- whole PC session (players_pc.asm re-shows the PC menu); same pattern
|
-- whole PC session (players_pc.asm re-shows the PC menu); same pattern
|
||||||
@@ -170,10 +179,10 @@ function PlayerPC.new(game)
|
|||||||
{ label = Strings("WITHDRAW ITEM"), keepOpen = true, onSelect = function() withdraw(game) end },
|
{ label = Strings("WITHDRAW ITEM"), keepOpen = true, onSelect = function() withdraw(game) end },
|
||||||
{ label = Strings("DEPOSIT ITEM"), keepOpen = true, onSelect = function() deposit(game) end },
|
{ label = Strings("DEPOSIT ITEM"), keepOpen = true, onSelect = function() deposit(game) end },
|
||||||
{ label = Strings("TOSS ITEM"), keepOpen = true, onSelect = function() toss(game) end },
|
{ label = Strings("TOSS ITEM"), keepOpen = true, onSelect = function() toss(game) end },
|
||||||
{ label = Strings("LOG OFF") },
|
{ label = Strings("LOG OFF"), onSelect = logOff },
|
||||||
-- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm
|
-- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm
|
||||||
-- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10
|
-- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10
|
||||||
}, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true })
|
}, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true, onCancel = logOff })
|
||||||
end
|
end
|
||||||
|
|
||||||
return PlayerPC
|
return PlayerPC
|
||||||
|
|||||||
+209
-51
@@ -103,11 +103,40 @@ local YELLOW_CYCLE_SPECIES = {
|
|||||||
"JIGGLYPUFF", "MEOWTH", "PSYDUCK", "VULPIX", "ABRA",
|
"JIGGLYPUFF", "MEOWTH", "PSYDUCK", "VULPIX", "ABRA",
|
||||||
"GROWLITHE", "CUBONE", "GASTLY", "HITMONLEE", "SNORLAX", "DRAGONITE",
|
"GROWLITHE", "CUBONE", "GASTLY", "HITMONLEE", "SNORLAX", "DRAGONITE",
|
||||||
}
|
}
|
||||||
local CYCLE_FRAMES = 240 -- the original waits ~4s between picks
|
-- ..(engine/movie/title.asm ln 227)
|
||||||
|
local HOLD_FRAMES = 200
|
||||||
|
local STARTERS = { CHARMANDER = true, SQUIRTLE = true, BULBASAUR = true }
|
||||||
|
|
||||||
|
-- ..(engine/movie/title2.asm ln 13)
|
||||||
|
local function scrollFrames(steps, offset)
|
||||||
|
local frames = {}
|
||||||
|
for _, step in ipairs(steps) do
|
||||||
|
for _ = 1, step[2] do
|
||||||
|
frames[#frames + 1] = offset
|
||||||
|
offset = offset - step[1]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return frames
|
||||||
|
end
|
||||||
|
local OUT_FRAMES = scrollFrames(
|
||||||
|
{ { 1, 2 }, { 2, 2 }, { 3, 2 }, { 4, 2 }, { 5, 2 }, { 6, 2 },
|
||||||
|
{ 8, 3 }, { 9, 3 } }, 0)
|
||||||
|
local IN_FRAMES = scrollFrames(
|
||||||
|
{ { 10, 2 }, { 9, 4 }, { 8, 4 }, { 6, 3 }, { 5, 2 }, { 3, 1 },
|
||||||
|
{ 1, 1 } }, 120)
|
||||||
|
|
||||||
|
-- ..(engine/movie/title2.asm ln 85)
|
||||||
|
local BALL_FRAMES = { 97, 95, 94, 93, 92, 93, 94, 95, 97, 100 }
|
||||||
|
local BALL_REST = 100
|
||||||
|
|
||||||
local function tryImage(path)
|
local function tryImage(path)
|
||||||
if not path then return nil end
|
if not path then return nil end
|
||||||
local ok, img = pcall(love.graphics.newImage, path)
|
-- resolve through Assets so a mod's derived art (save/mod-derived/)
|
||||||
|
-- wins here the way it does for every other generated sheet -- but
|
||||||
|
-- load uncached, because on NX the per-version overlay redirects the
|
||||||
|
-- open itself and a cached image would leak across Yellow/Blue boots
|
||||||
|
local ok, img = pcall(love.graphics.newImage,
|
||||||
|
require("src.render.Assets").resolve(path))
|
||||||
return ok and img or nil
|
return ok and img or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -151,16 +180,57 @@ function TitleState.new(game, opts)
|
|||||||
-- branding comes from field.title with the shipped art as fallback, so
|
-- branding comes from field.title with the shipped art as fallback, so
|
||||||
-- a total conversion rebrands the title without replacing the screen
|
-- a total conversion rebrands the title without replacing the screen
|
||||||
local title = (game.data.field and game.data.field.title) or {}
|
local title = (game.data.field and game.data.field.title) or {}
|
||||||
|
-- field.title itself is extraction data the field schema never exposes;
|
||||||
|
-- boot.title is the mod-reachable half of the same seam, so its keys
|
||||||
|
-- override here (a localized ribbon, a rebranded logo)
|
||||||
|
local boot = game.data.field and game.data.field.boot
|
||||||
|
if boot and type(boot.title) == "table" then
|
||||||
|
local merged = {}
|
||||||
|
for key, value in pairs(title) do merged[key] = value end
|
||||||
|
for key, value in pairs(boot.title) do merged[key] = value end
|
||||||
|
title = merged
|
||||||
|
end
|
||||||
self.title = title
|
self.title = title
|
||||||
self.logo = tryImage(imagePath(title.logo)
|
self.logo = tryImage(imagePath(title.logo)
|
||||||
or "assets/logo/pokemon_logo.png")
|
or "assets/logo/pokemon_logo.png")
|
||||||
-- versionRibbon is the file-12 key; version is the importer's
|
-- versionRibbon is the file-12 key; version is the importer's. The
|
||||||
|
-- vanilla sheet is two fragments the draw pass repositions, so an
|
||||||
|
-- explicit ribbon (a conversion's or a translation's continuous art)
|
||||||
|
-- draws whole instead.
|
||||||
|
self.versionFull = imagePath(title.versionRibbon) ~= nil
|
||||||
self.version = tryImage(imagePath(title.versionRibbon or title.version)
|
self.version = tryImage(imagePath(title.versionRibbon or title.version)
|
||||||
or "assets/generated/title/red_version.png")
|
or "assets/generated/title/red_version.png")
|
||||||
self.player = tryImage("assets/generated/title/player.png")
|
self.player = tryImage(imagePath(title.player)
|
||||||
|
or "assets/generated/title/player.png")
|
||||||
|
-- ..(engine/movie/title2.asm ln 85)
|
||||||
|
if self.player then
|
||||||
|
local pw, ph = self.player:getDimensions()
|
||||||
|
self.ballQuad = love.graphics.newQuad(0, 16, 8, 8, pw, ph)
|
||||||
|
self.playerQuads = {
|
||||||
|
{ love.graphics.newQuad(0, 0, pw, 16, pw, ph), 0, 0 },
|
||||||
|
{ love.graphics.newQuad(8, 16, pw - 8, 8, pw, ph), 8, 16 },
|
||||||
|
{ love.graphics.newQuad(0, 24, pw, ph - 24, pw, ph), 0, 24 },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
self.copyImg = tryImage(imagePath(title.copyright)
|
||||||
|
or "assets/generated/title/copyright.png")
|
||||||
|
self.copyQuads = {}
|
||||||
|
if self.copyImg then
|
||||||
|
local iw, ih = self.copyImg:getDimensions()
|
||||||
|
for t = 0, 18 do
|
||||||
|
self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
self.gfInc = tryImage(imagePath(title.gamefreakInc)
|
||||||
|
or "assets/generated/title/gamefreak_inc.png")
|
||||||
self.blue = GameVersion.isBlue()
|
self.blue = GameVersion.isBlue()
|
||||||
self.yellow = GameVersion.isYellow()
|
self.yellow = GameVersion.isYellow()
|
||||||
or title.layout == "yellow_pikachu"
|
or title.layout == "yellow_pikachu"
|
||||||
|
-- ..(pokeyellow engine/movie/title.asm .tileScreenCopyrightTiles / NineTile)
|
||||||
|
self.nineImg = self.yellow and tryImage(imagePath(title.nine)
|
||||||
|
or "assets/generated/title/nine.png") or nil
|
||||||
|
self.copyPrefix = self.yellow
|
||||||
|
and { 0, 1, 2, 3, 1, 2 } or { 0, 1, 2, 1, 3, 1, 4 }
|
||||||
-- Yellow title is a fixed Pikachu composition (title_yellow.asm), not
|
-- Yellow title is a fixed Pikachu composition (title_yellow.asm), not
|
||||||
-- TitleMons cycling. Prefer composed pikachu.png from the Yellow import.
|
-- TitleMons cycling. Prefer composed pikachu.png from the Yellow import.
|
||||||
self.yellowPikachu = self.yellow and tryImage(imagePath(title.pikachu)
|
self.yellowPikachu = self.yellow and tryImage(imagePath(title.pikachu)
|
||||||
@@ -183,7 +253,10 @@ function TitleState.new(game, opts)
|
|||||||
self.blinkTimer = 0
|
self.blinkTimer = 0
|
||||||
self.blinkAt = nil
|
self.blinkAt = nil
|
||||||
else
|
else
|
||||||
self.phase = "loop"
|
-- ..(engine/movie/title.asm ln 28)
|
||||||
|
self.scy = 0x40
|
||||||
|
self.phase = "drop"
|
||||||
|
self.dropStep, self.dropLeft = 1, nil
|
||||||
self.showBubble = true
|
self.showBubble = true
|
||||||
end
|
end
|
||||||
local defaultCycle = self.yellowLayout and { "PIKACHU" }
|
local defaultCycle = self.yellowLayout and { "PIKACHU" }
|
||||||
@@ -196,14 +269,15 @@ function TitleState.new(game, opts)
|
|||||||
self.cycleIndex = 1
|
self.cycleIndex = 1
|
||||||
self.timer = 0
|
self.timer = 0
|
||||||
self.blink = 0
|
self.blink = 0
|
||||||
|
self.scrollPhase = "hold"
|
||||||
|
self.scrollFrame = 1
|
||||||
|
self.monOffset = 0
|
||||||
|
self.ballY = BALL_REST
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
function TitleState:enter()
|
function TitleState:enter()
|
||||||
-- Yellow defers the title theme until after the logo drop and
|
if self.phase ~= "loop" then return end
|
||||||
-- Pikachu's cry (title.asm plays MUSIC_TITLE_SCREEN only after
|
|
||||||
-- WaitForSoundToFinish on PikachuCry1)
|
|
||||||
if self.yellowLayout then return end
|
|
||||||
self:startMusic()
|
self:startMusic()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -220,6 +294,11 @@ end
|
|||||||
local DROP_STEPS = {
|
local DROP_STEPS = {
|
||||||
{ -4, 16 }, { 3, 4 }, { -3, 4 }, { 2, 2 }, { -2, 2 }, { 1, 2 }, { -1, 2 },
|
{ -4, 16 }, { 3, 4 }, { -3, 4 }, { 2, 2 }, { -2, 2 }, { 1, 2 }, { -1, 2 },
|
||||||
}
|
}
|
||||||
|
local SETTLE_FRAMES = 36
|
||||||
|
|
||||||
|
-- ..(engine/movie/title.asm ln 201)
|
||||||
|
local RIBBON_FRAMES = {}
|
||||||
|
for offset = 112, 4, -4 do RIBBON_FRAMES[#RIBBON_FRAMES + 1] = offset end
|
||||||
|
|
||||||
-- the boot cinematic up to the interactive loop; one call per frame
|
-- the boot cinematic up to the interactive loop; one call per frame
|
||||||
function TitleState:updateSequence()
|
function TitleState:updateSequence()
|
||||||
@@ -243,12 +322,23 @@ function TitleState:updateSequence()
|
|||||||
self.dropLeft = nil
|
self.dropLeft = nil
|
||||||
end
|
end
|
||||||
elseif self.phase == "settle" then
|
elseif self.phase == "settle" then
|
||||||
-- ld c, 36 / DelayFrames, then the whoosh and the bubble
|
|
||||||
self.timer = self.timer + 1
|
self.timer = self.timer + 1
|
||||||
if self.timer >= 36 then
|
if self.timer >= SETTLE_FRAMES then
|
||||||
Sound.play(data, "Intro_Whoosh")
|
Sound.play(data, "Intro_Whoosh")
|
||||||
self.showBubble = true
|
self.showBubble = true
|
||||||
self.phase = "bubble"
|
self.phase = self.yellowLayout and "bubble" or "ribbon"
|
||||||
|
self.ribbonOffset = RIBBON_FRAMES[1]
|
||||||
|
self.timer = 0
|
||||||
|
end
|
||||||
|
elseif self.phase == "ribbon" then
|
||||||
|
self.timer = self.timer + 1
|
||||||
|
local offset = RIBBON_FRAMES[self.timer + 1]
|
||||||
|
if offset then
|
||||||
|
self.ribbonOffset = offset
|
||||||
|
else
|
||||||
|
self.ribbonOffset = nil
|
||||||
|
self:startMusic()
|
||||||
|
self.phase = "loop"
|
||||||
self.timer = 0
|
self.timer = 0
|
||||||
end
|
end
|
||||||
elseif self.phase == "bubble" then
|
elseif self.phase == "bubble" then
|
||||||
@@ -347,8 +437,12 @@ function ContinueInfo:draw()
|
|||||||
-- box at (4,7), 8x14 content; labels double-spaced from (5,9)
|
-- box at (4,7), 8x14 content; labels double-spaced from (5,9)
|
||||||
Font.drawBox(4, 7, 16, 10)
|
Font.drawBox(4, 7, 16, 10)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(Strings("PLAYER"), 40, 72)
|
-- the name follows the label's real width (one space after it), so a
|
||||||
Font.draw((save.player and save.player.name) or "RED", 96, 72)
|
-- localized label longer than PLAYER's six glyphs cannot run into it
|
||||||
|
local playerLabel = Strings("PLAYER")
|
||||||
|
Font.draw(playerLabel, 40, 72)
|
||||||
|
Font.draw((save.player and save.player.name) or "RED",
|
||||||
|
math.max(96, 40 + (#Font.split(playerLabel) + 1) * 8), 72)
|
||||||
local badges = require("src.inventory.Badges").count(self.game.data, save)
|
local badges = require("src.inventory.Badges").count(self.game.data, save)
|
||||||
Font.draw(Strings("BADGES"), 40, 88)
|
Font.draw(Strings("BADGES"), 40, 88)
|
||||||
Font.draw(("%2d"):format(badges), 128, 88)
|
Font.draw(("%2d"):format(badges), 128, 88)
|
||||||
@@ -401,17 +495,70 @@ function TitleState:openMenu()
|
|||||||
end
|
end
|
||||||
local th = #items * 2 + 2
|
local th = #items * 2 + 2
|
||||||
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
|
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
|
||||||
-- full-width title LOGO zones would recolor this box; see sgbPalettes
|
-- full-width title LOGO zones would recolor this box; see sgbPalettes.
|
||||||
menu.titleUiBox = { 0, 0, 12, th - 1 }
|
-- Menu.new may have grown tw for longer (e.g. localized) labels, so the
|
||||||
|
-- recolor zone follows the box's real width instead of the vanilla 13.
|
||||||
|
menu.titleUiBox = { 0, 0, menu.tw - 1, th - 1 }
|
||||||
game.stack:push(menu)
|
game.stack:push(menu)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ..(engine/movie/title.asm ln 271)
|
||||||
|
function TitleState:pickNewMon()
|
||||||
|
if #self.cycleSpecies < 2 then return end
|
||||||
|
local pick = self.cycleIndex
|
||||||
|
while pick == self.cycleIndex do
|
||||||
|
pick = love.math.random(1, #self.cycleSpecies)
|
||||||
|
end
|
||||||
|
self.cycleIndex = pick
|
||||||
|
end
|
||||||
|
|
||||||
|
function TitleState:setCyclePhase(phase)
|
||||||
|
self.scrollPhase = phase
|
||||||
|
self.scrollFrame = 1
|
||||||
|
self.timer = 0
|
||||||
|
if phase == "in" then
|
||||||
|
self:pickNewMon()
|
||||||
|
self.monOffset = IN_FRAMES[1]
|
||||||
|
elseif phase == "out" then
|
||||||
|
self.monOffset = OUT_FRAMES[1]
|
||||||
|
elseif phase == "ball" then
|
||||||
|
self.ballY = BALL_FRAMES[1]
|
||||||
|
else
|
||||||
|
self.monOffset = 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function TitleState:updateCycle()
|
||||||
|
local phase = self.scrollPhase
|
||||||
|
if phase == "hold" then
|
||||||
|
if self.timer >= HOLD_FRAMES then self:setCyclePhase("out") end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local frames = phase == "out" and OUT_FRAMES
|
||||||
|
or phase == "ball" and BALL_FRAMES or IN_FRAMES
|
||||||
|
self.scrollFrame = self.scrollFrame + 1
|
||||||
|
local value = frames[self.scrollFrame]
|
||||||
|
if value then
|
||||||
|
if phase == "ball" then self.ballY = value else self.monOffset = value end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if phase == "out" then
|
||||||
|
-- ..(engine/movie/title.asm ln 235)
|
||||||
|
self:setCyclePhase(
|
||||||
|
STARTERS[self.cycleSpecies[self.cycleIndex]] and "ball" or "in")
|
||||||
|
elseif phase == "ball" then
|
||||||
|
self:setCyclePhase("in")
|
||||||
|
else
|
||||||
|
self:setCyclePhase("hold")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function TitleState:update(dt)
|
function TitleState:update(dt)
|
||||||
if self.yellowLayout then
|
|
||||||
if self.phase ~= "loop" then
|
if self.phase ~= "loop" then
|
||||||
self:updateSequence()
|
self:updateSequence()
|
||||||
return -- input is ignored until the cinematic lands (title.asm)
|
return
|
||||||
end
|
end
|
||||||
|
if self.yellowLayout then
|
||||||
self:updateBlink()
|
self:updateBlink()
|
||||||
local input = self.game.input
|
local input = self.game.input
|
||||||
if input:wasPressed("start") or input:wasPressed("a") then
|
if input:wasPressed("start") or input:wasPressed("a") then
|
||||||
@@ -426,21 +573,7 @@ function TitleState:update(dt)
|
|||||||
end
|
end
|
||||||
self.timer = self.timer + 1
|
self.timer = self.timer + 1
|
||||||
self.blink = (self.blink + 1) % 60
|
self.blink = (self.blink + 1) % 60
|
||||||
if not self.yellowLayout and self.timer >= CYCLE_FRAMES then
|
self:updateCycle()
|
||||||
self.timer = 0
|
|
||||||
-- random pick that never repeats the current one
|
|
||||||
if #self.cycleSpecies > 1 then
|
|
||||||
local pick = self.cycleIndex
|
|
||||||
while pick == self.cycleIndex do
|
|
||||||
pick = love.math.random(1, #self.cycleSpecies)
|
|
||||||
end
|
|
||||||
self.cycleIndex = pick
|
|
||||||
end
|
|
||||||
self.slideIn = 20 -- TitleScreenScrollInMon slides the pic in
|
|
||||||
end
|
|
||||||
if self.slideIn and self.slideIn > 0 then
|
|
||||||
self.slideIn = self.slideIn - 1
|
|
||||||
end
|
|
||||||
local input = self.game.input
|
local input = self.game.input
|
||||||
if input:wasPressed("start") or input:wasPressed("a") then
|
if input:wasPressed("start") or input:wasPressed("a") then
|
||||||
-- the title mon cries when you leave the title (.finishedWaiting);
|
-- the title mon cries when you leave the title (.finishedWaiting);
|
||||||
@@ -452,15 +585,14 @@ function TitleState:update(dt)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The original tilemap (engine/movie/title.asm): logo at tile (2,1),
|
-- ..(engine/movie/title.asm ln 28)
|
||||||
-- the version ribbon at (7,8), Red's title art as OAM at px (82,80),
|
|
||||||
-- the title mon in the 7x7 box at tile (5,10), copyright on row 17.
|
|
||||||
-- Yellow (title_yellow.asm): logo (2,1), speech bubble (6,4), Pikachu
|
|
||||||
-- (4,8) 12x9 -- no version ribbon, no cycling mon, no Red OAM.
|
|
||||||
function TitleState:draw()
|
function TitleState:draw()
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
local scrollY = self.yellowLayout and -(self.scy or 0) or 0
|
local scrollY = -(self.scy or 0)
|
||||||
|
-- ..(engine/movie/title.asm ln 28)
|
||||||
|
local preRibbon = not self.yellowLayout
|
||||||
|
and (self.phase == "drop" or self.phase == "settle")
|
||||||
if self.logo then
|
if self.logo then
|
||||||
love.graphics.draw(self.logo, 16, 8 + scrollY)
|
love.graphics.draw(self.logo, 16, 8 + scrollY)
|
||||||
else
|
else
|
||||||
@@ -488,24 +620,29 @@ function TitleState:draw()
|
|||||||
-- Yellow's Version_GFX slot holds a leftover "Blue Version" ribbon
|
-- Yellow's Version_GFX slot holds a leftover "Blue Version" ribbon
|
||||||
-- (pokeyellow gfx/title/blue_version.png, unreferenced by title code);
|
-- (pokeyellow gfx/title/blue_version.png, unreferenced by title code);
|
||||||
-- the Yellow fallback layout draws no ribbon at all.
|
-- the Yellow fallback layout draws no ribbon at all.
|
||||||
if self.version and not self.yellow then
|
if self.version and not self.yellow and not preRibbon then
|
||||||
local iw, ih = self.version:getDimensions()
|
local iw, ih = self.version:getDimensions()
|
||||||
if self.blue then
|
local rx = self.ribbonOffset or 0
|
||||||
|
if self.versionFull then
|
||||||
|
-- a continuous ribbon (versionRibbon) centers as one piece
|
||||||
|
love.graphics.draw(self.version, math.floor((160 - iw) / 2) + rx, 64)
|
||||||
|
elseif self.blue then
|
||||||
love.graphics.draw(self.version,
|
love.graphics.draw(self.version,
|
||||||
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64)
|
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56 + rx, 64)
|
||||||
else
|
else
|
||||||
love.graphics.draw(self.version,
|
love.graphics.draw(self.version,
|
||||||
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
|
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56 + rx, 64)
|
||||||
love.graphics.draw(self.version,
|
love.graphics.draw(self.version,
|
||||||
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
|
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80 + rx, 64)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local sprite, spriteTrueColor = self:currentSprite()
|
local sprite, spriteTrueColor
|
||||||
|
if self.scrollPhase ~= "ball" then
|
||||||
|
sprite, spriteTrueColor = self:currentSprite()
|
||||||
|
end
|
||||||
if sprite then
|
if sprite then
|
||||||
local w, h = sprite:getDimensions()
|
local w, h = sprite:getDimensions()
|
||||||
local slide = (self.slideIn or 0) * 8 -- scroll in from the right
|
local x = 40 + math.floor((56 - w) / 2) + self.monOffset
|
||||||
-- bottom-aligned and centered in the (5,10)-(11,16) tile box
|
|
||||||
local x = 40 + math.floor((56 - w) / 2) + slide
|
|
||||||
local y = 136 - h
|
local y = 136 - h
|
||||||
love.graphics.draw(sprite, x, y)
|
love.graphics.draw(sprite, x, y)
|
||||||
-- a full-color mon keeps its own palette through the SGB pass, minus
|
-- a full-color mon keeps its own palette through the SGB pass, minus
|
||||||
@@ -521,13 +658,34 @@ function TitleState:draw()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- Red is OAM in the original: he draws over the mon's box edge
|
-- Red is OAM in the original: he draws over the mon's box edge
|
||||||
if self.player then
|
if self.playerQuads then
|
||||||
|
for _, part in ipairs(self.playerQuads) do
|
||||||
|
love.graphics.draw(self.player, part[1], 82 + part[2], 80 + part[3])
|
||||||
|
end
|
||||||
|
love.graphics.draw(self.player, self.ballQuad, 82, self.ballY)
|
||||||
|
elseif self.player then
|
||||||
love.graphics.draw(self.player, 82, 80)
|
love.graphics.draw(self.player, 82, 80)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
self:drawCopyright(136 + (preRibbon and 0 or scrollY))
|
||||||
|
end
|
||||||
|
|
||||||
|
function TitleState:drawCopyright(y)
|
||||||
|
if not self.title.copyrightText and self.copyImg and self.gfInc then
|
||||||
|
local x = 16
|
||||||
|
for _, t in ipairs(self.copyPrefix) do
|
||||||
|
love.graphics.draw(self.copyImg, self.copyQuads[t], x, y)
|
||||||
|
x = x + 8
|
||||||
|
end
|
||||||
|
if self.nineImg then
|
||||||
|
love.graphics.draw(self.nineImg, x, y)
|
||||||
|
x = x + 8
|
||||||
|
end
|
||||||
|
love.graphics.draw(self.gfInc, x, y)
|
||||||
|
return
|
||||||
|
end
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(self.title.copyrightText or Strings("2026 bois club games"),
|
Font.draw(self.title.copyrightText or Strings("GAME FREAK inc."), 16, y)
|
||||||
1, 136 + scrollY)
|
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -52,8 +52,13 @@ end
|
|||||||
-- falls back to require.
|
-- falls back to require.
|
||||||
function Check.parseRelease(jsonText, Json)
|
function Check.parseRelease(jsonText, Json)
|
||||||
Json = Json or require("src.link.Json")
|
Json = Json or require("src.link.Json")
|
||||||
local doc = Json.decode(jsonText)
|
local notJson = Json.describeUnexpected(jsonText)
|
||||||
if type(doc) ~= "table" or not doc.tag_name then
|
if notJson then return nil, notJson end
|
||||||
|
local doc, decodeErr = Json.decode(jsonText)
|
||||||
|
if type(doc) ~= "table" then
|
||||||
|
return nil, decodeErr or "no tag_name in release json"
|
||||||
|
end
|
||||||
|
if not doc.tag_name then
|
||||||
return nil, "no tag_name in release json"
|
return nil, "no tag_name in release json"
|
||||||
end
|
end
|
||||||
local version = stripV(doc.tag_name)
|
local version = stripV(doc.tag_name)
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ local mapScripts -- registry of hand-ported map scripts
|
|||||||
local COMPASS = { up = "north", down = "south", left = "west", right = "east" }
|
local COMPASS = { up = "north", down = "south", left = "west", right = "east" }
|
||||||
local DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
|
local DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
|
||||||
|
|
||||||
|
-- pokered's wNumberOfNoRandomBattleStepsLeft: three completed steps
|
||||||
|
-- after a wild battle before another random battle can start.
|
||||||
|
local WILD_ENCOUNTER_GRACE_STEPS = 3
|
||||||
|
|
||||||
-- Fly animation coord paths (engine/overworld/player_animations.asm):
|
-- Fly animation coord paths (engine/overworld/player_animations.asm):
|
||||||
-- y/x pairs in GB screen pixels, one pair every 3 frames (DoFlyAnimation's
|
-- y/x pairs in GB screen pixels, one pair every 3 frames (DoFlyAnimation's
|
||||||
-- Delay3). The port anchors a path on the player's own position instead
|
-- Delay3). The port anchors a path on the player's own position instead
|
||||||
@@ -81,8 +85,10 @@ local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 }
|
|||||||
-- above (screen = tile*8 + pixel - 8/16), measured against the player
|
-- above (screen = tile*8 + pixel - 8/16), measured against the player
|
||||||
-- sprite's fixed screen spot: ResetPlayerSpriteData parks it at $3c/$40
|
-- sprite's fixed screen spot: ResetPlayerSpriteData parks it at $3c/$40
|
||||||
-- (home/reset_player_sprite.asm), i.e. screen (64,60). So what ports over
|
-- (home/reset_player_sprite.asm), i.e. screen (64,60). So what ports over
|
||||||
-- is the delta from the sprite's top-left, which SpriteRenderer:draw puts at
|
-- is the delta from the sprite's top-left, which the vanilla
|
||||||
-- (px, py - 4). `tile` indexes the three stacked 8x8 tiles of
|
-- SpriteRenderer:draw puts at (px, py - 4); custom frame anchors move that
|
||||||
|
-- origin while keeping these offsets frame-relative. `tile` indexes the
|
||||||
|
-- three stacked 8x8 tiles of
|
||||||
-- assets/generated/fx/fishing_rod.png: FishingRodOAM only ever draws $fd
|
-- assets/generated/fx/fishing_rod.png: FishingRodOAM only ever draws $fd
|
||||||
-- (row 0, up/down) and $fe (row 1, left/right), and RIGHT is the LEFT tile
|
-- (row 0, up/down) and $fe (row 1, left/right), and RIGHT is the LEFT tile
|
||||||
-- x-flipped. Blitting the whole 8x24 sheet is what drew the rod as a
|
-- x-flipped. Blitting the whole 8x24 sheet is what drew the rod as a
|
||||||
@@ -206,7 +212,7 @@ function OverworldState.computeNeighbors(maps, rootId, hops, reachW, reachH)
|
|||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
function OverworldState:enter(mapId, x, y, facing)
|
function OverworldState:enter(mapId, x, y, facing, opts)
|
||||||
Game = require("src.core.Game")
|
Game = require("src.core.Game")
|
||||||
Game.overworld = self
|
Game.overworld = self
|
||||||
Collision.load(Game.data) -- tile-pair (elevation) collisions
|
Collision.load(Game.data) -- tile-pair (elevation) collisions
|
||||||
@@ -224,10 +230,12 @@ function OverworldState:enter(mapId, x, y, facing)
|
|||||||
-- a fresh entry, or a stale flag can freeze player input forever
|
-- a fresh entry, or a stale flag can freeze player input forever
|
||||||
self.engaging = false
|
self.engaging = false
|
||||||
self.emote = nil
|
self.emote = nil
|
||||||
|
-- volatile WRAM state in pokered; never serialize across save/load
|
||||||
|
self.wildEncounterGraceSteps = 0
|
||||||
-- survives save/load: a loaded game may start inside a building whose
|
-- survives save/load: a loaded game may start inside a building whose
|
||||||
-- exit mat is a LAST_MAP warp
|
-- exit mat is a LAST_MAP warp
|
||||||
self.lastOutdoor = Game.save.lastOutdoor
|
self.lastOutdoor = Game.save.lastOutdoor
|
||||||
self:setMap(mapId, x, y, facing, { via = "boot" })
|
self:setMap(mapId, x, y, facing, opts or { via = "boot" })
|
||||||
-- boot/load: derive the flag from the tile the save left us standing on,
|
-- boot/load: derive the flag from the tile the save left us standing on,
|
||||||
-- like MapEntryAfterBattle's IsPlayerStandingOnWarp, so a game saved on a
|
-- like MapEntryAfterBattle's IsPlayerStandingOnWarp, so a game saved on a
|
||||||
-- door mat can still walk straight back out (issue #378)
|
-- door mat can still walk straight back out (issue #378)
|
||||||
@@ -282,7 +290,7 @@ end
|
|||||||
|
|
||||||
function OverworldState:setMap(mapId, x, y, facing, opts)
|
function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||||
local fromMapId = self.map and self.map.id
|
local fromMapId = self.map and self.map.id
|
||||||
if fromMapId then
|
if fromMapId and not (opts and opts.checkpoint) then
|
||||||
Runtime.emit("map.exited", { mapId = fromMapId, toMapId = mapId })
|
Runtime.emit("map.exited", { mapId = fromMapId, toMapId = mapId })
|
||||||
end
|
end
|
||||||
-- ambient choreography is per-map: parallel runners die here, and the
|
-- ambient choreography is per-map: parallel runners die here, and the
|
||||||
@@ -451,8 +459,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
|||||||
local keepMusic = (opts and opts.keepMusic) or self.keepMusicOnce
|
local keepMusic = (opts and opts.keepMusic) or self.keepMusicOnce
|
||||||
self.keepMusicOnce = nil
|
self.keepMusicOnce = nil
|
||||||
if not keepMusic then
|
if not keepMusic then
|
||||||
require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike,
|
-- ..(home/overworld.asm ln 2346)
|
||||||
self.player.surfing)
|
local Music = require("src.core.Music")
|
||||||
|
Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing,
|
||||||
|
Music.MAP_FADE)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- forced bike/surf tiles fire the moment the player is placed on the
|
-- forced bike/surf tiles fire the moment the player is placed on the
|
||||||
@@ -460,13 +470,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
|||||||
-- (home/overworld.asm) -- a warp can land directly on one (the Route
|
-- (home/overworld.asm) -- a warp can land directly on one (the Route
|
||||||
-- 16/18 gate exits), and the scripted door-mat walkout that follows
|
-- 16/18 gate exits), and the scripted door-mat walkout that follows
|
||||||
-- suppresses onStepComplete, so waiting for a plain step never mounts
|
-- suppresses onStepComplete, so waiting for a plain step never mounts
|
||||||
self:checkForcedMovement()
|
if not (opts and opts.checkpoint) then self:checkForcedMovement() end
|
||||||
-- Seafoam B4F's map script pushes off the B3F stair warps every frame
|
-- Seafoam B4F's map script pushes off the B3F stair warps every frame
|
||||||
-- while the upper plugs are out (SeafoamIslandsB4FDefaultScript); the
|
-- while the upper plugs are out (SeafoamIslandsB4FDefaultScript); the
|
||||||
-- B3F/B4F force-surf mouths also arm their MOVE_OBJECT current scripts
|
-- B3F/B4F force-surf mouths also arm their MOVE_OBJECT current scripts
|
||||||
-- from CheckForceBikeOrSurf. Re-check here so a warp-in does not sit
|
-- from CheckForceBikeOrSurf. Re-check here so a warp-in does not sit
|
||||||
-- idle on those cells waiting for a player step.
|
-- idle on those cells waiting for a player step.
|
||||||
self:checkSeafoamCurrent()
|
if not (opts and opts.checkpoint) then self:checkSeafoamCurrent() end
|
||||||
|
|
||||||
-- snap the camera immediately: the overworld doesn't update while a
|
-- snap the camera immediately: the overworld doesn't update while a
|
||||||
-- Transition is on top, so a stale camera would show the new map at
|
-- Transition is on top, so a stale camera would show the new map at
|
||||||
@@ -476,27 +486,31 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
|||||||
|
|
||||||
-- fires before the onEnter chain so a listener sees the map in the same
|
-- fires before the onEnter chain so a listener sees the map in the same
|
||||||
-- state the map script does
|
-- state the map script does
|
||||||
|
if not (opts and opts.checkpoint) then
|
||||||
Runtime.emit("map.entered", {
|
Runtime.emit("map.entered", {
|
||||||
mapId = mapId, map = self.map, fromMapId = fromMapId,
|
mapId = mapId, map = self.map, fromMapId = fromMapId,
|
||||||
via = (opts and opts.via)
|
via = (opts and opts.via)
|
||||||
or (opts and opts.seamless and "connection")
|
or (opts and opts.seamless and "connection")
|
||||||
or (fromMapId and "warp" or "boot"),
|
or (fromMapId and "warp" or "boot"),
|
||||||
})
|
})
|
||||||
|
end
|
||||||
|
|
||||||
-- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers).
|
-- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers).
|
||||||
-- fromMapId lets elevators seed a valid walk-out floor when the ROM
|
-- fromMapId lets elevators seed a valid walk-out floor when the ROM
|
||||||
-- car warps still point at a missing map (Silph's UNUSED_MAP_ED) and
|
-- car warps still point at a missing map (Silph's UNUSED_MAP_ED) and
|
||||||
-- the player B-cancels the floor menu without .UpdateWarp.
|
-- the player B-cancels the floor menu without .UpdateWarp.
|
||||||
|
if not (opts and opts.checkpoint) then
|
||||||
local hooks = mapScripts.get(mapId)
|
local hooks = mapScripts.get(mapId)
|
||||||
if hooks and hooks.onEnter then
|
if hooks and hooks.onEnter then
|
||||||
hooks.onEnter(Game, self, fromMapId)
|
hooks.onEnter(Game, self, fromMapId)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
self:rebuildNeighbors()
|
self:rebuildNeighbors()
|
||||||
Logger.info("map: %s at (%d,%d)", mapId, x, y)
|
Logger.info("map: %s at (%d,%d)", mapId, x, y)
|
||||||
-- Route22Gate_Script rewrites wLastMap from the player's Y on entry
|
-- Route22Gate_Script rewrites wLastMap from the player's Y on entry
|
||||||
-- too (not only on step), so a save/load mid-gate keeps exits correct
|
-- too (not only on step), so a save/load mid-gate keeps exits correct
|
||||||
self:syncLastMapRewrite()
|
if not (opts and opts.checkpoint) then self:syncLastMapRewrite() end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Neighbor maps drawn at the composed connection offsets: at least the
|
-- Neighbor maps drawn at the composed connection offsets: at least the
|
||||||
@@ -760,8 +774,8 @@ function OverworldState:pushBattle(battle)
|
|||||||
local enemyLevel = battle.enemy and battle.enemy.mon and battle.enemy.mon.level or 0
|
local enemyLevel = battle.enemy and battle.enemy.mon and battle.enemy.mon.level or 0
|
||||||
-- the battle theme starts with the wipe, not after it
|
-- the battle theme starts with the wipe, not after it
|
||||||
-- (audio/play_battle_music.asm runs before the transition)
|
-- (audio/play_battle_music.asm runs before the transition)
|
||||||
if battle.computeMusicKind then
|
if battle.playBattleTheme then
|
||||||
require("src.core.Music").playBattle(Game.data, battle:computeMusicKind())
|
battle:playBattleTheme()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The fade back in from white on the way out is BattleState:finish()'s
|
-- The fade back in from white on the way out is BattleState:finish()'s
|
||||||
@@ -969,8 +983,14 @@ function OverworldState:update(dt)
|
|||||||
-- the bird carries the player in on landing, with its own
|
-- the bird carries the player in on landing, with its own
|
||||||
-- SFX_FLY (EnterMapAnim .flyAnimation)
|
-- SFX_FLY (EnterMapAnim .flyAnimation)
|
||||||
self.arriveWarp = "fly"
|
self.arriveWarp = "fly"
|
||||||
|
-- keep the sprite hidden through the warp fade-out (#916): flyAnim
|
||||||
|
-- just went nil but flyArrive is not armed until startWarpTo's
|
||||||
|
-- midpoint, and the overworld keeps drawing beneath the veil, so
|
||||||
|
-- without this the trainer pops back in at the old cell for 32 frames
|
||||||
|
self.playerHidden = true
|
||||||
self:startWarpTo(d.map, d.x, d.y, "down", nil, { via = "fly" })
|
self:startWarpTo(d.map, d.x, d.y, "down", nil, { via = "fly" })
|
||||||
else
|
else
|
||||||
|
self.playerHidden = false
|
||||||
self.player.inputLocked = false
|
self.player.inputLocked = false
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
@@ -1002,6 +1022,10 @@ function OverworldState:update(dt)
|
|||||||
self.player.spinFrames = nil
|
self.player.spinFrames = nil
|
||||||
self.player.spinRise = nil
|
self.player.spinRise = nil
|
||||||
self.player.inputLocked = false
|
self.player.inputLocked = false
|
||||||
|
-- keep the sprite hidden through the warp fade-out (#916): the spin is
|
||||||
|
-- over but the arrival spin-drop is not armed until startWarpTo's
|
||||||
|
-- midpoint, so without this the standing trainer shows under the veil
|
||||||
|
self.playerHidden = true
|
||||||
self:warpToHealPoint(onDone, { arrive = "teleport" })
|
self:warpToHealPoint(onDone, { arrive = "teleport" })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -1079,8 +1103,10 @@ function OverworldState:update(dt)
|
|||||||
local mapId = self.pendingSeamMusic
|
local mapId = self.pendingSeamMusic
|
||||||
self.pendingSeamMusic = nil
|
self.pendingSeamMusic = nil
|
||||||
if mapId == self.map.id then
|
if mapId == self.map.id then
|
||||||
require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike,
|
-- ..(home/overworld.asm ln 677)
|
||||||
self.player.surfing)
|
local Music = require("src.core.Music")
|
||||||
|
Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing,
|
||||||
|
Music.MAP_FADE)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if stepped and not scripted then
|
if stepped and not scripted then
|
||||||
@@ -1204,7 +1230,7 @@ function OverworldState:handleInput()
|
|||||||
if self:checkLedgeHop(dir) then return end
|
if self:checkLedgeHop(dir) then return end
|
||||||
if self:checkBoulderPush(dir) then return end
|
if self:checkBoulderPush(dir) then return end
|
||||||
end
|
end
|
||||||
local result, why = self.player:tryMove(dir, self.map, self.entities)
|
local result = self.player:tryMove(dir, self.map, self.entities)
|
||||||
-- a collision while standing on a warp square fires the warp when the
|
-- a collision while standing on a warp square fires the warp when the
|
||||||
-- extra check passes (CheckWarpsCollision: route-gate doorways, dock
|
-- extra check passes (CheckWarpsCollision: route-gate doorways, dock
|
||||||
-- entrances, ...), and only while BIT_STANDING_ON_WARP is set (issue
|
-- entrances, ...), and only while BIT_STANDING_ON_WARP is set (issue
|
||||||
@@ -1217,7 +1243,9 @@ function OverworldState:handleInput()
|
|||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if result == "blocked" and why ~= "entity" then
|
-- CollisionCheckOnLand (home/overworld.asm): a sprite takes the same
|
||||||
|
-- .collision branch as an impassable tile (#960)
|
||||||
|
if result == "blocked" then
|
||||||
if (self.bumpCooldown or 0) <= 0 then
|
if (self.bumpCooldown or 0) <= 0 then
|
||||||
require("src.core.Sound").play(Game.data, "Collision")
|
require("src.core.Sound").play(Game.data, "Collision")
|
||||||
self.bumpCooldown = 16
|
self.bumpCooldown = 16
|
||||||
@@ -2013,7 +2041,8 @@ function OverworldState:tryHiddenObject(fx, fy)
|
|||||||
-- SOMEONE'S/BILL'S PC main menu (DisplayPCMainMenu). Every other
|
-- SOMEONE'S/BILL'S PC main menu (DisplayPCMainMenu). Every other
|
||||||
-- pcTile is a Pokémon Center-style PC that shows the multi-PC menu. (#228)
|
-- pcTile is a Pokémon Center-style PC that shows the multi-PC menu. (#228)
|
||||||
require("src.core.Sound").play(Game.data, "Turn_On_PC")
|
require("src.core.Sound").play(Game.data, "Turn_On_PC")
|
||||||
Screens.push(Game, "PlayerPC")
|
-- direct access: ExitPlayerPC rings SFX_TURN_OFF_PC (players_pc.asm, #960)
|
||||||
|
Screens.push(Game, "PlayerPC", { direct = true })
|
||||||
else
|
else
|
||||||
self:openPC()
|
self:openPC()
|
||||||
end
|
end
|
||||||
@@ -2719,6 +2748,8 @@ function OverworldState:openPC(onDone)
|
|||||||
label = (Game.save.player.name or "RED") .. "'s PC",
|
label = (Game.save.player.name or "RED") .. "'s PC",
|
||||||
keepOpen = true,
|
keepOpen = true,
|
||||||
onSelect = function()
|
onSelect = function()
|
||||||
|
-- pc.asm .playersPC plays SFX_ENTER_PC before the farcall (#960)
|
||||||
|
require("src.core.Sound").play(Game.data, "Enter_PC")
|
||||||
Screens.push(Game, "PlayerPC")
|
Screens.push(Game, "PlayerPC")
|
||||||
done()
|
done()
|
||||||
end,
|
end,
|
||||||
@@ -2730,6 +2761,8 @@ function OverworldState:openPC(onDone)
|
|||||||
label = Strings("PROF.OAK's PC"),
|
label = Strings("PROF.OAK's PC"),
|
||||||
keepOpen = true,
|
keepOpen = true,
|
||||||
onSelect = function()
|
onSelect = function()
|
||||||
|
-- pc.asm OaksPC plays SFX_ENTER_PC before the farcall (#960)
|
||||||
|
require("src.core.Sound").play(Game.data, "Enter_PC")
|
||||||
self:openOaksPC(done)
|
self:openOaksPC(done)
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
@@ -2913,17 +2946,29 @@ function OverworldState:nurseHeal(onDone, npc)
|
|||||||
-- line: it comes back on the counter facing the player
|
-- line: it comes back on the counter facing the player
|
||||||
Follower.setVisible(self, true)
|
Follower.setVisible(self, true)
|
||||||
if npc then npc:facePlayer(self.player) end
|
if npc then npc:facePlayer(self.player) end
|
||||||
self:finishNurseHeal(bye, onDone)
|
self:finishNurseHeal(bye, onDone, npc)
|
||||||
end
|
end
|
||||||
end))
|
end))
|
||||||
end)
|
end)
|
||||||
end }))
|
end }))
|
||||||
end
|
end
|
||||||
|
|
||||||
function OverworldState:finishNurseHeal(bye, onDone)
|
-- pokecenter.asm bows the nurse between the two PrintText calls (#995)
|
||||||
|
function OverworldState:finishNurseHeal(bye, onDone, npc)
|
||||||
local t = Game.data.text
|
local t = Game.data.text
|
||||||
local fit = t._PokemonFightingFitText or Strings("Your POKéMON are\nfighting fit!")
|
local fit = t._PokemonFightingFitText or Strings("Your POKéMON are\nfighting fit!")
|
||||||
Game.stack:push(TextBox.new(Game, fit .. "\f" .. bye, onDone))
|
Game.stack:push(TextBox.new(Game, fit, function()
|
||||||
|
local function farewell()
|
||||||
|
Game.stack:push(TextBox.new(Game, bye, function()
|
||||||
|
if npc then npc:facePlayer(self.player) end
|
||||||
|
if onDone then onDone() end
|
||||||
|
end))
|
||||||
|
end
|
||||||
|
if not npc then farewell() return end
|
||||||
|
npc.facing = "up"
|
||||||
|
-- bubble = false is the silent world hold, this port's DelayFrames
|
||||||
|
self.emote = { npc = npc, frames = 20, bubble = false, onDone = farewell }
|
||||||
|
end))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The Cable Club link receptionist (TX_SCRIPT_CABLE_CLUB_RECEPTIONIST ->
|
-- The Cable Club link receptionist (TX_SCRIPT_CABLE_CLUB_RECEPTIONIST ->
|
||||||
@@ -3045,6 +3090,14 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
|
|||||||
if theme then require("src.core.Music").play(Game.data, theme) end
|
if theme then require("src.core.Music").play(Game.data, theme) end
|
||||||
end
|
end
|
||||||
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty)
|
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty)
|
||||||
|
battle.checkpointOrigin = {
|
||||||
|
kind = "trainer_encounter",
|
||||||
|
map = self.map.id,
|
||||||
|
npcId = npc.id,
|
||||||
|
trainerClass = d.trainerClass,
|
||||||
|
partyIndex = d.trainerParty or 1,
|
||||||
|
event = header and header.event or nil,
|
||||||
|
}
|
||||||
-- PrintEndBattleText (home/trainers.asm:341) is called from
|
-- PrintEndBattleText (home/trainers.asm:341) is called from
|
||||||
-- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle
|
-- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle
|
||||||
-- screen once ScrollTrainerPicAfterBattle has brought the beaten trainer
|
-- screen once ScrollTrainerPicAfterBattle has brought the beaten trainer
|
||||||
@@ -3450,6 +3503,10 @@ end
|
|||||||
|
|
||||||
function OverworldState:onStepComplete()
|
function OverworldState:onStepComplete()
|
||||||
local p = self.player
|
local p = self.player
|
||||||
|
local suppressWildEncounter = self.wildEncounterGraceSteps > 0
|
||||||
|
if suppressWildEncounter then
|
||||||
|
self.wildEncounterGraceSteps = self.wildEncounterGraceSteps - 1
|
||||||
|
end
|
||||||
self.todSteps = (self.todSteps or 0) + 1
|
self.todSteps = (self.todSteps or 0) + 1
|
||||||
-- UpdatePikachuHappinessAndMood rides the step counter (poison.asm)
|
-- UpdatePikachuHappinessAndMood rides the step counter (poison.asm)
|
||||||
require("src.world.PikachuFollower").onStep(Game.save)
|
require("src.world.PikachuFollower").onStep(Game.save)
|
||||||
@@ -3562,6 +3619,9 @@ function OverworldState:onStepComplete()
|
|||||||
-- wild encounters in grass, on water while surfing, or -- on indoor
|
-- wild encounters in grass, on water while surfing, or -- on indoor
|
||||||
-- maps whose tileset is not FOREST -- on EVERY tile
|
-- maps whose tileset is not FOREST -- on EVERY tile
|
||||||
-- (wild_encounters.asm: caves, towers, the Mansion, Power Plant)
|
-- (wild_encounters.asm: caves, towers, the Mansion, Power Plant)
|
||||||
|
-- The cooldown is checked after all other step processing so repel and
|
||||||
|
-- movement systems continue to advance during the protected steps.
|
||||||
|
if suppressWildEncounter then return end
|
||||||
local encDef = Game.data.encounters[self.map.id]
|
local encDef = Game.data.encounters[self.map.id]
|
||||||
local enc
|
local enc
|
||||||
local indoor = Game.data.field.indoorEncounters
|
local indoor = Game.data.field.indoorEncounters
|
||||||
@@ -3582,6 +3642,10 @@ function OverworldState:onStepComplete()
|
|||||||
end
|
end
|
||||||
local BattleState = require("src.battle.BattleState")
|
local BattleState = require("src.battle.BattleState")
|
||||||
local battle = BattleState.newWild(Game, enc.species, enc.level)
|
local battle = BattleState.newWild(Game, enc.species, enc.level)
|
||||||
|
battle.checkpointOrigin = {
|
||||||
|
kind = "wild_encounter",
|
||||||
|
map = self.map.id,
|
||||||
|
}
|
||||||
-- map.ghostBattles: unidentifiable without the named item (the
|
-- map.ghostBattles: unidentifiable without the named item (the
|
||||||
-- Pokemon Tower's Silph Scope)
|
-- Pokemon Tower's Silph Scope)
|
||||||
local ghost = Map.ghostBattles(self.map.def)
|
local ghost = Map.ghostBattles(self.map.def)
|
||||||
@@ -3944,6 +4008,9 @@ end
|
|||||||
-- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the
|
-- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the
|
||||||
-- blackout (pret HandlePlayerBlackOut) so the map script can HealParty.
|
-- blackout (pret HandlePlayerBlackOut) so the map script can HealParty.
|
||||||
function OverworldState:afterBattle(result, battle)
|
function OverworldState:afterBattle(result, battle)
|
||||||
|
if battle and battle.kind == "wild" then
|
||||||
|
self.wildEncounterGraceSteps = WILD_ENCOUNTER_GRACE_STEPS
|
||||||
|
end
|
||||||
local lead = Game.save.party[1]
|
local lead = Game.save.party[1]
|
||||||
Logger.info("battle over: %s (lead %s %d/%d)", tostring(result),
|
Logger.info("battle over: %s (lead %s %d/%d)", tostring(result),
|
||||||
lead and lead.species or "-", lead and lead.hp or 0,
|
lead and lead.species or "-", lead and lead.hp or 0,
|
||||||
@@ -3986,6 +4053,39 @@ function OverworldState:afterBattle(result, battle)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Rebind the data-only continuation attached to a supported battle checkpoint.
|
||||||
|
-- The overworld was reconstructed first, so transient input/NPC freezes from
|
||||||
|
-- the original encounter are intentionally not resumed.
|
||||||
|
function OverworldState:restoreBattleContinuation(battle, origin)
|
||||||
|
local game = battle and battle.game
|
||||||
|
if not game or type(origin) ~= "table" or not self.map
|
||||||
|
or origin.map ~= self.map.id then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if origin.kind == "wild_encounter" and battle.kind == "wild" then
|
||||||
|
battle.onFinish = function(result) self:afterBattle(result, battle) end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if origin.kind ~= "trainer_encounter" or battle.kind ~= "trainer"
|
||||||
|
or origin.trainerClass ~= battle.oppClass
|
||||||
|
or origin.partyIndex ~= (battle.partyIndex or 1)
|
||||||
|
or type(origin.npcId) ~= "string" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
battle.onFinish = function(result)
|
||||||
|
if result == "win" then
|
||||||
|
game.save.defeatedTrainers[origin.npcId] = true
|
||||||
|
if origin.event then game.save.flags[origin.event] = true end
|
||||||
|
self:checkVictoryRewards(battle.oppClass, battle.partyIndex)
|
||||||
|
end
|
||||||
|
self:afterBattle(result, battle)
|
||||||
|
self.engaging = false
|
||||||
|
local npc = self.npcPool and self.npcPool[origin.npcId]
|
||||||
|
if npc then npc.frozen = false end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
-- -------------------------------------------------------------------------
|
-- -------------------------------------------------------------------------
|
||||||
-- warps
|
-- warps
|
||||||
-- -------------------------------------------------------------------------
|
-- -------------------------------------------------------------------------
|
||||||
@@ -4134,8 +4234,21 @@ 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
|
||||||
|
-- PlayMapChangeSound (home/overworld.asm) plays before the tail-called
|
||||||
|
-- GBFadeOutToBlack, so the SFX starts with the fade (#961)
|
||||||
|
if doorWarp then
|
||||||
|
local dest = Game.data.maps[mapId]
|
||||||
|
local outdoor = dest and Map.isOutdoor(dest)
|
||||||
|
require("src.core.Sound").play(Game.data,
|
||||||
|
outdoor and "Go_Outside" or "Go_Inside")
|
||||||
|
end
|
||||||
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
|
||||||
|
-- map; the arrival arms its own cover (flyArrive / spinDrop) a few lines
|
||||||
|
-- down, so the player is never drawable mid-fade nor standing bare on the
|
||||||
|
-- landing frame (#916)
|
||||||
|
self.playerHidden = false
|
||||||
-- The warp we land ON stays inert for the completed-step check until we
|
-- The warp we land ON stays inert for the completed-step check until we
|
||||||
-- physically step off it, so a warp whose destination cell is itself a
|
-- physically step off it, so a warp whose destination cell is itself a
|
||||||
-- warp cannot bounce us straight back (elevator cars, stacked stair/door
|
-- warp cannot bounce us straight back (elevator cars, stacked stair/door
|
||||||
@@ -4165,9 +4278,6 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
|||||||
self.player.spinDrop = true
|
self.player.spinDrop = true
|
||||||
end
|
end
|
||||||
if doorWarp then
|
if doorWarp then
|
||||||
local outdoor = Map.isOutdoor(self.map.def)
|
|
||||||
require("src.core.Sound").play(Game.data,
|
|
||||||
outdoor and "Go_Outside" or "Go_Inside")
|
|
||||||
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
|
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
|
||||||
-- warp that lands on a door tile auto-steps south once, indoor or
|
-- warp that lands on a door tile auto-steps south once, indoor or
|
||||||
-- outdoor. Auto-walk leaves the mat, so the arrival disable
|
-- outdoor. Auto-walk leaves the mat, so the arrival disable
|
||||||
@@ -4735,9 +4845,15 @@ function OverworldState:drawWorld()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
local quad = self.rodQuads[oam.tile]
|
local quad = self.rodQuads[oam.tile]
|
||||||
-- the sprite's top-left is 4px above its cell (SpriteRenderer:draw)
|
-- Place the rod against the active sprite's anchored top-left. The
|
||||||
local rx = p.px - cam.x + oam.dx
|
-- vanilla result is still (px-cam, py-cam-4), while custom larger
|
||||||
local ry = p.py - cam.y - 4 + oam.dy
|
-- sheets keep the rod attached to their feet.
|
||||||
|
-- Fishing always uses the on-foot player sheet; read its fields
|
||||||
|
-- directly so this FX pass does not advance pose-side animation.
|
||||||
|
local sprite, px, py = p.sprite, p.px, p.py
|
||||||
|
local sx, sy = sprite:getScreenOrigin(px, py, cam.x, cam.y)
|
||||||
|
local rx = sx + oam.dx
|
||||||
|
local ry = sy + oam.dy
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
if quad and oam.flip then
|
if quad and oam.flip then
|
||||||
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1)
|
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1)
|
||||||
@@ -4860,7 +4976,8 @@ function OverworldState:drawWorld()
|
|||||||
g.npc:draw(cam.x - g.ox, cam.y - g.oy)
|
g.npc:draw(cam.x - g.ox, cam.y - g.oy)
|
||||||
end
|
end
|
||||||
for _, e in ipairs(self.entities) do
|
for _, e in ipairs(self.entities) do
|
||||||
if not ((self.flyAnim or self.flyArrive) and e == self.player) then
|
if not ((self.flyAnim or self.flyArrive or self.playerHidden)
|
||||||
|
and e == self.player) then
|
||||||
e:draw(cam.x, cam.y)
|
e:draw(cam.x, cam.y)
|
||||||
-- tall grass overdraws the sprite's feet (GB sprite priority);
|
-- tall grass overdraws the sprite's feet (GB sprite priority);
|
||||||
-- the overdraw is BG tiles, so it rides the shake offset too
|
-- the overdraw is BG tiles, so it rides the shake offset too
|
||||||
@@ -4911,7 +5028,8 @@ function OverworldState:drawWorld()
|
|||||||
items[#items + 1] = { y = g.npc.py + g.oy + 16, kind = "ghost", g = g }
|
items[#items + 1] = { y = g.npc.py + g.oy + 16, kind = "ghost", g = g }
|
||||||
end
|
end
|
||||||
for _, e in ipairs(self.entities) do
|
for _, e in ipairs(self.entities) do
|
||||||
if not ((self.flyAnim or self.flyArrive) and e == self.player) then
|
if not ((self.flyAnim or self.flyArrive or self.playerHidden)
|
||||||
|
and e == self.player) then
|
||||||
items[#items + 1] = { y = e.py + 16, kind = "entity", e = e }
|
items[#items + 1] = { y = e.py + 16, kind = "entity", e = e }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -122,6 +122,13 @@ local function shouldSpawn(game, ow)
|
|||||||
if not GameVersion.isYellow() then return false end
|
if not GameVersion.isYellow() then return false end
|
||||||
local save = game.save
|
local save = game.save
|
||||||
if not (save.flags and save.flags.EVENT_GOT_STARTER) then return false end
|
if not (save.flags and save.flags.EVENT_GOT_STARTER) then return false end
|
||||||
|
-- save.pikachuInBall mirrors DisablePikachuOverworldSpriteDrawing (pokeyellow
|
||||||
|
-- scripts/OaksLab.asm); nil falls back to the rival-fight flag (#1009)
|
||||||
|
if save.pikachuInBall == nil then
|
||||||
|
if not save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then return false end
|
||||||
|
elseif save.pikachuInBall then
|
||||||
|
return false
|
||||||
|
end
|
||||||
if save.onBike or (ow.player and ow.player.surfing) then return false end
|
if save.onBike or (ow.player and ow.player.surfing) then return false end
|
||||||
if not (game.data.sprites and game.data.sprites.SPRITE_PIKACHU) then
|
if not (game.data.sprites and game.data.sprites.SPRITE_PIKACHU) then
|
||||||
return false
|
return false
|
||||||
@@ -885,6 +892,28 @@ function PikachuFollower.onBillExitedMachine(game, ow)
|
|||||||
billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE")
|
billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- OaksLabPikachuMovementScript (pokeyellow scripts/OaksLab_2.asm): the
|
||||||
|
-- companion clears the cell the rival stops on (#1021)
|
||||||
|
function PikachuFollower.oaksLabMakeWay(game, ow, done)
|
||||||
|
if not GameVersion.isYellow() then return false end
|
||||||
|
local npc = findFollower(ow)
|
||||||
|
if not npc or not ow.player then return false end
|
||||||
|
local p = ow.player
|
||||||
|
local steps, facing
|
||||||
|
if p.cellY == 3 then -- .movement2, b = SPRITE_FACING_LEFT
|
||||||
|
if not (npc.cellY == p.cellY and npc.cellX < p.cellX) then return false end
|
||||||
|
steps, facing = { { "down", 1 }, { "right", 1 } }, "up"
|
||||||
|
else -- OaksLabPikachuMovementData1, b = SPRITE_FACING_DOWN
|
||||||
|
if npc.cellY <= p.cellY then return false end
|
||||||
|
steps, facing = { { "left", 1 }, { "up", 1 } }, "right"
|
||||||
|
end
|
||||||
|
movePikachu(ow, npc, steps, function()
|
||||||
|
npc.facing = facing -- PIKAMOVEMENT_LOOK_UP / _LOOK_RIGHT ends each table
|
||||||
|
if done then done() end
|
||||||
|
end)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
-- PikachuWalksToNurseJoy (engine/pikachu/pikachu_emotions.asm, run by
|
-- PikachuWalksToNurseJoy (engine/pikachu/pikachu_emotions.asm, run by
|
||||||
-- engine/events/pokecenter.asm once the heal is accepted): the companion
|
-- engine/events/pokecenter.asm once the heal is accepted): the companion
|
||||||
|
|||||||
@@ -335,8 +335,13 @@ function Player:draw(camX, camY)
|
|||||||
local fishTile = self.fishing and self.fishTiles and self.fishTiles[facing]
|
local fishTile = self.fishing and self.fishTiles and self.fishTiles[facing]
|
||||||
if fishTile then
|
if fishTile then
|
||||||
sprite:draw(px, py, camX, camY, facing, 0, false, true)
|
sprite:draw(px, py, camX, camY, facing, 0, false, true)
|
||||||
sprite:drawTile(fishTile, math.floor(px - camX),
|
-- The fishing pose replaces the bottom 8-pixel tile. Use the sprite's
|
||||||
math.floor(py - camY) - 4 + 8, facing == "right")
|
-- actual anchored frame origin so larger/custom sheets keep the pose at
|
||||||
|
-- their feet instead of falling back to the vanilla 16x16 top-left.
|
||||||
|
local sx, sy = sprite:getScreenOrigin(px, py, camX, camY)
|
||||||
|
sprite:drawTile(fishTile, sx,
|
||||||
|
sy + math.max(0, sprite.frameHeight - 8),
|
||||||
|
facing == "right")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
sprite:draw(px, py, camX, camY, facing, phase, flip)
|
sprite:draw(px, py, camX, camY, facing, phase, flip)
|
||||||
|
|||||||
@@ -6,13 +6,68 @@
|
|||||||
-- stays unsupported; anything a mod legitimately needs belongs here.
|
-- stays unsupported; anything a mod legitimately needs belongs here.
|
||||||
|
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
|
local Assets = require("src.render.Assets")
|
||||||
local MapLoader = require("src.world.MapLoader")
|
local MapLoader = require("src.world.MapLoader")
|
||||||
|
local Party = require("src.pokemon.Party")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
local WorldAPI = {}
|
local WorldAPI = {}
|
||||||
WorldAPI.__index = WorldAPI
|
WorldAPI.__index = WorldAPI
|
||||||
|
|
||||||
local NO_OVERWORLD = "no overworld"
|
local NO_OVERWORLD = "no overworld"
|
||||||
|
local overviewShades = {}
|
||||||
|
|
||||||
|
Assets.register(function() overviewShades = {} end)
|
||||||
|
|
||||||
|
local function shadeDigit(sum, pixelCount)
|
||||||
|
return tostring(math.max(0, math.min(3,
|
||||||
|
math.floor((1 - sum / pixelCount) * 3 + 0.5))))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mapTileRows(map)
|
||||||
|
local tileset = map.tileset
|
||||||
|
if not (tileset and tileset.image and tileset.tilesPerRow) then return nil end
|
||||||
|
local cached = overviewShades[tileset.image]
|
||||||
|
if not cached then
|
||||||
|
local ok, pixels = pcall(Assets.imageData, tileset.image)
|
||||||
|
if not ok then return nil end
|
||||||
|
cached = { pixels = pixels, shades = {} }
|
||||||
|
overviewShades[tileset.image] = cached
|
||||||
|
end
|
||||||
|
local rows, detailRows, perRow = {}, {}, tileset.tilesPerRow
|
||||||
|
for ty = 0, map.heightCells * 2 - 1 do
|
||||||
|
local row, detailTop, detailBottom = {}, {}, {}
|
||||||
|
for tx = 0, map.widthCells * 2 - 1 do
|
||||||
|
local tile = map:tileAt(tx, ty)
|
||||||
|
local shades = cached.shades[tile]
|
||||||
|
if shades == nil then
|
||||||
|
local sums = { 0, 0, 0, 0 }
|
||||||
|
local ox, oy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
|
||||||
|
for py = 0, 7 do
|
||||||
|
for px = 0, 7 do
|
||||||
|
local r, g, b = cached.pixels:getPixel(ox + px, oy + py)
|
||||||
|
local quadrant = math.floor(py / 4) * 2 + math.floor(px / 4) + 1
|
||||||
|
sums[quadrant] = sums[quadrant]
|
||||||
|
+ r * 0.2126 + g * 0.7152 + b * 0.0722
|
||||||
|
end
|
||||||
|
end
|
||||||
|
shades = {
|
||||||
|
shadeDigit(sums[1] + sums[2] + sums[3] + sums[4], 64),
|
||||||
|
shadeDigit(sums[1], 16), shadeDigit(sums[2], 16),
|
||||||
|
shadeDigit(sums[3], 16), shadeDigit(sums[4], 16),
|
||||||
|
}
|
||||||
|
cached.shades[tile] = shades
|
||||||
|
end
|
||||||
|
row[#row + 1] = shades[1]
|
||||||
|
detailTop[#detailTop + 1] = shades[2] .. shades[3]
|
||||||
|
detailBottom[#detailBottom + 1] = shades[4] .. shades[5]
|
||||||
|
end
|
||||||
|
rows[#rows + 1] = table.concat(row)
|
||||||
|
detailRows[#detailRows + 1] = table.concat(detailTop)
|
||||||
|
detailRows[#detailRows + 1] = table.concat(detailBottom)
|
||||||
|
end
|
||||||
|
return rows, detailRows
|
||||||
|
end
|
||||||
|
|
||||||
function WorldAPI.new(game, modId)
|
function WorldAPI.new(game, modId)
|
||||||
return setmetatable({ game = game, modId = modId }, WorldAPI)
|
return setmetatable({ game = game, modId = modId }, WorldAPI)
|
||||||
@@ -43,6 +98,53 @@ function WorldAPI:current()
|
|||||||
facing = p and p.facing }
|
facing = p and p.facing }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- A compact, read-only view of the active map for minimaps and companion UIs.
|
||||||
|
-- `rows` describes collision terrain; optional `tileRows` reduces each real
|
||||||
|
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
|
||||||
|
-- `tileDetailRows` preserves one shade per 4x4 quadrant. Markers identify
|
||||||
|
-- exits and item spots that are still active without exposing world internals.
|
||||||
|
function WorldAPI:mapOverview()
|
||||||
|
local ow = self:overworld()
|
||||||
|
if not ow or not ow.map then return nil, NO_OVERWORLD end
|
||||||
|
local map, rows, markers = ow.map, {}, {}
|
||||||
|
for y = 0, map.heightCells - 1 do
|
||||||
|
local row = {}
|
||||||
|
for x = 0, map.widthCells - 1 do
|
||||||
|
row[#row + 1] = map:isWarpTileCell(x, y) and "+"
|
||||||
|
or map:isWaterCell(x, y) and "~"
|
||||||
|
or map:isWalkableCell(x, y) and "." or " "
|
||||||
|
end
|
||||||
|
rows[#rows + 1] = table.concat(row)
|
||||||
|
end
|
||||||
|
local def = map.def or {}
|
||||||
|
for _, warp in ipairs(def.warps or {}) do
|
||||||
|
markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y }
|
||||||
|
end
|
||||||
|
local game, save = self.game, self.game.save or {}
|
||||||
|
for _, obj in ipairs(def.objects or {}) do
|
||||||
|
if obj.item and obj.item ~= "0" and obj.item ~= 0
|
||||||
|
and ow.objectVisible(save, map.id, obj) then
|
||||||
|
markers[#markers + 1] = { kind = "item", x = obj.x, y = obj.y }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local hidden = game.data and game.data.field and game.data.field.hiddenItems
|
||||||
|
for _, item in ipairs(hidden and hidden[map.id] or {}) do
|
||||||
|
local key = map.id .. "_" .. item.x .. "_" .. item.y
|
||||||
|
if not (save.hiddenTaken and save.hiddenTaken[key]) then
|
||||||
|
markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local tileRows, tileDetailRows = mapTileRows(map)
|
||||||
|
return { mapId = map.id, width = map.widthCells,
|
||||||
|
height = map.heightCells, rows = rows, markers = markers,
|
||||||
|
tileRows = tileRows,
|
||||||
|
tileWidth = tileRows and map.widthCells * 2,
|
||||||
|
tileHeight = tileRows and map.heightCells * 2,
|
||||||
|
tileDetailRows = tileDetailRows,
|
||||||
|
tileDetailWidth = tileDetailRows and map.widthCells * 4,
|
||||||
|
tileDetailHeight = tileDetailRows and map.heightCells * 4 }
|
||||||
|
end
|
||||||
|
|
||||||
-- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else
|
-- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else
|
||||||
-- lands the player without one, like a scripted warp.
|
-- lands the player without one, like a scripted warp.
|
||||||
function WorldAPI:warpTo(mapId, x, y, facing, opts)
|
function WorldAPI:warpTo(mapId, x, y, facing, opts)
|
||||||
@@ -164,6 +266,49 @@ function WorldAPI:queueScript(rows, extra)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The supported way to start a wild encounter. Hand-rolling this -- build a
|
||||||
|
-- BattleState, push it -- silently costs evolutions and blackout-on-loss
|
||||||
|
-- (both hang off onFinish -> afterBattle) plus the entry wipe and battle
|
||||||
|
-- theme (both owned by pushBattle). Nothing raises when they are missing.
|
||||||
|
function WorldAPI:startWildBattle(species, level)
|
||||||
|
local ow = self:overworld()
|
||||||
|
if not ow then return nil, NO_OVERWORLD end
|
||||||
|
if not self.game.data.pokemon[species] then
|
||||||
|
return nil, "unknown species: " .. tostring(species)
|
||||||
|
end
|
||||||
|
-- Pokemon.new writes the level through verbatim -- into level, the stat
|
||||||
|
-- calc and the exp curve -- so a fraction has to be refused here rather
|
||||||
|
-- than round somewhere downstream. The % test also catches NaN, which
|
||||||
|
-- passes both range comparisons.
|
||||||
|
level = tonumber(level)
|
||||||
|
if not level or level % 1 ~= 0 or level < 1 or level > 100 then
|
||||||
|
return nil, "level must be a whole number 1..100"
|
||||||
|
end
|
||||||
|
-- overworld() resolves the world from UNDER whatever sits on top of it,
|
||||||
|
-- so from a battle hook this would otherwise stack a second battle over
|
||||||
|
-- the live one -- and on a loss its afterBattle blacks out and warps
|
||||||
|
-- with the outer battle still on the stack.
|
||||||
|
local BattleTransition = require("src.render.BattleTransition")
|
||||||
|
for _, state in ipairs(self.game.stack and self.game.stack.states or {}) do
|
||||||
|
if state.awardExp or getmetatable(state) == BattleTransition then
|
||||||
|
return nil, "a battle is already running"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if ow.transitioning then return nil, "the world is mid-warp" end
|
||||||
|
-- BattleState.newWild marks the species SEEN before it reports an empty
|
||||||
|
-- party, so the party check comes first: a refused call must not leave a
|
||||||
|
-- Pokedex entry behind.
|
||||||
|
local save = self.game.save
|
||||||
|
if not (save and Party.firstHealthy(save.party or {})) then
|
||||||
|
return nil, "no healthy party"
|
||||||
|
end
|
||||||
|
local battle = require("src.battle.BattleState")
|
||||||
|
.newWild(self.game, species, level)
|
||||||
|
battle.onFinish = function(result) ow:afterBattle(result, battle) end
|
||||||
|
ow:pushBattle(battle)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
-- drop a map's cached instance so the next load re-reads its record; when
|
-- drop a map's cached instance so the next load re-reads its record; when
|
||||||
-- it is the active map the world reloads around the player in place
|
-- it is the active map the world reloads around the player in place
|
||||||
function WorldAPI:invalidateMap(mapId)
|
function WorldAPI:invalidateMap(mapId)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ROM-free regression tests for source-build version detection and routing."""
|
||||||
|
|
||||||
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest import TestCase, main, mock
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
|
||||||
|
import build_rom_data # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class BuildRomDataCliTest(TestCase):
|
||||||
|
def run_builder(self, sha1, *extra):
|
||||||
|
manifest = {"romSha1": sha1, "symbols": {}}
|
||||||
|
rom = SimpleNamespace(sha1=sha1)
|
||||||
|
with mock.patch.object(build_rom_data, "RomImage", return_value=rom), \
|
||||||
|
mock.patch.object(build_rom_data, "load_manifest", return_value=manifest), \
|
||||||
|
mock.patch.object(build_rom_data, "build") as build, \
|
||||||
|
mock.patch.object(build_rom_data.os, "makedirs"), \
|
||||||
|
redirect_stdout(StringIO()), redirect_stderr(StringIO()):
|
||||||
|
result = build_rom_data.main([
|
||||||
|
"--rom", "fixture.gb", "--only", "constants", *extra])
|
||||||
|
return result, build
|
||||||
|
|
||||||
|
def test_blue_rom_selects_blue_manifest_and_cache_paths(self):
|
||||||
|
result, build = self.run_builder(
|
||||||
|
build_rom_data.CANONICAL_BLUE_SHA1)
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
args = build.call_args.args
|
||||||
|
self.assertEqual(args[3], "blue/data/generated")
|
||||||
|
self.assertEqual(args[4], "blue/assets/generated")
|
||||||
|
|
||||||
|
def test_red_rom_keeps_historical_root_paths(self):
|
||||||
|
result, build = self.run_builder(build_rom_data.CANONICAL_RED_SHA1)
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
args = build.call_args.args
|
||||||
|
self.assertEqual(args[3], "data/generated")
|
||||||
|
self.assertEqual(args[4], "assets/generated")
|
||||||
|
|
||||||
|
def test_explicit_output_paths_are_preserved(self):
|
||||||
|
result, build = self.run_builder(
|
||||||
|
build_rom_data.CANONICAL_YELLOW_SHA1,
|
||||||
|
"--out", "/tmp/custom-data", "--assets", "/tmp/custom-assets")
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
args = build.call_args.args
|
||||||
|
self.assertEqual(args[3], "/tmp/custom-data")
|
||||||
|
self.assertEqual(args[4], "/tmp/custom-assets")
|
||||||
|
|
||||||
|
def test_unknown_rom_is_rejected_before_build(self):
|
||||||
|
unknown = "0" * 40
|
||||||
|
result, build = self.run_builder(unknown)
|
||||||
|
|
||||||
|
self.assertEqual(result, 1)
|
||||||
|
build.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -248,7 +248,7 @@ return function(game)
|
|||||||
|
|
||||||
-- ---- case 2: B-cancel settles on the OLD form's colours -----------------
|
-- ---- case 2: B-cancel settles on the OLD form's colours -----------------
|
||||||
local karp = startEvo("MAGIKARP", "GYARADOS")
|
local karp = startEvo("MAGIKARP", "GYARADOS")
|
||||||
U.wait(24)
|
U.wait(100) -- past the 80-frame pre-animLoop delay before the poll starts
|
||||||
U.hold(game, "b", 20) -- evolution.asm Evolution_CheckForCancel
|
U.hold(game, "b", 20) -- evolution.asm Evolution_CheckForCancel
|
||||||
if not waitFor(function() return findText("stopped evolving") ~= nil end, 240) then
|
if not waitFor(function() return findText("stopped evolving") ~= nil end, 240) then
|
||||||
check(false, "holding B prints \"stopped evolving\"")
|
check(false, "holding B prints \"stopped evolving\"")
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
-- Driver: cancel an evolution with the B button (#213).
|
-- Driver: cancel an evolution with the B button (#213).
|
||||||
--
|
--
|
||||||
-- pokered engine/pokemon/evos_moves.asm polls hJoyHeld during the pic
|
-- pokered engine/movie/evolution.asm polls hJoy5 during the pic flash: a
|
||||||
-- flash: holding B aborts the evolution (the mon keeps its species and
|
-- fresh B press aborts the evolution (the mon keeps its species and
|
||||||
-- _StoppedEvolvingText prints). Trade evolutions (wLinkState ==
|
-- _StoppedEvolvingText prints). Trade evolutions (wLinkState ==
|
||||||
-- LINK_STATE_TRADING) skip that poll and cannot be cancelled.
|
-- LINK_STATE_TRADING) skip that poll and cannot be cancelled.
|
||||||
--
|
--
|
||||||
-- Case 1 (level path, cancelable): open EvolutionState directly, wait a
|
-- Case 1 (level path, cancelable): open EvolutionState directly, wait past
|
||||||
-- few frames into the flash (t well under FLASH_FRAMES=220), hold B, and
|
-- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=220),
|
||||||
-- assert the mon stays CATERPIE with "stopped evolving" text on screen.
|
-- press B, and assert the mon stays CATERPIE with the "stopped evolving"
|
||||||
|
-- text on screen.
|
||||||
-- Case 1b: after cancel, checkParty with no level-ups must not re-offer;
|
-- Case 1b: after cancel, checkParty with no level-ups must not re-offer;
|
||||||
-- a subsequent level-up set must offer again (EvolveAfterBattle parity).
|
-- a subsequent level-up set must offer again (EvolveAfterBattle parity).
|
||||||
-- Case 2 (control): let the flash run to completion with no input and
|
-- Case 2 (control): let the flash run to completion with no input and
|
||||||
@@ -80,11 +81,11 @@ return function(game)
|
|||||||
Evolution.evolve(game, mon, "METAPOD", function() done1 = true end)
|
Evolution.evolve(game, mon, "METAPOD", function() done1 = true end)
|
||||||
|
|
||||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
|
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
|
||||||
U.wait(20) -- into the flash, well under FLASH_FRAMES=220
|
U.wait(100) -- past the 80-frame pre-animLoop delay, still under 220
|
||||||
U.log("case1 flash", "t=", top().t, "species=", mon.species)
|
U.log("case1 flash", "t=", top().t, "species=", mon.species)
|
||||||
U.shot(game, DIR .. "/evo213_1_evolving.png")
|
U.shot(game, DIR .. "/evo213_1_evolving.png")
|
||||||
|
|
||||||
U.hold(game, "b", 20) -- Gen1 hJoyHeld B-cancel
|
U.hold(game, "b", 20) -- Gen1 hJoy5 B-cancel
|
||||||
|
|
||||||
-- the flash aborts: EvolutionState is no longer the top (the stopped
|
-- the flash aborts: EvolutionState is no longer the top (the stopped
|
||||||
-- text overlays it and then pops it)
|
-- text overlays it and then pops it)
|
||||||
@@ -118,7 +119,7 @@ return function(game)
|
|||||||
if not waitFor(evoTop, 300) then
|
if not waitFor(evoTop, 300) then
|
||||||
error("EvolutionState never opened after level-up re-offer")
|
error("EvolutionState never opened after level-up re-offer")
|
||||||
end
|
end
|
||||||
U.wait(20)
|
U.wait(100) -- past the same 80-frame pre-animLoop delay
|
||||||
U.hold(game, "b", 20) -- cancel so case 2 stays independent
|
U.hold(game, "b", 20) -- cancel so case 2 stays independent
|
||||||
if not waitFor(function() return not evoTop() end, 240) then
|
if not waitFor(function() return not evoTop() end, 240) then
|
||||||
error("level-up re-offer did not abort on B")
|
error("level-up re-offer did not abort on B")
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- Driver (#889): export a .sav from a save that never came from a ROM import,
|
||||||
|
-- the case that used to write a save with no map context at all -- no map
|
||||||
|
-- header, no tileset header, sound id 0 -- so a real Game Boy continued into a
|
||||||
|
-- garbled map and hung on a white screen.
|
||||||
|
--
|
||||||
|
-- Saves the game in two places (an interior and an outdoor map with
|
||||||
|
-- connections), exports each, and prints the bytes the engine reads back on
|
||||||
|
-- Continue so a human can eyeball them, plus the export path to load in an
|
||||||
|
-- emulator.
|
||||||
|
return function(game)
|
||||||
|
local U = dofile("tests/drivers/util.lua")
|
||||||
|
local SaveFileIO = require("src.import.SaveFileIO")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
|
||||||
|
local version = GameVersion.get()
|
||||||
|
local spots = {
|
||||||
|
{ "REDS_HOUSE_2F", 3, 6 },
|
||||||
|
{ "PALLET_TOWN", 5, 6 },
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, spot in ipairs(spots) do
|
||||||
|
local map, x, y = spot[1], spot[2], spot[3]
|
||||||
|
U.teleport(game, map, x, y, "down")
|
||||||
|
U.wait(30)
|
||||||
|
-- the same sync the in-game SAVE does before writing (OverworldState:
|
||||||
|
-- captureSave), so the slot on disk holds the position we just walked to
|
||||||
|
local top = game.stack:top()
|
||||||
|
if top and top.captureSave then top:captureSave(game.save) end
|
||||||
|
SaveData.save(game.save)
|
||||||
|
local ok, pathOrErr = SaveFileIO.exportActiveSlot(version)
|
||||||
|
if not ok then
|
||||||
|
U.log(("export_sav_bug889: %s FAILED: %s"):format(map, tostring(pathOrErr)))
|
||||||
|
else
|
||||||
|
local bytes = love.filesystem.read(
|
||||||
|
("exports/%s/gen1recomp-%s-%s.sav"):format(
|
||||||
|
version, version, SaveData.activeSlot(version) or "save"))
|
||||||
|
local main = 0x2598 + 11
|
||||||
|
local function u8(off) return bytes:byte(main + off + 1) end
|
||||||
|
local hdr = {}
|
||||||
|
for i = 0, 9 do hdr[#hdr + 1] = ("%02X"):format(u8(112 + i)) end
|
||||||
|
U.log(("export_sav_bug889: %s -> %s"):format(map, pathOrErr))
|
||||||
|
U.log((" wCurMap=%02X wCurMapHeader=%s music=%02X/%02X tilesetBank=%02X"):
|
||||||
|
format(u8(103), table.concat(hdr, " "), u8(100), u8(101), u8(564)))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
U.log("export_sav_bug889: done")
|
||||||
|
love.event.quit()
|
||||||
|
while true do coroutine.yield() end
|
||||||
|
end
|
||||||
@@ -195,7 +195,7 @@ return function(game)
|
|||||||
ow:queueScript(slice, { npc = rival })
|
ow:queueScript(slice, { npc = rival })
|
||||||
|
|
||||||
local startY = ow.player.cellY
|
local startY = ow.player.cellY
|
||||||
local minY, walkShot = startY, false
|
local minY, walkShot, sharedCell = startY, false, false
|
||||||
local hof
|
local hof
|
||||||
for i = 1, 5000 do
|
for i = 1, 5000 do
|
||||||
local top = game.stack:top()
|
local top = game.stack:top()
|
||||||
@@ -205,8 +205,9 @@ return function(game)
|
|||||||
end
|
end
|
||||||
local w = game.overworld
|
local w = game.overworld
|
||||||
if w and w.map and w.map.id == "CHAMPIONS_ROOM" then
|
if w and w.map and w.map.id == "CHAMPIONS_ROOM" then
|
||||||
local y = w.player.cellY
|
local x, y = w.player.cellX, w.player.cellY
|
||||||
if y < minY then minY = y end
|
if y < minY then minY = y end
|
||||||
|
if x == rival.cellX and y == rival.cellY then sharedCell = true end
|
||||||
if y <= 2 and not walkShot then
|
if y <= 2 and not walkShot then
|
||||||
walkShot = U.shot(game, DIR .. "/hof704_follows_oak.png")
|
walkShot = U.shot(game, DIR .. "/hof704_follows_oak.png")
|
||||||
end
|
end
|
||||||
@@ -215,6 +216,7 @@ return function(game)
|
|||||||
end
|
end
|
||||||
check("the player walked out of the room before the warp (#704)",
|
check("the player walked out of the room before the warp (#704)",
|
||||||
minY < startY)
|
minY < startY)
|
||||||
|
check("the player routed around the rival", not sharedCell)
|
||||||
check("walk-out screenshot", walkShot)
|
check("walk-out screenshot", walkShot)
|
||||||
check("the induction started", hof ~= nil)
|
check("the induction started", hof ~= nil)
|
||||||
if not hof then
|
if not hof then
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user