The new experience (#201)

* new launcher and save converts and pipeline

* fixing bugs
This commit is contained in:
bryanthaboi
2026-07-25 12:36:53 -04:00
committed by GitHub
parent 6625391f76
commit 3069b2e2a9
135 changed files with 16596 additions and 1508 deletions
+16 -1
View File
@@ -214,8 +214,21 @@ jobs:
apk="$(find dist/android/debug -name '*.apk' | head -1)"
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
# Platform-independent update payload, built alongside the desktop
# apps above (same game.love that gets fused into each of them).
love_file=".bazinga/work/game.love"
[ -f "$love_file" ] || { echo "::error::$love_file not found (expected from scripts/build.sh)"; exit 1; }
cp "$love_file" "$outdir/gen1recomp-${v}.love"
ls -lh "$outdir"
# Checksums for every staged release asset (sums file itself is
# written after this and named outside the gen1recomp-* glob, so it
# never lists itself).
(cd "$outdir" && shasum -a 256 gen1recomp-* > sha256sums.txt)
cat "$outdir/sha256sums.txt"
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
@@ -266,7 +279,9 @@ jobs:
"dist/release/gen1recomp-${v}-macos.zip" \
"dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk"
"dist/release/gen1recomp-${v}-android.apk" \
"dist/release/gen1recomp-${v}.love" \
"dist/release/sha256sums.txt"
echo "Published release $tag"
+2 -3
View File
@@ -1,9 +1,8 @@
-- Flavor talk scripts for CopycatsHouse2F (registry id COPYCATS_HOUSE_2F).
-- Source: pokered/scripts/CopycatsHouse2F.asm, pokered/text/CopycatsHouse2F.asm
--
-- TEXT_COPYCATSHOUSE2F_COPYCAT is already ported (with the POKE DOLL /
-- TM31 MIMIC trade) in data/scripts/story4.lua, so it is intentionally
-- omitted here.
-- TEXT_COPYCATSHOUSE2F_COPYCAT (POKE DOLL -> TM31 MIMIC) lives in
-- data/scripts/story4.lua.
return {
COPYCATS_HOUSE_2F = {
+90 -45
View File
@@ -59,52 +59,97 @@ end
return {
talk = {
-- Oak: accepts the parcel and hands over the Pokédex, then (once the
-- player has beaten the Route 22 rival) hands over the real POKé
-- BALLs (scripts/OaksLab.asm OaksLabOak1Text, simplified: no rival
-- recall / dex-rating / "pokemon can fight" / "around the world"
-- branches -- see docs/known-differences.md)
-- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_
-- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk-
-- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating
-- (DisplayDexRating) is still skipped.
TEXT_OAKSLAB_OAK1 = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
{ "jump_if_false", 16 }, -- 3
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
{ "jump_if_true", 16 }, -- 5
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
-- OaksLab.asm OakGivesPokedex: HideObject TOGGLE_POKEDEX_1/2
-- so the table sprites leave with the gift (#106).
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, -- 11
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, -- 12
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
-- Until this ran, the walking man at (17,5) -- who owns the coffee
-- ask and the catch tutorial -- stayed OFF for the whole game
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 14
{ "jump", "end" }, -- 15
{ "check_flag", "EVENT_GOT_STARTER" }, -- 16
{ "jump_if_false", 31 }, -- 17
{ "check_item", "POKE_BALL" }, -- 18
{ "jump_if_true", 29 }, -- 19
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 20
{ "jump_if_false", 33 }, -- 21
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
{ "jump_if_true", 29 }, -- 23
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 24
{ "give_item", "POKE_BALL", 5, false }, -- 25
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 26
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 27
{ "jump", "end" }, -- 28
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 29
{ "jump", "end" }, -- 30
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 31
{ "jump", "end" }, -- 32
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 33
{ "face_player" },
{ "check_item", "POKE_BALL" },
{ "jump_if_true", "come_see" },
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
{ "jump_if_true", "give_balls" },
{ "check_flag", "EVENT_GOT_POKEDEX" },
{ "jump_if_true", "around_world" },
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
{ "jump_if_false", "pre_lab_battle" },
{ "check_item", "OAKS_PARCEL" },
{ "jump_if_false", "raise_young" },
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
{ "show_text", "_OaksLabOak1DeliverParcelText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ParcelThanksText" },
{ "take_item", "OAKS_PARCEL", 1 },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalGrampsText" },
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
-- OaksLabCalcRivalMovementScript: sprite map (8,11) → cell (4,7)
-- when the player stands below Oak (the usual desk talk).
{ "place_npc", 1, 4, 7, "up" },
{ "move_npc_to", 1, 4, 3 },
{ "play_music", "Music_OaksLab" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabRivalWhatDidYouCallMeForText" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabOakIHaveARequestText" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
{ "show_text", "_OaksLabOakGotPokedexText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabOakThatWasMyDreamText" },
{ "face_object", 1, "right" },
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" },
{ "set_flag", "EVENT_GOT_POKEDEX" },
{ "set_flag", "EVENT_OAK_GOT_PARCEL" },
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" },
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "move_npc_to", 1, 4, 7 },
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
{ "play_music", "Music_OaksLab" },
{ "set_flag", "EVENT_1ST_ROUTE22_RIVAL_BATTLE" },
{ "clear_flag", "EVENT_2ND_ROUTE22_RIVAL_BATTLE" },
{ "set_flag", "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" },
{ "show_object", "ROUTE_22", "ROUTE22_RIVAL1" },
{ "jump", "end" },
{ "label", "raise_young" },
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" },
{ "jump", "end" },
{ "label", "pre_lab_battle" },
{ "check_flag", "EVENT_GOT_STARTER" },
{ "jump_if_true", "can_fight" },
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" },
{ "jump", "end" },
{ "label", "can_fight" },
{ "show_text", "_OaksLabOak1YourPokemonCanFightText" },
{ "jump", "end" },
{ "label", "around_world" },
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" },
{ "jump", "end" },
{ "label", "give_balls" },
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "jump_if_true", "come_see" },
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "give_item", "POKE_BALL", 5, false },
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
{ "jump", "end" },
{ "label", "come_see" },
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" },
},
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
+16 -9
View File
@@ -878,16 +878,23 @@ M.HALL_OF_FAME = {
M.CERULEAN_CITY = {
talk = {
-- CeruleanCityRivalText: Bill line once beaten; pre-battle otherwise.
-- Post-fight walk matches CeruleanCityMovement4 (right then into town).
TEXT_CERULEANCITY_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2
{ "jump_if_true", 9 }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", 10 }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "jump", 10 }, -- 9
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2
{ "jump_if_true", 13 }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", "end" }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
{ "walk_npc", 1, { "right", "down", "down", "down",
"down", "down", "down" } }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
{ "jump", "end" }, -- 12
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 13
},
},
}
+60 -20
View File
@@ -445,33 +445,67 @@ local function engageSuperNerd(game, ow, onDone)
ow:engageTrainer(nerd, onDone)
end
local function mtMoonFossil(itemId, otherName)
-- MtMoonB2FMoveSuperNerdScript: player-near-dome coords walk RIGHT then
-- UP (MoveRight falls through into MoveUp); near-helix walks UP only.
local function mtMoonNerdWalk(px, py, itemId)
if (px == 12 and py == 7) or (px == 11 and py == 6) or (px == 12 and py == 5) then
return { "right", "up" }
end
if (px == 13 and py == 7) or (px == 14 and py == 6) or (px == 14 and py == 5) then
return { "up" }
end
return (itemId == "DOME_FOSSIL") and { "right", "up" } or { "up" }
end
-- scripts/MtMoonB2F.asm Dome/Helix fossil text_asm + MoveSuperNerd +
-- SuperNerdTakesOtherFossil: pick one, hide it, walk the nerd to the
-- other, "All right. Then this is mine!", hide the other.
local function mtMoonFossil(itemId, otherName, gotFlag)
return function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
if game.save.flags.EVENT_GOT_A_FOSSIL then
game.stack:push(TextBox.new(game, "You already took\na fossil.", done))
local t = game.data.text
local flags = game.save.flags
if flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL then
done()
return
end
-- can't grab a fossil until the Super Nerd is beaten -- he intercepts
if not superNerdBeaten(ow) then
engageSuperNerd(game, ow, done)
return
end
local name = game.data.items[itemId].name
game.stack:push(TextBox.new(game, ("You want the\n%s?"):format(name), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then done() return end
game.save.inventory[itemId] = 1
game.save.flags.EVENT_GOT_A_FOSSIL = true
local Commands = require("src.script.Commands")
local ctx = { save = game.save, overworld = ow, game = game }
Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name)
Commands.hide_object(ctx, "MT_MOON_B2F", otherName)
local want = (itemId == "DOME_FOSSIL")
and (t._MtMoonB2FDomeFossilYouWantText or "You want the\nDOME FOSSIL?")
or (t._MtMoonB2FHelixFossilYouWantText or "You want the\nHELIX FOSSIL?")
game.stack:push(TextBox.new(game, want, nil, { choice = function(yes)
if not yes then done() return end
if not require("src.inventory.Bag").add(game.save, itemId, 1) then
game.stack:push(TextBox.new(game,
("%s got the\n%s!"):format(game.save.player.name, name), done))
end))
end))
t._MtMoonB2FYouHaveNoRoomText or "Look, you've got\nno room for this.",
done))
return
end
local idef = game.data.items[itemId]
game.stringBuffer = idef and idef.name or itemId
require("src.core.Sound").play(game.data, "Get_Key_Item")
local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId)
game.stack:push(TextBox.new(game,
t._MtMoonB2FReceivedFossilText
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
function()
local Commands = require("src.script.Commands")
Commands.hide_object(
{ save = game.save, overworld = ow, game = game },
"MT_MOON_B2F", npc.def.name)
flags[gotFlag] = true
ow.runner:run({
{ "walk_npc", 1, dirs },
{ "text_opts", { auto = true } },
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "MT_MOON_B2F", otherName },
}, { onDone = done })
end))
end }))
end
end
@@ -486,8 +520,10 @@ M.MT_MOON_B2F = {
return false
end,
talk = {
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"),
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"),
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil(
"DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"),
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil(
"HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL", "EVENT_GOT_HELIX_FOSSIL"),
},
}
@@ -822,6 +858,10 @@ M.DAYCARE = {
if def then
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = mon.stats.hp
-- Daycare.asm: WriteMonMoves with wLearningMovesFromDayCare
local Pokemon = require("src.pokemon.Pokemon")
Pokemon.learnMovesFromDayCare(
game.data, mon, def, startLevel, newLevel)
end
table.insert(game.save.party, mon)
game.save.daycare = nil
+18 -17
View File
@@ -212,30 +212,31 @@ M.COPYCATS_HOUSE_2F = {
talk = {
TEXT_COPYCATSHOUSE2F_COPYCAT = function(game, ow, npc, done)
local t = text(game)
local Bag = require("src.inventory.Bag")
if game.save.flags.EVENT_GOT_TM31 then
push(game, t._CopycatsHouse2FCopycatWhirlingText
or "Huh? Huh? Huh?", done)
push(game, t._CopycatsHouse2FCopycatTM31Explanation2Text, done)
return
end
if (game.save.inventory.POKE_DOLL or 0) > 0 then
ask(game, t._CopycatsHouse2FCopycatPokeDollText
or "Oh wow!\nA POKé DOLL!\fFor me?\nCan I have it?", function(yes)
if not yes then done() return end
game.save.inventory.POKE_DOLL = game.save.inventory.POKE_DOLL - 1
if game.save.inventory.POKE_DOLL == 0 then
game.save.inventory.POKE_DOLL = nil
end
if not require("src.inventory.Bag").add(game.save, "TM_MIMIC", 1) then
push(game, "You don't have\nroom for TM31!", done)
-- Mimic line first; auto-trade POKE DOLL for TM31 (no YES/NO).
push(game, t._CopycatsHouse2FCopycatDoYouLikePokemonText, function()
if (game.save.inventory.POKE_DOLL or 0) <= 0 then
done()
return
end
push(game, t._CopycatsHouse2FCopycatTM31PreReceiveText, function()
if not Bag.add(game.save, "TM_MIMIC", 1) then
push(game, t._CopycatsHouse2FCopycatTM31NoRoomText, done)
return
end
game.stringBuffer = game.data.items.TM_MIMIC.name
require("src.core.Sound").play(game.data, "Get_Item1")
Bag.remove(game.save, "POKE_DOLL", 1)
game.save.flags.EVENT_GOT_TM31 = true
push(game, ("%s got\nTM31!"):format(game.save.player.name), done)
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
end)
end)
return
end
push(game, t._CopycatsHouse2FCopycatText
or "I like to mimic\npeople!", done)
end)
end,
},
}
+38 -15
View File
@@ -457,6 +457,19 @@ end
-- EVENT_ROUTE22_RIVAL_WANTS_BATTLE in Oak's lab (expired by Pewter
-- Gym) and again in Viridian Gym; we derive the same windows from the
-- surrounding story flags so old saves work too.
-- Rival1Exit → Viridian (right/down); Rival2Exit → League (left).
local function route22ExitDirs(n, py)
if n == 2 then
if py == 5 then return { "left", "left", "left", "left" } end
return { "left", "left", "left" }
end
if py == 5 then
return { "right", "right", "down", "down", "down", "down", "down" }
end
return { "up", "right", "right", "right",
"down", "down", "down", "down", "down", "down" }
end
local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
return {
{ "show_object", "ROUTE_22", objName }, -- 1
@@ -468,7 +481,7 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
{ "set_flag", beatFlag }, -- 7
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
{ "move_npc_to", objIndex, 25, 5 }, -- 10
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10
{ "hide_object", "ROUTE_22", objName }, -- 11
}
end
@@ -495,6 +508,14 @@ M.ROUTE_22 = {
-- Cerulean City: the Nugget Bridge rival ambush (CeruleanCityCoords2),
-- the TM28 rocket thief with his forced-fight cells (Coords1), and the
-- cave guard who steps aside once you are Champion.
-- Post-battle: Bill text then CeruleanCityMovement3/4 into town.
local function ceruleanRivalExitDirs(px)
if px == 20 then
return { "right", "down", "down", "down", "down", "down", "down" }
end
return { "left", "down", "down", "down", "down", "down", "down" }
end
local function ceruleanRivalScene(px, py)
return {
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 1
@@ -502,41 +523,43 @@ local function ceruleanRivalScene(px, py)
{ "face_object", 1, "down" }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", 10 }, -- 6
{ "jump_if_false", 11 }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "move_npc_to", 1, 20, 2 }, -- 9
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 10
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
}
end
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief,
-- then he returns TM28 (DIG) and hurries off
-- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket
-- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 /
-- Hide ROCKET → GBFadeInFromBlack — not a bare hide_object.
local rocketRows = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
{ "jump_if_true", 15 }, -- 3
{ "jump_if_true", 15 }, -- 3 → CeruleanHideRocket
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
{ "jump_if_true", 9 }, -- 5
{ "show_text", "_CeruleanCityRocketText" }, -- 6
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
{ "jump_if_false", 18 }, -- 8
{ "jump_if_false", "end" }, -- 8
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
{ "set_flag", "EVENT_GOT_TM28" }, -- 12
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15
-- CeruleanHideRocket (scripts/CeruleanCity_2.asm) does all three behind
-- one fade: the ROCKET goes, GUARD1 (28,12) appears and GUARD2 (27,12)
-- disappears. GUARD2 stands on the only walkable neighbour of the
-- trashed house's south door, which is one of the two ways through the
-- fence dividing Cerulean, so this swap is what reconnects the city.
-- Bill's ticket performs the same swap (data/scripts/story.lua), and
-- both are idempotent, so whichever the player reaches first opens it.
{ "fade", "out" }, -- 15 GBFadeOutToBlack
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
-- door neighbour — the swap reconnects the city (Bill's ticket does
-- the same in story.lua; either route is enough).
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
{ "fade", "in" }, -- 19 GBFadeInFromBlack
}
M.CERULEAN_CITY = {
+61 -7
View File
@@ -12,6 +12,12 @@
-- "; deactivate gym trainers" / SetEventRange in the other gyms, and
-- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER ..
-- EVENT_BEAT_FIGHTING_DOJO_TRAINER_3).
--
-- `dialogue` is the end-battle + post-battle text chain each gym leader
-- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM
-- script). Leaders are not def_trainers entries, so engageTrainer has
-- no header.won — checkVictoryRewards shows this chain instead of a
-- synthetic "received badge/TM" stub.
local function range(prefix, first, last)
local t = {}
@@ -31,28 +37,76 @@ return {
hide = {
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
{ "ROUTE_22", "ROUTE22_RIVAL1" },
},
dialogue = {
"_PewterGymBrockReceivedBoulderBadgeText",
"_PewterGymBrockBoulderBadgeInfoText",
"_PewterGymBrockWaitTakeThisText",
"_PewterGymReceivedTM34Text",
"_TM34ExplanationText",
} },
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
item = "TM_BUBBLEBEAM",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
dialogue = {
"_CeruleanGymMistyReceivedCascadeBadgeText",
"_CeruleanGymMistyCascadeBadgeInfoText",
"_CeruleanGymMistyReceivedTM11Text",
} },
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
item = "TM_THUNDERBOLT",
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2) },
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
dialogue = {
"_VermilionGymLTSurgeReceivedThunderBadgeText",
"_VermilionGymLTSurgeThunderBadgeInfoText",
"_VermilionGymLTSurgeReceivedTM24Text",
"_TM24ExplanationText",
} },
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
item = "TM_MEGA_DRAIN",
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6) },
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
dialogue = {
"_CeladonGymErikaReceivedRainbowBadgeText",
"_CeladonGymRainbowBadgeInfoText",
"_CeladonGymReceivedTM21Text",
"_TM21ExplanationText",
} },
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
item = "TM_TOXIC",
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5) },
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
dialogue = {
"_FuchsiaGymKogaReceivedSoulBadgeText",
"_FuchsiaGymKogaSoulBadgeInfoText",
"_FuchsiaGymKogaReceivedTM06Text",
"_FuchsiaGymKogaTM06ExplanationText",
} },
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
item = "TM_PSYWAVE",
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6) },
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
dialogue = {
"_SaffronGymSabrinaReceivedMarshBadgeText",
"_SaffronGymSabrinaMarshBadgeInfoText",
"_SaffronGymSabrinaReceivedTM46Text",
"_TM46ExplanationText",
} },
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
item = "TM_FIRE_BLAST",
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6) },
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
dialogue = {
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
"_CinnabarGymBlaineVolcanoBadgeInfoText",
"_CinnabarGymBlaineReceivedTM38Text",
"_CinnabarGymBlaineTM38ExplanationText",
} },
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
item = "TM_FISSURE",
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7) },
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
dialogue = {
"_ViridianGymGiovanniReceivedEarthBadgeText",
"_ViridianGymGiovanniEarthBadgeInfoText",
"_ViridianGymGiovanniReceivedTM27Text",
"_ViridianGymGiovanniTM27ExplanationText",
} },
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
+163
View File
@@ -0,0 +1,163 @@
# Launcher
The launcher is `src/import/RomImporter.lua`, the first-run / title screen
that runs before `Game:load`. Besides ROM import (see the file's own header)
it hosts a tabbed shell covering per-game save slots and a mod manager. This
file documents the runtime model; the visual spec lives separately.
## Android multi-ROM import
On Android, `love.system.pickFile()` opens the Storage Access Framework
picker (`GameActivity.showRomFilePicker`); the chosen file is copied to
`picked_rom.gb` in the app save directory. `RomImporter` then imports on
refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps
to a version that is **not** yet ready counts as pending. A leftover
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
After a successful import the consumed save-dir `.gb` is removed.
**Manual check (device/emulator):** import Red → switch to Blue → Choose →
system file picker must appear (not a silent Red re-extract) → pick Blue →
Blue becomes ready beside Red.
## Tab structure
`self.tab` is one of `"red"`, `"blue"`, `"yellow"`, `"mods"`. The tab bar
draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every
frame so `mousepressed` can dispatch clicks; switching tabs mid-import is
allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows).
- A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the
Play button, and the SAVE SLOT card in a responsive two-column grid (see
Responsiveness). The MODS tab (`_drawModsPanel`) shows the mod list instead.
- The self-updater banner (`self.Check`, see `docs/updater.md`) draws as a
centered pill in a reserved band just above the footer, on every tab. That
position is unchanged by this redesign, so `docs/updater.md` needed no edits.
## Save slot model
All slot I/O lives in `src/core/SaveData.lua` and goes through the same fs
abstraction (`persistFs`) every other save/options call uses, so portable
mode (an `io.*` filesystem used when `portable.txt` marks the install)
keeps working unchanged.
- **Files.** A version's playthroughs live under `saves/<version>/`, one file
per slot: `saves/<version>/slot1.lua` plus a rolling `.bak` and staged
`.tmp` witness (`slotNames`), mirroring the write/recovery discipline
`SaveData.save`/`load` already use for the flat legacy file. Slot ids match
`slot%d+`; `createSlot` allocates one past the highest existing number so a
reused id can never collide with a lingering file.
- **Registry.** The ordered slot list and which one is active persist in
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
`options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`.
- **Active slot resolution.** `saveNames(version)`, the function every
existing caller (`TitleState` hasSave/load/save, recovery order) already
goes through, now resolves the *active* slot instead of a fixed flat name.
Resolved once per version per process (`ensureVersionSlots`, cached in
`activeSlotCache`/`slotsChecked`): a registry entry wins; otherwise a lazy
legacy migration may create one; otherwise the flat legacy path is used
(`save.lua` / `save_blue.lua`), so a pre-slots install keeps working as before.
- **Legacy migration.** One-time per version, lazy on first
`listSlots`/`load`/`saveNames` call (`tryMigrateLegacy`): if a flat legacy
file exists and no `saves/<version>/` registry does, its main + `.bak` are
copied into `saves/<version>/slot1.lua(.bak)`, verified readable
(`decodeSlot`: main, then `.tmp`, then `.bak`), and only then are the
originals removed and `slot1` registered as active. A copy that fails to
verify leaves the originals in place; migration never loses data.
The launcher-facing API:
- `SaveData.listSlots(version)` -> array of `{id, exists, name, meta}` for
every registered slot. `name` is the save's player name, or `nil` for an
empty slot; `meta` is `{badges, timeText, dexCount}` (the same fields the
title screen's `ContinueInfo` shows) or `nil`. The pure part,
`SaveData.slotSummary(save)`, is unit-testable with no filesystem.
- `SaveData.setActiveSlot(version, slotId)` registers the id if new, persists
it as active, and updates the process cache so the very next save/load
lands there. The launcher calls this the moment a slot row is clicked
(`RomImporter:_selectSlot`); pressing Play needs no signature change, since
`Game.lua`/`main.lua` still just call `SaveData.load()`/`save()`.
- `SaveData.createSlot(version)` -> new slot id, registered but with **no
save file written**. An empty slot means the title screen offers NEW GAME
only, which needs no further changes.
## Launcher mod manager
`src/mods/LauncherMods.lua` is a launcher-only read of the mod set. It runs
before `Game:load`, so **it never loads a mod's entry chunk**; only
`manifest.json` is read and validated (`src/mods/Manifest.validate`), the way
`Loader:_discover` finds mods without running them. The real loader
(`src/mods/Loader.lua`) still owns the actual load at boot.
- `LauncherMods.list()` scans `mods/` one level deep (first id wins on a
duplicate) and returns one row per mod:
`{id, name, version, badge, description, enabled, status, statusDetail}`.
`badge` is the manifest's `category`, falling back to `profile`, then
`"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means
enabled, matching the loader's own default).
- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
and the validated manifests: `conflict` when enabling this mod collides
with another enabled one; `warn` for an out-of-range `game_version` or an
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
`love.*` calls, this half is table-driven by the test suite on its own.
- `LauncherMods.setEnabled(id, bool)` persists `options.mods[id]` as a plain
boolean, the exact shape `Loader:_saveState` writes, so the running game
and the in-game `ManagerState` see the change on next boot. The mods panel
calls this on every toggle and re-derives the list right away
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
shows without waiting for a reload.
- `LauncherMods.installZip(path)` mounts the archive with
`love.filesystem.mount`, locates the mod root via `locateRoot` (manifest at
the zip root, or inside one top-level folder), validates its manifest, and
copies the tree into the save-dir `mods/<id>/` before unmounting. Rejects a
duplicate of an already-installed mod id, and accepts either an external
path string or a LOVE `DroppedFile`, staging a dropped file into a save-dir
temp first (mount only reaches save-dir-relative paths), the same way
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
back, and every path unmounts and clears the staged temp file.
## Import / Export save
The SAVE FILES card wires a raw Gen1 `.sav` battery image to the save slots
through `src/import/SaveFileIO.lua`, which sits on top of
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
- **Import save** is live once the game's ROM is imported (playable). It opens
a native `.sav` picker (`chooseSav`, the per-OS dialogs mirror `chooseZip`;
Android has no picker and shows a drop hint). `SaveFileIO.importToSlot`
reads the bytes (an absolute path, a dropped LOVE file, or raw bytes),
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
The meta stamp is re-stamped off `gen1_import` to the current numeric format
so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT
panel is refreshed with the new slot selected.
- **Export save** is live only when the active slot actually holds a save
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
`rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
(`love.filesystem.createDirectory("exports")`). It returns the absolute path
(`love.filesystem.getSaveDirectory()`), which the notice line shows with a
desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
currently active game tab; when a non-game tab (mods, or the locked yellow
placeholder) is showing it defaults to red, the always-present first game
(`_savedropTarget`). `.gb` (ROM) and `.zip` (mod) routing is unchanged.
- **Failure UX.** Every error path (wrong size, bad checksum, write failure,
nothing to export, ROM not imported yet) surfaces as a red notice line on the
card. Nothing raises and nothing silently no-ops.
`SaveFileIO` is love-free enough to unit-test through the same in-memory
filesystem stub the slot backend uses (`tests/engine/save_file_io_tests.lua`).
## Responsiveness
Every measurement derives from `love.graphics.getDimensions()` each frame
plus the existing global scale `s = clamp(height / 768, 0.7, 1.6)`; nothing
assumes a fixed window size. The game panel's two-column grid (ROM/SAVE
FILES/Play on the left, SAVE SLOT on the right) collapses to one stacked
column, slot card below Play, when the window is too narrow for both
`~300 * s`-wide columns. The save-slot list and the mod list both scroll
(wheel, or drag on touch/desktop) clamped to their own content extent,
recomputed every draw. The tab bar labels only the active chip so it stays
narrow-safe, and content caps out at `~1440 * s` wide, centered.
+105
View File
@@ -18,6 +18,111 @@ Regenerate the reference straight into a wiki checkout:
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
```
## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it
*drawing*: a pipeline is a display mode a mod owns, which may replace the
overworld's world pass with geometry of its own and/or post-process the
finished image. `mods/voxel_world` is the worked example — a 3D diorama
overworld plus a tilt-shift miniature pass, in about 120 lines of glue over
its renderer.
A record declares what the mode *is*; the engine
(`src/render/Pipelines.lua`) supplies everything about *being a display
mode*: the OFF/1/2/3 ladder, an options row next to TILT, a hotkey,
persistence in `save.options.pipelines`, and the rule that a world pipeline
and the engine's own TILT are mutually exclusive.
```lua
mod.content.render_pipelines:register("diorama", {
label = "DIORAMA", -- options row label
levels = { "OFF", "15", "35", "50" }, -- ladder; defaults to OFF/ON
hotkey = "6", -- checked after the engine's keys
priority = 20, -- highest eligible wins the world
available = function() return Renderer3D.ok() end,
update = function(dt, level) Camera.ease(dt, level) end,
drawWorld = function(ctx) return renderScene(ctx) end,
})
```
Three draw stages, each optional; a record needs at least one:
| stage | signature | runs |
| --- | --- | --- |
| `drawWorld` | `(ctx) -> canvas \| nil` | instead of the flat/tilt world pass |
| `worldPresent` | `(canvas, ctx) -> canvas` | over the world, **before** the UI composites |
| `present` | `(canvas, ctx) -> canvas` | over the whole frame, world and UI alike |
`worldPresent` is the one to reach for when an effect must leave dialog
boxes and menus crisp — a depth-of-field or colour grade on the world only.
`present` is for effects that genuinely own the screen, like a CRT curve.
`ctx` carries the frame: `state`, `cam`, `vw`/`vh` (world-pixel view),
`width`/`height` (window pixels), `scale`, `level`, `paletteFor(map)` and
`spriteColors(map)`. It also carries `ctx.drawFx(project, scale)` — call it
with your own projection and the engine draws every active field effect
(the "!" bubble, the Poké Center heal machine, the Fly bird, the fishing
rod, Rock Tunnel darkness) at its correct anchor under your camera. There
is exactly one copy of each effect, so a new engine effect works in your
pipeline without you touching anything.
Three rules worth knowing:
- **`gate` governs input, never the draw.** It decides whether the player
may *change* the mode (default: free-roam overworld only). A mode that
stopped rendering during a warp would flash the flat 2D world every time
the player walked through a door.
- **`available` is re-read every frame** and is the only thing that decides
whether the mode can render at all. Answer `false` on a headless run or a
driver with no depth canvas and the engine silently keeps the vanilla 2D
path — which is why shipping a pipeline enabled is safe.
- **A callback that throws retires its pipeline**, attributed to your mod in
the manager's error feed, and the frame falls back to 2D. A broken
renderer costs the player a display mode, never the game.
Returning `nil` from `drawWorld` is a normal answer meaning "not this
frame"; the engine draws the vanilla world instead.
## Battle sprite scaling
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
the Game Boy did. A mod can override either, per species or per image.
Per species, on the `pokemon` record:
```lua
-- MEW's back pic renders 1.5x; its front pic is untouched
mod.content.pokemon:patch("MEW", { battleScaleBack = 1.5 })
```
`battleScaleFront` scales the enemy pic, `battleScaleBack` the player pic;
both take a number in `0.25 .. 4.0`.
Per image, on the `battle_sprite_scales` registry, keyed by the asset path
exactly as the data references it:
```lua
mod.content.battle_sprite_scales:register("abra_back", {
path = "assets/generated/battle/back/abrab.png",
scale = 1.5,
})
```
An image-level entry beats the species scale for that one pic, and it is
the only way to scale a pic that is not species-keyed — the player's
trainer back sprite, held on screen until "Go!", is a bare image path.
The resolution order at draw time is **image-level → species-level →
default** (1x front, 2x back).
- **The pic stays grounded at every scale.** The player pic keeps its feet
flush on the text-box top (`y = 96`); the enemy pic keeps its bottom edge
and horizontal centre pinned in its 7×7 slot. A larger pic grows upward
and outward from that anchor, never off the shelf.
- **Scaling composes with the send-out grow.** The `AnimateSendingOutMon`
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.
## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload
+126
View File
@@ -0,0 +1,126 @@
# Updater
A fused build (`love.filesystem.isFused()` true) ships a bundled `game.love`
baked into the executable, but that bundled copy is only ever the *fallback*.
On every launch, before anything else runs, `Boot.run` (`src/update/Boot.lua`)
looks in the save directory's `updates/` folder for a downloaded
`gen1recomp-X.Y.Z.love` payload that is both strictly newer than the bundled
engine version and runnable on this shell. If one qualifies, it is mounted
over `/` (so its files win over the fused source for every subsequent
`require`) and chainloaded in place: the payload's `main.lua` and `love.load`
run as if they had shipped in the executable. A dev/source checkout is never
fused, so `Boot.run` no-ops there and the working tree always runs itself.
The pieces are deliberately layered so the risky part is small. `Boot.select`
is a pure function (no `love.*` calls) that, given probed candidates and the
bundled `engine`/`shell`, decides what to run and what stale payloads to
delete. `Boot.probePayload` mounts one archive at an isolated mountpoint and
reads its `src/core/Version.lua` with `loadstring` (never `require`, so it is
never cached as a module) to learn its `engine` and `minShell`. `Boot.run`
orchestrates the crash guard, enumeration, selection, and the mount +
chainload, with full rollback on any failure. Checking for and fetching a
new payload is a separate, slower path: `src/update/Check.lua` is a thin
main-thread state machine the launcher screen polls, while the curl calls,
JSON parsing, and sha256 verification run on a background `love.thread`
(`src/update/check_worker.lua`) so a hung network call never blocks a frame.
## Version.lua fields
`src/core/Version.lua` carries three fields the updater reads directly (the
existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are
untouched):
- `engine` - the semver release, e.g. `"1.4.0"`. The repo default is the
`"0.0.0-dev"` placeholder; CI stamps the real `X.Y.Z` into the packed
`game.love` only, never the working tree. A `"0.0.0-dev"` engine always
reports itself up to date (it never chases a release, and it never counts
as a valid payload to chainload).
- `shell` - the native-shell contract this build's fused executable
implements.
- `minShell` - the lowest shell contract required to *run* this payload.
Bump `minShell` only when a payload needs something the currently-shipped
native shell cannot provide, for example a LOVE version bump, a new required
system binary, or a change to `love.run` itself (see Known limitations
below). An older shell refuses to chainload a payload whose `minShell`
exceeds the shell it provides; `Boot.select` keeps that payload in `updates/`
rather than deleting it, in case a future shell upgrade can run it, and
`Check`'s worker reports `needs_full` so the player is pointed at a full
installer instead. Do not bump `minShell` for an ordinary Lua/data release;
that is exactly the case the updater exists to avoid a reinstall for.
## Release assets
Each tagged release `vX.Y.Z` carries the existing per-platform archives
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
`-android.apk`) plus two assets the updater itself consumes:
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and
`Check.parseRelease`).
- `sha256sums.txt` - `shasum -a 256` output (`<hex> <filename>`, bare
filenames) covering at least the `.love` payload. `Check.parseSums`
tolerates a leading `*` binary marker and a `./` prefix but expects the
filename otherwise to match the asset name exactly.
A release missing either asset is treated as "no in-place update available":
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
(`https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest`).
## Save-directory layout
Under the save directory (identity `pokemon-love2d`):
```
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
updates/pending.txt crash-guard marker
```
`pending.txt` holds the filename of the payload currently being chainloaded.
`Boot.run`'s `chainload` writes it immediately before mounting, and removes it
on both a successful handoff and a clean rollback. If it is still present the
*next* time `Boot.run` starts, the previous boot died mid-handoff, so that
named payload is distrusted: it and the marker are deleted before candidates
are enumerated. Boot may still fall back to an older valid payload, or to the
bundled game, in that case.
## Update flow
1. **Boot** (every launch, fused builds only): crash-guard check, enumerate
and probe every `updates/*.love`, pick the highest engine that is
strictly newer than the bundled one and whose `minShell` this shell
satisfies, delete stale payloads, chainload the winner (or run the
bundled game if none qualifies).
2. **Check** (launcher screen): `Check.start()` kicks off an async check
against the GitHub releases API; safe to call every frame, it is a no-op
once a check is in flight or has reached a terminal state. `Check.state()`
reports `idle | checking | uptodate | available | downloading | ready |
needs_full | error` plus the latest version and download progress.
3. **Download + verify**: on `available`, `Check.download()` tells the
worker to fetch the payload, polling the growing `.part` file for
progress. On completion the worker re-fetches `sha256sums.txt`, verifies
the payload's sha256, and probes it with `Boot.probePayload` to gate its
`minShell` against this shell's `shell`. A verified, runnable payload is
renamed into place and reported as `ready`; anything else reports
`error` or `needs_full` and leaves `updates/` clean.
4. **Restart to apply**: a `ready` payload just sits in `updates/` until the
player relaunches; the next launch's Boot step (1) is what actually
mounts and runs it. There is no in-session hot-swap.
## Known limitations
- **`love.run` persists across handoff.** By the time `chainload` runs, the
bundled `love.run` has already returned its stepper to LOVE; redefining the
global `love.run` from the payload's `main.lua` does not affect the loop
already driving the frame. A payload that must change `love.run` itself
needs a `minShell` bump so an older shell refuses to chainload it rather
than running with half its intended behavior.
- **Android has no in-app download transport yet.** `check_worker.lua`
shells out to curl for both the release check and the download; curl is
absent on Android, so `Check` degrades to `status = "error"` there (the
launcher UI hides on that status) and the player is directed to the
releases page via `Check.releaseUrl()` instead.
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
`"0.0.0-dev"` placeholder that always reports up to date, so a source
checkout is always "the game" itself; updating it means pulling the repo.
+6
View File
@@ -56,6 +56,12 @@ local function bootGame(version)
end
function love.load(args)
-- Self-updater boot shell: a fused build may mount and chainload a newer
-- downloaded payload here. True means it took over, so we must stop. A
-- dev / source checkout no-ops (see src/update/Boot.lua).
local Boot = require("src.update.Boot")
if Boot.run(args) then return end
local savePath
for i, a in ipairs(args or {}) do
if a == "--editor" then
+6 -3
View File
@@ -43,9 +43,12 @@ The embedded `game.love` deliberately excludes `data/generated/`,
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and
`tools/rom_manifest.json`.
The current importer has desktop file pickers only. A production Android
release still needs a Storage Access Framework handoff that passes the chosen
ROM to LÖVE; the APK packaging itself is data-free.
ROM import on Android uses `love.system.pickFile()`
`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the
chosen file to `picked_rom.gb` under the app save directory. `RomImporter`
imports pending (not-yet-ready) `.gb` files from that folder on Choose /
refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload
itself remains data-free (no embedded ROM or generated cache).
### SDK / NDK
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "BALANCE",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "TOOL",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "AUDIO",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"permissions": ["engine_internals"],
"dependencies": [],
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "QUEST",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"permissions": ["engine_internals"],
"dependencies": [],
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "total_conversion",
"category": "TOTAL_CONVERSION",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 900,
"dependencies": [],
"optional_dependencies": [],
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "GRAPHICS",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"assets_transforms": "transforms.lua",
"dependencies": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "overhaul",
"category": "MECHANIC",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
+25
View File
@@ -68,6 +68,31 @@ if unzip -Z1 "$LOVE_FILE" \
fi
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
# ------------------------------------------------------- stamp release version
# The working tree ships Version.lua with engine "0.0.0-dev"; the real release
# number only ever lives inside the packed archive. When --version is a strict
# X.Y.Z, patch a copy of Version.lua (engine set to that number) under a staging
# dir and replace the entry inside game.love in place -- never the source tree.
# Short-hash / "dev" builds are left with the "-dev" default so they cannot be
# mistaken for a release. The stamp is then read back out of the archive and the
# build fails if it did not take.
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION into game.love"
stamp_dir="$WORK/stamp"
rm -rf "$stamp_dir"
mkdir -p "$stamp_dir/src/core"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
unzip -p "$LOVE_FILE" src/core/Version.lua \
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|| fail "version stamp failed: game.love does not report engine $VERSION"
say "stamped engine version: $VERSION"
else
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
fi
# --------------------------------------------------------------- macOS
build_mac() {
say "building macOS app"
+26
View File
@@ -145,6 +145,32 @@ pack_game_love() {
fail "game.love unexpectedly contains generated ROM data"
fi
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
# This script packs its own game.love (it does not reuse build.sh's), so it
# stamps the release version the same way: patch a copy of Version.lua
# (engine set to $VERSION) under a throwaway staging dir and replace the
# entry inside the archive in place -- never the source tree. VERSION is
# already validated as X.Y.Z above; when it is empty the packaged game keeps
# the "0.0.0-dev" default. The stamp is read back out and the build fails if
# it did not take.
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION into game.love"
local stamp_dir
stamp_dir="$(mktemp -d)"
mkdir -p "$stamp_dir/src/core"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
local version_re
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
unzip -p "$LOVE_FILE" src/core/Version.lua \
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|| fail "version stamp failed: game.love does not report engine $VERSION"
rm -rf "$stamp_dir"
say "stamped engine version: $VERSION"
else
say "no X.Y.Z --version, shipping default engine (no stamp)"
fi
}
# --------------------------------------------------------------- SDK check
+330 -159
View File
@@ -173,6 +173,14 @@ local function grayImage(img)
return getImage(meta.path) or img
end
-- the asset path a loaded battle image came from (nil for the headless
-- stub images), so the battle_sprite_scales registry can be looked up by
-- the same path data references
local function imagePathOf(img)
local m = imageMeta[img]
return m and m.path
end
-- the image a battler pic actually draws with this frame
function BattleState:picImage(img)
if self.grayPics then return grayImage(img) end
@@ -267,6 +275,10 @@ local function makeBattler(data, mon, isPlayer, save)
badgeBoosts = badgeBoosts,
statuses = data.statuses,
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain)
-- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move
-- while the tilemap still shows the prior condition until the next
-- post-action HUD refresh (core.asm after Execute*Move)
shownStatus = mon.status,
stages = {},
-- volatile state; Transform/Conversion/Mimic override the cur* fields
curStats = mon.stats,
@@ -548,6 +560,12 @@ function BattleState:say(text)
table.insert(self.queue, { text = text })
end
-- Message that opens YES/NO once typed out, keeping the text visible
-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice).
function BattleState:sayChoice(text, onChoose)
table.insert(self.queue, { text = text, choice = onChoose })
end
function BattleState:act(fn)
table.insert(self.queue, { fn = fn })
end
@@ -808,8 +826,24 @@ function BattleState:updateQueue()
if self.charIndex < self.total then
self.charIndex = math.min(self.total, self.charIndex + 2)
else
local item = self.current
-- TrainerAboutToUseText ends in `done` then DisplayTextBoxID: YES/NO
-- overlays the still-visible "Will … change POKéMON?" page.
if item and item.choice and not item.choiceOpen then
item.choiceOpen = true
self.waitingUI = true
local ChoiceBox = require("src.ui.ChoiceBox")
local battle = self
self.game.stack:push(ChoiceBox.new(self.game, function(yes)
local fn = item.choice
battle.current = nil
fn(yes)
end))
return true
end
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b") then
if not (item and item.choice)
and (input:wasPressed("a") or input:wasPressed("b")) then
self.current = nil
end
end
@@ -1060,11 +1094,14 @@ function BattleState:update(dt)
self:updateFx()
local input = self.game.input
-- safety net: HP changed outside a queued drain (level-up heals,
-- field effects) snaps once the queue is idle
-- safety net: HP/status changed outside a queued drain (level-up heals,
-- field effects, bag cures) snaps once the queue is idle
if self.phase == "menu" then
for _, b in ipairs({ self.player, self.enemy }) do
if b and b.shownHP then b.shownHP = b.mon.hp end
if b then
if b.shownHP then b.shownHP = b.mon.hp end
b.shownStatus = b.mon.status
end
end
end
@@ -2145,90 +2182,105 @@ end
-- move execution pipeline
-- ---------------------------------------------------------------------
-- Mirror DrawHUDsAndHPBars: reveal mon.status on the HUD only after the
-- current action's queued anim/text have played (PoisonEffect sets the
-- bit before PlayCurrentMoveAnimation2 + PrintText, but the HUD redraw
-- waits until after Execute*Move returns).
function BattleState:syncShownStatus()
for _, b in ipairs({ self.player, self.enemy }) do
if b then b.shownStatus = b.mon.status end
end
end
function BattleState:executeAction(user, target, action)
if user.mon.hp <= 0 or target.mon.hp <= 0 then return end
if not action then return end
-- ghost battles: the ghost never attacks; its whole turn is the
-- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463)
if self.ghost and not user.isPlayer then
self:sayNext(self.data.text._GetOutText or "GHOST: Get out...\nGet out...")
return
end
-- refresh the held-in-place mirror before the status checks (see
-- lockedAction): the victim is held exactly while the opponent's
-- trapping bit is set -- including a counter sitting at 0 until the
-- end-of-turn CheckNumAttacksLeft clear
user.boundTurns = target.trappingTurns
and math.max(1, target.trappingTurns) or nil
-- trainer class AI actions (engine/battle/trainer_ai.asm)
if action.special == "aiItem" then
self.aiUses = (self.aiUses or 1) - 1
for _, m in ipairs(TrainerAI.useItem(self, action.item)) do
self:sayNext(prefixEnemy(m, self.enemy))
end
self:drainNext()
require("src.core.Sound").play(self.data, "Heal_Ailment")
return
end
if action.special == "aiSwitch" then
self.aiUses = (self.aiUses or 1) - 1
local previous = self.enemy
local oldName = self.enemy.name
self.enemyIndex = action.index
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy,
previous = previous,
})
self.aiUses = self:aiUsesFor()
markSeen(self.game, self.enemy.mon.species)
-- _AIBattleWithdrawText: "X with-/drew Y!"
self:sayNext(("%s with-\ndrew %s!"):format(self.trainer.name, oldName))
self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name))
return
end
-- special locked actions. All of them still run the status gauntlet:
-- CheckPlayerStatusConditions (core.asm:3328-3583) evaluates sleep ->
-- freeze -> held-in-place -> flinch -> recharge -> disable tick ->
-- confusion -> paralysis BEFORE the bide/thrash/trapping handling.
if action.special == "recharge" then
-- only reaching .HyperBeamCheck consumes the flag (core.asm:3384-
-- 3392): sleep/freeze/held/flinch keep the mon recharging next turn
if self:preRechargeChecks(user, target) then return end
user.mustRecharge = nil
self:sayNext(("%s\nmust recharge!"):format(displayName(user)))
return
end
if action.special == "bound" then
if not target.trappingTurns then
-- the trap ended earlier this turn: the CANNOT_MOVE selection is
-- simply lost (ExecutePlayerMove returns immediately on $ff)
local function run()
-- ghost battles: the ghost never attacks; its whole turn is the
-- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463)
if self.ghost and not user.isPlayer then
self:sayNext(self.data.text._GetOutText or "GHOST: Get out...\nGet out...")
return
end
-- sleep/freeze take precedence over the held-in-place message
if self:statusInterrupt(user, target) then return end
return
end
if action.special == "trapping" then
if self:statusInterrupt(user, target) then return end
self:continueTrapping(user, target)
return
end
if action.special == "bide" then
if self:statusInterrupt(user, target) then return end
self:continueBide(user, target)
return
end
if self:statusInterrupt(user, target) then return end
self:performMove(user, target, action, false)
-- refresh the held-in-place mirror before the status checks (see
-- lockedAction): the victim is held exactly while the opponent's
-- trapping bit is set -- including a counter sitting at 0 until the
-- end-of-turn CheckNumAttacksLeft clear
user.boundTurns = target.trappingTurns
and math.max(1, target.trappingTurns) or nil
-- trainer class AI actions (engine/battle/trainer_ai.asm)
if action.special == "aiItem" then
self.aiUses = (self.aiUses or 1) - 1
for _, m in ipairs(TrainerAI.useItem(self, action.item)) do
self:sayNext(prefixEnemy(m, self.enemy))
end
self:drainNext()
require("src.core.Sound").play(self.data, "Heal_Ailment")
return
end
if action.special == "aiSwitch" then
self.aiUses = (self.aiUses or 1) - 1
local previous = self.enemy
local oldName = self.enemy.name
self.enemyIndex = action.index
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy,
previous = previous,
})
self.aiUses = self:aiUsesFor()
markSeen(self.game, self.enemy.mon.species)
-- _AIBattleWithdrawText: "X with-/drew Y!"
self:sayNext(("%s with-\ndrew %s!"):format(self.trainer.name, oldName))
self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name))
return
end
-- special locked actions. All of them still run the status gauntlet:
-- CheckPlayerStatusConditions (core.asm:3328-3583) evaluates sleep ->
-- freeze -> held-in-place -> flinch -> recharge -> disable tick ->
-- confusion -> paralysis BEFORE the bide/thrash/trapping handling.
if action.special == "recharge" then
-- only reaching .HyperBeamCheck consumes the flag (core.asm:3384-
-- 3392): sleep/freeze/held/flinch keep the mon recharging next turn
if self:preRechargeChecks(user, target) then return end
user.mustRecharge = nil
self:sayNext(("%s\nmust recharge!"):format(displayName(user)))
return
end
if action.special == "bound" then
if not target.trappingTurns then
-- the trap ended earlier this turn: the CANNOT_MOVE selection is
-- simply lost (ExecutePlayerMove returns immediately on $ff)
return
end
-- sleep/freeze take precedence over the held-in-place message
if self:statusInterrupt(user, target) then return end
return
end
if action.special == "trapping" then
if self:statusInterrupt(user, target) then return end
self:continueTrapping(user, target)
return
end
if action.special == "bide" then
if self:statusInterrupt(user, target) then return end
self:continueBide(user, target)
return
end
if self:statusInterrupt(user, target) then return end
self:performMove(user, target, action, false)
end
run()
-- after announce/anim/effect text (pokered DrawHUDsAndHPBars)
self:actNext(function() self:syncShownStatus() end)
end
-- Sleep / confusion onomatopoeia from Check*StatusConditions
@@ -2724,51 +2776,37 @@ function BattleState:enemyMonFainted()
end
if nextIndex then
self.enemyIndex = nextIndex
-- SHIFT battle style (the default): announce the next mon and
-- offer a free switch (SET skips the prompt)
-- EnemySendOutFirstMon (core.asm:1366-1443): SHIFT offers a free
-- switch when party count > 1, the active mon is alive, and the
-- battle-style bit is clear. pokered counts party slots (not
-- remaining HP); SET / single-mon / fainted active skip the prompt.
local nextMon = self.enemyParty[self.enemyIndex]
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
local style = (self.game.save.options or {}).battleStyle or "shift"
local healthy = 0
for _, mon in ipairs(self.game.save.party) do
if mon.hp > 0 then healthy = healthy + 1 end
end
if style ~= "set" and healthy > 1 and self.player.mon.hp > 0 then
self:say(("%s is\nabout to use\n%s!"):format(self.trainer.name, nextName))
self:say(("Will %s\nchange POKéMON?"):format(self.game.save.player.name))
local game = self.game
self:ui(function()
local ChoiceBox = require("src.ui.ChoiceBox")
return ChoiceBox.new(game, function(yes)
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
:lower()
local partyCount = #self.game.save.party
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
local shiftSwitchMon = nil
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
-- _TrainerAboutToUseText: "X is" / "about to use" then cont nick,
-- then para "Will PLAYER" / "change POKéMON?" with YES/NO.
self:say(("%s is\nabout to use"):format(self.trainer.name))
self:say(("%s!"):format(nextName))
self:sayChoice(
("Will %s\nchange POKéMON?"):format(self.game.save.player.name),
function(yes)
if not yes then return end
local game = self.game
Screens.push(game, "PartyMenu", {
battle = self,
forceSwitch = true,
onSwitch = function(mon)
if mon ~= self.player.mon and mon.hp > 0 then
local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
battler = self.player, previous = previous,
})
self:markParticipant()
self.nextInsert = 0
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
require("src.core.Sound").playCry(self.data, self.player.mon.species)
end)
shiftSwitchMon = mon
end
end,
})
end)
end)
end
self:act(function()
local previous = self.enemy
@@ -2797,6 +2835,29 @@ function BattleState:enemyMonFainted()
end)
end)
end)
-- SwitchPlayerMon after the enemy is out (core.asm:1436-1443)
self:act(function()
local mon = shiftSwitchMon
if not mon then return end
local previous = self.player
self.player = makeBattler(self.data, mon, true, self.game.save)
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
battler = self.player, previous = previous,
})
self:markParticipant()
self.nextInsert = 0
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
self:startGrowIn(self.player)
require("src.core.Sound").playCry(self.data, self.player.mon.species)
end)
end)
return
end
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
@@ -2920,6 +2981,8 @@ function BattleState:openReplacementMenu()
self:ui(function()
return self:buildScreen("PartyMenu", {
battle = self,
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
forceSwitch = true,
onSwitch = function(mon)
if mon.hp <= 0 then
self:say("There's no will\nto fight!")
@@ -3130,6 +3193,9 @@ end
-- called by BagMenu after an item is used in battle (consumes the turn)
function BattleState:itemUsed(messages)
-- bag cures clear mon.status before the message UI; refresh the HUD
-- once control returns (pokered DrawHUDsAndHPBars after item use)
self:syncShownStatus()
for _, m in ipairs(messages or {}) do self:say(m) end
table.insert(self.queue, { drain = true }) -- potions animate the bar
self:act(function()
@@ -3151,10 +3217,39 @@ function BattleState:ballMissMessage(shakes)
return t._ItemUseBallText04 or "Shoot! It was so\nclose too!"
end
-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
-- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party
-- AddPartyMon and SendNewMonToBox (#172).
function BattleState:askNicknameUI(mon, displayName)
local game = self.game
self.lockedBall = nil
self.blankForAskName = true
local TextBox = require("src.render.TextBox")
local text = ("Do you want to\ngive a nickname\nto %s?"):format(displayName)
local label = game.data.text and game.data.text._DoYouWantToNicknameText
if label then
-- extractor CONT is \t; TextBox scrolls on \n/\v
text = label:gsub("\t", "\n"):gsub("{RAM:?[%w_]*}", displayName)
end
return TextBox.new(game, text, nil, {
choice = function(yes)
self.blankForAskName = false
if not yes then return end
pcall(Screens.push, game, "NamingScreen", {
title = "NICKNAME?", maxLen = 10,
onDone = function(name)
if name and #name > 0 then mon.nickname = name end
end,
})
end,
})
end
-- The caught mon joins the party or a PC box (ItemUseBall .captured,
-- item_effects.asm:518-566): the caught text, then for a NEW species
-- "New POKéDEX data will be added" + the dex entry page, then the
-- party add (with the nickname ask) or the PC transfer text.
-- "New POKéDEX data will be added" + the dex entry page, then
-- AddPartyMon or SendNewMonToBox (both call AskName), then the PC
-- transfer text when the party was full.
function BattleState:storeCaughtMon()
-- ItemUseBall reloads the caught mon via LoadEnemyMonData
-- (item_effects.asm:472-501), regenerating its move list from the
@@ -3174,31 +3269,20 @@ function BattleState:storeCaughtMon()
return self:buildScreen("DexEntryMenu", species)
end)
end
if Party.add(game.save.party, self.enemy.mon) then
-- nickname prompt (AskName runs inside AddPartyMon; box mons are
-- never offered a nickname)
local function askCaughtNickname()
local caught = self.enemy.mon
local enemyName = self.enemy.name
self:uiNext(function()
local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
return TextBox.new(game, ("Do you want to\ngive a nickname\nto %s?")
:format(enemyName), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end
pcall(Screens.push, game, "NamingScreen", {
title = "NICKNAME?", maxLen = 10,
onDone = function(name)
if name and #name > 0 then caught.nickname = name end
end,
})
end))
end)
return self:askNicknameUI(caught, enemyName)
end)
end
if Party.add(game.save.party, self.enemy.mon) then
askCaughtNickname()
else
destination = "box"
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
if boxNum then
askCaughtNickname()
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
and "BILL's PC" or "someone's PC"
@@ -3449,12 +3533,15 @@ function BattleState:fxFaintActive(battler)
and fx.faint.frames > 0 or false
end
-- vertical slide offset for a fainting battler (the player's pic is
-- drawn 2x, so it slides 2x as fast to sink at the same visual rate)
function BattleState:fxFaintOffset(battler)
-- vertical slide offset for a fainting battler. The offset is in screen
-- pixels, so it scales with the pic's draw scale (the player's default 2x
-- sinks 2x as fast to sink at the same visual rate); a mod scale composes
-- the same way. scale defaults to the vanilla side scale when unknown.
function BattleState:fxFaintOffset(battler, scale)
local fx = self.fx
if self:fxFaintActive(battler) then
return (30 - fx.faint.frames) * 2 * (battler.isPlayer and 2 or 1)
scale = scale or (battler.isPlayer and 2 or 1)
return (30 - fx.faint.frames) * 2 * scale
end
return 0
end
@@ -3550,7 +3637,7 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
return
end
if self:fxFaintActive(battler) then
local off = self:fxFaintOffset(battler)
local off = self:fxFaintOffset(battler, scale)
local visible = img:getHeight() - math.floor(off / scale)
if visible > 0 then
local quad = love.graphics.newQuad(0, 0, img:getWidth(), visible,
@@ -3847,6 +3934,63 @@ function BattleState:drawAnimLayer(colorized)
end
end
-- ------------------------------------------------------------------
-- Mod-facing battle sprite scaling. The enemy front pic draws at 1x and
-- the player back pic at 2x on the GB; a mod can override either per
-- species (pokemon.battleScaleFront / battleScaleBack) or per image path
-- (the battle_sprite_scales registry, which is the only handle on the
-- non-species pics like the trainer back). These resolvers and the
-- placement math are pure (no love.*) so the grounding contract -- feet
-- pinned at any scale -- is unit-tested directly.
-- ------------------------------------------------------------------
-- the vanilla scale for a side: enemy front 1x, player back 2x
BattleState.BATTLE_SCALE_DEFAULT = { front = 1, back = 2 }
-- image-level override for an asset path, or nil. scales is the merged
-- data.battle_sprite_scales table (record id -> { path, scale }).
function BattleState.imageBattleScale(scales, path)
if not scales or not path then return nil end
for id, rec in pairs(scales) do
if id ~= "_owners" and type(rec) == "table" and rec.path == path then
return rec.scale
end
end
return nil
end
-- effective battle scale for a pic: image-level override, else the
-- species-level override for the side, else the side default. side is
-- "front" (enemy) or "back" (player); species may be nil (a non-species
-- pic like the trainer back, which only image-level scaling reaches).
function BattleState.resolveBattleScale(data, side, path, species)
local img = data and BattleState.imageBattleScale(data.battle_sprite_scales, path)
if img then return img end
local def = species and data and data.pokemon and data.pokemon[species]
local field = side == "back" and "battleScaleBack" or "battleScaleFront"
local override = def and def[field]
if override then return override end
return BattleState.BATTLE_SCALE_DEFAULT[side] or 1
end
-- Player (back) placement: feet flush on the text-box top (y=96) at any
-- scale, with the left transparent columns pulled back so opaque pixels
-- land where hardware's white-on-white columns left them. Returns the
-- top-left x, y and the scale (slide/shake offsets are added by the
-- caller). Feet stay at 96 for every scale: y + (h - pad) * scale == 96.
function BattleState.backPlacement(w, h, pad, padL, scale)
return 8 - padL * scale, 96 - (h - pad) * scale, scale
end
-- Enemy (front) placement: given the s=1 slot origin (ex, ey) from the
-- 7x7 tile layout, keep the bottom edge and horizontal centre pinned as
-- the pic scales -- the same compensation AnimateSendingOutMon's grow
-- uses. Returns top-left x, y and the scale. The bottom edge stays put
-- for every scale: y + h * scale == ey + h.
function BattleState.frontPlacement(ex, ey, w, h, scale)
return ex + w * (1 - scale) / 2, ey + h * (1 - scale), scale
end
-- Front/trainer pics: LoadUncompressedSpriteData centers the sprite in
-- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that
-- buffer at hlcoord 12,0. Horizontal pad is floor((8-w)/2) tiles;
@@ -3890,16 +4034,23 @@ function BattleState:drawPicsLayer(slide, sx, sy)
local img = self:picImage(self.enemy.sprite)
love.graphics.setColor(1, 1, 1, 1)
local ex, ey = enemyPicXY(img, slide, sx, sy)
local s = BattleState.resolveBattleScale(self.data, "front",
imagePathOf(img), self.enemy.mon and self.enemy.mon.species)
local gs = self:growInScale(self.enemy)
if gs then
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
-- and horizontal center pinned to the mon's slot while it grows
if gs > 0 then
love.graphics.draw(img, ex + img:getWidth() * (1 - gs) / 2,
ey + img:getHeight() * (1 - gs), 0, gs, gs)
-- and horizontal center pinned to the mon's slot while it grows --
-- the mod scale composes multiplicatively with the grow stage
local eff = s * gs
if eff > 0 then
local dx, dy = BattleState.frontPlacement(ex, ey,
img:getWidth(), img:getHeight(), eff)
love.graphics.draw(img, dx, dy, 0, eff, eff)
end
else
self:drawBattlerPic(self.enemy, ex, ey, 1)
local dx, dy = BattleState.frontPlacement(ex, ey,
img:getWidth(), img:getHeight(), s)
self:drawBattlerPic(self.enemy, dx, dy, s)
end
end
@@ -3913,9 +4064,14 @@ function BattleState:drawPicsLayer(slide, sx, sy)
local img = self:picImage(self.playerBackPic)
local pad = imagePadBottom[self.playerBackPic] or 0
local padL = imagePadLeft[self.playerBackPic] or 0
-- the trainer back is a bare pic, not species-keyed, so only an
-- image-level battle_sprite_scales entry can rescale it
local s = BattleState.resolveBattleScale(self.data, "back",
imagePathOf(self.playerBackPic), nil)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, 8 - padL * 2 + slide + sx,
96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
local dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(),
pad, padL, s)
love.graphics.draw(img, dx + slide + sx, dy + sy, 0, s, s)
elseif self.player and self.player.sprite and not hidePlayer
and not self.sendingOut and not self:fxHidden(self.player) then
local img = self:picImage(self.player.sprite)
@@ -3923,19 +4079,24 @@ function BattleState:drawPicsLayer(slide, sx, sy)
-- feet flush on the text box top (y=96), ignoring baked-in padding
local pad = imagePadBottom[self.player.sprite] or 0
local padL = imagePadLeft[self.player.sprite] or 0
local px = 8 - padL * 2 + sx
local s = BattleState.resolveBattleScale(self.data, "back",
imagePathOf(self.player.sprite),
self.player.mon and self.player.mon.species)
local gs = self:growInScale(self.player)
if gs then
-- the player-side AnimateSendingOutMon grow (after the poof,
-- core.asm:1757-1762): feet pinned at y=96, center at x=8+w
if gs > 0 then
love.graphics.draw(img, px + img:getWidth() * (1 - gs),
96 - (img:getHeight() - pad) * 2 * gs + sy,
0, 2 * gs, 2 * gs)
-- core.asm:1757-1762): feet pinned at y=96, horizontal centre
-- pinned, mod scale composed with the grow stage
local eff = s * gs
if eff > 0 then
love.graphics.draw(img,
8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
end
else
self:drawBattlerPic(self.player, px,
96 - (img:getHeight() - pad) * 2 + sy, 2)
local dx, dy = BattleState.backPlacement(img:getWidth(),
img:getHeight(), pad, padL, s)
self:drawBattlerPic(self.player, dx + sx, dy + sy, s)
end
end
if clipped then
@@ -3956,8 +4117,11 @@ function BattleState:drawHUDs(slide)
local barData = self:colorMode() and {} or self.data -- gray fill when zoned
local fx = self.fx
local hudShake = (fx and fx.hudShakeX) or 0
-- FaintEnemyPokemon clears the enemy HUD area; it stays blank through
-- TrainerAboutToUseText until DrawEnemyHUDAndHPBar after the next send-out
if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut
and not self:growInScale(self.enemy) and slide == 0 then
and not self:growInScale(self.enemy) and slide == 0
and not self.enemy.fainted then
-- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1),
-- HP bar (2,2) with the vertical tick at (1,2), underline row 3;
-- AnimationShakeEnemyHUD nudges just this block via SCX
@@ -3967,8 +4131,8 @@ function BattleState:drawHUDs(slide)
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0)
if self.enemy.mon.status then
Font.draw(self:statusLabel(self.enemy.mon), 40, 8)
if self.enemy.shownStatus then
Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8)
else
hudTile(0x6E, 32, 8) -- <LV>
Font.draw(tostring(self.enemy.mon.level), 40, 8)
@@ -4015,8 +4179,8 @@ function BattleState:drawHUDs(slide)
-- the tick at (18,10) and the triangle at (9,11)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.player.name, nameX(10, self.player.name), 56)
if self.player.mon.status then
Font.draw(self:statusLabel(self.player.mon), 120, 64)
if self.player.shownStatus then
Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64)
else
hudTile(0x6E, 112, 64) -- <LV>
Font.draw(tostring(self.player.mon.level), 120, 64)
@@ -4122,6 +4286,13 @@ function BattleState:drawTextArea()
end
function BattleState:draw()
-- AskName: ClearSprites + wild ClearScreenArea — white field under the
-- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top.
if self.blankForAskName then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
return
end
local fx = self.fx
-- window shakes (SE_SHAKE_SCREEN / the enemy-hit vertical shake);
-- the animations-off fallback keeps the old +-2 alternation
+2
View File
@@ -102,6 +102,8 @@ function Data:seedDefaults()
-- after-battle rows so Blaine's SetEventRange deactivation and talk
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
self:seedCinnabarGymTrainerHeaders()
-- #189: 1F cabin door order vs rooms map (survey zoom)
require("src.world.SsAnneLayout").apply(self.maps)
end
function Data:seedCinnabarGymTrainerHeaders()
+20
View File
@@ -37,6 +37,9 @@ function Game:load()
self.mods = ModLoader.new()
self.mods:load(Data)
self.modStatus = self.mods:status()
-- render pipelines dispatch off the merged dataset; point them at the
-- one the mods just merged into before anything can draw a frame
require("src.render.Pipelines").install(Data)
self.input = Input
Input:init()
@@ -198,6 +201,9 @@ function Game:update(dt)
-- Overworld tilt toggle tween: presentational, so it runs on the real
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
require("src.render.Tilt").update(dt)
-- mod render pipelines tween on the same real-frame clock, for the same
-- reason: they are presentational, so fast-forward must not speed them up
require("src.render.Pipelines").update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end)
-- Steady-state memory backstop: advance the incremental collector one
-- small step every rendered frame. The heavy GPU objects are now freed
@@ -341,6 +347,17 @@ function Game:keypressed(key)
self:writeOptions()
return
end
-- Mod render pipelines claim their hotkeys last, so one can never shadow
-- an engine display key however a mod declares it (12 §rendering
-- pipelines). syncOptions writes the whole ladder back, including the
-- tilt exclusion a world pipeline forces.
local Pipelines = require("src.render.Pipelines")
if Pipelines.hotkey(key, self.stack:top(), self.overworld) then
Pipelines.syncOptions(self.save.options)
require("src.render.Tilt").setLevel(self.save.options.tilt or 0)
self:writeOptions()
return
end
Input:keypressed(key)
end
@@ -457,6 +474,9 @@ function Game:applyOptions(opts)
if Sound.applyOptions then Sound.applyOptions(opts) end
require("src.render.PaletteFX").applyOptions(opts)
require("src.render.Tilt").applyOptions(opts)
-- after Tilt, so a persisted world pipeline can switch the tilt level it
-- just restored back off (the two are mutually exclusive)
require("src.render.Pipelines").applyOptions(opts)
require("src.render.Zoom").applyOptions(opts)
require("src.render.TileRenderer").applyOptions(opts)
-- returns true when a persisted GBC FX level was cleared on mobile
+284 -4
View File
@@ -17,6 +17,7 @@ local Runtime = require("src.mods.Runtime")
local Semver = require("src.mods.Semver")
local Boxes = require("src.pokemon.Boxes")
local Bag = require("src.inventory.Bag")
local Badges = require("src.inventory.Badges")
local GameVersion = require("src.core.GameVersion")
@@ -31,10 +32,11 @@ local OPTIONS_FILENAME = "options.lua"
-- Main / backup / staged-witness names for a version (defaults to the active
-- one). The backup is a rolling copy and .tmp is the staged-write witness;
-- load promotes either when the main file is missing or fails to parse.
local function saveNames(version)
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- Forward-declared here so saveFilename (just below) and every save/load
-- caller share the one upvalue; the body is filled in under "save slots"
-- once the options IO it depends on exists, because it now resolves the
-- ACTIVE slot for a version rather than the fixed flat name.
local saveNames
-- The main save filename for a version -- used by the title screen's
-- CONTINUE gate so it looks for the right game's save.
@@ -91,6 +93,18 @@ local function makePortableFs(dir)
os.remove(full(name))
return true
end,
createDirectory = function(name)
-- portable mode writes real files through io.*, which will not
-- create missing parent directories; mkdir the tree so a slot path
-- like "saves/red" exists before a write lands inside it
local osPath = full(name):gsub("/", SEP)
if SEP == "\\" then
os.execute('mkdir "' .. osPath .. '" 2>nul')
else
os.execute('mkdir -p "' .. osPath .. '" 2>/dev/null')
end
return true
end,
}
end
@@ -198,6 +212,11 @@ function SaveData.defaultOptions()
videoMode = "windowed",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- Per-pipeline display levels, keyed by render_pipelines id (see
-- src/render/Pipelines.lua). A level for a mod that is not installed
-- is kept rather than pruned, so re-enabling the mod restores the mode
-- the player left it in.
pipelines = {},
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
@@ -283,6 +302,264 @@ function SaveData.loadOptions(fs)
return SaveData.mergeOptions(data)
end
-- ------- save slots
-- A version's playthroughs live in numbered slots under saves/<version>/;
-- the active slot is where in-game SAVE and CONTINUE land. The registry
-- (the ordered slot list plus which one is active) persists in options.lua
-- under options.saveSlots[version]; the active slot is also cached
-- process-wide (like GameVersion.current) so the hot saveNames path does
-- not re-read options every call. A false cache entry means "no slot in
-- use" and the flat legacy path (save.lua / save_blue.lua) is used, which
-- keeps a brand-new install and every pre-slots caller working unchanged.
local activeSlotCache = {} -- version -> slotId in use, or false when none
local slotsChecked = {} -- version -> true once resolved this process
local function slotDir(version) return "saves/" .. version end
local function slotNames(version, id)
local main = slotDir(version) .. "/" .. id .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- the pre-slots flat names a version always used (save.lua for Red,
-- save_blue.lua for Blue); still the destination before any slot exists
local function legacyNames(version)
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- Slot resolution is only meaningful for versions GameVersion actually knows
-- (red/blue). The launcher also renders a locked placeholder tab ("yellow")
-- that has no info entry and therefore no saveSuffix; resolving its legacy
-- names would index a nil info table and crash. Treat any unknown version as
-- having no slots so the slot APIs degrade to empty/no-op instead.
local function knownVersion(version)
return GameVersion.info(version) ~= nil
end
-- Create the parent directory of a slot path when the fs supports it.
-- love.filesystem.createDirectory makes the whole tree; the injected memfs
-- stub keys files by full path and exposes no such method, so this is a
-- no-op there.
local function ensureParentDir(fs, name)
local dir = name:match("^(.*)/[^/]+$")
if dir and fs.createDirectory then fs.createDirectory(dir) end
end
-- Decode a slot's save using the same recovery order load() uses -- main,
-- then the .tmp write-witness, then the .bak -- so a slot mid-crash still
-- summarizes. nil when nothing readable is present.
local function decodeSlot(fs, version, id)
local main, bak, tmp = slotNames(version, id)
local data = fs.getInfo(main) and SaveSerializer.decode(fs.read(main) or "")
if data then return data end
data = fs.getInfo(tmp) and SaveSerializer.decode(fs.read(tmp) or "")
if data then return data end
data = fs.getInfo(bak) and SaveSerializer.decode(fs.read(bak) or "")
return data or nil
end
-- One-time legacy consolidation: a pre-slots install has a flat save file
-- (+ .bak) and no saves/<version>/ registry. Copy both into slot1, verify
-- the copy reads back, then remove the originals and register slot1 as the
-- active slot. Returns the new slot id, or nil when there is nothing to
-- migrate or the copy could not be verified (originals left in place so no
-- data is ever lost to a failed move).
local function tryMigrateLegacy(version, fs)
local lmain, lbak, ltmp = legacyNames(version)
local mainBody = fs.getInfo(lmain) and fs.read(lmain)
local bakBody = fs.getInfo(lbak) and fs.read(lbak)
if not (mainBody or bakBody) then return nil end
local id = "slot1"
local dmain, dbak = slotNames(version, id)
ensureParentDir(fs, dmain)
if mainBody then fs.write(dmain, mainBody) end
if bakBody then fs.write(dbak, bakBody) end
-- refuse to delete the originals unless the new slot is loadable (from
-- the main copy or, failing that, the backup)
if not decodeSlot(fs, version, id) then return nil end
remove(fs, lmain)
remove(fs, lbak)
remove(fs, ltmp)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
opts.saveSlots[version] = { list = { id }, active = id }
SaveData.saveOptions(opts, fs)
return id
end
-- Resolve (once per version per process) which slot in-game saves use: an
-- existing registry wins; otherwise a lazy legacy migration may create
-- slot1; otherwise false, meaning the flat legacy path.
local function ensureVersionSlots(version, fs)
if slotsChecked[version] then return end
slotsChecked[version] = true
if not knownVersion(version) then
activeSlotCache[version] = false
return
end
local opts = SaveData.loadOptions(fs)
local reg = opts.saveSlots and opts.saveSlots[version]
if reg and type(reg.list) == "table" and #reg.list > 0 then
activeSlotCache[version] = reg.active or reg.list[1]
return
end
activeSlotCache[version] = tryMigrateLegacy(version, fs) or false
end
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
-- the version, falling back to the flat legacy names when no slot is in use.
function saveNames(version)
version = version or GameVersion.get()
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local slot = activeSlotCache[version]
if slot then return slotNames(version, slot) end
return legacyNames(version)
end
-- Pure extraction of the launcher's per-slot summary from a decoded save,
-- factored out so it is unit-testable with no filesystem: the player name
-- (nil for an empty slot) and { badges, timeText, dexCount } -- the same
-- fields the title screen's ContinueInfo derives. Badges resolve against
-- the vanilla gym list (launcher has no loaded Data), which is what the
-- flat launcher meta line needs.
function SaveData.slotSummary(save)
if type(save) ~= "table" then return nil, nil end
local name = save.player and save.player.name or nil
local dexCount = 0
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
dexCount = dexCount + 1
end
local t = math.floor(save.playTime or 0)
local timeText = ("%d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60)
return name, {
badges = Badges.count(nil, save),
timeText = timeText,
dexCount = dexCount,
}
end
-- Slots visible to the launcher: every registered slot for a version, each
-- with whether it holds a save and the cheap summary above. A fresh
-- install with nothing registered returns an empty array; a legacy install
-- is migrated to slot1 first.
function SaveData.listSlots(version)
version = version or GameVersion.get()
if not knownVersion(version) then return {} end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local opts = SaveData.loadOptions(fs)
local reg = opts.saveSlots and opts.saveSlots[version]
local list = (reg and reg.list) or {}
local out = {}
for _, id in ipairs(list) do
local save = decodeSlot(fs, version, id)
local name, meta = SaveData.slotSummary(save)
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta }
end
return out
end
-- Point the active slot at slotId (registering it if new) and persist the
-- choice to options.lua; also update the process-global cache so the very
-- next save/load lands in the chosen slot.
function SaveData.setActiveSlot(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
local fs = persistFs(nil)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version] or { list = {}, active = nil }
local found = false
for _, id in ipairs(reg.list) do
if id == slotId then found = true break end
end
if not found then reg.list[#reg.list + 1] = slotId end
reg.active = slotId
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
slotsChecked[version] = true
activeSlotCache[version] = slotId
return slotId
end
-- Register a new empty slot for the version and return its id. Does NOT
-- write a save file and does NOT change the active slot: an empty slot
-- means the title screen offers NEW GAME only. Ids are "slot%d+",
-- allocated one past the highest existing number so a reused id can never
-- collide with a lingering file.
function SaveData.createSlot(version)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version] or { list = {}, active = nil }
local maxN = 0
for _, id in ipairs(reg.list) do
local n = tonumber(tostring(id):match("^slot(%d+)$"))
if n and n > maxN then maxN = n end
end
local id = "slot" .. (maxN + 1)
reg.list[#reg.list + 1] = id
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
return id
end
-- The active slot id in use for a version (resolved once per process like
-- saveNames does), or nil when none is registered and the flat legacy path is
-- in use. Public so the launcher's save Import/Export glue can name an export
-- after the slot it came from without reaching into the private cache.
function SaveData.activeSlot(version)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
return activeSlotCache[version] or nil
end
-- Write saveTable into an existing slot's file (SaveSerializer.encode), through
-- the same fs seam every other save/load call uses (so portable mode keeps
-- working) and the same .tmp-witness / .bak recovery discipline SaveData.save
-- uses for the flat path. Used by the launcher's save-import glue, which has
-- already registered the slot via createSlot but written no bytes yet; unlike
-- SaveData.save this targets a specific slot and never rebuilds meta or touches
-- options. Returns true, or false + an error string on a failed write.
function SaveData.writeSlot(version, slotId, saveTable)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
if type(slotId) ~= "string" then return false, "missing slot id" end
if type(saveTable) ~= "table" then return false, "missing save table" end
local main, bak, tmp = slotNames(version, slotId)
local encoded = SaveSerializer.encode(saveTable)
local fs = persistFs(nil)
ensureParentDir(fs, main)
if fs.getInfo(main) then
local prev = fs.read(main)
if prev then fs.write(bak, prev) end
end
local ok, err = fs.write(tmp, encoded)
if not ok then return false, err end
remove(fs, main)
ok, err = fs.write(main, encoded)
if not ok then return false, err end
remove(fs, tmp)
return true
end
-- Test seam: drop the process-global slot cache so a suite can exercise
-- migration/resolution against a freshly injected filesystem. Unused by
-- the game, which resolves each version exactly once per boot.
function SaveData.resetSlotState()
for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end
for k in pairs(slotsChecked) do slotsChecked[k] = nil end
end
-- ------- meta
-- the version/engine/mod-set stamp every v2 save carries; mods is the
@@ -525,6 +802,9 @@ function SaveData.save(data, mods)
end
local encoded = SaveSerializer.encode(gameOnly)
local fs = persistFs(nil)
-- the active slot may live in saves/<version>/, which must exist before
-- the .tmp/.bak/main writes land (a no-op for the flat legacy path)
ensureParentDir(fs, FILENAME)
if fs.getInfo(FILENAME) then
local prev = fs.read(FILENAME)
if prev then fs.write(BACKUP_FILENAME, prev) end
+10 -2
View File
@@ -4,14 +4,22 @@
-- tests.
local Version = {
engine = "1.0.0", -- game/engine release (semver triple)
engine = "0.0.0-dev", -- game/engine release (semver). Repo default is the
-- "-dev" placeholder; CI stamps the real X.Y.Z into
-- the packed game.love only, never the working tree.
shell = 1, -- native-shell contract this build implements
minShell = 1, -- lowest shell contract that can RUN this payload.
-- Bump only when a payload needs a newer native
-- binary (e.g. a LOVE version bump); an older shell
-- refuses to chainload a payload whose minShell
-- exceeds the shell it provides.
modApi = 2, -- mod API major (manifest `api`)
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
saveFormat = 4, -- save.meta.format
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
}
-- "gen1recomp v1.0.0"
-- "gen1recomp v0.0.0-dev" (or the stamped release version in shipped builds)
function Version.title(base)
return (base or "gen1recomp")
.. " v" .. Version.engine
+2
View File
@@ -375,6 +375,8 @@ function RomExtractor:extractMaps()
}
self:tick("Maps", mapIndex, #keys)
end
-- keep the extract ROM-faithful; Data:seedDefaults applies the #189
-- S.S. Anne 1F cabin reorder at load time
self:write("maps", out)
return out
end
+1516 -290
View File
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
-- SaveFileIO -- the launcher's glue between a raw Gen1 .sav battery image and
-- this project's save slots. Keeps RomImporter lean: the SAVE FILES card just
-- calls importToSlot / exportActiveSlot and renders the {ok, result} outcome.
--
-- Import reads bytes (an absolute picker path, a dropped LOVE file, or raw
-- bytes), runs them through SaveConvert.importSav (32768-byte + checksum
-- validated), then registers a fresh slot, writes it, and makes it active.
-- Export loads the active slot, encodes it back to a 32768-byte SRAM image, and
-- drops it in the save directory's exports/ folder, returning the absolute path
-- so the launcher can offer an "open folder" affordance.
--
-- Every failure returns false + a friendly one-line message (never raises), so
-- the card can surface it as a red notice line rather than crashing.
local SaveConvert = require("src.save_convert.SaveConvert")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local SaveFileIO = {}
local SAVE_SIZE = SaveConvert.SAVE_SIZE
-- Resolve raw save bytes from whatever the launcher hands us:
-- * a LOVE DroppedFile (a table/userdata with :open/:read/:getSize), read the
-- way RomImporter reads a dropped ROM;
-- * a raw 32768-byte string (the tests and the in-memory path) used as-is;
-- * any other string treated as an absolute picker path opened with io.open.
-- A picker path is never 32768 bytes long, so the length test disambiguates it
-- from a raw image cleanly. Returns bytes, or nil + an error string.
local function readSource(source)
local t = type(source)
if t == "table" or t == "userdata" then
if type(source.read) ~= "function" then
return nil, "that file could not be read"
end
local ok, openErr = source:open("r")
if not ok then return nil, "could not open the dropped file: " .. tostring(openErr) end
local data, readErr = source:read(source:getSize())
source:close()
if not data then return nil, "could not read the dropped file: " .. tostring(readErr) end
return data
end
if t ~= "string" then
return nil, "no save file was provided"
end
if #source == SAVE_SIZE then
return source
end
local f, openErr = io.open(source, "rb")
if not f then return nil, "could not read the save file: " .. tostring(openErr) end
local data = f:read("*a")
f:close()
if type(data) ~= "string" then return nil, "the save file was empty" end
return data
end
-- importToSlot(source, version) -> ok, slotIdOrErr
-- source: an absolute path, a LOVE DroppedFile, or raw 32768 bytes. On success
-- registers a new slot for the version, writes the imported save into it, makes
-- it the active slot, and returns true + the new slot id. On any failure
-- returns false + a friendly message.
function SaveFileIO.importToSlot(source, version)
version = version or GameVersion.get()
local bytes, readErr = readSource(source)
if not bytes then return false, readErr end
if #bytes ~= SAVE_SIZE then
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
:format(SAVE_SIZE, #bytes)
end
local save, convertErr = SaveConvert.importSav(bytes, version)
if not save then return false, convertErr end
-- Tag the game version and normalize the meta stamp: SaveConvert leaves
-- meta.format = "gen1_import", but SaveData.load's migration pass compares
-- the format numerically, so re-stamp it to the current format (the imported
-- table is already current-shaped, so no migration is skipped by doing so).
save.version = version
save.meta = SaveData.buildMeta(nil, save.meta)
local slotId = SaveData.createSlot(version)
if not slotId then return false, "this game has no save slots to import into" end
local ok, writeErr = SaveData.writeSlot(version, slotId, save)
if not ok then
return false, "could not write the imported save: " .. tostring(writeErr)
end
SaveData.setActiveSlot(version, slotId)
return true, slotId
end
-- exportActiveSlot(version) -> ok, pathOrErr
-- Loads the version's active slot save (SaveData.load semantics), encodes it
-- back to a 32768-byte SRAM image, and writes it to
-- exports/gen1recomp-<version>-<slotId>.sav in the save directory (created if
-- absent). Returns true + the absolute path on success, false + a friendly
-- message otherwise.
function SaveFileIO.exportActiveSlot(version)
version = version or GameVersion.get()
local save = SaveData.load(version)
if not save then return false, "this game has no save to export yet" end
local bytes, exportErr = SaveConvert.exportSav(save)
if not bytes then return false, exportErr end
local slotId = SaveData.activeSlot(version) or "save"
local fs = love and love.filesystem
if not (fs and fs.write) then return false, "no filesystem available to export to" end
if fs.createDirectory then fs.createDirectory("exports") end
local rel = ("exports/gen1recomp-%s-%s.sav"):format(version, slotId)
local ok, writeErr = fs.write(rel, bytes)
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
if base ~= "" then return true, base .. "/" .. rel end
return true, rel
end
return SaveFileIO
+4 -3
View File
@@ -114,10 +114,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local itemDef = data.items[itemId]
local name = itemDef and itemDef.name or itemId
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase
-- all refuse mid-battle (jp nz, ItemUseNotTime)
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase /
-- ItemUseTMHM all refuse mid-battle (jp nz, ItemUseNotTime)
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
or itemId == "RARE_CANDY" or itemId == "COIN_CASE") then
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
or (itemDef and itemDef.machine)) then
return "failed", { "OAK: " .. save.player.name
.. "!\nThis isn't the\ntime to use that!" }
end
+4
View File
@@ -501,6 +501,10 @@ function LinkState:updateTrade(input)
require("src.core.Sound").play(game.data, "Trade_Machine")
Screens.push(game, "TradeAnim", {
sent = sent, received = received,
enemyName = (self.peerName or "TRAINER"),
playerOt = game.save.player.name,
playerOtId = sent.otId or game.save.player.id,
enemyOtId = received.otId,
onDone = function()
game.stack:push(TextBox.new(game,
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
+335
View File
@@ -0,0 +1,335 @@
-- Launcher-side mod surface (18/launcher redesign): the mods panel runs
-- BEFORE Game:load, so this NEVER loads a mod entry chunk -- it scans
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real
-- load at boot; this reads the same options.mods enable-state the loader
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
-- and installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree.
--
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
-- no filesystem, so the engine tier can table-drive it; the discovery and
-- install paths reach for love.filesystem and SaveData.
local Manifest = require("src.mods.Manifest")
local ManagerState = require("src.mods.ManagerState")
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
local SaveData = require("src.core.SaveData")
local LauncherMods = {}
-- ------- pure status derivation
-- A hard-dependency / conflict / version verdict for one manifest. mods is
-- the id -> validated-manifest map resolveToggle reads (its dependencySpecs,
-- conflictSpecs, version and game_version are exactly the fields the loader's
-- Manifest.validate produced); enabledSet is the current desired enable-set.
local function statusFor(mods, id, enabledSet, enabled)
local m = mods[id]
-- conflict only bites an enabled mod: resolveToggle's conflict list is
-- bidirectional (this mod's conflicts spec vs an enabled other, and an
-- enabled other's spec vs this mod), which is exactly the launcher chip.
if enabled then
local r = ManagerState.resolveToggle(mods, id, true, enabledSet)
if #r.conflicts > 0 then
local otherId = r.conflicts[1]
local other = mods[otherId]
return "conflict",
"Conflicts with " .. ((other and other.name) or otherId)
end
end
-- warn: the engine is outside the mod's game_version range
if m.game_version
and not Semver.satisfies(Version.engine, m.game_version) then
return "warn", "Needs engine " .. m.game_version
.. " (have " .. Version.engine .. ")"
end
-- warn: a hard dependency is absent, switched off, or the wrong version.
-- resolveToggle would cascade-enable a merely-disabled dep rather than flag
-- it, so the disabled case is judged straight off the manifest here.
for _, spec in ipairs(m.dependencySpecs or {}) do
local dep = mods[spec.id]
if not dep then
return "warn", "Needs " .. spec.id .. " (not installed)"
elseif not enabledSet[spec.id] then
return "warn", "Needs " .. spec.id .. " (disabled)"
elseif spec.range
and not Semver.satisfies(dep.version, spec.range) then
return "warn", "Needs " .. spec.id .. " " .. spec.range
end
end
return "ok", "Ready"
end
-- deriveList(manifests, options) -> the panel row list, pure.
-- manifests is an array of validated manifests (Manifest.validate output);
-- options is the options table (only options.mods is read). Rows come back
-- sorted by id so the panel order is stable.
function LauncherMods.deriveList(manifests, options)
local mods = options and options.mods or {}
local ordered = {}
for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end
table.sort(ordered, function(a, b) return a.id < b.id end)
local byId, enabledSet = {}, {}
for _, m in ipairs(ordered) do
byId[m.id] = m
-- missing entry means enabled, matching the loader's default
if mods[m.id] ~= false then enabledSet[m.id] = true end
end
local out = {}
for _, m in ipairs(ordered) do
local enabled = enabledSet[m.id] == true
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
local raw = m.raw or {}
out[#out + 1] = {
id = m.id,
name = m.name or m.id,
version = m.version,
-- category, then profile, then a generic fallback -- uppercased
badge = tostring(raw.category or m.profile or "MOD"):upper(),
description = m.description or "",
enabled = enabled,
status = status,
statusDetail = detail,
}
end
return out
end
-- locateRoot(paths) -> the mod-root prefix inside a mounted archive, pure.
-- paths is a shallow listing: top-level file names as-is, and for a top-level
-- directory a "<dir>/manifest.json" entry when it holds one. Returns "" when
-- the manifest sits at the archive root, "<dir>" when a single top-level
-- folder holds it, or nil + a user-presentable reason.
function LauncherMods.locateRoot(paths)
for _, p in ipairs(paths) do
if p == "manifest.json" then return "" end
end
local topDirs, seen, hasManifest = {}, {}, {}
for _, p in ipairs(paths) do
local top, rest = p:match("^([^/]+)/(.+)$")
if top then
if not seen[top] then
seen[top] = true
topDirs[#topDirs + 1] = top
end
if rest == "manifest.json" then hasManifest[top] = true end
end
end
if #topDirs == 1 and hasManifest[topDirs[1]] then return topDirs[1] end
if #topDirs > 1 then
return nil, "the .zip must contain a single mod folder"
end
return nil, "no manifest.json found in the .zip"
end
-- ------- discovery (love.filesystem)
local function decodeManifest(raw, path)
local Json = require("src.link.Json")
local data, decodeErr = Json.decode(raw)
if not data then return nil, decodeErr end
local ok, manifest = pcall(Manifest.validate, data, path)
if not ok then return nil, manifest end
return manifest
end
-- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover,
-- but validates only -- no entry chunk is ever loaded). First id wins on a
-- duplicate. Returns an array of validated manifests.
local function discover()
local fs = love and love.filesystem
local out = {}
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
if not fs.getInfo("mods") then return out end
local seen = {}
for _, name in ipairs(fs.getDirectoryItems("mods")) do
local path = "mods/" .. name
local info = fs.getInfo(path)
if info and info.type == "directory" then
local raw = fs.read(path .. "/manifest.json")
if raw then
local manifest = decodeManifest(raw, path)
if manifest and not seen[manifest.id] then
seen[manifest.id] = true
out[#out + 1] = manifest
end
end
end
end
return out
end
-- list() -> the mods-panel rows for the current install. Reads the same
-- options.mods enable-state the loader persists, so a toggle here is what the
-- game sees on its next boot.
function LauncherMods.list()
local options = SaveData.loadOptions()
return LauncherMods.deriveList(discover(), options)
end
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
-- Loader:_saveState writes (a plain boolean), so the running game and the
-- in-game ManagerState pick it up unchanged.
function LauncherMods.setEnabled(id, enabled)
local options = SaveData.loadOptions()
options.mods = options.mods or {}
options.mods[id] = enabled and true or false
SaveData.saveOptions(options)
return true
end
-- ------- install (love.filesystem)
-- Read a .zip source into bytes. A string is an external absolute path (like
-- a chosen ROM) read with io.*, falling back to a save-dir-relative
-- love.filesystem read; a love DroppedFile is opened the way RomImporter
-- ingests dropped ROMs.
local function readArchive(source)
local t = type(source)
if (t == "userdata" or t == "table") and type(source.open) == "function" then
local ok = source:open("r")
if not ok then return nil, "could not open the dropped file" end
local data = source:read(source:getSize())
source:close()
if not data then return nil, "the dropped file could not be read" end
return data
end
if t == "string" then
local f = io.open(source, "rb")
if f then
local data = f:read("*a")
f:close()
if not data then return nil, "could not read " .. source end
return data
end
if love and love.filesystem then
local data = love.filesystem.read(source)
if data then return data end
end
return nil, "could not open " .. source
end
return nil, "unsupported archive source"
end
-- Shallow listing of a mounted archive shaped for locateRoot: files by name,
-- and for each top-level directory a "<dir>/manifest.json" marker only when it
-- actually holds one (so a lone folder with no manifest still reads as empty).
local function topLevelPaths(mount)
local fs = love.filesystem
local paths = {}
for _, name in ipairs(fs.getDirectoryItems(mount)) do
local info = fs.getInfo(mount .. "/" .. name)
if info and info.type == "directory" then
if fs.getInfo(mount .. "/" .. name .. "/manifest.json", "file") then
paths[#paths + 1] = name .. "/manifest.json"
end
else
paths[#paths + 1] = name
end
end
return paths
end
local function copyTree(src, dst)
local fs = love.filesystem
if not fs.createDirectory(dst) then
return nil, "could not create " .. dst
end
for _, name in ipairs(fs.getDirectoryItems(src)) do
local s = src .. "/" .. name
local d = dst .. "/" .. name
local info = fs.getInfo(s)
if info and info.type == "directory" then
local ok, err = copyTree(s, d)
if not ok then return nil, err end
else
local data = fs.read(s)
if data == nil then return nil, "could not read " .. name end
local ok, err = fs.write(d, data)
if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end
end
end
return true
end
local function removeTree(path)
local fs = love.filesystem
local info = fs.getInfo(path)
if not info then return end
if info.type == "directory" then
for _, child in ipairs(fs.getDirectoryItems(path)) do
removeTree(path .. "/" .. child)
end
end
fs.remove(path)
end
-- installZip(source) -> true, id | nil, errString
-- source is an external path or a love DroppedFile. The archive is validated
-- BEFORE anything is copied; every path unmounts and clears the staged temp
-- file, and a failed copy rolls its partial tree back. A dropped file outside
-- the save dir is staged into a save-dir temp first, because
-- love.filesystem.mount only reaches a save-directory-relative path.
function LauncherMods.installZip(source)
if not (love and love.filesystem) then
return nil, "mod install needs LOVE"
end
local fs = love.filesystem
local data, readErr = readArchive(source)
if not data then return nil, readErr end
-- stage into a save-dir temp so mount can reach it
local tmp = ("mod_import_%d_%d.zip"):format(os.time(), math.random(0, 999999))
local ok, writeErr = fs.write(tmp, data)
if not ok then
return nil, "could not stage the .zip: " .. tostring(writeErr)
end
local mount = "mod_import_mount"
if not fs.mount(tmp, mount) then
fs.remove(tmp)
return nil, "that .zip could not be opened"
end
local function cleanup()
pcall(fs.unmount, tmp)
fs.remove(tmp)
end
local prefix, rootErr = LauncherMods.locateRoot(topLevelPaths(mount))
if not prefix then
cleanup()
return nil, rootErr
end
local root = prefix == "" and mount or (mount .. "/" .. prefix)
local raw = fs.read(root .. "/manifest.json")
if not raw then
cleanup()
return nil, "the .zip has no readable manifest.json"
end
local manifest, manifestErr = decodeManifest(raw, root)
if not manifest then
cleanup()
return nil, "invalid mod manifest: " .. tostring(manifestErr)
end
-- reject a duplicate before touching the mods tree
local dest = "mods/" .. manifest.id
if fs.getInfo(dest) then
cleanup()
return nil, "a mod named '" .. manifest.id .. "' is already installed"
end
fs.createDirectory("mods")
local copied, copyErr = copyTree(root, dest)
if not copied then
removeTree(dest)
cleanup()
return nil, copyErr or "could not copy the mod files"
end
cleanup()
return true, manifest.id
end
return LauncherMods
+131
View File
@@ -40,6 +40,19 @@ function f.int(min, max)
end }
end
-- a bounded float; like f.int but keeps the fractional part (scales,
-- gains). Rejects out-of-range with the "expected number a..b" message.
function f.numRange(min, max)
local desc = "number"
if min and max then desc = ("number %s..%s"):format(min, max)
elseif min then desc = ("number >= %s"):format(min) end
return { kind = "num", min = min, max = max, desc = desc,
check = function(v)
return type(v) == "number"
and (min == nil or v >= min) and (max == nil or v <= max)
end }
end
function f.enum(values)
local set = {}
for _, value in ipairs(values) do set[value] = true end
@@ -420,6 +433,12 @@ R.pokemon = {
frames = f.opt(f.int(1)) } }),
cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")),
trueColor = f.opt(f.bool),
-- battle-pic scale overrides for this species' own pics: front is the
-- enemy pic (default 1x), back is the player pic (default 2x). An
-- image-level battle_sprite_scales entry for the same path beats these.
-- The pic stays grounded (feet pinned) at any scale; see docs/modding.md.
battleScaleFront = f.opt(f.numRange(0.25, 4.0)),
battleScaleBack = f.opt(f.numRange(0.25, 4.0)),
},
example = 'mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } })',
}
@@ -791,6 +810,118 @@ R.transitions = {
example = 'mod.content.transitions:register("dissolve", { frames = 30, draw = fn })',
}
-- ------- rendering pipelines
--
-- A pipeline is a display mode that owns part of the frame: it may replace
-- the overworld's world pass with geometry of its own (drawWorld) and/or
-- post-process the finished composite (present). Everything around that --
-- the OFF/1/2/3 ladder, its options row, its hotkey, persistence in
-- save.options.pipelines and the gating that keeps it out of battles and
-- menus -- is engine plumbing driven from this record, so a renderer mod
-- declares what it is and writes only the two draw functions.
--
-- Both callbacks are optional and independent: a present-only pipeline is a
-- post-process (bloom, tilt-shift, a CRT curve) that leaves whatever
-- rendered the frame alone, and a drawWorld-only pipeline is a world
-- renderer that composites straight. See src/render/Pipelines.lua for the
-- ctx each receives and docs/modding.md for the worked example.
R.render_pipelines = {
semantics = "record", target = "render_pipelines",
fields = {
-- shown in the options menu; the ladder labels default to OFF/ON
label = f.str,
levels = f.opt(f.list(f.str)),
-- keyboard key that cycles the ladder, checked after the engine's own
-- display hotkeys so a pipeline can never shadow one
hotkey = f.opt(f.str),
-- higher wins when two world pipelines are somehow active at once;
-- also the options-row order, so a mode and its post-process sort
-- together instead of by id
priority = f.opt(f.num),
-- hardware/driver gate, checked every frame: false keeps the vanilla
-- 2D path, which is what a headless run and a driver with no depth
-- canvas both get
available = f.opt(f.fn),
-- (top, overworld) -> boolean: whether the player may CHANGE the mode
-- right now. Defaults to the survey-zoom gate (free-roam overworld
-- only), which keeps a hotkey press from switching modes mid-warp or
-- mid-cutscene. It has no say over whether an already-on mode draws:
-- a mode that stopped rendering during a warp would flash the flat 2D
-- world every time the player walked through a door.
gate = f.opt(f.fn),
-- (dt, level): presentational tweens, ticked on real frame time
update = f.opt(f.fn),
-- (ctx) -> canvas | nil: render the world. nil falls back to the
-- vanilla flat/tilt draw for this frame.
drawWorld = f.opt(f.fn),
-- (canvas, ctx) -> canvas: post-process the WORLD image, before the UI
-- composites over it -- a depth-of-field or colour grade that must not
-- touch the dialog boxes and menus sitting on top. Only runs when some
-- pipeline rendered the world, since the vanilla world pass has no
-- single finished image to hand over.
worldPresent = f.opt(f.fn),
-- (canvas, ctx) -> canvas: post-process the whole finished composite,
-- world and UI alike (a CRT curve, a full-screen grade). Must return a
-- canvas; the input unchanged is the correct answer when the effect is
-- off.
present = f.opt(f.fn),
-- drop GPU objects (window resize, hot reload, mode switch)
invalidate = f.opt(f.fn),
},
-- a pipeline that does neither half is dead weight and would silently
-- occupy an options row and a hotkey
extra = function(_, value)
if value.drawWorld == nil and value.present == nil
and value.worldPresent == nil then
return "a render pipeline needs drawWorld, worldPresent or present"
end
end,
-- A pipeline callback fails at play time, long after the load phase has
-- handed its report to the mod manager, so the merge leaves behind who
-- wrote each record for Pipelines to name in the failure -- the same
-- provenance trick the audio registries use (Loader.stampAudioOwners).
-- Placement is otherwise the default record merge.
write = function(target, registry)
local owners, tombstones = {}, {}
for id in pairs(registry.ops) do
local value = registry:get(id)
if value == nil then
tombstones[#tombstones + 1] = id
else
target[id] = value
local owner = registry.owners[id]
if owner and owner ~= Schemas.ENGINE then owners[id] = owner end
end
end
for _, id in ipairs(tombstones) do target[id] = nil end
target._owners = owners
end,
example = 'mod.content.render_pipelines:register("voxel", ' ..
'{ label = "VOXEL", levels = { "OFF", "15", "35", "50" }, drawWorld = fn })',
}
-- ------- battle sprite scales
--
-- Per-image battle-pic scale overrides, keyed by record id and consulted
-- by asset path at draw time. Where a species' battleScaleFront /
-- battleScaleBack scales its own front/back pic, this scales ANY battle
-- pic by the path it is drawn from -- the only handle on the non-species
-- pics like the player's trainer back sprite. Image-level beats
-- species-level; both compose with the send-out grow and keep the sprite
-- grounded (feet pinned) at whatever scale. See docs/modding.md.
R.battle_sprite_scales = {
semantics = "record", target = "battle_sprite_scales",
fields = {
-- the asset path exactly as data references it, e.g.
-- "assets/generated/battle/back/abrab.png"
path = f.path,
-- 1 = native pixels; the drawn size relative to the pic's own pixels
scale = f.numRange(0.25, 4.0),
},
example = 'mod.content.battle_sprite_scales:register("abra_back", ' ..
'{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })',
}
-- ------- progression
R.evolution_methods = {
+28
View File
@@ -29,6 +29,34 @@ function Pokemon.movesAtLevel(speciesDef, level)
return moves
end
-- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare):
-- grant learnset moves with startLevel < moveLevel <= newLevel, shifting
-- the oldest slot out when full. Silent — no LearnMove prompts.
function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel)
if not (speciesDef and speciesDef.learnset and mon) then return end
mon.moves = mon.moves or {}
for _, entry in ipairs(speciesDef.learnset) do
local moveLevel = entry.level
if moveLevel > newLevel then break end
if moveLevel > startLevel then
local known = false
for _, mv in ipairs(mon.moves) do
if mv.id == entry.move then known = true break end
end
if not known then
local mdef = data.moves[entry.move]
local slot = { id = entry.move, pp = mdef and mdef.pp or 0 }
if #mon.moves < 4 then
mon.moves[#mon.moves + 1] = slot
else
table.remove(mon.moves, 1)
mon.moves[#mon.moves + 1] = slot
end
end
end
end
end
function Pokemon.new(data, species, level, rng)
local def = data.pokemon[species]
assert(def, "unknown species " .. tostring(species))
+423
View File
@@ -0,0 +1,423 @@
-- Rendering pipelines: the engine side of the render_pipelines registry.
--
-- A pipeline is a display mode a mod owns. It may replace the overworld's
-- world pass with geometry of its own (drawWorld) and/or post-process the
-- finished composite (present). Everything else about being a display mode
-- -- the OFF/1/2/3 ladder, the options row, the hotkey, persistence, the
-- free-roam gate, and never letting a mod's error take the frame down -- is
-- engine plumbing and lives here, so a renderer mod writes the two draw
-- functions and declares the rest.
--
-- The two halves compose independently and in priority order: the highest
-- priority eligible drawWorld renders the world, then every eligible
-- present folds over whatever came out (the world pipeline's canvas, or the
-- vanilla flat/tilt composite when none ran). A present that is switched
-- off returns its input, so a full ladder of them costs nothing at level 0.
--
-- Nothing here reaches collision, movement, triggers or scripts: like
-- survey zoom and tilt, a pipeline is purely presentational, which is why
-- its level rides in save.options rather than the save proper.
--
-- Spec: docs/modding.md (rendering pipelines)
local Data = require("src.core.Data")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Zoom = require("src.render.Zoom")
local Pipelines = {}
-- id -> level. Levels live here rather than on the records because the
-- records are merged content: frozen after load, and shared with whatever
-- else reads Data.
local levels = {}
-- ids whose callbacks have already thrown, so a pipeline that fails every
-- frame reports once instead of filling the log at 60Hz
local broken = {}
Pipelines.DEFAULT_LEVELS = { "OFF", "ON" }
-- The merged dataset the records live in. The boot singleton is the
-- default -- Game.data is that very table -- and install() lets a headless
-- caller (the SDK harness, a tool) point this at a dataset of its own.
local source = Data
function Pipelines.install(data)
source = data or Data
Pipelines.reset()
end
-- ------- catalog
-- Every registered pipeline as { id = ..., def = ... }, ordered by priority
-- (descending, ties by id) so selection, the options rows and the present
-- fold all walk the same sequence.
--
-- Memoized on the namespace table's identity. Content freezes at the merge
-- boundary, so the answer cannot change for a given table -- and this is
-- read several times per frame by update(), worldPipeline() and the
-- endFrame present check, which is no place to allocate and sort. An empty
-- list is cached too, so a mod-free boot pays one table for the process.
local listCache, listSource = nil, nil
function Pipelines.list()
local defs = source and source.render_pipelines
if defs == listSource and listCache then return listCache end
local out = {}
if type(defs) == "table" then
for id, def in pairs(defs) do
-- the merge writes provenance under _owners; skip the bookkeeping
-- keys rather than treating them as pipelines
if type(id) == "string" and id:sub(1, 1) ~= "_" and type(def) == "table" then
out[#out + 1] = { id = id, def = def }
end
end
table.sort(out, function(a, b)
local pa, pb = a.def.priority or 0, b.def.priority or 0
if pa ~= pb then return pa > pb end
return a.id < b.id
end)
end
listCache, listSource = out, defs
return out
end
function Pipelines.get(id)
local defs = source and source.render_pipelines
local def = type(defs) == "table" and defs[id] or nil
return type(def) == "table" and def or nil
end
-- the mod that registered a pipeline, so a runtime failure lands in the
-- feed the mod manager shows instead of only in the console
local function ownerOf(id)
local defs = source and source.render_pipelines
local owners = type(defs) == "table" and defs._owners or nil
return owners and owners[id] or nil
end
-- Run one of a pipeline's callbacks under pcall. A mod that throws mid-
-- frame must not take the frame with it: the pipeline is marked broken,
-- attributed once, and treated as absent from then on -- which degrades to
-- the vanilla 2D path rather than a black screen.
local function guard(id, fn, ...)
if broken[id] then return nil end
local ok, result = pcall(fn, ...)
if ok then return result end
broken[id] = true
Logger.error("render pipeline %s failed: %s -- disabled for this session",
id, tostring(result))
Runtime.reportError(ownerOf(id), "render pipeline failed: " .. tostring(result))
return nil
end
-- Whether a callback's return is a real Canvas we can composite. A mod that
-- forgets a return, or hands back a shade string / flag / number, must be
-- ignored rather than trusted -- draw() on a non-canvas takes the frame down.
-- Real LOVE canvases are userdata answering typeOf("Canvas"); the headless
-- test stub (tests/love_stub) fakes them as tables carrying the Canvas method
-- shape (love.graphics.newCanvas), so accept either and nothing else.
local function isCanvas(v)
if type(v) == "userdata" then
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
end
if type(v) == "table" then
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
end
return false
end
-- Dispatch a mod render callback with its GPU state fenced off: push("all")
-- before and pop() after, so a callback that returns cleanly but leaves a
-- shader bound, the canvas redirected, or blend/colour changed cannot corrupt
-- the engine composite that follows. guard() catches a callback that throws;
-- this catches one that dirties state. A pipeline already retired skips the
-- push/pop entirely, so the stack stays balanced.
local function guardRender(id, fn, ...)
if broken[id] then return nil end
love.graphics.push("all")
local out = guard(id, fn, ...)
love.graphics.pop()
return out
end
-- ------- levels
function Pipelines.levelLabels(id)
local def = Pipelines.get(id)
local labels = def and def.levels
if type(labels) ~= "table" or labels[1] == nil then
return Pipelines.DEFAULT_LEVELS
end
return labels
end
-- highest selectable level: one less than the label count, so a two-label
-- ladder is a plain OFF/ON toggle
function Pipelines.maxLevel(id)
return #Pipelines.levelLabels(id) - 1
end
function Pipelines.level(id)
return levels[id] or 0
end
function Pipelines.levelLabel(id, level)
local labels = Pipelines.levelLabels(id)
return labels[(level or Pipelines.level(id)) + 1] or labels[1] or "OFF"
end
-- A world pipeline and the engine's own tilt mode are two answers to the
-- same question, so switching one on switches the other off -- the rule
-- tilt and survey zoom already follow between themselves. Present-only
-- pipelines (post-processes) compose with tilt and are left alone.
local function excludeTilt(id, level)
local def = Pipelines.get(id)
if not (def and def.drawWorld) or level <= 0 then return end
local Tilt = require("src.render.Tilt")
if Tilt.level > 0 then Tilt.setLevel(0) end
-- one world pipeline at a time, for the same reason
for _, entry in ipairs(Pipelines.list()) do
if entry.id ~= id and entry.def.drawWorld and Pipelines.level(entry.id) > 0 then
levels[entry.id] = 0
end
end
end
function Pipelines.setLevel(id, level)
if not Pipelines.get(id) then return 0 end
level = math.floor(tonumber(level) or 0)
if level < 0 then level = 0 end
local max = Pipelines.maxLevel(id)
if level > max then level = max end
levels[id] = level
excludeTilt(id, level)
return level
end
-- Advance the ladder and wrap to OFF, the shape every display hotkey walks.
function Pipelines.cycle(id, dir)
local max = Pipelines.maxLevel(id)
if max < 1 then return 0 end
local span = max + 1
local target = (Pipelines.level(id) + (dir or 1)) % span
if target < 0 then target = target + span end
return Pipelines.setLevel(id, target)
end
-- Turning a world pipeline on must switch tilt off in the save too, not
-- just in the live module, or the next boot restores both. Call sites hand
-- over the options table so this stays the one place that rule lives.
function Pipelines.syncOptions(opts)
if type(opts) ~= "table" then return end
local bucket = opts.pipelines
if type(bucket) ~= "table" then
bucket = {}
opts.pipelines = bucket
end
for _, entry in ipairs(Pipelines.list()) do
bucket[entry.id] = Pipelines.level(entry.id)
if entry.def.drawWorld and Pipelines.level(entry.id) > 0 then
opts.tilt = 0
end
end
end
-- Restore levels from a loaded options table. A pipeline whose mod is gone
-- keeps its stored level untouched in the bucket (so re-enabling the mod
-- restores the mode) but contributes nothing while absent.
function Pipelines.applyOptions(opts)
local bucket = type(opts) == "table" and opts.pipelines or nil
levels = {}
broken = {}
local world = nil
for _, entry in ipairs(Pipelines.list()) do
local stored = type(bucket) == "table" and bucket[entry.id] or 0
local level = math.floor(tonumber(stored) or 0)
if level < 0 then level = 0 end
local max = Pipelines.maxLevel(entry.id)
if level > max then level = max end
-- list() is priority order, so the first world pipeline with a stored
-- level is the one that wins; the rest restore to OFF rather than
-- sitting on a level that can never render
if entry.def.drawWorld and level > 0 then
if world then level = 0 else world = entry.id end
end
levels[entry.id] = level
end
-- a restored world pipeline and tilt are two answers to the same
-- question; the pipeline wins, as it does at every place that sets one
if world then require("src.render.Tilt").setLevel(0) end
end
function Pipelines.reset()
levels = {}
broken = {}
end
-- ------- per-frame
-- Presentational tweens run on real frame time, like Tilt's. Every
-- pipeline ticks, not just the active ones: a mode easing back OUT still
-- has an angle to retire.
function Pipelines.update(dt)
for _, entry in ipairs(Pipelines.list()) do
if entry.def.update then
guardRender(entry.id, entry.def.update, dt, Pipelines.level(entry.id))
end
end
end
-- A pipeline may run this frame when it is switched on, has not thrown, and
-- its hardware gate says yes. `available` is consulted every frame rather
-- than cached: a driver that loses its depth canvas on a resize has to be
-- able to change its mind.
--
-- Deliberately NOT gated on the state stack. `gate` governs whether the
-- player may CHANGE the mode, never whether it draws -- a display mode that
-- stopped rendering during a warp, a scripted cutscene or an open menu
-- would flash the flat 2D world for those frames every time the player
-- walked through a door. Once a mode is on it renders until it is off.
function Pipelines.eligible(id)
local def = Pipelines.get(id)
if not def or broken[id] then return false end
if Pipelines.level(id) <= 0 then return false end
if def.available and guard(id, def.available) ~= true then return false end
return true
end
-- Whether the player may cycle this mode right now: the free-roam gate,
-- which keeps a hotkey press from switching modes mid-warp or mid-cutscene.
-- Input only -- see eligible() for why the draw path does not consult it.
function Pipelines.canToggle(id, top, overworld)
local def = Pipelines.get(id)
if not def then return false end
local gate = def.gate or Zoom.gateOK
return guard(id, gate, top, overworld) == true
end
-- The pipeline that owns the world pass right now, or nil for the vanilla
-- flat/tilt draw. Highest priority wins; the exclusion rules above mean
-- there is normally only one candidate anyway.
function Pipelines.worldPipeline()
for _, entry in ipairs(Pipelines.list()) do
if entry.def.drawWorld and Pipelines.eligible(entry.id) then
return entry.id, entry.def
end
end
return nil
end
-- Render the world through `id`. Returns the canvas to composite, or nil
-- when the pipeline declined this frame (nothing to draw, a transient
-- failure), which the caller treats as "fall back to the 2D path".
function Pipelines.drawWorld(id, ctx)
local def = Pipelines.get(id)
if not (def and def.drawWorld) then return nil end
return guardRender(id, def.drawWorld, ctx)
end
-- Fold every eligible world post-process over a pipeline's world image,
-- before the UI composites on top. This is where a depth-of-field or a
-- colour grade belongs when it must leave the dialog boxes and menus crisp;
-- `present` below is the whole-frame counterpart. Only reachable once some
-- pipeline rendered the world, so it is gated on the overworld state the
-- same way drawWorld is.
function Pipelines.worldPresent(canvas, ctx)
if canvas == nil then return nil end
for _, entry in ipairs(Pipelines.list()) do
if entry.def.worldPresent and Pipelines.eligible(entry.id) then
local out = guardRender(entry.id, entry.def.worldPresent, canvas, ctx)
-- accept only a real Canvas: a pass that returns a non-canvas (a
-- forgotten return, a shade string) is ignored, not folded in
if isCanvas(out) then canvas = out end
end
end
return canvas
end
-- Fold every eligible post-process over the finished frame. Present
-- pipelines are not gated on the overworld state -- a CRT curve or a colour
-- grade applies to menus and battles too -- so eligibility here is just
-- "switched on and available". A pass that returns a non-canvas is
-- ignored rather than trusted, so a mod cannot blank the screen by
-- forgetting a return.
function Pipelines.present(canvas, ctx)
if canvas == nil then return nil end
for _, entry in ipairs(Pipelines.list()) do
if entry.def.present and Pipelines.eligible(entry.id) then
local out = guardRender(entry.id, entry.def.present, canvas, ctx)
-- accept only a real Canvas: a pass that returns a non-canvas is
-- ignored (docstring above), so a mod cannot blank or crash the frame
-- by forgetting a return or handing back a truthy non-canvas
if isCanvas(out) then canvas = out end
end
end
return canvas
end
-- true when any present-only pass wants to run, so the composite path can
-- skip allocating a target it would not use
function Pipelines.wantsPresent()
for _, entry in ipairs(Pipelines.list()) do
if entry.def.present and Pipelines.eligible(entry.id) then return true end
end
return false
end
-- ------- input and UI
-- Cycle whichever pipeline claims `key`. Returns the id when one did, so
-- the caller knows the key was consumed. Checked after the engine's own
-- display hotkeys, so a mod can never shadow one.
function Pipelines.hotkey(key, top, overworld)
for _, entry in ipairs(Pipelines.list()) do
if entry.def.hotkey == key then
-- the gate belongs here and nowhere else: it stops the player
-- flipping modes mid-warp or mid-cutscene, and has no say over
-- whether an already-on mode draws
if Pipelines.canToggle(entry.id, top, overworld) then
Pipelines.cycle(entry.id)
return entry.id
end
return nil
end
end
return nil
end
-- Options rows for every registered pipeline, in the same priority order,
-- in the descriptor shape src/ui/OptionRows.lua renders.
function Pipelines.rows(game)
local rows = {}
for _, entry in ipairs(Pipelines.list()) do
local id = entry.id
rows[#rows + 1] = {
id = "pipeline:" .. id,
label = entry.def.label or id:upper(),
value = function() return Pipelines.levelLabel(id) end,
step = function(g, dir)
Pipelines.cycle(id, dir)
local opts = g and g.save and g.save.options
if opts then
Pipelines.syncOptions(opts)
-- the exclusion above may have switched tilt off; keep the live
-- module in step with the option it just wrote
require("src.render.Tilt").setLevel(opts.tilt or 0)
end
return true
end,
}
end
return rows
end
-- Drop every pipeline's GPU objects (window resize, hot reload).
function Pipelines.invalidate()
for _, entry in ipairs(Pipelines.list()) do
if entry.def.invalidate then guardRender(entry.id, entry.def.invalidate) end
end
end
return Pipelines
+76 -4
View File
@@ -9,12 +9,28 @@
local Zoom = require("src.render.Zoom")
local Tilt = require("src.render.Tilt")
local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local Renderer = {}
Renderer.WIDTH = 160
Renderer.HEIGHT = 144
-- Whether a value is a real Canvas we can composite. Real LOVE canvases are
-- userdata answering typeOf("Canvas"); the headless test stub fakes them as
-- tables carrying the Canvas method shape. A mod pipeline handing back a
-- non-canvas must be rejected before it reaches love.graphics.draw, which
-- would otherwise take the frame down with it.
local function isCanvas(v)
if type(v) == "userdata" then
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
end
if type(v) == "table" then
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
end
return false
end
-- Tilt mode: the upright billboard canvas is grown by this many world
-- pixels on every side beyond the ground world view, so a structure or
-- sprite standing near a view edge still draws in full instead of being
@@ -57,6 +73,22 @@ function Renderer:init()
-- the projected ground in endFrame; never touched while tilt is off.
self.uprightCanvas = nil
self.uprightActive = false
-- a render pipeline's finished world image, already at window resolution
-- (see src/render/Pipelines.lua). nil is "no pipeline rendered this
-- frame", which is every vanilla frame.
self.worldOverride = nil
end
-- Hand endFrame a pipeline's world image to composite instead of the world
-- canvas. Cleared every frame, so a pipeline that declines one frame falls
-- straight back to the 2D path rather than showing a stale image.
function Renderer:setWorldOverride(canvas)
-- Defensive: a pipeline that hands back a non-canvas (forgotten return, a
-- truthy sentinel) must not reach the worldOverride blit in endFrame, where
-- love.graphics.draw on it would crash the frame. Reject it and fall back
-- to the 2D path rather than trust it.
if canvas ~= nil and not isCanvas(canvas) then canvas = nil end
self.worldOverride = canvas
end
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
@@ -91,6 +123,7 @@ end
function Renderer:beginFrame(transparent)
self.worldActive = false
self.uprightActive = false
self.worldOverride = nil
-- warp-fade overlay from Transition (issue #121); cleared each frame so
-- a popped transition cannot leave a sticky black veil
self.worldFadeAlpha = nil
@@ -380,7 +413,11 @@ function Renderer:endFrame(zones, worldZones)
zones = withTrueColor(zones, "ui")
worldZones = withTrueColor(worldZones, "world")
local needPresent = GBCFX.active()
-- A post-process pipeline needs the whole composite in a canvas for the
-- same reason GBC FX does, so either one alone is enough to take the
-- present path; with neither, the frame draws straight to the screen
-- exactly as it always did.
local needPresent = GBCFX.active() or Pipelines.wantsPresent()
local present = nil
if needPresent then
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
@@ -441,7 +478,27 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setShader()
end
if self.worldActive then
if self.worldOverride then
-- A render pipeline already produced the whole world -- terrain,
-- characters and its own FX overlay -- as one window-resolution image,
-- so it composites with a straight 1:1 blit and the world canvas is
-- skipped entirely (nothing drew into it). The UI blit below still
-- runs, so dialogs, menus and the HUD sit on top as usual.
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpi, 1 / dpi)
love.graphics.setScissor()
-- the screen-space overlays the flat path draws over its composite
local fade = self.worldFadeAlpha
if fade and fade > 0 then
love.graphics.setColor(0, 0, 0, fade)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1)
end
if self.battleCascadeProg then
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S)
end
elseif self.worldActive then
local sp = Zoom.scale(Sp)
local s = sp / dpi
local wvw = self.worldCanvas:getWidth()
@@ -526,11 +583,26 @@ function Renderer:endFrame(zones, worldZones)
if present then
love.graphics.setCanvas()
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(present, Sp)
-- Post-process pipelines run over the finished composite -- world, UI
-- and all -- and before GBC FX, so a blur or colour grade is what the
-- LCD grid is then drawn over rather than something that smears the
-- grid itself. Each pass hands back a canvas; with none registered
-- this returns `present` unchanged and the frame is byte-identical.
local composed = Pipelines.present(present,
{ width = ww, height = wh, scale = Sp, dpi = dpi }) or present
if GBCFX.active() then
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(composed, Sp)
else
-- the present canvas only existed for the post-process, so put the
-- result on the screen at the same 1:1 unit mapping it was built at
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(composed, 0, 0)
end
end
self.worldActive = false
self.uprightActive = false
self.worldOverride = nil
PaletteFX.setPass(nil)
end
+27
View File
@@ -65,8 +65,12 @@ end
Assets.register(SpriteRenderer.invalidate)
-- exported: a render pipeline's own sprite geometry picks frames by the
-- same tables, so a 3D pose can never drift from the 2D one
local STAND = { down = 0, up = 1, left = 2, right = 2 }
local WALK = { down = 3, up = 4, left = 5, right = 5 }
SpriteRenderer.STAND = STAND
SpriteRenderer.WALK = WALK
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
@@ -83,6 +87,29 @@ function SpriteRenderer.new(spriteDef, seed)
return self
end
-- The image this sprite would draw from right now: the plain sheet, or the
-- OBP-recolored bake of it. Exposed so a render pipeline can texture its
-- own geometry from the very same image -- the geometry carries sheet pixel
-- coordinates rather than baked colors, so sharing this one resolver is
-- what makes palette modes and sprite-replacing mods apply to 2D and 3D
-- alike.
--
-- Deliberately free of draw's bookkeeping: markTrueColor and
-- markSpriteRedraw exist to patch up the screen-space zone shader, and a
-- pipeline that renders into its own canvas never runs through it. For the
-- same reason the OG-RED bake is returned unconditionally here rather than
-- only during a redraw pass -- there is no later pass to restore it.
function SpriteRenderer:resolveImage()
if self.def.trueColor then return self.image end
if PaletteFX.usesGbcPack() then
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then return getObpImage(self.def.image, colors, group) end
elseif PaletteFX.usesSpriteObp() then
return getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
end
return self.image
end
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
local function blitFrame(image, quad, x, y, flip, redraw)
+68 -23
View File
@@ -50,6 +50,7 @@ function TextBox.new(game, text, onDone, opts)
self.charIndex = 0
self.shown = {} -- visible lines (max 2), each a list of glyph codes
self.waiting = false
self.contAdvance = false
self.done = false
self.blink = 0
self:beginLine()
@@ -65,7 +66,11 @@ TextBox.TOKENS = {
PLAYER = function(game) return game.save.player.name or "RED" end,
RIVAL = function(game) return game.save.player.rival or "BLUE" end,
RAM = function(game, arg)
return arg == "wStringBuffer" and game.stringBuffer or nil
if arg == "wStringBuffer" then return game.stringBuffer end
if arg == "wBoxNumString" then return game.boxNumString end
-- SendNewMonToBox / _SentToBoxText reads the deposited nick here
if arg == "wBoxMonNicks" then return game.boxMonNicks end
return nil
end,
}
@@ -83,31 +88,55 @@ end
-- Split marked-up text into pages of lines. \v-scrolled lines become
-- additional lines on the same page (the box scrolls them).
-- pages.contBefore[p][i] is true when line i was preceded by \v (cont):
-- pokered ContText waits for A/B + ▼ before scrolling that line in.
function TextBox.paginate(text, maxCols)
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS
local pages = {}
local contBefore = {}
local function pushLine(lines, conts, line, wait)
while #line > maxCols do
local cut = maxCols
for i = maxCols, 1, -1 do
if line:sub(i, i) == " " then cut = i break end
end
table.insert(lines, line:sub(1, cut))
table.insert(conts, wait)
wait = false
line = line:sub(cut + 1)
end
table.insert(lines, line)
table.insert(conts, wait)
end
for pageText in (text .. "\f"):gmatch("(.-)\f") do
if pageText ~= "" then
local lines = {}
for chunk in (pageText .. "\n"):gmatch("(.-)[\n\v]") do
local line = chunk
-- wrap long lines defensively (the source rarely needs it)
while #line > maxCols do
local cut = maxCols
for i = maxCols, 1, -1 do
if line:sub(i, i) == " " then cut = i break end
end
table.insert(lines, line:sub(1, cut))
line = line:sub(cut + 1)
local lines, conts = {}, {}
local pos, waitNext = 1, false
while true do
local npos = pageText:find("[\n\v]", pos)
if not npos then
pushLine(lines, conts, pageText:sub(pos), waitNext)
break
end
table.insert(lines, line)
pushLine(lines, conts, pageText:sub(pos, npos - 1), waitNext)
waitNext = pageText:sub(npos, npos) == "\v"
pos = npos + 1
end
if lines[#lines] == "" then
table.remove(lines)
table.remove(conts)
end
if #lines > 0 then
table.insert(pages, lines)
table.insert(contBefore, conts)
end
-- drop trailing empty line from the final gmatch round
if lines[#lines] == "" then table.remove(lines) end
if #lines > 0 then table.insert(pages, lines) end
end
end
if #pages == 0 then pages = { { "" } } end
if #pages == 0 then
pages = { { "" } }
contBefore = { { false } }
end
pages.contBefore = contBefore
return pages
end
@@ -179,10 +208,17 @@ function TextBox:update(dt)
if input:wasPressed("a") or input:wasPressed("b") then
require("src.core.Sound").play(self.game.data, "Press_AB")
self.waiting = false
self.shown = {}
self.pageIndex = self.pageIndex + 1
self.lineIndex = 1
self:beginLine()
if self.contAdvance then
-- ContText / ManualTextScroll: keep the box, scroll one line
self.contAdvance = false
self.lineIndex = self.lineIndex + 1
self:beginLine()
else
self.shown = {}
self.pageIndex = self.pageIndex + 1
self.lineIndex = 1
self:beginLine()
end
end
return
end
@@ -203,10 +239,19 @@ function TextBox:update(dt)
-- line finished
local page = self.pages[self.pageIndex]
if self.lineIndex < #page then
self.lineIndex = self.lineIndex + 1
self:beginLine()
local nextIdx = self.lineIndex + 1
local conts = self.pages.contBefore and self.pages.contBefore[self.pageIndex]
if conts and conts[nextIdx] then
-- pokered <CONT>: ▼ + WaitForTextScrollButtonPress before scroll
self.waiting = true
self.contAdvance = true
else
self.lineIndex = nextIdx
self:beginLine()
end
elseif self.pageIndex < #self.pages then
self.waiting = true
self.contAdvance = false
else
self.done = true
end
+5
View File
@@ -179,6 +179,11 @@ local function recolorSample(r, g, b, a, colors)
return col[1] / 255, col[2] / 255, col[3] / 255, a
end
-- exported: a render pipeline bakes a map's palette into its own texture
-- atlas the same way, and has to land on the identical colors as the 2D
-- tiles it is standing in for
TileRenderer.recolorSample = recolorSample
-- the 8 shifted variants of one tile (built once per sheet + tile id [+
-- gbcKey, when `colors` recolors it for RED++ -- see buildAnim])
local shiftVariants = {}
+813
View File
@@ -0,0 +1,813 @@
-- Vanilla Gen1 (Red/Blue, international) raw SRAM save (32768 bytes) <->
-- this project's save.lua shape (src/core/SaveData.lua / SaveData.newGame).
--
-- Pure Lua, no love.* dependency -- runs under plain luajit for the CLI
-- (tools/save_convert/convert.lua) and headless tests alike.
--
-- Every offset below was derived mechanically from the authoritative
-- source (../pokered/ram/wram.asm, ram/sram.asm, macros/ram.asm) and
-- cross-checked against three independently well-known Gen1 save
-- addresses: money @ 0x25F3, badges @ 0x2602, party data @ 0x2F2C --
-- all three fall out exactly right from the single sPlayerName anchor
-- below, strong triangulated confirmation the whole chain (SRAM bank 1
-- layout, wMainData field order, party_struct/box_struct sizes) is right.
--
-- SRAM layout (32768 bytes = 4 banks x 8192): bank 0 is sprite buffers +
-- Hall of Fame (not modeled -- see "explicitly out of scope" in the
-- save-converter plan); bank 1 is "Save Data" (sPlayerName through
-- sMainDataCheckSum); banks 2/3 are the 12 PC boxes (6 each) + checksums.
--
-- Fields with no equivalent in save.lua (current sprite/animation state,
-- connection-header cache, Day Care, Safari Zone, HOF roster) are
-- intentionally not modeled: on export, encode() starts from the
-- ORIGINAL imported bytes as a template when available (GenSave.decode
-- stashes them) so that scratch state round-trips untouched instead of
-- being invented; with no template (a save that originated in this
-- project) those bytes stay zero-filled, which is safe because the real
-- game regenerates all of it from wCurMap on the next map load anyway.
local bit = require("bit")
local GenSave = {}
-- ------------------------------------------------------------------
-- Absolute byte offsets (0-based, matching a raw 32768-byte .sav file)
-- ------------------------------------------------------------------
local NAME_LENGTH = 11
local PARTY_LENGTH = 6
local MONS_PER_BOX = 20
local NUM_BADGES = 8
local BOX_STRUCT_SIZE = 33 -- Species,HP,Level,Status,Type1,Type2,CatchRate,
-- Moves x4,OTID,Exp x3,HPExp,AtkExp,DefExp,
-- SpdExp,SpcExp,DVs,PP x4 (macros/ram.asm box_struct)
local PARTY_STRUCT_SIZE = 44 -- box_struct + Level + Stats x5 (party_struct)
local BOX_REGION_SIZE = 1 + (MONS_PER_BOX + 1) + MONS_PER_BOX * BOX_STRUCT_SIZE
+ MONS_PER_BOX * NAME_LENGTH + MONS_PER_BOX * NAME_LENGTH -- 1122
local O = {}
O.playerName = 9624 -- sPlayerName (11B) = 0x2598
O.mainData = O.playerName + NAME_LENGTH -- sMainData (wMainDataStart mirror)
O.pokedexOwned = O.mainData + 0 -- 19B (flag_array 151)
O.pokedexSeen = O.mainData + 19 -- 19B
O.numBagItems = O.mainData + 38 -- 1B
O.bagItems = O.mainData + 39 -- 41B (20 x (id,qty) + $FF term)
O.money = O.mainData + 80 -- 3B BCD = 0x25F3
O.rivalName = O.mainData + 83 -- 11B
O.options = O.mainData + 94 -- 1B
O.badges = O.mainData + 95 -- 1B = 0x2602
O.playerId = O.mainData + 98 -- 2B (big-endian)
O.curMap = O.mainData + 103 -- 1B
O.yCoord = O.mainData + 106 -- 1B
O.xCoord = O.mainData + 107 -- 1B
O.lastMap = O.mainData + 110 -- 1B
O.numPcItems = O.mainData + 579 -- 1B
O.pcItems = O.mainData + 580 -- 101B (50 x (id,qty) + $FF term)
O.currentBoxNum = O.mainData + 681 -- 1B (bits 0-6: box 0-11, bit 7: unused here)
O.coins = O.mainData + 685 -- 2B BCD
O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits)
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
-- wEventFlags anchor: 320 (event flag_array) + 293 (the wGrassRate/enemy-party
-- battle UNION) + 66 + 66 (wEnemyMonOT/Nicks, 6 x NAME_LENGTH each; the
-- rgbds FOR n,1,PARTY_LENGTH+1 loop is end-exclusive => 6 mons, not 7) + 2
-- (wTrainerHeaderPtr) + 6 (ds) + 1 (wOpponentAfterWrongAnswer) + 1
-- (wCurMapScript) + 7 (ds) = 762. Confirmed on the real fixture: those five
-- bytes read 201h 30m 07s, a sane completed-save clock.
O.playTimeHours = O.mainData + 1866 -- 1B
O.playTimeMaxed = O.mainData + 1867 -- 1B (set once past 255h)
O.playTimeMinutes = O.mainData + 1868 -- 1B (0-59)
O.playTimeSeconds = O.mainData + 1869 -- 1B (0-59)
O.playTimeFrames = O.mainData + 1870 -- 1B (0-59, 1/60s ticks)
O.mainDataSize = 1929 -- wMainDataEnd - wMainDataStart
O.spriteData = O.mainData + O.mainDataSize
O.spriteDataSize = 512 -- 2 x 16 sprites x 16B
O.partyData = O.spriteData + O.spriteDataSize -- = 0x2F2C
O.partyCount = O.partyData
O.partySpecies = O.partyData + 1 -- 7B (PARTY_LENGTH+1)
O.partyMons = O.partyData + 8 -- 6 x 44B
O.partyMonOT = O.partyData + 8 + PARTY_LENGTH * PARTY_STRUCT_SIZE
O.partyMonNicks = O.partyMonOT + PARTY_LENGTH * NAME_LENGTH
O.partyDataSize = 1 + (PARTY_LENGTH + 1) + PARTY_LENGTH * PARTY_STRUCT_SIZE
+ PARTY_LENGTH * NAME_LENGTH + PARTY_LENGTH * NAME_LENGTH -- 404
O.curBoxData = O.partyData + O.partyDataSize
O.boxCount = O.curBoxData
O.boxSpecies = O.curBoxData + 1 -- 21B
O.boxMons = O.curBoxData + 22 -- 20 x 33B
O.boxMonOT = O.curBoxData + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE
O.boxMonNicks = O.boxMonOT + MONS_PER_BOX * NAME_LENGTH
O.checksumStart = O.playerName
O.checksumEnd = O.curBoxData + BOX_REGION_SIZE + 1 -- + sTileAnimations (1B)
O.mainChecksum = O.checksumEnd -- 1B
O.box1 = 16384 -- bank 2 start
O.boxBank2Checksum = O.box1 + 6 * BOX_REGION_SIZE
O.boxBank2IndividualChecksums = O.boxBank2Checksum + 1 -- 6B
O.box7 = 24576 -- bank 3 start
O.boxBank3Checksum = O.box7 + 6 * BOX_REGION_SIZE
O.boxBank3IndividualChecksums = O.boxBank3Checksum + 1 -- 6B
GenSave.OFFSETS = O
GenSave.BOX_REGION_SIZE = BOX_REGION_SIZE
GenSave.SAVE_SIZE = 32768
-- ------------------------------------------------------------------
-- Byte-level helpers. `bytes` is a 32768-byte Lua string (1-based
-- indexing, so byte offset N is string position N+1); `buf` for writing
-- is a 32768-entry array of 1-char strings, joined at the end.
-- ------------------------------------------------------------------
local function u8(bytes, off) return bytes:byte(off + 1) end
local function u16be(bytes, off) return u8(bytes, off) * 256 + u8(bytes, off + 1) end
local function u24be(bytes, off)
return u8(bytes, off) * 65536 + u8(bytes, off + 1) * 256 + u8(bytes, off + 2)
end
local function setByte(buf, off, v)
buf[off + 1] = string.char(bit.band(v, 0xFF))
end
local function setU16be(buf, off, v)
setByte(buf, off, bit.band(bit.rshift(v, 8), 0xFF))
setByte(buf, off + 1, bit.band(v, 0xFF))
end
local function setU24be(buf, off, v)
setByte(buf, off, bit.band(bit.rshift(v, 16), 0xFF))
setByte(buf, off + 1, bit.band(bit.rshift(v, 8), 0xFF))
setByte(buf, off + 2, bit.band(v, 0xFF))
end
local function setBcd(buf, off, nbytes, v)
for i = nbytes - 1, 0, -1 do
local d = v % 100
v = math.floor(v / 100)
setByte(buf, off + i, math.floor(d / 10) * 16 + (d % 10))
end
end
local function readBcd(bytes, off, nbytes)
local n = 0
for i = 0, nbytes - 1 do
local b = u8(bytes, off + i)
n = n * 100 + math.floor(b / 16) * 10 + (b % 16)
end
return n
end
-- CalcCheckSum (engine/menus/save.asm): complement of the additive sum
local function checksum(bytes, from, to)
local sum = 0
for i = from, to - 1 do sum = bit.band(sum + u8(bytes, i), 0xFF) end
return bit.band(bit.bnot(sum), 0xFF)
end
-- flag_array packs LSB-first within each byte (bit 0 of byte 0 = index 0).
-- This is pokered's runtime FlagAction convention (home/predef macros): it
-- takes flag number N, addresses byte N/8, and builds the mask by rotating
-- a 1 left N%8 times starting from bit 0 -- i.e. flag N%8==0 is the LSB.
-- Same convention PKHeX uses for Gen1 dex/event flags (FlagUtil.GetFlag:
-- data[ofs + bit/8] >> (bit%8) & 1). Cross-validated against the real save:
-- ZAPDOS (dex 145) is physically boxed there, so its owned/seen flag must be
-- set; only the LSB reading returns it set (MSB-first spuriously drops
-- exactly that one bit at the byte-18 boundary), yielding a complete 151/151
-- dex. The prior MSB-first code round-tripped self-consistently but decoded
-- every flag_array (pokedex AND event flags) to the wrong bit.
local function bitGet(bytes, base, index)
local byteOff = base + math.floor(index / 8)
local b = u8(bytes, byteOff)
return bit.band(bit.rshift(b, index % 8), 1) == 1
end
-- Set one bit directly in `buf` (0-based flag index into a flag_array
-- starting at `base`), preserving every other bit already in that byte --
-- template bytes (see encode()'s header note) survive for bits this pass
-- never explicitly touches, e.g. event-flag bits with no known name
-- sharing a byte with ones that do.
local function bitSet(buf, base, index, value)
local byteOff = base + math.floor(index / 8)
local bitIdx = index % 8
local cur = buf[byteOff + 1] and buf[byteOff + 1]:byte() or 0
local mask = bit.lshift(1, bitIdx)
setByte(buf, byteOff, value and bit.bor(cur, mask) or bit.band(cur, bit.bnot(mask)))
end
-- ------------------------------------------------------------------
-- Text (fixed-length name fields: charmap-encoded, "@" ($50) terminated,
-- $50-padded after the terminator). setCharmap(cm) must be called once
-- before decode/encode (src/save_convert/data/charmap.lua's shape).
-- ------------------------------------------------------------------
local charmap
function GenSave.setCharmap(cm) charmap = cm end
local function decodeName(bytes, off, len)
local out = {}
for i = 0, len - 1 do
local b = u8(bytes, off + i)
if b == 0x50 then break end
out[#out + 1] = charmap.byByte[b] or "?"
end
return table.concat(out)
end
local function encodeName(buf, off, len, text)
local i, pos = 0, 1
while i < len - 1 and pos <= #text do
-- a bracketed control token (e.g. "<DOT>", from decodeName reading a
-- byte with no plain-glyph mapping) is ONE game character despite
-- being several text bytes here; match it as a whole unit first, or
-- it would fall through to per-byte matching and turn into "?" x5
local bracket = text:match("^(<[^<>]*>)", pos)
local ch, clen
if bracket and charmap.byToken[bracket] then
ch, clen = bracket, #bracket
else
local b0 = text:byte(pos)
clen = (b0 < 0x80 and 1) or (b0 < 0xE0 and 2) or (b0 < 0xF0 and 3) or 4
ch = text:sub(pos, pos + clen - 1)
end
setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
i, pos = i + 1, pos + clen
end
-- Write exactly ONE $50 terminator and then STOP. The bytes after it are
-- left untouched: when encoding over a template they stay as the original
-- save's post-terminator padding (so an unchanged name round-trips
-- byte-identical), and on a templateless export they stay zero-filled. The
-- game reads a name only up to the first $50, so whatever follows is inert.
if i < len then setByte(buf, off + i, 0x50) end
end
-- ------------------------------------------------------------------
-- DV / PP packing (box_struct DVs, PP)
-- ------------------------------------------------------------------
-- DVs:: dw, packed as byte0=(Attack<<4)|Defense, byte1=(Speed<<4)|Special;
-- HP DV is derived, not stored, from each stat DV's low bit.
local function decodeDVs(bytes, off)
local b0, b1 = u8(bytes, off), u8(bytes, off + 1)
local atk, def = bit.rshift(b0, 4), bit.band(b0, 0xF)
local spe, spc = bit.rshift(b1, 4), bit.band(b1, 0xF)
local hp = bit.bor(bit.lshift(bit.band(atk, 1), 3), bit.lshift(bit.band(def, 1), 2),
bit.lshift(bit.band(spe, 1), 1), bit.band(spc, 1))
return { hp = hp, attack = atk, defense = def, speed = spe, special = spc }
end
local function encodeDVs(buf, off, dvs)
setByte(buf, off, bit.bor(bit.lshift(bit.band(dvs.attack or 0, 0xF), 4), bit.band(dvs.defense or 0, 0xF)))
setByte(buf, off + 1, bit.bor(bit.lshift(bit.band(dvs.speed or 0, 0xF), 4), bit.band(dvs.special or 0, 0xF)))
end
-- PP byte: top 2 bits = PP Up count (0-3), bottom 6 bits = current PP
local function decodePPByte(b) return bit.band(b, 0x3F), bit.rshift(b, 6) end
local function encodePPByte(pp, ppUps) return bit.bor(bit.lshift(bit.band(ppUps or 0, 3), 6), bit.band(pp or 0, 0x3F)) end
-- ------------------------------------------------------------------
-- Crosswalks (built once from `data` = {pokemon=,moves=,items=,maps=})
-- ------------------------------------------------------------------
-- pokered constants/type_constants.asm PHYSICAL/SPECIAL block; stable,
-- not worth a dedicated extractor for 15 names.
local TYPE_BY_INDEX = {
[0] = "NORMAL", [1] = "FIGHTING", [2] = "FLYING", [3] = "POISON",
[4] = "GROUND", [5] = "ROCK", [6] = "BIRD", [7] = "BUG", [8] = "GHOST",
[20] = "FIRE", [21] = "WATER", [22] = "GRASS", [23] = "ELECTRIC",
[24] = "PSYCHIC_TYPE", [25] = "ICE", [26] = "DRAGON",
}
local TYPE_INDEX = {}
for i, name in pairs(TYPE_BY_INDEX) do TYPE_INDEX[name] = i end
-- Badge bit order (constants/ram_constants.asm BIT_BOULDERBADGE=0 ..
-- BIT_EARTHBADGE=7); this project stores badges as truthy
-- save.inventory[id] entries, not a flag or a separate bitmask
-- (src/inventory/Badges.lua Badges.list's VANILLA order matches exactly).
local BADGE_BY_BIT = {
[0] = "BOULDERBADGE", [1] = "CASCADEBADGE", [2] = "THUNDERBADGE",
[3] = "RAINBOWBADGE", [4] = "SOULBADGE", [5] = "MARSHBADGE",
[6] = "VOLCANOBADGE", [7] = "EARTHBADGE",
}
local BADGE_BY_BIT_SET = {}
for _, name in pairs(BADGE_BY_BIT) do BADGE_BY_BIT_SET[name] = true end
-- STATUS_* bits (constants/battle_constants.asm): 0-2 sleep-turns-left,
-- 3 PSN, 4 BRN, 5 FRZ, 6 PAR
local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 }
local function decodeStatus(b)
if bit.band(b, 7) > 0 then return "SLP" end
for name, bitIdx in pairs(STATUS_BIT) do
if bit.band(b, bit.lshift(1, bitIdx)) ~= 0 then return name end
end
return nil
end
local function encodeStatus(status)
if status == "SLP" then return 7 end
if status and STATUS_BIT[status] then return bit.lshift(1, STATUS_BIT[status]) end
return 0
end
local function buildIndexCrosswalk(defs)
local byIndex, byId = {}, {}
for id, def in pairs(defs or {}) do
if def.index ~= nil then
byIndex[def.index] = id
byId[id] = def.index
end
end
return byIndex, byId
end
-- Pokedex bit position: NATIONAL DEX NUMBER (1-151), NOT the internal ROM
-- species byte (`def.index`, used for party/box mon structs) -- these are
-- two completely different Gen1 numbering schemes (the whole "MissingNo"
-- phenomenon is dex-number vs internal-index mismatches). This project's
-- generated data has no dedicated dex-number field, but every pokemon.lua
-- entry's `source` documents its extraction origin as "ROM:BaseStats[N]",
-- and BaseStats is declared in dex order in the disassembly -- verified
-- directly against 5 species (BULBASAUR->[1], CHARMANDER->[4],
-- SQUIRTLE->[7], PIKACHU->[25], MEWTWO->[150], all exactly their real
-- national dex numbers) before relying on it here.
local function buildDexCrosswalk(defs)
local byDex, dexOf = {}, {}
for id, def in pairs(defs or {}) do
local n = def.source and tonumber(def.source:match("BaseStats%[(%d+)%]"))
if n then
byDex[n] = id
dexOf[id] = n
end
end
return byDex, dexOf
end
-- TM/HM item entries carry no `index` (data/generated/items.lua extracts
-- them by move/slot, not by their place in the raw item-constant table),
-- so buildIndexCrosswalk alone would silently drop every TM/HM from the
-- bag/PC on encode. Their real item ids ARE derivable: pokered's
-- constants/item_constants.asm declares "HM_\1: the item id, starting at
-- $C4" and "TM_\1: the item id, starting at $C9" for slot 1, incrementing
-- per slot -- i.e. HM01=196+.. , TM01=201+(number-1).
local function addMachineIndices(defs, byIndex, byId)
for id, def in pairs(defs or {}) do
if byId[id] == nil and def.machine and def.machine.number then
local base = def.machine.kind == "HM" and 195 or 200
local idx = base + def.machine.number
byIndex[idx] = id
byId[id] = idx
end
end
end
function GenSave.crosswalks(data)
local pokemonByIndex, pokemonIndex = buildIndexCrosswalk(data.pokemon)
local movesByIndex, movesIndex = buildIndexCrosswalk(data.moves)
local itemsByIndex, itemsIndex = buildIndexCrosswalk(data.items)
addMachineIndices(data.items, itemsByIndex, itemsIndex)
local mapsByIndex, mapsIndex = buildIndexCrosswalk(data.maps)
local pokemonByDex, pokemonDex = buildDexCrosswalk(data.pokemon)
return {
pokemonByIndex = pokemonByIndex, pokemonIndex = pokemonIndex,
pokemonByDex = pokemonByDex, pokemonDex = pokemonDex,
movesByIndex = movesByIndex, movesIndex = movesIndex,
itemsByIndex = itemsByIndex, itemsIndex = itemsIndex,
mapsByIndex = mapsByIndex, mapsIndex = mapsIndex,
speciesDefs = data.pokemon or {},
}
end
-- ------------------------------------------------------------------
-- Mon struct (box_struct is a byte-for-byte prefix of party_struct;
-- decodeMon reads the box_struct fields, then Level+Stats if isParty).
-- Type1/Type2 are read for nothing (this project derives type from
-- species) but re-derived from data.pokemon[species].types on encode.
-- ------------------------------------------------------------------
local function decodeMon(bytes, off, isParty, cw)
local speciesIdx = u8(bytes, off)
if speciesIdx == 0 then return nil end -- empty slot
local species = cw.pokemonByIndex[speciesIdx]
local hp = u16be(bytes, off + 1)
local boxLevel = u8(bytes, off + 3)
local status = decodeStatus(u8(bytes, off + 4))
local catchRate = u8(bytes, off + 7)
local moves = {}
for i = 0, 3 do
local moveIdx = u8(bytes, off + 8 + i)
if moveIdx > 0 then
local pp, ppUps = decodePPByte(u8(bytes, off + 29 + i))
moves[#moves + 1] = { id = cw.movesByIndex[moveIdx], pp = pp, ppUps = ppUps }
end
end
local otId = u16be(bytes, off + 12)
local exp = u24be(bytes, off + 14)
local statExp = {
hp = u16be(bytes, off + 17), attack = u16be(bytes, off + 19),
defense = u16be(bytes, off + 21), speed = u16be(bytes, off + 23),
special = u16be(bytes, off + 25),
}
local dvs = decodeDVs(bytes, off + 27)
local mon = {
species = species, exp = exp, dvs = dvs, statExp = statExp,
hp = hp, status = status, moves = moves, otId = otId,
catchRate = catchRate, level = boxLevel,
-- Type1/Type2 as physically stored. This project derives type from species
-- for gameplay, but the raw bytes are captured so encode() can reproduce
-- them verbatim: some real saves (traded/tampered mons) carry type values
-- that do not match the ROM base stats, and re-deriving would corrupt them.
typeBytes = { u8(bytes, off + 5), u8(bytes, off + 6) },
}
if isParty then
mon.level = u8(bytes, off + 33)
mon.stats = {
hp = u16be(bytes, off + 34), attack = u16be(bytes, off + 36),
defense = u16be(bytes, off + 38), speed = u16be(bytes, off + 40),
special = u16be(bytes, off + 42),
}
end
return mon
end
local function encodeMon(buf, off, mon, isParty, cw)
if not mon then
setByte(buf, off, 0)
return
end
setByte(buf, off, cw.pokemonIndex[mon.species] or 0)
setU16be(buf, off + 1, mon.hp or 0)
setByte(buf, off + 3, mon.level or 1)
setByte(buf, off + 4, encodeStatus(mon.status))
local def = cw.speciesDefs[mon.species]
if mon.typeBytes then
-- reproduce the exact stored type bytes captured on decode (faithful
-- byte round-trip); fresh, engine-built mons have none and derive below.
setByte(buf, off + 5, mon.typeBytes[1] or 0)
setByte(buf, off + 6, mon.typeBytes[2] or 0)
else
local t = (def and def.types) or {}
setByte(buf, off + 5, TYPE_INDEX[t[1]] or 0)
setByte(buf, off + 6, TYPE_INDEX[t[2] or t[1]] or 0)
end
setByte(buf, off + 7, mon.catchRate or (def and def.catchRate) or 0)
for i = 0, 3 do
local mv = mon.moves and mon.moves[i + 1]
setByte(buf, off + 8 + i, mv and (cw.movesIndex[mv.id] or 0) or 0)
setByte(buf, off + 29 + i, mv and encodePPByte(mv.pp, mv.ppUps) or 0)
end
setU16be(buf, off + 12, mon.otId or 0)
setU24be(buf, off + 14, mon.exp or 0)
local se = mon.statExp or {}
setU16be(buf, off + 17, se.hp or 0)
setU16be(buf, off + 19, se.attack or 0)
setU16be(buf, off + 21, se.defense or 0)
setU16be(buf, off + 23, se.speed or 0)
setU16be(buf, off + 25, se.special or 0)
encodeDVs(buf, off + 27, mon.dvs or {})
if isParty then
setByte(buf, off + 33, mon.level or 1)
local st = mon.stats or {}
setU16be(buf, off + 34, st.hp or 0)
setU16be(buf, off + 36, st.attack or 0)
setU16be(buf, off + 38, st.defense or 0)
setU16be(buf, off + 40, st.speed or 0)
setU16be(buf, off + 42, st.special or 0)
end
end
-- ------------------------------------------------------------------
-- Bag / PC items: (id, qty) byte pairs, $FF-terminated
-- ------------------------------------------------------------------
local function decodeItemList(bytes, off, capacity, cw)
local inventory, order = {}, {}
for i = 0, capacity - 1 do
local idByte = u8(bytes, off + i * 2)
if idByte == 0xFF then break end
local qty = u8(bytes, off + i * 2 + 1)
local id = cw.itemsByIndex[idByte]
if id then
inventory[id] = qty
order[#order + 1] = id
end
end
return inventory, order
end
local function encodeItemList(buf, off, capacity, inventory, order, cw)
local i = 0
local seen = {}
local function put(id, qty)
if i >= capacity or not qty or qty <= 0 then return end
local idByte = cw.itemsIndex[id]
if not idByte then return end
setByte(buf, off + i * 2, idByte)
setByte(buf, off + i * 2 + 1, math.min(qty, 99))
i = i + 1
seen[id] = true
end
for _, id in ipairs(order or {}) do
if inventory[id] and not seen[id] then put(id, inventory[id]) end
end
for id, qty in pairs(inventory or {}) do
if not seen[id] then put(id, qty) end
end
setByte(buf, off + i * 2, 0xFF)
return i -- count actually written (badges etc. in `inventory` that
-- aren't real items are silently skipped by put(), so this
-- can be less than #inventory -- see the wNumBagItems caller)
end
-- ------------------------------------------------------------------
-- decode: raw 32768-byte SRAM string -> save.lua-shaped table
-- ------------------------------------------------------------------
function GenSave.decode(bytes, data, opts)
assert(#bytes == GenSave.SAVE_SIZE, "expected a 32768-byte save")
local cw = GenSave.crosswalks(data)
local warnings = {}
local function warn(msg) warnings[#warnings + 1] = msg end
if checksum(bytes, O.checksumStart, O.checksumEnd) ~= u8(bytes, O.mainChecksum) then
warn("main data checksum mismatch (importing anyway)")
end
local save = {
meta = { format = "gen1_import" },
player = {
name = decodeName(bytes, O.playerName, NAME_LENGTH),
rival = decodeName(bytes, O.rivalName, NAME_LENGTH),
id = u16be(bytes, O.playerId),
},
money = readBcd(bytes, O.money, 3),
coins = readBcd(bytes, O.coins, 2),
inventory = {},
pcItems = {},
pokedex = { seen = {}, owned = {} },
flags = {},
party = {},
boxes = {},
currentBox = 1,
}
-- pokedex (own/seen, flag_array NUM_POKEMON: bit 0 = dex #1)
for dex, species in pairs(cw.pokemonByDex) do
local bitIdx = dex - 1
if bitGet(bytes, O.pokedexOwned, bitIdx) then save.pokedex.owned[species] = true end
if bitGet(bytes, O.pokedexSeen, bitIdx) then save.pokedex.seen[species] = true end
end
save.inventory, save.bagOrder = decodeItemList(bytes, O.bagItems, 20, cw)
save.pcItems, save.pcOrder = decodeItemList(bytes, O.pcItems, 50, cw)
-- badges: truthy save.inventory[id] entries (src/inventory/Badges.lua),
-- set AFTER decodeItemList since that call replaces save.inventory
local badgesByte = u8(bytes, O.badges)
for i = 0, NUM_BADGES - 1 do
if bit.band(badgesByte, bit.lshift(1, i)) ~= 0 then
save.inventory[BADGE_BY_BIT[i]] = 1
end
end
-- party
local partyCount = u8(bytes, O.partyCount)
for i = 0, math.min(partyCount, PARTY_LENGTH) - 1 do
local mon = decodeMon(bytes, O.partyMons + i * PARTY_STRUCT_SIZE, true, cw)
if mon then
mon.ot = decodeName(bytes, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH)
mon.nickname = decodeName(bytes, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH)
save.party[#save.party + 1] = mon
end
end
-- current box (bank 1) + the 11 stored boxes (banks 2/3)
for i = 1, 12 do save.boxes[i] = {} end
local function decodeBoxRegion(base, boxNum)
local count = u8(bytes, base)
for i = 0, math.min(count, MONS_PER_BOX) - 1 do
local mon = decodeMon(bytes, base + 22 + i * BOX_STRUCT_SIZE, false, cw)
if mon then
mon.ot = decodeName(bytes, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH)
mon.nickname = decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH)
table.insert(save.boxes[boxNum], mon)
end
end
end
local curBoxNum = bit.band(u8(bytes, O.currentBoxNum), 0x7F) -- 0-based box index
curBoxNum = math.max(1, math.min(12, curBoxNum + 1))
decodeBoxRegion(O.curBoxData, curBoxNum)
for b = 1, 6 do
if b + 0 ~= curBoxNum then decodeBoxRegion(O.box1 + (b - 1) * BOX_REGION_SIZE, b) end
end
for b = 7, 12 do
if b ~= curBoxNum then decodeBoxRegion(O.box7 + (b - 7) * BOX_REGION_SIZE, b) end
end
save.currentBox = curBoxNum
-- event flags (only bits with a known name are decoded)
local events = data.eventFlags
if events then
for bitIdx, name in pairs(events.byBit) do
if bitGet(bytes, O.eventFlags, bitIdx) then save.flags[name] = true end
end
end
-- map + position
local mapIdx = u8(bytes, O.curMap)
local mapId = cw.mapsByIndex[mapIdx]
local y, x = u8(bytes, O.yCoord), u8(bytes, O.xCoord)
if mapId then
save.player.map, save.player.x, save.player.y = mapId, x, y
else
warn(("unknown map index %d, defaulting spawn"):format(mapIdx))
end
local lastMapIdx = u8(bytes, O.lastMap)
local lastMapId = cw.mapsByIndex[lastMapIdx]
if lastMapId then save.lastOutdoor = { id = lastMapId } end
-- play time: this project stores save.playTime as a single float of
-- SECONDS (src/core/Game.lua accumulates dt each frame; StartMenu /
-- TrainerCard / TitleState render it H:MM via t/3600 and (t/60)%60).
-- Fold the Gen1 H/M/S/F fields into that one number; frames are 1/60s
-- sub-second ticks, kept as a fraction so an export recovers them exactly.
save.playTime = u8(bytes, O.playTimeHours) * 3600
+ u8(bytes, O.playTimeMinutes) * 60
+ u8(bytes, O.playTimeSeconds)
+ u8(bytes, O.playTimeFrames) / 60
save.warnings = warnings
save.rawImport = bytes -- template for a later encode(); see file header
return save
end
-- ------------------------------------------------------------------
-- encode: save.lua-shaped table -> raw 32768-byte SRAM string
-- ------------------------------------------------------------------
function GenSave.encode(save, data, template)
local cw = GenSave.crosswalks(data)
local src = template or save.rawImport
local buf = {}
if src then
for i = 1, GenSave.SAVE_SIZE do buf[i] = src:sub(i, i) end
else
local zero = string.char(0)
for i = 1, GenSave.SAVE_SIZE do buf[i] = zero end
end
encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED")
encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE")
setU16be(buf, O.playerId, (save.player and save.player.id) or 0)
setBcd(buf, O.money, 3, math.min(save.money or 0, 999999))
setBcd(buf, O.coins, 2, math.min(save.coins or 0, 9999))
local badgesByte = 0
for bitIdx, name in pairs(BADGE_BY_BIT) do
if save.inventory and save.inventory[name] then
badgesByte = bit.bor(badgesByte, bit.lshift(1, bitIdx))
end
end
setByte(buf, O.badges, badgesByte)
for dex, species in pairs(cw.pokemonByDex) do
local bitIdx = dex - 1
bitSet(buf, O.pokedexOwned, bitIdx,
(save.pokedex and save.pokedex.owned and save.pokedex.owned[species]) and true or false)
bitSet(buf, O.pokedexSeen, bitIdx,
(save.pokedex and save.pokedex.seen and save.pokedex.seen[species]) and true or false)
end
-- Badges occupy real item IDs in data/generated/items.lua (Gen1's item
-- ID space includes them, $01-$08, for the "got the BOULDERBADGE!"
-- text display), but this project's save.lua stores them as truthy
-- save.inventory[id] entries alongside actual bag items (see the badge
-- block above and src/inventory/Badges.lua) -- a real save NEVER
-- writes them into wBagItems (they only ever live in wObtainedBadges,
-- already encoded above), so they must be filtered out here or they'd
-- corrupt the bag with bogus "badge items".
local bagInventory = {}
for id, qty in pairs(save.inventory or {}) do
if not BADGE_BY_BIT_SET[id] then bagInventory[id] = qty end
end
local bagN = encodeItemList(buf, O.bagItems, 20, bagInventory, save.bagOrder, cw)
setByte(buf, O.numBagItems, bagN)
local pcN = encodeItemList(buf, O.pcItems, 50, save.pcItems or {}, save.pcOrder, cw)
setByte(buf, O.numPcItems, pcN)
local events = data.eventFlags
if events and save.flags then
for name in pairs(save.flags) do
local bitIdx = events.byName[name]
if bitIdx then bitSet(buf, O.eventFlags, bitIdx, true) end
end
end
-- party
local party = save.party or {}
local partyN = math.min(#party, PARTY_LENGTH)
setByte(buf, O.partyCount, partyN)
for i = 0, partyN - 1 do
local mon = party[i + 1]
encodeMon(buf, O.partyMons + i * PARTY_STRUCT_SIZE, mon, true, cw)
setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0)
encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH,
mon.ot or (save.player and save.player.name) or "RED")
encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH,
mon.nickname or mon.species or "")
end
-- $FF-terminate the species index list right after the last real mon. The
-- struct, OT-name and nickname bytes of the empty slots past partyN are left
-- exactly as the template holds them (original stale data -> byte-identical
-- round-trip) or zero on a fresh export -- the game never reads past the
-- count, so this matches how it leaves those bytes itself.
setByte(buf, O.partySpecies + partyN, 0xFF)
-- boxes: current box mirrors save.currentBox into sCurBoxData; all 12
-- also get written into their bank-2/3 slot (sCurBoxData is a working
-- copy the real game keeps in sync on every PC visit, so keeping both
-- copies consistent here matches that invariant)
local function encodeBoxRegion(base, mons)
local n = math.min(#mons, MONS_PER_BOX)
setByte(buf, base, n)
for i = 0, n - 1 do
local mon = mons[i + 1]
encodeMon(buf, base + 22 + i * BOX_STRUCT_SIZE, mon, false, cw)
setByte(buf, base + 1 + i, cw.pokemonIndex[mon.species] or 0)
encodeName(buf, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH,
mon.ot or (save.player and save.player.name) or "RED")
encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH,
mon.nickname or mon.species or "")
end
-- $FF-terminate the species list after the last real mon; empty slots past
-- n keep their template bytes (byte-identical round-trip) or zero (fresh
-- export), just as the game leaves stale box data untouched past the count.
setByte(buf, base + 1 + n, 0xFF)
end
local boxes = save.boxes or {}
local curBoxNum = math.max(1, math.min(12, save.currentBox or 1))
encodeBoxRegion(O.curBoxData, boxes[curBoxNum] or {})
-- bit 7 of wCurBoxNum is the "box system initialized" flag, not part of the
-- 0-11 index; preserve it from the template, or set it on a templateless
-- export (any save we emit has an initialized box system).
local prevBoxByte = buf[O.currentBoxNum + 1]
local boxHiBit = (src and prevBoxByte) and bit.band(prevBoxByte:byte(), 0x80) or 0x80
setByte(buf, O.currentBoxNum, bit.bor(bit.band(curBoxNum - 1, 0x7F), boxHiBit))
for b = 1, 6 do encodeBoxRegion(O.box1 + (b - 1) * BOX_REGION_SIZE, boxes[b] or {}) end
for b = 7, 12 do encodeBoxRegion(O.box7 + (b - 7) * BOX_REGION_SIZE, boxes[b] or {}) end
-- map + position
if save.player and save.player.map then
setByte(buf, O.curMap, cw.mapsIndex[save.player.map] or 0)
setByte(buf, O.yCoord, save.player.y or 0)
setByte(buf, O.xCoord, save.player.x or 0)
end
if save.lastOutdoor and save.lastOutdoor.id then
setByte(buf, O.lastMap, cw.mapsIndex[save.lastOutdoor.id] or 0)
end
-- play time: split save.playTime (seconds) back into H/M/S/F. The real
-- game freezes the clock at 255h and sets wPlayTimeMaxed once past it, so
-- mirror that cap rather than letting hours overflow a single byte.
local totalFrames = math.floor((save.playTime or 0) * 60 + 0.5)
local hours = math.floor(totalFrames / 216000) -- 3600s * 60 frames
if hours > 255 then
setByte(buf, O.playTimeHours, 255)
setByte(buf, O.playTimeMaxed, 1)
setByte(buf, O.playTimeMinutes, 59)
setByte(buf, O.playTimeSeconds, 59)
setByte(buf, O.playTimeFrames, 59)
else
local rem = totalFrames - hours * 216000
local mins = math.floor(rem / 3600); rem = rem - mins * 3600
local secs = math.floor(rem / 60)
setByte(buf, O.playTimeHours, hours)
setByte(buf, O.playTimeMaxed, 0)
setByte(buf, O.playTimeMinutes, mins)
setByte(buf, O.playTimeSeconds, secs)
setByte(buf, O.playTimeFrames, rem - secs * 60)
end
local out = table.concat(buf)
-- checksums, computed last over the now-final bytes
local outBuf = {}
for i = 1, #out do outBuf[i] = out:sub(i, i) end
setByte(outBuf, O.mainChecksum, checksum(out, O.checksumStart, O.checksumEnd))
-- Per pokered (engine/menus/save.asm SaveSAVtoSRAM / CalcCheckSum): each box
-- gets its own checksum, and the bank aggregate is CalcCheckSum over the
-- ENTIRE six-box region (6 x 1122 bytes), not a sum of the six box sums.
local function boxChecksum(base) return checksum(out, base, base + BOX_REGION_SIZE) end
for b = 0, 5 do
setByte(outBuf, O.boxBank2IndividualChecksums + b,
boxChecksum(O.box1 + b * BOX_REGION_SIZE))
end
setByte(outBuf, O.boxBank2Checksum,
checksum(out, O.box1, O.box1 + 6 * BOX_REGION_SIZE))
for b = 0, 5 do
setByte(outBuf, O.boxBank3IndividualChecksums + b,
boxChecksum(O.box7 + b * BOX_REGION_SIZE))
end
setByte(outBuf, O.boxBank3Checksum,
checksum(out, O.box7, O.box7 + 6 * BOX_REGION_SIZE))
return table.concat(outBuf)
end
return GenSave
+189
View File
@@ -0,0 +1,189 @@
-- SaveConvert -- the runtime-facing entry point the launcher UI calls to
-- turn a vanilla Gen1 (Red/Blue, international) battery save into this
-- project's in-memory save table, and back out to a raw .sav image.
--
-- This is the ONE place the engine, the tests and the CLI
-- (tools/save_convert/convert.lua) share: the GenSave codec, the crosswalk
-- data loading, the merge over new-game defaults, and the version tag all
-- live here so every consumer behaves identically.
--
-- Pure Lua, no love.* dependency at require time: GenSave and the crosswalk
-- tables load through `require`, exactly how src/core/Data.lua pulls the
-- generated modules -- which resolves under both plain luajit (package.path
-- "./?.lua") for the headless CLI/tests and love.filesystem for a fused
-- build, with an OS-path fallback for odd working directories. The only
-- place `love` is referenced is inside a guarded fallback, so running under
-- stock Lua never touches it.
local GenSave = require("src.save_convert.GenSave")
local SaveConvert = {}
SaveConvert.SAVE_SIZE = GenSave.SAVE_SIZE
-- ------------------------------------------------------------------
-- Crosswalk data loading (cached). Mirrors src/core/Data.lua: prefer
-- `require` (works headless via package.path and fused via love's package
-- searcher); fall back to love.filesystem.load, then a plain dofile, for
-- the rare case a host has an unusual cwd or module path.
-- ------------------------------------------------------------------
-- { require-module-path, os-relative-file-path } for each table the codec
-- needs. pokemon/moves/items/maps come from the shared generated data;
-- charmap/event_flags are the save-convert-specific crosswalks.
local DATA_MODULES = {
pokemon = { "data.generated.pokemon", "data/generated/pokemon.lua" },
moves = { "data.generated.moves", "data/generated/moves.lua" },
items = { "data.generated.items", "data/generated/items.lua" },
maps = { "data.generated.maps", "data/generated/maps.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" },
}
local function loadTable(requirePath, filePath)
local ok, mod = pcall(require, requirePath)
if ok and type(mod) == "table" then return mod end
-- fused build with an unexpected module path: read straight off the
-- mounted filesystem (love is a global here, only ever touched when it
-- actually exists -- stock Lua never reaches this branch)
if love and love.filesystem and love.filesystem.getInfo
and love.filesystem.getInfo(filePath) then
local chunk = love.filesystem.load(filePath)
if chunk then
local m = chunk()
if type(m) == "table" then return m end
end
end
local chunk = loadfile(filePath)
if chunk then
local m = chunk()
if type(m) == "table" then return m end
end
return nil, ("cannot load save-convert data module %q (tried require %q and file %q)")
:format(requirePath, requirePath, filePath)
end
local crosswalk -- { pokemon=, moves=, items=, maps=, eventFlags= }
local charmapReady
local function ensureData()
if not crosswalk then
local data = {}
for key, spec in pairs(DATA_MODULES) do
if key ~= "charmap" then
local mod, e = loadTable(spec[1], spec[2])
if not mod then return nil, e end
data[key] = mod
end
end
crosswalk = data
end
if not charmapReady then
local cm, err = loadTable(DATA_MODULES.charmap[1], DATA_MODULES.charmap[2])
if not cm then return nil, err end
GenSave.setCharmap(cm)
charmapReady = true
end
return crosswalk
end
-- Exposed for the CLI/tests so they can share the exact data set the codec
-- uses (and so a caller can pre-warm the cache). Returns data, err.
function SaveConvert.loadData()
return ensureData()
end
-- ------------------------------------------------------------------
-- new-game default skeleton the decoded fields merge on top of. Carried
-- verbatim from tools/save_convert/convert.lua so the CLI and the runtime
-- produce a byte-identical save table for the same input.
-- ------------------------------------------------------------------
local function defaultsSave()
return {
meta = { format = "gen1_import", mods = {} },
defeatedTrainers = {},
repelSteps = 0,
modData = {},
options = {
textSpeed = 3, animations = true, battleStyle = "shift",
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0,
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
videoMode = "windowed", mods = {},
},
}
end
-- Merge a GenSave.decode() result over the new-game defaults, exactly the
-- way convert.lua did, then stamp the requested version. The 32768-byte
-- import template GenSave stashes as `rawImport` and the decode `warnings`
-- are dropped here: neither belongs in a serialized slot file (a fresh
-- export always starts zero-filled -- see GenSave.lua's header).
local function mergeDefaults(decoded, version)
decoded.warnings = nil
decoded.rawImport = nil
local save = defaultsSave()
for k, v in pairs(decoded) do save[k] = v end
save.lastHeal = { map = save.player.map, x = save.player.x, y = save.player.y }
save.lastOutdoor = save.lastOutdoor or { id = save.player.map }
if version ~= nil then
save.meta = save.meta or {}
save.meta.version = version
end
return save
end
SaveConvert.mergeDefaults = mergeDefaults
-- ------------------------------------------------------------------
-- Public API
-- ------------------------------------------------------------------
-- importSav(bytes, version) -> saveTable, err
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
-- checksum, decodes through GenSave, and returns a save table fully merged
-- over the new-game defaults and tagged with `version`, ready to hand to
-- SaveSerializer.encode for a slot file. On any failure returns nil + a
-- message (never raises).
function SaveConvert.importSav(bytes, version)
if type(bytes) ~= "string" then
return nil, "expected raw save bytes as a string"
end
if #bytes ~= GenSave.SAVE_SIZE then
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
end
local data, derr = ensureData()
if not data then return nil, derr end
local ok, decoded = pcall(GenSave.decode, bytes, data)
if not ok then return nil, "decode failed: " .. tostring(decoded) end
-- checksum validation: GenSave.decode records a warning rather than
-- throwing (so it can still read a foreign/corrupt save), but for the
-- runtime import path a bad main-data checksum means the file is not a
-- trustworthy save, so reject it.
for _, w in ipairs(decoded.warnings or {}) do
if tostring(w):find("checksum") then
return nil, "save data checksum invalid (" .. tostring(w) .. ")"
end
end
return mergeDefaults(decoded, version)
end
-- exportSav(saveTable) -> bytes, err
-- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware:
-- if the table still carries the stashed import template (saveTable.rawImport)
-- GenSave reproduces every unmodeled region from it; otherwise those regions
-- are zero-filled. On failure returns nil + a message (never raises).
function SaveConvert.exportSav(saveTable)
if type(saveTable) ~= "table" then
return nil, "expected a save table"
end
local data, derr = ensureData()
if not data then return nil, derr end
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
if not ok then return nil, "encode failed: " .. tostring(bytes) end
return bytes
end
return SaveConvert
+579
View File
@@ -0,0 +1,579 @@
-- Generated by tools/build_data.py. DO NOT EDIT.
-- Gen1 text byte <-> glyph/token charmap (fixed-length name
-- fields: player/rival/OT names, nicknames -- box/party mon
-- names are NUL-free, '@' ($50) terminated, space-padded).
-- byToken's key is the literal glyph for ordinary characters
-- ("A", "é", ...) or a bracketed control token
-- ("<PLAYER>", "@") for terminators/substitutions -- only
-- the plain single-glyph entries are meaningful inside a name.
return {
byByte = {
[0] = "<NULL>",
[5] = "",
[6] = "",
[7] = "",
[8] = "",
[9] = "",
[10] = "",
[11] = "",
[12] = "",
[13] = "",
[14] = "",
[15] = "",
[16] = "",
[17] = "",
[18] = "",
[19] = "",
[25] = "",
[26] = "",
[27] = "",
[28] = "",
[38] = "",
[39] = "",
[40] = "",
[41] = "",
[42] = "",
[43] = "",
[44] = "",
[45] = "",
[46] = "",
[47] = "",
[48] = "",
[49] = "",
[50] = "",
[51] = "",
[52] = "",
[58] = "",
[59] = "",
[60] = "",
[61] = "",
[62] = "",
[64] = "",
[65] = "",
[66] = "",
[67] = "",
[68] = "",
[69] = "",
[70] = "",
[71] = "",
[72] = "",
[73] = "<PAGE>",
[74] = "<PKMN>",
[75] = "<_CONT>",
[76] = "<SCROLL>",
[78] = "<NEXT>",
[79] = "<LINE>",
[80] = "@",
[81] = "<PARA>",
[82] = "<PLAYER>",
[83] = "<RIVAL>",
[84] = "#",
[85] = "<CONT>",
[86] = "<……>",
[87] = "<DONE>",
[88] = "<PROMPT>",
[89] = "<TARGET>",
[90] = "<USER>",
[91] = "<PC>",
[92] = "<TM>",
[93] = "<TRAINER>",
[94] = "<ROCKET>",
[95] = "<DEXEND>",
[96] = "<BOLD_A>",
[97] = "<BOLD_B>",
[98] = "<BOLD_C>",
[99] = "<BOLD_D>",
[100] = "<BOLD_E>",
[101] = "<BOLD_F>",
[102] = "<BOLD_G>",
[103] = "<BOLD_H>",
[104] = "<BOLD_I>",
[105] = "<BOLD_V>",
[106] = "<BOLD_S>",
[107] = "<BOLD_L>",
[108] = "<BOLD_M>",
[109] = "<COLON>",
[110] = "",
[111] = "",
[112] = "",
[113] = "",
[114] = "",
[115] = "",
[116] = "·",
[117] = "",
[118] = "",
[119] = "",
[120] = "",
[121] = "",
[122] = "",
[123] = "",
[124] = "",
[125] = "",
[126] = "",
[127] = " ",
[128] = "A",
[129] = "B",
[130] = "C",
[131] = "D",
[132] = "E",
[133] = "F",
[134] = "G",
[135] = "H",
[136] = "I",
[137] = "J",
[138] = "K",
[139] = "L",
[140] = "M",
[141] = "N",
[142] = "O",
[143] = "P",
[144] = "Q",
[145] = "R",
[146] = "S",
[147] = "T",
[148] = "U",
[149] = "V",
[150] = "W",
[151] = "X",
[152] = "Y",
[153] = "Z",
[154] = "(",
[155] = ")",
[156] = ":",
[157] = ";",
[158] = "[",
[159] = "]",
[160] = "a",
[161] = "b",
[162] = "c",
[163] = "d",
[164] = "e",
[165] = "f",
[166] = "g",
[167] = "h",
[168] = "i",
[169] = "j",
[170] = "k",
[171] = "l",
[172] = "m",
[173] = "n",
[174] = "o",
[175] = "p",
[176] = "q",
[177] = "r",
[178] = "s",
[179] = "t",
[180] = "u",
[181] = "v",
[182] = "w",
[183] = "x",
[184] = "y",
[185] = "z",
[186] = "é",
[187] = "'d",
[188] = "'l",
[189] = "'s",
[190] = "'t",
[191] = "'v",
[192] = "",
[193] = "",
[194] = "",
[195] = "",
[196] = "",
[197] = "",
[198] = "",
[199] = "",
[200] = "",
[201] = "",
[202] = "",
[203] = "",
[204] = "",
[205] = "",
[206] = "",
[207] = "",
[208] = "",
[209] = "",
[210] = "",
[211] = "",
[212] = "",
[213] = "",
[214] = "",
[215] = "",
[216] = "",
[217] = "",
[218] = "",
[219] = "",
[220] = "",
[221] = "",
[222] = "",
[223] = "",
[224] = "'",
[225] = "<PK>",
[226] = "<MN>",
[227] = "-",
[228] = "'r",
[229] = "'m",
[230] = "?",
[231] = "!",
[232] = ".",
[233] = "",
[234] = "",
[235] = "",
[236] = "",
[237] = "",
[238] = "",
[239] = "",
[240] = "<ED>",
[241] = "×",
[242] = "<DOT>",
[243] = "/",
[244] = ",",
[245] = "",
[246] = "0",
[247] = "1",
[248] = "2",
[249] = "3",
[250] = "4",
[251] = "5",
[252] = "6",
[253] = "7",
[254] = "8",
[255] = "9",
},
byToken = {
[" "] = 127,
["!"] = 231,
["#"] = 84,
["'"] = 224,
["'d"] = 187,
["'l"] = 188,
["'m"] = 229,
["'r"] = 228,
["'s"] = 189,
["'t"] = 190,
["'v"] = 191,
["("] = 154,
[")"] = 155,
[","] = 244,
["-"] = 227,
["."] = 232,
["/"] = 243,
["0"] = 246,
["1"] = 247,
["2"] = 248,
["3"] = 249,
["4"] = 250,
["5"] = 251,
["6"] = 252,
["7"] = 253,
["8"] = 254,
["9"] = 255,
[":"] = 156,
[";"] = 157,
["<BOLD_A>"] = 96,
["<BOLD_B>"] = 97,
["<BOLD_C>"] = 98,
["<BOLD_D>"] = 99,
["<BOLD_E>"] = 100,
["<BOLD_F>"] = 101,
["<BOLD_G>"] = 102,
["<BOLD_H>"] = 103,
["<BOLD_I>"] = 104,
["<BOLD_L>"] = 107,
["<BOLD_M>"] = 108,
["<BOLD_P>"] = 114,
["<BOLD_S>"] = 106,
["<BOLD_V>"] = 105,
["<COLON>"] = 109,
["<CONT>"] = 85,
["<DEXEND>"] = 95,
["<DONE>"] = 87,
["<DOT>"] = 242,
["<ED>"] = 240,
["<ID>"] = 115,
["<LINE>"] = 79,
["<LV>"] = 110,
["<MN>"] = 226,
["<NEXT>"] = 78,
["<NULL>"] = 0,
["<PAGE>"] = 73,
["<PARA>"] = 81,
["<PC>"] = 91,
["<PK>"] = 225,
["<PKMN>"] = 74,
["<PLAYER>"] = 82,
["<PROMPT>"] = 88,
["<RIVAL>"] = 83,
["<ROCKET>"] = 94,
["<SCROLL>"] = 76,
["<TARGET>"] = 89,
["<TM>"] = 92,
["<TRAINER>"] = 93,
["<USER>"] = 90,
["<_CONT>"] = 75,
["<to>"] = 112,
["<……>"] = 86,
["?"] = 230,
["@"] = 80,
A = 128,
B = 129,
C = 130,
D = 131,
E = 132,
F = 133,
G = 134,
H = 135,
I = 136,
J = 137,
K = 138,
L = 139,
M = 140,
N = 141,
O = 142,
P = 143,
Q = 144,
R = 145,
S = 146,
T = 147,
U = 148,
V = 149,
W = 150,
X = 151,
Y = 152,
Z = 153,
["["] = 158,
["]"] = 159,
a = 160,
b = 161,
c = 162,
d = 163,
e = 164,
f = 165,
g = 166,
h = 167,
i = 168,
j = 169,
k = 170,
l = 171,
m = 172,
n = 173,
o = 174,
p = 175,
q = 176,
r = 177,
s = 178,
t = 179,
u = 180,
v = 181,
w = 182,
x = 183,
y = 184,
z = 185,
["¥"] = 240,
["·"] = 116,
["×"] = 241,
["é"] = 186,
[""] = 112,
[""] = 113,
[""] = 114,
[""] = 115,
[""] = 117,
[""] = 96,
[""] = 97,
[""] = 116,
[""] = 117,
[""] = 122,
[""] = 124,
[""] = 121,
[""] = 123,
[""] = 125,
[""] = 126,
[""] = 237,
[""] = 237,
[""] = 236,
[""] = 238,
[""] = 245,
[""] = 239,
[" "] = 127,
[""] = 232,
[""] = 112,
[""] = 113,
[""] = 114,
[""] = 115,
[""] = 118,
[""] = 177,
[""] = 110,
[""] = 178,
[""] = 111,
[""] = 179,
[""] = 119,
[""] = 180,
[""] = 120,
[""] = 181,
[""] = 182,
[""] = 38,
[""] = 183,
[""] = 39,
[""] = 184,
[""] = 40,
[""] = 185,
[""] = 41,
[""] = 186,
[""] = 42,
[""] = 187,
[""] = 43,
[""] = 188,
[""] = 44,
[""] = 189,
[""] = 45,
[""] = 190,
[""] = 46,
[""] = 191,
[""] = 47,
[""] = 192,
[""] = 48,
[""] = 193,
[""] = 49,
[""] = 223,
[""] = 194,
[""] = 50,
[""] = 195,
[""] = 51,
[""] = 196,
[""] = 52,
[""] = 197,
[""] = 198,
[""] = 199,
[""] = 200,
[""] = 201,
[""] = 202,
[""] = 58,
[""] = 68,
[""] = 203,
[""] = 59,
[""] = 69,
[""] = 204,
[""] = 60,
[""] = 70,
[""] = 205,
[""] = 61,
[""] = 71,
[""] = 206,
[""] = 62,
[""] = 72,
[""] = 207,
[""] = 208,
[""] = 209,
[""] = 210,
[""] = 211,
[""] = 224,
[""] = 212,
[""] = 225,
[""] = 213,
[""] = 226,
[""] = 214,
[""] = 215,
[""] = 216,
[""] = 217,
[""] = 218,
[""] = 219,
[""] = 220,
[""] = 221,
[""] = 222,
[""] = 233,
[""] = 128,
[""] = 176,
[""] = 129,
[""] = 234,
[""] = 130,
[""] = 235,
[""] = 131,
[""] = 244,
[""] = 132,
[""] = 133,
[""] = 5,
[""] = 134,
[""] = 6,
[""] = 135,
[""] = 7,
[""] = 136,
[""] = 8,
[""] = 137,
[""] = 9,
[""] = 138,
[""] = 10,
[""] = 139,
[""] = 11,
[""] = 140,
[""] = 12,
[""] = 141,
[""] = 13,
[""] = 142,
[""] = 14,
[""] = 143,
[""] = 15,
[""] = 144,
[""] = 16,
[""] = 172,
[""] = 145,
[""] = 17,
[""] = 146,
[""] = 18,
[""] = 147,
[""] = 19,
[""] = 148,
[""] = 149,
[""] = 150,
[""] = 151,
[""] = 152,
[""] = 153,
[""] = 25,
[""] = 64,
[""] = 154,
[""] = 26,
[""] = 65,
[""] = 155,
[""] = 27,
[""] = 66,
[""] = 156,
[""] = 28,
[""] = 67,
[""] = 157,
[""] = 158,
[""] = 159,
[""] = 160,
[""] = 161,
[""] = 173,
[""] = 162,
[""] = 174,
[""] = 163,
[""] = 175,
[""] = 164,
[""] = 165,
[""] = 166,
[""] = 167,
[""] = 168,
[""] = 169,
[""] = 170,
[""] = 171,
[""] = 227,
[""] = 240,
[""] = 231,
[""] = 242,
[""] = 243,
[""] = 246,
[""] = 247,
[""] = 248,
[""] = 249,
[""] = 250,
[""] = 251,
[""] = 252,
[""] = 253,
[""] = 254,
[""] = 255,
[""] = 230,
[""] = 229,
[""] = 228,
},
source = "pokered constants/charmap.asm",
}
File diff suppressed because it is too large Load Diff
+55 -9
View File
@@ -261,7 +261,9 @@ function Commands.move_player(ctx, dir, tiles)
end
function Commands.move_npc(ctx, objIndex, dir, tiles)
local npc = ctx.overworld:npcByIndex(objIndex)
local ow = ctx.overworld
if not ow then return end
local npc = ow:npcByIndex(objIndex)
if npc then walkEntity(ctx, npc, dir, tiles) end
end
@@ -308,6 +310,7 @@ end
function Commands.move_npc_to(ctx, objIndex, tx, ty)
local ow = ctx.overworld
if not ow then return end
local npc = ow:npcByIndex(objIndex)
if not npc then return end
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc)
@@ -339,6 +342,20 @@ function Commands.face_object(ctx, objIndex, dir)
if npc then npc.facing = dir end
end
-- Instantly relocate an NPC (OaksLabCalcRivalMovementScript / SetSpritePosition1).
-- facing is optional. Headless-safe no-op without an overworld.
function Commands.place_npc(ctx, objIndex, x, y, facing)
local ow = ctx.overworld
if not ow then return end
local npc = ow:npcByIndex(objIndex)
if not npc then return end
npc.cellX, npc.cellY = x, y
npc.px, npc.py = x * 16, y * 16
npc.moving = false
npc.targetX, npc.targetY = nil, nil
if facing then npc.facing = facing end
end
function Commands.face_npc(ctx)
-- make the player face the talking NPC
if ctx.npc and ctx.overworld then
@@ -485,7 +502,7 @@ function Commands.heal_party(ctx)
end
-- AskName (engine/menus/naming_screen.asm): yes/no then NamingScreen.
-- AddPartyMon only offers this for party mons (box deposits skip it).
-- AddPartyMon and SendNewMonToBox both offer this.
-- Preserves ctx.lastCheck so GivePokemon's carry is not clobbered by the
-- yes/no result (scripts jump_if_false on give failure afterwards).
local function askNickname(ctx, mon)
@@ -527,8 +544,9 @@ end
-- the asm's carry: true when the mon was given, false when both the
-- party and every box are full (that .boxFull path leaves the giver's
-- script able to offer again later, e.g. the Celadon Eevee ball).
-- Party adds run AskName (AddPartyMon) when a script runner is present;
-- mods that pre-set gift.nickname skip the prompt.
-- AskName runs for party (AddPartyMon) and box (SendNewMonToBox) when a
-- script runner is present; mods that pre-set gift.nickname skip it.
-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37).
function Commands.give_pokemon(ctx, species, level)
-- 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:
@@ -540,14 +558,17 @@ function Commands.give_pokemon(ctx, species, level)
end
local Pokemon = require("src.pokemon.Pokemon")
local Party = require("src.pokemon.Party")
local Boxes = require("src.pokemon.Boxes")
local mon = Pokemon.new(ctx.game.data, species, level)
if gift.nickname then mon.nickname = gift.nickname end
ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species
ctx.pendingPokemonName = species
require("src.battle.BattleState").stampOT(ctx.save, mon)
local addedToParty = Party.add(ctx.save.party, mon)
local boxNum = nil
if not addedToParty then
if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then
boxNum = Boxes.deposit(ctx.save, mon)
if not boxNum then
ctx.lastCheck = false
return
end
@@ -558,11 +579,30 @@ function Commands.give_pokemon(ctx, species, level)
dex.owned[species] = true
end
ctx.lastCheck = true
-- AddPartyMon AskName: party only; skip box deposits, mod-set nicknames,
-- and callback-style callers that have no script runner to yield on.
if addedToParty and not gift.nickname and ctx.runner then
ctx.addedToParty = addedToParty
ctx.boxNum = boxNum
-- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks
-- and callback-style callers with no script runner to yield on.
if not gift.nickname and ctx.runner then
askNickname(ctx, mon)
end
if boxNum then
local name = mon.nickname
or (ctx.game.data.pokemon[species] and ctx.game.data.pokemon[species].name)
or species
ctx.game.boxMonNicks = name
ctx.game.stringBuffer = tostring(boxNum)
if ctx.runner then
Commands.show_text(ctx, "_SentToBoxText")
else
local TextBox = require("src.render.TextBox")
local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText
ctx.game.stack:push(TextBox.new(ctx.game, raw or (
"There's no more\nroom for POKéMON!\v" .. name
.. " was\vsent to POKéMON\vBOX " .. boxNum .. " on PC!"),
function() end))
end
end
end
function Commands.give_money(ctx, amount)
@@ -783,6 +823,8 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
local newMon = Pokemon.new(data, trade.get, sent.level)
newMon.nickname = trade.nickname
newMon.traded = true -- boosted exp + Name Rater refusal (different OT)
newMon.ot = "TRAINER" -- InGameTrade_TrainerString
newMon.otId = (love.math and love.math.random or math.random)(0, 65535)
table.remove(party, slot)
table.insert(party, newMon)
local dex = ctx.save.pokedex
@@ -790,9 +832,13 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
dex.seen[trade.get] = true
dex.owned[trade.get] = true
end
-- the trade machine animation (engine/movie/trade.asm)
-- InternalClockTradeAnim (engine/movie/trade.asm)
Screens.push(ctx.game, "TradeAnim", {
sent = sent, received = newMon,
enemyName = "TRAINER",
playerOt = ctx.save.player.name,
playerOtId = sent.otId or ctx.save.player.id,
enemyOtId = newMon.otId,
onDone = function() runner:resume() end,
})
runner:yield()
+6
View File
@@ -331,6 +331,12 @@ end
local function useItem(game, battle, id, list)
local def = game.data.items[id]
-- ItemUseTMHM checks wIsInBattle before BootedUpTMText
if battle and def and def.machine then
local _, payload = ItemEffects.use(game.data, game.save, id, nil, battle)
showMessages(game, payload)
return
end
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
-- TMs/HMs boot up and announce their move before the target picker
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
+98 -21
View File
@@ -3,9 +3,11 @@
-- plus CHANGE BOX.
local Boxes = require("src.pokemon.Boxes")
local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local Party = require("src.pokemon.Party")
local TextBox = require("src.render.TextBox")
local BoxMenu = {}
@@ -14,6 +16,11 @@ local function monLabel(game, mon)
return ("%s :L%d"):format(mon.nickname or def.name, mon.level)
end
local function monName(game, mon)
local def = game.data.pokemon[mon.species]
return mon.nickname or def.name
end
-- Per-mon submenu (bills_pc.asm DisplayDepositWithdrawMenu): the chosen
-- action + STATS + CANCEL. STATS shows the status screen and returns
-- here; CANCEL/B goes back to the list.
@@ -31,8 +38,26 @@ local function monSubmenu(game, action, mon, onAction)
}, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
end
-- After a successful transfer: close the mon list (BoxMenu stays beneath
-- via keepOpen) and show the taken-out / stored text (jp BillsPCMenu).
local function afterTransfer(game, list, text)
list:close()
game.stack:push(TextBox.new(game, text))
end
local function withdraw(game)
local box = Boxes.active(game.save)
local t = game.data.text
if #box == 0 then
game.stack:push(TextBox.new(game, t._NoMonText
or "What? There are\nno POKéMON here!"))
return
end
if #game.save.party >= Party.MAX then
game.stack:push(TextBox.new(game, t._CantTakeMonText
or "You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."))
return
end
local items = {}
for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i })
@@ -49,13 +74,29 @@ local function withdraw(game)
end
table.remove(box, item.value)
table.insert(game.save.party, mon)
list:close()
local name = monName(game, mon)
game.stringBuffer = name
require("src.core.Sound").playCry(game.data, mon.species)
afterTransfer(game, list, t._MonIsTakenOutText
or (name .. " is\ntaken out.\vGot " .. name .. "."))
end)
end,
}))
end
local function deposit(game)
local t = game.data.text
if #game.save.party <= 1 then
game.stack:push(TextBox.new(game, t._CantDepositLastMonText
or "You can't deposit\nthe last POKéMON!"))
return
end
local box = Boxes.active(game.save)
if #box >= Boxes.CAPACITY then
game.stack:push(TextBox.new(game, t._BoxFullText
or "Oops! This Box is\nfull of POKéMON."))
return
end
local items = {}
for i, mon in ipairs(game.save.party) do
table.insert(items, { label = monLabel(game, mon), value = i })
@@ -69,40 +110,50 @@ local function deposit(game)
list.footer = "You need at least\none POKéMON!"
return
end
local box = Boxes.active(game.save)
if #box >= Boxes.CAPACITY then
local active = Boxes.active(game.save)
if #active >= Boxes.CAPACITY then
list.footer = ("BOX %d is full!"):format(game.save.currentBox)
return
end
table.remove(game.save.party, item.value)
table.insert(box, mon)
list:close()
table.insert(active, mon)
local name = monName(game, mon)
game.stringBuffer = name
game.boxNumString = tostring(game.save.currentBox)
require("src.core.Sound").playCry(game.data, mon.species)
afterTransfer(game, list, t._MonWasStoredText
or (name .. " was\nstored in Box " .. game.boxNumString .. "."))
end)
end,
}))
end
-- RELEASE POKéMON (bills_pc.asm .release): confirm, then "Bye [MON]!"
-- RELEASE POKéMON (bills_pc.asm BillsPCRelease): confirm, then "Bye [MON]!".
-- Index by list.index (not stale item.value) after removeCurrent (#171).
local function release(game)
local box = Boxes.active(game.save)
local t = game.data.text
if #box == 0 then
game.stack:push(TextBox.new(game, t._NoMonText
or "What? There are\nno POKéMON here!"))
return
end
local items = {}
for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game,
("BOX %d (RELEASE)"):format(game.save.currentBox), items, {
onChoose = function(item, list)
local mon = box[item.value]
onChoose = function(_, list)
local mon = box[list.index]
if not mon then return end
local def = game.data.pokemon[mon.species]
local name = mon.nickname or def.name
local name = monName(game, mon)
local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
"Once released,\n" .. name .. " is\ngone forever. OK?", function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end
table.remove(box, item.value)
table.remove(box, list.index)
require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game,
("%s was\nreleased outside.\fBye %s!"):format(name, name)))
@@ -129,7 +180,6 @@ local function changeBox(game)
-- the original asks BEFORE switching ("When you change a #MON
-- BOX, data will be saved. OK?"); declining aborts the change
local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
"When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function()
game.stack:push(ChoiceBox.new(game, function(yes)
@@ -143,21 +193,48 @@ local function changeBox(game)
}))
end
-- bills_pc.asm BillsPCMenu chrome: What? text box + BOX No. overlay
local function drawChrome(game)
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("What?", 8, 112)
Font.drawBox(9, 14, 11, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("BOX No.", 80, 128)
local n = game.save.currentBox or 1
if n >= 10 then
Font.draw(tostring(n), 136, 128)
else
Font.draw(tostring(n), 144, 128)
end
love.graphics.setColor(1, 1, 1, 1)
end
function BoxMenu.new(game)
Boxes.ensure(game.save)
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
-- 12x10 → total 14x12. "CHANGE BOX" is 10 tiles and needs the
-- full interior (cursor col + label); the old tw=12 right-side box
-- drew the final glyph on the border.
return Menu.new(game, {
{ label = "WITHDRAW", onSelect = function() withdraw(game) end },
{ label = "DEPOSIT", onSelect = function() deposit(game) end },
{ label = "RELEASE", onSelect = function() release(game) end },
{ label = "CHANGE BOX", onSelect = function() changeBox(game) end },
-- 12x10 → total 14x12. "CHANGE BOX" / "WITHDRAW <PK><MN>" need the
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
local menu = Menu.new(game, {
{ label = "WITHDRAW <PK><MN>", keepOpen = true,
onSelect = function() withdraw(game) end },
{ label = "DEPOSIT <PK><MN>", keepOpen = true,
onSelect = function() deposit(game) end },
{ label = "RELEASE <PK><MN>", keepOpen = true,
onSelect = function() release(game) end },
{ label = "CHANGE BOX", keepOpen = true,
onSelect = function() changeBox(game) end },
{ label = "SEE YA!" },
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/pokemon_pc.asm)
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
local baseDraw = menu.draw
function menu:draw()
baseDraw(self)
drawChrome(game)
end
return menu
end
return BoxMenu
+12 -7
View File
@@ -1,4 +1,4 @@
-- YES/NO choice box (top-left of the text box area, like the original).
-- YES/NO choice box (InitYesNoTextBoxParameters: above the text box, right).
local Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
@@ -14,6 +14,11 @@ function ChoiceBox.new(game, onChoose, opts)
self.index = (opts and opts.defaultNo) and 2 or 1
-- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent
self.noSound = (opts and opts.noSound) or false
local box = Theme.choiceBox
self.tx = (opts and opts.tx) or box.tx
self.ty = (opts and opts.ty) or box.ty
self.tw = (opts and opts.tw) or box.tw
self.th = (opts and opts.th) or box.th
return self
end
@@ -38,13 +43,13 @@ function ChoiceBox:update(dt)
end
function ChoiceBox:draw()
local box = Theme.choiceBox
Font.drawBox(box.tx, box.ty, box.tw, box.th)
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("YES", (box.tx + 2) * 8, (box.ty + 1) * 8)
Font.draw("NO", (box.tx + 2) * 8, (box.ty + 3) * 8)
Font.drawCode(Theme.cursor, (box.tx + 1) * 8,
(box.ty + (self.index == 1 and 1 or 3)) * 8)
Font.draw("YES", (tx + 2) * 8, (ty + 1) * 8)
Font.draw("NO", (tx + 2) * 8, (ty + 3) * 8)
Font.drawCode(Theme.cursor, (tx + 1) * 8,
(ty + (self.index == 1 and 1 or 3)) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
+10 -5
View File
@@ -37,8 +37,12 @@ function ListMenu.new(game, title, items, opts)
-- text box, a money box sits top-right, and the list shortens to
-- clear them (DisplayPokemartDialogue_'s screen)
self.dialogue = opts.dialogue
-- PC item lists (players_pc.asm): PrintListMenuEntries shows 4 names
-- and PrintText footers ("How many?", stored/withdrew) use the standard
-- bottom text box — same row budget as the mart, without the money box.
self.messageBox = opts.messageBox
self.money = opts.money -- () -> current money for the box
self.rows = opts.dialogue and 4 or ROWS
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
return self
end
@@ -137,8 +141,10 @@ function ListMenu:draw()
love.graphics.setColor(0, 0, 0, 1)
local money = ("¥%d"):format(self.money and self.money() or 0)
Font.draw(money, 152 - Font.width(money), 8)
-- the clerk's line in the standard bottom text box; long prompts
-- wrap and keep their last two lines, like the GB's scrolled box
end
if self.dialogue or (self.messageBox and self.footer) then
-- standard bottom text box (PrintText); long prompts wrap and keep
-- their last two lines, like the GB's scrolled box (#115/#174)
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if self.footer then
@@ -153,8 +159,7 @@ function ListMenu:draw()
end
end
elseif self.footer then
-- PC deposit/withdraw footers use "\n"; draw the last two lines so
-- long item names are not clipped at the screen edge (#115).
-- bare footer (bag money line, etc.)
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
+24 -12
View File
@@ -22,6 +22,8 @@ function MoveLearnMenu.new(game, mon, newMoveId, onDone)
self.newMoveId = newMoveId
self.onDone = onDone
self.index = 1
-- forget-list UI only after TryingToLearn YES (learn_move.asm .loop)
self.selecting = false
return self
end
@@ -31,24 +33,32 @@ end
-- TryingToLearnText + yes/no (learn_move.asm TryingToLearn): NO offers
-- AbandonLearning, whose own NO loops back here (DontAbandonLearning).
-- Use TextBox opts.choice so YES/NO overlays the still-visible prompt;
-- pushing ChoiceBox from onDone pops the text first and leaves YES/NO
-- on "Which move should be forgotten?" (#173).
function MoveLearnMenu:enter()
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local game = self.game
local mdef = game.data.moves[self.newMoveId]
local name = self:monName()
self.selecting = false
game.stack:push(TextBox.new(game,
("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f")
:format(name, mdef.name, name) ..
("Delete an older\nmove to make room\vfor %s?"):format(mdef.name),
function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then self:confirmAbandon() end
end))
end))
nil, {
choice = function(yes)
if yes then
self.selecting = true
else
self:confirmAbandon()
end
end,
}))
end
function MoveLearnMenu:update(dt)
if not self.selecting then return end
local input = self.game.input
local n = #self.mon.moves + 1 -- moves + CANCEL
if input:wasPressed("up") then
@@ -82,15 +92,15 @@ end
-- (DontAbandonLearning)
function MoveLearnMenu:confirmAbandon()
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local game = self.game
local mdef = game.data.moves[self.newMoveId]
self.selecting = false
game.stack:push(TextBox.new(game,
("Abandon learning\n%s?"):format(mdef.name), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if yes then self:finish(false) else self:enter() end
end))
end))
("Abandon learning\n%s?"):format(mdef.name), nil, {
choice = function(yes)
if yes then self:finish(false) else self:enter() end
end,
}))
end
function MoveLearnMenu:finish(learned)
@@ -98,6 +108,7 @@ function MoveLearnMenu:finish(learned)
local game = self.game
local name = self:monName()
local mdef = game.data.moves[self.newMoveId]
self.selecting = false
game.stack:pop()
local msg
if learned then
@@ -114,6 +125,7 @@ function MoveLearnMenu:finish(learned)
end
function MoveLearnMenu:draw()
if not self.selecting then return end
-- single-spaced move list box (TryingToLearn: TextBoxBorder at 4,7)
-- plus the port's extra CANCEL row
Font.drawBox(4, 5, 16, 7)
+30
View File
@@ -10,6 +10,7 @@
-- bottom line like pokered's.
local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local Zoom = require("src.render.Zoom")
@@ -193,6 +194,15 @@ local function buildRows(game)
local o = g.save.options
o.tilt = wrapIndex((o.tilt or 0) + dir, 4)
Tilt.setLevel(o.tilt)
-- tilt and a mod's world pipeline are two answers to the same
-- question; turning this on switches that off (Pipelines does the
-- same in the other direction)
if o.tilt > 0 then
for _, entry in ipairs(Pipelines.list()) do
if entry.def.drawWorld then Pipelines.setLevel(entry.id, 0) end
end
Pipelines.syncOptions(o)
end
return true
end },
{ id = "gbcfx", label = "GBC FX",
@@ -295,6 +305,26 @@ local function buildRows(game)
end
rows = filtered
end
-- A mod's render pipelines are display modes like TILT, so their rows sit
-- with it rather than at the end of the list where a mod's own
-- ui.options.rows additions land. Nothing registered means nothing
-- spliced, so a vanilla install sees the list it always had.
local pipelineRows = Pipelines.rows(game)
if pipelineRows[1] then
local merged = {}
for _, row in ipairs(rows) do
merged[#merged + 1] = row
if row.id == "tilt" then
for _, extra in ipairs(pipelineRows) do merged[#merged + 1] = extra end
end
end
-- no TILT row to anchor to (a future build could drop it): append
-- rather than silently lose the modes
if #merged == #rows then
for _, extra in ipairs(pipelineRows) do merged[#merged + 1] = extra end
end
rows = merged
end
return rows
end
+71 -49
View File
@@ -1,8 +1,11 @@
-- Party menu: list the party, choose a member.
-- Modes:
-- default: A -> submenu (STATS / SWITCH order / CANCEL)
-- opts.onSwitch: A -> hand the chosen mon to the callback (battle
-- switch, item targeting via opts.pickOnly)
-- default: A -> submenu (STATS / SWITCH / field moves)
-- opts.onSwitch + opts.battle (voluntary PKMN): A -> SWITCH / STATS /
-- CANCEL (core.asm PartyMenuOrRockOrRun), then onSwitch on SWITCH
-- opts.onSwitch + opts.forceSwitch: A -> onSwitch immediately
-- (ChooseNextMon / SHIFT free-switch)
-- opts.pickOnly + opts.onSwitch: A -> onSwitch (item / script target)
-- opts.onCancel: fired when the menu closes without a pick (B)
-- Pops itself on B.
@@ -125,6 +128,7 @@ function PartyMenu.new(game, opts)
self.onSwitch = opts.onSwitch
self.onCancel = opts.onCancel
self.pickOnly = opts.pickOnly
self.forceSwitch = opts.forceSwitch
self.battle = opts.battle
self.party = opts.party -- link battles pass their clamped copies
self.swapFrom = nil
@@ -156,7 +160,17 @@ function PartyMenu:update(dt)
-- hook-injected entries carry a callback instead of an action id
entry.onSelect(mon, self.game)
elseif action == "stats" then
-- battle and field alike return to the party list afterwards
-- (core.asm .partyMenuWasSelected)
Screens.push(self.game, "SummaryMenu", mon)
elseif action == "battle_switch" then
self.game.stack:pop()
self.onSwitch(mon)
return
elseif action == "cancel" then
self.game.stack:pop()
if self.onCancel then self.onCancel() end
return
elseif action == "switch" then
self.swapFrom = self.index
elseif action == "fly" then
@@ -341,58 +355,66 @@ function PartyMenu:update(dt)
require("src.core.Sound").play(self.game.data, "Swap")
end
self.swapFrom = nil
elseif self.onSwitch then
elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then
self.game.stack:pop()
self.onSwitch(mon)
else
self.submenu = true
self.subIndex = 1
-- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
-- builds the same dynamic list)
local items = { { label = "STATS", action = "stats" },
{ label = "SWITCH", action = "switch" } }
local items
local ow = self.game.overworld
-- Field moves (HMs/TMs) are usable out of battle even when the mon
-- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
-- Battle still excludes this list via `not self.battle`. Softboiled
-- can appear for a fainted user; its heal transfer then no-ops.
if not self.battle and ow then
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU —
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
local outside = Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets"))
for _, mv in ipairs(mon.moves) do
if mv.id == "FLY" and outside
and self.game.save.inventory.THUNDERBADGE then
table.insert(items, { label = "FLY", action = "fly" })
elseif mv.id == "FLASH" and ow.dark
and self.game.save.inventory.BOULDERBADGE then
table.insert(items, { label = "FLASH", action = "flash" })
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
-- CUT/SURF/STRENGTH are party-menu field moves too
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
-- with the same list-time badge filter this file already uses
-- for FLY/FLASH. The facing-tile/activation check happens on
-- selection (useCutFieldMove/useSurfFieldMove).
table.insert(items, { label = "CUT", action = "cut" })
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
table.insert(items, { label = "SURF", action = "surf" })
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
table.insert(items, { label = "STRENGTH", action = "strength" })
elseif mv.id == "SOFTBOILED" then
table.insert(items, { label = "SOFTBOILED", action = "softboiled" })
elseif mv.id == "TELEPORT" and outside then
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
-- .teleport -> CheckIfInOutsideMap); dark maps don't
-- block it
table.insert(items, { label = "TELEPORT", action = "escape" })
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
and ow.map.id ~= "AGATHAS_ROOM" then
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
-- ESCAPE_ROPE): usable in the dungeon tilesets of
-- escape_rope_tilesets.asm minus Agatha's room, even in
-- the dark (Rock Tunnel)
table.insert(items, { label = "DIG", action = "escape" })
if self.battle and self.onSwitch then
-- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun)
items = { { label = "SWITCH", action = "battle_switch" },
{ label = "STATS", action = "stats" },
{ label = "CANCEL", action = "cancel" } }
else
-- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
-- builds the same dynamic list)
items = { { label = "STATS", action = "stats" },
{ label = "SWITCH", action = "switch" } }
-- Field moves (HMs/TMs) are usable out of battle even when the mon
-- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
-- Battle still excludes this list via `not self.battle`. Softboiled
-- can appear for a fainted user; its heal transfer then no-ops.
if not self.battle and ow then
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU —
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
local outside = Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets"))
for _, mv in ipairs(mon.moves) do
if mv.id == "FLY" and outside
and self.game.save.inventory.THUNDERBADGE then
table.insert(items, { label = "FLY", action = "fly" })
elseif mv.id == "FLASH" and ow.dark
and self.game.save.inventory.BOULDERBADGE then
table.insert(items, { label = "FLASH", action = "flash" })
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
-- CUT/SURF/STRENGTH are party-menu field moves too
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
-- with the same list-time badge filter this file already uses
-- for FLY/FLASH. The facing-tile/activation check happens on
-- selection (useCutFieldMove/useSurfFieldMove).
table.insert(items, { label = "CUT", action = "cut" })
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
table.insert(items, { label = "SURF", action = "surf" })
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
table.insert(items, { label = "STRENGTH", action = "strength" })
elseif mv.id == "SOFTBOILED" then
table.insert(items, { label = "SOFTBOILED", action = "softboiled" })
elseif mv.id == "TELEPORT" and outside then
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
-- .teleport -> CheckIfInOutsideMap); dark maps don't
-- block it
table.insert(items, { label = "TELEPORT", action = "escape" })
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
and ow.map.id ~= "AGATHAS_ROOM" then
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
-- ESCAPE_ROPE): usable in the dungeon tilesets of
-- escape_rope_tilesets.asm minus Agatha's room, even in
-- the dark (Rock Tunnel)
table.insert(items, { label = "DIG", action = "escape" })
end
end
end
end
+6 -3
View File
@@ -71,6 +71,7 @@ end
local function withdraw(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
messageBox = true,
onChoose = function(item, list)
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag")
@@ -107,6 +108,7 @@ local function deposit(game)
if not Bag.isBadge(id) then depositable[id] = count end
end
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
messageBox = true,
onChoose = function(item, list)
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
if pcFull(game, pc, item.value) then
@@ -126,6 +128,7 @@ end
local function toss(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
messageBox = true,
onChoose = function(item, list)
local def = game.data.items[item.value]
if (def and def.keyItem) or item.value:find("^HM_") then
@@ -161,9 +164,9 @@ function PlayerPC.new(game)
{ label = "DEPOSIT ITEM", onSelect = function() deposit(game) end },
{ label = "TOSS ITEM", onSelect = function() toss(game) end },
{ label = "LOG OFF" },
-- the whole PC session runs silent (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/players_pc.asm PlayersPCMenu)
}, { tx = 3, ty = 0, tw = 17, th = 10, noSound = true })
-- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm
-- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10
}, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true })
end
return PlayerPC
+8 -4
View File
@@ -40,15 +40,19 @@ function QuantityBox:update(dt)
end
function QuantityBox:draw()
local w = self.unitPrice and 11 or 7
local tx = 20 - w - 1
Font.drawBox(tx, 13, w, 3)
-- DisplayChooseQuantityMenu (home/list_menu.asm): non-priced box at
-- hlcoord 15,9 (interior 3x1); priced at hlcoord 7,9 (interior 11x1).
-- TextBoxBorder adds the frame, so outer size is +2 on each axis.
local tw = self.unitPrice and 13 or 5
local tx = self.unitPrice and 7 or 15
local ty = 9
Font.drawBox(tx, ty, tw, 3)
love.graphics.setColor(0, 0, 0, 1)
local s = ("×%02d"):format(self.qty) -- the multiply glyph tile
if self.unitPrice then
s = s .. (" ¥%d"):format(self.qty * self.unitPrice)
end
Font.draw(s, (tx + 1) * 8, 14 * 8)
Font.draw(s, (tx + 1) * 8, (ty + 1) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
+2 -1
View File
@@ -15,7 +15,8 @@ local Theme = {
cols = Renderer.WIDTH / 8,
rows = Renderer.HEIGHT / 8,
textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 },
choiceBox = { tx = 0, ty = 7, tw = 6, th = 5 },
-- InitYesNoTextBoxParameters / AskName: hlcoord 14, 7 (YES_NO_MENU 4x3)
choiceBox = { tx = 14, ty = 7, tw = 6, th = 5 },
}
function Theme.load(data)
+427 -45
View File
@@ -1,8 +1,7 @@
-- Link/in-game trade cinematic (engine/movie/trade.asm, trade2.asm):
-- the traded POKéMON rises away with its cry and a goodbye, then the
-- received one descends with its cry and "take good care" text.
-- A skips the slide animations ahead. Calls onDone() after popping.
-- InternalClockTradeAnim (engine/movie/trade.asm): cable-trade cinematic
-- used by in-game NPC trades and internally-clocked link trades.
local Font = require("src.render.Font")
local Sound = require("src.core.Sound")
local TextBox = require("src.render.TextBox")
@@ -10,13 +9,22 @@ local TradeAnim = {}
TradeAnim.__index = TradeAnim
TradeAnim.isOpaque = true
-- SGB: generic whole-screen palette (SET_PAL_GENERIC)
function TradeAnim:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end
local SLIDE_FRAMES = 90
local REST_Y = 44 -- resting top of the sprite, roughly screen centre
local DEFAULT_ART = {
gameBoy = "assets/generated/trade/game_boy.png",
openCable = "assets/generated/trade/open_cable.png",
cableHoriz = "assets/generated/trade/cable_horiz.png",
cableConn = "assets/generated/trade/cable_conn.png",
cableVert = "assets/generated/trade/cable_vert.png",
cableCorner = "assets/generated/trade/cable_corner.png",
cableEnd = "assets/generated/trade/cable_end.png",
cableBall = "assets/generated/trade/cable_ball.png",
cableBallAlt = "assets/generated/trade/cable_ball_alt.png",
bubble = "assets/generated/trade/bubble.png",
}
local function tryImage(path)
if not path then return nil end
@@ -29,11 +37,46 @@ local function nameOf(game, mon)
return mon.nickname or (def and def.name) or mon.species
end
local function speciesName(game, mon)
local def = game.data.pokemon[mon.species]
return (def and def.name) or mon.species
end
local function dexOf(game, mon)
local def = game.data.pokemon[mon.species]
return def and def.dex or 0
end
local function spriteOf(game, mon)
local def = game.data.pokemon[mon.species]
return tryImage(def and def.spriteFront)
end
local function expand(game, key, subs)
local raw = game.data.text and game.data.text[key]
if not raw then return key end
for token, value in pairs(subs or {}) do
raw = raw:gsub("{" .. token .. "}", value)
end
return TextBox.substitute(game, raw)
end
-- InternalClockTradeFuncSequence
local SEQ = {
"show_player",
"open_cable",
"ball_enter",
"transfer_lr",
"delay",
"went_to",
"for_sends",
"farewell",
"transfer_rl",
"open_cable2",
"show_enemy",
"done",
}
function TradeAnim.new(game, opts)
opts = opts or {}
local self = setmetatable({}, TradeAnim)
@@ -41,62 +84,401 @@ function TradeAnim.new(game, opts)
self.sent = opts.sent
self.received = opts.received
self.onDone = opts.onDone
self.enemyName = opts.enemyName or (self.received and self.received.ot) or "TRAINER"
self.playerName = (game.save.player and game.save.player.name) or "RED"
self.playerOt = opts.playerOt or self.playerName
self.playerOtId = opts.playerOtId
or (self.sent and self.sent.otId)
or (game.save.player and game.save.player.id)
or 0
self.enemyOtId = opts.enemyOtId
or (self.received and self.received.otId)
or love.math.random(0, 65535)
local art = (game.data.field and game.data.field.tradeArt) or DEFAULT_ART
self.img = {
gameBoy = tryImage(art.gameBoy or DEFAULT_ART.gameBoy),
openCable = tryImage(art.openCable or DEFAULT_ART.openCable),
cableHoriz = tryImage(art.cableHoriz or DEFAULT_ART.cableHoriz),
cableConn = tryImage(art.cableConn or DEFAULT_ART.cableConn),
cableVert = tryImage(art.cableVert or DEFAULT_ART.cableVert),
cableCorner = tryImage(art.cableCorner or DEFAULT_ART.cableCorner),
cableEnd = tryImage(art.cableEnd or DEFAULT_ART.cableEnd),
cableBall = tryImage(art.cableBall or DEFAULT_ART.cableBall),
cableBallAlt = tryImage(art.cableBallAlt or DEFAULT_ART.cableBallAlt),
bubble = tryImage(art.bubble or DEFAULT_ART.bubble),
}
self.sentSprite = spriteOf(game, self.sent)
self.receivedSprite = spriteOf(game, self.received)
self.phase = "out"
self.recvSprite = spriteOf(game, self.received)
self.seq = 1
self.phase = SEQ[1]
self.t = 0
self.scx = 0
self.ballX = 0
self.ballY = 0
self.monX = 0
self.monY = 0
self.flash = false
self.monVisible = true
self.waitingText = false
self.cableFlash = false
return self
end
function TradeAnim:enter()
Sound.playCry(self.game.data, self.sent.species)
Sound.play(self.game.data, "Trade_Machine")
end
function TradeAnim:advance()
self.seq = self.seq + 1
self.phase = SEQ[self.seq] or "done"
self.t = 0
self.scx = 0
self.sub = nil
self.flash = false
self.cableFlash = false
self.poof = nil
if self.phase == "done" then
self.game.stack:pop()
if self.onDone then self.onDone() end
elseif self.phase == "show_enemy" then
self.monVisible = false
elseif self.phase == "transfer_lr" then
-- wBaseCoord $54, $1c OAM -> screen (76, 12)
self.monX, self.monY = 76, 12
elseif self.phase == "transfer_rl" then
-- wBaseCoord $64, $44 OAM -> screen (92, 52), right GB on screen
self.monX, self.monY = 92, 52
self.scx = 160
elseif self.phase == "ball_enter" then
-- lb bc, $20, $60: b = Y, c = X (Trade_AnimateBallEnteringLinkCable)
self.ballX, self.ballY = 0x60, 0x20
elseif self.phase == "open_cable" or self.phase == "open_cable2" then
-- SCX $a0 -> $f0: cable slides in from the right, open end rests at x64
self.scx = 0x50
Sound.play(self.game.data, "Heal_HP")
end
end
function TradeAnim:say(text, delay, thenFn)
self.waitingText = true
self.game.stack:push(TextBox.new(self.game, text, function()
self.waitingText = false
if thenFn then thenFn() else self:advance() end
end, { auto = { delay = delay or 80 } }))
end
function TradeAnim:skipHeld()
return self.game.input:wasPressed("a") or self.game.input:isDown("a")
end
function TradeAnim:update(dt)
local input = self.game.input
if self.phase == "out" or self.phase == "in" then
self.t = self.t + 1
if input:wasPressed("a") then self.t = SLIDE_FRAMES end
if self.t < SLIDE_FRAMES then return end
if self.phase == "out" then
self.phase = "goodbye"
self.game.stack:push(TextBox.new(self.game,
("Goodbye %s!"):format(nameOf(self.game, self.sent)),
function()
self.phase = "in"
self.t = 0
Sound.playCry(self.game.data, self.received.species)
end))
if self.waitingText or self.phase == "done" then return end
local skip = self.game.input:wasPressed("a")
self.t = self.t + 1
local p = self.phase
if p == "show_player" then
-- slide from SCX $86 -> 0, hold 80, poof, cry (Trade_ShowPlayerMon)
if not self.sub then self.sub = "slide" end
if self.sub == "slide" then
self.scx = math.max(0, 0x86 - self.t * 2)
if skip or self.scx <= 0 then
self.scx = 0
self.sub = "hold"
self.t = 0
end
elseif self.sub == "hold" then
if skip or self.t >= 80 then
self.sub = "poof"
self.t = 0
self.monVisible = false
Sound.play(self.game.data, "Ball_Poof")
end
elseif self.sub == "poof" then
self.poof = math.max(0, 16 - self.t)
if skip or self.t >= 16 then
Sound.playCry(self.game.data, self.sent.species)
self.sub = nil
self:advance()
end
end
elseif p == "open_cable" or p == "open_cable2" then
-- 20 steps of 4px, matching the SCX loop
self.scx = math.max(0, 0x50 - self.t * 4)
if skip or self.scx <= 0 then
self.scx = 0
self:advance()
end
elseif p == "ball_enter" then
-- TRADE_BALL_SHAKE then ball rides the cable; X from $60 toward $a0
if self.t < 20 then
if skip then self.t = 20 end
return
end
local step = self.t - 20
if step % 3 == 0 then
self.ballX = 0x60 + math.floor(step / 3) * 4
self.flash = not self.flash
if self.ballX < 0xA0 then Sound.play(self.game.data, "Tink") end
end
if skip then self.ballX = 0xA0 end
if self.ballX >= 0xA0 then self:advance() end
elseif p == "transfer_lr" then
-- scroll left GB off while the mon rides the cable, then the sprite
-- itself moves right and down into the right GB (Trade_AnimMonMoveVertical)
if self.t <= 80 then
self.scx = math.min(160, self.t * 2)
self.monX, self.monY = 76, 12
elseif self.t <= 80 + 32 then
self.monX = 76 + math.floor((self.t - 80) / 8) * 4
elseif self.t <= 80 + 64 then
self.monX = 92
self.monY = 12 + math.floor((self.t - 112) / 8) * 10
else
self.phase = "takecare"
self.game.stack:push(TextBox.new(self.game,
("Take good care\nof %s!"):format(nameOf(self.game, self.received)),
function()
self.game.stack:pop()
if self.onDone then self.onDone() end
end))
self:advance()
return
end
if self.t % 8 == 0 then self.cableFlash = not self.cableFlash end
if skip then self:advance() end
elseif p == "transfer_rl" then
-- mirror: sprite climbs out of the right GB, then the screen scrolls
-- back to the left GB
if self.t <= 32 then
self.scx = 160
self.monX, self.monY = 92, 52 - math.floor(self.t / 8) * 10
elseif self.t <= 64 then
self.monX = 92 - math.floor((self.t - 32) / 8) * 4
self.monY = 12
elseif self.t <= 64 + 80 then
self.scx = math.max(0, 160 - (self.t - 64) * 2)
self.monX, self.monY = 76, 12
else
self:advance()
return
end
if self.t % 8 == 0 then self.cableFlash = not self.cableFlash end
if skip then self:advance() end
elseif p == "delay" then
if skip or self.t >= 100 then self:advance() end
elseif p == "went_to" then
local text = expand(self.game, "_TradeWentToText", {
["RAM:wStringBuffer"] = speciesName(self.game, self.sent),
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
})
self:say(text, 200)
elseif p == "for_sends" then
local a = expand(self.game, "_TradeForText", {
["RAM:wStringBuffer"] = speciesName(self.game, self.sent),
})
local b = expand(self.game, "_TradeSendsText", {
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
})
self.waitingText = true
self.game.stack:push(TextBox.new(self.game, a, function()
self.game.stack:push(TextBox.new(self.game, b, function()
self.waitingText = false
self:advance()
end, { auto = { delay = 80 } }))
end, { auto = { delay = 80 } }))
elseif p == "farewell" then
local a = expand(self.game, "_TradeWavesFarewellText", {
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
})
local b = expand(self.game, "_TradeTransferredText", {
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
})
self.waitingText = true
self.game.stack:push(TextBox.new(self.game, a, function()
self.game.stack:push(TextBox.new(self.game, b, function()
self.waitingText = false
self:advance()
end, { auto = { delay = 80 } }))
end, { auto = { delay = 80 } }))
elseif p == "show_enemy" then
if self.t == 1 then
Sound.play(self.game.data, "Ball_Poof")
self.monVisible = true
elseif self.t == 20 then
Sound.playCry(self.game.data, self.received.species)
elseif self.t >= 120 or skip then
local text = expand(self.game, "_TradeTakeCareText", {
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
})
self:say(text, 80)
end
end
end
local function drawCableHoriz(self, y, x0, x1)
if self.img.cableHoriz then
love.graphics.draw(self.img.cableHoriz, x0 - (self.scx % 8), y)
else
love.graphics.setColor(0.2, 0.2, 0.2, 1)
love.graphics.rectangle("fill", x0, y + 1, math.max(0, x1 - x0), 6)
love.graphics.setColor(1, 1, 1, 1)
end
end
function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
-- Trade_PrintPlayerMonInfoText: rows +0/+2/+4/+6 from the box top; the
-- No. line replaces part of the top border (hlcoord 5, 0 in trade2.asm)
Font.drawBox(4, boxTy, 12, 8)
local y0 = boxTy * 8
local no = ("No.%03d"):format(dexOf(self.game, mon))
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 56, y0, Font.width(no), 8)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(no, 56, y0)
Font.draw(speciesName(self.game, mon), 40, y0 + 16)
Font.draw("OT/" .. (ot or "????"), 40, y0 + 32)
Font.draw(("IDNo.%05d"):format(otId or 0), 40, y0 + 48)
love.graphics.setColor(1, 1, 1, 1)
end
function TradeAnim:drawIconInBubble(mon, x, y)
local spr = (mon.species == self.received.species) and self.recvSprite or self.sentSprite
if spr then
local sw, sh = spr:getDimensions()
local s = 16 / math.max(sw, sh)
love.graphics.draw(spr, x, y, 0, s, s)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 4, y + 4, 8, 8)
love.graphics.setColor(1, 1, 1, 1)
end
if self.img.bubble then
if not self.bubbleQuad then
local iw, ih = self.img.bubble:getDimensions()
self.bubbleQuad = love.graphics.newQuad(0, 0, 16, 16, iw, ih)
self.bubbleQuadAlt = ih >= 32
and love.graphics.newQuad(0, 16, 16, 16, iw, ih)
or self.bubbleQuad
end
local q = self.cableFlash and self.bubbleQuadAlt or self.bubbleQuad
love.graphics.draw(self.img.bubble, q, x - 8, y - 8)
end
end
function TradeAnim:drawGameBoy(x, y)
if self.img.gameBoy then
love.graphics.draw(self.img.gameBoy, x, y)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", x, y, 48, 64)
love.graphics.setColor(1, 1, 1, 1)
end
end
function TradeAnim:drawLeftGB()
-- cable from GB to the right edge
if self.img.cableConn then
love.graphics.draw(self.img.cableConn, 88, 32)
end
drawCableHoriz(self, 32, 96, 160)
self:drawGameBoy(40, 24)
Font.drawBox(4, 12, 9, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.playerName, 40, 112)
love.graphics.setColor(1, 1, 1, 1)
end
function TradeAnim:drawRightGB()
drawCableHoriz(self, 32, 0, 112)
if self.img.cableCorner then love.graphics.draw(self.img.cableCorner, 112, 32) end
if self.img.cableVert then
for i = 1, 4 do
love.graphics.draw(self.img.cableVert, 120, 40 + (i - 1) * 8)
end
end
if self.img.cableEnd then love.graphics.draw(self.img.cableEnd, 112, 72) end
if self.img.cableConn then love.graphics.draw(self.img.cableConn, 104, 72) end
self:drawGameBoy(56, 64)
Font.drawBox(6, 0, 9, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.enemyName, 56, 16)
love.graphics.setColor(1, 1, 1, 1)
end
function TradeAnim:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local sprite, y
if self.phase == "out" then
-- the sent mon rises up and off the screen
sprite = self.sentSprite
y = REST_Y - math.floor((self.t / SLIDE_FRAMES) * (REST_Y + 60))
elseif self.phase == "in" or self.phase == "takecare" then
-- the received mon descends into place
sprite = self.receivedSprite
local t = self.phase == "in" and self.t or SLIDE_FRAMES
y = -60 + math.floor((t / SLIDE_FRAMES) * (REST_Y + 60))
end
if sprite and y then
local w = sprite:getWidth()
love.graphics.draw(sprite, math.floor((160 - w) / 2), y)
local p = self.phase
if p == "show_player" then
love.graphics.push()
love.graphics.translate(-self.scx, 0)
-- mon pic on the BG at hlcoord 7, 2; info box on the window at
-- hWY $50, so it sits in the bottom half of the screen
if self.monVisible and self.sentSprite then
love.graphics.draw(self.sentSprite, 56, 16)
end
self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10)
love.graphics.pop()
if self.poof and self.poof > 0 then
love.graphics.setColor(0, 0, 0, self.poof / 16)
love.graphics.circle("fill", 80, 40, 20 - (self.poof or 0))
love.graphics.setColor(1, 1, 1, 1)
end
elseif p == "open_cable" or p == "open_cable2" then
love.graphics.push()
love.graphics.translate(self.scx, 0)
if self.img.openCable then
love.graphics.draw(self.img.openCable, 64, 16) -- open end at SCX $f0
end
love.graphics.pop()
elseif p == "ball_enter" then
if self.img.openCable then
love.graphics.draw(self.img.openCable, 64, 16)
end
-- OAM coords carry a (+8, +16) hardware offset
local ball = self.flash and (self.img.cableBallAlt or self.img.cableBall)
or self.img.cableBall
if ball then
love.graphics.draw(ball, self.ballX - 8, self.ballY - 16)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.circle("fill", self.ballX, self.ballY - 8, 6)
love.graphics.setColor(1, 1, 1, 1)
end
elseif p == "transfer_lr" or p == "transfer_rl" then
-- two-screen world: left GB scene at x0, right GB scene at x160;
-- the mon icon stays in screen space like the OAM sprite it ports
love.graphics.push()
love.graphics.translate(-self.scx, 0)
self:drawLeftGB()
love.graphics.translate(160, 0)
self:drawRightGB()
love.graphics.pop()
local mon = (p == "transfer_lr") and self.sent or self.received
self:drawIconInBubble(mon, self.monX, self.monY)
if self.cableFlash then
love.graphics.setColor(1, 1, 1, 0.15)
love.graphics.rectangle("fill", 0, 32, 160, 8)
love.graphics.setColor(1, 1, 1, 1)
end
elseif p == "show_enemy" then
if self.monVisible and self.recvSprite then
love.graphics.draw(self.recvSprite, 56, 16)
end
self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10)
end
-- went_to / for_sends / farewell / delay: cleared window; TextBox draws
end
return TradeAnim
+7 -7
View File
@@ -34,7 +34,8 @@ function TrainerCard.new(game)
local self = setmetatable({ game = game }, TrainerCard)
local img = tryImage("assets/generated/trainer_card/badges.png")
if img then
-- 8 pairs of [gym leader face, badge]
-- badges.2bpp is 8 stacked [face, badge] pairs (DrawBadges FaceBadgeTiles)
self.faces = { img = img, quads = quads16(img, 8, 32, 0, 0) }
self.badges = { img = img, quads = quads16(img, 8, 32, 0, 16) }
end
local nums = tryImage("assets/generated/trainer_card/badge_numbers.png")
@@ -124,7 +125,7 @@ function TrainerCard:draw()
love.graphics.setColor(0, 0, 0, 1)
end
-- numbered badge grid (rows 11-17): earned solid, unearned dimmed
-- numbered badge grid (rows 11-17): face by default, badge when owned
self:frameBox(0, 11, 20, 7)
local badges = Badges.list(self.game.data)
for i = 1, #badges do
@@ -136,12 +137,11 @@ function TrainerCard:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.nums.img, self.nums.quads[i - 1], tx, ty)
end
if self.badges and self.badges.quads[i - 1]
and save.inventory[Badges.itemFor(badges[i])] then
-- unearned badge slots stay blank (DrawBadges)
if self.faces and self.faces.quads[i - 1] then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.badges.img, self.badges.quads[i - 1],
tx + 4, ty + 6)
local owned = save.inventory[Badges.itemFor(badges[i])]
local sheet = owned and self.badges or self.faces
love.graphics.draw(sheet.img, sheet.quads[i - 1], tx + 4, ty + 6)
end
end
love.graphics.setColor(1, 1, 1, 1)
+263
View File
@@ -0,0 +1,263 @@
-- The boot shell: the heart of the self-updater. A fused build, before it
-- runs the game bundled inside it, looks in its save directory for a newer
-- payload (a downloaded gen1recomp-X.Y.Z.love), and if one is present and
-- runnable, mounts it over the bundled source and chainloads it -- so the
-- binary shipped once can keep updating the Lua it runs without a reinstall.
--
-- Only a fused build self-updates. A dev / source checkout IS the game, so
-- Boot.run is a no-op there.
--
-- Three pieces, deliberately layered so the risky part is small and the
-- decision part is testable:
-- * Boot.select -- pure: given probed candidates + the bundled version,
-- decide what to run and what to delete. No love.*.
-- * Boot.probePayload-- read one archive's advertised version, isolated.
-- * Boot.run -- orchestrates: crash-guard, enumerate, select, and
-- (if a payload wins) mount + chainload with full
-- rollback on any failure so the bundled game always
-- boots.
--
-- Known limitation: the bundled love.run keeps driving the frame loop after a
-- handoff (it has already returned its stepper to LÖVE; redefining the global
-- love.run does nothing to the running one). A payload that must change
-- love.run itself therefore requires a minShell bump so an older shell refuses
-- to chainload it.
local Semver = require("src.update.Semver")
local Boot = {}
-- Save-directory layout (identity "pokemon-love2d"), per the shared contract.
local PAYLOAD_DIR = "updates"
local PENDING = "updates/pending.txt"
-- Isolated mountpoint used only to peek at a candidate's Version.lua, so its
-- copy never collides with the running source's copy at "/".
local PROBE_MOUNT = "__pokeport_probe"
-- Downloaded payloads are named gen1recomp-<X.Y.Z>.love.
local function isPayloadName(name)
return name:match("^gen1recomp%-.+%.love$") ~= nil
end
-- The love callbacks the payload's main.lua chunk may redefine when it runs.
-- We snapshot these before a handoff and restore them if the handoff fails, so
-- the exact bundled closures (with their intact upvalues) drive the game
-- again. love.run is included: harmless to restore, and it is one of the
-- globals a payload main.lua reassigns.
local CALLBACK_NAMES = {
"load", "update", "draw", "quit", "run",
"keypressed", "keyreleased", "textinput",
"mousepressed", "mousereleased", "mousemoved", "wheelmoved",
"touchpressed", "touchmoved", "touchreleased",
"gamepadpressed", "gamepadreleased", "gamepadaxis", "joystickremoved",
"focus", "visible", "resize", "filedropped", "directorydropped",
"errorhandler", "threaderror", "lowmemory",
}
local function snapshotCallbacks()
local snap = {}
for _, k in ipairs(CALLBACK_NAMES) do snap[k] = love[k] end
return snap
end
local function restoreCallbacks(snap)
for _, k in ipairs(CALLBACK_NAMES) do love[k] = snap[k] end
end
-- Drop every bundled Lua module the payload must be allowed to re-resolve: all
-- src.* modules plus the main/conf chunks. conf.lua cached the bundled
-- Version into package.loaded["src.core.Version"]; without this purge the next
-- require would hand back the bundled copy instead of the payload's. Setting
-- existing fields to nil during a pairs traversal is explicitly permitted.
local function purgeBundledModules()
for key in pairs(package.loaded) do
if key:match("^src%.") or key == "main" or key == "conf" then
package.loaded[key] = nil
end
end
end
-- Boot.probePayload(rel) -> { engine = string, minShell = number } | nil, err
--
-- Mount the archive at rel (a save-directory-relative path) on an isolated
-- mountpoint, read its src/core/Version.lua by executing the source with
-- loadstring (NEVER require -- we must not cache or run it as a module), then
-- unmount. Version.lua is zero-require, so running its chunk is safe.
function Boot.probePayload(rel)
if not love.filesystem.mount(rel, PROBE_MOUNT) then
return nil, "could not mount " .. tostring(rel)
end
local chunkPath = PROBE_MOUNT .. "/src/core/Version.lua"
local ok, result = pcall(function()
local src = love.filesystem.read(chunkPath)
if not src then error("Version.lua missing", 0) end
local chunk = loadstring(src, "@" .. chunkPath)
if not chunk then error("Version.lua would not compile", 0) end
return chunk()
end)
love.filesystem.unmount(rel)
if not ok then return nil, tostring(result) end
local v = result
if type(v) ~= "table" or type(v.engine) ~= "string" then
return nil, "payload has no usable Version table"
end
return { engine = v.engine, minShell = tonumber(v.minShell) or 1 }
end
-- Boot.select(candidates, bundledEngine, bundledShell) -> chosen | nil, toDelete
--
-- Pure (no love.*): decide which payload to run and which to delete.
-- candidates is a list of { name = , engine = , minShell = }.
-- * chosen: the highest engine that is STRICTLY newer than bundledEngine and
-- whose minShell <= bundledShell (a payload the running shell can host).
-- * toDelete: stale payloads -- engine <= bundled (old or the same as what we
-- already ship), or superseded by the chosen one (not newer than chosen).
-- A payload newer than the chosen one but unrunnable here (minShell too
-- high) is kept: a future shell upgrade may be able to run it.
function Boot.select(candidates, bundledEngine, bundledShell)
local chosen
for _, c in ipairs(candidates) do
local newer = Semver.compare(c.engine, bundledEngine) > 0
local runnable = (c.minShell or 1) <= bundledShell
if newer and runnable then
if not chosen or Semver.compare(c.engine, chosen.engine) > 0 then
chosen = c
end
end
end
local toDelete = {}
for _, c in ipairs(candidates) do
if not (chosen and c.name == chosen.name) then
local stale = Semver.compare(c.engine, bundledEngine) <= 0
if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then
stale = true
end
if stale then toDelete[#toDelete + 1] = c.name end
end
end
return chosen and chosen.name or nil, toDelete
end
-- Mount the chosen payload and hand control to it. Returns true when the
-- payload is live and has completed its own love.load; false (with full
-- rollback) on any failure, so the caller runs the bundled game instead.
local function chainload(name, args)
local rel = PAYLOAD_DIR .. "/" .. name
-- Crash marker: if we die between here and clearing it, the next boot's
-- crash guard distrusts this payload and deletes it.
love.filesystem.write(PENDING, name)
-- Prepend-mount the payload at "/" (appendToPath = false) so its files win
-- over the fused source for every subsequent require / love.filesystem read.
if not love.filesystem.mount(rel, "/", false) then
love.filesystem.remove(PENDING)
return false
end
local snapshot = snapshotCallbacks()
purgeBundledModules()
_G.POKEPORT_PAYLOAD_MOUNTED = true
-- Chainload: run the payload's main.lua (redefines the love callbacks from
-- the NEW code), then call its love.load. The new love.load calls Boot.run
-- again, which no-ops via the flag set above.
local ok, err = pcall(function()
local chunk = assert(love.filesystem.load("main.lua"))
chunk()
love.load(args)
end)
if not ok then
-- Handoff failed after mounting. Unwind everything so the bundled game
-- boots cleanly: clear the flag, unmount the payload, purge any payload
-- modules it cached (so bundled requires reload from source), restore the
-- bundled love callbacks with their intact upvalues, and drop the marker.
-- Delete the payload too: it failed deterministically once, so leaving it
-- would re-select and re-fail it on every boot forever.
print("update: payload handoff failed, reverting to bundled: " .. tostring(err))
_G.POKEPORT_PAYLOAD_MOUNTED = nil
pcall(love.filesystem.unmount, rel)
purgeBundledModules()
restoreCallbacks(snapshot)
love.filesystem.remove(rel)
love.filesystem.remove(PENDING)
return false
end
-- Success: the payload owns the game now. Drop the marker and tell the
-- caller to stop so the bundled love.load does not run on top of it.
love.filesystem.remove(PENDING)
return true
end
-- Everything after the fused / flag guards, wrapped so an unexpected error in
-- enumeration or selection can never crash the boot.
local function runInner(args)
-- Crash guard first: a pending.txt naming a payload means a previous boot
-- crashed mid-handoff. Distrust that payload -- delete it and the marker --
-- then continue (we may still pick an older valid payload, or fall through
-- to the bundled game).
local pending = love.filesystem.read(PENDING)
if pending then
pending = pending:gsub("%s+$", "")
if pending ~= "" then
love.filesystem.remove(PAYLOAD_DIR .. "/" .. pending)
end
love.filesystem.remove(PENDING)
end
-- Enumerate and probe every payload in updates/.
local candidates = {}
if love.filesystem.getInfo(PAYLOAD_DIR, "directory") then
for _, entry in ipairs(love.filesystem.getDirectoryItems(PAYLOAD_DIR)) do
if isPayloadName(entry) then
local info = Boot.probePayload(PAYLOAD_DIR .. "/" .. entry)
if info then
candidates[#candidates + 1] = {
name = entry,
engine = info.engine,
minShell = info.minShell,
}
end
end
end
end
local Version = require("src.core.Version")
local chosen, toDelete = Boot.select(candidates, Version.engine, Version.shell)
for _, victim in ipairs(toDelete) do
love.filesystem.remove(PAYLOAD_DIR .. "/" .. victim)
end
if not chosen then return false end
return chainload(chosen, args)
end
-- Boot.run(args) -> boolean
--
-- The first line of love.load. True means a payload was mounted and
-- chainloaded and the caller must return immediately; false means boot the
-- bundled game as normal.
function Boot.run(args)
-- Dev / source checkouts never self-update.
if not (love.filesystem.isFused and love.filesystem.isFused()) then
return false
end
-- The chainloaded love.load calls Boot.run again; the flag makes it a no-op.
if _G.POKEPORT_PAYLOAD_MOUNTED then return false end
local ok, result = pcall(runInner, args)
if not ok then
-- An error escaped before any handoff mount (chainload cleans up after
-- itself), so state is still clean. Never crash the boot.
return false
end
return result
end
return Boot
+177
View File
@@ -0,0 +1,177 @@
-- Async release-check and payload-download for the self-update flow.
--
-- The heavy lifting (curl calls, sha256 verification, the Boot gate) happens
-- on a background love.thread worker (src/update/check_worker.lua); this module
-- is only the thin main-thread state machine the UI polls. Two channels carry
-- the conversation:
-- "update_check_cmd" main -> worker: { cmd = "check" | "download" | "quit" }
-- "update_check_state" worker -> main: { status, latest, progress, error }
--
-- Nothing here ever blocks or throws into the game loop: when love.thread is
-- absent (the headless test stub) or the worker cannot run (no curl, Android),
-- state() simply reports "error" and the UI hides itself. See the shared
-- contract in the task brief for the status vocabulary and the file layout.
--
-- The release-JSON extraction and the sums parsing are exported as pure
-- functions (no love.* calls) so plain-Lua tests can cover them, and so the
-- worker can reuse the exact same code path via love.filesystem.load.
local Check = {}
Check.REPO = "bryanthaboi/pokemon-gen1-recomp-project"
local CMD = "update_check_cmd"
local STATE = "update_check_state"
-- ---------------------------------------------------------------------------
-- pure helpers (no love.*) -- also used inside the worker
-- ---------------------------------------------------------------------------
-- Find the release asset named exactly `name`, returning its download URL and
-- byte size (or nil when the release has no such asset).
function Check.pickAsset(assets, name)
if type(assets) ~= "table" then return nil end
for _, a in ipairs(assets) do
if type(a) == "table" and a.name == name then
return { url = a.browser_download_url, size = tonumber(a.size) }
end
end
return nil
end
local function stripV(tag)
return (tostring(tag):gsub("^[vV]", ""))
end
-- Decode a GitHub "releases/latest" response into just the fields the updater
-- needs. Returns { version, payloadName, payload, sums } where payload/sums are
-- { url, size } tables (or nil when that asset is missing), or nil, err when the
-- document is not a release with a strict X.Y.Z tag. Json is injected so the
-- worker can pass a filesystem-loaded codec; on the main thread / in tests it
-- falls back to require.
function Check.parseRelease(jsonText, Json)
Json = Json or require("src.link.Json")
local doc = Json.decode(jsonText)
if type(doc) ~= "table" or not doc.tag_name then
return nil, "no tag_name in release json"
end
local version = stripV(doc.tag_name)
if not version:match("^%d+%.%d+%.%d+$") then
return nil, "release tag is not X.Y.Z: " .. tostring(doc.tag_name)
end
local payloadName = "gen1recomp-" .. version .. ".love"
return {
version = version,
payloadName = payloadName,
payload = Check.pickAsset(doc.assets, payloadName),
sums = Check.pickAsset(doc.assets, "sha256sums.txt"),
}
end
-- Parse a shasum -a 256 file ("<hex> <filename>", bare filenames). With a
-- `target` argument returns just that file's hash (or nil); otherwise returns
-- the whole name -> hash map. Tolerates the "*" binary marker and "./" prefix.
function Check.parseSums(text, target)
local map = {}
for line in tostring(text):gmatch("[^\r\n]+") do
local hash, file = line:match("^(%x+)%s+%*?(%S+)")
if hash and file then
map[(file:gsub("^%./", ""))] = hash:lower()
end
end
if target ~= nil then return map[target] end
return map
end
-- ---------------------------------------------------------------------------
-- main-thread state machine
-- ---------------------------------------------------------------------------
function Check.releaseUrl()
return "https://github.com/" .. Check.REPO .. "/releases/latest"
end
local worker -- the love.thread, once started
local cmdCh, stateCh -- the two channels
local workerReady -- nil = untried, true = running, false = unavailable
local requested -- a check has been asked for this session
local cache = { status = "idle" } -- newest snapshot from the worker
local function ensureWorker()
if workerReady ~= nil then return workerReady end
if not (love and love.thread and love.thread.newThread) then
workerReady = false
return false
end
local ok, th = pcall(love.thread.newThread, "src/update/check_worker.lua")
if not ok or not th then
workerReady = false
return false
end
cmdCh = love.thread.getChannel(CMD)
stateCh = love.thread.getChannel(STATE)
if not pcall(function() th:start() end) then
workerReady = false
return false
end
worker = th
workerReady = true
return true
end
-- Pull every pending snapshot off the state channel (keeping the newest) and
-- surface a worker crash as a soft error the UI can hide on.
local function drain()
if stateCh then
local msg = stateCh:pop()
while msg do
cache = msg
msg = stateCh:pop()
end
end
if worker then
local err = worker:getError()
if err then
cache = { status = "error", error = tostring(err) }
end
end
end
-- Begin (or, on a prior error, retry) an async check. Safe to call every frame:
-- once a check is in flight or has reached a terminal state it is a no-op.
function Check.start()
drain()
if cache.status == "checking" or cache.status == "downloading" then return end
if requested and cache.status ~= "error" and cache.status ~= "idle" then return end
if not ensureWorker() then
cache = { status = "error", error = "background threads unavailable" }
return
end
requested = true
cache = { status = "checking" }
cmdCh:push({ cmd = "check" })
end
-- Current snapshot: { status, latest, progress, error }. status is one of
-- idle | checking | uptodate | available | downloading | ready | needs_full | error.
function Check.state()
drain()
return {
status = cache.status or "idle",
latest = cache.latest,
progress = cache.progress,
error = cache.error,
}
end
-- Start downloading the payload announced by an "available" check. A no-op in
-- any other state (the worker still holds the release info from the check).
function Check.download()
drain()
if not cmdCh then return end
if cache.status ~= "available" then return end
cache = { status = "downloading", latest = cache.latest, progress = 0 }
cmdCh:push({ cmd = "download" })
end
return Check
+53
View File
@@ -0,0 +1,53 @@
-- Strict X.Y.Z semantic-version parsing and comparison for the self-updater.
-- Every part of the updater (Boot, Check, the release picker) agrees on this
-- one notion of "newer". Zero requires and no love.* calls, so plain-Lua
-- tests can exercise it and Boot can use it during the earliest boot step.
--
-- We only need the numeric core (major.minor.patch): the engine field is a
-- bare X.Y.Z in shipped builds and the "0.0.0-dev" placeholder in the working
-- tree. Pre-release / build metadata is intentionally not supported -- a
-- "-dev" or any other suffix makes parse fail, which is the safe answer for
-- the updater (a dev checkout never counts as a real release to chainload).
local Semver = {}
-- Parse a strict "X.Y.Z" string (an optional leading "v" is allowed) into
-- { major = n, minor = n, patch = n }. Returns nil for anything else --
-- extra components, non-numeric parts, or a trailing suffix like "-dev".
function Semver.parse(s)
if type(s) ~= "string" then return nil end
local body = s:match("^v?(.+)$")
if not body then return nil end
local maj, min, pat = body:match("^(%d+)%.(%d+)%.(%d+)$")
if not maj then return nil end
return {
major = tonumber(maj),
minor = tonumber(min),
patch = tonumber(pat),
}
end
-- Coerce an argument that is either an already-parsed table or a version
-- string into a parsed table (or nil).
local function coerce(v)
if type(v) == "table" then return v end
return Semver.parse(v)
end
-- Compare two versions, each a parsed table or an X.Y.Z string.
-- Returns -1 when a < b, 0 when equal, 1 when a > b. An unparseable side
-- sorts as the lowest possible version so a bogus value never wins a "newer"
-- test; two unparseable sides compare equal.
function Semver.compare(a, b)
local pa, pb = coerce(a), coerce(b)
if not pa and not pb then return 0 end
if not pa then return -1 end
if not pb then return 1 end
for _, field in ipairs({ "major", "minor", "patch" }) do
if pa[field] < pb[field] then return -1 end
if pa[field] > pb[field] then return 1 end
end
return 0
end
return Semver
+343
View File
@@ -0,0 +1,343 @@
-- Background worker for the self-update flow (driven by src/update/Check.lua).
--
-- Runs on a love.thread so no curl call, sha256 pass or archive probe ever
-- touches the render thread. Talks over two channels:
-- "update_check_cmd" in: { cmd = "check" | "download" | "quit" }
-- "update_check_state" out: { status, latest, progress, error }
--
-- Transport is curl shelled out via io.popen (curl ships on macOS, Windows 10+
-- and desktop Linux). Everything is wrapped so a missing curl, an HTTP error,
-- or a hung download degrades to a "error"/"needs_full" state rather than
-- blocking or crashing the game. On Android curl is absent and the check
-- soft-fails to "error", which the UI hides.
--
-- Fresh love threads do not carry the "src.*" package searcher, so sibling
-- modules are pulled in with love.filesystem.load exactly like
-- src/core/chip_worker.lua does. Semver and Boot are authored in parallel; we
-- load them defensively and degrade (a local semver fallback, a permissive
-- gate) if they are not present yet.
require("love.thread")
require("love.filesystem")
require("love.data")
require("love.timer")
require("love.system")
local function loadModule(path)
local ok, chunk = pcall(love.filesystem.load, path)
if not ok or type(chunk) ~= "function" then return nil end
local ok2, mod = pcall(chunk)
if not ok2 then return nil end
return mod
end
local Json = loadModule("src/link/Json.lua")
local Check = loadModule("src/update/Check.lua")
local Version = loadModule("src/core/Version.lua")
local Semver = loadModule("src/update/Semver.lua")
-- Boot's top-level require("src.update.Semver") cannot resolve in this thread
-- (no src.* searcher), which would leave Boot nil and the minShell gate
-- permanently permissive. Seed the loaded table first so it resolves.
if Semver then package.loaded["src.update.Semver"] = Semver end
local Boot = loadModule("src/update/Boot.lua")
local cmdCh = love.thread.getChannel("update_check_cmd")
local stateCh = love.thread.getChannel("update_check_state")
local function post(t) stateCh:push(t) end
local osName = (love.system and love.system.getOS and love.system.getOS()) or ""
local isWindows = osName == "Windows"
local saveDir = love.filesystem.getSaveDirectory()
local API_URL = "https://api.github.com/repos/bryanthaboi/pokemon-gen1-recomp-project/releases/latest"
-- the release picked by the last "check"; kept between commands so "download"
-- knows the payload url/size/name without re-fetching
local pending = nil
-- ---------------------------------------------------------------------------
-- shell / curl
-- ---------------------------------------------------------------------------
local function shq(s)
s = tostring(s)
if isWindows then
return '"' .. s:gsub('"', '') .. '"'
end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
-- run curl and return its response body (text), or nil on any failure. Used
-- for the small text resources (release JSON, sums file); -f makes curl exit
-- non-zero and emit nothing on an HTTP error, so an empty read is a failure.
local function curlCapture(url)
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
.. "-H " .. shq("User-Agent: gen1recomp-updater") .. " "
.. "-H " .. shq("Accept: application/vnd.github+json") .. " "
.. shq(url)
local ok, pipe = pcall(io.popen, cmd)
if not ok or not pipe then return nil end
local out = pipe:read("*a")
pipe:close()
if not out or out == "" then return nil end
return out
end
local function haveCurl()
local ok, pipe = pcall(io.popen, "curl --version")
if not ok or not pipe then return false end
local out = pipe:read("*a")
pipe:close()
return out ~= nil and out:find("curl", 1, true) ~= nil
end
-- ---------------------------------------------------------------------------
-- version compare (Semver per contract item 5, with a local fallback)
-- ---------------------------------------------------------------------------
local function parseTriple(s)
s = (tostring(s):gsub("^[vV]", ""))
local a, b, c = s:match("^(%d+)%.(%d+)%.(%d+)")
if not a then return nil end
return { tonumber(a), tonumber(b), tonumber(c) }
end
-- -1 | 0 | 1 for a<b | a==b | a>b
local function compareVersions(a, b)
if Semver and Semver.compare then
local ok, r = pcall(Semver.compare, a, b)
if ok and r ~= nil then return r end
end
local pa, pb = parseTriple(a), parseTriple(b)
if not pa or not pb then return 0 end
for i = 1, 3 do
if pa[i] ~= pb[i] then return pa[i] < pb[i] and -1 or 1 end
end
return 0
end
-- ---------------------------------------------------------------------------
-- verification and the shell gate
-- ---------------------------------------------------------------------------
local function sha256hex(data)
local digest = love.data.hash("sha256", data)
if type(digest) == "userdata" and digest.getString then
digest = digest:getString()
end
return love.data.encode("string", "hex", digest)
end
-- Confirm the save-dir file `rel` hashes to the sum listed for `payloadName`.
local function verifyPayload(rel, payloadName, sumsText)
local want = Check.parseSums(sumsText, payloadName)
if not want then return false, "no checksum for " .. payloadName end
local data = love.filesystem.read(rel)
if not data then return false, "cannot read downloaded payload" end
if sha256hex(data):lower() ~= want:lower() then
return false, "checksum mismatch"
end
return true
end
-- true = ok to run, false = payload needs a newer shell (needs_full). When Boot
-- cannot probe (module missing during parallel dev, or a probe failure) we allow
-- it: Boot.run's crash-guard handles a payload that turns out unrunnable.
local function gatePasses(rel)
if not (Boot and Boot.probePayload) then return true end
local info = Boot.probePayload(rel)
if not info then return true end
local shell = (Version and Version.shell) or 1
if info.minShell and info.minShell > shell then return false end
return true
end
-- ---------------------------------------------------------------------------
-- check
-- ---------------------------------------------------------------------------
local function doCheck()
post({ status = "checking" })
if not haveCurl() then
post({ status = "error", error = "curl not available" })
return
end
local body = curlCapture(API_URL)
if not body then
post({ status = "error", error = "release check failed" })
return
end
local rel, perr = Check.parseRelease(body, Json)
if not rel then
post({ status = "error", error = perr or "bad release json" })
return
end
pending = rel
-- Unstamped dev build: the working tree always looks "newer", so never
-- pester the developer with an update (contract item, Check design).
local currentEngine = (Version and Version.engine) or "0.0.0-dev"
if currentEngine == "0.0.0-dev" then
post({ status = "uptodate", latest = rel.version })
return
end
if compareVersions(rel.version, currentEngine) <= 0 then
post({ status = "uptodate", latest = rel.version })
return
end
-- A newer release, but without the .love payload or its sums we cannot do an
-- in-place update: send the user to the full installers.
if not (rel.payload and rel.payload.url and rel.sums and rel.sums.url) then
post({ status = "needs_full", latest = rel.version })
return
end
-- Already downloaded on a previous run? Verify and gate it rather than
-- pulling the bytes again.
local finalRel = "updates/" .. rel.payloadName
if love.filesystem.getInfo(finalRel) then
local sums = curlCapture(rel.sums.url)
if sums and verifyPayload(finalRel, rel.payloadName, sums) then
if gatePasses(finalRel) == false then
love.filesystem.remove(finalRel)
post({ status = "needs_full", latest = rel.version })
return
end
post({ status = "ready", latest = rel.version })
return
end
-- stale / corrupt: drop it and offer a fresh download
love.filesystem.remove(finalRel)
end
post({ status = "available", latest = rel.version })
end
-- ---------------------------------------------------------------------------
-- download
-- ---------------------------------------------------------------------------
-- Launch curl in the background writing `partAbs`, touching `doneAbs` when it
-- exits. Returns without waiting so the caller can poll the growing file for
-- progress. We deliberately do not capture curl's exit code: an incomplete or
-- failed transfer simply fails the checksum below, which is the real gate.
local function launchDownload(url, partAbs, doneAbs)
if isWindows then
-- a tiny batch file sidesteps cmd.exe's nested-quote madness
local batRel = "updates/dl.bat"
love.filesystem.write(batRel,
"@echo off\r\n"
.. "curl -fsSL --connect-timeout 15 --max-time 900 -o \""
.. partAbs .. "\" \"" .. url .. "\"\r\n"
.. "type nul > \"" .. doneAbs .. "\"\r\n")
os.execute('start "" /b ' .. shq(saveDir .. "/" .. batRel))
else
-- ( ... ) & backgrounds the whole group so os.execute returns at once
os.execute("( curl -fsSL --connect-timeout 15 --max-time 900 -o "
.. shq(partAbs) .. " " .. shq(url)
.. " ; touch " .. shq(doneAbs) .. " ) >/dev/null 2>&1 &")
end
end
local function doDownload()
if not (pending and pending.payload and pending.payload.url) then
post({ status = "error", error = "nothing to download" })
return
end
local rel = pending
post({ status = "downloading", latest = rel.version, progress = 0 })
love.filesystem.createDirectory("updates")
local partRel = "updates/" .. rel.payloadName .. ".part"
local doneRel = "updates/" .. rel.payloadName .. ".done"
local finalRel = "updates/" .. rel.payloadName
love.filesystem.remove(partRel)
love.filesystem.remove(doneRel)
local partAbs = saveDir .. "/updates/" .. rel.payloadName .. ".part"
local doneAbs = saveDir .. "/updates/" .. rel.payloadName .. ".done"
local size = rel.payload.size or 0
launchDownload(rel.payload.url, partAbs, doneAbs)
-- poll the .part size for progress until curl drops the done-marker; a
-- stalled or run-away transfer breaks out and lets verification fail cleanly
local waited, lastSize, lastChange = 0, -1, 0
while true do
if love.filesystem.getInfo(doneRel) then break end
local pinfo = love.filesystem.getInfo(partRel)
local cur = (pinfo and pinfo.size) or 0
if size > 0 then
local p = cur / size
if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready"
post({ status = "downloading", latest = rel.version, progress = p })
else
post({ status = "downloading", latest = rel.version })
end
if cur ~= lastSize then lastSize, lastChange = cur, waited end
if waited - lastChange > 60 then break end -- 60s with no growth: give up
if waited > 960 then break end -- absolute ceiling
love.timer.sleep(0.25)
waited = waited + 0.25
end
love.filesystem.remove(doneRel)
local sums = curlCapture(rel.sums and rel.sums.url or "")
if not sums then
love.filesystem.remove(partRel)
post({ status = "error", error = "checksum fetch failed" })
return
end
local ok, verr = verifyPayload(partRel, rel.payloadName, sums)
if not ok then
love.filesystem.remove(partRel)
post({ status = "error", error = verr or "verification failed" })
return
end
if gatePasses(partRel) == false then
love.filesystem.remove(partRel)
post({ status = "needs_full", latest = rel.version })
return
end
-- finalize: rename the verified .part to its real name (fall back to a
-- love.filesystem copy if os.rename is unavailable on this platform)
if not os.rename(partAbs, saveDir .. "/updates/" .. rel.payloadName) then
local data = love.filesystem.read(partRel)
if not data then
post({ status = "error", error = "finalize failed" })
return
end
love.filesystem.write(finalRel, data)
love.filesystem.remove(partRel)
end
post({ status = "ready", latest = rel.version })
end
-- ---------------------------------------------------------------------------
-- command loop
-- ---------------------------------------------------------------------------
while true do
local cmd = cmdCh:demand() -- blocks until the main thread pushes work
if type(cmd) == "table" then
if cmd.cmd == "quit" then
break
elseif cmd.cmd == "check" then
local ok, err = pcall(doCheck)
if not ok then post({ status = "error", error = tostring(err) }) end
elseif cmd.cmd == "download" then
local ok, err = pcall(doDownload)
if not ok then post({ status = "error", error = tostring(err) }) end
end
end
end
+10
View File
@@ -116,6 +116,16 @@ FieldDefaults.FIELD = {
badgeGates = { ROUTE_22_GATE = { passedFlag = "PASSED_ROUTE22_GATE" } },
-- VermilionGymSetDoorTile opens the motorized door once both locks are hit
hiddenExtras = {
-- PrintTrashText bins (#188); seeds stale caches missing the key
printTrash = {
SS_ANNE_KITCHEN = {
{ x = 13, y = 5, facing = "down" },
{ x = 13, y = 7, facing = "down" },
},
VERMILION_GYM = {
{ x = 6, y = 1, facing = "down" },
},
},
trashCans = { map = "VERMILION_GYM",
doorBlock = { bx = 2, by = 2, block = 5 } },
},
+6
View File
@@ -223,6 +223,12 @@ function Map:setBlock(bx, by, block)
self.def.blocks[by * self.def.width + bx + 1] = block
end
-- true if the cell's collision tile is a door tile
-- (pokered IsPlayerStandingOnDoorTile)
function Map:isDoorTileCell(cx, cy)
return self.doorTiles[self:cellTile(cx, cy)] or false
end
-- true if the cell's collision tile is a door or warp-activating tile
function Map:isWarpTileCell(cx, cy)
local t = self:cellTile(cx, cy)
+11 -2
View File
@@ -101,9 +101,18 @@ function NPC:walkPhase()
return (p >= 4 and p < 12) and 1 or 0
end
-- Same contract as Player:pose -- the sheet, position, facing and step
-- phase this frame renders to -- so a render pipeline can pose an NPC
-- without caring which kind of entity it is. An NPC never hops, so the
-- trailing hop flag is always false.
function NPC:pose()
return self.sprite, self.px, self.py, self.facing,
self:walkPhase(), self.stepFlip, false
end
function NPC:draw(camX, camY)
self.sprite:draw(self.px, self.py, camX, camY, self.facing,
self:walkPhase(), self.stepFlip)
local sprite, px, py, facing, phase, flip = self:pose()
sprite:draw(px, py, camX, camY, facing, phase, flip)
end
return NPC
+183 -40
View File
@@ -12,6 +12,7 @@ local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader")
local NPC = require("src.world.NPC")
local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local Player = require("src.world.Player")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
@@ -20,6 +21,7 @@ local Tilt = require("src.render.Tilt")
local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition")
local Warp = require("src.world.Warp")
local Zoom = require("src.render.Zoom")
-- isOverworld marks the live world state for WorldAPI's stack scan
local OverworldState = { isOpaque = true, isOverworld = true }
@@ -716,16 +718,13 @@ function OverworldState:update(dt)
end
if self.healAnim then
local ha = self.healAnim
local Music = require("src.core.Music")
if ha.jinglePlaying and not ha.jingleDone then
ha.jingleDone = not Music.oneShotPlaying()
end
local ev = OverworldState.stepHealAnim(ha)
if ev == "ball" then
require("src.core.Sound").play(Game.data, "Healing_Machine")
elseif ev == "jingle" then
ha.jinglePlaying = Music.playOnce(Game.data, "Music_PkmnHealed")
ha.jingleDone = not ha.jinglePlaying
-- playOnce restores the map theme when the jingle ends; we no longer
-- block the fighting-fit text on that (#157)
require("src.core.Music").playOnce(Game.data, "Music_PkmnHealed")
elseif ev == "done" then
local done = ha.onDone
self.healAnim = nil
@@ -1509,6 +1508,15 @@ function OverworldState:tryHiddenObject(fx, fy)
end
end
-- PrintTrashText: SS Anne kitchen + Vermilion Gym non-puzzle can
for _, h in ipairs(extras.printTrash and extras.printTrash[self.map.id] or {}) do
if h.x == fx and h.y == fy then
Game.stack:push(TextBox.new(Game, txt._VermilionGymTrashText
or "Nope, there's\nonly trash here."))
return true
end
end
-- the Vermilion Gym trash can lock puzzle
if self.map.id == "VERMILION_GYM" then
for _, h in ipairs(extras.trashCans.cans or {}) do
@@ -2188,13 +2196,10 @@ function OverworldState:dexRating()
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating))
end
-- AnimateHealingMachine (engine/overworld/healing_machine.asm): the
-- monitor lights, then one ball per party mon appears every 30 frames
-- (SFX_HEALING_MACHINE each); the healed jingle plays while the machine
-- sprites flash 8 times (an OBP1 xor every 10 frames), then a 32-frame
-- beat once the jingle ends. Pure per-frame step over the ha table
-- ({ balls, lit, timer, visible, jingleDone }); returns "ball"/"jingle"/
-- "done" when the caller must fire the matching side effect.
-- AnimateHealingMachine (engine/overworld/healing_machine.asm): balls
-- every 30 frames, then jingle + FlashSprite8Times (8 x 10). #157: skip
-- pokered's post-flash .waitLoop2 / DelayFrames 32 so fighting-fit is
-- immediate; jingle still plays and restoreMap runs when it ends.
function OverworldState.stepHealAnim(ha)
ha.timer = ha.timer + 1
ha.phase = ha.phase or "balls"
@@ -2217,17 +2222,11 @@ function OverworldState.stepHealAnim(ha)
ha.visible = not ha.visible
ha.flashes = ha.flashes + 1
if ha.flashes >= 8 then
ha.phase = "wait"
ha.visible = true
ha.phase = "done"
return "done"
end
end
elseif ha.phase == "wait" then
-- .waitLoop2: hold until the jingle ends, then 32 more frames
if not ha.jingleDone then
ha.timer = 0
elseif ha.timer >= 32 then
return "done"
end
end
end
@@ -2403,19 +2402,34 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
Commands.hide_object(ctx, entry[1], entry[2])
end
end
local lines = {}
if reward.badge then
Game.save.inventory[reward.badge] = 1
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
or reward.badge
table.insert(lines, ("%s received\nthe %s!"):format(Game.save.player.name, name))
end
if reward.item then
local inv = Game.save.inventory
inv[reward.item] = (inv[reward.item] or 0) + 1
local name = Game.data.items[reward.item] and Game.data.items[reward.item].name
or reward.item
table.insert(lines, ("%s received\n%s!"):format(Game.save.player.name, name))
local idef = Game.data.items[reward.item]
-- GiveItem -> CopyToStringBuffer for "{RAM:wStringBuffer}" received texts
Game.stringBuffer = idef and idef.name or reward.item
end
local lines = {}
if reward.dialogue then
local text = Game.data.text or {}
for _, label in ipairs(reward.dialogue) do
if text[label] and text[label] ~= "" then
table.insert(lines, text[label])
end
end
elseif reward.badge or reward.item then
if reward.badge then
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
or reward.badge
table.insert(lines, ("%s received\nthe %s!"):format(Game.save.player.name, name))
end
if reward.item then
local name = Game.stringBuffer or reward.item
table.insert(lines, ("%s received\n%s!"):format(Game.save.player.name, name))
end
end
if #lines > 0 then
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f")))
@@ -2437,6 +2451,8 @@ end
-- interposed NPCs / walls -- but unsigned 8-bit Y makes a sprite exactly 4
-- tiles north of the player sit at Y=$fc, so |$3c-$fc|=$c0 and a range-4
-- DOWN trainer does not engage that tile (Route 9 Bug Catcher / issue #76).
-- Off-screen sprites (IMAGEINDEX=$ff) never engage: without that gate, the
-- same 8-bit wrap makes far same-row trainers look in-range (#153/#183).
local PLAYER_SCREEN_X, PLAYER_SCREEN_Y = 0x40, 0x3c
local function u8(n) return n % 256 end
local function calcDiff(a, b)
@@ -2452,6 +2468,14 @@ local function trainerSightPixelDist(npc, player, horizontal)
u8(PLAYER_SCREEN_Y + (npc.cellY - player.cellY) * 16))
end
-- CheckSpriteAvailability (movement.asm): wXCoord/wYCoord = player - 4;
-- visible when sprite is in [wCoord, wCoord + SCREEN_*/2 - 1] (GB 10x9).
local function trainerSpriteOnScreen(npc, player)
local dx = npc.cellX - player.cellX
local dy = npc.cellY - player.cellY
return dx >= -4 and dx <= 5 and dy >= -4 and dy <= 4
end
-- STAY trainers with a facing spot the player crossing their line of
-- sight (range from the extracted trainer headers), walk up and battle.
function OverworldState:checkTrainerSight()
@@ -2464,7 +2488,8 @@ function OverworldState:checkTrainerSight()
-- walkers included (they sight between steps)
if d.trainerClass and not npc.moving
and not self:trainerDefeated(npc)
and not mapScripts.talkScript(self.map.id, d.text) then
and not mapScripts.talkScript(self.map.id, d.text)
and trainerSpriteOnScreen(npc, p) then
local header = Game.data:trainerHeader(self.map.def.label, d.index)
local range = header and header.range or 0
local vec = DIRVEC[npc.facing]
@@ -3208,7 +3233,7 @@ function OverworldState:takeWarp(warpDef)
self:startWarpTo(destMap, x, y, facing)
return
end
self.doorWarp = true -- door SFX + outdoor walk-out step
self.doorWarp = true -- door SFX + PlayerStepOutFromDoor walk-out
self:startWarpTo(destMap, x, y, facing)
end
@@ -3293,12 +3318,12 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
local outdoor = Map.isOutdoor(self.map.def)
require("src.core.Sound").play(Game.data,
outdoor and "Go_Outside" or "Go_Inside")
-- stepping out of an outdoor door/cave entrance (the original's
-- walk-out). Auto-walk leaves the mat, so the arrival disable
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
-- warp that lands on a door tile auto-steps south once, indoor or
-- outdoor. Auto-walk leaves the mat, so the arrival disable
-- (warpEntryCell / justWarped) is unnecessary -- and would let you
-- stand on the door without re-entering if you hold back into it.
if outdoor and self.player.facing == "down"
and self.map:isWarpTileCell(self.player.cellX, self.player.cellY) then
if self.map:isDoorTileCell(self.player.cellX, self.player.cellY) then
self.warpEntryCell = nil
self.justWarped = false
self:scriptMove(self.player, "down", 1)
@@ -3539,11 +3564,22 @@ function OverworldState:drawWorld()
-- tilt is active). So the ground draw calls below never change with tilt;
-- only the sprite/FX draw path below them branches. The sorts below only
-- reorder (no draws), so they run once for both paths.
local tilt = Tilt.active()
self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh)
self.map.renderer:draw(cam.x, bgY, vw, vh)
for _, nb in ipairs(self.neighbors) do
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
-- A render pipeline (src/render/Pipelines.lua) replaces the ground draw
-- entirely with geometry of its own, so it is decided before tilt and
-- wins over it. It falls back to the tilt/flat path whenever it cannot
-- run this frame -- headless, a driver with no depth canvas, or a mod
-- that threw -- so no caller ever sees a blank frame.
local pipelineId = Pipelines.worldPipeline()
local tilt = (not pipelineId) and Tilt.active()
-- the pipeline's finished world image, once it has run; nil keeps every
-- path below on the vanilla flat/tilt draw
local override
if not pipelineId then
self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh)
self.map.renderer:draw(cam.x, bgY, vw, vh)
for _, nb in ipairs(self.neighbors) do
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
end
end
-- per-billboard SGB palette source; only needed (and only paid for) when
-- tilting. nil headless / on stale palettes -> billboards go uncolorized.
@@ -3774,7 +3810,114 @@ function OverworldState:drawWorld()
end
end
if not tilt then
if pipelineId then
-- === PIPELINE PATH: a mod owns the world pass. ======================
-- It renders terrain and characters however it likes and hands back one
-- window-resolution image; the field FX stay ordinary 2D draws
-- composited on top by ctx.drawFx, each anchored to where its ground
-- point projects under the pipeline's own camera. That is the direct
-- analogue of what :billboard does for tilt, and it keeps exactly one
-- copy of every effect: the closures above are the ones that run.
local pw, ph = love.graphics.getDimensions()
local pscale = Zoom.scale(Game.renderer:fitScale())
local ctx = {
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
width = pw, height = ph, scale = pscale,
level = Pipelines.level(pipelineId),
-- the SGB world palette a map draws under; nil in the true-colour
-- modes, whose art is already baked (and must not be re-mapped)
paletteFor = function(map)
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
end,
spriteColors = function(map)
if PaletteFX.usesGbcPack() then return nil end
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
end,
fx = { heal = fxHeal, dust = fxDust, cutTree = fxCutTree,
emote = fxEmote, dark = fxDark, bird = fxBird, rod = fxRod },
}
-- Draw every active field FX into the finished scene. `project(wx, wy)`
-- maps a world point to canvas pixels (nil when it is behind the
-- camera) and `scale` is canvas pixels per world pixel; the pipeline
-- owns the camera, this owns where each effect belongs and how the
-- closures' flat coordinates are slid onto the projected anchor.
-- Deliberately unscaled by depth, like :billboard: an effect keeps its
-- crisp authored size and only its anchor moves.
ctx.drawFx = function(project, scale)
scale = scale or pscale
local colors = ctx.spriteColors()
local function at(drawFn, wx, wy)
if not drawFn then return end
local sx, sy = project(wx, wy)
if not sx then return end -- behind the camera
local shader = colors and PaletteFX.shader() or nil
if shader then
PaletteFX.sendColors(shader, colors)
love.graphics.setShader(shader)
end
-- the closures draw relative to the flat foot; slide that onto the
-- projected anchor, in world-pixel units inside the scaled transform
local fx, fy = wx - cam.x, wy - cam.y
love.graphics.push()
love.graphics.scale(scale, scale)
love.graphics.translate(sx / scale - fx, sy / scale - fy)
drawFn()
love.graphics.pop()
if shader then love.graphics.setShader() end
end
-- ground-hugging effects sit on the cell they belong to
if self.dustAnim then
at(fxDust, self.dustAnim.x * 16 + 8, self.dustAnim.y * 16 + 8)
end
if self.cutAnim then
at(fxCutTree, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16)
end
if self.healAnim then
at(fxHeal, self.healAnim.px + 8, self.healAnim.py + 16)
end
-- standing effects anchor at the foot of whoever they belong to
if self.emote and self.emote.npc then
at(fxEmote, self.emote.npc.px + 8, self.emote.npc.py + 16)
end
if self.flyAnim then
at(fxBird, self.player.px + 8, self.player.py + 16)
end
if self.fishing then
at(fxRod, self.player.px + 8, self.player.py + 16)
end
-- Rock Tunnel darkness is a screen-space light window, not a ground
-- object: draw it flat over the finished scene like the tilt path.
-- It fills the view in world-pixel units, so it only needs the scale.
if self.dark then
love.graphics.push()
love.graphics.scale(scale, scale)
fxDark()
love.graphics.pop()
end
end
override = Pipelines.drawWorld(pipelineId, ctx)
-- world post-processes (a miniature-diorama blur, a colour grade) fold
-- over the finished scene here, so they never touch the UI drawn on top
if override then
override = Pipelines.worldPresent(override, ctx)
end
Game.renderer:setWorldOverride(override)
if not override then
-- The pipeline declined this frame (nothing to draw, or it threw and
-- was retired). The ground pass was skipped on its behalf above, so
-- draw it now and fall through to the flat path below rather than
-- compositing an empty canvas.
self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh)
self.map.renderer:draw(cam.x, bgY, vw, vh)
for _, nb in ipairs(self.neighbors) do
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
end
end
end
if override then
-- the pipeline owns the whole frame; nothing else draws into the world
elseif not tilt then
-- === FLAT PATH: everything into the one world canvas, as before =====
-- OBP-baked sprites replay after the zone pass in GBC mode, so their
-- grass feet-overdraw must replay over them too, colorized with the
+34 -16
View File
@@ -140,28 +140,29 @@ end
local SPIN_ORDER = { "down", "left", "up", "right" }
function Player:draw(camX, camY)
-- What this frame renders to: the sheet, where it sits, which way it faces
-- and how far through a step it is. Shared by the 2D draw below and by a
-- render pipeline's own geometry (src/render/Pipelines.lua), so the two can
-- never disagree about which sprite or facing is current.
--
-- The last return says the player is mid-ledge-hop, which is what the 2D
-- path draws the ground shadow from and a 3D path turns into vertical lift.
--
-- This ADVANCES the surf-bob and spinner timers, so exactly one of pose()
-- and draw() may run per frame -- and draw() is written in terms of pose()
-- to keep that true by construction. (hopFrames counts down in
-- Player:update, on the fixed step, so it is safe to read here.)
function Player:pose()
local py = self.py
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs.
-- hopFrames counts down in Player:update (fixed step), never here.
local hopping = false
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs
if self.hopFrames and self.hopFrames > 0 then
local total = self.hopTotal or 32
-- update runs before draw, so remaining N means N steps already
-- consumed this hop → t matches the old draw-side post-decrement phase
local t = 1 - self.hopFrames / total
py = py - math.floor(10 * math.sin(t * math.pi) + 0.5)
-- the shadow stays on the ground under the jumper: one 8x8 tile
-- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left
-- is 8px below the sprite's standing top-left (LoadHoppingShadowOAM +
-- LedgeHoppingShadowOAMBlock, engine/overworld/ledges.asm)
if self.shadowImg then
local sx = math.floor(self.px - camX)
local sy = math.floor(self.py - camY) - 4 + 8
love.graphics.draw(self.shadowImg, sx, sy)
love.graphics.draw(self.shadowImg, sx + 16, sy, 0, -1, 1)
love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1)
love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1)
end
hopping = true
elseif self.surfing then
self.bobTimer = ((self.bobTimer or 0) + 1) % 32
py = py + (self.bobTimer < 16 and 0 or 1)
@@ -186,7 +187,24 @@ function Player:draw(camX, camY)
end
local sprite = (self.surfing and self.surfSprite)
or (self.onBike and self.bikeSprite) or self.sprite
sprite:draw(self.px, py, camX, camY, facing, phase, flip)
return sprite, self.px, py, facing, phase, flip, hopping
end
function Player:draw(camX, camY)
local sprite, px, py, facing, phase, flip, hopping = self:pose()
-- the shadow stays on the ground under the jumper: one 8x8 tile
-- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left
-- is 8px below the sprite's standing top-left (LoadHoppingShadowOAM +
-- LedgeHoppingShadowOAMBlock, engine/overworld/ledges.asm)
if hopping and self.shadowImg then
local sx = math.floor(self.px - camX)
local sy = math.floor(self.py - camY) - 4 + 8
love.graphics.draw(self.shadowImg, sx, sy)
love.graphics.draw(self.shadowImg, sx + 16, sy, 0, -1, 1)
love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1)
love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1)
end
sprite:draw(px, py, camX, camY, facing, phase, flip)
end
return Player
+62
View File
@@ -0,0 +1,62 @@
-- S.S. Anne 1F cabins: ROM packs rooms so hallway doors map right→left
-- onto the 3×2 rooms map (warp 1 = top-left = rightmost door). Survey
-- zoom makes that look left/right flipped vs 2F/B1F. Reorder door→cell
-- and slide objects so each door keeps its OG occupants while L→R doors
-- land in reading order on the rooms map.
local SsAnneLayout = {}
local function leftmostRoomDoor(hall)
local best
for _, w in ipairs(hall.warps or {}) do
if w.destMap == "SS_ANNE_1F_ROOMS" then
if not best or w.x < best.x then best = w end
end
end
return best
end
function SsAnneLayout.apply(maps)
if not maps then return false end
local hall = maps.SS_ANNE_1F
local rooms = maps.SS_ANNE_1F_ROOMS
if not hall or not rooms then return false end
local left = leftmostRoomDoor(hall)
-- already hallway-ordered (leftmost door → rooms warp 1)
if not left or left.destWarp == 1 then return false end
local doors = {}
for _, w in ipairs(hall.warps) do
if w.destMap == "SS_ANNE_1F_ROOMS" then doors[#doors + 1] = w end
end
table.sort(doors, function(a, b) return a.x < b.x end)
for i, w in ipairs(doors) do w.destWarp = i end
-- rooms warps 1..6 return through hall warps 8..3 (L→R cabin doors)
for i, w in ipairs(rooms.warps or {}) do
if w.destMap == "SS_ANNE_1F" then w.destWarp = 9 - i end
end
local warpPos = {}
for i, w in ipairs(rooms.warps or {}) do
warpPos[i] = { x = w.x, y = w.y }
end
for _, o in ipairs(rooms.objects or {}) do
local col, row = math.floor(o.x / 10), math.floor(o.y / 10)
local oldK
for k, p in ipairs(warpPos) do
if math.floor(p.x / 10) == col and math.floor(p.y / 10) == row then
oldK = k
break
end
end
if oldK then
local newK = 7 - oldK
o.x = o.x + (warpPos[newK].x - warpPos[oldK].x)
o.y = o.y + (warpPos[newK].y - warpPos[oldK].y)
end
end
return true
end
return SsAnneLayout
+28
View File
@@ -0,0 +1,28 @@
-- Driver: #187 S.S. Anne cabin door entry. Entering a room from the
-- corridor should land one tile south of the door (pokered
-- PlayerStepOutFromDoor), not leave the player on the door tile.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- SS_ANNE_1F warp at (31,8) -> SS_ANNE_1F_ROOMS (rightmost cabin)
U.teleport(game, "SS_ANNE_1F", 31, 9, "up")
U.shot(game, DIR .. "/anne_0_before_enter.png")
local ow = game.overworld
U.log("before map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY,
"facing:", ow.player.facing)
U.hold(game, "up", 20)
for _ = 1, 90 do
U.wait(1)
if ow.map.id == "SS_ANNE_1F_ROOMS" and not ow.transitioning
and #ow.scriptMoves == 0 and not ow.player.moving then
break
end
end
U.wait(8)
U.shot(game, DIR .. "/anne_1_after_enter.png")
U.log("after map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY,
"facing:", ow.player.facing,
"onDoor:", tostring(ow.map:isWarpTileCell(ow.player.cellX, ow.player.cellY)))
end
@@ -0,0 +1,58 @@
-- Driver: mid-battle PKMN opens SWITCH / STATS / CANCEL (#180).
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/battle_party_submenu_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
game.save.party = {
Pokemon.new(game.data, "CHARMANDER", 12),
Pokemon.new(game.data, "SQUIRTLE", 10),
}
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
local battle = BattleState.newWild(game, "PIDGEY", 8)
battle.onFinish = function() end
ow:pushBattle(battle)
local function mashUntil(cond, max)
for _ = 1, max or 80 do
if cond() then return true end
U.tap(game, "a")
U.wait(4)
end
return false
end
mashUntil(function() return battle.phase == "menu" end)
U.shot(game, DIR .. "/battle_party_0_menu.png")
-- FIGHT/PKMN/ITEM/RUN: right to PKMN, then A
U.tap(game, "right"); U.wait(4)
U.tap(game, "a"); U.wait(12)
U.shot(game, DIR .. "/battle_party_1_list.png")
local pm = game.stack:top()
U.log("party open:", pm and pm.onSwitch ~= nil)
U.tap(game, "a"); U.wait(8)
pm = game.stack:top()
U.shot(game, DIR .. "/battle_party_2_switch_stats.png")
local labels = {}
if pm and pm.submenu and pm.subItems then
for _, e in ipairs(pm.subItems) do
table.insert(labels, e.label)
end
end
U.log("submenu:", table.concat(labels, "/"))
U.log("order ok:", labels[1] == "SWITCH" and labels[2] == "STATS"
and labels[3] == "CANCEL")
-- STATS
U.tap(game, "down"); U.wait(4)
U.tap(game, "a"); U.wait(14)
U.shot(game, DIR .. "/battle_party_3_stats.png")
U.log("stats open:", game.stack:top() ~= pm)
end
+53 -1
View File
@@ -1,10 +1,12 @@
-- Driver: the two reworked battle flows.
-- Driver: reworked battle flows.
-- A) Old man catch tutorial (DisplayBattleMenu's old-man script):
-- scripted cursor FIGHT(80f) -> ITEM(50f), forced item menu with
-- one POKé BALL x50 -- itself scripted (list_menu.asm:65-80):
-- '▶' hover 80f, auto-A leaves the hollow '▷', always-caught throw.
-- B) Mimic's MID-move copy menu (MimicEffect): the chooser opens only
-- after the hit test, at (0,7) like MoveSelectionMenu .mimicmenu.
-- C) #162 trainer SHIFT offer: after KO'ing the first enemy mon,
-- "about to use" + "Will … change POKéMON?" with YES/NO.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
@@ -83,5 +85,55 @@ return function(game)
U.shot(game, DIR .. "/mimic_5_moves_after.png") -- slot now holds the copy
while game.stack:top() ~= ow do game.stack:pop() end
U.wait(5)
-- ------------------------------------------------ C) SHIFT about-to-use
-- Inject the foe-faint path at the menu so screenshots don't depend on
-- accuracy rolls; party has 2 slots so TrainerAboutToUseText fires.
local ChoiceBox = require("src.ui.ChoiceBox")
local PartyMenu = require("src.ui.PartyMenu")
game.save.options = game.save.options or {}
game.save.options.battleStyle = "shift"
game.save.options.animations = false
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 50),
Pokemon.new(game.data, "BLASTOISE", 50),
}
local trainer = BattleState.newTrainer(game, "OPP_YOUNGSTER", 1)
trainer.onFinish = function() end
ow:pushBattle(trainer)
mashUntil(function() return trainer.phase == "menu" end)
U.shot(game, DIR .. "/shift_0_menu.png")
trainer:markParticipant()
trainer.enemy.mon.hp = 0
trainer.enemyParty[1].hp = 0
trainer.phase = "messages"
trainer.afterQueue = "menu"
trainer:onFaint(trainer.enemy)
local function topIs(cls)
return getmetatable(game.stack:top()) == cls
end
local function textDone(needle)
local t = trainer.current and trainer.current.text
return t and t:find(needle, 1, true)
and (trainer.charIndex or 0) >= (trainer.total or 0)
end
mashUntil(function() return textDone("about to use") end, 200)
U.shot(game, DIR .. "/shift_1_about_to_use.png")
mashUntil(function() return textDone("change POKéMON") end, 80)
U.wait(20) -- ChoiceBox auto-opens once the page has typed out
mashUntil(function() return topIs(ChoiceBox) end, 40)
U.shot(game, DIR .. "/shift_2_will_change.png")
U.shot(game, DIR .. "/shift_3_yes_no.png")
U.tap(game, "a"); U.wait(8) -- YES
mashUntil(function() return topIs(PartyMenu) end, 40)
U.shot(game, DIR .. "/shift_4_party.png")
U.tap(game, "down"); U.wait(4)
U.tap(game, "a"); U.wait(8) -- BLASTOISE
mashUntil(function() return textDone("sent") end, 120)
U.shot(game, DIR .. "/shift_5_enemy_sent.png")
mashUntil(function() return textDone("BLASTOISE") end, 120)
U.shot(game, DIR .. "/shift_6_player_sent.png")
while game.stack:top() ~= ow do game.stack:pop() end
U.wait(5)
love.event.quit()
end
+120
View File
@@ -0,0 +1,120 @@
-- Driver: Bill's PC (#177) — chrome (What? / BOX No. / <PK><MN>) and
-- withdraw/deposit returning to BillsPCMenu instead of closing the PC.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Boxes = require("src.pokemon.Boxes")
local Menu = require("src.ui.Menu")
local ListMenu = require("src.ui.ListMenu")
local TextBox = require("src.render.TextBox")
local Pokemon = require("src.pokemon.Pokemon")
local pass = true
local function check(cond, msg)
if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end
end
local function topIs(mt) return getmetatable(game.stack:top()) == mt end
local function topMenu()
local top = game.stack:top()
return getmetatable(top) == Menu and top or nil
end
local function mash(btn, cond, frames)
for _ = 1, frames or 120 do
if cond() then return true end
U.tap(game, btn)
U.wait(3)
end
return false
end
-- party + box stocked so withdraw and deposit both work
game.save.party = {
Pokemon.new(game.data, "PIKACHU", 12),
Pokemon.new(game.data, "EEVEE", 10),
}
Boxes.ensure(game.save)
game.save.currentBox = 1
game.save.boxes[1] = {
Pokemon.new(game.data, "SPEAROW", 8),
Pokemon.new(game.data, "RATTATA", 6),
}
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_MET_BILL = true
-- PC tile at (13,3); stand on (13,4) facing up
U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up")
local ow = game.overworld
U.tap(game, "a") -- open PC
U.wait(6)
check(topIs(Menu), "PC main menu opened")
U.tap(game, "a") -- BILL'S PC
U.wait(8)
local bills = topMenu()
check(bills ~= nil, "Bill's PC menu opened")
check(bills and bills.items[1] and bills.items[1].label:find("<PK>", 1, true),
"WITHDRAW label includes <PK><MN> icon tokens")
U.shot(game, DIR .. "/bills_pc_1_menu.png")
-- WITHDRAW first box mon
U.tap(game, "a") -- WITHDRAW
U.wait(8)
check(topIs(ListMenu), "withdraw list opened")
U.tap(game, "a") -- pick SPEAROW
U.wait(6)
check(topIs(Menu), "withdraw mon submenu opened")
U.tap(game, "a") -- WITHDRAW action
U.wait(6)
check(mash("a", function() return topIs(TextBox) end, 60),
"withdraw shows taken-out text")
-- let the typewriter finish so the shot shows the taken-out line
for _ = 1, 90 do
local tb = game.stack:top()
if getmetatable(tb) == TextBox and (tb.waiting or tb.done) then break end
U.wait(2)
end
U.shot(game, DIR .. "/bills_pc_2_withdraw_text.png")
check(mash("a", function()
local m = topMenu()
return m and m.items and m.items[1]
and tostring(m.items[1].label):find("WITHDRAW", 1, true)
end, 120), "after withdraw, back on Bill's PC menu")
check(#game.save.party == 3, "party gained the withdrawn mon")
check(#game.save.boxes[1] == 1, "box lost the withdrawn mon")
U.shot(game, DIR .. "/bills_pc_3_menu_after_withdraw.png")
-- DEPOSIT party lead (PIKACHU)
U.tap(game, "down") -- DEPOSIT
U.wait(2)
U.tap(game, "a")
U.wait(8)
check(topIs(ListMenu), "deposit list opened")
U.tap(game, "a") -- first party mon
U.wait(6)
check(topIs(Menu), "deposit mon submenu opened")
U.tap(game, "a") -- DEPOSIT action
U.wait(6)
check(mash("a", function() return topIs(TextBox) end, 60),
"deposit shows stored text")
for _ = 1, 90 do
local tb = game.stack:top()
if getmetatable(tb) == TextBox and (tb.waiting or tb.done) then break end
U.wait(2)
end
U.shot(game, DIR .. "/bills_pc_4_deposit_text.png")
check(mash("a", function()
local m = topMenu()
return m and m.items and m.items[1]
and tostring(m.items[1].label):find("WITHDRAW", 1, true)
end, 120), "after deposit, back on Bill's PC menu")
check(#game.save.party == 2, "party lost the deposited mon")
check(#game.save.boxes[1] == 2, "box gained the deposited mon")
U.shot(game, DIR .. "/bills_pc_5_menu_after_deposit.png")
-- still inside the PC (not back on the overworld)
check(game.stack:top() ~= ow, "PC still open after transfers")
check(not topIs(ListMenu), "not stuck in a transfer list")
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
end
+95 -1
View File
@@ -1,15 +1,35 @@
-- Driver: throw Poké Balls and capture the catch suspense sequence
-- (toss -> poof -> mon hides -> ball shakes -> breakout or capture).
-- (toss -> poof -> mon hides -> ball shakes -> breakout or capture),
-- #159 nickname ask (text + white field, YES then NO), and #172
-- full-party catch nickname prompt before box transfer.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local ChoiceBox = require("src.ui.ChoiceBox")
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 12))
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
local BattleState = require("src.battle.BattleState")
local function topIs(cls)
return getmetatable(game.stack:top()) == cls
end
local function mashUntil(cond, n)
for _ = 1, (n or 400) do
if cond() then return true end
U.tap(game, "a")
U.wait(3)
end
return false
end
local function onNicknameChoice()
return topIs(ChoiceBox)
end
-- one throw, screenshotting every 20 frames through the chain
local function throwAndShoot(tag, rng)
local battle = BattleState.newWild(game, "PIDGEY", 8)
@@ -36,4 +56,78 @@ return function(game)
throwAndShoot("break", function(a, b) return b end)
-- rng low: clean capture, ball stays shut
throwAndShoot("caught", function(a, b) return a end)
-- #159: party catch -> nickname ask on white (YES cursor, then NO)
game.save.party = { Pokemon.new(game.data, "PIKACHU", 5) }
game.save.pokedex = game.save.pokedex or { owned = {}, seen = {} }
game.save.pokedex.owned.RATTATA = true
game.save.pokedex.seen.RATTATA = true
local battle = BattleState.newWild(game, "RATTATA", 3)
battle.onFinish = function() end
battle.rng = function(a, b) return a end
ow:pushBattle(battle)
for _ = 1, 14 do U.tap(game, "a"); U.wait(6) end
battle.phase = "messages"
battle.afterQueue = "menu"
battle:throwBall("POKE_BALL")
U.log("nick YES:", mashUntil(onNicknameChoice, 600))
U.shot(game, DIR .. "/catch_nickname_yes.png")
U.tap(game, "down")
U.wait(4)
U.shot(game, DIR .. "/catch_nickname_no.png")
U.tap(game, "a") -- confirm NO
U.wait(8)
for _ = 1, 20 do U.tap(game, "a"); U.wait(4) end
while game.stack:top() ~= ow do game.stack:pop() end
U.wait(5)
-- #172: full party of 6 -> nickname ask, then "sent to BOX" / PC text
game.save.party = {}
for _ = 1, 6 do
table.insert(game.save.party, Pokemon.new(game.data, "RATTATA", 5))
end
game.save.pokedex.owned.PIDGEY = true
game.save.pokedex.seen.PIDGEY = true
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_MET_BILL = true
battle = BattleState.newWild(game, "PIDGEY", 8)
battle.onFinish = function() end
battle.rng = function(a, b) return a end
ow:pushBattle(battle)
for _ = 1, 14 do U.tap(game, "a"); U.wait(6) end
battle.phase = "messages"
battle.afterQueue = "menu"
battle:throwBall("POKE_BALL")
U.log("full-party nick ask:", mashUntil(onNicknameChoice, 600))
U.wait(4)
U.shot(game, DIR .. "/catch_fullparty_nickname.png")
-- decline nickname (NO), then wait (no A/B) for ItemUseBallText07
if topIs(ChoiceBox) then
U.tap(game, "b") -- NO
end
-- drain the B edge before the transfer text can see it
for _ = 1, 8 do U.wait(1) end
local sawBox = false
for _ = 1, 400 do
if game.stack:top() == battle and battle.current and battle.current.text
and battle.current.text:find("transferred", 1, true) then
sawBox = true
-- wait until the typewriter has drawn most of the line
if (battle.charIndex or 0) >= math.floor((battle.total or 0) * 0.7) then
break
end
end
U.wait(1)
end
U.log("full-party box text:", sawBox,
battle.current and battle.current.text or "nil")
U.shot(game, DIR .. "/catch_fullparty_box.png")
for _ = 1, 30 do U.tap(game, "a"); U.wait(4) end
while game.stack:top() ~= ow do game.stack:pop() end
end
+70
View File
@@ -0,0 +1,70 @@
-- Driver: Cerulean TM28 Rocket despawn fade (#170).
-- Skips the fight (EVENT_BEAT set), talks through the TM return, and
-- captures a mid-fade frame while CeruleanHideRocket runs.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
table.insert(game.save.party, Pokemon.new(game.data, "BLASTOISE", 50))
game.save.flags.EVENT_BEAT_CERULEAN_ROCKET_THIEF = true
-- Rocket at (30,8); stand south and face him
U.teleport(game, "CERULEAN_CITY", 30, 9, "up")
local ow = game.overworld
local function mashUntil(cond, label, cap)
for _ = 1, cap or 600 do
if cond() then return true end
if game.stack:top() ~= ow then U.tap(game, "a") end
U.wait(3)
end
U.log("TIMEOUT waiting for " .. label)
return false
end
U.shot(game, DIR .. "/cerulean_rocket_00_before.png")
U.tap(game, "a")
U.wait(20)
U.shot(game, DIR .. "/cerulean_rocket_01_return_tm.png")
local function rocketAlive()
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == "CERULEANCITY_ROCKET" then return true end
end
return false
end
-- Prefer mid fade-in (rocket already toggled off, screen still dark),
-- matching pokered's post-HideObject GBFadeInFromBlack frames.
local midShot = false
for _ = 1, 900 do
local overlay = ow.fadeOverlay
local alpha = overlay and overlay.alpha or 0
if not midShot and alpha > 0.35 and alpha < 0.95 and not rocketAlive() then
U.shot(game, DIR .. "/cerulean_rocket_02_mid_fade.png")
midShot = true
end
if midShot and not ow.fadeOverlay and game.stack:top() == ow
and not ow.runner:isRunning() then
break
end
if game.stack:top() ~= ow then U.tap(game, "a") end
U.wait(2)
end
U.log("mid-fade shot:", tostring(midShot))
mashUntil(function()
return game.stack:top() == ow and not ow.runner:isRunning()
and not ow.fadeOverlay
end, "idle after hide", 200)
U.wait(10)
U.shot(game, DIR .. "/cerulean_rocket_03_after.png")
local names = {}
for _, n in ipairs(ow.npcs or {}) do
names[#names + 1] = n.def and n.def.name or "?"
end
local toggles = game.save.objectToggles and game.save.objectToggles.CERULEAN_CITY
U.log("rocket alive:", tostring(rocketAlive()))
U.log("rocket toggle:", tostring(toggles and toggles.CERULEANCITY_ROCKET))
U.log("got TM28:", tostring(game.save.flags.EVENT_GOT_TM28))
U.log("npcs:", table.concat(names, ","))
end
+104
View File
@@ -0,0 +1,104 @@
-- Driver: Copycat TM31 MIMIC trade (scripts/CopycatsHouse2F.asm).
-- Teleport to 2F with a POKE DOLL, talk through the mimic dialogue + gift,
-- screenshot the TM receive box, and assert flag/inventory.
--
-- SHOT_DIR=/tmp/copycat_tm31 POKEPORT_DRIVER=tests/drivers/copycat_tm31_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Bag = require("src.inventory.Bag")
local TextBox = require("src.render.TextBox")
Bag.add(game.save, "POKE_DOLL", 1)
U.teleport(game, "COPYCATS_HOUSE_2F", 4, 4, "up")
local ow = game.overworld
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.text == "TEXT_COPYCATSHOUSE2F_COPYCAT" then
npc.wanders, npc.moving = false, false
npc.cellX, npc.cellY = 4, 3
npc.px, npc.py = npc.cellX * 16, npc.cellY * 16
npc.facing = "down"
end
end
U.wait(5)
U.shot(game, DIR .. "/copycat_0_room.png")
local function topBox()
local top = game.stack:top()
return getmetatable(top) == TextBox and top or nil
end
local function pageText(box)
if not box then return "" end
return table.concat(box.pages[box.pageIndex] or {}, "\n")
end
-- Advance until a fully-typed page matches pred(pageText), then stop
-- without consuming that page (so the shot catches it). Last pages
-- set done (not waiting); mid-text page breaks set waiting.
local function mashToPage(pred)
for _ = 1, 500 do
local box = topBox()
if box and (box.waiting or box.done) and pred(pageText(box)) then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
U.tap(game, "a")
U.wait(20)
U.log("mimic page:", mashToPage(function(s)
return s:find("like POKéMON", 1, true) ~= nil
or s:find("like POK", 1, true) ~= nil
end))
U.shot(game, DIR .. "/copycat_1_mimic.png")
U.log("pre-receive page:", mashToPage(function(s)
return s:find("DOLL", 1, true) ~= nil
end))
U.shot(game, DIR .. "/copycat_2_prereceive.png")
U.log("TM receive page:", mashToPage(function(s)
return s:find("received", 1, true) ~= nil
and s:find("TM31", 1, true) ~= nil
end))
U.shot(game, DIR .. "/copycat_3_received_tm31.png")
local function mash(btn, cond)
for _ = 1, 400 do
if cond() then return true end
U.tap(game, btn)
U.wait(3)
end
return false
end
U.log("dialogue done:", mash("a", function()
return game.stack:top() == ow
end))
U.shot(game, DIR .. "/copycat_4_done.png")
U.log("EVENT_GOT_TM31:", tostring(game.save.flags.EVENT_GOT_TM31))
U.log("bag TM_MIMIC:", tostring(game.save.inventory.TM_MIMIC),
"POKE_DOLL:", tostring(game.save.inventory.POKE_DOLL))
assert(game.save.flags.EVENT_GOT_TM31, "EVENT_GOT_TM31 not set")
assert((game.save.inventory.TM_MIMIC or 0) >= 1, "TM_MIMIC missing from bag")
assert((game.save.inventory.POKE_DOLL or 0) == 0, "POKE_DOLL not taken")
U.tap(game, "a")
U.wait(20)
U.log("thanks page:", mashToPage(function(s)
return s:find("Thanks for TM31", 1, true) ~= nil
end))
U.wait(2)
U.shot(game, DIR .. "/copycat_5_thanks.png")
mash("a", function() return game.stack:top() == ow end)
U.log("DONE")
love.event.quit()
end
+75
View File
@@ -0,0 +1,75 @@
-- Driver (#184): Magikarp Day Care 13→19 should learn Tackle at 15.
-- Runs the DAYCARE retrieve script (same path as the gentleman NPC),
-- logs the moveset, then screenshots status page 2 (moves + PP).
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Growth = require("src.pokemon.Growth")
local SummaryMenu = require("src.ui.SummaryMenu")
local story2 = require("data.scripts.story2")
local def = game.data.pokemon.MAGIKARP
local karp = Pokemon.new(game.data, "MAGIKARP", 13)
karp.nickname = "FISHY"
game.save.party = { Pokemon.new(game.data, "RATTATA", 5) }
game.save.money = 10000
game.save.player.name = "RED"
local steps = Growth.expForLevel(def.growthRate, 19)
- Growth.expForLevel(def.growthRate, 13)
game.save.daycare = {
mon = karp, steps = steps, depositLevel = 13,
}
local before = {}
for _, mv in ipairs(karp.moves) do before[#before + 1] = mv.id end
U.log("before: Lv", karp.level, "moves", table.concat(before, ","))
U.teleport(game, "DAYCARE", 2, 2, "up")
-- Resolve dialogue synchronously (parity_daycare style) without stacking
-- fake TextBoxes over the overworld.
local realTextBox = package.loaded["src.render.TextBox"]
package.loaded["src.render.TextBox"] = {
new = function(_, text, onDone, opts)
if opts and opts.choice then
opts.choice(true)
elseif onDone then
onDone()
end
return { text = text }
end,
}
local realPush = game.stack.push
game.stack.push = function() end
local done = false
story2.DAYCARE.talk.TEXT_DAYCARE_GENTLEMAN(
game, game.overworld, nil, function() done = true end)
game.stack.push = realPush
package.loaded["src.render.TextBox"] = realTextBox
local mon = game.save.party[#game.save.party]
local ids = {}
for _, mv in ipairs(mon.moves or {}) do ids[#ids + 1] = mv.id end
U.log("after: Lv", mon.level, "moves", table.concat(ids, ","),
"done=", done, "daycare_cleared=", game.save.daycare == nil)
local hasTackle = false
for _, id in ipairs(ids) do if id == "TACKLE" then hasTackle = true end end
U.log("Magikarp 13→19 learned Tackle:", hasTackle)
local summary = SummaryMenu.new(game, mon)
game.stack:push(summary)
U.wait(4)
U.shot(game, DIR .. "/daycare_00_summary_p1.png")
summary.page = 2
U.wait(2)
U.shot(game, DIR .. "/daycare_01_moves.png")
U.log("shots under", DIR)
if not (mon.species == "MAGIKARP" and mon.level == 19 and hasTackle) then
error("daycare #184 failed: Lv" .. tostring(mon.level)
.. " [" .. table.concat(ids, ",") .. "]")
end
end
+97
View File
@@ -0,0 +1,97 @@
-- Driver: #163 cont (\\v) must show ▼ and wait for A before scrolling.
-- Talks to VIRIDIANFOREST_YOUNGSTER5 (cont mid-page) and the nearby
-- TRAINER TIPS1 sign (para then conts). Screenshots the ▼ wait frames.
--
-- SHOT_DIR=/tmp/dialogue_cont POKEPORT_DRIVER=tests/drivers/dialogue_cont_wait_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/dialogue_cont"
local TextBox = require("src.render.TextBox")
local pass = true
local function check(cond, msg)
if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end
end
local function topIs(mt) return getmetatable(game.stack:top()) == mt end
local function waitForTextBox(frames)
for _ = 1, frames or 60 do
if topIs(TextBox) then return game.stack:top() end
U.wait(1)
end
return nil
end
-- type out until waiting/done without mashing A (held A would only
-- speed the typewriter; wasPressed edges are what clear waits)
local function waitUntilPrompt(box, frames)
for _ = 1, frames or 240 do
if not topIs(TextBox) then return false end
if box.waiting or box.done then return true end
U.wait(1)
end
return box.waiting or box.done
end
-- ▼ blinks half the time (blink < 30); settle so the shot shows it.
-- Keep POKEPORT_SPEED=1: scriptedIterations>1 can advance past the
-- wait in the same rendered frame before love.draw captures.
local function shotPrompt(box, path)
for _ = 1, 60 do
if not (box.waiting or box.done) then break end
if box.blink < 20 then break end
U.wait(1)
end
U.shot(game, path)
U.wait(2)
end
local function dismissText()
for _ = 1, 80 do
if not topIs(TextBox) then return end
U.tap(game, "a")
U.wait(2)
end
end
-- Youngster5 @ (27,40): "I ran out of POKé / BALLs to catch" then
-- \v "POKéMON with!" — the bug auto-scrolled past this with no ▼.
U.teleport(game, "VIRIDIAN_FOREST", 27, 41, "up")
U.tap(game, "a")
local box = waitForTextBox(90)
check(box ~= nil, "youngster5 opened a TextBox")
if box then
check(waitUntilPrompt(box, 300), "youngster5 reached a prompt")
check(box.waiting and box.contAdvance,
"youngster5 first prompt is a cont wait (▼)")
shotPrompt(box, DIR .. "/dialogue_cont_00_youngster5_arrow.png")
U.tap(game, "a")
U.wait(3)
check(waitUntilPrompt(box, 300), "youngster5 reached para/done after cont")
shotPrompt(box, DIR .. "/dialogue_cont_01_youngster5_after.png")
dismissText()
end
-- Trainer Tips1 sign @ (24,40): para after title, then two conts.
U.teleport(game, "VIRIDIAN_FOREST", 24, 41, "up")
U.tap(game, "a")
box = waitForTextBox(90)
check(box ~= nil, "tips1 sign opened a TextBox")
if box then
check(waitUntilPrompt(box, 300), "tips1 reached first prompt")
-- first prompt is the para after "TRAINER TIPS"
check(box.waiting and not box.contAdvance,
"tips1 first prompt is a para wait")
shotPrompt(box, DIR .. "/dialogue_cont_02_tips1_para.png")
U.tap(game, "a")
U.wait(3)
check(waitUntilPrompt(box, 300), "tips1 typed into page 2")
check(box.waiting and box.contAdvance,
"tips1 page2 pauses on cont with ▼")
shotPrompt(box, DIR .. "/dialogue_cont_03_tips1_cont_arrow.png")
dismissText()
end
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
end
+18 -2
View File
@@ -1,5 +1,5 @@
-- Driver: reproduce the door-warp flow. Teleports to Pallet Town in
-- front of Red's house, walks in, tries to move inside, walks back out.
-- Driver: door-warp flow (Pallet house) plus #187 S.S. Anne enter-from-above.
-- Anne-only shots: tests/drivers/anne_door_test.lua
return function(game)
local U = dofile("tests/drivers/util.lua")
@@ -32,4 +32,20 @@ return function(game)
U.shot(game, DIR .. "/door_9_back_outside.png")
U.log("final map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY,
"transitioning:", tostring(ow.transitioning))
-- #187: enter SS Anne cabin from above; expect one tile south of door
U.teleport(game, "SS_ANNE_1F", 31, 9, "up")
U.shot(game, DIR .. "/door_10_anne_before.png")
U.hold(game, "up", 20)
for _ = 1, 90 do
U.wait(1)
if ow.map.id == "SS_ANNE_1F_ROOMS" and not ow.transitioning
and #ow.scriptMoves == 0 and not ow.player.moving then
break
end
end
U.wait(8)
U.shot(game, DIR .. "/door_11_anne_after.png")
U.log("anne map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY,
"facing:", ow.player.facing)
end
+93
View File
@@ -0,0 +1,93 @@
-- Driver: gym-leader post-battle dialogue chain (#164).
-- Invokes checkVictoryRewards for Brock then Misty (same path as a win)
-- and screenshots pages that must include badge-effect + TM explanation
-- text — not just a synthetic "received badge/TM" stub.
--
-- SHOT_DIR=/tmp/gym164 POKEPORT_DRIVER=tests/drivers/gym_leader_victory_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local TextBox = require("src.render.TextBox")
local OW = require("src.world.OverworldController")
local function currentPageText()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return "" end
local page = top.pages and top.pages[top.pageIndex]
if not page then return "" end
return table.concat(page, "\n")
end
local function mashUntil(cond)
for _ = 1, 1200 do
if cond() then return true end
U.tap(game, "a")
U.wait(2)
end
return false
end
local function advancePages(want, shotName)
U.log(shotName, "waiting for:", want)
local ok = mashUntil(function()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return false end
-- only match once the current page has finished typing
if not (top.waiting or top.done) then return false end
return currentPageText():find(want, 1, true) ~= nil
end)
U.log(shotName, "found:", ok, "page:", currentPageText():gsub("\n", "\\n"))
U.shot(game, DIR .. "/" .. shotName .. ".png")
assert(ok, shotName .. " missing dialogue containing: " .. want)
end
local function runLeader(mapId, x, y, class, party, shots)
while game.stack:top() do game.stack:pop() end
game.save.flags = game.save.flags or {}
game.save.inventory = game.save.inventory or {}
game.save.defeatedTrainers = game.save.defeatedTrainers or {}
game.stack:push(OW, mapId, x, y, "up")
U.wait(5)
local ow = game.stack:top()
U.shot(game, DIR .. "/" .. shots.prefix .. "_0_gym.png")
ow:checkVictoryRewards(class, party)
U.wait(10)
for _, s in ipairs(shots.pages) do
advancePages(s.want, shots.prefix .. "_" .. s.name)
end
U.log(shots.prefix, "closing box:", mashUntil(function()
return game.stack:top() == ow
end))
U.shot(game, DIR .. "/" .. shots.prefix .. "_done.png")
end
game.save.player.name = game.save.player.name or "RED"
runLeader("PEWTER_GYM", 4, 3, "OPP_BROCK", 1, {
prefix = "brock",
pages = {
{ want = "BOULDERBADGE", name = "1_badge" },
{ want = "FLASH", name = "2_flash" },
{ want = "Wait!", name = "3_wait" },
{ want = "TM34", name = "4_tm34" },
{ want = "BIDE", name = "5_bide" },
},
})
assert(game.save.flags.EVENT_BEAT_BROCK, "EVENT_BEAT_BROCK")
assert(game.save.inventory.BOULDERBADGE, "BOULDERBADGE")
assert((game.save.inventory.TM_BIDE or 0) >= 1, "TM_BIDE")
runLeader("CERULEAN_GYM", 5, 5, "OPP_MISTY", 1, {
prefix = "misty",
pages = {
{ want = "CASCADEBADGE", name = "1_badge" },
{ want = "CUT", name = "2_cut" },
{ want = "TM11", name = "3_tm11" },
},
})
assert(game.save.flags.EVENT_BEAT_MISTY, "EVENT_BEAT_MISTY")
assert(game.save.inventory.CASCADEBADGE, "CASCADEBADGE")
assert((game.save.inventory.TM_BUBBLEBEAM or 0) >= 1, "TM_BUBBLEBEAM")
U.log("gym_leader_victory_test: ok")
end
+71 -8
View File
@@ -1,10 +1,11 @@
-- Driver: Pokémon Center nurse heal, welcome/choice dialogue, the
-- machine monitor + per-mon balls, the jingle flash, and the farewell.
-- Driver: Pokémon Center nurse heal + Mom home heal.
-- Logs frames between healAnim end and the fighting-fit / looking-great text.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
local mon = Pokemon.new(game.data, "CHARMANDER", 12)
mon.hp = 3
table.insert(game.save.party, mon)
@@ -14,7 +15,6 @@ return function(game)
U.teleport(game, "VIRIDIAN_POKECENTER", 3, 3, "up")
local ow = game.overworld
-- mash A until a condition holds
local function mashUntil(cond)
for _ = 1, 400 do
if cond() then return true end
@@ -24,6 +24,11 @@ return function(game)
return false
end
local function topIsText()
return getmetatable(game.stack:top()) == TextBox
end
-- -------- Poké Center --------
U.tap(game, "a") -- talk to the nurse
U.wait(30)
U.shot(game, DIR .. "/heal_00_welcome.png")
@@ -38,7 +43,6 @@ return function(game)
U.shot(game, DIR .. "/heal_02_ball1.png")
U.wait(28)
U.shot(game, DIR .. "/heal_03_ball2.png")
-- overlay must stay glued to the machine under survey zoom
game:zoomStep(-1); game:zoomStep(-1)
U.wait(3)
U.shot(game, DIR .. "/heal_03z_zoomed.png")
@@ -51,14 +55,73 @@ return function(game)
U.shot(game, DIR .. "/heal_05_flash_b.png")
U.wait(10)
U.shot(game, DIR .. "/heal_06_flash_c.png")
-- wait out the jingle + 32-frame beat
for _ = 1, 40 do
if not ow.healAnim then break end
U.wait(10)
local animEndFrame, fitFrame
for _ = 1, 400 do
if ow.healAnim == nil and not animEndFrame then
animEndFrame = U.frame()
if topIsText() then fitFrame = animEndFrame end
U.shot(game, DIR .. "/heal_06b_anim_end.png")
if fitFrame then break end
end
if animEndFrame and topIsText() and not fitFrame then
fitFrame = U.frame()
break
end
U.wait(1)
end
U.shot(game, DIR .. "/heal_07_fit.png")
U.log("pc anim_end_frame:", animEndFrame, "fit_frame:", fitFrame,
"gap_anim_to_text:",
(fitFrame and animEndFrame) and (fitFrame - animEndFrame) or "n/a")
U.log("farewell reached:", mashUntil(function()
return game.stack:top() == ow
end))
U.shot(game, DIR .. "/heal_08_end.png")
-- -------- Mom (Reds House) --------
require("src.script.Flags").set(game.save, "EVENT_GOT_STARTER")
for _, m in ipairs(game.save.party) do
m.hp = 1
end
-- stand below Mom (5,4) facing up
U.teleport(game, "REDS_HOUSE_1F", 5, 5, "up")
ow = game.overworld
U.tap(game, "a")
U.wait(20)
U.shot(game, DIR .. "/heal_mom_00_rest.png")
U.log("mom fade started:", mashUntil(function()
return ow.fadeOverlay ~= nil
end))
local fadeStart = U.frame()
U.shot(game, DIR .. "/heal_mom_01_fade.png")
local Music = require("src.core.Music")
local sawJingle, jingleDoneFrame, fadeGone, greatFrame = false
for _ = 1, 600 do
if Music.oneShotPlaying() then sawJingle = true end
if sawJingle and not Music.oneShotPlaying() and not jingleDoneFrame then
jingleDoneFrame = U.frame()
end
if ow.fadeOverlay == nil and fadeStart and not fadeGone
and U.frame() > fadeStart + 5 then
fadeGone = U.frame()
end
if fadeGone and topIsText() then
greatFrame = U.frame()
break
end
U.wait(1)
end
U.shot(game, DIR .. "/heal_mom_02_great.png")
U.log("mom saw_jingle:", sawJingle,
"jingle_done_frame:", jingleDoneFrame,
"fade_gone_frame:", fadeGone,
"great_frame:", greatFrame,
"gap_fade_to_text:",
(greatFrame and fadeGone) and (greatFrame - fadeGone) or "n/a")
mashUntil(function()
return game.stack:top() == ow
end)
U.shot(game, DIR .. "/heal_mom_03_end.png")
end
+162
View File
@@ -0,0 +1,162 @@
-- Driver: level-up learn with a full moveset (#173).
-- Injects the post-KO level-up queue (grew + StatBox + learnMove) inside
-- a live wild battle so MoveLearnMenu runs the same path as a real win.
--
-- SHOT_DIR=/tmp/learn173 POKEPORT_DRIVER=tests/drivers/learn_move_full_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/learn173"
os.execute("mkdir -p " .. DIR)
local Pokemon = require("src.pokemon.Pokemon")
local mon = Pokemon.new(game.data, "CHARMANDER", 14)
mon.moves = {
{ id = "SCRATCH", pp = 35 },
{ id = "GROWL", pp = 40 },
{ id = "EMBER", pp = 25 },
{ id = "SMOKESCREEN", pp = 20 },
}
table.insert(game.save.party, 1, mon)
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(game, "RATTATA", 2)
battle.onFinish = function() end
ow:pushBattle(battle)
local function top() return game.stack:top() end
local function stackIds()
local parts = {}
for _, st in ipairs(game.stack.states or {}) do
local id = st.screenId or (st.pages and "TextBox")
or (st.onChoose and "ChoiceBox")
or (st.mon and st.stats and "StatBox")
or (st.newMoveId and "MoveLearnMenu")
or (st.phase and ("Battle:" .. tostring(st.phase)))
or "?"
if st.selecting ~= nil then
id = id .. (st.selecting and "[sel]" or "[ask]")
end
if st.pages then
id = id .. string.format("[p%d/%d%s%s]", st.pageIndex or 0, #st.pages,
st.waiting and "W" or "", st.done and "D" or "")
end
parts[#parts + 1] = id
end
return table.concat(parts, ">")
end
local function isText()
local t = top()
return t and t.pages ~= nil
end
local function isChoice()
local t = top()
return t and t.onChoose ~= nil and t.index ~= nil and not t.mon
end
local function learnMenu()
for _, st in ipairs(game.stack.states or {}) do
if st.newMoveId and st.mon and st.index then return st end
end
end
local function waitFor(cond, max)
for i = 1, max or 900 do
if cond() then return true end
U.wait(1)
end
return false
end
local function atPrompt()
local t = top()
return t and t.pages and (t.waiting or t.done)
end
-- #163: \v conts pause with ▼ mid-page; tap through them so the shot
-- lands on the full page (or the done+choice tail)
local function advanceTextPage()
waitFor(atPrompt, 300)
for _ = 1, 4 do
local t = top()
if not (t and t.pages and t.waiting and t.contAdvance) then break end
U.tap(game, "a")
U.wait(2)
waitFor(atPrompt, 300)
end
U.wait(2)
end
waitFor(function() return battle.phase == "menu" end, 400)
local StatBox = BattleState.StatBox
battle.phase = "messages"
battle.afterQueue = "menu"
battle.queue = {}
battle.current = nil
battle.nextInsert = 0
battle:say("CHARMANDER gained\n16 EXP. Points!")
battle:say("CHARMANDER grew\nto level 15!")
battle:ui(function() return StatBox.new(game, mon) end)
mon.level = 15
-- append (not uiNext): learnMove uses uiNext for mid-fn ordering
battle:ui(function()
return battle:buildScreen("MoveLearnMenu", mon, "LEER")
end)
for _ = 1, 400 do
if learnMenu() then break end
U.tap(game, "a")
U.wait(2)
end
if not learnMenu() then error("MoveLearnMenu never opened: " .. stackIds()) end
U.log("opened", stackIds())
-- page 1
advanceTextPage()
U.log("shot0", stackIds())
U.shot(game, DIR .. "/learn_0_trying.png")
U.tap(game, "a"); U.wait(2)
-- page 2
advanceTextPage()
U.log("shot1", stackIds())
U.shot(game, DIR .. "/learn_1_cant_more.png")
U.tap(game, "a"); U.wait(2)
-- page 3 types out (through its cont), then ChoiceBox overlays
advanceTextPage()
if not waitFor(isChoice, 120) then
error("ChoiceBox never appeared: " .. stackIds())
end
U.wait(4) -- let choice settle over Delete prompt
U.log("shot2", stackIds())
U.shot(game, DIR .. "/learn_2_delete_yesno.png")
U.tap(game, "a"); U.wait(4) -- YES
if not waitFor(function()
local m = learnMenu()
return m and m.selecting and top() == m
end, 120) then
error("forget list never active: " .. stackIds())
end
U.log("shot3", stackIds())
U.shot(game, DIR .. "/learn_3_which_move.png")
U.tap(game, "a"); U.wait(4) -- forget SCRATCH
local n = 4
for _ = 1, 60 do
if not isText() then break end
local t = top()
if t.done or t.waiting then
U.log("shot" .. n, stackIds())
U.shot(game, ("%s/learn_%d_after.png"):format(DIR, n))
n = n + 1
U.tap(game, "a")
U.wait(4)
else
U.wait(1)
end
end
U.log("done", DIR, "moves[1]=", mon.moves[1] and mon.moves[1].id, stackIds())
love.event.quit()
end
+112
View File
@@ -0,0 +1,112 @@
-- Driver: Mt Moon B2F fossil pick (scripts/MtMoonB2F.asm). After the
-- Super Nerd is beaten, take one fossil and confirm he walks to the other
-- and says "All right. Then this is mine!" before it vanishes.
-- Runs DOME then HELIX paths (map is reset between).
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
if #game.save.party == 0 then
table.insert(game.save.party, Pokemon.new(game.data, "PIKACHU", 20))
end
local function runPick(tag, x, y, takeName, leaveName, itemId, expectNerdX, expectNerdY, gotFlag)
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_BEAT_MT_MOON_3_SUPER_NERD = true
game.save.flags.EVENT_GOT_DOME_FOSSIL = nil
game.save.flags.EVENT_GOT_HELIX_FOSSIL = nil
game.save.inventory.DOME_FOSSIL = nil
game.save.inventory.HELIX_FOSSIL = nil
game.save.objectToggles = game.save.objectToggles or {}
game.save.objectToggles.MT_MOON_B2F = nil
U.teleport(game, "MT_MOON_B2F", x, y, "up")
local ow = game.overworld
U.wait(5)
local function idle()
return game.stack:top() == ow and not ow.runner:isRunning()
and #ow.scriptMoves == 0
end
local function mash(cond, label, cap)
for _ = 1, cap or 400 do
if cond() then return true end
U.tap(game, "a")
U.wait(3)
end
U.log("TIMEOUT:", tag, label)
return false
end
local function fossilVisible(name)
for _, n in ipairs(ow.npcs) do
if n.def and n.def.name == name then return true end
end
return false
end
local function nerd()
return ow:npcByIndex(1)
end
U.shot(game, DIR .. ("/mtmoon_%s_0_before.png"):format(tag))
U.log(tag, "before", takeName .. ":", tostring(fossilVisible(takeName)),
leaveName .. ":", tostring(fossilVisible(leaveName)),
"nerd:", nerd() and (nerd().cellX .. "," .. nerd().cellY) or "nil")
U.tap(game, "a")
U.wait(24)
U.shot(game, DIR .. ("/mtmoon_%s_1_ask.png"):format(tag))
U.tap(game, "a") -- YES
mash(function()
return game.stack:top() == ow or ow.runner:isRunning()
end, "received / walk start", 200)
local sawMineText, midwalk = false, false
for _ = 1, 500 do
local n = nerd()
if not midwalk and n and (n.cellX ~= 12 or n.cellY ~= 8
or n.moving or #ow.scriptMoves > 0) then
if n.cellX ~= 12 or n.cellY ~= 8 or n.moving then
midwalk = true
U.shot(game, DIR .. ("/mtmoon_%s_2_walk.png"):format(tag))
end
end
local top = game.stack:top()
if top and top ~= ow and top.pages and top.done then
local flat = ""
for _, page in ipairs(top.pages) do
for _, line in ipairs(page) do flat = flat .. line .. " " end
end
if flat:find("this is mine", 1, true) then
sawMineText = true
U.shot(game, DIR .. ("/mtmoon_%s_3_mine.png"):format(tag))
break
end
end
U.wait(1)
end
mash(idle, "cutscene idle", 400)
U.shot(game, DIR .. ("/mtmoon_%s_4_done.png"):format(tag))
local n = nerd()
U.log(tag, "sawMineText:", tostring(sawMineText),
"midwalk:", tostring(midwalk))
U.log(tag, "after", takeName .. ":", tostring(fossilVisible(takeName)),
leaveName .. ":", tostring(fossilVisible(leaveName)))
U.log(tag, "nerd at:", n and (n.cellX .. "," .. n.cellY) or "nil",
"expect:", expectNerdX .. "," .. expectNerdY)
U.log(tag, "flag:", gotFlag, tostring(game.save.flags[gotFlag]),
"bag:", tostring(game.save.inventory[itemId]))
end
runPick("dome", 12, 7,
"MTMOONB2F_DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "DOME_FOSSIL",
13, 7, "EVENT_GOT_DOME_FOSSIL")
runPick("helix", 13, 7,
"MTMOONB2F_HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL", "HELIX_FOSSIL",
12, 7, "EVENT_GOT_HELIX_FOSSIL")
U.log("DONE")
love.event.quit()
end
+65 -11
View File
@@ -1,6 +1,5 @@
-- Driver: the full Pallet intro chain. Steps north to trigger Oak,
-- follows him to the lab, takes a starter, watches the rival
-- counter-pick, tries to leave (door gate + ambush battle).
-- Driver: Pallet intro (Oak escort → starter → rival ambush) plus the
-- #158 parcel-return / rival-join / Pokédex-gift cutscene.
return function(game)
local U = dofile("tests/drivers/util.lua")
@@ -96,13 +95,68 @@ return function(game)
U.log("final:", ow.map.id, ow.player.cellX, ow.player.cellY,
"battled:", tostring(game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB),
"battleSeen:", tostring(BattleSeen))
U.log("stuck-state: runner=", tostring(ow.runner:isRunning()),
"moves=", #ow.scriptMoves, "top=", tostring(game.stack:top() == ow),
"transitioning=", tostring(ow.transitioning),
"emote=", tostring(ow.emote ~= nil))
for i, mv in ipairs(ow.scriptMoves) do
U.log((" move %d: entity=%s dir=%s remaining=%d moving=%s"):format(
i, tostring(mv.entity.def and mv.entity.def.sprite or "PLAYER"),
tostring(mv.dir), mv.remaining, tostring(mv.entity.moving)))
-- ---- #158: return Oak's Parcel → rival joins → Pokédex gift ----
local Flags = require("src.script.Flags")
Flags.set(game.save, "EVENT_GOT_OAKS_PARCEL")
game.save.inventory.OAKS_PARCEL = 1
-- desk Oak + both dexes visible; rival hidden until the script shows
-- him. Keep the starter-ball hides from the pick (Squirtle + Bulbasaur).
game.save.objectToggles = game.save.objectToggles or {}
game.save.objectToggles.OAKS_LAB = {
OAKSLAB_OAK1 = true,
OAKSLAB_OAK2 = false,
OAKSLAB_RIVAL = false,
OAKSLAB_POKEDEX1 = true,
OAKSLAB_POKEDEX2 = true,
OAKSLAB_SQUIRTLE_POKE_BALL = false,
OAKSLAB_BULBASAUR_POKE_BALL = false,
}
U.teleport(game, "OAKS_LAB", 5, 3, "up")
ow = game.overworld
U.wait(20)
U.shot(game, DIR .. "/oak_12_parcel_ready.png")
U.tap(game, "a")
U.wait(40)
U.shot(game, DIR .. "/oak_13_deliver.png")
local rivalSeen, atDeskShot, leaveShot = false, false, false
for _ = 1, 1200 do
if idle() and Flags.get(game.save, "EVENT_GOT_POKEDEX")
and not ow:npcByIndex(1) then
break
end
local rival = ow:npcByIndex(1)
local top = game.stack:top()
if rival and not rivalSeen then
rivalSeen = true
U.wait(24)
U.shot(game, DIR .. "/oak_14_rival_arrive.png")
end
if rival and rival.cellY <= 3 and not atDeskShot then
atDeskShot = true
U.shot(game, DIR .. "/oak_14b_rival_at_desk.png")
end
if rival and rival.cellY >= 6 and Flags.get(game.save, "EVENT_GOT_POKEDEX")
and not leaveShot then
leaveShot = true
U.shot(game, DIR .. "/oak_14c_rival_leave.png")
end
if top ~= ow then
U.tap(game, "a")
end
U.wait(4)
end
mashUntil(idle, "pokedex cutscene done", 600)
U.shot(game, DIR .. "/oak_15_after_pokedex.png")
local toggles = game.save.objectToggles and game.save.objectToggles.OAKS_LAB
local rivalAfter = ow:npcByIndex(1)
U.log("parcel:",
"gotDex=", tostring(Flags.get(game.save, "EVENT_GOT_POKEDEX")),
"oakGot=", tostring(Flags.get(game.save, "EVENT_OAK_GOT_PARCEL")),
"rivalSeen=", tostring(rivalSeen),
"rivalGone=", tostring(rivalAfter == nil),
"atDesk=", tostring(atDeskShot),
"dex1=", tostring(toggles and toggles.OAKSLAB_POKEDEX1),
"dex2=", tostring(toggles and toggles.OAKSLAB_POKEDEX2),
"route22=", tostring(Flags.get(game.save, "EVENT_ROUTE22_RIVAL_WANTS_BATTLE")))
end
+87
View File
@@ -0,0 +1,87 @@
-- Driver: Player's PC deposit "How many?" with >7 bag stacks (#174).
-- pokered PrintListMenuEntries shows 4 names; PrintText + quantity box
-- must not overlap the list.
-- SHOT_DIR=/tmp/pc_deposit POKEPORT_DRIVER=tests/drivers/pc_deposit_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local PlayerPC = require("src.ui.PlayerPC")
local ListMenu = require("src.ui.ListMenu")
local QuantityBox = require("src.ui.QuantityBox")
local Bag = require("src.inventory.Bag")
local pass = true
local function check(cond, msg)
if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end
end
local function topIs(mt) return getmetatable(game.stack:top()) == mt end
U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up")
game.save.pcItems = {}
-- >7 distinct depositable stacks (bug reproduces only then)
local seed = {
"POTION", "SUPER_POTION", "ANTIDOTE", "AWAKENING", "BURN_HEAL",
"ICE_HEAL", "PARLYZ_HEAL", "FULL_HEAL", "REPEL", "POKE_BALL",
}
for _, id in ipairs(seed) do Bag.add(game.save, id, 5) end
check(#seed > 7, "seeded more than 7 bag stacks")
game.stack:push(PlayerPC.new(game))
U.wait(4)
U.tap(game, "down"); U.wait(1) -- DEPOSIT ITEM
U.tap(game, "a"); U.wait(4)
check(topIs(ListMenu), "DEPOSIT ITEM list opened")
local list = game.stack:top()
check(list.rows == 4, "deposit list uses 4 rows like PrintListMenuEntries")
check(list.messageBox == true, "deposit list uses messageBox footer")
check(#list.items > 7, "list has more than 7 depositable items")
U.shot(game, DIR .. "/pc_deposit_0_list.png")
-- pick first non-key stack (seed items are all quantity-prompted)
U.tap(game, "a"); U.wait(4)
check(topIs(QuantityBox), "quantity box on stack")
check(list.footer == "How many?", "How many? footer set")
U.shot(game, DIR .. "/pc_deposit_1_how_many.png")
-- layout probe: list rows stay above the text box (ty=12 → y=96)
local drawn = {}
local Font = require("src.render.Font")
local savedDraw, savedBox = Font.draw, Font.drawBox
Font.draw = function(text, x, y)
drawn[#drawn + 1] = { kind = "text", text = tostring(text), x = x, y = y }
return savedDraw(text, x, y)
end
Font.drawBox = function(tx, ty, tw, th)
drawn[#drawn + 1] = { kind = "box", tx = tx, ty = ty, tw = tw, th = th }
return savedBox(tx, ty, tw, th)
end
game.stack:draw()
Font.draw, Font.drawBox = savedDraw, savedBox
local howY, qtyBox, textBox, maxItemY
for _, d in ipairs(drawn) do
if d.kind == "text" and d.text == "How many?" then howY = d.y end
if d.kind == "box" and d.ty == 9 and d.tw == 5 then qtyBox = d end
if d.kind == "box" and d.tx == 0 and d.ty == 12 and d.tw == 20 then
textBox = d
end
if d.kind == "text" and tostring(d.text):match("^x%d") and d.y then
if not maxItemY or d.y > maxItemY then maxItemY = d.y end
end
end
check(howY == 112, "How many? sits on text-box first line (y=112)")
check(textBox ~= nil, "bottom PrintText box drawn")
check(qtyBox ~= nil, "quantity box at pokered row 9")
check(maxItemY and maxItemY <= 72, "last visible item row above qty/text (y<=72)")
if maxItemY and howY then
check(maxItemY + 8 <= howY, "item glyphs do not overlap How many?")
end
U.tap(game, "a"); U.wait(6) -- confirm x01
U.shot(game, DIR .. "/pc_deposit_2_stored.png")
check(type(list.footer) == "string" and list.footer:find("stored", 1, true),
"stored-via-PC footer after deposit")
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
love.event.quit(pass and 0 or 1)
end
+37
View File
@@ -0,0 +1,37 @@
-- Driver: Bill's PC vs player's PC top menus (#176). Both should open
-- on the left (pokered BillsPCMenu / PlayersPCMenu TextBoxBorder at 0,0).
-- SHOT_DIR=/tmp/pc_sides POKEPORT_DRIVER=tests/drivers/pc_menu_sides_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local BoxMenu = require("src.ui.BoxMenu")
local PlayerPC = require("src.ui.PlayerPC")
U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up")
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_MET_BILL = true
game.stack:push(BoxMenu.new(game))
U.wait(5)
local bills = game.stack:top()
U.log(("bills tx=%d ty=%d tw=%d th=%d"):format(
bills.tx, bills.ty, bills.tw, bills.th))
U.shot(game, DIR .. "/pc_0_bills.png")
U.wait(8) -- let async captureScreenshot flush before pop
game.stack:pop()
U.wait(2)
game.stack:push(PlayerPC.new(game))
U.wait(5)
local player = game.stack:top()
U.log(("player tx=%d ty=%d tw=%d th=%d"):format(
player.tx, player.ty, player.tw, player.th))
U.shot(game, DIR .. "/pc_1_player.png")
U.wait(8)
local sameSide = player.tx == bills.tx and player.ty == bills.ty
U.log(sameSide and "PASS: both menus share top-left origin"
or "FAIL: menus not on the same side")
U.log("DONE")
love.event.quit()
end
+74
View File
@@ -0,0 +1,74 @@
-- Driver: Bill's PC RELEASE two mons in one list session (#171).
-- SHOT_DIR=/tmp/pc_release POKEPORT_DRIVER=tests/drivers/pc_release_two_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Boxes = require("src.pokemon.Boxes")
local BoxMenu = require("src.ui.BoxMenu")
local ListMenu = require("src.ui.ListMenu")
local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
local pass = true
local function check(cond, msg)
if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end
end
local function topIs(mt) return getmetatable(game.stack:top()) == mt end
local function mash(btn, cond, n)
for _ = 1, (n or 240) do
if cond() then return true end
U.tap(game, btn)
U.wait(2)
end
return false
end
U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up")
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_MET_BILL = true
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
local box = Boxes.active(game.save)
for i = #box, 1, -1 do box[i] = nil end
box[1] = Pokemon.new(game.data, "RATTATA", 5)
box[2] = Pokemon.new(game.data, "PIDGEY", 6)
box[3] = Pokemon.new(game.data, "CATERPIE", 4)
check(#box == 3, "seeded 3 boxed mons")
game.stack:push(BoxMenu.new(game))
U.wait(4)
U.tap(game, "down"); U.wait(1)
U.tap(game, "down"); U.wait(1)
U.tap(game, "a") -- RELEASE
check(mash("a", function() return topIs(ListMenu) end, 20), "RELEASE list opened")
U.shot(game, DIR .. "/pc_release_0_list.png")
local function releaseOne(label)
check(topIs(ListMenu), label .. ": still on release list")
local before = #Boxes.active(game.save)
U.tap(game, "a") -- pick current row
check(mash("a", function() return topIs(ChoiceBox) end), label .. ": confirm choice")
U.shot(game, DIR .. "/pc_release_" .. label .. "_confirm.png")
U.tap(game, "up"); U.wait(1) -- defaultNo -> YES
U.tap(game, "a")
check(mash("a", function()
return topIs(ListMenu) or (#game.stack.states > 0 and topIs(TextBox) == false)
end), label .. ": bye text advancing")
check(mash("a", function() return topIs(ListMenu) end), label .. ": back on list")
local after = #Boxes.active(game.save)
check(after == before - 1, label .. ": box count " .. before .. " -> " .. after)
U.shot(game, DIR .. "/pc_release_" .. label .. "_list.png")
end
releaseOne("1st")
releaseOne("2nd")
check(#Boxes.active(game.save) == 1, "one mon remains after two releases")
check(topIs(ListMenu), "still in RELEASE list without re-entering")
U.shot(game, DIR .. "/pc_release_done.png")
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
love.event.quit(pass and 0 or 1)
end
+203
View File
@@ -0,0 +1,203 @@
-- Driver: #154 Route 22 rival exit direction + #168 Cerulean Bill text
-- and southbound walk-off.
--
-- SHOT_DIR=/tmp/rival_walkoff POKEPORT_SPEED=8 \
-- POKEPORT_DRIVER=tests/drivers/rival_walkoff_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/rival_walkoff"
os.execute("mkdir -p " .. DIR)
local Pokemon = require("src.pokemon.Pokemon")
local Flags = require("src.script.Flags")
local Zoom = require("src.render.Zoom")
if game.save.options then game.save.options.zoom = 0 end
Zoom.reset()
local function tank()
local mon = Pokemon.new(game.data, "MEWTWO", 100)
mon.moves = {
{ id = "PSYCHIC_M", pp = 99 },
{ id = "THUNDERBOLT", pp = 99 },
{ id = "ICE_BEAM", pp = 99 },
{ id = "RECOVER", pp = 99 },
}
return mon
end
game.save.party = { tank() }
game.save.player = game.save.player or {}
game.save.player.name = "RED"
game.save.player.rival = "BLUE"
Flags.set(game.save, "EVENT_CHOSE_SQUIRTLE")
local function idle(ow)
return game.stack:top() == ow and not ow.runner:isRunning()
and #ow.scriptMoves == 0 and not ow.transitioning
end
local function mashUntil(cond, label, cap)
for _ = 1, cap or 800 do
if cond() then return true end
if game.stack:top() ~= game.overworld then U.tap(game, "a") end
U.wait(2)
end
U.log("TIMEOUT waiting for " .. label)
return false
end
local function rivalNpc()
local ow = game.overworld
for _, npc in ipairs(ow.npcs or {}) do
if npc.def and npc.def.name and npc.def.name:find("RIVAL") then
return npc
end
end
end
-- Fight until the battle state leaves the stack. Do NOT mash the
-- post-battle script text -- the caller screenshots that.
local function winBattle(label)
local ow = game.overworld
local sawBattle = false
for frames = 1, 5000 do
local top = game.stack:top()
if top and top.phase then
sawBattle = true
if top.phase == "menu" then
top.menuIndex = 1
elseif top.phase == "moveSelect" then
top.moveIndex = 1
end
U.tap(game, "a")
if frames > 2400 and top.onFinish then
U.log("force-finishing stalled", label)
top.onFinish("win")
if game.stack:top() == top then game.stack:pop() end
end
elseif sawBattle then
-- battle popped: either a TextBox or the overworld runner
return true
elseif top ~= ow then
U.tap(game, "a") -- pre-battle dialogue
end
U.wait(2)
end
U.log("TIMEOUT winBattle", label)
return false
end
-- ---------------------------------------------------------- #154
-- Route 22 optional post-lab rival: after the fight he must walk
-- toward Viridian (right / down), not back toward the League.
Flags.set(game.save, "EVENT_GOT_POKEDEX")
Flags.clear(game.save, "EVENT_BEAT_BROCK")
Flags.clear(game.save, "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE")
Flags.clear(game.save, "EVENT_BEAT_GIOVANNI")
Flags.clear(game.save, "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE")
U.teleport(game, "ROUTE_22", 30, 5, "left")
local ow = game.overworld
U.shot(game, DIR .. "/r22_0_before.png")
U.hold(game, "left", 20)
mashUntil(function()
return ow.runner:isRunning() or game.stack:top() ~= ow
end, "route22 ambush", 200)
U.shot(game, DIR .. "/r22_1_ambush.png")
mashUntil(function() return game.stack:top() ~= ow end, "route22 battle", 800)
winBattle("route22")
local sawExit, exitFacing, exitX, exitY = false, nil, nil, nil
for _ = 1, 600 do
if game.stack:top() ~= ow then U.tap(game, "a") end
local r = rivalNpc()
if r and game.save.flags.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then
if (r.facing == "right" or r.facing == "down" or r.cellX > 28)
and not sawExit then
sawExit = true
exitFacing, exitX, exitY = r.facing, r.cellX, r.cellY
U.shot(game, DIR .. "/r22_2_exit.png")
U.log("r22 exit:", exitFacing, exitX, exitY)
elseif sawExit and (r.facing == "down" or r.cellY > 5) then
U.shot(game, DIR .. "/r22_3_exit_mid.png")
U.log("r22 exit mid:", r.facing, r.cellX, r.cellY)
break
end
end
if idle(ow) and game.save.flags.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then
break
end
U.wait(2)
end
mashUntil(function() return idle(ow) end, "route22 done", 800)
U.shot(game, DIR .. "/r22_4_done.png")
U.log("r22 beat:", tostring(game.save.flags.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE),
"exitFacing:", tostring(exitFacing))
-- ---------------------------------------------------------- #168
-- Nugget Bridge: Bill dialogue, then walk into Cerulean (south).
Flags.clear(game.save, "EVENT_BEAT_CERULEAN_RIVAL")
game.save.party = { tank() }
U.teleport(game, "CERULEAN_CITY", 20, 7, "up")
ow = game.overworld
U.shot(game, DIR .. "/cer_0_before.png")
U.hold(game, "up", 20)
mashUntil(function()
return ow.runner:isRunning() or game.stack:top() ~= ow
end, "cerulean ambush", 200)
U.shot(game, DIR .. "/cer_1_ambush.png")
mashUntil(function() return game.stack:top() ~= ow end, "cerulean battle", 800)
winBattle("cerulean")
local function boxText(top)
if not top or type(top.pages) ~= "table" then return "" end
local parts = {}
for _, page in ipairs(top.pages) do
if type(page) == "table" then
for _, line in ipairs(page) do parts[#parts + 1] = tostring(line) end
end
end
return table.concat(parts, " ")
end
local billShot, exitShot, lastBox, postBoxes = false, false, nil, 0
for _ = 1, 2000 do
local top = game.stack:top()
if top ~= ow then
local t = boxText(top)
if game.save.flags.EVENT_BEAT_CERULEAN_RIVAL and t ~= "" and top ~= lastBox then
lastBox = top
postBoxes = postBoxes + 1
U.log("post-battle box", postBoxes, t:sub(1, 80))
-- Bill line is the second post-flag text (after DefeatedText)
if not billShot and (t:find("BILL") or t:find("Storage")
or t:find("invented") or t:find("MANIAC") or t:find("pages")
or t:find("guess what") or postBoxes >= 2) then
billShot = true
for _ = 1, 30 do U.wait(2); U.tap(game, "a") end -- advance into Bill body
U.wait(20)
U.shot(game, DIR .. "/cer_2_bill.png")
U.log("bill text:", boxText(game.stack:top()):sub(1, 160))
end
end
U.tap(game, "a")
elseif game.save.flags.EVENT_BEAT_CERULEAN_RIVAL then
local r = rivalNpc()
if r and r.cellY and r.cellY >= 6 and r.facing == "down" and not exitShot then
exitShot = true
U.shot(game, DIR .. "/cer_3_exit.png")
U.log("cer exit:", r.facing, r.cellX, r.cellY)
end
if idle(ow) then break end
end
U.wait(2)
end
mashUntil(function() return idle(ow) end, "cerulean done", 800)
U.shot(game, DIR .. "/cer_4_done.png")
U.log("cer beat:", tostring(game.save.flags.EVENT_BEAT_CERULEAN_RIVAL),
"billShot:", tostring(billShot), "exitShot:", tostring(exitShot))
end
+5
View File
@@ -731,6 +731,11 @@ local function switchTo(battle, slot)
end
local t = replacementMenu()
if not t or t.index ~= slot then break end
press("a") -- select mon -> SWITCH / STATS / CANCEL
U.wait(8)
t = replacementMenu()
if not (t and t.submenu) then break end
-- SWITCH is first (SwitchStatsCancelText)
press("a")
U.wait(10)
ok = true
@@ -0,0 +1,60 @@
-- Driver: S.S. Anne kitchen PrintTrashText bins (#188).
-- Teleports beside each bin, interacts, screenshots the dialogue.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local TextBox = require("src.render.TextBox")
local pass = true
local function check(cond, msg)
if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end
end
local function topIs(mt) return getmetatable(game.stack:top()) == mt end
local bins = game.data.field.hiddenExtras
and game.data.field.hiddenExtras.printTrash
and game.data.field.hiddenExtras.printTrash.SS_ANNE_KITCHEN
check(bins and #bins == 2, "SS_ANNE_KITCHEN has 2 PrintTrashText bins")
for i, bin in ipairs(bins or {}) do
-- stand west of the bin facing right (cooks occupy the column)
U.teleport(game, "SS_ANNE_KITCHEN", bin.x - 1, bin.y, "right")
local ow = game.overworld
local fx, fy = ow.player:facingCell()
U.log(("bin %d: at (%d,%d) facing %s -> (%d,%d)"):format(
i, ow.player.cellX, ow.player.cellY, ow.player.facing, fx, fy))
check(fx == bin.x and fy == bin.y, ("facing kitchen trash at (%d,%d)"):format(bin.x, bin.y))
U.tap(game, "a")
local opened = false
for _ = 1, 60 do
if topIs(TextBox) then opened = true; break end
U.wait(2)
end
check(opened, ("bin %d opened a TextBox"):format(i))
if opened then
local box = game.stack:top()
for _ = 1, 180 do
if not topIs(TextBox) then break end
if box.waiting or box.done then break end
U.wait(1)
end
U.wait(4)
local page = box.pages and box.pages[1]
local body = type(page) == "table" and table.concat(page, "\n")
or (type(page) == "string" and page or "")
U.log("dialogue:", body:gsub("\n", "\\n"):sub(1, 80))
check(body:find("only trash here", 1, true) ~= nil,
("bin %d shows trash dialogue"):format(i))
U.shot(game, DIR .. ("/ss_anne_kitchen_trash_%d.png"):format(i))
for _ = 1, 40 do
if not topIs(TextBox) then break end
U.tap(game, "a")
U.wait(2)
end
end
end
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
end
@@ -0,0 +1,49 @@
-- Driver: #189 S.S. Anne 1F door→room order. Enter each hallway cabin
-- door left→right; screenshot room contents (SHOT_DIR).
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots-189"
local doors = {}
for i, w in ipairs(game.data.maps.SS_ANNE_1F.warps) do
if w.destMap == "SS_ANNE_1F_ROOMS" then
doors[#doors + 1] = { index = i, x = w.x, y = w.y, destWarp = w.destWarp }
end
end
table.sort(doors, function(a, b) return a.x < b.x end)
U.teleport(game, "SS_ANNE_1F_ROOMS", 12, 8, "down")
U.shot(game, DIR .. "/anne189_rooms_overview.png")
U.teleport(game, "SS_ANNE_1F", 20, 10, "up")
U.shot(game, DIR .. "/anne189_hall_overview.png")
for n, d in ipairs(doors) do
U.teleport(game, "SS_ANNE_1F", d.x, d.y + 1, "up")
U.shot(game, string.format("%s/anne189_door%d_hall_x%d.png", DIR, n, d.x))
U.hold(game, "up", 24)
local ow = game.overworld
for _ = 1, 120 do
U.wait(1)
if ow.map.id == "SS_ANNE_1F_ROOMS" and not ow.transitioning
and #(ow.scriptMoves or {}) == 0 and not ow.player.moving then
break
end
end
U.wait(10)
local names = {}
local col = math.floor(ow.player.cellX / 10)
local row = math.floor(ow.player.cellY / 10)
for _, npc in ipairs(ow.npcs or {}) do
if math.floor(npc.cellX / 10) == col and math.floor(npc.cellY / 10) == row then
names[#names + 1] = npc.def.name or "?"
end
end
table.sort(names)
U.log(string.format(
"door%d hall(%d,%d)#%d destWarp=%d -> %s (%d,%d) cell=%d,%d npcs: %s",
n, d.x, d.y, d.index, d.destWarp, ow.map.id,
ow.player.cellX, ow.player.cellY, col, row, table.concat(names, ",")))
U.shot(game, string.format("%s/anne189_door%d_room.png", DIR, n))
end
end
+84
View File
@@ -0,0 +1,84 @@
-- Driver for #169: poison status icon must appear AFTER move anim +
-- "was poisoned!" text, not during the "used POISONPOWDER!" announce.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local mon = Pokemon.new(game.data, "BULBASAUR", 12)
mon.moves[1] = { id = "POISONPOWDER", pp = 20 }
mon.moves[2] = { id = "TACKLE", pp = 20 }
table.insert(game.save.party, 1, mon)
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
local battle = BattleState.newWild(game, "PIDGEY", 8)
battle.onFinish = function() end
-- force hit + make the foe never act (skip second half of the turn)
battle.rng = function(a, b) return a or 0 end
battle.enemyAction = function() return nil end
ow:pushBattle(battle)
local function waitFor(cond, max)
for _ = 1, max or 600 do
if cond() then return true end
U.wait(1)
end
return false
end
local function mashUntil(cond, max)
for _ = 1, max or 80 do
if cond() then return true end
U.tap(game, "a")
U.wait(4)
end
return false
end
mashUntil(function() return battle.phase == "menu" end)
U.shot(game, DIR .. "/poison_0_menu.png")
U.tap(game, "a"); U.wait(8) -- FIGHT
U.tap(game, "a"); U.wait(2) -- POISONPOWDER
-- announce: "BULBASAUR used POISONPOWDER!" — no PSN on HUD yet
waitFor(function()
return battle.current and battle.current.text
and battle.current.text:find("POISONPOWDER", 1, true)
end, 120)
U.shot(game, DIR .. "/poison_1_announce.png")
U.log(("announce shownStatus=%s mon.status=%s"):format(
tostring(battle.enemy.shownStatus), tostring(battle.enemy.mon.status)))
-- mash through announce into the move anim / poisoned text
local sawAnim, sawText = false, false
for _ = 1, 200 do
if battle.animPlaying or battle.animName == "POISONPOWDER" then
if not sawAnim then
sawAnim = true
U.shot(game, DIR .. "/poison_2_anim.png")
U.log(("anim shownStatus=%s"):format(tostring(battle.enemy.shownStatus)))
end
end
if battle.current and battle.current.text
and battle.current.text:find("poisoned", 1, true) then
if not sawText then
sawText = true
U.shot(game, DIR .. "/poison_3_text.png")
U.log(("text shownStatus=%s"):format(tostring(battle.enemy.shownStatus)))
end
end
if battle.enemy.shownStatus == "PSN" and sawText then
U.shot(game, DIR .. "/poison_4_icon.png")
U.log("HUD PSN revealed after poisoned text")
break
end
U.tap(game, "a")
U.wait(2)
end
waitFor(function() return battle.phase == "menu" end, 200)
U.shot(game, DIR .. "/poison_5_menu.png")
U.log(("final shownStatus=%s"):format(tostring(battle.enemy.shownStatus)))
end
+60
View File
@@ -0,0 +1,60 @@
-- Driver: try a TM from the battle bag; Gen 1 refuses with Oak's
-- "This isn't the time to use that!" (ItemUseTMHM -> ItemUseNotTime).
--
-- SHOT_DIR=/tmp/tm_battle_shots POKEPORT_DRIVER=tests/drivers/tm_battle_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Bag = require("src.inventory.Bag")
local TextBox = require("src.render.TextBox")
local ListMenu = require("src.ui.ListMenu")
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 12))
Bag.add(game.save, "TM_TOXIC", 1)
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(game, "PIDGEY", 8)
battle.onFinish = function() end
ow:pushBattle(battle)
local function mashUntil(cond, max)
for _ = 1, max or 80 do
if cond() then return true end
U.tap(game, "a")
U.wait(4)
end
return false
end
local function pageText(box)
if not box or getmetatable(box) ~= TextBox then return "" end
return table.concat(box.pages[box.pageIndex] or {}, "\n")
end
U.log("to menu:", mashUntil(function() return battle.phase == "menu" end))
U.shot(game, DIR .. "/tm_battle_0_menu.png")
-- FIGHT/PKMN / ITEM/RUN: down to ITEM, then A
U.tap(game, "down"); U.wait(4)
U.tap(game, "a"); U.wait(12)
U.shot(game, DIR .. "/tm_battle_1_bag.png")
U.log("bag open:", getmetatable(game.stack:top()) == ListMenu)
U.tap(game, "a")
mashUntil(function()
local top = game.stack:top()
return getmetatable(top) == TextBox and top.waiting
and pageText(top):find("isn't the", 1, true)
end, 40)
U.shot(game, DIR .. "/tm_battle_2_reject.png")
local top = game.stack:top()
local text = pageText(top)
U.log("reject TextBox:", getmetatable(top) == TextBox)
U.log("oak text:", text:find("isn't the", 1, true) and "ok" or text)
U.log("tm still in bag:", (game.save.inventory.TM_TOXIC or 0) >= 1)
U.log("not booted:", not text:find("Booted", 1, true))
end
+155
View File
@@ -0,0 +1,155 @@
-- Driver: NPC in-game trade cable animation (InternalClockTradeAnim).
-- Vermilion Trade House: SPEAROW -> FARFETCH'D (DUX).
--
-- SHOT_DIR=/tmp/trade_shots POKEPORT_DRIVER=tests/drivers/trade_anim_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/trade_shots"
local Pokemon = require("src.pokemon.Pokemon")
local TradeAnim = require("src.ui.TradeAnim")
local TextBox = require("src.render.TextBox")
local PartyMenu = require("src.ui.PartyMenu")
local ChoiceBox = require("src.ui.ChoiceBox")
local function topIs(cls)
return getmetatable(game.stack:top()) == cls
end
game.save.party = { Pokemon.new(game.data, "SPEAROW", 10) }
U.teleport(game, "VERMILION_TRADE_HOUSE", 3, 6, "up")
U.wait(5)
U.shot(game, DIR .. "/trade_00_house.png")
U.tap(game, "a")
U.wait(10)
for _ = 1, 200 do
if topIs(ChoiceBox) then break end
U.tap(game, "a")
U.wait(2)
end
U.shot(game, DIR .. "/trade_01_offer.png")
U.tap(game, "a") -- YES
U.wait(6)
for _ = 1, 60 do
if topIs(PartyMenu) then break end
U.wait(1)
end
U.log("party menu:", topIs(PartyMenu))
U.shot(game, DIR .. "/trade_02_party.png")
U.tap(game, "a")
U.wait(6)
for _ = 1, 200 do
if topIs(TradeAnim) then break end
U.tap(game, "a")
U.wait(2)
end
local anim = game.stack:top()
if getmetatable(anim) ~= TradeAnim then
U.log("FAIL: TradeAnim never appeared")
return
end
U.log("TradeAnim phase:", anim.phase)
local function ffUntil(phase, cap)
for _ = 1, cap or 3000 do
if anim.phase == phase or anim.phase == "done" then break end
if anim.waitingText or topIs(TextBox) then
U.wait(1)
else
anim:update(1 / 60)
end
end
U.wait(1)
end
-- hold mid show_player (scx settled, mon visible)
ffUntil("show_player", 1)
while anim.phase == "show_player" and (anim.sub ~= "hold" or anim.scx > 0) do
anim:update(1 / 60)
end
for _ = 1, 10 do anim:update(1 / 60) end
U.wait(1)
U.shot(game, DIR .. "/trade_03_show_player.png")
U.log("show_player sub=", anim.sub, "scx=", anim.scx)
ffUntil("open_cable", 500)
-- mid scroll-in
while anim.phase == "open_cable" and anim.scx > 40 do
anim:update(1 / 60)
end
U.wait(1)
U.shot(game, DIR .. "/trade_04_open_cable.png")
U.log("open_cable scx=", anim.scx)
ffUntil("ball_enter", 200)
while anim.phase == "ball_enter" and anim.ballX < 0x80 do
anim:update(1 / 60)
end
U.wait(1)
U.shot(game, DIR .. "/trade_05_ball_enter.png")
U.log("ball at", anim.ballX, anim.ballY)
ffUntil("transfer_lr", 400)
for _ = 1, 24 do anim:update(1 / 60) end
U.wait(1)
U.shot(game, DIR .. "/trade_06_transfer_lr.png")
ffUntil("went_to", 800)
for _ = 1, 600 do
if topIs(TextBox) then
local tb = game.stack:top()
if tb.done then break end
-- finish typewriter so both lines are visible
if tb.codes then tb.charIndex = #tb.codes end
if tb.pages and tb.pageIndex and tb.lineIndex then
local page = tb.pages[tb.pageIndex]
if page and tb.lineIndex < #page then
tb.lineIndex = #page
tb.shown = {}
for _, line in ipairs(page) do
local codes = require("src.render.Font").encode(line)
tb.shown[#tb.shown + 1] = codes
end
tb.charIndex = #(tb.shown[#tb.shown] or {})
tb.done = true
end
end
break
end
U.wait(1)
end
U.wait(2)
U.shot(game, DIR .. "/trade_07_went_to.png")
U.log("went-to:", topIs(TextBox))
ffUntil("transfer_rl", 2500)
for _ = 1, 16 do anim:update(1 / 60) end
U.wait(1)
U.shot(game, DIR .. "/trade_08_transfer_rl.png")
ffUntil("show_enemy", 800)
while anim.phase == "show_enemy" and not anim.monVisible do
anim:update(1 / 60)
end
for _ = 1, 30 do anim:update(1 / 60) end
U.wait(1)
U.shot(game, DIR .. "/trade_09_show_enemy.png")
for _ = 1, 5000 do
if game.stack:top() == game.overworld then break end
local top = game.stack:top()
if getmetatable(top) == TradeAnim and not top.waitingText then
top:update(1 / 60)
end
U.tap(game, "a")
U.wait(1)
end
U.shot(game, DIR .. "/trade_10_done.png")
local mon = game.save.party[1]
U.log("party:", mon and mon.species, "nick:", mon and mon.nickname,
"ot:", mon and mon.ot)
U.log("done")
end
+34
View File
@@ -0,0 +1,34 @@
-- Driver: trainer card with empty badge slots (gym leader faces) and a
-- partial set (face/badge mix), matching DrawBadges in pokered.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
game.save.player.name = "RED"
game.save.money = 3000
game.save.playTime = 3661
game.save.inventory = game.save.inventory or {}
local TrainerCard = require("src.ui.TrainerCard")
-- no badges: every slot should show the gym leader face
for _, id in ipairs({
"BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", "RAINBOWBADGE",
"SOULBADGE", "MARSHBADGE", "VOLCANOBADGE", "EARTHBADGE",
}) do
game.save.inventory[id] = nil
end
game.stack:push(TrainerCard.new(game))
U.wait(5)
U.shot(game, DIR .. "/trainer_card_0_faces.png")
U.tap(game, "b"); U.wait(3)
-- boulder + cascade owned: first two slots swap to badges
game.save.inventory.BOULDERBADGE = true
game.save.inventory.CASCADEBADGE = true
game.stack:push(TrainerCard.new(game))
U.wait(5)
U.shot(game, DIR .. "/trainer_card_1_partial.png")
U.tap(game, "b"); U.wait(3)
U.log("TRAINER_CARD_DRIVER: done")
end
@@ -0,0 +1,70 @@
-- Driver: trainer sight must not engage off the GB screen (#153/#183).
-- Oracle: engine/overworld/trainer_sight.asm TrainerEngage (IMAGEINDEX=$ff)
-- + movement.asm CheckSpriteAvailability (10x9 window). 8-bit pixel
-- distance wraps at ~12/28 tiles; without the on-screen gate, Viridian
-- Forest / Victory Road trainers aggro through trees and walls.
--
-- Scene A: VIRIDIAN_FOREST (18,33), west of tree wall vs Bug Catcher
-- (30,33) LEFT range 4 — dx=12 pixel-wrap must NOT engage.
-- Scene B: same trainer at (26,33) on-screen — MUST engage + walk-up.
-- Scene C: VICTORY_ROAD_2F (0,9) vs Hiker (12,9) — dx=12 must NOT engage.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function trainerByIndex(ow, index)
for _, npc in ipairs(ow.npcs) do
if npc.def.index == index then return npc end
end
end
-- --- A: Viridian Forest wrap through trees (dx=12, walkable west of wall) ---
U.teleport(game, "VIRIDIAN_FOREST", 18, 33, "right")
local ow = game.overworld
local t = trainerByIndex(ow, 2)
U.wait(10)
U.log("VF far: player", ow.player.cellX, ow.player.cellY,
"trainer", t and t.cellX, t and t.cellY,
"engaging", tostring(ow.engaging))
assert(t and t.cellX == 30 and t.cellY == 33, "VF Bug Catcher at (30,33)")
assert(not ow.engaging, "VF: off-screen trainer must not engage through trees")
assert(t.cellX == 30 and t.cellY == 33, "VF: trainer must not walk through trees")
U.shot(game, DIR .. "/153_vf_far_no_aggro.png")
-- --- B: real on-screen sight line (same corridor, no tree wall) ---
U.teleport(game, "VIRIDIAN_FOREST", 26, 33, "right")
ow = game.overworld
t = trainerByIndex(ow, 2)
local guard = 0
while not ow.engaging and guard < 30 do
guard = guard + 1
U.wait(1)
end
U.log("VF near: engaging", tostring(ow.engaging),
"emote", tostring(ow.emote and ow.emote.frames),
"trainer", t and t.cellX, t and t.cellY)
assert(ow.engaging, "VF: distance-4 on-screen must engage")
U.shot(game, DIR .. "/153_vf_near_engage.png")
-- EmotionBubble 60f + (dist-1)*16 walk frames
U.wait(60 + 3 * 16 + 10)
U.log("VF walkup: trainer", t.cellX, t.cellY, "player", ow.player.cellX, ow.player.cellY)
assert(t.cellY == 33 and t.cellX >= 26 and t.cellX <= 30,
"VF: walk-up stays on the open corridor")
assert(t.cellX == 27, "VF: trainer stops adjacent after distance-1 walk-up")
U.shot(game, DIR .. "/153_vf_near_walkup.png")
-- --- C: Victory Road 2F wrap (dx=12) ---
U.teleport(game, "VICTORY_ROAD_2F", 0, 9, "right")
ow = game.overworld
t = trainerByIndex(ow, 1)
U.wait(10)
U.log("VR2 far: player", ow.player.cellX, ow.player.cellY,
"trainer", t and t.cellX, t and t.cellY,
"engaging", tostring(ow.engaging))
assert(t and t.cellX == 12 and t.cellY == 9, "VR2 Hiker at (12,9)")
assert(not ow.engaging, "VR2: off-screen trainer must not engage")
assert(t.cellX == 12 and t.cellY == 9, "VR2: trainer must not walk through walls")
U.shot(game, DIR .. "/183_vr2_far_no_aggro.png")
U.log("trainer_sight_walls_test OK")
end
+9 -1
View File
@@ -33,7 +33,15 @@ end
function U.shot(game, path)
game.capturePath = path
U.wait(2) -- let the capture flush
-- love.draw consumes capturePath once per rendered frame, but fast runs
-- (POKEPORT_SPEED) step the driver many times per render; spin until the
-- capture lands so later actions can't outrun it
for _ = 1, 120 do
if not game.capturePath then break end
frame = frame + 1
coroutine.yield()
end
U.wait(1)
end
-- skip the intro movie + title into a fresh overworld game
+193
View File
@@ -0,0 +1,193 @@
-- Love-free coverage for the pure halves of src/mods/LauncherMods.lua: the
-- status derivation (deriveList) over a synthetic manifest list + options
-- table, and the archive-root location logic (locateRoot). The discovery and
-- installZip paths need love.filesystem and are exercised by the launcher; the
-- decision logic under them lives here so a bad range/conflict/root call fails
-- one line instead of the app.
-- luajit tests/engine/launcher_mods_tests.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Manifest = require("src.mods.Manifest")
local Version = require("src.core.Version")
local LauncherMods = require("src.mods.LauncherMods")
-- validated manifests are the exact shape deriveList/resolveToggle read
local function mf(raw)
return Manifest.validate(raw)
end
-- index a deriveList result by mod id for assertions
local function byId(list)
local m = {}
for _, row in ipairs(list) do m[row.id] = row end
return m
end
-- ------- badge derivation: category, then profile, then MOD (uppercased)
do
local list = LauncherMods.deriveList({
mf({ id = "cat", name = "Cat Mod", version = "1.0.0", entry = "m.lua",
category = "gameplay" }),
mf({ id = "prof", name = "Prof Mod", version = "1.0.0", entry = "m.lua",
profile = "overhaul" }),
mf({ id = "plain", name = "Plain", version = "1.0.0", entry = "m.lua" }),
}, { mods = {} })
local m = byId(list)
eq(m.cat.badge, "GAMEPLAY", "badge uses the manifest category, uppercased")
eq(m.prof.badge, "OVERHAUL", "badge falls back to the profile when no category")
-- no category field, so the fallback reaches the profile default ("content")
eq(m.plain.badge, "CONTENT", "bare manifest badge falls back to the profile")
eq(#list, 3, "every discovered manifest yields one row")
check(m.cat.id < m.plain.id and m.plain.id < m.prof.id,
"rows come back sorted by id (cat < plain < prof)")
end
-- ------- enabled defaults to true; a false entry disables
do
local manifests = {
mf({ id = "aaa", name = "A", version = "1.0.0", entry = "m.lua" }),
mf({ id = "bbb", name = "B", version = "1.0.0", entry = "m.lua" }),
}
local m = byId(LauncherMods.deriveList(manifests, { mods = { bbb = false } }))
check(m.aaa.enabled, "a mod with no options entry defaults to enabled")
check(not m.bbb.enabled, "an explicit false disables the mod")
eq(m.aaa.status, "ok", "a healthy enabled mod is ok")
eq(m.aaa.statusDetail, "Ready", "ok detail reads Ready")
end
-- ------- conflict: only when this mod is enabled and the other is too
do
local manifests = {
mf({ id = "alpha", name = "Alpha", version = "1.0.0", entry = "m.lua",
conflicts = { "beta" } }),
mf({ id = "beta", name = "Beta", version = "1.0.0", entry = "m.lua" }),
}
-- both enabled: the declaring side (and, symmetrically, the other) conflict
local both = byId(LauncherMods.deriveList(manifests, { mods = {} }))
eq(both.alpha.status, "conflict", "enabled mod conflicting with an enabled mod")
check(both.alpha.statusDetail:find("Beta", 1, true) ~= nil,
"conflict detail names the other mod")
eq(both.beta.status, "conflict",
"resolveToggle conflict is bidirectional: the target is flagged too")
-- disable beta: alpha no longer conflicts (nothing enabled to conflict with)
local off = byId(LauncherMods.deriveList(manifests, { mods = { beta = false } }))
eq(off.alpha.status, "ok", "no conflict once the other side is disabled")
eq(off.beta.status, "ok", "a disabled mod is never a conflict")
end
-- ------- warn: unsatisfied game_version range against Version.engine
do
-- a range the -dev engine cannot satisfy (needs a released >=1.0.0)
local manifests = {
mf({ id = "future", name = "Future", version = "1.0.0", entry = "m.lua",
game_version = ">=1.0.0" }),
}
local m = byId(LauncherMods.deriveList(manifests, { mods = {} }))
eq(m.future.status, "warn", "engine outside the game_version range warns")
check(m.future.statusDetail:find(">=1.0.0", 1, true) ~= nil,
"version warn detail quotes the required range")
check(m.future.statusDetail:find(Version.engine, 1, true) ~= nil,
"version warn detail quotes the engine version")
end
-- ------- warn: hard dependency missing, disabled, or wrong version
do
local base = { id = "base", name = "Base", version = "1.0.0", entry = "m.lua" }
local needsMissing = { id = "needy", name = "Needy", version = "1.0.0",
entry = "m.lua", dependencies = { "ghost" } }
local m = byId(LauncherMods.deriveList({ mf(needsMissing) }, { mods = {} }))
eq(m.needy.status, "warn", "a missing hard dependency warns")
check(m.needy.statusDetail:find("not installed", 1, true) ~= nil,
"missing-dep detail says not installed")
-- present but disabled
local m2 = byId(LauncherMods.deriveList(
{ mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0",
entry = "m.lua", dependencies = { "base" } }) },
{ mods = { base = false } }))
eq(m2.needy.status, "warn", "a disabled hard dependency warns")
check(m2.needy.statusDetail:find("disabled", 1, true) ~= nil,
"disabled-dep detail says disabled")
-- present, enabled, but the version is out of range
local m3 = byId(LauncherMods.deriveList(
{ mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0",
entry = "m.lua", dependencies = { "base@>=2.0.0" } }) },
{ mods = {} }))
eq(m3.needy.status, "warn", "a dependency below the required range warns")
eq(m3.base.status, "ok", "the satisfied dependency itself stays ok")
-- the same dep satisfied: needy is ok
local m4 = byId(LauncherMods.deriveList(
{ mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0",
entry = "m.lua", dependencies = { "base@>=1.0.0" } }) },
{ mods = {} }))
eq(m4.needy.status, "ok", "a satisfied dependency clears the warn")
end
-- ------- conflict outranks warn when a mod trips both
do
local manifests = {
mf({ id = "alpha", name = "Alpha", version = "1.0.0", entry = "m.lua",
conflicts = { "beta" }, game_version = ">=1.0.0" }),
mf({ id = "beta", name = "Beta", version = "1.0.0", entry = "m.lua" }),
}
local m = byId(LauncherMods.deriveList(manifests, { mods = {} }))
eq(m.alpha.status, "conflict",
"conflict is reported ahead of a version warn on the same mod")
end
-- ------- locateRoot: manifest at the archive root
do
local root, err = LauncherMods.locateRoot({ "manifest.json", "main.lua" })
eq(root, "", "a root-level manifest.json resolves to the empty prefix")
eq(err, nil, "no error for a root-level manifest")
end
-- ------- locateRoot: manifest inside a single top-level folder
do
local root = LauncherMods.locateRoot({
"mymod/manifest.json", "mymod/main.lua", "mymod/assets/x.png" })
eq(root, "mymod", "a single wrapping folder resolves to that folder name")
end
-- ------- locateRoot: no manifest anywhere
do
local root, err = LauncherMods.locateRoot({ "readme.txt", "stuff/x.lua" })
eq(root, nil, "an archive with no manifest.json resolves to nil")
check(err:find("no manifest.json", 1, true) ~= nil,
"the no-manifest reason is user-presentable")
end
-- ------- locateRoot: multiple top-level folders is ambiguous
do
local root, err = LauncherMods.locateRoot({
"one/manifest.json", "two/manifest.json" })
eq(root, nil, "two candidate mod folders resolves to nil")
check(err:find("single mod folder", 1, true) ~= nil,
"the ambiguous reason asks for a single mod folder")
end
-- ------- locateRoot: a lone folder without a manifest is not a root
do
local root, err = LauncherMods.locateRoot({ "assets/x.png" })
eq(root, nil, "a single folder with no manifest is not a mod root")
check(err ~= nil, "the no-root case carries a reason")
end
T.finish("launcher_mods")
+33
View File
@@ -0,0 +1,33 @@
-- Bill's PC vs player's PC top-menu origin/size (#176).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local Data = require("src.core.Data")
if not (Data.pokemon and next(Data.pokemon)) then Data:load() end
local SaveData = require("src.core.SaveData")
local BoxMenu = require("src.ui.BoxMenu")
local PlayerPC = require("src.ui.PlayerPC")
local game = {
data = Data,
save = SaveData.newGame(),
stack = { push = function() end },
}
local bills = BoxMenu.new(game)
local player = PlayerPC.new(game)
T.eq(bills.tx, 0, "Bill's PC TextBoxBorder at x=0")
T.eq(player.tx, 0, "Player's PC TextBoxBorder at x=0")
T.eq(player.ty, 0, "Player's PC at y=0")
T.eq(player.tw, 16, "Player's PC width (players_pc.asm c=$e +2)")
T.eq(player.th, 10, "Player's PC height (players_pc.asm b=$8 +2)")
T.check(player.tx == bills.tx and player.ty == bills.ty,
"both PC menus share the same top-left origin")
local labelTiles = 2 + #"WITHDRAW ITEM"
T.check(player.tx + labelTiles <= player.tx + player.tw - 1,
"WITHDRAW ITEM fits inside the Player's PC border")
T.finish("pc_menu_sides")
+92
View File
@@ -0,0 +1,92 @@
-- Headless regression: two Bill's PC releases in one list session (#171).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local Data = require("src.core.Data")
if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end
require("src.render.Font").load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local Boxes = require("src.pokemon.Boxes")
local BoxMenu = require("src.ui.BoxMenu")
local ListMenu = require("src.ui.ListMenu")
local ChoiceBox = require("src.ui.ChoiceBox")
local SaveData = require("src.core.SaveData")
local Sound = require("src.core.Sound")
local realCry, realPlay = Sound.playCry, Sound.play
Sound.playCry = function() end
Sound.play = function() end
local stack = { states = {} }
function stack:push(s) self.states[#self.states + 1] = s end
function stack:pop()
local t = self.states[#self.states]
self.states[#self.states] = nil
return t
end
function stack:top() return self.states[#self.states] end
function stack:update(dt)
local t = self:top()
if t and t.update then t:update(dt) end
end
local pressed = {}
local game = {
data = Data,
save = SaveData.newGame(),
stack = stack,
input = {
wasPressed = function(_, key) return pressed[key] or false end,
isDown = function() return false end,
},
}
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
local box = Boxes.active(game.save)
box[1] = Pokemon.new(Data, "RATTATA", 5)
box[2] = Pokemon.new(Data, "PIDGEY", 6)
box[3] = Pokemon.new(Data, "CATERPIE", 4)
local function press(btn)
pressed = { [btn] = true }
stack:update(1 / 60)
pressed = {}
end
local function topMt() return getmetatable(stack:top()) end
local function mash(btn, cond, n)
for _ = 1, (n or 400) do
if cond() then return true end
press(btn)
end
return false
end
stack:push(BoxMenu.new(game))
press("down"); press("down"); press("a")
T.check(topMt() == ListMenu, "RELEASE opens the box list")
T.eq(#box, 3, "box still has 3 before releases")
local function releaseCurrent()
local before = #box
press("a")
T.check(mash("a", function() return topMt() == ChoiceBox end),
"confirm choice opens")
press("up") -- defaultNo -> YES
press("a")
T.check(mash("a", function() return topMt() == ListMenu end),
"returns to RELEASE list")
T.eq(#box, before - 1, "one mon removed from the box")
end
releaseCurrent()
releaseCurrent()
T.eq(#box, 1, "two releases leave one mon")
T.check(topMt() == ListMenu, "still on RELEASE list after the second")
T.eq(box[1].species, "CATERPIE", "remaining mon is the third seeded one")
Sound.playCry, Sound.play = realCry, realPlay
T.finish("pc_release")
+233
View File
@@ -0,0 +1,233 @@
-- Launcher save Import/Export glue (src/import/SaveFileIO.lua): the end-to-end
-- importToSlot -> listSlots roundtrip and the exportActiveSlot output-byte
-- sanity check, driven love-free through the same in-memory filesystem stub
-- tests/engine/save_slots.lua uses. A synthetic 32KB SRAM image is built via
-- GenSave.encode (no real save checked in); a fixture-gated case exercises the
-- real .sav when POKEPORT_SAV_FIXTURE points at one.
-- luajit tests/engine/save_file_io_tests.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local GenSave = require("src.save_convert.GenSave")
local SaveConvert = require("src.save_convert.SaveConvert")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local SaveFileIO = require("src.import.SaveFileIO")
local realFS = love.filesystem
-- A love.filesystem stub keyed by full path, extended past save_slots' memfs
-- with the export surface SaveFileIO reaches for (createDirectory /
-- getSaveDirectory). A directory key is implied by any file under it.
local function memfs(files)
return {
files = files,
write = function(path, content) files[path] = content return true end,
read = function(path) return files[path] end,
remove = function(path) files[path] = nil return true end,
getInfo = function(path)
if files[path] then return { type = "file" } end
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
end
return nil
end,
createDirectory = function() return true end,
getSaveDirectory = function() return "/fake/save" end,
}
end
local function fresh()
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
return files
end
-- ---- crosswalk data + synthetic 32KB save (built the way the codec tests do)
GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")())
local data = {
pokemon = loadfile("data/generated/pokemon.lua")(),
moves = loadfile("data/generated/moves.lua")(),
items = loadfile("data/generated/items.lua")(),
maps = loadfile("data/generated/maps.lua")(),
eventFlags = loadfile("src/save_convert/data/event_flags.lua")(),
}
-- independent checksum re-derivation (complement of the additive byte sum) so
-- the export sanity check does not trust the encoder that wrote it
local bit = require("bit")
local OFF = GenSave.OFFSETS
local function rawChecksum(bytes, from, to)
local sum = 0
for i = from, to - 1 do sum = bit.band(sum + bytes:byte(i + 1), 0xFF) end
return bit.band(bit.bnot(sum), 0xFF)
end
local function mainChecksumValid(bytes)
return rawChecksum(bytes, OFF.checksumStart, OFF.checksumEnd)
== bytes:byte(OFF.mainChecksum + 1)
end
local function syntheticSave(name)
local seed = SaveData.newGame({ playerName = name, rivalName = "BLUE" })
seed.money = 4321
seed.inventory = { POTION = 2, POKE_BALL = 7, BOULDERBADGE = 1 }
seed.bagOrder = { "POTION", "POKE_BALL" }
seed.party = { {
species = "SQUIRTLE", level = 6, exp = 200,
dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 },
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 22, attack = 12, defense = 13, speed = 11, special = 12 },
hp = 22, status = nil,
moves = { { id = "TACKLE", pp = 35, ppUps = 0 } },
nickname = "SQ", ot = name, otId = seed.player.id, catchRate = 45,
} }
return GenSave.encode(seed, data, nil)
end
-- ---------------------------------------------- importToSlot -> listSlots
do
fresh()
local bytes = syntheticSave("IMP")
eq(#bytes, GenSave.SAVE_SIZE, "the synthetic save is 32768 bytes")
local ok, slotId = SaveFileIO.importToSlot(bytes, "red")
eq(ok, true, "importToSlot succeeds on a valid 32KB save")
eq(slotId, "slot1", "the first import registers slot1")
local slots = SaveData.listSlots("red")
eq(#slots, 1, "the imported save shows up as exactly one slot")
eq(slots[1].id, "slot1", "the listed slot is slot1")
eq(slots[1].exists, true, "the imported slot reports a save present")
eq(slots[1].name, "IMP", "the imported slot surfaces the decoded player name")
eq(SaveData.activeSlot("red"), "slot1", "the imported slot is made active")
-- the slot loads cleanly (meta re-stamped from gen1_import to the numeric
-- format, so runMigrations does not choke)
local loaded = SaveData.load("red")
check(loaded ~= nil, "the imported slot loads back")
eq(loaded and loaded.player.name, "IMP", "loaded save keeps the player name")
eq(loaded and loaded.money, 4321, "loaded save keeps the money")
eq(loaded and #loaded.party, 1, "loaded save keeps the party")
-- a second import allocates a fresh slot and makes it active
local ok2, slot2 = SaveFileIO.importToSlot(syntheticSave("TWO"), "red")
eq(ok2, true, "a second import succeeds")
eq(slot2, "slot2", "the second import allocates slot2")
eq(#SaveData.listSlots("red"), 2, "both imported slots are listed")
eq(SaveData.activeSlot("red"), "slot2", "the newest import becomes active")
end
-- ---------------------------------------------- exportActiveSlot byte sanity
do
local files = fresh()
SaveFileIO.importToSlot(syntheticSave("EXP"), "red")
local ok, path = SaveFileIO.exportActiveSlot("red")
eq(ok, true, "exportActiveSlot succeeds for an active slot with a save")
eq(path, "/fake/save/exports/gen1recomp-red-slot1.sav",
"the export path is absolute and names the version + slot")
local outBytes = files["exports/gen1recomp-red-slot1.sav"]
check(outBytes ~= nil, "the export file lands in the save-dir exports/ folder")
eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "the export is exactly 32768 bytes")
check(outBytes and mainChecksumValid(outBytes),
"the export carries a valid main-data checksum")
-- the export re-imports to an equivalent save
local re = SaveConvert.importSav(outBytes, "red")
check(re ~= nil, "the export re-imports through SaveConvert")
eq(re and re.player and re.player.name, "EXP", "the export round-trips the player name")
eq(re and re.party[1] and re.party[1].species, "SQUIRTLE",
"the export round-trips the party")
eq(re and re.inventory and re.inventory.BOULDERBADGE, 1,
"the export round-trips a badge")
end
-- ---------------------------------------------- failure UX (never raises)
do
fresh()
-- wrong size via a DroppedFile-shaped source (100 bytes)
local shortFile = {
_bytes = string.rep("\0", 100),
open = function() return true end,
getSize = function(self) return #self._bytes end,
read = function(self) return self._bytes end,
close = function() return true end,
}
local ok, err = SaveFileIO.importToSlot(shortFile, "red")
eq(ok, false, "a wrong-size save is rejected, not imported")
check(type(err) == "string" and err:find("32", 1, true) ~= nil,
"the wrong-size error names the required size")
eq(#SaveData.listSlots("red"), 0, "a rejected import creates no slot")
-- bad checksum: flip a modeled byte in an otherwise valid image
local good = syntheticSave("BAD")
local corrupt = good:sub(1, OFF.money)
.. string.char((good:byte(OFF.money + 1) + 1) % 256)
.. good:sub(OFF.money + 2)
local okc, errc = SaveFileIO.importToSlot(corrupt, "red")
eq(okc, false, "a bad-checksum save is rejected")
check(type(errc) == "string" and errc:find("checksum", 1, true) ~= nil,
"the bad-checksum error mentions the checksum")
eq(#SaveData.listSlots("red"), 0, "a rejected checksum creates no slot")
-- export with nothing to export
local oke, erre = SaveFileIO.exportActiveSlot("red")
eq(oke, false, "exportActiveSlot fails cleanly when there is no save")
check(type(erre) == "string", "the empty-export failure carries a message")
end
-- ---------------------------------------------- fixture-gated real save
do
local fixturePath = os.getenv("POKEPORT_SAV_FIXTURE")
local fixtureBytes
if fixturePath then
local ff = io.open(fixturePath, "rb")
if ff then
fixtureBytes = ff:read("*a")
ff:close()
if #fixtureBytes ~= GenSave.SAVE_SIZE then fixtureBytes = nil end
end
end
if not fixtureBytes then
print("save_file_io fixture case skipped (set POKEPORT_SAV_FIXTURE to a 32KB .sav)")
else
local files = fresh()
local ok, slotId = SaveFileIO.importToSlot(fixtureBytes, "red")
eq(ok, true, "fixture: a real .sav imports to a slot")
check(slotId ~= nil, "fixture: the import returns a slot id")
local slots = SaveData.listSlots("red")
eq(#slots, 1, "fixture: the real save shows as one slot")
check(slots[1].exists and type(slots[1].name) == "string" and #slots[1].name > 0,
"fixture: the imported slot has a non-empty player name")
local loaded = SaveData.load("red")
check(loaded ~= nil and #loaded.party >= 1 and #loaded.party <= 6,
"fixture: the imported slot loads with a 1..6 party")
local eok, path = SaveFileIO.exportActiveSlot("red")
eq(eok, true, "fixture: the imported real save exports")
local rel = path:gsub("^/fake/save/", "")
local outBytes = files[rel]
eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "fixture: the export is 32768 bytes")
check(outBytes and mainChecksumValid(outBytes),
"fixture: the export has a valid main-data checksum")
end
end
love.filesystem = realFS
T.finish("save_file_io")
+211
View File
@@ -0,0 +1,211 @@
-- Save-slot backend (src/core/SaveData.lua): legacy migration, the slot
-- registry in options.lua, listSlots/setActiveSlot/createSlot, and the
-- active-slot resolution behind saveNames/save/load. Self-contained: it
-- installs the love stub only for a swappable in-memory filesystem, the
-- same way tests/mod_save_tests isolates its save round-trips.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or require("tests.love_stub")
local SaveSerializer = require("src.core.SaveSerializer")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local realFS = love.filesystem
-- an isolated love.filesystem: keys are full paths, so "saves/red/slot1.lua"
-- needs no directory support (createDirectory is deliberately absent, which
-- is exactly what the ensureParentDir no-op path handles)
local function memfs(files)
return {
files = files,
write = function(path, content) files[path] = content return true end,
read = function(path) return files[path] end,
remove = function(path) files[path] = nil return true end,
getInfo = function(path)
if files[path] then return { type = "file" } end
return nil
end,
}
end
-- a fresh filesystem + cleared process globals: each scenario is a first boot
local function fresh()
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
return files
end
-- a minimal but fully decodable Red save
local function legacySave(name, dexOwned, badges, playTime)
local owned = {}
for _, id in ipairs(dexOwned or {}) do owned[id] = true end
local inv = {}
for _, id in ipairs(badges or {}) do inv[id] = true end
return {
version = "red",
player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 },
pokedex = { seen = {}, owned = owned },
inventory = inv,
playTime = playTime or 0,
}
end
-- ---------------------------------------------- slotSummary (pure)
do
local name, meta = SaveData.slotSummary(
legacySave("ASH", { "PIKACHU", "PIDGEY", "RATTATA" },
{ "BOULDERBADGE", "CASCADEBADGE" }, 3661))
T.eq(name, "ASH", "slotSummary reads the player name")
T.eq(meta.dexCount, 3, "slotSummary counts owned dex entries")
T.eq(meta.badges, 2, "slotSummary counts vanilla badges from inventory")
T.eq(meta.timeText, "1:01", "slotSummary formats playTime as H:MM")
local n2, m2 = SaveData.slotSummary(nil)
T.eq(n2, nil, "slotSummary of an empty slot has no name")
T.eq(m2, nil, "slotSummary of an empty slot has no meta")
end
-- ---------------------------------------------- legacy migration happy path
do
local files = fresh()
files["save.lua"] = SaveSerializer.encode(
legacySave("RED", { "BULBASAUR", "CHARMANDER" }, { "BOULDERBADGE" }, 7325))
local slots = SaveData.listSlots("red")
T.eq(#slots, 1, "legacy save migrates into exactly one slot")
T.eq(slots[1].id, "slot1", "the migrated slot is slot1")
T.eq(slots[1].exists, true, "the migrated slot reports a save present")
T.eq(slots[1].name, "RED", "the migrated slot surfaces the player name")
T.eq(slots[1].meta.badges, 1, "migrated slot meta carries the badge count")
T.eq(slots[1].meta.dexCount, 2, "migrated slot meta carries the dex count")
T.eq(slots[1].meta.timeText, "2:02", "migrated slot meta carries the time")
T.eq(files["save.lua"], nil, "the flat legacy file is removed after migration")
T.check(files["saves/red/slot1.lua"] ~= nil, "the slot file now holds the save")
local opts = SaveSerializer.decode(files["options.lua"])
T.eq(opts.saveSlots.red.active, "slot1", "options registers slot1 as active")
T.eq(opts.saveSlots.red.list[1], "slot1", "options lists the migrated slot")
-- load() now resolves the active slot and reads the migrated save
local loaded = SaveData.load("red")
T.check(loaded and loaded.player.name == "RED", "load reads the active slot")
end
-- ---------------------------------------------- migration idempotence
do
local files = fresh()
files["save.lua"] = SaveSerializer.encode(legacySave("ONCE", { "MEW" }, {}, 0))
SaveData.listSlots("red") -- first boot: migrates
local slotBytes = files["saves/red/slot1.lua"]
-- a second boot: registry exists, no flat file, so nothing re-migrates
SaveData.resetSlotState()
local slots = SaveData.listSlots("red")
T.eq(#slots, 1, "a re-boot does not duplicate the migrated slot")
T.eq(files["save.lua"], nil, "no flat file reappears on re-boot")
T.eq(files["saves/red/slot1.lua"], slotBytes, "the slot bytes are untouched")
local opts = SaveSerializer.decode(files["options.lua"])
T.eq(#opts.saveSlots.red.list, 1, "the registry still lists exactly one slot")
end
-- ---------------------------------------------- mixed real / empty slots
do
local files = fresh()
files["save.lua"] = SaveSerializer.encode(
legacySave("REAL", { "EEVEE" }, { "BOULDERBADGE" }, 60))
SaveData.listSlots("red") -- slot1 = the migrated real save
local empty = SaveData.createSlot("red")
T.eq(empty, "slot2", "createSlot allocates slot2 alongside the migrated slot1")
local slots = SaveData.listSlots("red")
T.eq(#slots, 2, "both the real and empty slots are listed")
T.eq(slots[1].exists, true, "the migrated slot still reports a save")
T.eq(slots[1].name, "REAL", "the real slot keeps its name")
T.eq(slots[2].exists, false, "the freshly created slot is empty")
T.eq(slots[2].name, nil, "an empty slot has no name")
T.eq(slots[2].meta, nil, "an empty slot has no meta")
end
-- ---------------------------------------------- setActiveSlot persistence
do
local files = fresh()
SaveData.createSlot("red") -- slot1
SaveData.createSlot("red") -- slot2
SaveData.setActiveSlot("red", "slot2")
local opts = SaveSerializer.decode(files["options.lua"])
T.eq(opts.saveSlots.red.active, "slot2", "setActiveSlot persists the active id")
T.eq(opts.saveSlots.red.list[1], "slot1", "the slot list is preserved")
T.eq(opts.saveSlots.red.list[2], "slot2", "the target slot stays in the list")
-- selecting a slot that was never registered adds it
SaveData.setActiveSlot("red", "slot7")
opts = SaveSerializer.decode(files["options.lua"])
T.eq(opts.saveSlots.red.active, "slot7", "an unregistered active slot is added")
T.eq(opts.saveSlots.red.list[3], "slot7", "the added slot lands in the list")
end
-- ---------------------------------------------- createSlot id allocation
do
fresh()
T.eq(SaveData.createSlot("red"), "slot1", "first slot is slot1")
T.eq(SaveData.createSlot("red"), "slot2", "second slot is slot2")
T.eq(SaveData.createSlot("red"), "slot3", "ids increment past the highest")
end
-- ---------------------------------------------- saveNames follows the slot
do
local files = fresh()
SaveData.createSlot("red") -- slot1
SaveData.createSlot("red") -- slot2
SaveData.setActiveSlot("red", "slot2")
local save = SaveData.newGame()
save.player.name = "SLOT2"
T.check(SaveData.save(save), "save writes to the active slot")
T.check(files["saves/red/slot2.lua"] ~= nil, "bytes land in slot2's file")
T.eq(files["saves/red/slot1.lua"], nil, "slot1 is untouched by a slot2 save")
T.eq(files["save.lua"], nil, "no flat file is written once a slot is active")
local loaded = SaveData.load("red")
T.check(loaded and loaded.player.name == "SLOT2", "load reads back from slot2")
end
-- ---------------------------------------------- a version with no slots
do
local files = fresh()
local slots = SaveData.listSlots("red")
T.eq(#slots, 0, "a fresh install with no legacy save lists no slots")
-- with nothing registered, save/load use the flat legacy path, exactly
-- as they did before slots existed
local save = SaveData.newGame()
save.player.name = "FLAT"
T.check(SaveData.save(save), "a slotless version saves to the flat file")
T.check(files["save.lua"] ~= nil, "the flat save.lua is written")
T.eq(files["saves/red/slot1.lua"], nil, "no slot directory is created")
local loaded = SaveData.load("red")
T.check(loaded and loaded.player.name == "FLAT", "load reads the flat file")
T.eq(SaveData.saveFilename("red"), "save.lua",
"saveFilename still resolves the flat name with no slot in use")
end
love.filesystem = realFS
T.finish("save_slots")

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