Compare commits

..

10 Commits

Author SHA1 Message Date
bryanthaboi 8e6b5ae73d Merge pull request #1234 from bryanthaboi/dev
bugs and slugs
2026-08-13 15:44:26 -04:00
bryanthaboi 0b96ac0ab6 fancy cart 2026-08-13 15:36:08 -04:00
bryanthaboi f6a035947f Update OverworldController.lua 2026-08-13 13:31:36 -04:00
bryanthaboi 941181d31c check bonxes 2026-08-13 13:22:18 -04:00
bryanthaboi 9ceb1a8940 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-13 13:07:22 -04:00
bryanthaboi 37051a26b5 CLOSES #1206, CLOSES #1189, CLOSES #1175, CLOSES #1129, CLOSES #1119, CLOSES #1089, CLOSES #1073, CLOSES #1069, CLOSES #1065, CLOSES #1010, CLOSES #990, CLOSES #989, CLOSES #988, CLOSES #978, CLOSES #944, CLOSES #936, CLOSES #1221, CLOSES #1220, CLOSES #1193, CLOSES #1101, CLOSES #1066, CLOSES #1056, CLOSES #1041, CLOSES #984, CLOSES #1225, CLOSES #1214, CLOSES #1011, CLOSES #1035, CLOSES #1219, CLOSES #1048, CLOSES #1047, CLOSES #964, CLOSES #949, CLOSES #1149, CLOSES #1007, CLOSES #914, CLOSES #1115, CLOSES #1146, CLOSES #999, CLOSES #1207, CLOSES #1029, CLOSES #1120, CLOSES #987, CLOSES #983 2026-08-13 13:07:20 -04:00
bryanthaboi b12a0dac91 Merge pull request #1205 from 1Jamie/fix/gen2-battle-rng-static-damage 2026-08-13 11:27:02 -04:00
1jamie a722d24759 fix merge conflicts with dev 2026-08-13 07:39:11 -05:00
github-actions 833388c235 chore(ios): update app-repo.json [skip ci] 2026-08-13 06:22:43 -04:00
1jamie 6ff7dd0c38 refactor(battle): enhance RNG handling and fixed damage mechanics
- Always supply Gen 2 battle random (BattleRandom) and Gen 1 / love-style rng.
- Add EFFECT_STATIC_DAMAGE for Sonic Boom and Dragon Rage (move power; Ghost
  immunity via resettypematchup for static damage only).
- Keep Magnitude rolling via BattleRandom instead of collapsing to Magnitude 4.
- Cover RNG, static damage, and Magnitude in gen2 battle tests.
2026-08-12 17:52:02 -05:00
64 changed files with 1437 additions and 250 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

+13 -13
View File
@@ -246,9 +246,17 @@ return {
-- the table sprites; re-entering the lab applies the same HideObject
-- the gift script now does (OaksLab.asm OakGivesPokedex).
onEnter = function(game, ow)
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
return
local flags = game.save.flags or {}
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then
local rival = ow:npcByIndex(1)
if rival then
rival.cellX = flags.EVENT_CHOSE_CHARMANDER and 7
or flags.EVENT_CHOSE_SQUIRTLE and 8 or 6
rival.cellY = 4
rival.px, rival.py = rival.cellX * 16, rival.cellY * 16
end
end
if not flags.EVENT_GOT_POKEDEX then return end
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
@@ -286,6 +294,7 @@ return {
-- fanfare for the taunt/challenge exchange, same as the Yellow port
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
local rows = {
{ "face_player_dir", "up" },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
@@ -309,28 +318,19 @@ return {
local base = #rows
local party = flags.EVENT_CHOSE_BULBASAUR and 3
or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1
table.insert(rows, { "save_end_battle_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party })
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
table.insert(rows, { "heal_party" })
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
-- OaksLabRivalEndBattleScript: on WIN, print the "picked the wrong
-- POKéMON!" gloat, then BOTH win and loss print the shared exit line
-- _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON fight to
-- toughen it up!\012<PLAYER>! Gramps! Smell you later!") before Blue
-- marches out. A loss skips only the gloat (that taunt was already
-- shown in-battle via Rival1WinText), never the exit line (#231). The
-- jump_if_false convergence point is the exit line: base+6 indexes the
-- SmellYouLater row below, so WIN falls IPicked -> SmellYouLater and
-- LOSS jumps straight to SmellYouLater (both then walk-out + hide).
table.insert(rows, { "jump_if_false", base + 6 })
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
-- walk out past the player. The fanfare was dropped here (#683) -- the
-- parcel scene above already plays Music_MeetRival on both arrival and
-- departure (lines 144-146), and this exit should match (#596).
table.insert(rows, { "stop_music" })
table.insert(rows, { "play_music", "Music_MeetRival" })
table.insert(rows, { "play_music", "Music_MeetRival", { start = "rival" } })
table.insert(rows, { "move_npc_to", 1, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
table.insert(rows, { "play_music", "Music_OaksLab" })
+10 -3
View File
@@ -226,9 +226,15 @@ return {
},
onEnter = function(game, ow)
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
return
local flags = game.save.flags or {}
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then
local rival = ow:npcByIndex(RIVAL)
if rival then
rival.cellX, rival.cellY = 7, 4
rival.px, rival.py = 7 * 16, 4 * 16
end
end
if not flags.EVENT_GOT_POKEDEX then return end
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
@@ -296,13 +302,14 @@ return {
table.insert(rows, { "wait", 20 })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
table.insert(rows, { "stop_music" })
table.insert(rows, { "play_music", "Music_MeetRival" })
table.insert(rows, { "play_music", "Music_MeetRival", { start = "rival" } })
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
table.insert(rows, { "play_music", "Music_OaksLab" })
-- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009)
table.insert(rows, { "face_player_dir", "up" })
table.insert(rows, { "set_field", "pikachuInBall", false })
table.insert(rows, { "spawn_pikachu_follower" })
table.insert(rows, { "play_cry", "PIKACHU" })
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
+19 -19
View File
@@ -142,20 +142,23 @@ M.VIRIDIAN_CITY = {
M.BLUES_HOUSE = {
talk = {
TEXT_BLUESHOUSE_DAISY_SITTING = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_TOWN_MAP" }, -- 2
{ "jump_if_true", 10 }, -- 3
{ "check_flag", "EVENT_GOT_STARTER" }, -- 4
{ "jump_if_false", 12 }, -- 5
{ "show_text", "_BluesHouseDaisyOfferMapText" }, -- 6
{ "face_player" },
{ "check_flag", "EVENT_GOT_TOWN_MAP" },
{ "jump_if_true", "got_map" },
{ "check_flag", "EVENT_GOT_POKEDEX" },
{ "jump_if_false", "too_early" },
{ "show_text", "_BluesHouseDaisyOfferMapText" },
-- _GotMapText: "{PLAYER} got a\n{RAM:wStringBuffer}!" -- the
-- buffer supplies "TOWN MAP" (scripts/BluesHouse.asm GotMapText)
{ "give_item", "TOWN_MAP", 1, "_GotMapText" }, -- 7
{ "set_flag", "EVENT_GOT_TOWN_MAP" }, -- 8
{ "jump", 13 }, -- 9
{ "show_text", "_BluesHouseDaisyUseMapText" }, -- 10
{ "jump", 13 }, -- 11
{ "show_text", "_BluesHouseDaisyRivalAtLabText" }, -- 12
{ "give_item", "TOWN_MAP", 1, "_GotMapText" },
{ "hide_object", "BLUES_HOUSE", "BLUESHOUSE_TOWN_MAP" },
{ "set_flag", "EVENT_GOT_TOWN_MAP" },
{ "jump", "end" },
{ "label", "got_map" },
{ "show_text", "_BluesHouseDaisyUseMapText" },
{ "jump", "end" },
{ "label", "too_early" },
{ "show_text", "_BluesHouseDaisyRivalAtLabText" },
},
},
}
@@ -1058,6 +1061,7 @@ local championsRoomRivalScript = {
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 17
{ "face_object", 2, "down" }, -- 18
{ "load_player_starter_name" },
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 20
@@ -1067,13 +1071,8 @@ local championsRoomRivalScript = {
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement.
-- The player walks out after Oak instead of the screen just fading on the
-- spot (#704). Route one tile right before walking north so the player
-- reaches the north-wall HALL_OF_FAME warp without sharing the rival's
-- (4,2) cell. The original simulated movement bypasses entity collision,
-- but this scene should not visibly walk through the defeated rival.
{ "move_player", "right", 1 }, -- 26
-- scripts/ChampionsRoom.asm WalkToHallOfFame_RLEMovement
{ "move_player", "left", 1 },
{ "move_player", "up", 3 }, -- 27
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
@@ -1238,6 +1237,7 @@ local function pokemonTower2FRivalScript(playerX)
{ "jump_if_false", "end" }, -- 6 loss: stay
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "walk_npc", 1, exitDirs }, -- 9
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
{ "jump", "end" }, -- 11
+3
View File
@@ -413,6 +413,9 @@ M.ROUTE_8_GATE = saffronGate("TEXT_ROUTE8GATE_GUARD", { { 2, 3 }, { 2, 4 } }, tr
-- -------------------------------------------------------------------
M.POKEMON_FAN_CLUB = {
onEnter = function(game, ow)
require("src.world.PikachuFollower").onFanClubEntered(game, ow)
end,
talk = {
TEXT_POKEMONFANCLUB_CHAIRMAN = {
{ "face_player" }, -- 1
+18 -13
View File
@@ -142,7 +142,7 @@ M.MT_MOON_POKECENTER = {
local Commands = require("src.script.Commands")
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
"MAGIKARP", 5)
push(game, ("%s got a\nMAGIKARP!"):format(game.save.player.name), done)
push(game, t._GotMonText or "{PLAYER} got\n{RAM:wNameBuffer}!", done)
end)
end,
},
@@ -512,24 +512,29 @@ M.ROUTE_24 = {
local flags = game.save.flags
local function battleOrDone()
if ow:trainerDefeated(npc) then
push(game, "I hate this!\nMy dreams of\nTEAM ROCKET...", done)
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
done)
else
ow:engageTrainer(npc, done)
end
end
if not flags.EVENT_GOT_NUGGET then
push(game, "Congratulations!\nYou beat our 5\ncontest trainers!\f"
.. "You just earned a\nfabulous prize!", function()
local t = text(game)
push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f"
.. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function()
require("src.core.Sound").play(game.data, "Get_Item1")
if not require("src.inventory.Bag").add(game.save, "NUGGET", 1,
game.data) then
push(game, t._Route24CooltrainerM1NoRoomText, done)
return
end
flags.EVENT_GOT_NUGGET = true
require("src.inventory.Bag").add(game.save, "NUGGET", 1)
push(game, ("%s received\na NUGGET!"):format(game.save.player.name),
function()
ask(game, "By the way, would\nyou like to join\nTEAM ROCKET?",
function()
push(game, "Arrgh! You are\nnot convinced?\fThen I'll show\n"
.. "you my power!", battleOrDone)
end)
end)
game.stringBuffer = game.data.items.NUGGET.name
push(game, t._Route24CooltrainerM1ReceivedNuggetText, function()
require("src.core.Sound").play(game.data, "Get_Item1")
push(game, t._Route24CooltrainerM1JoinTeamRocketText,
battleOrDone)
end)
end)
return
end
+22 -10
View File
@@ -508,12 +508,12 @@ M.PEWTER_CITY = {
-- Rival ambush: show the hidden rival, walk him up to the player, run
-- the battle rows, march him back and hide him. On a loss the walk is
-- skipped (the blackout rebuilds the map mid-script).
local function runAmbush(game, ow, rows, playerFacing)
local function runAmbush(game, ow, rows, playerFacing, musicOpts)
if ow.runner:isRunning() then return false end
ow.player.facing = playerFacing
-- the rival encounter sting (MUSIC_MEET_RIVAL); the battle music
-- takes over and the map theme returns after the victory jingle
require("src.core.Music").play(game.data, "Music_MeetRival")
require("src.core.Music").play(game.data, "Music_MeetRival", nil, musicOpts)
ow.runner:run(rows)
return true
end
@@ -567,10 +567,12 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
{ "face_object", objIndex, rivalFacing }, -- 3
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
{ "rival_battle", oppClass, baseParty }, -- 5
{ "jump_if_false", 11 }, -- 6
{ "jump_if_false", 12 }, -- 6
{ "set_flag", beatFlag }, -- 7
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
{ "play_music", "Music_MeetRival", { start = "rival",
tempo = n == 2 and 100 or nil } },
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10
{ "hide_object", "ROUTE_22", objName }, -- 11
}
@@ -594,7 +596,8 @@ M.ROUTE_22 = {
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
return runAmbush(game, ow,
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing)
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing,
{ tempo = 100 })
end
return false
end,
@@ -618,10 +621,11 @@ local function ceruleanRivalScene(px, py)
{ "face_object", 1, "down" }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", 11 }, -- 6
{ "jump_if_false", 12 }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
}
@@ -730,7 +734,7 @@ local JIGGLYPUFF_SILENCE, JIGGLYPUFF_STEP, JIGGLYPUFF_TAIL = 32, 24, 48
-- Built as a TextBox `auto` table: auto.sound fires the frame the last
-- page has typed out (PrintText returning), and auto.tick then runs once
-- per frame while the gate it returns still reads as playing.
local function jigglypuffDance(game, npc)
local function jigglypuffDance(game, npc, ow)
local Music = require("src.core.Music")
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
-- matching the sprite's current facing (showMapText has just turned it
@@ -776,7 +780,13 @@ local function jigglypuffDance(game, npc)
if npc then npc.facing = JIGGLYPUFF_SPIN[step] end
return
end
if frames >= JIGGLYPUFF_TAIL then phase = "done" end
if frames >= JIGGLYPUFF_TAIL then
phase = "done"
if require("src.core.GameVersion").isYellow()
and require("src.world.PikachuFollower").starterInParty(game.save) then
ow.pikachuPewterSleepScene = true
end
end
end,
}
end
@@ -789,7 +799,7 @@ M.PEWTER_POKECENTER = {
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
done, { auto = jigglypuffDance(game, npc) }))
done, { auto = jigglypuffDance(game, npc, ow) }))
end,
},
}
@@ -864,10 +874,11 @@ M.SILPH_CO_7F = {
{ "face_object", 9, "up" }, -- 4
{ "show_text", "_SilphCo7FRivalWaitedHereText" }, -- 5
{ "rival_battle", "OPP_RIVAL2", 7 }, -- 6
{ "jump_if_false", 12 }, -- 7
{ "jump_if_false", 13 }, -- 7
{ "set_flag", "EVENT_BEAT_SILPH_CO_RIVAL" }, -- 8
{ "show_text", "_SilphCo7FRivalDefeatedText" }, -- 9
{ "show_text", "_SilphCo7FRivalGoodLuckToYouText" }, -- 10
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "move_npc_to", 9, 5, y + 1 }, -- 11
{ "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 12
}, "down")
@@ -899,10 +910,11 @@ M.SS_ANNE_2F = {
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
{ "show_text", "_SSAnne2FRivalText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
{ "jump_if_false", 11 }, -- 6
{ "jump_if_false", 12 }, -- 6
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
}, onLeft and "up" or "left")
+8 -6
View File
@@ -130,18 +130,20 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
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).
`"MOD"`, uppercased. `enabledByVersion` contains an answer for each game;
missing entries default to enabled (except experimental mods), matching the
loader. On the first run with per-game controls, legacy shared choices are
copied to every installed game's answer.
- `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
- `LauncherMods.setEnabled(id, bool, version)` persists the selected game's
answer, so the running game and the in-game `ManagerState` see the change on
next boot. The MODS panel renders a coloured checkbox for Red, Blue, Yellow,
and Gold on every row 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
+8 -8
View File
@@ -96,14 +96,14 @@ The launcher asks the same question of a mod's dependencies: one whose hard
dependency does not run on the selected game reads `Needs <id> (not for Gold)`,
matching the loader's contagious skip.
A separate overlay, `options.modsByVersion[version][id]`, is where a per-game
enable flag will live. It is a preview: `SaveData.PER_VERSION_MODS` is `false`,
so `SaveData.modScope` answers nil for every caller and the launcher panel, the
in-game manager and the loader all read *and* write the one shared
`options.mods` flag. Nothing consults the overlay for enablement until that
flips, which is deliberate: the overlay is plantable from an imported
`.g1rmodlist`, and a reader scoped differently from the writers would show a
mod set no boot would honour.
A separate overlay, `options.modsByVersion[version][id]`, holds each game's
enable flag. The launcher shows a coloured Red / Blue / Yellow / Gold checkbox
for every installed mod, and the loader and in-game manager read the same
game-specific answer on the next boot. On the first launch after this feature,
the existing shared state is copied to every game, so a mod that was enabled
remains enabled everywhere; after that, changing one checkbox affects only
that game. New mods still default to enabled on every game (experimental mods
retain their explicit opt-in default).
That is deliberate. Gold reimplements the battle engine, the overworld, the
script VM and the save format, so a Gen 1 mod dropped into a Gold boot would
+7 -12
View File
@@ -260,18 +260,13 @@ selected game reads `Needs <id> (not for Gold)` rather than `Ready`.
### One limit worth knowing
**Per-game enable flags are still a preview.** The overlay
`options.modsByVersion[version][id]` exists and every surface goes through
`SaveData.modEnabled` / `SaveData.setModEnabled`, but
`SaveData.PER_VERSION_MODS` is `false`
(`src/core/SaveData.lua:489`). While it is false, `SaveData.modScope` answers
nil for every caller, so the launcher panel, the in-game manager *and* the
loader all read and write the one shared `options.mods` flag and the overlay is
not consulted for enablement anywhere. That matters because the overlay is
plantable from an imported `.g1rmodlist`: keeping every reader on the same
scope as every writer is what stops a stored per-game flag from showing a mod
set no boot would honour. Nothing about this affects a mod author; it affects
what a player can currently express.
**Enablement is per game.** The overlay
`options.modsByVersion[version][id]` is read and written through
`SaveData.modEnabled` / `SaveData.setModEnabled` by the launcher, in-game
manager, and loader. Existing shared settings are copied to every game the
first time this version sees the installed mods; from then on, each coloured
game checkbox changes only that game's next boot. Nothing about this affects a
mod author; it affects what a player can express.
Targeting is a different question from enablement and *is* enforced per game,
as above. The two do not share a switch.
+7
View File
@@ -12,6 +12,13 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.1.81",
"date": "2026-08-13",
"size": 9886854,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.81/gen1recomp++-0.1.81-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1091 Encounter Rate is tied to Game Speed\n- #1097 Phase through walls exiting a cave\n- #1100 Allow for control edit and vibration toggle on gold\n- #1104 wrong warp\n- #1135 update for iphone - haptic vibrations\n- #1155 Magnitude always rolls a 4\n- #1157 Sonic Boom doesn't deal set damage\n- #1158 Unable to view opponent's next Pokemon\n- #1159 Dig animation is bugged\n- #1160 64x64 battle sprite followup for mods\n- #1167 [Gold] items in ilex forest missing\n- #1208 Gold Save game not showing properly in LAUNCHER after having multiple saves\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian\n- @MaxTomahawk\n- @Yukitty\n- Codex Agent"
},
{
"version": "0.1.80",
"date": "2026-08-12",
+34 -13
View File
@@ -613,6 +613,7 @@ local function newBattle(game)
self.phase = "intro"
self.menuIndex = 1
self.moveIndex = 1
self.playerMoveListIndex = 1
self.frame = 0
return self
end
@@ -929,6 +930,11 @@ function BattleState:sayNext(text)
table.insert(self.queue, self.nextInsert, { text = text })
end
function BattleState:sayNextWaitSfx(text, sfx)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { text = text, waitForLearningSfx = sfx })
end
-- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
function BattleState:sayNextAuto(text, delay)
self.nextInsert = (self.nextInsert or 0) + 1
@@ -1255,13 +1261,13 @@ function BattleState:updateQueue()
-- no subanimation player: keep the single-sound fallback (with
-- the move's pitch/tempo modifiers; GROWL/ROAR play the
-- attacker's cry -- GetMoveSound/IsCryMove)
if item.anim == "GROWL" or item.anim == "ROAR" then
if self:animationsOn() and (item.anim == "GROWL" or item.anim == "ROAR") then
local attacker = item.attackerIsPlayer and self.player or self.enemy
if attacker then
require("src.core.Sound").playMoveCry(self.data, attacker.mon.species,
anim and anim.tempo)
end
elseif anim and anim.sound then
elseif self:animationsOn() and anim and anim.sound then
local Sound = require("src.core.Sound")
if Sound.playMove then
Sound.playMove(self.data, anim)
@@ -1361,6 +1367,11 @@ function BattleState:updateQueue()
self.current = nil
end
elseif not (item and item.choice) then
if item and item.waitForLearningSfx and not item.soundStarted then
item.soundStarted = true
self.waitingSound = item.waitForLearningSfx()
return true
end
-- The page is typed out and waiting on the player: PromptText
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
-- blinks it until A/B, so the arrow belongs on a finished page and not
@@ -1746,6 +1757,7 @@ end
local function sendOutMonCursors(self)
self.menuIndex = 1
self.moveIndex = 1
self.playerMoveListIndex = 1
end
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
@@ -2044,8 +2056,10 @@ function BattleState:update(dt)
if self.moveSwapIndex then
self:swapMoves(self.moveSwapIndex, self.moveIndex)
self.moveSwapIndex = nil
self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves)
else
self.moveSwapIndex = self.moveIndex
self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves)
end
elseif input:wasPressed("b") then
require("src.core.Sound").play(self.data, "Press_AB")
@@ -2068,6 +2082,7 @@ function BattleState:update(dt)
self.phase = "messages"
self.afterQueue = "menu"
else
self.playerMoveListIndex = self.moveIndex
self:resolveTurn(mv)
end
end
@@ -2423,11 +2438,9 @@ function BattleState:resolveTurn(playerAction)
end
local order
if pFirst then
order = { { self.player, self.enemy, playerAction },
{ self.enemy, self.player, enemyAction } }
order = { { true, playerAction }, { false, enemyAction } }
else
order = { { self.enemy, self.player, enemyAction },
{ self.player, self.enemy, playerAction } }
order = { { false, enemyAction }, { true, playerAction } }
end
self.phase = "messages"
@@ -2435,7 +2448,9 @@ function BattleState:resolveTurn(playerAction)
for _, entry in ipairs(order) do
self:act(function()
self:executeAction(entry[1], entry[2], entry[3])
local user = entry[1] and self.player or self.enemy
local target = entry[1] and self.enemy or self.player
self:executeAction(user, target, entry[2])
end)
end
self:act(function() self:endOfTurn() end)
@@ -2945,7 +2960,7 @@ function BattleState:applyHitFx(hit)
Sound.play(self.data, hit.sfx)
end
end
if not t or not self:animationsOn() then return end
if not t then return end
if t == 1 then
-- PredefShakeScreenVertically b=8: the window drops by b for 3 frames
-- then home for 3, b counting down
@@ -3839,9 +3854,12 @@ function BattleState:awardExp()
participants, alive = 1, { self.player.mon }
end
local function applyShare(mon, split, announce)
local playerId = self.game.save.player and self.game.save.player.id
local traded = mon.otId ~= nil and playerId ~= nil
and mon.otId ~= playerId or mon.traded == true and mon.otId == nil
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
self.enemy.mon.level, self.kind == "trainer",
split, mon.traded)
split, traded)
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
-- without this gate it re-triggers after every later fight (#213).
@@ -3865,7 +3883,7 @@ function BattleState:awardExp()
local text = Strings.source("%s gained\n%d EXP. Points!")
if announce == "expAll" then
text = Strings.source("%s gained\nwith EXP.ALL,\v%d EXP. Points!")
elseif mon.traded then
elseif traded then
text = Strings.source("%s gained\na boosted\v%d EXP. Points!")
end
self:sayNext(Strings(text, name, gained))
@@ -4129,15 +4147,17 @@ function BattleState:learnMove(mon, moveId)
if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
self:sayNext(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
mdef.name))
self:sayNextWaitSfx(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
mdef.name), function()
return require("src.core.Sound").play(self.data, "Level_Up")
end)
return
end
-- the "trying to learn" preamble lives inside MoveLearnMenu:enter;
-- ordered insert so multi-level gains keep each level's checks
-- between its own stat box and the next "grew to level" text
self:uiNext(function()
return self:buildScreen("MoveLearnMenu", mon, moveId)
return self:buildScreen("MoveLearnMenu", mon, moveId, nil, "Level_Up")
end)
end
@@ -5791,6 +5811,7 @@ function BattleState:drawTextArea()
Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8)
end
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
end
end
+1 -2
View File
@@ -310,8 +310,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if record and record.afterDamage then
record.afterDamage(ctx, totalDealt)
elseif moveInst.struggle then
-- struggle recoils even when its effect id resolves to no record
local recoil = math.max(1, math.floor(dmg / 2))
local recoil = math.max(1, math.floor(totalDealt / 2))
battle:sayNext(romText(battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user)))
battle:applyDamage(user, recoil)
end
+7 -8
View File
@@ -348,8 +348,9 @@ MoveEffects.primary = {
target.disabledTurns = battle.rng(1, 8)
local id = target.curMoves[slot].id
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!", displayName(target),
battle.data.moves[id].name) }
return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!",
{ TARGET = displayName(target),
["RAM:wNameBuffer"] = battle.data.moves[id].name }) }
end,
SPLASH_EFFECT = function(battle)
@@ -441,11 +442,10 @@ local function hitsFrom(dist, ctx)
return dist[r + 1]
end
-- drain_hp.asm halves the RAW wDamage IN PLACE (minimum 1) and heals
-- that amount, so Counter would see the halved value
-- engine/battle/core.asm ApplyDamageToEnemyPokemon
local function drainHalf(label, text)
return function(ctx)
local heal = math.max(1, math.floor(ctx.rawDamage / 2))
local heal = math.max(1, math.floor(ctx.totalDealt / 2))
ctx.battle.lastDamage = heal
local mon = ctx.user.mon
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
@@ -529,9 +529,8 @@ MoveEffects.full = {
RECOIL_EFFECT = {
afterDamage = function(ctx)
-- recoil.asm reads the RAW computed wDamage (not the HP actually
-- removed): overkill and substitute hits recoil at full strength
local recoil = math.max(1, math.floor(ctx.rawDamage
-- engine/battle/move_effects/recoil.asm
local recoil = math.max(1, math.floor(ctx.totalDealt
/ (ctx.moveInst.struggle and 2 or 4)))
ctx.say(romText(ctx.battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(ctx.user)))
ctx.battle:applyDamage(ctx.user, recoil)
+3 -1
View File
@@ -243,7 +243,9 @@ function Status.beforeMove(battler, rng, battle, selectedMoveId)
local shown = moves and moves[selectedMoveId] and moves[selectedMoveId].name
or tostring(selectedMoveId)
table.insert(msgs, romText(battle and battle.data, "_MoveIsDisabledText",
"%s's\n%s is\ndisabled!", name(battler), shown))
"%s's\n%s is\ndisabled!", {
USER = name(battler), ["RAM:wNameBuffer"] = shown,
}))
return false, msgs
end
end
+23 -8
View File
@@ -37,8 +37,10 @@ local Prize = require("src.battle.gen2.Prize")
-- * Gen 1's `user` / `target` / `battler` are battler wrappers around a mon
-- ({ mon = , name = , isPlayer = }); Gen 2's engine works on the party mon
-- table directly, so that is what these payloads carry.
-- * Gen 1's `rng` is love.math.random (1..n); Gen 2's injected `random` is
-- the cart's BattleRandom convention (0..n-1). Both keys are present.
-- * Gen 1's `rng` is love.math.random (rng(n) → 1..n, rng(lo,hi) → lo..hi).
-- Gen 2's cart BattleRandom is random(n) → 0..n-1. Both live on the
-- battle: `random` / `roller()` are BattleRandom for damage, accuracy,
-- Magnitude, etc.; `rng` is the Gen 1 / love-style view of the same stream.
local Runtime = require("src.mods.Runtime")
-- The two battle lines that carry the cart's own `line` break: a marker-bearing
-- literal has to stay reachable from a translation mod (#186, #245), which is
@@ -88,6 +90,19 @@ local function rand(random, n)
return math.random(n) - 1
end
-- Gen 1 / love.math view of BattleRandom: rng(n) → 1..n, rng(lo,hi) → lo..hi.
local function loveStyleRng(random)
return function(lo, hi)
if hi == nil then
local n = lo or 1
if n < 1 then n = 1 end
return (rand(random, n) or 0) + 1
end
if hi < lo then lo, hi = hi, lo end
return lo + (rand(random, hi - lo + 1) or 0)
end
end
-- data/trainers/leaders.asm. The two lists are ONE array in the ROM: only
-- KantoGymLeaders carries the -1 terminator, and GymLeaders falls through into
-- it, so IsGymLeader matches all twenty-two classes while IsKantoGymLeader
@@ -212,12 +227,15 @@ Battle.KANTO_BADGE_ORDER = {
-- this wild battle is BATTLETYPE_ROAMING; the caller built `wild`
-- through Roamers.beginBattle and reads Battle.roaming back to
-- bank the beast's HP afterwards
-- random(n) 0..n-1, injected so a test is deterministic
-- random(n) 0..n-1, injected so a test is deterministic (BattleRandom)
-- rng(lo,hi) / rng(n) Gen 1 / love.math contract; defaults over `random`
function Battle.new(opts)
opts = opts or {}
local self = setmetatable({}, Battle)
self.data = opts.data or {}
self.random = opts.random or function(n) return rand(nil, n) end
-- Same stream as `random`, Gen 1 / love.math calling convention.
self.rng = opts.rng or loveStyleRng(self.random)
self.party = opts.party or {}
self.trainer = opts.trainer
self.save = opts.save
@@ -403,11 +421,8 @@ function Battle:endBattle(outcome)
Runtime.emit("battle.ended", { battle = self, result = outcome })
end
-- A never-nil 0..n-1 roller for the hook contexts. `random` is optional on the
-- constructor (a headless test injects one, the game leaves it to love.math),
-- and a mod reaching for ctx.rng must not have to know that. Note the
-- convention: this is the cart's BattleRandom byte (0..n-1), NOT Gen 1's
-- love.math.random (1..n).
-- Never-nil BattleRandom (0..n-1) for call sites that want the cart byte.
-- `battle.rng` is the Gen 1 / love.math view of the same stream.
function Battle:roller()
if not self.rollerFn then
self.rollerFn = function(n) return rand(self.random, n) end
+11 -1
View File
@@ -265,8 +265,18 @@ Effects.MAGNITUDE_POWER = {
-- is what damagecalc reads as the move's power -- data/moves/moves.asm stores
-- MAGNITUDE at power 1 precisely because this overwrites it. Returns the
-- power and the magnitude number the text prints.
--
-- `random` is BattleRandom (0..n-1). If none is supplied, roll via love.math
-- / math.random — never hard-code 0 (that always yields Magnitude 4).
function Effects.magnitudePower(random)
local roll = random and random(256) or 0
local roll
if type(random) == "function" then
roll = random(256) or 0
elseif love and love.math and love.math.random then
roll = love.math.random(256) - 1
else
roll = math.random(256) - 1
end
for _, row in ipairs(Effects.MAGNITUDE_POWER) do
if row[1] >= roll then return row[2], row[3] end
end
+11 -2
View File
@@ -1230,8 +1230,17 @@ function Engine.new(data, header, options)
engine.tempo = header.tempo
engine.tempoLocked = true
end
for _, spec in ipairs(chip and chip.channels
or headerChannels(banks, header)) do
local channels = chip and chip.channels or headerChannels(banks, header)
if header.startChannels then
local byNumber = {}
for _, start in ipairs(header.startChannels) do
byNumber[start.number] = start.address
end
for _, spec in ipairs(channels) do
spec.address = byNumber[spec.number] or spec.address
end
end
for _, spec in ipairs(channels) do
local frameTicks = options.frameTicks
local hardware = (spec.number - 1) % 4 + 1
if hardware == 4 then
+18 -2
View File
@@ -689,10 +689,26 @@ function Game2:usePartyItem(itemId)
self:say(result.text)
return
end
self:consumeItem(itemId)
if action == "candy" then
if action == "stone" then
local party = (self.save and self.save.party) or {}
local index
for i, member in ipairs(party) do
if member == mon then index = i break end
end
Screens.push(self, "Gen2EvolutionAnim", {
mon = mon, entry = result.evolution, index = index,
party = party, save = self.save,
force = true,
onDone = function(evolution)
if evolution and evolution.evolved then self:consumeItem(itemId) end
self.stack:pop()
end,
})
elseif action == "candy" then
self:consumeItem(itemId)
self:say(result.text, function() self:afterRareCandy(mon, result) end)
else
self:consumeItem(itemId)
self:say(result.text)
end
end
+17 -2
View File
@@ -64,6 +64,7 @@ state = {
fanfareResume = false, -- start/resume state.source when the fanfare ends
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
tempo = nil, -- alternate-tempo override in force for `current`
start = nil,
failed = {}, -- labels whose def could not be started; logged once
}
@@ -215,9 +216,11 @@ function Music.play(data, song, loop, ctx)
song = selectSong(song, ctx)
local tempo = ctx and ctx.tempo or nil
local start = ctx and ctx.start or nil
-- a hook may silence the cue outright, or swap in a label the dedupe
-- below has to compare against
if not song or (song == state.current and tempo == state.tempo) then return end
if not song or (song == state.current and tempo == state.tempo
and start == state.start) then return end
local def = songDef(data, song)
if not def or state.failed[song] then return end
@@ -240,6 +243,17 @@ function Music.play(data, song, loop, ctx)
slowed.tempo = tempo
def = slowed
end
if start == "rival" and song == "Music_MeetRival"
and def.bank == 2 and def.address == 17050 then
local started = {}
for key, value in pairs(def) do started[key] = value end
started.startChannels = {
{ number = 1, address = 0x71a2 },
{ number = 2, address = 0x721d },
{ number = 3, address = 0x72b5 },
}
def = started
end
local wantLoop = loop ~= false
local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
if not src then
@@ -276,6 +290,7 @@ function Music.play(data, song, loop, ctx)
state.source, state.loopSource, state.chip = src, loopSrc, isChip
state.current = song
state.tempo = tempo
state.start = start
if Runtime.wants("music.started") then
Runtime.emit("music.started", {
song = song, previous = previous, chip = isChip,
@@ -290,7 +305,7 @@ function Music.stop()
stopSource(state.loopSource)
require("src.core.ChipAudio").stopMusic()
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
state.tempo = nil
state.tempo, state.start = nil, nil
state.chip = false
state.pendingRestore = nil
if previous and Runtime.wants("music.stopped") then
+8
View File
@@ -33,6 +33,14 @@ return function(data, label, fallback, ...)
local args = { ... }
if #args == 0 then return text end
if #args == 1 and type(args[1]) == "table" then
local values = args[1]
return (text:gsub("%b{}", function(token)
local value = values[token] or values[token:sub(2, -2)]
return value == nil and token or tostring(value)
end))
end
local slots, named = 0, 0
for token in text:gmatch("%b{}") do
slots = slots + 1
+63 -8
View File
@@ -304,6 +304,10 @@ function SaveData.defaultOptions()
-- options.lua written before this key keeps its exact meaning. Read and
-- written through SaveData.modEnabled / SaveData.setModEnabled.
modsByVersion = {},
-- Set after the first per-game enablement migration. Older options files
-- have only `mods`, so the migration copies each installed mod's current
-- answer to every game before game-specific toggles begin changing it.
modsByVersionMigrated = false,
-- Named setups the player can switch between (#593; src/mods/ModProfile.lua
-- owns the shape, src/mods/ManagerState.lua the UI): each row is
-- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }.
@@ -563,19 +567,68 @@ end
-- only holds the games the player actually chose for, so a mod set can differ
-- between Red and Gold without either one owning the other's flags.
-- Whether a per-game answer is honoured at boot. The loader reads the enable
-- flags once, before any entry chunk (src/mods/Loader.lua _loadState), so this
-- flips on with that read and not before: until then every writer keeps to the
-- shared flag and no surface promises what the boot does not do.
SaveData.PER_VERSION_MODS = false
-- Per-game answers are live. Every reader and writer goes through modScope,
-- so a choice made in the launcher is the choice the next boot loads.
SaveData.PER_VERSION_MODS = true
-- The version a write should be scoped to: the game asked for once per-game
-- flags are live, nil (the shared flag) while they are only a preview.
-- The version a write is scoped to: per-game controls name one game; a nil
-- caller still addresses the legacy shared fallback.
function SaveData.modScope(version)
if SaveData.PER_VERSION_MODS then return version end
return nil
end
-- Promote an installation that predates per-game flags. `mods` may contain
-- manifest rows ({ id, experimental }) or bare ids. A mod with no old entry
-- had the loader default: on, except for experimental mods. Copy that answer
-- to every game once, preserving any per-game overlay somebody imported before
-- this feature shipped. New installs need no rows here: an absent answer
-- still defaults to enabled for every game.
function SaveData.migrateModEnablement(options, mods)
if type(options) ~= "table" or options.modsByVersionMigrated then return false end
options.mods = type(options.mods) == "table" and options.mods or {}
options.modsByVersion = type(options.modsByVersion) == "table"
and options.modsByVersion or {}
local known = {}
for id in pairs(options.mods) do
if type(id) == "string" and id ~= "" then known[id] = { id = id } end
end
for version, bucket in pairs(options.modsByVersion) do
if GameVersion.VERSIONS[version] and type(bucket) == "table" then
for id in pairs(bucket) do
if type(id) == "string" and id ~= "" then known[id] = known[id] or { id = id } end
end
end
end
for _, mod in ipairs(mods or {}) do
local id = type(mod) == "table" and mod.id or mod
if type(id) == "string" and id ~= "" then
known[id] = type(mod) == "table" and mod or (known[id] or { id = id })
end
end
-- Do not create options.lua just to record an empty migration on a fresh
-- no-mod boot. Keep it pending until there is a real installed or saved
-- mod answer to preserve.
if next(known) == nil then return false end
for id, mod in pairs(known) do
local shared = options.mods[id]
if type(shared) ~= "boolean" then shared = not (mod.experimental == true) end
for _, version in ipairs(GameVersion.ORDER) do
local bucket = options.modsByVersion[version]
if type(bucket) ~= "table" then
bucket = {}
options.modsByVersion[version] = bucket
end
if type(bucket[id]) ~= "boolean" then bucket[id] = shared end
end
end
options.modsByVersionMigrated = true
return true
end
-- true/false as chosen for `version`, else the shared flag, else nil -- the
-- caller owns the default (the loader enables, the launcher keeps
-- experimental mods off until asked).
@@ -1834,6 +1887,8 @@ function SaveData.newGame(boot)
boot = type(boot) == "table" and boot or {}
local map = boot.startMap or "REDS_HOUSE_2F"
local x, y = boot.startX or 3, boot.startY or 6
local facing = boot.startFacing or "down"
if map == "REDS_HOUSE_2F" and boot.version ~= "yellow" then facing = "up" end
local heal = SaveData.defaultHeal(boot)
local save = {
meta = { format = Version.saveFormat, mods = {} },
@@ -1844,7 +1899,7 @@ function SaveData.newGame(boot)
map = map,
x = x,
y = y,
facing = boot.startFacing or "down",
facing = facing,
name = boot.playerName or "RED",
rival = boot.rivalName or "BLUE",
-- 16-bit trainer ID rolled at new game (wPlayerID, filled from
+14
View File
@@ -450,6 +450,20 @@ record("RARE_CANDY", "candy", function(ctx)
return rareCandy(ctx.mon, ctx.data)
end)
for _, itemId in ipairs({ "SUN_STONE", "MOON_STONE", "FIRE_STONE",
"THUNDERSTONE", "WATER_STONE", "LEAF_STONE" }) do
record(itemId, "stone", function(ctx)
if ctx.mon.item == "EVERSTONE" then
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
end
local Evolution = require("src.core.gen2.Evolution")
local entry = Evolution.checkMon(ctx.data, ctx.mon,
{ force = true, item = ctx.item })
if not entry then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end
return { used = true, evolution = entry }
end)
end
for itemId in pairs(ItemEffects.REVIVE) do
record(itemId, "revive", function(ctx) return revive(ctx.item, ctx.mon) end)
end
+308 -40
View File
@@ -100,6 +100,7 @@ end
-- other half of that guarantee.
function LauncherView.update(imp, dt)
if not imp._flex then return end
if imp._launchFade then return end
local down = false
if love.mouse and love.mouse.isDown then
@@ -312,6 +313,236 @@ local function cartColor(version)
return CART_COLOR[version] or PAL.green
end
local CART_DRAG_SLOP = 8
local TAU = math.pi * 2
local function cartridgeState(imp, version)
imp._cartridge = imp._cartridge or {}
local state = imp._cartridge[version]
if not state then
state = { spin = 0, lastTime = Kit.time }
imp._cartridge[version] = state
end
return state
end
local function cartridgeLabel(imp, version)
imp._cartridgeLabels = imp._cartridgeLabels or {}
local label = imp._cartridgeLabels[version]
if label ~= nil then return label or nil end
local ok, image = pcall(love.graphics.newImage,
"assets/labels/" .. tostring(version) .. ".png")
if not ok then
imp._cartridgeLabels[version] = false
return nil
end
local iw, ih = image:getDimensions()
label = { image = image, width = iw, height = ih }
imp._cartridgeLabels[version] = label
return label
end
local function cartProject(cx, cy, yaw, pitch, x, y, z)
local cyaw, syaw = math.cos(yaw), math.sin(yaw)
local cpitch, spitch = math.cos(pitch), math.sin(pitch)
local rx = x * cyaw + z * syaw
local rz = -x * syaw + z * cyaw
local ry = y * cpitch - rz * spitch
rz = y * spitch + rz * cpitch
local perspective = 620 / (620 - rz)
return cx + rx * perspective, cy + ry * perspective
end
local function cartPolygon(points, color, alpha)
local flat = {}
for i = 1, #points do
flat[#flat + 1], flat[#flat + 2] = points[i][1], points[i][2]
end
Theme.col(color, alpha or 1)
love.graphics.polygon("fill", flat)
end
local function cartQuad(project, x, y, w, h, z)
return {
{ project(x, y, z) }, { project(x + w, y, z) },
{ project(x + w, y + h, z) }, { project(x, y + h, z) },
}
end
local function cartPill(project, x, y, w, h, z, color, alpha)
local points, radius = {}, h / 2
for i = 0, 10 do
local a = math.pi + math.pi * i / 10
points[#points + 1] = { project(x + radius + math.cos(a) * radius,
y + radius + math.sin(a) * radius, z) }
end
for i = 0, 10 do
local a = math.pi * i / 10
points[#points + 1] = { project(x + w - radius + math.cos(a) * radius,
y + radius + math.sin(a) * radius, z) }
end
cartPolygon(points, color, alpha)
end
local function cartLabelMesh(imp, version, label, points)
if not love.graphics.newMesh then return nil end
imp._cartridgeLabelMeshes = imp._cartridgeLabelMeshes or {}
local mesh = imp._cartridgeLabelMeshes[version]
if not mesh then
mesh = love.graphics.newMesh({
{ 0, 0, 0, 0, 255, 255, 255, 255 },
{ 0, 0, 1, 0, 255, 255, 255, 255 },
{ 0, 0, 1, 1, 255, 255, 255, 255 },
{ 0, 0, 0, 1, 255, 255, 255, 255 },
}, "fan", "dynamic")
mesh:setTexture(label.image)
imp._cartridgeLabelMeshes[version] = mesh
end
mesh:setVertices({
{ points[1][1], points[1][2], 0, 0, 255, 255, 255, 255 },
{ points[2][1], points[2][2], 1, 0, 255, 255, 255, 255 },
{ points[3][1], points[3][2], 1, 1, 255, 255, 255, 255 },
{ points[4][1], points[4][2], 0, 1, 255, 255, 255, 255 },
})
return mesh
end
local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
local state = cartridgeState(imp, version)
local focused = Kit.focusable(key, x, y, w, h)
local hot = Kit.hover(x, y, w, h)
local active = state.active
local cx, cy = x + w / 2, y + h / 2
if Kit.mouseClicked and Kit.hit(x, y, w, h) and not Kit.blockClicks then
if Kit.mouseDown then
state.active = true
state.startX, state.startY = Kit.mouseX, Kit.mouseY
state.lastDragX, state.lastDragY = Kit.mouseX, Kit.mouseY
state.dragged = false
active = true
else
queueAction(imp, key, action)
end
end
if state.active then
active = true
if Kit.mouseDown then
local movedX, movedY = Kit.mouseX - state.startX, Kit.mouseY - state.startY
if movedX * movedX + movedY * movedY > CART_DRAG_SLOP * CART_DRAG_SLOP then
state.dragged = true
end
if state.dragged then
local dragX = Kit.mouseX - (state.lastDragX or Kit.mouseX)
local dragY = Kit.mouseY - (state.lastDragY or Kit.mouseY)
state.spin = state.spin + dragX * 0.018
state.pitchDrag = clamp((state.pitchDrag or 0) + dragY * 0.010,
-1.20, 1.20)
end
state.lastDragX, state.lastDragY = Kit.mouseX, Kit.mouseY
else
if not state.dragged then queueAction(imp, key, action) end
state.active, active = nil, false
state.dragged = nil
end
end
local dt = math.min(0.08, math.max(0, Kit.time - (state.lastTime or Kit.time)))
state.lastTime = Kit.time
if not state.active then
local upright = math.floor(state.spin / TAU + 0.5) * TAU
state.spin = state.spin + (upright - state.spin) * math.min(1, dt * 4)
state.pitchDrag = (state.pitchDrag or 0)
* (1 - math.min(1, dt * 4))
end
local pointerX = clamp((Kit.mouseX - cx) / math.max(1, w / 2), -1, 1)
local pointerY = clamp((Kit.mouseY - cy) / math.max(1, h / 2), -1, 1)
local hoverTilt = hot and pointerX * 0.12 or math.sin(Kit.time * 0.75) * 0.035
local pressX = active and pointerX * w * 0.025 or 0
local pressY = active and pointerY * h * 0.018 or 0
local yaw = -0.42 + state.spin + hoverTilt
local pitch = 0.14 + (state.pitchDrag or 0)
+ (hot and pointerY * 0.08 or math.sin(Kit.time * 0.6) * 0.018)
local pressedScale = active and 0.965 or 1
Kit._audit("control", x, y, w, h, key)
if focused then
Theme.strokeRounded(x - 3, y - 3, w + 6, h + 6, PAL.lineStrong,
Theme.A.focus, 2, Theme.cardRadius() + 2)
end
local halfW, halfH = w / 2, h / 2
local depth = math.max(8, w * 0.14)
local project = function(px, py, pz)
return cartProject(cx + pressX, cy + pressY, yaw, pitch,
px * pressedScale, py * pressedScale, pz * pressedScale)
end
local capH = h * 3 / 65
local mainTop = -halfH + capH
local capRight = halfW - w * 5 / 57
local mainFront = cartQuad(project, -halfW, mainTop, w, h - capH, depth)
local mainBack = cartQuad(project, -halfW, mainTop, w, h - capH, -depth)
local capFront = cartQuad(project, -halfW, -halfH,
capRight + halfW, capH, depth)
local capBack = cartQuad(project, -halfW, -halfH,
capRight + halfW, capH, -depth)
local shell = cartColor(version)
local side = { math.floor(shell[1] * 0.54), math.floor(shell[2] * 0.54),
math.floor(shell[3] * 0.54) }
cartPolygon(mainBack, side, 1)
cartPolygon(capBack, side, 1)
cartPolygon({ mainFront[2], mainFront[3], mainBack[3], mainBack[2] }, side, 1)
cartPolygon({ mainFront[3], mainFront[4], mainBack[4], mainBack[3] }, side, 1)
cartPolygon({ mainFront[1], mainFront[2], mainBack[2], mainBack[1] }, side, 1)
cartPolygon({ capFront[2], capFront[3], capBack[3], capBack[2] }, side, 1)
cartPolygon({ capFront[1], capFront[2], capBack[2], capBack[1] }, side, 1)
cartPolygon({ capFront[4], capFront[1], capBack[1], capBack[4] }, side, 1)
cartPolygon(mainFront, shell, 1)
cartPolygon(capFront, shell, 1)
local faceZ = depth + 0.8
for i = 0, 4 do
local ry = mainTop + 7 + i * h * 0.025
cartPolygon(cartQuad(project, -halfW + 2, ry, w * 0.13, 2, faceZ), side, 0.7)
cartPolygon(cartQuad(project, halfW - w * 0.13 - 2, ry, w * 0.13, 2, faceZ), side, 0.7)
end
local recessX, recessY = -w * 0.32, mainTop + h * 0.023
local recessW, recessH = w * 0.64, h * 0.24
cartPolygon(cartQuad(project, recessX, recessY, recessW, recessH, faceZ), shell, 0.88)
cartPill(project, recessX + w * 0.025, recessY + h * 0.025,
recessW - w * 0.05, h * 0.12, faceZ + 0.5, shell, 0.7)
cartPill(project, recessX + w * 0.045, recessY + h * 0.043,
recessW - w * 0.09, h * 0.083, faceZ + 0.8, side, 0.42)
local labelX, labelY = -w * 0.33, -h * 0.20
local labelW, labelH = w * 0.66, h * 0.55
local plate = cartQuad(project, labelX - 2, labelY - 2, labelW + 4, labelH + 4, faceZ + 0.8)
cartPolygon(plate, side, 0.95)
local labelPoints = cartQuad(project, labelX, labelY, labelW, labelH, faceZ + 1.2)
local label = cartridgeLabel(imp, version)
local mesh = label and cartLabelMesh(imp, version, label, labelPoints)
if mesh then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(mesh)
elseif label then
local artScale = math.min(labelW / label.width, labelH / label.height)
love.graphics.draw(label.image, labelPoints[1][1], labelPoints[1][2],
0, artScale, artScale)
end
cartPolygon({
{ project(-w * 0.07, h * 0.37, faceZ + 1) },
{ project(w * 0.07, h * 0.37, faceZ + 1) },
{ project(0, h * 0.43, faceZ + 1) },
}, side, 0.70)
if not state.active and (Kit._activateId == key) then
queueAction(imp, key, action)
end
end
local function modStatusColor(status)
if status == "ok" then return Strings("Ready"), PAL.green end
if status == "conflict" then return Strings("Conflict"), PAL.red end
@@ -1011,8 +1242,16 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end
local tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s)
local tagH = Kit.textHeight("micro") + math.floor(10 * m.s)
Kit.tag(x + Kit.textWidth("title", Kit.ellipsize("title", gameName, w * 0.6))
+ math.floor(12 * m.s), y + (titleH - tagH) / 2, tagW, tagH, tagText, tagCol)
local tagX = x + Kit.textWidth("title", Kit.ellipsize("title", gameName, w * 0.6))
+ math.floor(12 * m.s)
Kit.tag(tagX, y + (titleH - tagH) / 2, tagW, tagH, tagText, tagCol)
if ready then
local hint = Strings("(PRESS THE CART TO PLAY)")
local hintX = tagX + tagW + math.floor(10 * m.s)
local hintW = math.max(0, x + w - hintX)
Kit.text("micro", Kit.ellipsize("micro", hint, hintW), hintX,
y + (titleH - Kit.textHeight("micro")) / 2, PAL.heading)
end
local cy = y + titleH + math.floor(12 * m.s)
local remaining = availH - (titleH + math.floor(12 * m.s))
@@ -1038,25 +1277,20 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
local ly = cy
if ready then
-- Play IS the panel: it takes the space the ROM buttons used to hold, at
-- the top of the column where the eye lands, wearing this game's own
-- cartridge colour rather than a generic green.
-- Play grows into the room the column has: it is the one thing on this
-- screen the player came for, and the space freed by moving ROM and
-- control management out belongs to it rather than to a gap. Clamped at
-- both ends so a short window still gets a real button and a tall one
-- does not get a billboard.
local playH = math.floor(clamp(remaining * 0.30, 64 * m.s, 132 * m.s))
local mgW = playH
-- The cartridge takes the Play button's former place. Its portrait
-- ratio comes from a real Game Boy cart rather than stretching the old
-- horizontal control, and its body colour comes from the active game.
local playH = math.floor(clamp(remaining * 0.52, 112 * m.s, 260 * m.s))
local mgW = math.max(Kit.tapMin(), math.floor(34 * m.s))
local bgap = math.floor(8 * m.s)
btn(imp, lx, ly, lw - mgW - bgap, playH, "play-" .. version,
Strings("Play ") .. gameName, {
fill = cartColor(version), ink = PAL.inverse, font = "stat",
action = function() imp:play(version) end,
})
local cartAreaW = lw - mgW - bgap
local cartW = math.min(cartAreaW, math.floor(playH * 0.88))
local cartX = lx + math.floor((cartAreaW - cartW) / 2)
cartridgeButton(imp, cartX, ly, cartW, playH, "play-" .. version,
version, gameName, function() imp:play(version, true) end)
imp._gearIcon = imp._gearIcon
or love.graphics.newImage("assets/launcher/gear.png")
iconButton(imp, "manage-" .. version, lx + lw - mgW, ly, playH,
iconButton(imp, "manage-" .. version, lx + lw - mgW, ly, mgW,
imp._gearIcon, function() imp._gameManage = version end)
ly = ly + playH + gap
end
@@ -1139,6 +1373,28 @@ local function drawCheck(x, y, size, color)
love.graphics.pop()
end
-- One compact coloured checkbox for each game. The cartridge colour carries
-- the game identity even when the row is narrow; the letter keeps an unchecked
-- box legible without relying on colour alone.
local function modGameCheckbox(x, y, size, checked, game, id)
local color = cartColor(game)
local focused = Kit.focusable(id, x, y, size, size)
local hot = focused or Kit.hover(x, y, size, size)
if love.graphics then
if checked then
Theme.fillRounded(x, y, size, size, color, 1)
drawCheck(x, y, size, PAL.inverse)
else
Theme.fillRounded(x, y, size, size, PAL.bg, 1)
Kit.textCenterBold("micro", game:sub(1, 1):upper(), x,
y + (size - Kit.textHeight("micro")) / 2, size, color)
end
Theme.strokeRounded(x, y, size, size, color,
hot and Theme.A.focus or Theme.A.hover, 1)
end
return Kit.press(x, y, size, size) or Kit._activateId == id
end
local function buildModsPanel(imp, x, y, w, availH, m)
imp:_ensureMods()
local ModUpdate = require("src.mods.ModUpdate")
@@ -1225,15 +1481,14 @@ local function buildModsPanel(imp, x, y, w, availH, m)
mods = sorted
end
-- A mod row is a fixed height: name line, version + status line, one line
-- of description, and an action row. Fixed because a page of uniform rows
-- is what lets perPage come from the viewport.
local chipH = math.max(Kit.tapMin(), math.floor(30 * m.s))
-- Text block on the left, chips right-aligned beside it: one row, not a
-- text block with a button strip stacked under it.
-- A mod row is a fixed height: its details first, then a dedicated second
-- line of per-game checkboxes. Fixed because a page of uniform rows is
-- what lets perPage come from the viewport.
local togH = math.floor(26 * m.s)
local gamesLabel = Strings("Enable for:")
local textH = Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(2 * m.s) + Kit.textHeight("small")
local rowH = math.floor(8 * m.s) + math.max(textH, chipH)
local rowH = math.floor(8 * m.s) + textH + math.floor(8 * m.s) + togH
+ math.floor(8 * m.s)
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local listH = availH - (cy - y) - pagerH - gap
@@ -1258,28 +1513,36 @@ local function buildModsPanel(imp, x, y, w, availH, m)
local px, inner = x + pad, w - 2 * pad
local ly = ry + math.floor(10 * m.s)
local togW = math.floor(56 * m.s)
local togH = math.floor(26 * m.s)
local togGap = math.floor(4 * m.s)
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
local togKey = "mod-toggle-" .. mod.id
-- The toggle reports its own new value, but the importer owns the state:
-- queue the flip and let _toggleMod (which may raise an experimental-mod
-- confirm) decide what actually happens.
local _, flipped = Kit.toggle(px + inner - togW,
ry + (rowH - togH) / 2, togW, togH, mod.enabled, togKey)
if flipped then
queueAction(imp, togKey, function() imp:_toggleMod(mod.id) end)
-- These answer separate games, not a single shared install flag. The
-- importer receives the game id so an experimental confirmation also
-- applies only to the checkbox the player pressed.
local flipped = false
local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s)
Kit.text("micro", gamesLabel, px,
gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted)
local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s)
for _, game in ipairs(GameVersion.ORDER) do
local togKey = "mod-toggle-" .. mod.id .. "-" .. game
if modGameCheckbox(tx, gamesY, togH,
mod.enabledByVersion and mod.enabledByVersion[game] == true,
game, togKey) then
local version = game
queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end)
flipped = true
end
tx = tx + togH + togGap
end
-- The toggle sits inside the row's rect, so its press also passes the
-- row's hit test; `flipped` gates the row action to everywhere else.
-- The checkboxes sit inside the row's rect, so their press also passes the
-- row hit test; `flipped` gates the row action to everywhere else.
if not flipped
and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then
local id = mod.id
queueAction(imp, rowKey, function() imp._modActions = id end)
end
local chipsW = togW + math.floor(6 * m.s)
local textW = inner - chipsW - math.floor(12 * m.s)
local textW = inner
local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s)
-- the games the mod is for, beside its category: the same chip the
@@ -1741,7 +2004,7 @@ local function buildConfirmModal(imp, m)
elseif c.kind == "importOversize" then
imp:_importSave(c.version, c.source, true)
else
imp:_toggleMod(c.id, true)
imp:_toggleMod(c.id, true, c.version)
end
end,
})
@@ -2608,6 +2871,11 @@ function LauncherView.draw(imp)
end
end
if imp._launchFade then
Theme.fill(0, 0, m.W, m.H, PAL.bg,
math.min(1, imp._launchFade.elapsed / imp._launchFade.duration))
end
Kit.endFrame()
drawPadCursor(imp)
end
+27 -8
View File
@@ -2048,6 +2048,15 @@ end
function RomImporter:update(dt)
self.pulse = self.pulse + dt
if self._launchFade then
self._launchFade.elapsed = self._launchFade.elapsed + dt
if self._launchFade.elapsed >= self._launchFade.duration then
local version = self._launchFade.version
self._launchFade = nil
self:play(version)
return
end
end
self:_updatePadCursor(dt)
self:_stepBaseRomScan()
-- Pump the FlexLove view (input polling + the queued click actions). The
@@ -2436,9 +2445,15 @@ function RomImporter:joystickhat(joystick, hat, direction)
end
-- Player pressed Play on a game whose ROM is imported: hand off to boot.
function RomImporter:play(version)
function RomImporter:play(version, fade)
if self.workState == "working" then return end
if not self.ready[version] then return end
if fade then
if not self._launchFade then
self._launchFade = { version = version, elapsed = 0, duration = 0.24 }
end
return
end
self._handedOff = true
-- #835: remember the game being launched so the next launcher start opens on
-- its column (_applyLastVersionTab). It rides options.lua rather than a file
@@ -3064,15 +3079,19 @@ function RomImporter:_modUpdateInfo(id)
return self.modUpdateInfo and self.modUpdateInfo[id] or nil
end
-- Flip a mod's enabled flag (persisted via LauncherMods.setEnabled) and relist
-- so the toggle, count, and every status chip reflect the new resolution.
-- Enabling an experimental mod arms a confirm first.
function RomImporter:_toggleMod(id, confirmed)
-- Flip one game's mod flag (persisted via LauncherMods.setEnabled) and relist
-- so that game's checkbox and status chips reflect the new resolution.
-- Enabling an experimental mod arms a confirmation for that same game.
function RomImporter:_toggleMod(id, confirmed, version)
local LauncherMods = require("src.mods.LauncherMods")
local cur, experimental = false, false
for _, m in ipairs(self.mods or {}) do
if m.id == id then
cur = m.enabled
if version and m.enabledByVersion then
cur = m.enabledByVersion[version] == true
else
cur = m.enabled
end
experimental = m.experimental == true
break
end
@@ -3080,7 +3099,7 @@ function RomImporter:_toggleMod(id, confirmed)
local want = not cur
if want and experimental and not confirmed then
self._modConfirm = {
kind = "experimental", id = id,
kind = "experimental", id = id, version = version,
title = "Experimental mod",
yesLabel = "Enable",
lines = {
@@ -3092,7 +3111,7 @@ function RomImporter:_toggleMod(id, confirmed)
return
end
self._modConfirm = nil
LauncherMods.setEnabled(id, want, self.modScope)
LauncherMods.setEnabled(id, want, version or self.modScope)
self:_refreshMods()
end
+11
View File
@@ -210,6 +210,17 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- (ItemUsePokeFlute, engine/items/item_effects.asm); never consumed.
if itemId == "POKE_FLUTE" then
if not battle then
if ow and ow.map and ow.map.id == "PEWTER_POKECENTER"
and ow.pikachuPewterSleepScene then
local Follower = require("src.world.PikachuFollower")
local pika = Follower.current(ow)
local player = ow.player
if pika and player
and math.abs(pika.cellX - player.cellX) + math.abs(pika.cellY - player.cellY) == 1 then
return "flute_wake_pikachu", { romText(data, "_PlayedFluteHadEffectText",
"{PLAYER} played the\nPOKé FLUTE.") }
end
end
-- standing next to a not-yet-beaten Snorlax: this is the ONLY way
-- Snorlax wakes -- using the flute from the item-use menu, never
-- just talking to it with the flute in the bag (see
+44 -11
View File
@@ -36,6 +36,7 @@ local ModTargets = require("src.mods.ModTargets")
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs")
local LauncherMods = {}
@@ -107,8 +108,9 @@ end
-- deriveList(manifests, options [, version]) -> the panel row list, pure.
-- manifests is an array of validated manifests (Manifest.validate output);
-- options is the options table (options.mods, options.modsByVersion and
-- options.modsGen2 are read). `version` is the game the panel is showing:
-- nil keeps the pre-per-game view, where the shared flag is the whole answer.
-- options.modsGen2 are read). `version` is the game the panel is showing;
-- each row also carries its answer for every game so the launcher can render
-- the coloured game checkboxes together.
-- Rows come back sorted by id so the panel order is stable.
function LauncherMods.deriveList(manifests, options, version)
local ordered = {}
@@ -126,8 +128,7 @@ function LauncherMods.deriveList(manifests, options, version)
-- this game's choice, then the shared flag, then the default: enabled,
-- matching the loader -- except experimental mods, which stay off until
-- the player opts in. Scoped through modScope, so this reads exactly what
-- setEnabled writes and the loader loads: while per-game flags are a
-- preview the shared flag is the whole answer on every surface.
-- setEnabled writes and the loader loads for the selected game.
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
if decided == nil then decided = not m.experimental end
if decided then enabledSet[m.id] = true end
@@ -152,6 +153,14 @@ function LauncherMods.deriveList(manifests, options, version)
badge = badge,
description = m.description or "",
enabled = enabled,
enabledByVersion = (function()
local answers = {}
for _, game in ipairs(GameVersion.ORDER) do
local answer = SaveData.modEnabled(options, m.id, game)
answers[game] = answer == true or (answer == nil and not m.experimental)
end
return answers
end)(),
status = status,
statusDetail = detail,
github = m.github,
@@ -281,7 +290,14 @@ end
function LauncherMods.list(version)
local ok, result = pcall(function()
local options = SaveData.loadOptions()
return LauncherMods.deriveList(discover(), options, version)
local manifests = discover()
-- The first build containing game-specific switches turns the old shared
-- state into one explicit answer per installed mod and game. Saving here
-- means users who only visit the launcher still receive the migration.
if SaveData.migrateModEnablement(options, manifests) then
SaveData.saveOptions(options)
end
return LauncherMods.deriveList(manifests, options, version)
end)
if not ok then
-- a single bad options/mod file must not blank the launcher
@@ -364,10 +380,8 @@ function LauncherMods.translationStrings()
return merged
end
-- setEnabled(id, enabled [, version]): 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. With `version` the choice
-- lands in that game's overlay instead and no other game moves.
-- setEnabled(id, enabled [, version]): with a game, persist just that game's
-- answer. The loader and the in-game manager use the same scope on next boot.
function LauncherMods.setEnabled(id, enabled, version)
local options = SaveData.loadOptions()
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
@@ -384,7 +398,15 @@ function LauncherMods.setAllEnabled(ids, enabled, version)
local options = SaveData.loadOptions()
local scope = SaveData.modScope(version)
for _, id in ipairs(ids or {}) do
SaveData.setModEnabled(options, id, enabled, scope)
if scope then
SaveData.setModEnabled(options, id, enabled, scope)
elseif SaveData.PER_VERSION_MODS then
for _, game in ipairs(GameVersion.ORDER) do
SaveData.setModEnabled(options, id, enabled, game)
end
else
SaveData.setModEnabled(options, id, enabled)
end
end
SaveData.saveOptions(options)
return true
@@ -843,7 +865,7 @@ end
-- uninstall(id) -> true | nil, errString
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
-- or the save directory, CacheFs decides -- #330) and clears every enable flag
-- so the loader and in-game manager no longer see it. Rejects missing ids.
-- Does not touch other mods' enable state.
function LauncherMods.uninstall(id)
@@ -872,8 +894,19 @@ function LauncherMods.uninstall(id)
-- Drop the enable flag so a reinstall of the same id starts from the
-- loader's default (enabled) rather than a stale false.
local options = SaveData.loadOptions()
local changed = false
if options.mods and options.mods[id] ~= nil then
options.mods[id] = nil
changed = true
end
for _, version in ipairs(GameVersion.ORDER) do
local bucket = options.modsByVersion and options.modsByVersion[version]
if type(bucket) == "table" and bucket[id] ~= nil then
bucket[id] = nil
changed = true
end
end
if changed then
SaveData.saveOptions(options)
end
return true
+20 -4
View File
@@ -270,8 +270,7 @@ function Loader:_targetVersion()
return version
end
-- The version an enable flag is read and written under: this game once
-- per-game flags are live, nil (the shared flag) while they are a preview.
-- The version an enable flag is read and written under: this running game.
-- Reads and writes go through the same answer so the two can never drift.
function Loader:_enableScope()
return SaveData.modScope(self:_targetVersion())
@@ -1306,13 +1305,30 @@ function Loader:load(data)
require("src.mods.Builtins").install(self.content, data, self.generation)
self:_loadState()
self:_discover()
-- Existing installs stored one shared answer. Once their manifests are
-- known, split that answer across every game before the next launcher/game
-- toggle can change one independently. _loadState already used the same
-- fallback, so this write cannot change the current boot's result.
do
local options = SaveData.loadOptions(self.fs)
local installed = {}
for id, mod in pairs(self.mods) do
installed[#installed + 1] = {
id = id,
experimental = mod.manifest and mod.manifest.experimental == true,
}
end
if SaveData.migrateModEnablement(options, installed) and self.fs.write then
SaveData.saveOptions(options, self.fs)
end
end
-- Experimental mods stay off until the player opts in: a missing
-- options.mods entry normally means enabled, but experimental flips that.
do
local options = SaveData.loadOptions(self.fs)
local modsOpt = options.mods or {}
local scope = self:_enableScope()
for id, mod in pairs(self.mods) do
if not self.disabled[id] and modsOpt[id] == nil
if not self.disabled[id] and SaveData.modEnabled(options, id, scope) == nil
and mod.manifest.experimental then
self.disabled[id] = true
end
+1 -2
View File
@@ -735,8 +735,7 @@ function ManagerState:toggleGen2Force(m)
}, apply)
end
-- Where the loader persisted an enable flag: this game's slot once it keeps
-- them per game, the shared flag until then (SaveData.modScope).
-- Where the loader persists an enable flag: this running game's slot.
function ManagerState:enableScope()
return SaveData.modScope((self:targetGame()))
end
+1
View File
@@ -140,6 +140,7 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
require("src.core.Sound").play(game.data, "Get_Item1")
game.stack:push(TextBox.new(game,
romText(game.data, "_LearnedMove1Text",
"%s learned\n%s!", name, mdef.name), nextStep))
+6 -1
View File
@@ -62,6 +62,8 @@ local function getObpImage(path, colors, group)
return obpCache[key]
end
SpriteRenderer.obpImage = getObpImage
-- hot reload drops the sheets; live instances hold their own image, so
-- the world rebuilds them (MapLoader.invalidateAll) rather than this
function SpriteRenderer.invalidate()
@@ -281,7 +283,7 @@ end
-- swapped and OAM_XFLIP on each (data/sprites/facings.asm:192-197). Optional
-- and trailing, so every existing call site is unchanged.
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip,
topHalf, forceFlip)
topHalf, forceFlip, frameOverride)
local x, y = self:getScreenOrigin(px, py, camX, camY)
local image = self.image
local redraw = false
@@ -329,6 +331,9 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip,
-- still 3-frame sprites turn to face (the nurse at her machine,
-- facePlayer on STAY NPCs) but never show walk frames.
local frame, flip = pose(self, facing, walkPhase, stepFlip)
if frameOverride and self.frames[frameOverride] then
frame, flip = frameOverride, false
end
if forceFlip then flip = true end
local quad = self.frames[frame]
local drawHeight = self.frameHeight
+1
View File
@@ -130,6 +130,7 @@ TextBox.TOKENS = {
STRBUF = function(game) return game.stringBuffer end,
RAM = function(game, arg)
if arg == "wStringBuffer" then return game.stringBuffer end
if arg == "wNameBuffer" 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
+18
View File
@@ -100,6 +100,7 @@ O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_
-- Sits 2 bytes (wPlayerCoins) past O.coins per the walk above; absolute
-- 0x2852 (#763, #857).
O.toggleObjectFlags = O.coins + 2 -- 32B
O.hiddenItemFlags = O.townVisited - 27
-- 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
@@ -770,6 +771,15 @@ function GenSave.decode(bytes, data, opts)
end
end
if data.hiddenItems then
save.hiddenTaken = {}
for i, row in ipairs(data.hiddenItems) do
if bitGet(bytes, O.hiddenItemFlags, i - 1) then
save.hiddenTaken[row[1] .. "_" .. row[2] .. "_" .. row[3]] = true
end
end
end
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
-- into de and rotates it right one bit per iteration with b counting up
@@ -953,6 +963,14 @@ function GenSave.encode(save, data, template)
end
end
if data.hiddenItems then
local taken = save.hiddenTaken or {}
for i, row in ipairs(data.hiddenItems) do
local key = row[1] .. "_" .. row[2] .. "_" .. row[3]
bitSet(buf, O.hiddenItemFlags, i - 1, taken[key] and true or false)
end
end
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
-- save exported from this port is flyable on hardware (#263). A save
-- table with no `visited` key at all says nothing about the set, so leave
+7
View File
@@ -49,6 +49,7 @@ local DATA_MODULES = {
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" },
toggleObjects = { "src.save_convert.data.toggle_objects", "src/save_convert/data/toggle_objects.lua" },
hiddenItems = { "src.save_convert.data.hidden_items", "src/save_convert/data/hidden_items.lua" },
}
local OPTIONAL_MODULES = { tilesets = true, audio = true }
@@ -64,6 +65,10 @@ local YELLOW_EVENT_FLAGS = {
"src.save_convert.data.event_flags_yellow",
"src/save_convert/data/event_flags_yellow.lua",
}
local YELLOW_HIDDEN_ITEMS = {
"src.save_convert.data.hidden_items_yellow",
"src/save_convert/data/hidden_items_yellow.lua",
}
local function loadTable(requirePath, filePath)
local ok, mod = pcall(require, requirePath)
@@ -133,6 +138,8 @@ local function ensureData(gameVersion)
if name ~= "charmap" then
if name == "eventFlags" and gameVersion == "yellow" then
spec = YELLOW_EVENT_FLAGS -- Yellow's bit numbering differs (#838)
elseif name == "hiddenItems" and gameVersion == "yellow" then
spec = YELLOW_HIDDEN_ITEMS
end
local mod = loadCacheTable(gameVersion, spec[2])
if not mod then
+30
View File
@@ -0,0 +1,30 @@
return {
{ "VIRIDIAN_FOREST", 1, 18 }, { "VIRIDIAN_FOREST", 16, 42 },
{ "MT_MOON_B2F", 18, 12 }, { "ROUTE_25", 38, 3 },
{ "ROUTE_9", 14, 7 }, { "SS_ANNE_KITCHEN", 13, 9 },
{ "SS_ANNE_B1F_ROOMS", 3, 1 }, { "ROUTE_10", 9, 17 },
{ "ROUTE_10", 16, 53 }, { "ROCKET_HIDEOUT_B1F", 21, 15 },
{ "ROCKET_HIDEOUT_B3F", 27, 17 }, { "ROCKET_HIDEOUT_B4F", 25, 1 },
{ "POKEMON_TOWER_5F", 4, 12 }, { "ROUTE_13", 1, 14 },
{ "ROUTE_13", 16, 13 }, { "POKEMON_MANSION_B1F", 1, 9 },
{ "SAFARI_ZONE_GATE", 10, 1 }, { "SAFARI_ZONE_WEST", 6, 5 },
{ "SILPH_CO_5F", 12, 3 }, { "SILPH_CO_9F", 2, 15 },
{ "COPYCATS_HOUSE_2F", 1, 1 }, { "CERULEAN_CAVE_1F", 14, 11 },
{ "CERULEAN_CAVE_B1F", 27, 3 }, { "POWER_PLANT", 17, 16 },
{ "POWER_PLANT", 12, 1 }, { "SEAFOAM_ISLANDS_B2F", 15, 15 },
{ "SEAFOAM_ISLANDS_B4F", 25, 17 }, { "POKEMON_MANSION_1F", 8, 16 },
{ "POKEMON_MANSION_3F", 1, 9 }, { "ROUTE_23", 9, 44 },
{ "ROUTE_23", 19, 70 }, { "ROUTE_23", 8, 90 },
{ "VICTORY_ROAD_2F", 5, 2 }, { "VICTORY_ROAD_2F", 26, 7 },
{ "UNUSED_MAP_6F", 14, 11 }, { "VIRIDIAN_CITY", 14, 4 },
{ "ROUTE_11", 48, 5 }, { "ROUTE_12", 2, 63 },
{ "ROUTE_17", 15, 14 }, { "ROUTE_17", 8, 45 },
{ "ROUTE_17", 17, 72 }, { "ROUTE_17", 4, 91 },
{ "ROUTE_17", 8, 121 }, { "UNDERGROUND_PATH_NORTH_SOUTH", 3, 4 },
{ "UNDERGROUND_PATH_NORTH_SOUTH", 4, 34 },
{ "UNDERGROUND_PATH_WEST_EAST", 12, 2 },
{ "UNDERGROUND_PATH_WEST_EAST", 21, 5 }, { "CELADON_CITY", 48, 15 },
{ "ROUTE_25", 10, 1 }, { "MT_MOON_B2F", 33, 9 },
{ "SEAFOAM_ISLANDS_B3F", 9, 16 }, { "VERMILION_CITY", 14, 11 },
{ "CERULEAN_CITY", 15, 8 }, { "ROUTE_4", 40, 3 },
}
@@ -0,0 +1,31 @@
return {
{ "SILPH_CO_5F", 12, 3 }, { "SILPH_CO_9F", 2, 15 },
{ "POKEMON_MANSION_3F", 1, 9 }, { "POKEMON_MANSION_B1F", 1, 9 },
{ "SAFARI_ZONE_WEST", 6, 5 }, { "CERULEAN_CAVE_2F", 16, 13 },
{ "CERULEAN_CAVE_B1F", 8, 14 }, { "UNUSED_MAP_6F", 14, 11 },
{ "SEAFOAM_ISLANDS_B2F", 15, 15 }, { "SEAFOAM_ISLANDS_B3F", 9, 16 },
{ "SEAFOAM_ISLANDS_B4F", 25, 17 }, { "VIRIDIAN_FOREST", 1, 18 },
{ "VIRIDIAN_FOREST", 16, 42 }, { "MT_MOON_B2F", 18, 12 },
{ "MT_MOON_B2F", 33, 9 }, { "SS_ANNE_B1F_ROOMS", 3, 1 },
{ "SS_ANNE_KITCHEN", 13, 9 }, { "UNDERGROUND_PATH_NORTH_SOUTH", 3, 4 },
{ "UNDERGROUND_PATH_NORTH_SOUTH", 4, 34 },
{ "UNDERGROUND_PATH_WEST_EAST", 12, 2 },
{ "UNDERGROUND_PATH_WEST_EAST", 21, 5 }, { "ROCKET_HIDEOUT_B1F", 21, 15 },
{ "ROCKET_HIDEOUT_B3F", 27, 17 }, { "ROCKET_HIDEOUT_B4F", 25, 1 },
{ "ROUTE_10", 9, 17 }, { "ROUTE_10", 16, 53 },
{ "POWER_PLANT", 17, 16 }, { "POWER_PLANT", 12, 1 },
{ "ROUTE_11", 48, 5 }, { "ROUTE_12", 2, 63 },
{ "ROUTE_13", 1, 14 }, { "ROUTE_13", 16, 13 },
{ "ROUTE_17", 15, 14 }, { "ROUTE_17", 8, 45 },
{ "ROUTE_17", 17, 72 }, { "ROUTE_17", 4, 91 },
{ "ROUTE_17", 8, 121 }, { "ROUTE_23", 9, 44 },
{ "ROUTE_23", 19, 70 }, { "ROUTE_23", 8, 90 },
{ "VICTORY_ROAD_2F", 5, 2 }, { "VICTORY_ROAD_2F", 26, 7 },
{ "ROUTE_25", 38, 3 }, { "ROUTE_25", 10, 1 },
{ "ROUTE_4", 40, 3 }, { "ROUTE_9", 14, 7 },
{ "COPYCATS_HOUSE_2F", 1, 1 }, { "VIRIDIAN_CITY", 14, 4 },
{ "CERULEAN_CITY", 15, 8 }, { "CERULEAN_CAVE_1F", 18, 7 },
{ "POKEMON_TOWER_5F", 4, 12 }, { "VERMILION_CITY", 14, 11 },
{ "CELADON_CITY", 48, 15 }, { "SAFARI_ZONE_GATE", 10, 1 },
{ "POKEMON_MANSION_1F", 8, 16 },
}
+17 -3
View File
@@ -526,6 +526,22 @@ function Commands.set_field(ctx, key, value)
ctx.save[key] = value
end
function Commands.load_player_starter_name(ctx)
local flags = ctx.save.flags or {}
local species = flags.EVENT_CHOSE_PIKACHU and "PIKACHU"
or flags.EVENT_CHOSE_CHARMANDER and "CHARMANDER"
or flags.EVENT_CHOSE_SQUIRTLE and "SQUIRTLE"
or flags.EVENT_CHOSE_BULBASAUR and "BULBASAUR"
or (ctx.save.party and ctx.save.party[1] and ctx.save.party[1].species)
local def = species and ctx.game.data.pokemon[species]
ctx.game.stringBuffer = def and def.name or species or ""
end
function Commands.spawn_pikachu_follower(ctx)
require("src.world.PikachuFollower").onMapEntered(
ctx.game, ctx.overworld, nil, false)
end
local function toggleObject(ctx, mapId, objName, visible)
local save = ctx.save
save.objectToggles = save.objectToggles or {}
@@ -1131,9 +1147,7 @@ end
-- opts.tempo is the Music_*AlternateTempo override (audio/alternate_tempo.asm
-- re-points channel 1 at a stub that only changes the song's `tempo`) (#847).
function Commands.play_music(ctx, songId, opts)
local tempo = opts and opts.tempo
require("src.core.Music").play(ctx.game.data, songId, nil,
tempo and { tempo = tempo } or nil)
require("src.core.Music").play(ctx.game.data, songId, nil, opts)
if opts and opts.keep and ctx.overworld then
ctx.overworld.keepMusicOnce = true
end
+19 -7
View File
@@ -60,6 +60,14 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
return
end
if result == "flute_wake_pikachu" then
require("src.core.Sound").play(game.data, "Pokeflute")
showMessages(game, payload, function()
game.overworld.pikachuPewterSleepScene = nil
end)
return
end
-- field POKé FLUTE next to a not-yet-beaten Snorlax: "had effect" text,
-- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake)
if result == "flute_wake" then
@@ -142,12 +150,9 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
list:close()
local ow = game.overworld
local p = ow and ow.player
if ow and p then
local fx, fy = p:facingCell()
if ow.map:inBounds(fx, fy) and ow.map:isWaterCell(fx, fy) then
ow:goFishing(id)
return
end
if ow and p and ow:facingIsShoreOrWater() then
ow:goFishing(id)
return
end
showMessages(game, { Strings("No good! It's not\neven near water.") })
return
@@ -176,19 +181,25 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
end
if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp })
require("src.core.Sound").play(game.data, "Get_Item1")
showMessages(game, { Strings("%s learned\n%s!", target.nickname or
game.data.pokemon[target.species].name, mdef.name) })
if result == "learn" then consume(game, id) end
list.items = buildItems(game)
list.index = math.min(list.index, math.max(1, #list.items))
taught()
else
require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId,
function(learned)
if learned and result == "learn" then consume(game, id) end
if learned then
list.items = buildItems(game)
list.index = math.min(list.index, math.max(1, #list.items))
end
if learned then taught() end
end)
end
end
list:close()
teach()
return
end
@@ -322,6 +333,7 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
local mdef = game.data.moves[moveId]
if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp })
require("src.core.Sound").play(game.data, "Get_Item1")
local name = target.nickname or def.name
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
nextStep)
+17
View File
@@ -119,6 +119,13 @@ local function deposit(game)
onChoose = function(item, list)
local mon = game.save.party[item.value]
if not mon then return end
local Follower = require("src.world.PikachuFollower")
if Follower.isFollowingDisabled(game.overworld)
and Follower.isStarterPikachu(game.save, mon) then
game.stack:push(TextBox.new(game, t._SleepingPikachuText2
or Strings("There isn't any\nresponse...")))
return
end
monSubmenu(game, "DEPOSIT", mon, function()
if #game.save.party <= 1 then
list.footer = Strings("You need at least\none POKéMON!")
@@ -167,6 +174,16 @@ local function release(game)
local mon = box[list.index]
if not mon then return end
local name = monName(game, mon)
if require("src.core.GameVersion").isYellow()
and mon.species == "PIKACHU"
and mon.otId == game.save.player.id
and mon.ot == game.save.player.name then
require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game,
(t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name))
:gsub("{RAM:wNameBuffer}", name)))
return
end
game.stack:push(TextBox.new(game,
Strings("Once released,\n%s is\ngone forever. OK?", name), nil, {
defaultNo = true, noSound = true,
+7 -2
View File
@@ -17,12 +17,13 @@ local HM_MOVES = {
CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true,
}
function MoveLearnMenu.new(game, mon, newMoveId, onDone)
function MoveLearnMenu.new(game, mon, newMoveId, onDone, learnedSound)
local self = setmetatable({}, MoveLearnMenu)
self.game = game
self.mon = mon
self.newMoveId = newMoveId
self.onDone = onDone
self.learnedSound = learnedSound or "Get_Item1"
self.index = 1
-- forget-list UI only after TryingToLearn YES (learn_move.asm .loop)
self.selecting = false
@@ -118,6 +119,7 @@ function MoveLearnMenu:finish(learned)
self.selecting = false
game.stack:pop()
local msg
local opts
if learned then
-- pokered pages this as four texts in a row; _ForgotAndText carries
-- the "And..." tail
@@ -127,13 +129,16 @@ function MoveLearnMenu:finish(learned)
"\f%s forgot\n%s!\fAnd...", name, self.forgot)
.. "\f" .. romText(game.data, "_LearnedMove1Text",
"%s learned\n%s!", name, mdef.name)
opts = { auto = { sound = function()
return require("src.core.Sound").play(game.data, self.learnedSound)
end, wait = true } }
else
msg = romText(game.data, "_DidNotLearnText",
"%s\ndid not learn\v%s!", name, mdef.name)
end
game.stack:push(TextBox.new(game, msg, function()
if self.onDone then self.onDone(learned) end
end))
end, opts))
end
function MoveLearnMenu:draw()
+23
View File
@@ -72,6 +72,21 @@ end
local function sameItems(_, items) return items end
local function followerUnavailable(game, mon)
local ow = game.overworld
local Follower = require("src.world.PikachuFollower")
return Follower.isFollowingDisabled(ow)
and Follower.isStarterPikachu(game.save, mon)
end
local function refuseUnavailable(self)
self.swapFrom = nil
local TextBox = require("src.render.TextBox")
local t = self.game.data and self.game.data.text or {}
self.game.stack:push(TextBox.new(self.game,
t._SleepingPikachuText1 or Strings("There isn't any\nresponse...")))
end
-- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is
-- excluded by map id in ItemUseEscapeRope)
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
@@ -367,6 +382,10 @@ function PartyMenu:update(dt)
self.submenu = nil
elseif input:wasPressed("a") then
local mon = party[self.index]
if followerUnavailable(self.game, mon) then
refuseUnavailable(self)
return
end
local entry = self.subItems[self.subIndex]
local action = entry.action
if not action and entry.onSelect then
@@ -577,6 +596,10 @@ function PartyMenu:update(dt)
if self.onCancel then self.onCancel() end
elseif input:wasPressed("a") and #party > 0 then
local mon = party[self.index]
if followerUnavailable(self.game, mon) then
refuseUnavailable(self)
return
end
if self.softboiledFrom then
local user = party[self.softboiledFrom]
local heal = math.floor(user.stats.hp / 5)
+21
View File
@@ -104,6 +104,27 @@ local function sell(game)
dialogue = true,
money = function() return game.save.money end,
footer = greet,
onSelectKey = function(item, l)
if not item then return end
if not l.swapIndex then
l.swapIndex = l.index
return
end
local order = Bag.order(game.save)
order[l.swapIndex], order[l.index] = order[l.index], order[l.swapIndex]
l.swapIndex = nil
require("src.core.Sound").play(game.data, "Swap")
local rebuilt = {}
for _, id in ipairs(order) do
local def = game.data.items[id]
rebuilt[#rebuilt + 1] = {
value = id,
label = def and def.name or id,
right = "x" .. game.save.inventory[id],
}
end
l.items = rebuilt
end,
onChoose = function(item)
local def = game.data.items[item.value]
-- only key items and HMs are unsellable (pokemart.asm IsKeyItem /
+2 -1
View File
@@ -34,7 +34,8 @@ function StartMenu.new(game)
-- POKéMON is always listed (draw_start_menu.asm prints it even with
-- an empty party; selecting it then just no-ops)
table.insert(items, { label = Strings("POKéMON"), onSelect = function()
table.insert(items, { label = Strings("POKéMON"),
keepOpen = #game.save.party == 0, onSelect = function()
if #game.save.party == 0 then return end
Screens.push(game, "PartyMenu", { onCancel = reopen })
end })
+15 -8
View File
@@ -200,8 +200,9 @@ function TitleState.new(game, opts)
self.versionFull = imagePath(title.versionRibbon) ~= nil
self.version = tryImage(imagePath(title.versionRibbon or title.version)
or "assets/generated/title/red_version.png")
self.player = tryImage(imagePath(title.player)
or "assets/generated/title/player.png")
self.playerPath = imagePath(title.player)
or "assets/generated/title/player.png"
self.player = tryImage(self.playerPath)
-- ..(engine/movie/title2.asm ln 85)
if self.player then
local pw, ph = self.player:getDimensions()
@@ -597,6 +598,12 @@ end
-- ..(engine/movie/title.asm ln 28)
function TitleState:draw()
local PaletteFX = require("src.render.PaletteFX")
local playerImage = self.player
if playerImage and PaletteFX.usesSpriteObp() then
playerImage = require("src.render.SpriteRenderer").obpImage(
self.playerPath, PaletteFX.ogObj())
end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local scrollY = -(self.scy or 0)
@@ -660,8 +667,8 @@ function TitleState:draw()
-- layout has no cycling mon and no Red art (title_yellow.asm).
if spriteTrueColor then
local cover
if self.player then
local pw, ph = self.player:getDimensions()
if playerImage then
local pw, ph = playerImage:getDimensions()
cover = { 82, 80, pw, ph }
end
markVisibleTrueColor(x, y, w, h, cover)
@@ -670,11 +677,11 @@ function TitleState:draw()
-- Red is OAM in the original: he draws over the mon's box edge
if self.playerQuads then
for _, part in ipairs(self.playerQuads) do
love.graphics.draw(self.player, part[1], 82 + part[2], 80 + part[3])
love.graphics.draw(playerImage, part[1], 82 + part[2], 80 + part[3])
end
love.graphics.draw(self.player, self.ballQuad, 82, self.ballY)
elseif self.player then
love.graphics.draw(self.player, 82, 80)
love.graphics.draw(playerImage, self.ballQuad, 82, self.ballY)
elseif playerImage then
love.graphics.draw(playerImage, 82, 80)
end
end
self:drawCopyright(136 + (preRibbon and 0 or scrollY))
+11 -3
View File
@@ -272,9 +272,6 @@ function EvolutionAnim:commit()
evolved.level, evolved)
end
-- One LearnMove call per move the new species picks up at this level. There
-- is no forget screen in the Gold port yet, so a full moveset reports the way
-- src/battle/gen2/Battle.lua's own level-up path does instead of prompting.
function EvolutionAnim:nextLearn()
self.learnIndex = (self.learnIndex or 0) + 1
local moveId = self.pending and self.pending[self.learnIndex]
@@ -289,6 +286,17 @@ function EvolutionAnim:nextLearn()
self.learned[#self.learned + 1] = moveId
self.lines = { self.nick .. " learned", moveName .. "!" }
elseif reason == "full" then
if self.game and self.game.learnMoveOn then
self.phase = "waitingLearn"
return self.game:learnMoveOn(self.evolved, moveId, function(learned)
if learned then
self.learned[#self.learned + 1] = moveId
else
self.full[#self.full + 1] = moveId
end
self:nextLearn()
end)
end
self.full[#self.full + 1] = moveId
self.lines = { self.nick .. " wants to", "learn " .. moveName .. "!" }
else
+2 -1
View File
@@ -128,7 +128,8 @@ end
function NPC:draw(camX, camY)
local sprite, px, py, facing, phase, flip = self:pose()
sprite:draw(px, py, camX, camY, facing, phase, flip)
sprite:draw(px, py, camX, camY, facing, phase, flip, nil, nil,
self.frameOverride)
end
return NPC
+22 -2
View File
@@ -1087,6 +1087,7 @@ function OverworldState:update(dt)
-- the player lands on desk Oak.
local scripted = self.runner:isRunning() or #self.scriptMoves > 0
or self.engaging or self.emote or self.teleportOut
or self.flyAnim or self.flyArrive
if not scripted and not self.transitioning then
self:checkTrainerSight()
-- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and
@@ -1095,6 +1096,7 @@ function OverworldState:update(dt)
-- the player can never start another step after being spotted.
scripted = self.runner:isRunning() or #self.scriptMoves > 0
or self.engaging or self.emote or self.teleportOut
or self.flyAnim or self.flyArrive
end
if not scripted and not self.transitioning then
self:handleInput()
@@ -1664,6 +1666,10 @@ end
-- Goldeen/Poliwag L10; Super Rod uses the map's extracted fishing group
-- (no group means "Not even a nibble!").
function OverworldState:goFishing(rod)
if GameVersion.isYellow() then
Game.save.pikachuEmotionModifier = 2
Game.save.pikachuMood = 0x81
end
local pool, always = fishingPool(Game.data, rod, self.map.id)
local enc
if Runtime.wantsHook("encounter.fishing") then
@@ -2905,6 +2911,11 @@ end
-- POKéMON" and "fighting fit".
function OverworldState:nurseHeal(onDone, npc)
local t = Game.data.text
if self.map.id == "PEWTER_POKECENTER" and self.pikachuPewterSleepScene then
Game.stack:push(TextBox.new(Game,
t._LooksContentText or Strings("PIKACHU looks\ncontent."), onDone))
return
end
local bye = t._PokemonCenterFarewellText or romText(Game.data, "_PokemonCenterFarewellText", "We hope to see\nyou again!")
local hello = t._PokemonCenterWelcomeText
or Strings("Welcome to our\nPOKéMON CENTER!")
@@ -2978,9 +2989,13 @@ function OverworldState:finishNurseHeal(bye, onDone, npc)
end))
end
if not npc then farewell() return end
npc.facing = "up"
npc.frameOverride = 3
-- bubble = false is the silent world hold, this port's DelayFrames
self.emote = { npc = npc, frames = 20, bubble = false, onDone = farewell }
self.emote = { npc = npc, frames = 20, bubble = false, onDone = function()
npc.frameOverride = nil
npc:facePlayer(self.player)
farewell()
end }
end))
end
@@ -2992,6 +3007,11 @@ end
-- for the original serial handshake; declining prints "Please come again!"
function OverworldState:cableClubReceptionist(onDone)
local t = Game.data.text
if self.map.id == "PEWTER_POKECENTER" and self.pikachuPewterSleepScene then
Game.stack:push(TextBox.new(Game,
t._LooksContentText or Strings("PIKACHU looks\ncontent."), onDone))
return
end
local welcome = t._CableClubNPCWelcomeText or romText(Game.data, "_CableClubNPCWelcomeText", "Welcome to the\nCable Club!")
if not Game.save.flags.EVENT_GOT_POKEDEX then
-- CableClubNPC .didNotConnect path before the pokedex
+43 -1
View File
@@ -65,6 +65,17 @@ function PikachuFollower.starterInParty(save, needHealthy)
return nil
end
function PikachuFollower.isStarterPikachu(save, mon)
if not (mon and mon.species == "PIKACHU") then return false end
local player = save.player or {}
return mon.otId == player.id and mon.ot == player.name
end
function PikachuFollower.isFollowingDisabled(ow)
return ow and (ow.pikachuBillsScene or ow.pikachuFanClubScene
or ow.pikachuPewterSleepScene) and true or false
end
-- ModifyPikachuHappiness. mon is the party mon the event applied to for
-- the per-mon reasons (IsThisPartyMonStarterPikachu); GYMLEADER and
-- WALKING instead require any healthy starter in the party
@@ -199,6 +210,7 @@ function PikachuFollower.onMapEntered(game, ow, opts, viaMapLoad)
-- Bill's House owns a short scripted scene that deliberately keeps
-- Pikachu off the normal trailing loop. A new map instance ends it.
ow.pikachuBillsScene = nil
ow.pikachuFanClubScene = nil
remove(ow)
if not shouldSpawn(game, ow) then return end
-- opts.keepPikachu is the follower a connection crossing kept alive:
@@ -410,7 +422,8 @@ end
-- (pikachu_follow.asm keeps it one walk step behind)
function PikachuFollower.update(game, ow)
if ow.pikaHop then return end -- the counter hop owns the follower (#417)
if ow.pikachuBillsScene then return end
if ow.pikachuBillsScene or ow.pikachuFanClubScene
or ow.pikachuPewterSleepScene then return end
local npc = findFollower(ow)
if not npc then
if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end
@@ -713,6 +726,13 @@ function PikachuFollower.talk(game, ow, npc, done)
ow.player.facing = OPPOSITE[npc.facing] or ow.player.facing
local save = game.save
local emotion = selectEmotion(game, ow, save)
if ow.pikachuPewterSleepScene then
local finish = done
done = function()
ow.pikachuPewterSleepScene = nil
if finish then finish() end
end
end
local e = EMOTIONS[emotion] or EMOTIONS[1]
if e.turnAway then
npc.facing = ow.player.facing -- pikaemotion_9: back to the player
@@ -798,6 +818,28 @@ local function movePikachu(ow, npc, steps, onDone)
nextStep(1)
end
function PikachuFollower.onFanClubEntered(game, ow)
if not (GameVersion.isYellow() and ow.map
and ow.map.id == "POKEMON_FAN_CLUB") then return end
local starter = PikachuFollower.starterInParty(game.save)
local npc = findFollower(ow)
if not npc or (starter and starter.status) then return end
ow.pikachuFanClubScene = true
ow.pikachuMapScriptActive = true
ow.player.facing = "down"
for _, other in ipairs(ow.npcs or {}) do
if other.def and other.def.name == "POKEMONFANCLUB_SEEL" then
other.movementStatus = 2
other.facing = "down"
break
end
end
billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE")
movePikachu(ow, npc, { { "up", 1 }, { "right", 3 }, { "up", 1 } }, function()
npc.facing = "up"
end)
end
function PikachuFollower.onBillsHouseEnter(game, ow)
if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then
return
+82
View File
@@ -0,0 +1,82 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local ItemEffects = require("src.core.gen2.ItemEffects")
local Game2 = require("src.core.Game2")
local Screens = require("src.ui.Screens")
local EvolutionAnim = require("src.ui.gen2.EvolutionAnim")
local data = {
pokemon = {
SUNKERN = {
evolutions = {
{ method = "EVOLVE_ITEM", item = "SUN_STONE", into = "SUNFLORA" },
},
},
},
}
local mon = { species = "SUNKERN", item = nil }
T.eq(ItemEffects.partyAction("SUN_STONE"), "stone",
"SUN STONE opens the party target flow")
local result = ItemEffects.useOnMon("SUN_STONE", mon, data)
T.check(result.used, "SUN STONE succeeds on SUNKERN")
T.eq(result.evolution and result.evolution.into, "SUNFLORA",
"SUN STONE selects SUNFLORA")
mon.item = "EVERSTONE"
result = ItemEffects.useOnMon("SUN_STONE", mon, data)
T.check(not result.used, "held EVERSTONE refuses the evolution")
local function startStone()
local partyOptions, evolutionOptions
local game = setmetatable({
data = {
pokemon = data.pokemon,
screens = {
Gen2PartyMenu = function(_, options)
partyOptions = options
return {}
end,
Gen2EvolutionAnim = function(_, options)
evolutionOptions = options
return {}
end,
},
},
save = {
party = { { species = "SUNKERN", item = nil } },
inventory = { SUN_STONE = 1 },
},
stack = { pop = function() end, push = function() end },
}, Game2)
Screens.invalidate()
game:usePartyItem("SUN_STONE")
partyOptions.onChoose(nil, game.save.party[1])
return game, evolutionOptions
end
local game, evolution = startStone()
T.check(evolution.force, "SUN STONE evolution sets wForceEvolution")
T.eq(game.save.inventory.SUN_STONE, 1,
"SUN STONE remains until evolution succeeds")
local animation = EvolutionAnim.new({ data = { pokemon = data.pokemon } }, {
mon = game.save.party[1], entry = evolution and evolution.entry,
force = evolution and evolution.force,
})
T.check(not animation:cancelPressed({ wasPressed = function(_, key)
return key == "b"
end }), "B cannot cancel a forced stone evolution")
evolution.onDone({ canceled = true })
T.eq(game.save.inventory.SUN_STONE, 1,
"a canceled evolution does not consume SUN STONE")
game, evolution = startStone()
evolution.onDone({ evolved = { species = "SUNFLORA" } })
T.eq(game.save.inventory.SUN_STONE, nil,
"a completed evolution consumes SUN STONE")
Screens.invalidate()
T.finish("gen2 sun stone bug 1219")
+18 -6
View File
@@ -56,10 +56,24 @@ do
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")
check(m.aaa.enabledByVersion.red and m.aaa.enabledByVersion.gold,
"every row exposes its enabled answer for each game")
eq(m.aaa.status, "ok", "a healthy enabled mod is ok")
eq(m.aaa.statusDetail, "Ready", "ok detail reads Ready")
end
do
local manifests = {
mf({ id = "one", name = "One", version = "1.0.0", entry = "m.lua" }),
}
local row = byId(LauncherMods.deriveList(manifests, {
mods = { one = true }, modsByVersion = { gold = { one = false } },
}, "gold")).one
check(row.enabledByVersion.red, "the Red checkbox keeps the shared answer")
check(not row.enabledByVersion.gold, "the Gold checkbox reads Gold's answer")
check(not row.enabled, "the selected game's row state matches its checkbox")
end
-- ------- experimental defaults to disabled; github surfaces on the row
do
@@ -291,10 +305,9 @@ end
-- ------- enable flags: the panel reads exactly what the switch writes
--
-- One scope for both halves (SaveData.modScope). While per-game flags are a
-- preview the shared flag is the whole answer, so a modsByVersion overlay --
-- which an imported .g1rmodlist can plant, ModProfile.restoreVersions -- can
-- never leave the switch showing an answer no writer can reach.
-- One scope for both halves (SaveData.modScope). Per-game flags are live, so
-- a modsByVersion answer -- including one restored from a .g1rmodlist -- is
-- both what the launcher shows and what the next boot reads.
local SaveData = require("src.core.SaveData")
@@ -309,9 +322,8 @@ do
}
local planted = { mods = { one = true },
modsByVersion = { gold = { one = false } } }
local expected = SaveData.PER_VERSION_MODS and false or true
eq(byId(LauncherMods.deriveList(manifests, planted, "gold")).one.enabled,
expected, "the overlay is read exactly when a write can reach it")
false, "the overlay is read exactly when a write can reach it")
-- the round trip, the thing the dead switch failed: flip it, re-derive
local options = { mods = {} }
+26
View File
@@ -162,6 +162,32 @@ do
"modScope follows the PER_VERSION_MODS switch")
end
do
-- First launch after per-game controls shipped: preserve every old answer
-- and make the old implicit defaults explicit for the installed set.
local opts = {
mods = { old_on = true, old_off = false },
modsByVersion = { gold = { old_on = false } },
}
check(SaveData.migrateModEnablement(opts, {
{ id = "old_on" }, { id = "old_off" }, { id = "implicit" },
{ id = "lab", experimental = true },
}), "legacy mod state is migrated once")
check(opts.modsByVersionMigrated, "the migration is marked complete")
eq(SaveData.modEnabled(opts, "old_on", "red"), true,
"an old enabled mod is enabled for Red")
eq(SaveData.modEnabled(opts, "old_on", "gold"), false,
"an already-stored preview answer is preserved")
eq(SaveData.modEnabled(opts, "old_off", "blue"), false,
"an old disabled mod stays disabled for every game")
eq(SaveData.modEnabled(opts, "implicit", "yellow"), true,
"an old implicit default is enabled for every game")
eq(SaveData.modEnabled(opts, "lab", "red"), false,
"an experimental mod keeps its existing opt-in default")
check(not SaveData.migrateModEnablement(opts, { { id = "newer" } }),
"a later mod install does not rerun the legacy migration")
end
-- ------- a profile carries the per-game half of a setup
do
+5 -4
View File
@@ -65,7 +65,7 @@ T.check(pushed[1].text:find(BYE, 1, true) == nil,
pushed[1].onDone()
T.eq(#pushed, 1, "the farewell waits for the bow")
T.eq(nurse.facing, "up", "image index $14: the nurse bows")
T.eq(nurse.frameOverride, 3, "image index $1: the nurse bows")
T.check(fakeSelf.emote ~= nil, "the bow is a world hold, not a text pause")
local hold = fakeSelf.emote or {}
T.eq(hold.npc, nurse, "the hold is anchored on the nurse")
@@ -79,11 +79,12 @@ if hold.onDone then hold.onDone() end
T.eq(#pushed, 2, "the farewell follows the bow")
local farewell = pushed[2] or {}
T.eq(farewell.text, BYE, "second box is the farewell text")
T.eq(nurse.facing, "up", "she is still bowed while the farewell prints")
T.eq(nurse.frameOverride, nil, "the bow ends before the farewell prints")
T.eq(nurse.facing, "down", "the nurse faces the player for the farewell")
if farewell.onDone then farewell.onDone() end
T.eq(nurse.facing, "down", "the trailing UpdateSprites faces her back")
T.eq(faced, 1, "she is turned back exactly once")
T.eq(nurse.facing, "down", "the trailing UpdateSprites keeps her facing")
T.eq(faced, 2, "she is turned back before and after the farewell")
T.eq(finished, 1, "control returns to the player once, after the farewell")
-- === no nurse sprite (the Yellow/rest-stop callers): no bow, same text
+55
View File
@@ -0,0 +1,55 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local GenSave = require("src.save_convert.GenSave")
local romText = require("src.core.RomText")
local localized = {
text = {
_MoveIsDisabledText = "The move {RAM:wNameBuffer} from {USER} is disabled!",
},
}
T.eq(romText(localized, "_MoveIsDisabledText", "%s's %s is disabled!", {
USER = "PIKACHU", ["RAM:wNameBuffer"] = "THUNDER",
}), "The move THUNDER from PIKACHU is disabled!",
"named ROM tokens survive translation reordering")
GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")())
local data = {
pokemon = {}, moves = {}, items = {}, maps = {},
hiddenItems = loadfile("src/save_convert/data/hidden_items.lua")(),
}
local save = {
player = { name = "RED", id = 1, map = "PALLET_TOWN", x = 0, y = 0 },
rival = { name = "BLUE" }, party = {}, boxes = {}, inventory = {},
pcItems = {}, flags = {}, pokedex = { seen = {}, owned = {} },
hiddenTaken = {
VIRIDIAN_FOREST_1_18 = true,
VIRIDIAN_CITY_14_4 = true,
},
}
for i = 1, 12 do save.boxes[i] = {} end
local bytes = GenSave.encode(save, data, nil)
local decoded = GenSave.decode(bytes, data)
T.check(decoded.hiddenTaken.VIRIDIAN_FOREST_1_18,
"Viridian Forest hidden potion imports from wObtainedHiddenItemsFlags")
T.check(decoded.hiddenTaken.VIRIDIAN_CITY_14_4,
"Viridian City hidden potion imports from wObtainedHiddenItemsFlags")
T.check(not decoded.hiddenTaken.ROUTE_9_14_7,
"an uncollected hidden item remains available")
love = love or require("tests.love_stub")
local start = require("src.ui.StartMenu").new({
data = {}, save = {
flags = {}, party = {}, inventory = {}, options = {},
player = { name = "RED" }, pokedex = { owned = {} },
},
})
local pokemonRow
for _, row in ipairs(start.items) do
if row.label == "POKéMON" then pokemonRow = row break end
end
T.check(pokemonRow and pokemonRow.keepOpen,
"the empty-party POKéMON row leaves the start menu open")
T.finish("open menu bugs 949 and 1149")
+113
View File
@@ -58,6 +58,8 @@ local MATCHUPS = {
{ attacker = "NORMAL", defender = "ROCK", multiplier = 5 },
{ attacker = "NORMAL", defender = "STEEL", multiplier = 5 },
{ attacker = "ELECTRIC", defender = "GROUND", multiplier = 0 },
-- Sonic Boom is Normal; Gen 2 StaticDamage still respects Ghost immunity.
{ attacker = "NORMAL", defender = "GHOST", multiplier = 0 },
}
local MOVES = {
@@ -144,6 +146,15 @@ local POKEMON = {
levelMoves = { { level = 1, move = "TACKLE" } },
evolutions = {},
},
GASTLY = {
id = "GASTLY", index = 92, name = "GASTLY",
baseStats = { hp = 30, attack = 35, defense = 30, speed = 80,
specialAttack = 100, specialDefense = 35 },
types = { "GHOST", "POISON" }, catchRate = 190, baseExp = 62,
growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 127,
levelMoves = { { level = 1, move = "LICK" } },
evolutions = {},
},
}
local DATA = {
@@ -613,6 +624,21 @@ local battle, player, wild = newBattle()
check("battle picks the first healthy mon", battle.player, player)
check("wild battle flag", battle.wild, true)
-- Gen 2 battles expose BattleRandom (`random` / :roller()) and the Gen 1 /
-- love.math `rng` over the same stream.
check("battle.rng is present", type(battle.rng), "function")
check("battle.rng(lo,hi) respects bounds", battle.rng(10, 10), 10)
check("battle.rng(lo,hi) another fixed point", battle.rng(50, 50), 50)
do
local roll = battle.rng(0, 255)
check("battle.rng(0,255) is an integer", roll == math.floor(roll), true)
check("battle.rng(0,255) in range", roll >= 0 and roll <= 255, true)
end
-- zeroRandom always returns 0, so the love adapter maps:
-- rng(n) → 0+1 = 1; rng(lo,hi) → lo + 0 = lo
check("battle.rng(n) is 1..n over BattleRandom", battle.rng(7), 1)
check("battle.rng(0,99) uses both args", battle.rng(0, 99), 0)
-- A move spends PP and deals damage.
local before = wild.hp
battle:takeTurn({ kind = "move", move = "TACKLE" })
@@ -910,6 +936,16 @@ local EFFECT_MOVES = {
SEISMIC_TOSS = { id = "SEISMIC_TOSS", name = "SEISMICTOSS", power = 1,
type = "FIGHTING", accuracy = 100, pp = 20,
effect = "EFFECT_LEVEL_DAMAGE" },
-- EFFECT_STATIC_DAMAGE: Sonic Boom (20) and Dragon Rage (40).
SONICBOOM = { id = "SONICBOOM", name = "SONICBOOM", power = 20,
type = "NORMAL", accuracy = 90, pp = 20,
effect = "EFFECT_STATIC_DAMAGE" },
DRAGON_RAGE = { id = "DRAGON_RAGE", name = "DRAGON RAGE", power = 40,
type = "DRAGON", accuracy = 100, pp = 10,
effect = "EFFECT_STATIC_DAMAGE" },
MAGNITUDE = { id = "MAGNITUDE", name = "MAGNITUDE", power = 1,
type = "GROUND", accuracy = 100, pp = 30,
effect = "EFFECT_MAGNITUDE" },
LOCK_ON = { id = "LOCK_ON", name = "LOCK-ON", power = 0, type = "NORMAL",
accuracy = 100, accuracyRaw = 0xff, pp = 5, effect = "EFFECT_LOCK_ON" },
-- data/moves/moves.asm:169, :172, :151.
@@ -1078,6 +1114,83 @@ tossBattle:takeTurn({ kind = "move", move = "SEISMIC_TOSS" })
check("seismic toss deals the level",
tossBefore - tossWild.hp, tossPlayer.level)
-- EFFECT_STATIC_DAMAGE: Sonic Boom (20) and Dragon Rage (40).
-- Cart: constantdamage + resettypematchup (effects.asm StaticDamage).
do
check("static damage uses move power",
Effects.fixedDamage("EFFECT_STATIC_DAMAGE", { level = 10 }, { hp = 50 }, nil,
40), 40)
check("sonic boom fixed damage is 20",
Effects.fixedDamage("EFFECT_STATIC_DAMAGE", { level = 10 }, { hp = 50 }, nil,
20), 20)
local boomBattle, _, boomWild = effectBattle({ "SONICBOOM" })
boomWild.hp = 100
boomWild.maxHp = 100
local boomBefore = boomWild.hp
boomBattle:takeTurn({ kind = "move", move = "SONICBOOM" })
check("sonic boom deals flat 20", boomBefore - boomWild.hp, 20)
-- Normal vs Ghost is 0x; StaticDamage's resettypematchup misses.
local ghostBattle, _, ghostWild = effectBattle({ "SONICBOOM" })
ghostWild.species = "GASTLY"
local ghostBefore = ghostWild.hp
ghostBattle:takeTurn({ kind = "move", move = "SONICBOOM" })
check("sonic boom misses Ghost in Gen 2", ghostWild.hp, ghostBefore)
local rageBattle, _, rageWild = effectBattle({ "DRAGON_RAGE" })
rageWild.hp = 100
rageWild.maxHp = 100
local rageBefore = rageWild.hp
rageBattle:takeTurn({ kind = "move", move = "DRAGON_RAGE" })
check("dragon rage deals flat 40", rageBefore - rageWild.hp, 40)
end
-- Magnitude: BattleRandom walks magnitude_power.asm. Nil random must not
-- collapse to roll 0 (always Magnitude 4); Battle.new always supplies a roller.
do
local p4, n4 = Effects.magnitudePower(function() return 0 end)
check("magnitude roll 0 is power 10", p4, 10)
check("magnitude roll 0 is number 4", n4, 4)
local p8, n8 = Effects.magnitudePower(function() return 200 end)
check("magnitude roll 200 is power 90", p8, 90)
check("magnitude roll 200 is number 8", n8, 8)
local seen = {}
for roll = 0, 255 do
local _, number = Effects.magnitudePower(function() return roll end)
seen[number] = true
end
for want = 4, 10 do
check(("magnitude table reaches %d"):format(want), seen[want] == true, true)
end
-- Engine path: inject a mid-table roll and confirm the announce + damage.
local magBattle, magPlayer, magWild = newBattle({
random = function(n)
-- Pin the getmagnitude byte: return 200 whenever n == 256.
if n == 256 then return 200 end
return 0
end,
})
magPlayer.moves = { { id = "MAGNITUDE", pp = 30, maxPp = 30 } }
magWild.hp = 200
magWild.maxHp = 200
local magEvents = magBattle:takeTurn({ kind = "move", move = "MAGNITUDE" })
local announced
for _, ev in ipairs(magEvents) do
if ev.kind == "message" and type(ev.text) == "string"
and ev.text:match("^Magnitude %d+") then
announced = ev.text
break
end
end
check("magnitude announces rolled number", announced, "Magnitude 8!")
check("magnitude deals more than power-1 would",
magWild.hp < 200, true)
end
-- --------------------------------------------------------------- held items
local Ai = require("src.battle.gen2.Ai")
+4 -2
View File
@@ -1017,8 +1017,10 @@ press(ms, "select")
check(avail[1].enabled == false, "SELECT quick-toggles the focused mod")
check(ms:isStaged(avail[1]), "a flip against boot state is staged")
check(ms:glyphFor(avail[1]) == ".", "staged mods show the staged glyph")
check(mgame.save.options.mods.badmod == false,
"the live options table mirrors the flip")
local managerScope = ms:enableScope()
check(managerScope and mgame.save.options.modsByVersion
and mgame.save.options.modsByVersion[managerScope].badmod == false,
"the live options table mirrors the flip for this game")
check(ms.restartPending, "staged changes arm the apply screen")
ms:discardChanges()
check(avail[1].enabled == true and not ms.restartPending,
+3 -3
View File
@@ -60,14 +60,14 @@ for _, r in ipairs(rows) do
end
check(not hasRecord, "CHAMPIONS_ROOM rival script no longer calls record_hall_of_fame")
-- The post-battle walk takes the right-hand detour before heading north, so
-- The post-battle walk takes the left-hand detour before heading north, so
-- the player does not visibly pass through the rival at (4,2).
local route = {}
for _, r in ipairs(rows) do
if r[1] == "move_player" then route[#route + 1] = r end
end
eq(route[#route - 1] and route[#route - 1][2], "right",
"walk-out route first moves right around the rival")
eq(route[#route - 1] and route[#route - 1][2], "left",
"walk-out route first moves left around the rival")
eq(route[#route] and route[#route][2], "up",
"walk-out route then heads north to Hall of Fame")
+3 -3
View File
@@ -203,13 +203,13 @@ do
eq(tb.waitFrames, 60, "for the 60 frames AnimationBlinkEnemyMon takes")
end
-- the OPTIONS animation toggle still gates the whole thing; the sound does not
-- engine/battle/animations.asm PlayApplyingAttackAnimation
do
local tb = freshBattle()
Game.save.options.animations = false
tb:applyHitFx({ animType = 5, sfx = "Damage" })
eq(tb.fx.shakeProg, nil, "animations off arms no shake")
eq(tb.fx.blink, nil, "and no blink")
check(tb.fx.shakeProg ~= nil, "animations off keeps the hit shake")
eq(tb.fx.blink, nil, "type 5 remains a shake, not a blink")
Game.save.options.animations = true
end
+93
View File
@@ -0,0 +1,93 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity Yellow disabled Pikachu")
local check, eq = S.check, S.eq
local Data = require("src.core.Data")
Data:load()
local GameVersion = require("src.core.GameVersion")
local Follower = require("src.world.PikachuFollower")
local ItemEffects = require("src.inventory.ItemEffects")
local PartyMenu = require("src.ui.PartyMenu")
local BoxMenu = require("src.ui.BoxMenu")
local TextBox = require("src.render.TextBox")
local oldVersion = GameVersion.get()
GameVersion.set("yellow")
local save = {
player = { id = 7, name = "RED" },
party = {
{ species = "PIKACHU", otId = 7, ot = "RED", hp = 20 },
{ species = "PIDGEY", otId = 7, ot = "RED", hp = 20 },
},
}
check(Follower.isStarterPikachu(save, save.party[1]), "the player Pikachu is identified")
check(not Follower.isStarterPikachu(save, save.party[2]), "other party members are not starter Pikachu")
local pika = { pikachuFollower = true, cellX = 3, cellY = 4, facing = "down" }
local seel = { def = { name = "POKEMONFANCLUB_SEEL" }, cellX = 1, cellY = 4 }
local moves = {}
local ow = {
map = { id = "POKEMON_FAN_CLUB" },
player = { cellX = 3, cellY = 5, facing = "up" },
npcs = { pika, seel },
scriptMove = function(_, npc, dir, tiles, done)
moves[#moves + 1] = { dir, tiles }
npc.facing = dir
if done then done() end
end,
}
Follower.onFanClubEntered({ save = save, data = Data }, ow)
check(ow.pikachuFanClubScene, "Fan Club disables normal Pikachu following")
check(ow.pikachuMapScriptActive, "Fan Club sets the map-script flag")
eq(ow.player.facing, "down", "Fan Club resets the player direction")
eq(moves[1] and moves[1][1], "up", "Fan Club starts with slide-up displacement")
eq(moves[1] and moves[1][2], 1, "Fan Club slide-up spans one tile")
eq(moves[2] and moves[2][1], "right", "Fan Club then walks right")
eq(moves[2] and moves[2][2], 3, "Fan Club walks right three tiles")
eq(moves[3] and moves[3][1], "up", "Fan Club ends walking up")
eq(moves[3] and moves[3][2], 1, "Fan Club final up spans one tile")
eq(seel.movementStatus, 2, "Fan Club puts Seel into movement delay")
eq(seel.facing, "down", "Fan Club turns Seel down")
check(Follower.isFollowingDisabled(ow), "disabled Fan Club follower blocks starter selection")
local sleepOw = {
map = { id = "PEWTER_POKECENTER" },
player = { cellX = 3, cellY = 5 },
npcs = { pika },
pikachuPewterSleepScene = true,
}
local result = ItemEffects.use(Data, save, "POKE_FLUTE", nil, nil, nil, sleepOw)
eq(result, "flute_wake_pikachu", "Poké Flute is allowed next to sleeping Pikachu")
check(Follower.isFollowingDisabled(sleepOw), "sleeping Pikachu disables normal follower actions")
local pushed = {}
local partyGame = {
save = save,
overworld = sleepOw,
stack = { push = function(_, state) pushed[#pushed + 1] = state end },
input = { wasPressed = function(_, key) return key == "a" end },
}
PartyMenu.new(partyGame):update()
check(getmetatable(pushed[#pushed]) == TextBox,
"sleeping Pikachu cannot be selected from the party menu")
local boxGame = {
save = save,
overworld = sleepOw,
data = { pokemon = { PIKACHU = { name = "PIKACHU" }, PIDGEY = { name = "PIDGEY" } }, text = {} },
stack = { push = function(_, state) pushed[#pushed + 1] = state end },
}
local pc = BoxMenu.new(boxGame)
pc.items[2].onSelect()
local depositList = pushed[#pushed]
depositList.onChoose(depositList.items[1], depositList)
check(getmetatable(pushed[#pushed]) == TextBox,
"sleeping Pikachu cannot be deposited into Bill's PC")
eq(#save.party, 2, "PC refusal leaves the party unchanged")
GameVersion.set(oldVersion)
S.finish()
+6 -7
View File
@@ -891,8 +891,7 @@ do
check(tb:lockedAction(tb.enemy) == nil, "victim is free after the release")
end
-- #2: recoil and drain use the RAW computed damage, not the HP-capped
-- amount dealt
-- engine/battle/core.asm ApplyDamageToEnemyPokemon
do
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
local rb = BattleState.newWild(Game, "RATTATA", 3)
@@ -903,8 +902,8 @@ do
rb.rng = mkseq({ 0, 255, 255 })
local hpBefore = rb.player.mon.hp
rb:performMove(rb.player, rb.enemy, { id = "TAKE_DOWN", pp = 10 })
eq(hpBefore - rb.player.mon.hp, math.floor(raw / 4),
"recoil is raw damage / 4 even when only 1 HP was dealt")
eq(hpBefore - rb.player.mon.hp, 1,
"recoil is capped damage / 4 with a minimum of 1")
local db = BattleState.newWild(Game, "RATTATA", 3)
db.enemy.mon.hp = 1
@@ -914,9 +913,9 @@ do
check(rawD >= 4, "raw MEGA DRAIN damage is meaningful (" .. rawD .. ")")
db.rng = mkseq({ 0, 255, 255 })
db:performMove(db.player, db.enemy, { id = "MEGA_DRAIN", pp = 10 })
eq(db.player.mon.hp - 1, math.floor(rawD / 2),
"drain heals raw damage / 2 even when only 1 HP was dealt")
eq(db.lastDamage, math.floor(rawD / 2),
eq(db.player.mon.hp - 1, 1,
"drain heals capped damage / 2 with a minimum of 1")
eq(db.lastDamage, 1,
"drain halves wDamage in place (Counter would see the half)")
end