Merge pull request #1572 from bryanthaboi/dev

bugs and bug reporting
This commit is contained in:
bryanthaboi
2026-08-19 14:03:47 -04:00
committed by GitHub
57 changed files with 1937 additions and 262 deletions
+1
View File
@@ -0,0 +1 @@
* @bryanthaboi
+1 -1
View File
@@ -53,7 +53,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
### Watch the latest update video ### Watch the latest update video
[![Watch the latest update video](https://img.youtube.com/vi/8IOgqbe4YvA/maxresdefault.jpg)](https://www.youtube.com/watch?v=8IOgqbe4YvA) [![Watch the latest update video](https://img.youtube.com/vi/yi7LkWQPKKM/maxresdefault.jpg)](https://youtu.be/yi7LkWQPKKM)
This project does not include a ROM, emulate the Game Boy, transpile assembly, This project does not include a ROM, emulate the Game Boy, transpile assembly,
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+19
View File
@@ -5,8 +5,27 @@
-- voucher exchange and the BICYCLE/CANCEL price window need more than -- voucher exchange and the BICYCLE/CANCEL price window need more than
-- command rows (#568). -- command rows (#568).
local TextBox = require("src.render.TextBox")
-- data/events/hidden_events.asm:542
local BIKE_DISPLAYS = {
{ 1, 0 }, { 2, 1 }, { 1, 2 }, { 3, 2 }, { 0, 4 }, { 1, 5 },
}
return { return {
BIKE_SHOP = { BIKE_SHOP = {
-- engine/events/hidden_events/new_bike.asm:1
onInteract = function(game, ow, fx, fy)
for _, c in ipairs(BIKE_DISPLAYS) do
if c[1] == fx and c[2] == fy then
game.stack:push(TextBox.new(game,
(game.data.text or {})._NewBicycleText or "A shiny new\nBICYCLE!"))
return true
end
end
return false
end,
talk = { talk = {
-- BikeShopMiddleAgedWomanText (pokered/scripts/BikeShop.asm): -- BikeShopMiddleAgedWomanText (pokered/scripts/BikeShop.asm):
-- always shows the same flavor line, no branching. -- always shows the same flavor line, no branching.
+1
View File
@@ -168,6 +168,7 @@ return {
{ "jump_if_true", "come_see" }, { "jump_if_true", "come_see" },
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, { "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "give_item", "POKE_BALL", 5, false }, { "give_item", "POKE_BALL", 5, false },
{ "text_sound", "Get_Key_Item" }, -- OaksLab.asm:1060
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, { "show_text", "_OaksLabOak1ReceivedPokeballsText" },
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, { "show_text", "_OaksLabGivePokeballsExplanationText" },
{ "jump", "end" }, { "jump", "end" },
+11 -1
View File
@@ -516,7 +516,17 @@ M.ROUTE_24 = {
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText, push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
done) done)
else else
ow:engageTrainer(npc, done) -- scripts/Route24.asm:125
ow:engageTrainer(npc, function()
if ow:trainerDefeated(npc) then
-- scripts/Route24.asm:62
push(game,
text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
done)
else
done()
end
end, text(game)._Route24CooltrainerM1DefeatedText, true)
end end
end end
if not flags.EVENT_GOT_NUGGET then if not flags.EVENT_GOT_NUGGET then
+5 -4
View File
@@ -7,9 +7,9 @@ local M = {}
local function text(game) return game.data.text end local function text(game) return game.data.text end
local function push(game, s, done) local function push(game, s, done, opts)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, done)) game.stack:push(TextBox.new(game, s, done, opts))
end end
-- PrintText on a text_end string returns with the box still drawn and -- PrintText on a text_end string returns with the box still drawn and
@@ -236,7 +236,6 @@ M.CINNABAR_GYM = {
if yes == machine.yes then if yes == machine.yes then
-- CinnabarGymQuizCorrectText: item jingle, then the gate -- CinnabarGymQuizCorrectText: item jingle, then the gate
-- slides open (SFX_GO_INSIDE) if it was still locked -- slides open (SFX_GO_INSIDE) if it was still locked
Sound.play(game.data, "Get_Item1")
push(game, t._CinnabarGymQuizCorrectText push(game, t._CinnabarGymQuizCorrectText
or "You're absolutely\ncorrect!\fGo on through!", function() or "You're absolutely\ncorrect!\fGo on through!", function()
if not game.save.flags[gymGateFlag(index)] then if not game.save.flags[gymGateFlag(index)] then
@@ -244,7 +243,9 @@ M.CINNABAR_GYM = {
Sound.play(game.data, "Go_Inside") Sound.play(game.data, "Go_Inside")
end end
applyGymGates(game, ow) applyGymGates(game, ow)
end) end, { preSound = function()
return Sound.play(game.data, "Get_Item1")
end })
return return
end end
Sound.play(game.data, "Denied") Sound.play(game.data, "Denied")
+6 -6
View File
@@ -17,9 +17,9 @@ local function surfingPikachu(game)
return nil return nil
end end
local function push(game, text, done) local function push(game, text, done, opts)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, text, done)) game.stack:push(TextBox.new(game, text, done, opts))
end end
-- the two-variant posters: the surf-capable line once a surfing -- the two-variant posters: the surf-capable line once a surfing
@@ -69,11 +69,11 @@ return {
TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done) TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done)
local t = game.data.text local t = game.data.text
-- scripts/SummerBeachHouse.asm:68
push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!", push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!",
function() done, { auto = { wait = true, delay = 0, sound = function()
require("src.core.Sound").playCry(game.data, "PIKACHU") return require("src.core.Sound").playCry(game.data, "PIKACHU")
done() end } })
end)
end, end,
TEXT_SUMMERBEACHHOUSE_POSTER1 = poster(1), TEXT_SUMMERBEACHHOUSE_POSTER1 = poster(1),
+21
View File
@@ -6,6 +6,27 @@
// Registered from a constructor so no LÖVE/SDL source needs to know about it. // Registered from a constructor so no LÖVE/SDL source needs to know about it.
#import <UIKit/UIKit.h> #import <UIKit/UIKit.h>
#import <sys/utsname.h>
@interface GRDeviceBridge : NSObject
+ (NSString *)deviceModel;
@end
@implementation GRDeviceBridge
+ (NSString *)deviceModel
{
#if TARGET_OS_SIMULATOR
NSString *simulatorModel = NSProcessInfo.processInfo.environment[@"SIMULATOR_MODEL_IDENTIFIER"];
if (simulatorModel.length > 0) return simulatorModel;
#endif
struct utsname systemInfo;
if (uname(&systemInfo) == 0) {
NSString *model = [NSString stringWithUTF8String:systemInfo.machine];
if (model.length > 0) return model;
}
return @"";
}
@end
__attribute__((constructor)) __attribute__((constructor))
static void GRBootstrapInstall(void) static void GRBootstrapInstall(void)
+29
View File
@@ -156,6 +156,7 @@ int w_syncHealthSteps(lua_State *L)
""" % MARKER """ % MARKER
WRAP_REGISTRATION = """#ifdef LOVE_IOS WRAP_REGISTRATION = """#ifdef LOVE_IOS
{ "getDeviceModel", w_getDeviceModel },
{ "pickFile", w_pickFile }, { "pickFile", w_pickFile },
{ "pickFileKinds", w_pickFileKinds }, { "pickFileKinds", w_pickFileKinds },
{ "createFile", w_createFile }, { "createFile", w_createFile },
@@ -202,6 +203,7 @@ int w_syncHealthSteps(lua_State *L)
""" """
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
{ "getDeviceModel", w_getDeviceModel },
{ "syncHealthSteps", w_syncHealthSteps }, { "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload }, { "httpDownload", w_httpDownload },
{ "httpRequest", w_httpRequest }, { "httpRequest", w_httpRequest },
@@ -210,6 +212,33 @@ WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
BRIDGE_EXTRA_FUNCS = """ BRIDGE_EXTRA_FUNCS = """
#ifdef LOVE_IOS #ifdef LOVE_IOS
int w_getDeviceModel(lua_State *L)
{
Class cls = objc_getClass("GRDeviceBridge");
if (cls == nullptr)
{
lua_pushnil(L);
return 1;
}
typedef id (*GRObj)(Class, SEL);
id value = ((GRObj)objc_msgSend)(cls, sel_registerName("deviceModel"));
if (value == nullptr)
{
lua_pushnil(L);
return 1;
}
typedef const char *(*GRUTF8)(id, SEL);
const char *bytes = ((GRUTF8)objc_msgSend)(value,
sel_registerName("UTF8String"));
if (bytes == nullptr || bytes[0] == '\\0')
{
lua_pushnil(L);
return 1;
}
lua_pushstring(L, bytes);
return 1;
}
int w_httpDownload(lua_State *L) int w_httpDownload(lua_State *L)
{ {
const char *url = luaL_checkstring(L, 1); const char *url = luaL_checkstring(L, 1);

Before

Width:  |  Height:  |  Size: 318 B

After

Width:  |  Height:  |  Size: 318 B

Before

Width:  |  Height:  |  Size: 687 B

After

Width:  |  Height:  |  Size: 687 B

@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats.
- Answers written to `mod.save` via `intro.oak_speech.answered`.
- Custom `toast_kid.png` sprite shown mid-speech.
+43
View File
@@ -0,0 +1,43 @@
# Silly Oak Intro Example
Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival,
player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`.
## Try it (play through yourself)
```sh
rm -rf mods/example_silly_oak
cp -r mods/examples/example_silly_oak mods/
love .
```
Then **NEW GAME** and mash A / pick the menus. Disable or delete
`mods/example_silly_oak` when you're done so vanilla boots clean.
## Headless check
```sh
luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
```
## Auto driver (screenshots + save asserts)
```sh
rm -rf mods/example_silly_oak
cp -r mods/examples/example_silly_oak mods/
SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \
POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love .
```
`POKEPORT_IDENTITY` keeps this run's save out of your normal slot.
## What it demonstrates
| Seam | Where |
|---|---|
| `hooks:wrap("intro.oak_speech.build")` | `main.lua` -- reshape the step list |
| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` -- anchored on vanilla step ids |
| step kinds `say` / `yesno` / `choice` | `main.lua` |
| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` |
| `events:on("intro.oak_speech.answered")` | `main.lua``mod.save` |
| `events:on("intro.oak_speech.finished")` | `main.lua` |
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

+107
View File
@@ -0,0 +1,107 @@
-- Gallery entry: reshape Oak's intro speech with extra questions, sprite
-- swaps (oak / rival / player / pokemon / a custom image), and answers
-- that land in mod.save.
--
-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered.
return function(mod)
local toastPic = mod.path .. "/assets/toast_kid.png"
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
steps = next(steps, speech)
-- after oak says hello, immediately derail
mod.ui.insertStepAfter(steps, "oak_welcome", {
id = "silly_quiz_intro",
kind = "say",
pic = "oak",
text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.",
})
mod.ui.insertStepAfter(steps, "silly_quiz_intro", {
id = "silly_toast",
kind = "yesno",
pic = "oak",
saveKey = "likes_toast",
text = "Do you like\ntoast?",
})
-- brand new sprite mid-speech
mod.ui.insertStepAfter(steps, "silly_toast", {
id = "silly_toast_kid",
kind = "say",
pic = { type = "image", path = toastPic },
reveal = "fade",
saveKey = nil,
text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.",
})
-- existing mon with a wipe + cry, parked after the real demo mon
mod.ui.insertStepAfter(steps, "demo_mon", {
id = "silly_mew",
kind = "say",
pic = { type = "pokemon", id = "MEW" },
reveal = "wipe",
cry = "MEW",
text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.",
})
mod.ui.insertStepAfter(steps, "silly_mew", {
id = "silly_snack",
kind = "choice",
pic = "oak",
saveKey = "snack",
text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.",
choices = { "BERRIES", "LEFTOVERS", "OLD ROD" },
})
-- swap to rival pic for a loaded question before naming him
mod.ui.insertStepBefore(steps, "ask_rival_name", {
id = "silly_trust",
kind = "choice",
pic = "rival",
reveal = "fade",
saveKey = "trusts_rival",
text = "Look at this kid.\nTrustworthy?",
choices = { "SURE", "NO" },
values = { true, false },
})
-- player pic for one last bit after both names are set
mod.ui.insertStepAfter(steps, "name_rival", {
id = "silly_pineapple",
kind = "yesno",
pic = "player",
saveKey = "pineapple_on_pizza",
text = "{PLAYER}. Be honest.\nPineapple on\vpizza?",
})
mod.ui.insertStepAfter(steps, "silly_pineapple", {
id = "silly_closing",
kind = "say",
pic = "oak",
text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.",
})
return steps
end)
-- every answered step with a saveKey lands in mod.save (and therefore
-- save.modData[mod.id] once the slot is written)
mod.events:on("intro.oak_speech.answered", function(ev)
if not ev.saveKey then return end
mod.save:set(ev.saveKey, ev.value)
mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value))
end)
mod.events:on("intro.oak_speech.finished", function(ev)
local answers = ev.answers or {}
for key, value in pairs(answers) do
if mod.save:get(key) == nil then
mod.save:set(key, value)
end
end
mod.save:set("quiz_done", true)
mod.log:info("silly oak quiz done")
end)
end
@@ -0,0 +1,15 @@
{
"id": "example_silly_oak",
"name": "Silly Oak Intro Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "UI",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save."
}
+21
View File
@@ -0,0 +1,21 @@
-- Sharing metadata for the manager detail pane.
return {
summary = "Oak asks dumb questions during the intro and remembers your answers.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "intro", "ui", "oak", "hooks" },
differences = {
changed = {
"Oak's NEW GAME speech gains extra questions and sprite beats",
},
added = {
"mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done",
"Custom Toast Kid pic mid-intro",
},
known = { "vanilla naming and the shrink-away still run" },
},
credits = {
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" },
},
compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,159 @@
-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors,
-- and answers landing in mod.save.
--
-- Needs an imported ROM dataset (data/generated/). Headless CI and a
-- fresh checkout without a ROM skip cleanly -- the gallery is also
-- covered by tests/mod_examples_tests.lua when generated data is present.
package.path = "./?.lua;./?/init.lua;" .. package.path
local function hasGenerated()
local handle = io.open("data/generated/constants.lua", "r")
if handle then handle:close() return true end
return false
end
if not hasGenerated() then
print("example_silly_oak_test skipped (needs data/generated/)")
os.exit(0)
end
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local OakSpeech = require("src.ui.OakSpeech")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
local mod = run.mod
T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state")
local ModUI = require("src.ui.ModUI")
local bucket = function()
return run.loader.modSave.example_silly_oak or {}
end
local toastPath = (mod.path or "mods/examples/example_silly_oak")
.. "/assets/toast_kid.png"
-- ------- build hook injects every silly beat around vanilla anchors
local speech = OakSpeech.new({
data = Data,
save = { player = { name = "RED", rival = "BLUE" } },
stack = { push = function() end, pop = function() end },
}, nil)
local steps = speech:buildSteps()
local ids = {}
for _, step in ipairs(steps) do ids[#ids + 1] = step.id end
local function has(id)
for _, x in ipairs(ids) do if x == id then return true end end
return false
end
T.check(has("oak_welcome") and has("name_player") and has("shrink"),
"vanilla anchors still present")
T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"),
"toast quiz beats injected")
T.check(has("silly_mew") and has("silly_snack"),
"MEW reveal and snack choice injected")
T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"),
"rival trust + pineapple beats injected")
-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask
local function indexOf(id)
for i, x in ipairs(ids) do if x == id then return i end end
return 0
end
T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"),
"Toast Kid shows before the demo mon")
T.check(indexOf("demo_mon") < indexOf("silly_mew"),
"MEW shows after the demo mon")
T.check(indexOf("silly_trust") < indexOf("ask_rival_name"),
"trust question is before rival naming")
T.check(indexOf("name_rival") < indexOf("silly_pineapple")
and indexOf("silly_pineapple") < indexOf("legend"),
"pineapple lands between rival name and the legend beat")
-- ------- step shapes cover choice / yesno / custom image / pokemon
local byId = {}
for _, step in ipairs(steps) do byId[step.id] = step end
T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no")
T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast")
T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice")
T.eq(#byId.silly_snack.choices, 3, "snack has three options")
T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image",
"Toast Kid uses a custom image pic")
T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon"
and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW",
"MEW beat uses pokemon pic + cry")
T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic")
-- ------- resolvePic covers trainer / pokemon / player / image shorthand
local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech)
local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech)
local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech)
local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data },
{ type = "pokemon", id = "MEW", flip = true }, speech)
local customImg = OakSpeech.resolvePic({ data = Data },
{ type = "image", path = toastPath }, speech)
-- headless love stub may return nil images; the call itself must not throw
T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata"
or type(oakImg) == "table",
"oak shorthand resolves without error")
T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata"
or type(rivalImg) == "table",
"rival shorthand resolves without error")
T.check(playerImg == speech.playerPic or playerImg == nil
or type(playerImg) == "userdata" or type(playerImg) == "table",
"player shorthand resolves without error")
T.check(mewFlip == true, "pokemon flip flag is honored")
T.check(customImg ~= nil or true, "custom image path is accepted")
-- ------- answered event writes mod.save (loader.modSave bucket)
Runtime.emit("intro.oak_speech.answered", {
saveKey = "likes_toast", value = true, label = "YES", index = 1,
step = byId.silly_toast, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3,
step = byId.silly_snack, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "trusts_rival", value = false, label = "NO", index = 2,
step = byId.silly_trust, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1,
step = byId.silly_pineapple, speech = speech,
})
Runtime.emit("intro.oak_speech.finished", {
speech = speech, answers = speech.answers,
})
local saved = bucket()
T.eq(saved.likes_toast, true, "likes_toast saved")
T.eq(saved.snack, "OLD ROD", "snack saved")
T.eq(saved.trusts_rival, false, "trusts_rival saved")
T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved")
T.eq(saved.quiz_done, true, "quiz_done stamped on finish")
-- ------- ModUI step helpers (public surface)
local tiny = {
{ id = "a", kind = "say" },
{ id = "b", kind = "say" },
}
ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" })
T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor")
ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" })
T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor")
ModUI.removeStep(tiny, "mid")
T.check(tiny[2].id ~= "mid", "removeStep drops by id")
run.release()
T.finish("example_silly_oak")
+126 -53
View File
@@ -1008,9 +1008,23 @@ end
-- flickers the OBJ palette, DoBallTossSpecialEffects) -- flickers the OBJ palette, DoBallTossSpecialEffects)
function BattleState:animNext(name, isPlayer, shakes, ball) function BattleState:animNext(name, isPlayer, shakes, ball)
self.nextInsert = (self.nextInsert or 0) + 1 self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, local row = { anim = name, attackerIsPlayer = isPlayer, shakes = shakes,
{ anim = name, attackerIsPlayer = isPlayer, shakes = shakes, ball = ball }
ball = ball }) table.insert(self.queue, self.nextInsert, row)
return row
end
-- an animation row ahead of the move's own, with PlayBattleAnimation2's
-- applying-animation shake (engine/battle/effects.asm:1461-1471)
function BattleState:animBeforeMove(name, isPlayer)
local at
for i, item in ipairs(self.queue) do
if item == self.moveAnimRow then at = i break end
end
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, at or self.nextInsert,
{ anim = name, attackerIsPlayer = isPlayer, animDelayed = true,
hit = { animType = isPlayer and 6 or 3 } })
end end
-- insert an act right after the current queue item -- insert an act right after the current queue item
@@ -1554,8 +1568,13 @@ end
function BattleState:sendOutText(name) function BattleState:sendOutText(name)
local e = self.enemy and self.enemy.mon local e = self.enemy and self.enemy.mon
local pct = 100 local pct = 100
if e and e.hp > 0 and math.floor(e.stats.hp / 4) > 0 then if e and e.hp > 0 then
pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4)) -- the same routine stamps wLastSwitchInEnemyMonHP
-- (engine/battle/common_text.asm:105-110)
self.lastSwitchInEnemyHP = e.hp
if math.floor(e.stats.hp / 4) > 0 then
pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4))
end
end end
if pct >= 70 then return Strings("Go! %s!", name) end if pct >= 70 then return Strings("Go! %s!", name) end
if pct >= 40 then return Strings("Do it! %s!", name) end if pct >= 40 then return Strings("Do it! %s!", name) end
@@ -1563,6 +1582,27 @@ function BattleState:sendOutText(name)
return self:romText("_EnemysWeakText", "The enemy's weak!\nGet'm! %s!", name) return self:romText("_EnemysWeakText", "The enemy's weak!\nGet'm! %s!", name)
end end
-- RetreatMon / PlayerMon2Text (engine/battle/common_text.asm:167-243): the
-- adjective reads the enemy HP lost since this mon switched in
function BattleState:withdrawText(name)
local e = self.enemy and self.enemy.mon
local drop = 0
if e and self.lastSwitchInEnemyHP and math.floor(e.stats.hp / 4) > 0 then
drop = math.floor((self.lastSwitchInEnemyHP - e.hp) * 25
/ math.floor(e.stats.hp / 4))
end
local word = ""
if drop <= 0 then
word = self:romText("_EnoughText", "enough!")
elseif drop >= 70 then
word = self:romText("_GoodText", "good!")
elseif drop >= 30 then
word = self:romText("_OKExclamationText", "OK!")
end
return self:romText("_PlayerMon2Text", "%s ", name) .. word
.. self:romText("_ComeBackText", "\nCome back!")
end
-- The cry a mon makes as it takes the field. Yellow does not run its -- The cry a mon makes as it takes the field. Yellow does not run its
-- starter Pikachu through PlayCry at all: SendOutMon branches to -- starter Pikachu through PlayCry at all: SendOutMon branches to
-- .starterPikachu (engine/battle/core.asm:1807-1817) and voices PCM -- .starterPikachu (engine/battle/core.asm:1807-1817) and voices PCM
@@ -1816,7 +1856,8 @@ function BattleState:enter()
self.enemySendingOut = true self.enemySendingOut = true
self:slidePic("foe") self:slidePic("foe")
end) end)
self:say(Strings("%s sent\nout %s!", foeName, self.enemy.name)) -- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
self:sayAuto(Strings("%s sent\nout %s!", foeName, self.enemy.name))
self:act(function() self:act(function()
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the -- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
-- pic grows out of the ball (AnimateSendingOutMon), then the cry -- pic grows out of the ball (AnimateSendingOutMon), then the cry
@@ -1845,7 +1886,8 @@ function BattleState:enter()
self.sendingOut = true self.sendingOut = true
self:slidePic("back") self:slidePic("back")
end) end)
self:say(self:sendOutText(self.player.name)) -- _GoText.._PlayerMon1Text carry no prompt (data/text/text_2.asm:1274-1294)
self:sayAuto(self:sendOutText(self.player.name))
-- then the POOF plays and the mon appears with its cry -- then the POOF plays and the mon appears with its cry
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry) -- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
self:queueSendOutAnim(true) self:queueSendOutAnim(true)
@@ -2659,22 +2701,28 @@ function BattleState:resolveSwitch(newMon)
self.phase = "messages" self.phase = "messages"
self.afterQueue = "menu" self.afterQueue = "menu"
self:act(function() self:act(function()
self:restoreMimicked(self.player) -- the battle copy leaves with it -- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
local previous = self.player -- outgoing pic and holds 50 frames before the mon is recalled
self.player = makeBattler(self.data, newMon, true, self.game.save) self:sayNextAuto(self:withdrawText(self.player.name),
-- SendOutMon (core.asm:1761-1762): player's send-out clears the Timing.SWITCH_PLAYER_MON)
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch self:actNext(function()
clearTrapping(self.enemy) self:restoreMimicked(self.player) -- the battle copy leaves with it
self:syncSides() local previous = self.player
Runtime.emit("battle.battler_switched", { self.player = makeBattler(self.data, newMon, true, self.game.save)
battle = self, side = self.sides[1], battler = self.player, -- SendOutMon (core.asm:1761-1762): player's send-out clears the
previous = previous, -- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
}) clearTrapping(self.enemy)
self:markParticipant() self:syncSides()
sendOutMonCursors(self) Runtime.emit("battle.battler_switched", {
self.sendingOut = true battle = self, side = self.sides[1], battler = self.player,
self:sayNext(self:sendOutText(self.player.name)) previous = previous,
self:queueSendOutAnim(false) })
self:markParticipant()
sendOutMonCursors(self)
self.sendingOut = true
self:sayNextAuto(self:sendOutText(self.player.name))
self:queueSendOutAnim(false)
end)
end) end)
self:act(function() self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction()) self:executeAction(self.enemy, self.player, self:enemyAction())
@@ -2703,7 +2751,12 @@ function BattleState:residualFor(b, opp)
if b.residualDone then return end if b.residualDone then return end
b.residualDone = true b.residualDone = true
local msgs = Status.residual(b, opp, self) local msgs = Status.residual(b, opp, self)
local rec = Status.recordFor(self.data and self.data.statuses, b.mon.status)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
-- engine/battle/core.asm:490-493
if rec and rec.residual then
self:animNext("BURN_PSN_ANIM", b.isPlayer)
end
if b.leechSeeded and b.mon.hp > 0 then if b.leechSeeded and b.mon.hp > 0 then
-- the drain plays the ABSORB animation from the healing side -- the drain plays the ABSORB animation from the healing side
-- (core.asm:506-517 flips hWhoseTurn before PlayMoveAnimation) -- (core.asm:506-517 flips hWhoseTurn before PlayMoveAnimation)
@@ -3587,7 +3640,17 @@ function BattleState:executeAction(user, target, action)
markSeen(self.game, self.enemy.mon.species) markSeen(self.game, self.enemy.mon.species)
-- _AIBattleWithdrawText: "X with-/drew Y!" -- _AIBattleWithdrawText: "X with-/drew Y!"
self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName)) self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName))
self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name)) -- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
self.enemySendingOut = true
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
self:actNext(function()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
self:actNext(function()
self:waitSfxNext(self:playEntranceCry(self.enemy))
end)
end)
return return
end end
@@ -4277,6 +4340,9 @@ function BattleState:enemyMonFainted()
self:act(function() self:act(function()
local previous = self.enemy local previous = self.enemy
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false) self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
-- EnemySendOutFirstMon (core.asm:1359-1363): the fresh foe's HP is the
-- new wLastSwitchInEnemyMonHP baseline RetreatMon measures from
self.lastSwitchInEnemyHP = self.enemy.mon.hp
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player) clearTrapping(self.player)
self:syncSides() self:syncSides()
@@ -4296,7 +4362,7 @@ function BattleState:enemyMonFainted()
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation -- (AnimateSendingOutMon) with the cry; no POOF -- that animation
-- belongs to the player-side SendOutMon (core.asm:1757-1762) -- belongs to the player-side SendOutMon (core.asm:1757-1762)
self.enemySendingOut = true self.enemySendingOut = true
self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name)) self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
self:actNext(function() self:actNext(function()
self.enemySendingOut = false self.enemySendingOut = false
self:startGrowIn(self.enemy) self:startGrowIn(self.enemy)
@@ -4309,34 +4375,41 @@ function BattleState:enemyMonFainted()
self:act(function() self:act(function()
local mon = shiftSwitchMon local mon = shiftSwitchMon
if not mon then return end if not mon then return end
local previous = self.player -- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
self.player = makeBattler(self.data, mon, true, self.game.save) -- hold, then the recall and the send-out
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
battler = self.player, previous = previous,
})
-- Taking the SHIFT offer ZEROES wPartyGainExpFlags and
-- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon
-- (EnemySendOutFirstMon tail, core.asm:1436-1443), and SwitchPlayerMon
-- then FLAG_SETs only the mon coming in (core.asm:2424-2433). Without
-- the reset the mon that was out when the enemy fainted -- marked by
-- the send-out act above, which mirrors EnemySendOut's own re-flag
-- (core.asm:1276-1289) -- stayed a participant, so the exp divisor in
-- enemyMonFainted counted two mons and the switch-in earned half the
-- next KO (#275). Voluntary switches (resolveSwitch) and post-faint
-- replacements (openReplacementMenu) must NOT do this: pokered's
-- party-menu SwitchPlayerMon keeps the outgoing mon flagged, which is
-- the deliberate exp-share, and a fainted mon is already dropped by
-- onFaint mirroring RemoveFaintedPlayerMon (core.asm:1002-1007).
self.participants = {}
self:markParticipant()
self.nextInsert = 0 self.nextInsert = 0
sendOutMonCursors(self) self:sayNextAuto(self:withdrawText(self.player.name),
self.sendingOut = true Timing.SWITCH_PLAYER_MON)
self:sayNext(self:sendOutText(self.player.name)) self:actNext(function()
self:queueSendOutAnim(false) local previous = self.player
self.player = makeBattler(self.data, mon, true, self.game.save)
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
battler = self.player, previous = previous,
})
-- Taking the SHIFT offer ZEROES wPartyGainExpFlags and
-- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon
-- (EnemySendOutFirstMon tail, core.asm:1436-1443), and SwitchPlayerMon
-- then FLAG_SETs only the mon coming in (core.asm:2424-2433). Without
-- the reset the mon that was out when the enemy fainted -- marked by
-- the send-out act above, which mirrors EnemySendOut's own re-flag
-- (core.asm:1276-1289) -- stayed a participant, so the exp divisor in
-- enemyMonFainted counted two mons and the switch-in earned half the
-- next KO (#275). Voluntary switches (resolveSwitch) and post-faint
-- replacements (openReplacementMenu) must NOT do this: pokered's
-- party-menu SwitchPlayerMon keeps the outgoing mon flagged, which is
-- the deliberate exp-share, and a fainted mon is already dropped by
-- onFaint mirroring RemoveFaintedPlayerMon (core.asm:1002-1007).
self.participants = {}
self:markParticipant()
self.nextInsert = 0
sendOutMonCursors(self)
self.sendingOut = true
self:sayNextAuto(self:sendOutText(self.player.name))
self:queueSendOutAnim(false)
end)
end) end)
return return
end end
@@ -4526,7 +4599,7 @@ function BattleState:openReplacementMenu()
self.nextInsert = 0 self.nextInsert = 0
sendOutMonCursors(self) sendOutMonCursors(self)
self.sendingOut = true self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name)) self:sayNextAuto(self:sendOutText(self.player.name))
self:queueSendOutAnim(false) self:queueSendOutAnim(false)
end, end,
}) })
+20 -1
View File
@@ -92,6 +92,20 @@ local function hitCount(ctx, record)
return dist[r + 1] return dist[r + 1]
end end
-- engine/battle/effects.asm:119-151 (poison), :194-255 (burn/freeze/paralyze)
local FBP_SIDE_STATUS = { BRN = true, FRZ = true, PAR = true }
local function secondaryStatusFx(battle, user, status)
if status == "PSN" then
local row = battle:animNext(user.isPlayer and "ENEMY_HUD_SHAKE_ANIM"
or "SHAKE_SCREEN_ANIM", user.isPlayer)
row.animDelayed = true
row.hit = { animType = user.isPlayer and 6 or 3 }
elseif FBP_SIDE_STATUS[status] and user.isPlayer then
battle:animNext("ENEMY_HUD_SHAKE_ANIM", true).animDelayed = true
end
end
-- The damaging pipeline, extracted from the performMove monolith: every -- The damaging pipeline, extracted from the performMove monolith: every
-- stage keeps the original's exact check order and rng consumption -- stage keeps the original's exact check order and rng consumption
-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy -> -- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy ->
@@ -318,7 +332,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- secondary side effects (blocked by fainting) -- secondary side effects (blocked by fainting)
if record and record.run and record.kind ~= "primary" if record and record.run and record.kind ~= "primary"
and target.mon.hp > 0 and totalDealt > 0 then and target.mon.hp > 0 and totalDealt > 0 then
for _, m in ipairs(record.run(ctx)) do local hadStatus = target.mon.status
local msgs = record.run(ctx)
if target.mon.status and target.mon.status ~= hadStatus then
secondaryStatusFx(battle, user, target.mon.status)
end
for _, m in ipairs(msgs) do
battle:sayNext(m) battle:sayNext(m)
end end
end end
+9
View File
@@ -581,6 +581,15 @@ MoveEffects.full = {
end, end,
}, },
THRASH_PETAL_DANCE_EFFECT = { THRASH_PETAL_DANCE_EFFECT = {
-- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage
-- (data/battle/special_effects.asm:22) and animates the setup turn
beforeAccuracy = function(ctx)
local user = ctx.user
if not user.thrashTurns then
ctx.battle:animBeforeMove(
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
end
end,
afterDamage = function(ctx) afterDamage = function(ctx)
local user = ctx.user local user = ctx.user
if not user.thrashTurns then if not user.thrashTurns then
+86 -13
View File
@@ -6,6 +6,49 @@ local IssueReport = {}
local FORM_URL = "https://github.com/bryanthaboi/gen1recomp/issues/new" local FORM_URL = "https://github.com/bryanthaboi/gen1recomp/issues/new"
local TEMPLATE = "bug_report.yml" local TEMPLATE = "bug_report.yml"
local APPLE_MODELS = {
["iPhone14,2"] = "iPhone 13 Pro",
["iPhone14,3"] = "iPhone 13 Pro Max",
["iPhone14,4"] = "iPhone 13 mini",
["iPhone14,5"] = "iPhone 13",
["iPhone14,7"] = "iPhone 14",
["iPhone14,8"] = "iPhone 14 Plus",
["iPhone15,2"] = "iPhone 14 Pro",
["iPhone15,3"] = "iPhone 14 Pro Max",
["iPhone15,4"] = "iPhone 15",
["iPhone15,5"] = "iPhone 15 Plus",
["iPhone16,1"] = "iPhone 15 Pro",
["iPhone16,2"] = "iPhone 15 Pro Max",
["iPhone17,1"] = "iPhone 16 Pro",
["iPhone17,2"] = "iPhone 16 Pro Max",
["iPhone17,3"] = "iPhone 16",
["iPhone17,4"] = "iPhone 16 Plus",
["iPhone17,5"] = "iPhone 16e",
["Mac14,2"] = "MacBook Air (13-inch, M2)",
["Mac14,3"] = "Mac mini (M2)",
["Mac14,5"] = "MacBook Pro (14-inch, M2 Max)",
["Mac14,6"] = "MacBook Pro (16-inch, M2 Max)",
["Mac14,7"] = "MacBook Pro (13-inch, M2)",
["Mac14,9"] = "MacBook Pro (14-inch, M2 Pro)",
["Mac14,10"] = "MacBook Pro (16-inch, M2 Pro)",
["Mac14,12"] = "Mac mini (M2 Pro)",
["Mac14,13"] = "Mac Studio (M2 Max)",
["Mac14,14"] = "Mac Studio (M2 Ultra)",
["Mac14,15"] = "MacBook Air (15-inch, M2)",
["Mac15,3"] = "MacBook Pro (14-inch, M3)",
["Mac15,6"] = "MacBook Pro (14-inch, M3 Pro)",
["Mac15,7"] = "MacBook Pro (16-inch, M3 Pro)",
["Mac15,12"] = "MacBook Air (13-inch, M3)",
["Mac15,13"] = "MacBook Air (15-inch, M3)",
["Mac16,1"] = "MacBook Pro (14-inch, M4)",
["Mac16,5"] = "MacBook Pro (16-inch, M4 Max)",
["Mac16,6"] = "MacBook Pro (14-inch, M4 Max)",
["Mac16,7"] = "MacBook Pro (16-inch, M4 Pro)",
["Mac16,8"] = "MacBook Pro (14-inch, M4 Pro)",
["Mac16,10"] = "Mac mini (M4)",
["Mac16,11"] = "Mac mini (M4 Pro)",
}
local function clean(value) local function clean(value)
if value == nil then return nil end if value == nil then return nil end
local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "") local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "")
@@ -36,6 +79,16 @@ local function commandValue(command)
return clean(value) return clean(value)
end end
local function commandText(command)
if not io or type(io.popen) ~= "function" then return nil end
local ok, pipe = pcall(io.popen, command, "r")
if not ok or not pipe then return nil end
local readOK, value = pcall(pipe.read, pipe, "*a")
pcall(pipe.close, pipe)
if not readOK then return nil end
return clean(value)
end
local function percentEncode(value) local function percentEncode(value)
local text = tostring(value or "") local text = tostring(value or "")
return (text:gsub("([^%w%-_%.~])", function(char) return (text:gsub("([^%w%-_%.~])", function(char)
@@ -66,6 +119,25 @@ local function loveVersion()
return result return result
end end
local function friendlyModel(identifier)
identifier = clean(identifier)
if not identifier then return nil end
return APPLE_MODELS[identifier] or identifier
end
local function macModel()
local details = commandText("system_profiler SPHardwareDataType 2>/dev/null")
if details then
local name = clean(details:match("Model Name:%s*([^\r\n]+)"))
local chip = clean(details:match("Chip:%s*([^\r\n]+)"))
if name and chip and not name:find(chip, 1, true) then
return name .. " (" .. chip .. ")"
end
if name then return name end
end
return friendlyModel(commandValue("sysctl -n hw.model 2>/dev/null"))
end
local function appVersion() local function appVersion()
local version = clean(Version.engine) local version = clean(Version.engine)
if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end
@@ -73,20 +145,25 @@ local function appVersion()
end end
local function deviceModel(rawOS, system) local function deviceModel(rawOS, system)
local model = clean(call(system.getModel)) local nativeModel = clean(call(system.getDeviceModel))
if model then return model end if nativeModel then return friendlyModel(nativeModel) end
if rawOS == "OS X" or rawOS == "macOS" then if rawOS == "OS X" or rawOS == "macOS" then
return commandValue("sysctl -n hw.model 2>/dev/null") return macModel()
end
local model = clean(call(system.getModel))
if model and not model:lower():find("gpu", 1, true)
and not model:lower():find("renderer", 1, true) then
return friendlyModel(model)
end end
if rawOS == "Windows" then if rawOS == "Windows" then
return commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL") return friendlyModel(commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL"))
end end
if rawOS == "Linux" then if rawOS == "Linux" then
return commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null") return friendlyModel(commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null") or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null"))
end end
if rawOS == "Android" then if rawOS == "Android" then
return commandValue("getprop ro.product.model 2>/dev/null") return friendlyModel(commandValue("getprop ro.product.model 2>/dev/null"))
end end
return nil return nil
end end
@@ -121,7 +198,7 @@ local function metadata(options, context)
local window = love and love.window or {} local window = love and love.window or {}
local rawOS = clean(call(system.getOS)) local rawOS = clean(call(system.getOS))
local model = deviceModel(rawOS, system) local model = deviceModel(rawOS, system)
local renderer, rendererVersion, _, rendererDevice = call(graphics.getRendererInfo) local renderer, rendererVersion = call(graphics.getRendererInfo)
local width, height = call(graphics.getDimensions) local width, height = call(graphics.getDimensions)
local pixelWidth, pixelHeight = call(graphics.getPixelDimensions) local pixelWidth, pixelHeight = call(graphics.getPixelDimensions)
local modeWidth, modeHeight, flags = call(window.getMode) local modeWidth, modeHeight, flags = call(window.getMode)
@@ -134,11 +211,7 @@ local function metadata(options, context)
if value then lines[#lines + 1] = "- " .. label .. ": " .. value end if value then lines[#lines + 1] = "- " .. label .. ": " .. value end
end end
add("Platform", formOS(rawOS)) add("Platform", formOS(rawOS))
local hardware = model add("Device", model)
if rendererDevice and rendererDevice ~= model then
hardware = hardware and (hardware .. " (" .. rendererDevice .. ")") or rendererDevice
end
add("Device", hardware)
local rendererDetails = clean(renderer) local rendererDetails = clean(renderer)
if rendererDetails and clean(rendererVersion) then if rendererDetails and clean(rendererVersion) then
rendererDetails = rendererDetails .. " " .. clean(rendererVersion) rendererDetails = rendererDetails .. " " .. clean(rendererVersion)
-27
View File
@@ -541,29 +541,6 @@ local function modRows(opts, mod)
return rows return rows
end end
local function troubleshootingRows(opts, hooks)
return {
{
label = Strings("SAFE MODE"),
actionLabel = function()
return SaveData.isSafeMode(opts) and Strings("Turn off") or Strings("Turn on")
end,
action = function()
SaveData.setSafeMode(opts, not SaveData.isSafeMode(opts))
return true
end,
},
{
label = Strings("REPORT ISSUE"),
actionLabel = Strings("Report bug"),
action = function()
if hooks and hooks.reportIssue then hooks.reportIssue(opts) end
return false
end,
},
}
end
-- ------- Gen 2 (Gold) -- ------- Gen 2 (Gold)
-- --
-- Gold reads NONE of the rows above. Its OPTION screen writes a different -- Gold reads NONE of the rows above. Its OPTION screen writes a different
@@ -744,10 +721,6 @@ function LauncherSettings.open(hooks, version)
sections[#sections + 1] = { title = mod.name, rows = rows } sections[#sections + 1] = { title = mod.name, rows = rows }
end end
end end
sections[#sections + 1] = {
title = Strings("TROUBLESHOOTING"),
rows = troubleshootingRows(opts, hooks),
}
return { return {
opts = opts, opts = opts,
version = version, version = version,
+131 -38
View File
@@ -406,6 +406,15 @@ local function cartQuad(project, x, y, w, h, z)
} }
end end
local function cartFacing(points)
local area = 0
for i = 1, #points do
local a, b = points[i], points[i % #points + 1]
area = area + a[1] * b[2] - b[1] * a[2]
end
return area > 0
end
local function cartPill(project, x, y, w, h, z, color, alpha) local function cartPill(project, x, y, w, h, z, color, alpha)
local points, radius = {}, h / 2 local points, radius = {}, h / 2
for i = 0, 10 do for i = 0, 10 do
@@ -628,51 +637,64 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
local side = { math.floor(shell[1] * 0.54), math.floor(shell[2] * 0.54), local side = { math.floor(shell[1] * 0.54), math.floor(shell[2] * 0.54),
math.floor(shell[3] * 0.54) } math.floor(shell[3] * 0.54) }
cartPolygon(mainBack, side, 1) local frontFacing = cartFacing(mainFront)
cartPolygon(capBack, side, 1) if frontFacing then
cartPolygon(mainBack, side, 1)
cartPolygon(capBack, side, 1)
else
cartPolygon(mainFront, shell, 1)
cartPolygon(capFront, shell, 1)
end
cartPolygon({ mainFront[2], mainFront[3], mainBack[3], mainBack[2] }, 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[3], mainFront[4], mainBack[4], mainBack[3] }, side, 1)
cartPolygon({ mainFront[1], mainFront[2], mainBack[2], mainBack[1] }, 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[2], capFront[3], capBack[3], capBack[2] }, side, 1)
cartPolygon({ capFront[1], capFront[2], capBack[2], capBack[1] }, 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({ capFront[4], capFront[1], capBack[1], capBack[4] }, side, 1)
cartPolygon(mainFront, shell, 1) if frontFacing then
cartPolygon(capFront, shell, 1) cartPolygon(mainFront, shell, 1)
cartPolygon(capFront, shell, 1)
local faceZ = depth + 0.8 else
for i = 0, 4 do cartPolygon(mainBack, side, 1)
local ry = mainTop + 7 + i * h * 0.025 cartPolygon(capBack, side, 1)
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 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 if frontFacing then
local labelW, labelH = w * 0.66, h * 0.55 local faceZ = depth + 0.8
local plate = cartQuad(project, labelX - 2, labelY - 2, labelW + 4, labelH + 4, faceZ + 0.8) for i = 0, 4 do
cartPolygon(plate, side, 0.95) local ry = mainTop + 7 + i * h * 0.025
local labelPoints = cartQuad(project, labelX, labelY, labelW, labelH, faceZ + 1.2) cartPolygon(cartQuad(project, -halfW + 2, ry, w * 0.13, 2, faceZ), side, 0.7)
local label = cartridgeLabel(imp, version) cartPolygon(cartQuad(project, halfW - w * 0.13 - 2, ry, w * 0.13, 2, faceZ), side, 0.7)
local mesh = label and cartLabelMesh(imp, version, label, labelPoints) end
if mesh then local recessX, recessY = -w * 0.32, mainTop + h * 0.023
love.graphics.setColor(1, 1, 1, 1) local recessW, recessH = w * 0.64, h * 0.24
love.graphics.draw(mesh) cartPolygon(cartQuad(project, recessX, recessY, recessW, recessH, faceZ), shell, 0.88)
elseif label then cartPill(project, recessX + w * 0.025, recessY + h * 0.025,
local artScale = math.min(labelW / label.width, labelH / label.height) recessW - w * 0.05, h * 0.12, faceZ + 0.5, shell, 0.7)
love.graphics.draw(label.image, labelPoints[1][1], labelPoints[1][2], cartPill(project, recessX + w * 0.045, recessY + h * 0.043,
0, artScale, artScale) 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)
end 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)
love.graphics.pop() love.graphics.pop()
if not state.active and (Kit._activateId == key) then if not state.active and (Kit._activateId == key) then
@@ -926,10 +948,13 @@ local HEADER_TABS = {
{ id = "mods", key = "tab-mods" }, { id = "mods", key = "tab-mods" },
{ id = "find", key = "tab-find" }, { id = "find", key = "tab-find" },
{ id = "skins", key = "tab-skins", glyph = true }, { id = "skins", key = "tab-skins", glyph = true },
{ id = "bug", key = "tab-bug" },
} }
for _, t in ipairs(HEADER_TABS) do for _, t in ipairs(HEADER_TABS) do
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter } t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
if t.glyph then t.opts.drawFn = drawSkinGlyph end if t.glyph then
t.opts.drawFn = drawSkinGlyph
end
end end
-- Which cartridge the dropdown is showing: the open game tab, else the last -- Which cartridge the dropdown is showing: the open game tab, else the last
@@ -1067,6 +1092,8 @@ local function buildHeader(imp, m)
or love.graphics.newImage("assets/launcher/mods.png") or love.graphics.newImage("assets/launcher/mods.png")
imp._findIcon = imp._findIcon imp._findIcon = imp._findIcon
or love.graphics.newImage("assets/launcher/find.png") or love.graphics.newImage("assets/launcher/find.png")
imp._bugIcon = imp._bugIcon
or love.graphics.newImage("assets/launcher/bug.png")
-- Game tabs keep their cartridge colours -- that is the one piece of brand -- Game tabs keep their cartridge colours -- that is the one piece of brand
-- identity in the launcher, and "the red one" is how people actually refer -- identity in the launcher, and "the red one" is how people actually refer
-- to these. The colour rides the outline and the glyph at rest and becomes -- to these. The colour rides the outline and the glyph at rest and becomes
@@ -1077,6 +1104,7 @@ local function buildHeader(imp, m)
for _, t in ipairs(tabs) do for _, t in ipairs(tabs) do
if t.id == "mods" then t.icon = imp._modsIcon end if t.id == "mods" then t.icon = imp._modsIcon end
if t.id == "find" then t.icon = imp._findIcon end if t.id == "find" then t.icon = imp._findIcon end
if t.id == "bug" then t.icon = imp._bugIcon end
end end
local tabH = m.chip local tabH = m.chip
local tx = m.x + m.pad local tx = m.x + m.pad
@@ -1923,7 +1951,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
-- notice line -- notice line
local noticeText, noticeCol local noticeText, noticeCol
if safeMode then if safeMode then
noticeText, noticeCol = "Safe mode is on. All mods are disabled. Turn it off in Settings to change mod toggles.", PAL.yellow noticeText, noticeCol = "Safe mode is on. All mods are disabled. Turn it off in the Bug tab to change mod toggles.", PAL.yellow
elseif imp.modNotice then elseif imp.modNotice then
noticeText = imp.modNotice.text noticeText = imp.modNotice.text
noticeCol = imp.modNotice.ok and PAL.green or PAL.red noticeCol = imp.modNotice.ok and PAL.green or PAL.red
@@ -2322,6 +2350,69 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
return cy + hintH - y return cy + hintH - y
end end
local function buildBugPanel(imp, x, y, w, availH, m)
local SaveData = require("src.core.SaveData")
local gap = m.gap
local pad = math.floor(16 * m.s)
local cy = y
local safeMode = imp:_safeModeEnabled()
Kit.text("button", Strings("Troubleshooting"), x, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + gap
if imp.issueNotice then
cy = cy + Kit.textWrapped("small", imp.issueNotice.text, x, cy, w,
imp.issueNotice.ok and PAL.green or PAL.red, 2) + gap
end
local switchW = math.floor(92 * m.s)
local switchH = math.max(m.btnH, Kit.tapMin())
local detail = safeMode
and Strings("All mods are disabled and their toggles are locked until safe mode is turned off.")
or Strings("Temporarily disable every mod while you reproduce a bug.")
local textW = math.max(0, w - 2 * pad - switchW - gap)
local detailH = Kit.wrapHeight("small", detail, textW, 3)
local safeH = math.max(switchH, Kit.textHeight("small") + math.floor(4 * m.s) + detailH)
+ 2 * pad
Kit.card(x, cy, w, safeH)
local textX = x + pad
local textY = cy + pad
Kit.text("small", Strings("Safe mode"), textX, textY, PAL.heading)
Kit.textWrapped("small", detail, textX,
textY + Kit.textHeight("small") + math.floor(4 * m.s), textW,
PAL.muted, 3)
local toggleX = x + w - pad - switchW
local toggleY = cy + math.floor((safeH - switchH) / 2)
local _, changed = Kit.toggle(toggleX, toggleY, switchW, switchH, safeMode,
"bug-safe-mode")
if changed then
queueAction(imp, "bug-safe-mode", function() imp:_toggleSafeMode() end)
end
cy = cy + safeH + gap
local reportLabel = Strings("Report a bug")
local reportW = math.min(w - 2 * pad,
Kit.textWidth("small", reportLabel) + math.floor(32 * m.s))
local reportDetail = Strings("Fill out the GitHub form with the available system information.")
local reportTextW = math.max(0, w - 2 * pad - reportW - gap)
local reportDetailH = Kit.wrapHeight("small", reportDetail, reportTextW, 3)
local reportH = math.max(m.btnH, Kit.textHeight("small") + math.floor(4 * m.s) + reportDetailH)
+ 2 * pad
Kit.card(x, cy, w, reportH)
Kit.text("small", Strings("Something not working?"), textX, cy + pad, PAL.heading)
Kit.textWrapped("small", reportDetail, textX,
cy + pad + Kit.textHeight("small") + math.floor(4 * m.s), reportTextW,
PAL.muted, 3)
btn(imp, x + w - pad - reportW,
cy + math.floor((reportH - m.btnH) / 2), reportW, m.btnH,
"bug-report", reportLabel, {
kind = "accent", font = "small",
action = function()
imp:_ensureMods()
imp:_reportIssue(SaveData.loadOptions(), nil)
end })
end
local function buildFindPanel(imp, x, y, w, availH, m) local function buildFindPanel(imp, x, y, w, availH, m)
imp:_ensureFind() imp:_ensureFind()
imp:_ensureMods() imp:_ensureMods()
@@ -4822,6 +4913,8 @@ function LauncherView.draw(imp)
contentH = buildFindPanel(imp, x, py, panelW, budgetH, m) contentH = buildFindPanel(imp, x, py, panelW, budgetH, m)
elseif imp.tab == "skins" then elseif imp.tab == "skins" then
contentH = buildSkinsPanel(imp, x, py, panelW, budgetH, m) contentH = buildSkinsPanel(imp, x, py, panelW, budgetH, m)
elseif imp.tab == "bug" then
contentH = buildBugPanel(imp, x, py, panelW, budgetH, m)
else else
contentH = buildGamePanel(imp, x, py, panelW, availH, m, imp.tab, budgetH) contentH = buildGamePanel(imp, x, py, panelW, availH, m, imp.tab, budgetH)
end end
+32 -20
View File
@@ -1266,6 +1266,7 @@ end
function RomImporter:_applyLastVersionTab() function RomImporter:_applyLastVersionTab()
local okLO, LO = pcall(require, "src.core.LaunchOptions") local okLO, LO = pcall(require, "src.core.LaunchOptions")
if okLO and LO.pendingTab then return end if okLO and LO.pendingTab then return end
if os.getenv("POKEPORT_LAUNCHER_TAB") then return end
local okOpt, opts = pcall(function() local okOpt, opts = pcall(function()
return require("src.core.SaveData").loadOptions() return require("src.core.SaveData").loadOptions()
end) end)
@@ -1337,7 +1338,7 @@ function RomImporter.new(onComplete, opts)
-- player at least arrives on the tab they asked for (src/core/LaunchOptions). -- player at least arrives on the tab they asked for (src/core/LaunchOptions).
tab = (function() tab = (function()
local okLO, LO = pcall(require, "src.core.LaunchOptions") local okLO, LO = pcall(require, "src.core.LaunchOptions")
return (okLO and LO.pendingTab) or "red" return (okLO and LO.pendingTab) or os.getenv("POKEPORT_LAUNCHER_TAB") or "red"
end)(), end)(),
logo = love.graphics.newImage("assets/logo/logo.png"), logo = love.graphics.newImage("assets/logo/logo.png"),
bcg = love.graphics.newImage("assets/logo/bcg.png"), bcg = love.graphics.newImage("assets/logo/bcg.png"),
@@ -1364,7 +1365,8 @@ function RomImporter.new(onComplete, opts)
-- in draw); modNotice is the last install/delete result { ok, text }. -- in draw); modNotice is the last install/delete result { ok, text }.
-- requiredImportNotice stays inside the imported-files modal so validation -- requiredImportNotice stays inside the imported-files modal so validation
-- failures are visible beside the file picker that caused them. -- failures are visible beside the file picker that caused them.
mods = nil, modScroll = 0, modNotice = nil, requiredImportNotice = nil, mods = nil, modScroll = 0, modNotice = nil, issueNotice = nil,
requiredImportNotice = nil,
-- Which game the MODS panel is answering for (a GameVersion id, nil = -- Which game the MODS panel is answering for (a GameVersion id, nil =
-- every game). Rows resolve their enable-state and their "runs here" -- every game). Rows resolve their enable-state and their "runs here"
-- verdict against it (src/mods/ModTargets.lua). -- verdict against it (src/mods/ModTargets.lua).
@@ -2755,7 +2757,7 @@ function RomImporter:resumeAfterOverlay()
end end
function RomImporter:_cycleTab(delta) function RomImporter:_cycleTab(delta)
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins" } local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins", "bug" }
local idx = 1 local idx = 1
for i, id in ipairs(order) do for i, id in ipairs(order) do
if id == self.tab then idx = i; break end if id == self.tab then idx = i; break end
@@ -3630,9 +3632,6 @@ function RomImporter:_openSettings()
-- option block, and Gold's is not the flat Gen 1 one (#1100). -- option block, and Gold's is not the flat Gen 1 one (#1100).
local hooks = {} local hooks = {}
local version = self.tab local version = self.tab
hooks.reportIssue = function(opts)
return self:_reportIssue(opts, version)
end
if self.onEditTouchControls then if self.onEditTouchControls then
local version = self.tab local version = self.tab
hooks.editTouchControls = function() hooks.editTouchControls = function()
@@ -3655,7 +3654,6 @@ function RomImporter:_openSettings()
end) end)
if ok and model then if ok and model then
self._settings = model self._settings = model
self._settingsSafeModeAtOpen = require("src.core.SaveData").isSafeMode(model.opts)
end end
end end
@@ -3670,22 +3668,36 @@ function RomImporter:_closeSettings()
local model = self._settings local model = self._settings
if model then if model then
model.save() model.save()
local safeMode = require("src.core.SaveData").isSafeMode(model.opts)
if safeMode ~= self._settingsSafeModeAtOpen then
self.mods = nil
self.safeMode = safeMode
self._modSortCache = nil
self._modInfoFetch = nil
end
end end
self._settings = nil self._settings = nil
self._settingsSafeModeAtOpen = nil end
function RomImporter:_safeModeEnabled()
if self.safeMode == nil then
local SaveData = require("src.core.SaveData")
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
end
return self.safeMode == true
end
function RomImporter:_toggleSafeMode()
local SaveData = require("src.core.SaveData")
local options = SaveData.loadOptions()
local enabled = not SaveData.isSafeMode(options)
SaveData.setSafeMode(options, enabled)
SaveData.saveOptions(options)
self.safeMode = enabled
self.mods = nil
self._modSortCache = nil
self._modInfoFetch = nil
self.modNotice = nil
end end
function RomImporter:_reportIssue(options, version) function RomImporter:_reportIssue(options, version)
self.issueNotice = nil
local ok, IssueReport = pcall(require, "src.core.IssueReport") local ok, IssueReport = pcall(require, "src.core.IssueReport")
if not ok then if not ok then
self.modNotice = { ok = false, text = "Could not prepare the issue report." } self.issueNotice = { ok = false, text = "Could not prepare the issue report." }
return false return false
end end
local opened, url, reason = IssueReport.open(options, { local opened, url, reason = IssueReport.open(options, {
@@ -3693,11 +3705,11 @@ function RomImporter:_reportIssue(options, version)
mods = self.mods, mods = self.mods,
}) })
if not opened then if not opened then
self.modNotice = { ok = false, text = reason or "Could not open the issue report." } self.issueNotice = { ok = false, text = reason or "Could not open the issue report." }
return false return false
end end
self._lastIssueReportURL = url self._lastIssueReportURL = url
if reason then self.modNotice = { ok = true, text = reason } end if reason then self.issueNotice = { ok = true, text = reason } end
return true return true
end end
@@ -4180,7 +4192,7 @@ end
-- Enabling an experimental mod arms a confirmation for that same game. -- Enabling an experimental mod arms a confirmation for that same game.
function RomImporter:_toggleMod(id, confirmed, version) function RomImporter:_toggleMod(id, confirmed, version)
if self.safeMode then if self.safeMode then
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." } self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." }
return return
end end
local LauncherMods = require("src.mods.LauncherMods") local LauncherMods = require("src.mods.LauncherMods")
@@ -4224,7 +4236,7 @@ end
-- recovery action, and Delete is the only destructive one on this panel. -- recovery action, and Delete is the only destructive one on this panel.
function RomImporter:_setAllMods(want, confirmed) function RomImporter:_setAllMods(want, confirmed)
if self.safeMode then if self.safeMode then
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." } self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." }
return return
end end
local LauncherMods = require("src.mods.LauncherMods") local LauncherMods = require("src.mods.LauncherMods")
+13
View File
@@ -57,6 +57,8 @@ function TextBox.new(game, text, onDone, opts)
self.money = opts and opts.money self.money = opts and opts.money
self.auto = opts and opts.auto self.auto = opts and opts.auto
self.stay = opts and opts.stay self.stay = opts and opts.stay
-- engine/events/hidden_events/cinnabar_gym_quiz.asm:119
self.preSound = opts and opts.preSound
-- opts.instant: put the LAST page up already typed, with no typewriter and -- opts.instant: put the LAST page up already typed, with no typewriter and
-- no page waits. A `yesorno` follows a `writetext` that has already been -- no page waits. A `yesorno` follows a `writetext` that has already been
-- read, so re-typing the line under the YES/NO box would be wrong -- the -- read, so re-typing the line under the YES/NO box would be wrong -- the
@@ -270,6 +272,17 @@ end
function TextBox:update(dt) function TextBox:update(dt)
local input = self.game.input local input = self.game.input
self.blink = (self.blink + 1) % 60 self.blink = (self.blink + 1) % 60
-- home/text.asm:506
if self.preSound then
if not self.preStarted then
self.preStarted = true
self.preSrc = self.preSound()
end
if self.preSrc and self.preSrc.isPlaying and self.preSrc:isPlaying() then
return
end
self.preSound, self.preSrc = nil, nil
end
-- A page or CONT advance blocks the whole box while the original's scroll -- A page or CONT advance blocks the whole box while the original's scroll
-- and clear run (src/core/Timing.lua TEXT_SCROLL_PAIR / TEXT_PAGE_CLEAR). -- and clear run (src/core/Timing.lua TEXT_SCROLL_PAIR / TEXT_PAGE_CLEAR).
-- Nothing types and no input is read until it drains. -- Nothing types and no input is read until it drains.
+2 -1
View File
@@ -254,7 +254,8 @@ function Commands.give_item(ctx, itemId, count, gotText)
Commands.show_text(ctx, gotText Commands.show_text(ctx, gotText
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer)) or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
else else
Sound.play(ctx.game.data, jingle) -- scripts/OaksLab.asm:1058
Commands.text_sound(ctx, jingle)
end end
end end
+24 -3
View File
@@ -537,6 +537,15 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
-- Route22Gate_Script rewrites wLastMap from the player's Y on entry -- Route22Gate_Script rewrites wLastMap from the player's Y on entry
-- too (not only on step), so a save/load mid-gate keeps exits correct -- too (not only on step), so a save/load mid-gate keeps exits correct
if not (opts and opts.checkpoint) then self:syncLastMapRewrite() end if not (opts and opts.checkpoint) then self:syncLastMapRewrite() end
-- home/overworld.asm:1821 (JoypadOverworld runs RunMapScript every frame);
-- scripts/Route16Gate1F.asm:16, Route5Gate.asm:19, Route22Gate.asm:21
if opts and opts.freshBoot and not opts.checkpoint then
local standing = mapScripts and mapScripts.get(mapId)
if not (standing and standing.onStep
and standing.onStep(Game, self, self.player.cellX, self.player.cellY)) then
self:checkBadgeGate()
end
end
end end
-- Neighbor maps drawn at the composed connection offsets: at least the -- Neighbor maps drawn at the composed connection offsets: at least the
@@ -2910,7 +2919,15 @@ function OverworldState:openPC(onDone)
keepOpen = true, keepOpen = true,
onSelect = function() onSelect = function()
require("src.core.Sound").play(Game.data, "Enter_PC") require("src.core.Sound").play(Game.data, "Enter_PC")
Screens.push(Game, "BoxMenu") -- engine/menus/pc.asm:73 BillsPC prints the access text before the farcall
local accessed = metBill
and romText(Game.data, "_AccessedBillsPCText",
"Accessed BILL's\nPC.\fAccessed POKéMON\nStorage System.")
or romText(Game.data, "_AccessedSomeonesPCText",
"Accessed someone's\nPC.\fAccessed POKéMON\nStorage System.")
Game.stack:push(TextBox.new(Game, accessed, function()
Screens.push(Game, "BoxMenu")
end))
done() done()
end, end,
}) })
@@ -2920,9 +2937,13 @@ function OverworldState:openPC(onDone)
label = (Game.save.player.name or "RED") .. "'s PC", label = (Game.save.player.name or "RED") .. "'s PC",
keepOpen = true, keepOpen = true,
onSelect = function() onSelect = function()
-- pc.asm .playersPC plays SFX_ENTER_PC before the farcall (#960) -- pc.asm .playersPC plays SFX_ENTER_PC then prints AccessedMyPCText
-- before the farcall (engine/menus/pc.asm:54, #960)
require("src.core.Sound").play(Game.data, "Enter_PC") require("src.core.Sound").play(Game.data, "Enter_PC")
Screens.push(Game, "PlayerPC") Game.stack:push(TextBox.new(Game,
romText(Game.data, "_AccessedMyPCText",
"Accessed my PC.\fAccessed Item\nStorage System."),
function() Screens.push(Game, "PlayerPC") end))
done() done()
end, end,
}) })
+4
View File
@@ -126,6 +126,10 @@ local PLAYER_STATE_BY_ID = {
[8] = FieldMoves.PLAYER_SURF_PIKA, [8] = FieldMoves.PLAYER_SURF_PIKA,
} }
-- engine/overworld/variables.asm:49 VAR_MOVEMENT reads wPlayerState back
local PLAYER_STATE_ID = {}
for id, state in pairs(PLAYER_STATE_BY_ID) do PLAYER_STATE_ID[state] = id end
local BATTLETYPE = { local BATTLETYPE = {
CANLOSE = 1, CANLOSE = 1,
FORCESHINY = 7, FORCESHINY = 7,
@@ -0,0 +1,50 @@
-- The bike shop's "A shiny new BICYCLE!" is six hidden_event rows, not a
-- bg_event: data/events/hidden_events.asm:542-549 points every one of them
-- at PrintNewBikeText (engine/events/hidden_events/new_bike.asm:1), which
-- tx_pre_jumps NewBicycleText with ANY_FACING and no gating. The port's
-- field extractor lifts none of that family, so BIKE_SHOP had no display
-- text at all (#1530).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local pushed
package.loaded["src.render.TextBox"] = {
new = function(_, text, onDone) return { text = text, onDone = onDone } end,
}
local scripts = require("data.scripts.flavor.bike_shop")
local onInteract = scripts.BIKE_SHOP.onInteract
T.check(type(onInteract) == "function", "BIKE_SHOP carries an onInteract hook")
local game = {
data = { text = { _NewBicycleText = "A shiny new\nBICYCLE!" } },
stack = { push = function(_, box) pushed = box end },
}
-- data/events/hidden_events.asm:543-548 (the macro emits y then x, so the
-- source pairs read x, y)
for _, cell in ipairs({ { 1, 0 }, { 2, 1 }, { 1, 2 }, { 3, 2 }, { 0, 4 }, { 1, 5 } }) do
pushed = nil
local consumed = onInteract(game, {}, cell[1], cell[2])
T.eq(consumed, true, ("(%d,%d) is a display tile"):format(cell[1], cell[2]))
T.check(pushed ~= nil and pushed.text == game.data.text._NewBicycleText,
("(%d,%d) prints _NewBicycleText"):format(cell[1], cell[2]))
end
-- data/generated/maps.lua BIKE_SHOP object_events sit at (6,2), (5,6) and
-- (1,3): none of the six, so the hook never steals an NPC's talk
for _, cell in ipairs({ { 4, 4 }, { 6, 2 }, { 5, 6 }, { 1, 3 }, { 0, 0 } }) do
pushed = nil
local consumed = onInteract(game, {}, cell[1], cell[2])
T.eq(consumed, false, ("(%d,%d) is not a display tile"):format(cell[1], cell[2]))
T.eq(pushed, nil, "and pushes nothing")
end
-- with no cache text the hook still prints the line
pushed = nil
onInteract({ data = {}, stack = game.stack }, {}, 1, 0)
T.check(pushed ~= nil and pushed.text:find("BICYCLE", 1, true) ~= nil,
"a dataset without the label falls back to the engine wording")
T.finish("bike shop display text (#1530)")
+25 -3
View File
@@ -146,12 +146,34 @@ T.eq(resumed, 1, "the script resumed once")
-- ------------------------------------------------------------- plain gift -- ------------------------------------------------------------- plain gift
-- gotText == false is the script-shows-its-own-text form (Oak's 5 POKE -- gotText == false is the script-shows-its-own-text form (Oak's 5 POKE
-- BALLs): nothing to hang the sound on, so it plays on the spot -- BALLs): the received text the script prints next carries the jingle
-- (scripts/OaksLab.asm:1058-1062), so give_item arms it and plays nothing
plays = {} plays = {}
Commands.give_item(ctx, "FIX_POTION", 1, false) Commands.give_item(ctx, "FIX_POTION", 1, false)
T.eq(jingles(), 1, "the no-text gift still plays its jingle immediately") T.eq(jingles(), 0, "the no-text gift plays nothing on the spot")
T.eq(plays[1], "item.wav", "with the plain-item sound")
T.eq(stack:top(), nil, "and pushes no box of its own") T.eq(stack:top(), nil, "and pushes no box of its own")
T.check(ctx.textOpts and ctx.textOpts.auto and ctx.textOpts.auto.sound ~= nil,
"the jingle is armed for the next show_text")
Commands.show_text(ctx, "{PLAYER} GOT\nSOMETHING!")
local box2 = stack:top()
T.check(getmetatable(box2) == TextBox, "the script's own received box is up")
T.eq(ctx.textOpts, nil, "show_text consumed the armed jingle")
for _ = 1, 2000 do
if box2.done then break end
step(box2.waiting and "a" or nil)
end
T.check(box2.done, "the received text typed out")
T.eq(jingles(), 0, "silent until the last character is placed")
step()
T.eq(jingles(), 1, "the jingle fires once the text is out")
T.eq(plays[#plays], "item.wav", "with the plain-item sound")
step("a")
T.eq(stack:top(), box2, "A does not close the box during the jingle")
sources["item.wav"].playing = false
step()
step("a")
T.eq(stack:top(), nil, "A closes the box after the jingle")
-- ------------------------------------------------------------ script data -- ------------------------------------------------------------ script data
-- both Viridian Mart paths must hand give_item the quest text, since that -- both Viridian Mart paths must hand give_item the quest text, since that
@@ -29,4 +29,12 @@ check(patch:find("int w_pickFileKinds", 1, true)
check(patch:find('("GRPickerBridge.swift", ID_FILE_PICKER', 1, true), check(patch:find('("GRPickerBridge.swift", ID_FILE_PICKER', 1, true),
"iOS build patch compiles the required-import picker bridge") "iOS build patch compiles the required-import picker bridge")
local bootstrap = read("mobile/ios/native/GRBootstrap.m")
check(bootstrap:find("struct utsname", 1, true)
and bootstrap:find("SIMULATOR_MODEL_IDENTIFIER", 1, true),
"iOS native bridge reads the hardware model on device and simulator")
check(patch:find("int w_getDeviceModel", 1, true)
and patch:find('{ "getDeviceModel", w_getDeviceModel }', 1, true),
"iOS liblove patch exposes the hardware model to Lua")
print("ios_required_import_picker_test: ok") print("ios_required_import_picker_test: ok")
+21
View File
@@ -50,20 +50,34 @@ local imp = read("src/import/RomImporter.lua")
check(view:find('id = "skins"', 1, true) ~= nil, check(view:find('id = "skins"', 1, true) ~= nil,
"LauncherView registers a skins tab") "LauncherView registers a skins tab")
check(view:find('id = "bug"', 1, true) ~= nil,
"LauncherView registers a bug tab")
check(view:find("drawSkinGlyph", 1, true) ~= nil, check(view:find("drawSkinGlyph", 1, true) ~= nil,
"the skins tab draws its own glyph rather than shipping an asset") "the skins tab draws its own glyph rather than shipping an asset")
check(view:find('assets/launcher/bug.png', 1, true) ~= nil,
"the bug tab uses the standard bug report asset")
-- the tab has to be next to Find, which is what the request was -- the tab has to be next to Find, which is what the request was
local order = view:match("local HEADER_TABS = %{(.-)%}\n") local order = view:match("local HEADER_TABS = %{(.-)%}\n")
check(order ~= nil, "HEADER_TABS found") check(order ~= nil, "HEADER_TABS found")
if order then if order then
local findAt = order:find('id = "find"', 1, true) local findAt = order:find('id = "find"', 1, true)
local skinsAt = order:find('id = "skins"', 1, true) local skinsAt = order:find('id = "skins"', 1, true)
local bugAt = order:find('id = "bug"', 1, true)
check(findAt and skinsAt and skinsAt > findAt, check(findAt and skinsAt and skinsAt > findAt,
"the skins tab sits immediately after Find") "the skins tab sits immediately after Find")
check(skinsAt and bugAt and bugAt > skinsAt,
"the bug tab sits after Skins")
end end
check(view:find('imp.tab == "skins"', 1, true) ~= nil, check(view:find('imp.tab == "skins"', 1, true) ~= nil,
"the panel dispatch routes the skins tab") "the panel dispatch routes the skins tab")
check(view:find("buildSkinsPanel", 1, true) ~= nil, "and a panel builds it") check(view:find("buildSkinsPanel", 1, true) ~= nil, "and a panel builds it")
check(view:find('imp.tab == "bug"', 1, true) ~= nil,
"the panel dispatch routes the bug tab")
check(view:find("buildBugPanel", 1, true) ~= nil, "and the bug panel builds it")
check(view:find('Kit.toggle', 1, true) ~= nil,
"the bug panel uses a switch for safe mode")
check(view:find('bug-report', 1, true) ~= nil,
"the bug panel has a report action")
-- the panel must not offer the studio when the host did not supply it -- the panel must not offer the studio when the host did not supply it
check(view:find("if imp.onOpenSkinStudio then", 1, true) ~= nil, check(view:find("if imp.onOpenSkinStudio then", 1, true) ~= nil,
"the Studio button is hidden without a host hook (mobile)") "the Studio button is hidden without a host hook (mobile)")
@@ -79,8 +93,15 @@ check(imp:find("_installMod", 1, true) ~= nil,
local cycle = imp:match("local order = %{(.-)%}") local cycle = imp:match("local order = %{(.-)%}")
check(cycle and cycle:find('"skins"', 1, true) ~= nil, check(cycle and cycle:find('"skins"', 1, true) ~= nil,
"shoulder-button tab cycling reaches the skins tab") "shoulder-button tab cycling reaches the skins tab")
check(cycle and cycle:find('"bug"', 1, true) ~= nil,
"shoulder-button tab cycling reaches the bug tab")
local switch = imp:match("function RomImporter:_switchTab%(id%)(.-)\nend") local switch = imp:match("function RomImporter:_switchTab%(id%)(.-)\nend")
check(switch and switch:find("_ensureSkins(true)", 1, true) ~= nil, check(switch and switch:find("_ensureSkins(true)", 1, true) ~= nil,
"switching to the tab re-reads the skin list") "switching to the tab re-reads the skin list")
check(imp:find('function RomImporter:_safeModeEnabled', 1, true) ~= nil
and imp:find('function RomImporter:_toggleSafeMode', 1, true) ~= nil,
"the importer owns the safe mode state")
check(imp:find('function RomImporter:_reportIssue', 1, true) ~= nil,
"the importer owns issue report opening")
T.finish("launcher_skins_tab") T.finish("launcher_skins_tab")
+100
View File
@@ -0,0 +1,100 @@
-- engine/menus/pc.asm prints the access text between SFX_ENTER_PC and the
-- farcall: BillsPC (:73-85) picks AccessedBillsPCText / AccessedSomeonesPCText
-- off EVENT_MET_BILL, .playersPC (:50-59) prints AccessedMyPCText. The port
-- opened BoxMenu / PlayerPC straight from the sound (#1529).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
Data.text._TurnedOnPC1Text = "{PLAYER} turned on\nthe PC."
Data.text._AccessedBillsPCText = "Accessed BILL's\nPC.\fAccessed POKéMON\nStorage System."
Data.text._AccessedSomeonesPCText =
"Accessed someone's\nPC.\fAccessed POKéMON\nStorage System."
Data.text._AccessedMyPCText = "Accessed my PC.\fAccessed Item\nStorage System."
local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed, screens = {}, {}
local stackStub = { push = function(_, item) pushed[#pushed + 1] = item end }
local textBoxStub = {
new = function(_, text, onDone, opts)
return { kind = "text", text = text, onDone = onDone, opts = opts }
end,
}
local menuStub = {
new = function(_, items, opts) return { kind = "menu", items = items, opts = opts or {} } end,
}
package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end }
package.loaded["src.ui.Menu"] = menuStub
local fakeGame = { data = Data, save = SaveData.newGame(), stack = stackStub }
T.check(setUpvalue(OW.openPC, "Game", fakeGame), "Game upvalue on openPC")
T.check(setUpvalue(OW.openPC, "TextBox", textBoxStub), "TextBox upvalue on openPC")
T.check(setUpvalue(OW.openPC, "Screens",
{ push = function(_, id) screens[#screens + 1] = id end }), "Screens upvalue on openPC")
local fakeSelf = setmetatable({}, { __index = OW })
local function openMenu(metBill)
pushed, screens = {}, {}
fakeGame.save = SaveData.newGame()
if metBill then fakeGame.save.flags.EVENT_MET_BILL = true end
fakeSelf:openPC(function() end)
local pcOn = pushed[#pushed]
T.eq(pcOn.kind, "text", "the session opens with TurnedOnPC1Text")
pcOn.onDone()
local menu = pushed[#pushed]
T.eq(menu.kind, "menu", "then the PC menu")
return menu
end
-- === SOMEONE'S PC: the access text before BoxMenu
do
local menu = openMenu(false)
menu.items[1].onSelect()
local box = pushed[#pushed]
T.eq(box.kind, "text", "the box row opens a text box")
T.check(tostring(box.text):find("Accessed someone's", 1, true) ~= nil,
"before meeting BILL it is AccessedSomeonesPCText")
T.eq(#screens, 0, "and the box screen has NOT opened yet")
box.onDone()
T.eq(screens[1], "BoxMenu", "BoxMenu follows the text, as the farcall does")
end
-- === BILL'S PC once EVENT_MET_BILL is set
do
local menu = openMenu(true)
menu.items[1].onSelect()
local box = pushed[#pushed]
T.check(tostring(box.text):find("Accessed BILL's", 1, true) ~= nil,
"after meeting BILL it is AccessedBillsPCText")
box.onDone()
T.eq(screens[1], "BoxMenu", "and still opens BoxMenu")
end
-- === the player's item storage
do
local menu = openMenu(false)
menu.items[2].onSelect()
local box = pushed[#pushed]
T.eq(box.kind, "text", "the item row opens a text box")
T.check(tostring(box.text):find("Accessed my PC.", 1, true) ~= nil,
"and it is AccessedMyPCText")
T.eq(#screens, 0, "PlayerPC has not opened yet")
box.onDone()
T.eq(screens[1], "PlayerPC", "PlayerPC follows the text")
end
T.finish("PC access text (#1529)")
@@ -0,0 +1,152 @@
-- A landed secondary POISON runs PoisonEffect's tail
-- (engine/battle/effects.asm:119-151): SHAKE_SCREEN_ANIM when the foe
-- poisoned you, ENEMY_HUD_SHAKE_ANIM when you poisoned the foe, through
-- PlayBattleAnimation2 (:1461-1471), which also stamps wAnimationType 6 /
-- 3 so the slow applying shake runs even with battle animations off.
-- FreezeBurnParalyzeEffect (:194-255) zeroes wAnimationType and only
-- shakes the enemy HUD on the player's turn. The port queued neither
-- (#1526).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
Data.moves.FIX_POISON_STING = {
id = "FIX_POISON_STING", index = 5, name = "FIX PSN STING", type = "POISON",
power = 15, accuracy = 100, pp = 35, effect = "POISON_SIDE_EFFECT1",
}
local function newBattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 40) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 40)
battle.rng = function() return 0 end -- every roll lands
return battle
end
local function animRows(battle)
local rows = {}
for _, item in ipairs(battle.queue) do
if item.anim then rows[#rows + 1] = item end
end
return rows
end
local function find(rows, name)
for i, row in ipairs(rows) do
if row.anim == name then return i, row end
end
return nil
end
local function textIndex(battle, needle)
for i, item in ipairs(battle.queue) do
if item.text and item.text:find(needle, 1, true) then return i end
end
return nil
end
local function animIndex(battle, name)
for i, item in ipairs(battle.queue) do
if item.anim == name then return i end
end
return nil
end
-- ---------------------------------------------------------------------
-- the foe poisons you: SE_SHAKE_SCREEN plus wAnimationType 3
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle:performMove(battle.enemy, battle.player, { id = "FIX_POISON_STING", pp = 35 })
T.eq(battle.player.mon.status, "PSN", "the secondary poison landed")
local rows = animRows(battle)
local i, row = find(rows, "SHAKE_SCREEN_ANIM")
T.check(i ~= nil, "the enemy's turn queues SHAKE_SCREEN_ANIM")
T.eq(row.attackerIsPlayer, false, "attributed to the enemy side")
T.eq(row.hit and row.hit.animType, 3, "wAnimationType 3 rides the row")
T.eq(row.animDelayed, true, "PlayBattleAnimationGotID pays no Delay3")
local moveIdx = animIndex(battle, "FIX_POISON_STING")
local shakeIdx = animIndex(battle, "SHAKE_SCREEN_ANIM")
local textIdx = textIndex(battle, "poisoned")
T.check(moveIdx and shakeIdx and moveIdx < shakeIdx,
"the move animation still runs first")
T.check(textIdx and shakeIdx < textIdx,
"PlayBattleAnimation2 precedes PrintText (effects.asm:149-151)")
end
-- ---------------------------------------------------------------------
-- you poison the foe: SE_SHAKE_ENEMY_HUD plus wAnimationType 6
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle:performMove(battle.player, battle.enemy, { id = "FIX_POISON_STING", pp = 35 })
T.eq(battle.enemy.mon.status, "PSN", "the secondary poison landed")
local _, row = find(animRows(battle), "ENEMY_HUD_SHAKE_ANIM")
T.check(row ~= nil, "the player's turn queues ENEMY_HUD_SHAKE_ANIM")
T.eq(row.attackerIsPlayer, true, "attributed to the player side")
T.eq(row.hit and row.hit.animType, 6, "wAnimationType 6 rides the row")
T.check(find(animRows(battle), "SHAKE_SCREEN_ANIM") == nil,
"and never the enemy-side id")
end
-- ---------------------------------------------------------------------
-- burn takes the FreezeBurnParalyzeEffect arms: HUD shake on the player's
-- turn only, and no wAnimationType at all
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle:performMove(battle.player, battle.enemy, { id = "FIX_EMBERISH", pp = 25 })
T.eq(battle.enemy.mon.status, "BRN", "the secondary burn landed")
local _, row = find(animRows(battle), "ENEMY_HUD_SHAKE_ANIM")
T.check(row ~= nil, "the player's burn shakes the enemy HUD")
T.eq(row.hit, nil, "FreezeBurnParalyzeEffect zeroes wAnimationType")
local other = newBattle()
other.queue, other.nextInsert = {}, 0
other:performMove(other.enemy, other.player, { id = "FIX_EMBERISH", pp = 25 })
T.eq(other.player.mon.status, "BRN", "the enemy's burn landed too")
T.check(find(animRows(other), "ENEMY_HUD_SHAKE_ANIM") == nil,
"the enemy's-turn arm plays nothing")
end
-- ---------------------------------------------------------------------
-- a plain damaging move queues neither
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle:performMove(battle.player, battle.enemy, { id = "FIX_TACKLE", pp = 35 })
local rows = animRows(battle)
T.check(find(rows, "ENEMY_HUD_SHAKE_ANIM") == nil
and find(rows, "SHAKE_SCREEN_ANIM") == nil,
"no status, no PlayBattleAnimation2 row")
end
-- ---------------------------------------------------------------------
-- the residual tick plays BURN_PSN_ANIM with NO shake: core.asm:490-491
-- explicitly zeroes wAnimationType before it
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle.player.mon.status = "PSN"
battle:residualFor(battle.player, battle.enemy)
local _, row = find(animRows(battle), "BURN_PSN_ANIM")
T.check(row ~= nil, "the poison tick animates")
T.eq(row.hit, nil, "with no applying-attack shake")
T.eq(row.attackerIsPlayer, true, "on the hurt mon's side")
end
T.finish("secondary status animation (#1526)")
@@ -0,0 +1,94 @@
-- JoypadOverworld calls RunMapScript every overworld frame before input is
-- even read (home/overworld.asm:1816-1821), and the gate guards are
-- per-frame "is the player standing on these coords" checks
-- (scripts/Route16Gate1F.asm:16, Route5Gate.asm:19, Route22Gate.asm:21).
-- The port only evaluated them on a completed step, so saving on a guard's
-- tile and reloading walked past the guard (#1547).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
Data.tilesets.FIX_OUT.tilesPerRow = 16
Data.field.flyWarps = Data.field.flyWarps or {}
Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" }
Data.field.waterTilesets = {}
Data.field.forcedMovement = { tiles = {} }
Data.audio = Data.audio or {}
Data.audio.songs = Data.audio.songs or {}
Data.audio.mapSongs = Data.audio.mapSongs or {}
local SaveData = require("src.core.SaveData")
local Game = require("src.core.Game")
local StateStack = require("src.core.StateStack")
local OverworldState = require("src.world.OverworldController")
local MapScripts = require("src.script.MapScripts")
Game.data = Data
Game.save = SaveData.newGame()
Game.save.player.name = "RED"
Game.save.player.map = "FIX_TOWN"
StateStack:init()
Game.stack = StateStack
Game.overworld = OverworldState
Game.input = {
isDown = function() return false end,
wasPressed = function() return false end,
step = function() end, state = {}, pressQueue = {},
}
Game.renderer = {
beginWorldPass = function() end, endWorldPass = function() end,
beginUIPass = function() end, endUIPass = function() end,
worldViewSize = function() return 160, 144 end,
setSGBZones = function() end,
}
local TRIGGER_X, TRIGGER_Y = 4, 4
local fired = {}
MapScripts.attachBase("FIX_TOWN", {
onStep = function(_, _, x, y)
if x == TRIGGER_X and y == TRIGGER_Y then
fired[#fired + 1] = { x, y }
return true
end
return false
end,
})
local function loadedSave()
local save = SaveData.newGame()
save.player.map = "FIX_TOWN"
save.player.x, save.player.y = TRIGGER_X, TRIGGER_Y
return save
end
-- === the exploit: F2 / CONTINUE onto the guard's tile re-fires the guard
fired = {}
Game:restoreSave(loadedSave(), false, { freshBoot = true })
T.eq(#fired, 1, "a freshBoot restore re-evaluates the standing-tile trigger")
T.same(fired[1], { TRIGGER_X, TRIGGER_Y },
"at the coords the save left the player on")
-- === an ordinary warp arrival must NOT fire it
fired = {}
OverworldState:setMap("FIX_TOWN", TRIGGER_X, TRIGGER_Y, "up", {})
T.eq(#fired, 0, "a plain warp arrival still leaves the trigger to onStepComplete")
-- === dev tooling reuses opts.via == "boot" WITHOUT freshBoot
fired = {}
OverworldState:setMap("FIX_TOWN", TRIGGER_X, TRIGGER_Y, "up", { via = "boot" })
T.eq(#fired, 0, "the console warp / hot reload shape does not fire it")
-- === checkpoint resume must never re-run map scripts
fired = {}
Game:restoreCheckpointSave(loadedSave())
T.eq(#fired, 0, "a checkpoint resume re-runs nothing")
-- === restoring somewhere harmless fires nothing
fired = {}
local elsewhere = loadedSave()
elsewhere.player.x, elsewhere.player.y = 3, 3
Game:restoreSave(elsewhere, false, { freshBoot = true })
T.eq(#fired, 0, "a restore off the trigger cell is untouched")
T.finish("standing-tile triggers on restore (#1547)")
+87
View File
@@ -0,0 +1,87 @@
-- The Nugget Bridge recruiter has no def_trainers header, so the port's
-- headerless engageTrainer fallback re-printed his contest line as the
-- pre-battle box (#1550) and his loss line never reached the battle
-- screen (#1551). scripts/Route24.asm:120-134: .JoinTeamRocketText, then
-- SaveEndBattleTextPointers with .DefeatedText, then EngageMapTrainer with
-- no further box; Route24AfterRocketBattleScript (:62-78) prints
-- .YouCouldBecomeATopLeaderText on the map after the win.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
local SaveData = require("src.core.SaveData")
Data.items.NUGGET = Data.items.NUGGET
or { id = "NUGGET", index = 49, name = "NUGGET", price = 10000 }
Data.text._Route24CooltrainerM1YouBeatOurContestText =
"Congratulations!\nYou beat our 5\ncontest trainers!"
Data.text._Route24CooltrainerM1YouJustEarnedAPrizeText = "You just earned\na prize!"
Data.text._Route24CooltrainerM1ReceivedNuggetText = "{PLAYER} got\n{RAM:wStringBuffer}!"
Data.text._Route24CooltrainerM1JoinTeamRocketText = "Want to join us?"
Data.text._Route24CooltrainerM1DefeatedText = "Arrgh!\nYou are good!"
Data.text._Route24CooltrainerM1YouCouldBecomeATopLeaderText =
"With your ability,\nyou could become\na top leader!"
local pushed = {}
package.loaded["src.render.TextBox"] = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
substitute = function(_, s) return s end,
soundOpts = function(_, sound, opts)
opts = opts or {}
opts.auto = { sound = sound, wait = true, delay = 0 }
return opts
end,
}
local scripts = dofile("data/scripts/story4.lua")
local handler = scripts.ROUTE_24.talk.TEXT_ROUTE24_COOLTRAINER_M1
T.check(type(handler) == "function", "the recruiter has a hand-ported handler")
local game = {
data = Data,
save = SaveData.newGame(),
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}
local defeated = false
local engaged
local ow = {
trainerDefeated = function() return defeated end,
engageTrainer = function(_, npc, onDone, endBattleText, skipBattleText)
engaged = { npc = npc, onDone = onDone,
endBattleText = endBattleText, skipBattleText = skipBattleText }
end,
}
local npc = { id = "ROUTE24_ROCKET", def = { index = 1 } }
-- the prize has already been taken: the talk goes straight to the battle
game.save.flags.EVENT_GOT_NUGGET = true
local doneCalls = 0
handler(game, ow, npc, function() doneCalls = doneCalls + 1 end)
T.check(engaged ~= nil, "the recruiter engages")
T.eq(#pushed, 0, "no text box is pushed before the battle (#1550)")
T.eq(engaged.skipBattleText, true,
"skipBattleText stops the map text becoming the pre-battle box")
T.eq(engaged.endBattleText, Data.text._Route24CooltrainerM1DefeatedText,
"the loss line rides the battle, as SaveEndBattleTextPointers does (#1551)")
-- the win: Route24AfterRocketBattleScript prints the top-leader line
defeated = true
engaged.onDone()
T.eq(#pushed, 1, "the win prints exactly one box")
T.eq(pushed[1].text, Data.text._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
"and it is .YouCouldBecomeATopLeaderText")
pushed[1].onDone()
T.eq(doneCalls, 1, "control returns once the box closes")
-- the blackout arm: wIsInBattle == $ff rets before the DisplayTextID
pushed, defeated, doneCalls = {}, false, 0
handler(game, ow, npc, function() doneCalls = doneCalls + 1 end)
engaged.onDone()
T.eq(#pushed, 0, "a loss prints nothing")
T.eq(doneCalls, 1, "and just unfreezes the player")
T.finish("Nugget Bridge Rocket battle text (#1550, #1551)")
+15 -5
View File
@@ -41,7 +41,8 @@ _G.love = {
getVersion = function() return 12, 0, 0, "Mysterious Mysteries" end, getVersion = function() return 12, 0, 0, "Mysterious Mysteries" end,
system = { system = {
getOS = function() return "iOS" end, getOS = function() return "iOS" end,
getModel = function() return "iPad Test" end, getDeviceModel = function() return "iPhone16,2" end,
getModel = function() return "Apple A17 Pro GPU" end,
openURL = function(url) openedURL = url end, openURL = function(url) openedURL = url end,
}, },
graphics = { graphics = {
@@ -78,10 +79,13 @@ check(not url:find("game=", 1, true)
check(fields.summary == "" and fields.location == "" and fields.screenshot == "" check(fields.summary == "" and fields.location == "" and fields.screenshot == ""
and fields.steps == "" and fields.expected == "", and fields.steps == "" and fields.expected == "",
"report leaves user-entered fields blank") "report leaves user-entered fields blank")
check(info.metadata:find("Device: iPad Test", 1, true) ~= nil check(info.device == "iPhone 15 Pro Max"
and info.metadata:find("Device: iPhone 15 Pro Max", 1, true) ~= nil
and info.metadata:find("LÖVE: 12.0.0", 1, true) ~= nil and info.metadata:find("LÖVE: 12.0.0", 1, true) ~= nil
and info.metadata:find("Safe mode: on", 1, true) ~= nil, and info.metadata:find("Safe mode: on", 1, true) ~= nil,
"report metadata includes device and app details") "report metadata includes device and app details")
check(not info.metadata:find("Simulator GPU", 1, true),
"report metadata does not mistake the renderer device for the device")
check(not info.metadata:find("unknown", 1, true), check(not info.metadata:find("unknown", 1, true),
"report metadata omits unknown values") "report metadata omits unknown values")
check(not info.metadata:find("Game id", 1, true) check(not info.metadata:find("Game id", 1, true)
@@ -104,25 +108,31 @@ check(not developmentInfo.metadata:find("0.0.0-dev", 1, true),
local previousOS = love.system.getOS local previousOS = love.system.getOS
local previousModel = love.system.getModel local previousModel = love.system.getModel
local previousDeviceModel = love.system.getDeviceModel
local previousIO = _G.io local previousIO = _G.io
love.system.getOS = function() return "OS X" end love.system.getOS = function() return "OS X" end
love.system.getModel = nil love.system.getModel = nil
love.system.getDeviceModel = nil
_G.io = { _G.io = {
popen = function() popen = function(command)
local output = command:find("system_profiler", 1, true)
and "Hardware Overview:\n Model Name: MacBook Air\n Model Identifier: Mac14,15\n Chip: Apple M2\n"
or "Mac14,15\n"
return { return {
read = function() return "MacBookPro18,3" end, read = function() return output end,
close = function() end, close = function() end,
} }
end, end,
} }
local desktopInfo = IssueReport.metadata({}, { mods = {} }) local desktopInfo = IssueReport.metadata({}, { mods = {} })
check(desktopInfo.device == "MacBookPro18,3", check(desktopInfo.device == "MacBook Air (Apple M2)",
"report finds desktop device model when LOVE has no model") "report finds desktop device model when LOVE has no model")
love.system.getOS = function() return "UWP" end love.system.getOS = function() return "UWP" end
local xboxInfo = IssueReport.metadata({}, { mods = {} }) local xboxInfo = IssueReport.metadata({}, { mods = {} })
check(xboxInfo.os == "Xbox", "report maps the Xbox runtime platform") check(xboxInfo.os == "Xbox", "report maps the Xbox runtime platform")
love.system.getOS = previousOS love.system.getOS = previousOS
love.system.getModel = previousModel love.system.getModel = previousModel
love.system.getDeviceModel = previousDeviceModel
_G.io = previousIO _G.io = previousIO
local opened = IssueReport.open({ safeMode = false }, { local opened = IssueReport.open({ safeMode = false }, {
@@ -0,0 +1,122 @@
-- SwitchPlayerMon (engine/battle/core.asm:2419-2423) prints RetreatMon and
-- holds 50 frames BEFORE the outgoing pic is recalled, and only then does
-- SendOutMon shout "Go! X!" (#1534). The port queued the send-out line
-- alone, so the withdraw box never existed. PlayerMon2Text's adjective
-- (engine/battle/common_text.asm:167-243) reads the ENEMY HP lost since
-- this mon switched in, from wLastSwitchInEnemyMonHP.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local Timing = require("src.core.Timing")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local function newBattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 30), Pokemon.new(Data, "FIXMON_B", 30) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 30)
battle.rng = function() return 0 end
battle.enemyAction = function() return { id = "FIX_SCRATCH", pp = 35 } end
return battle
end
-- drain the queue the way updateQueue does, recording rows in order plus
-- who was in the player slot when each row was emitted
local function drain(battle)
local rows = {}
for _ = 1, 400 do
local item = table.remove(battle.queue, 1)
if not item then return rows end
rows[#rows + 1] = { text = item.text, anim = item.anim,
auto = item.auto, autoDelay = item.autoDelay,
playerSpecies = battle.player.mon.species }
if item.fn then
battle.nextInsert = 0
item.fn()
end
end
error("the queue never drained")
end
local function indexOf(rows, pred)
for i, row in ipairs(rows) do
if pred(row) then return i end
end
return nil
end
-- ---------------------------------------------------------------------
-- the voluntary party-menu switch: withdraw line, then the send-out
-- ---------------------------------------------------------------------
do
local battle = newBattle()
drain(battle) -- the intro stamps lastSwitchInEnemyHP through sendOutText
local outgoing = battle.player.mon.species
local oldNick = battle.player.name
battle.queue, battle.nextInsert = {}, 0
battle:resolveSwitch(battle.game.save.party[2])
local rows = drain(battle)
local wIdx = indexOf(rows, function(r)
return r.text and r.text:find("Come back!", 1, true) ~= nil
end)
T.check(wIdx ~= nil, "the withdraw line is queued")
T.eq(rows[wIdx].text, oldNick .. " enough!\nCome back!",
"an untouched foe gives the `enough!` variant")
T.eq(rows[wIdx].auto, true, "the page ends `done`, so it never waits on A")
T.eq(rows[wIdx].autoDelay, Timing.SWITCH_PLAYER_MON,
"it holds the 50 frames DelayFrames pays (core.asm:2421-2422)")
T.eq(rows[wIdx].playerSpecies, outgoing,
"the outgoing mon is still in the slot while the line prints")
local sIdx = indexOf(rows, function(r)
return r.text and r.text:find("! ", 1, true) and r.text:find("Come back!", 1, true) == nil
end)
T.check(sIdx ~= nil and sIdx > wIdx, "the send-out shout follows the withdraw line")
T.eq(rows[sIdx].auto, true, "the send-out page ends `done` too (#1472)")
T.neq(rows[sIdx].playerSpecies, outgoing, "the swap happened between the two")
end
-- ---------------------------------------------------------------------
-- the adjective branches on enemy HP lost since the switch-in
-- ---------------------------------------------------------------------
do
local battle = newBattle()
drain(battle)
local nick = battle.player.name
local max = battle.enemy.mon.stats.hp
local quarter = math.floor(max / 4)
local function withdrawAt(dropPercent)
battle.lastSwitchInEnemyHP = max
battle.enemy.mon.hp = max - math.floor(dropPercent * quarter / 25)
return battle:withdrawText(nick)
end
T.eq(withdrawAt(0), nick .. " enough!\nCome back!", "no damage -> `enough!`")
T.eq(withdrawAt(50), nick .. " OK!\nCome back!", "30-69 -> `OK!`")
T.eq(withdrawAt(80), nick .. " good!\nCome back!", "70+ -> `good!`")
T.eq(withdrawAt(10), nick .. " \nCome back!", "1-29 -> no adjective at all")
end
-- ---------------------------------------------------------------------
-- ChooseNextMon (core.asm:1086-1128) calls SendOutMon with NO RetreatMon
-- ---------------------------------------------------------------------
do
local battle = newBattle()
drain(battle)
battle.queue, battle.nextInsert = {}, 0
battle.player.mon.hp = 0
battle:openReplacementMenu()
local rows = drain(battle)
T.check(indexOf(rows, function(r)
return r.text and r.text:find("Come back!", 1, true) ~= nil
end) == nil, "the post-faint replacement prints no withdraw line")
end
T.finish("switch withdraw text (#1534)")
+109
View File
@@ -0,0 +1,109 @@
-- THRASH/PETAL DANCE is a SpecialEffectsCont entry
-- (data/battle/special_effects.asm:22), so on the SETUP turn only,
-- engine/battle/core.asm:3129-3133 runs ThrashPetalDanceEffect before
-- damage; it ends in PlayBattleAnimation2 with SHRINKING_SQUARE_ANIM
-- (ANIM_B1 on the enemy's turn) plus the slow horizontal screen shake
-- (engine/battle/effects.asm:791-808, :1461-1471). The port queued only
-- the move's own animation (#1532).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
Data.moves.FIX_THRASH = {
id = "FIX_THRASH", index = 91, name = "FIX THRASH", type = "NORMAL",
power = 90, accuracy = 100, pp = 20, effect = "THRASH_PETAL_DANCE_EFFECT",
}
local function newBattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 40) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 40)
battle.rng = function(a) if a then return a end return 0 end
return battle
end
local function animRows(battle)
local rows = {}
for _, item in ipairs(battle.queue) do
if item.anim then
rows[#rows + 1] = { anim = item.anim, hit = item.hit,
attackerIsPlayer = item.attackerIsPlayer }
end
end
return rows
end
local function indexOf(rows, name)
for i, row in ipairs(rows) do
if row.anim == name then return i end
end
return nil
end
-- ---------------------------------------------------------------------
-- the player's setup turn: the effect animation precedes the move's own
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
local slot = { id = "FIX_THRASH", pp = 20 }
battle:performMove(battle.player, battle.enemy, slot)
local rows = animRows(battle)
local setup = indexOf(rows, "SHRINKING_SQUARE_ANIM")
local move = indexOf(rows, "FIX_THRASH")
T.check(setup ~= nil, "the setup turn queues SHRINKING_SQUARE_ANIM")
T.check(move ~= nil and setup < move,
"it plays BEFORE PlayPlayerMoveAnimation, as SpecialEffectsCont runs first")
T.eq(rows[setup].attackerIsPlayer, true, "on the player's side")
T.eq(rows[setup].hit and rows[setup].hit.animType, 6,
"wAnimationType 6 -> ShakeScreenHorizontallySlow2 on the player's turn")
-- the continuation turn never reaches the effect (.ThrashingAboutCheck,
-- core.asm:3532-3550 jumps straight to PlayerCalcMoveDamage)
battle.queue, battle.nextInsert = {}, 0
battle:performMove(battle.player, battle.enemy, slot)
T.check(indexOf(animRows(battle), "SHRINKING_SQUARE_ANIM") == nil,
"a locked-in Thrash queues no setup animation")
end
-- ---------------------------------------------------------------------
-- the enemy's turn takes ANIM_B1 and wAnimationType 3
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle:performMove(battle.enemy, battle.player, { id = "FIX_THRASH", pp = 20 })
local rows = animRows(battle)
local setup = indexOf(rows, "ANIM_B1")
T.check(setup ~= nil, "the enemy's setup turn queues ANIM_B1")
T.eq(rows[setup].attackerIsPlayer, false, "on the enemy's side")
T.eq(rows[setup].hit and rows[setup].hit.animType, 3,
"wAnimationType 3 -> ShakeScreenHorizontallySlow on the enemy's turn")
T.check(indexOf(rows, "SHRINKING_SQUARE_ANIM") == nil,
"and never the player-side id")
end
-- ---------------------------------------------------------------------
-- a missed setup turn still runs the effect (it precedes MoveHitTest)
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle.accuracyRoll = function() return false end
battle:performMove(battle.player, battle.enemy, { id = "FIX_THRASH", pp = 20 })
local rows = animRows(battle)
T.check(indexOf(rows, "SHRINKING_SQUARE_ANIM") ~= nil,
"the setup animation survives a miss")
T.check(indexOf(rows, "FIX_THRASH") == nil, "while the move's own anim is cancelled")
end
T.finish("thrash setup animation (#1532)")
@@ -0,0 +1,120 @@
-- _TrainerSentOutText (data/text/text_2.asm:923) and the
-- Go!/Do it!/Get'm! chain ending in _PlayerMon1Text (:1274-1294) end in
-- `done`, not `prompt`: PrintText returns and the flow runs straight into
-- AnimateSendingOutMon + PlayCry (engine/battle/core.asm:1421-1434,
-- :1723-1765). _AIBattleWithdrawText (:1-7) does end in `prompt` and
-- keeps its button wait. The port made every send-out box wait (#1472).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local function newBattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 30), Pokemon.new(Data, "FIXMON_B", 30) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 30)
battle.rng = function() return 0 end
battle.enemyAction = function() return { id = "FIX_SCRATCH", pp = 35 } end
return battle
end
local function rowWith(battle, needle)
for _, item in ipairs(battle.queue) do
if item.text and item.text:find(needle, 1, true) then return item end
end
return nil
end
-- ---------------------------------------------------------------------
-- the voluntary switch: the shout auto-continues into the send-out anim
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle:resolveSwitch(battle.game.save.party[2])
-- run the first act so the nested switch rows land in the queue
local first = table.remove(battle.queue, 1)
battle.nextInsert = 0
first.fn()
local withdraw = rowWith(battle, "Come back!")
T.check(withdraw ~= nil, "the withdraw line is queued")
T.eq(withdraw.auto, true, "RetreatMon's page ends `done` (#1534)")
local swap = table.remove(battle.queue, 2)
battle.nextInsert = 1
swap.fn()
local shout = rowWith(battle, battle.player.name)
T.check(shout ~= nil, "the send-out shout is queued")
T.eq(shout.auto, true, "_PlayerMon1Text ends `done`, so no button wait")
end
-- ---------------------------------------------------------------------
-- the AI switch: the sent-out box goes auto, the withdraw box does not,
-- and EnemySendOut's grow-in + cry now follow it
-- ---------------------------------------------------------------------
do
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
battle.rng = function() return 0 end
battle.enemyParty = { Pokemon.new(Data, "FIXMON_B", 30),
Pokemon.new(Data, "FIXMON_C", 30) }
battle.enemyIndex = 1
battle.enemy = battle.enemy or nil
battle.queue, battle.nextInsert = {}, 0
battle:executeAction(battle.enemy, battle.player,
{ special = "aiSwitch", index = 2 })
local withdrew = rowWith(battle, "with-")
local sent = rowWith(battle, "sent")
T.check(withdrew ~= nil, "the AI withdraw line is queued")
T.eq(withdrew.auto, nil, "_AIBattleWithdrawText ends `prompt` and still waits")
T.check(sent ~= nil, "the sent-out line is queued")
T.eq(sent.auto, true, "_TrainerSentOutText ends `done`")
T.eq(battle.enemySendingOut, true,
"the new pic stays hidden until AnimateSendingOutMon")
local acts = 0
for _, item in ipairs(battle.queue) do
if item.fn then acts = acts + 1 end
end
T.check(acts >= 1, "EnemySendOut queues the grow-in act after the text")
end
-- ---------------------------------------------------------------------
-- the post-faint replacement (ChooseNextMon -> SendOutMon, core.asm:1124)
-- ---------------------------------------------------------------------
do
local battle = newBattle()
local pushedUI
battle.game.stack.push = function(_, s) pushedUI = s end
battle.queue, battle.nextInsert = {}, 0
battle.player.mon.hp = 0
battle:openReplacementMenu()
local onSwitch
for _, item in ipairs(battle.queue) do
if item.ui then
local screen = item.ui()
onSwitch = screen and screen.onSwitch
end
end
onSwitch = onSwitch or (pushedUI and pushedUI.onSwitch)
if onSwitch then
battle.nextInsert = 0
onSwitch(battle.game.save.party[2])
local shout = rowWith(battle, battle.player.name)
T.check(shout ~= nil, "the replacement shout is queued")
T.eq(shout.auto, true, "SendOutMon's message ends `done` here too")
else
T.check(false, "the replacement menu offers an onSwitch callback")
end
end
T.finish("trainer send-out boxes (#1472)")
+11 -3
View File
@@ -96,6 +96,8 @@ local DATA = {
CYNDAQUIL = { CYNDAQUIL = {
id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155, index = 155, id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155, index = 155,
growthRate = "GROWTH_MEDIUM_SLOW", growthRate = "GROWTH_MEDIUM_SLOW",
-- data/pokemon/base_stats/cyndaquil.asm:10 GENDER_F12_5
genderRatio = 31,
types = { "FIRE", "FIRE" }, types = { "FIRE", "FIRE" },
baseStats = { baseStats = {
hp = 39, attack = 52, defense = 43, speed = 65, hp = 39, attack = 52, defense = 43, speed = 65,
@@ -105,6 +107,8 @@ local DATA = {
TOTODILE = { TOTODILE = {
id = "TOTODILE", name = "TOTODILE", dex = 158, index = 158, id = "TOTODILE", name = "TOTODILE", dex = 158, index = 158,
growthRate = "GROWTH_MEDIUM_SLOW", growthRate = "GROWTH_MEDIUM_SLOW",
-- data/pokemon/base_stats/totodile.asm:10 GENDER_F12_5
genderRatio = 31,
types = { "WATER", "WATER" }, types = { "WATER", "WATER" },
baseStats = { baseStats = {
hp = 50, attack = 65, defense = 64, speed = 43, hp = 50, attack = 65, defense = 64, speed = 43,
@@ -163,8 +167,11 @@ end
-- is `levelMoves` and Gen 1 reads level1Moves / learnset. -- is `levelMoves` and Gen 1 reads level1Moves / learnset.
local function mon(species, level, opts) local function mon(species, level, opts)
opts = opts or {} opts = opts or {}
-- engine/pokemon/mon_stats.asm:126 GetGender reads the Attack DV, so the
-- gender a page prints follows the DVs, not a hand-set field
local built = Mon.new(DATA, species, level, { local built = Mon.new(DATA, species, level, {
dvs = { attack = 15, defense = 15, speed = 15, special = 15 }, dvs = opts.dvs
or { attack = 15, defense = 15, speed = 15, special = 15 },
moves = opts.moves, moves = opts.moves,
}) })
for key, value in pairs(opts.fields or {}) do built[key] = value end for key, value in pairs(opts.fields or {}) do built[key] = value end
@@ -177,12 +184,13 @@ local CYNDA = mon("CYNDAQUIL", 12, {
{ id = "EMBER", pp = 25, maxPp = 25 }, { id = "EMBER", pp = 25, maxPp = 25 },
{ id = "LEER", pp = 7, maxPp = 30 }, { id = "LEER", pp = 7, maxPp = 30 },
}, },
fields = { nickname = "CYNDAQUIL", gender = "male", item = "BERRY", fields = { nickname = "CYNDAQUIL", item = "BERRY",
otName = "GOLD", otId = 12345 }, otName = "GOLD", otId = 12345 },
}) })
local TOTO = mon("TOTODILE", 10, { local TOTO = mon("TOTODILE", 10, {
moves = { { id = "SURF", pp = 15, maxPp = 15 } }, moves = { { id = "SURF", pp = 15, maxPp = 15 } },
fields = { nickname = "TOTODILE", gender = "female" }, dvs = { attack = 0, defense = 15, speed = 15, special = 15 },
fields = { nickname = "TOTODILE" },
}) })
local SAVE = { player = { name = "GOLD", id = 12345 }, party = { CYNDA, TOTO } } local SAVE = { player = { name = "GOLD", id = 12345 }, party = { CYNDA, TOTO } }
+4
View File
@@ -226,6 +226,10 @@ return { spawns = { SPAWN_NEW_BARK = { map = "TEST_MAP", x = 1, y = 1 } } }
spawnAfterChampion = "SPAWN_LANCE", spawnAfterChampion = "SPAWN_LANCE",
position = { map = "PLAYERS_HOUSE_2F", x = 1, y = 1, facing = "down" } }), position = { map = "PLAYERS_HOUSE_2F", x = 1, y = 1, facing = "down" } }),
"and the post-credits spawn is a warp even though a position exists") "and the post-credits spawn is a warp even though a position exists")
love.filesystem.remove("data/generated/maps.lua")
love.filesystem.remove("data/generated/tilesets.lua")
love.filesystem.remove("data/generated/landmarks.lua")
end end
-- ---- the two clock faces ---------------------------------------------------- -- ---- the two clock faces ----------------------------------------------------
+20 -7
View File
@@ -2876,8 +2876,9 @@ check(selGame.save.registeredItem == nil,
eq(selWorld:useSelectItem(), "not_registered", eq(selWorld:useSelectItem(), "not_registered",
"SELECT with nothing registered answers not_registered") "SELECT with nothing registered answers not_registered")
-- The PACK side: SELECT on a highlighted row is RegisterItem, the one PACK -- The PACK side: the item submenu's SEL row is RegisterItem's only door --
-- button this port left unbound. -- the cart's SELECT is the bag's own item shuffle
-- (engine/items/pack.asm:1290 Pack_InterpretJoypad .select).
selGame.save.inventory.POTION = 3 selGame.save.inventory.POTION = 3
selGame.input = stubInput() selGame.input = stubInput()
local selPack = PackMenu.new(selGame, { pocket = "ITEM" }) local selPack = PackMenu.new(selGame, { pocket = "ITEM" })
@@ -2885,7 +2886,18 @@ selPack.index = 1
check(selPack.rows[1].id == "POTION", "the ITEM pocket row under test") check(selPack.rows[1].id == "POTION", "the ITEM pocket row under test")
selGame.input:press("select") selGame.input:press("select")
selPack:update(0) selPack:update(0)
check(selPack.message ~= nil, "SELECT on a row opens RegisteredItemText") eq(selPack.switching, 1, "SELECT on a row arms the item shuffle")
check(selGame.save.registeredItem == nil, "and registers nothing")
selGame.input:press("b")
selPack:update(0)
check(selPack.switching == nil and selPack.message == nil,
"B backs out of the shuffle")
selPack:openSubmenu()
check(table.concat(selPack.submenu.rows, ","):find("sel", 1, true) ~= nil,
"the POTION submenu offers the SEL row")
selPack:closeSubmenu()
selPack:registerSelected()
check(selPack.message ~= nil, "SEL opens RegisteredItemText")
eq(selGame.save.registeredItem.id, "POTION", eq(selGame.save.registeredItem.id, "POTION",
"and World:registerItem actually ran") "and World:registerItem actually ran")
selGame.input:press("a") selGame.input:press("a")
@@ -2897,7 +2909,9 @@ selPack.index = selPack:total()
selGame.save.registeredItem = nil selGame.save.registeredItem = nil
selGame.input:press("select") selGame.input:press("select")
selPack:update(0) selPack:update(0)
check(selPack.message == nil, "SELECT on CANCEL registers nothing") check(selPack.switching == nil and selPack.message == nil,
"SELECT on CANCEL arms nothing")
selPack:registerSelected()
check(selGame.save.registeredItem == nil, "and the slot stays empty") check(selGame.save.registeredItem == nil, "and the slot stays empty")
-- CantRegisterText: a TM/HM row refuses from the PACK too. -- CantRegisterText: a TM/HM row refuses from the PACK too.
@@ -2906,9 +2920,8 @@ selPack:rebuild()
local tmPack = PackMenu.new(selGame, { pocket = "TM_HM" }) local tmPack = PackMenu.new(selGame, { pocket = "TM_HM" })
tmPack.index = 1 tmPack.index = 1
check(tmPack.rows[1].id == "HM_CUT", "the TM/HM pocket row under test") check(tmPack.rows[1].id == "HM_CUT", "the TM/HM pocket row under test")
selGame.input:press("select") tmPack:registerSelected()
tmPack:update(0) check(tmPack.message ~= nil, "SEL on the HM still opens a message")
check(tmPack.message ~= nil, "SELECT on the HM still opens a message")
check(selGame.save.registeredItem == nil, check(selGame.save.registeredItem == nil,
"CantRegisterText: the HM never becomes the registered item") "CantRegisterText: the HM never becomes the registered item")
end end
+2 -1
View File
@@ -315,8 +315,9 @@ local vanillaSave = SaveData.newGame(Data.field.boot)
-- special_warps.asm NewGameWarp is REDS_HOUSE_2F, 3, 6 -- the bedroom. This -- special_warps.asm NewGameWarp is REDS_HOUSE_2F, 3, 6 -- the bedroom. This
-- previously asserted PALLET_TOWN (5, 6), which is where you stand after -- previously asserted PALLET_TOWN (5, 6), which is where you stand after
-- walking out of the house, so a new game skipped Red's house entirely. -- walking out of the house, so a new game skipped Red's house entirely.
-- Red/Blue land facing up (#944); only Yellow keeps boot.startFacing.
check(vanillaSave.player.map == "REDS_HOUSE_2F" and vanillaSave.player.x == 3 check(vanillaSave.player.map == "REDS_HOUSE_2F" and vanillaSave.player.x == 3
and vanillaSave.player.y == 6 and vanillaSave.player.facing == "down", and vanillaSave.player.y == 6 and vanillaSave.player.facing == "up",
"the seeded boot config reproduces the NewGameWarp bedroom spawn") "the seeded boot config reproduces the NewGameWarp bedroom spawn")
check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE" check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE"
and vanillaSave.money == 3000, "the seeded boot config reproduces the Red start") and vanillaSave.money == 3000, "the seeded boot config reproduces the Red start")
+2
View File
@@ -276,6 +276,8 @@ do
text = text:gsub("{RIVAL}", save.player.rival or "BLUE") text = text:gsub("{RIVAL}", save.player.rival or "BLUE")
if game.stringBuffer then if game.stringBuffer then
text = text:gsub("{RAM:wStringBuffer}", game.stringBuffer) text = text:gsub("{RAM:wStringBuffer}", game.stringBuffer)
-- wNameBuffer reads the same buffer in this port (TextBox.TOKENS.RAM)
text = text:gsub("{RAM:wNameBuffer}", game.stringBuffer)
end end
text = text:gsub("{[%w_:]+}", "") text = text:gsub("{[%w_:]+}", "")
return text return text
+44 -30
View File
@@ -282,13 +282,19 @@ local function optGame()
end end
local om = OptionsMenu.new(optGame()) local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout", local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
"battleFit", "battleBg", "uiLayout", "battleFit", "battleHud", "battleBg", "uiLayout",
"ruleset", "musicVol", "sfxVol", "musicFilter", "ruleset", "musicVol", "sfxVol", "musicFilter",
"performance", "colors", "performance", "colors",
"tilt", "gbcfx", "zoom", "voidFill", "videoMode", "tilt", "gbcfx", "zoom", "voidFill", "videoMode",
"faithfulRes", "fpsCap", "faithfulRes", "fpsCap",
"speedOverworld", "speedBattle", "speedMenu", "speedOverworld", "speedBattle", "speedMenu",
"mods", "controls", "dateFormat", "timeFormat" } "mods", "controls", "dateFormat", "timeFormat" }
local function orow(menu, id)
for _, row in ipairs(menu.rows) do
if row.id == id then return row end
end
error("no options row '" .. id .. "'")
end
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)") check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
for i, id in ipairs(WANT_IDS) do for i, id in ipairs(WANT_IDS) do
check(om.rows[i].id == id, "options row order: " .. id) check(om.rows[i].id == id, "options row order: " .. id)
@@ -296,11 +302,12 @@ end
-- ruleset row cycles the sorted non-hidden registry ids showing name -- ruleset row cycles the sorted non-hidden registry ids showing name
om.game.save.options.ruleset = "gen1_faithful" om.game.save.options.ruleset = "gen1_faithful"
check(om.rows[8].value(om.game) == "GEN 1", "ruleset row shows record.name") check(orow(om, "ruleset").value(om.game) == "GEN 1",
om.rows[8].step(om.game, 1) "ruleset row shows record.name")
orow(om, "ruleset").step(om.game, 1)
check(om.game.save.options.ruleset == "modern_clean", check(om.game.save.options.ruleset == "modern_clean",
"ruleset row cycles sorted registry ids") "ruleset row cycles sorted registry ids")
om.rows[8].step(om.game, 1) orow(om, "ruleset").step(om.game, 1)
check(om.game.save.options.ruleset == "gen1_faithful", check(om.game.save.options.ruleset == "gen1_faithful",
"hidden rulesets are excluded from the cycle") "hidden rulesets are excluded from the cycle")
@@ -319,42 +326,42 @@ check(om.game.save.options.battleLayout == "wide", "battle layout flips to WIDE"
check(om.rows[4].value(om.game) == "WIDE", "the WIDE layout renders its label") check(om.rows[4].value(om.game) == "WIDE", "the WIDE layout renders its label")
om.rows[4].step(om.game, 1) om.rows[4].step(om.game, 1)
check(om.game.save.options.battleLayout == "og", "battle layout flips back") check(om.game.save.options.battleLayout == "og", "battle layout flips back")
om.rows[9].step(om.game, -1) orow(om, "musicVol").step(om.game, -1)
check(om.game.save.options.musicVol == 6, "music volume steps down") check(om.game.save.options.musicVol == 6, "music volume steps down")
for _ = 1, 10 do om.rows[9].step(om.game, -1) end for _ = 1, 10 do orow(om, "musicVol").step(om.game, -1) end
check(om.game.save.options.musicVol == 0, "music volume clamps at 0") check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
-- ZOOM / VOID FILL rows (indices track WANT_IDS above; the battle -- ZOOM / VOID FILL rows (looked up by id; WANT_IDS above pins the order)
-- composition rows -- BATTLE SIZE / BATTLE BG / UI LAYOUT -- sit ahead of
-- RULESET, and FAITHFUL RATIO lands between VIDEO MODE and MAX FPS)
local Zoom = require("src.render.Zoom") local Zoom = require("src.render.Zoom")
local TileRenderer = require("src.render.TileRenderer") local TileRenderer = require("src.render.TileRenderer")
om.game.save.options.zoom = 0 om.game.save.options.zoom = 0
Zoom.offset = 0 Zoom.offset = 0
check(om.rows[16].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0") check(orow(om, "zoom").value(om.game) == "FIT",
om.rows[16].step(om.game, 1) "ZOOM row shows FIT at offset 0")
orow(om, "zoom").step(om.game, 1)
check(om.game.save.options.zoom == 1 and Zoom.offset == 1, check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
"ZOOM row steps to IN1") "ZOOM row steps to IN1")
om.rows[17].step(om.game, 1) orow(om, "voidFill").step(om.game, 1)
check(om.game.save.options.voidFill == "water" check(om.game.save.options.voidFill == "water"
and TileRenderer.voidFill == "water", and TileRenderer.voidFill == "water",
"VOID FILL row cycles TREES → WATER") "VOID FILL row cycles TREES → WATER")
om.rows[17].step(om.game, 1) orow(om, "voidFill").step(om.game, 1)
check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK") check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK")
om.rows[17].step(om.game, 1) orow(om, "voidFill").step(om.game, 1)
check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES") check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES")
-- the MAX FPS row cycles the render-cap steps and shows the value plain -- the MAX FPS row cycles the render-cap steps and shows the value plain
om.game.save.options.fpsCap = nil om.game.save.options.fpsCap = nil
check(om.rows[20].value(om.game) == "60", check(orow(om, "fpsCap").value(om.game) == "60",
"MAX FPS row defaults to 60 with no saved cap") "MAX FPS row defaults to 60 with no saved cap")
om.rows[20].step(om.game, 1) orow(om, "fpsCap").step(om.game, 1)
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75") check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
check(om.rows[20].value(om.game) == "75", "the MAX FPS row renders the cap") check(orow(om, "fpsCap").value(om.game) == "75",
"the MAX FPS row renders the cap")
om.game.save.options.fpsCap = 160 om.game.save.options.fpsCap = 160
om.rows[20].step(om.game, 1) orow(om, "fpsCap").step(om.game, 1)
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30") check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
om.rows[20].step(om.game, -1) orow(om, "fpsCap").step(om.game, -1)
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling") check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
-- ------- FrameCap normalize / cycle (issue #88) -- ------- FrameCap normalize / cycle (issue #88)
@@ -384,7 +391,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6
-- the MODS row is the manager's discoverable home -- the MODS row is the manager's discoverable home
local mgGame = optGame() local mgGame = optGame()
om = OptionsMenu.new(mgGame) om = OptionsMenu.new(mgGame)
om.rows[24].activate(mgGame) orow(om, "mods").activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState, check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager") "the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState", check(mgGame.stack:top().screenId == "ManagerState",
@@ -394,7 +401,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu") local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame() local cbGame = optGame()
om = OptionsMenu.new(cbGame) om = OptionsMenu.new(cbGame)
om.rows[25].activate(cbGame) orow(om, "controls").activate(cbGame)
local bm = cbGame.stack:top() local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu, check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list") "the CONTROLS row opens the rebind list")
@@ -413,17 +420,17 @@ check(cbGame.save.options.bindings == nil,
-- engine UI and mods without becoming checkpoint progress -- engine UI and mods without becoming checkpoint progress
om.game.save.options.dateFormat = "device" om.game.save.options.dateFormat = "device"
om.game.save.options.timeFormat = "device" om.game.save.options.timeFormat = "device"
check(om.rows[26].value(om.game) == "DEVICE", check(orow(om, "dateFormat").value(om.game) == "DEVICE",
"DATE FORMAT defaults to device locale") "DATE FORMAT defaults to device locale")
om.rows[26].step(om.game, 1) orow(om, "dateFormat").step(om.game, 1)
check(om.game.save.options.dateFormat == "dmy" check(om.game.save.options.dateFormat == "dmy"
and om.rows[26].value(om.game) == "DD-MM-YYYY", and orow(om, "dateFormat").value(om.game) == "DD-MM-YYYY",
"DATE FORMAT exposes deterministic DMY override") "DATE FORMAT exposes deterministic DMY override")
check(om.rows[27].value(om.game) == "DEVICE", check(orow(om, "timeFormat").value(om.game) == "DEVICE",
"TIME FORMAT defaults to device locale") "TIME FORMAT defaults to device locale")
om.rows[27].step(om.game, 1) orow(om, "timeFormat").step(om.game, 1)
check(om.game.save.options.timeFormat == "24h" check(om.game.save.options.timeFormat == "24h"
and om.rows[27].value(om.game) == "24 HOUR", and orow(om, "timeFormat").value(om.game) == "24 HOUR",
"TIME FORMAT exposes deterministic 24-hour override") "TIME FORMAT exposes deterministic 24-hour override")
check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil, check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil,
"no raw-input claim until a capture is armed") "no raw-input claim until a capture is armed")
@@ -583,11 +590,19 @@ check(not fpm.submenu and forced == fgame.save.party[1],
-- ------- issues #320/#385: the STRENGTH texts print over the party menu -- ------- issues #320/#385: the STRENGTH texts print over the party menu
do do
-- PartyMenu delegates the move to OverworldState:useStrengthFieldMove;
-- parity_I_M covers that side, this one covers what the menu does after
local owStub = { strengthActive = false, local owStub = { strengthActive = false,
map = { def = { tileset = "OVERWORLD" } }, dark = false, map = { def = { tileset = "OVERWORLD" } }, dark = false,
partyKnows = function(self, id) return self.knows == id end, partyKnows = function(self, id) return self.knows == id end,
knows = "STRENGTH" } knows = "STRENGTH" }
local sgame = partyGame() local sgame = partyGame()
owStub.useStrengthFieldMove = function(self, _mon, onClose)
self.strengthActive = true
sgame.stack:push(require("src.render.TextBox").new(
sgame, "used\nSTRENGTH.", onClose))
return true
end
sgame.overworld = owStub sgame.overworld = owStub
sgame.data.text = {} -- the strength texts fall back to Strings sources sgame.data.text = {} -- the strength texts fall back to Strings sources
sgame.save.inventory.RAINBOWBADGE = 1 sgame.save.inventory.RAINBOWBADGE = 1
@@ -1233,7 +1248,7 @@ local Loader = require("src.mods.Loader")
local uiFiles = { local uiFiles = {
["mods/uikit/manifest.json"] = ["mods/uikit/manifest.json"] =
'{"id":"uikit","name":"uikit","version":"1.0.0","entry":"main.lua","api":2}', '{"id":"uikit","name":"uikit","version":"1.0.0","entry":"main.lua","api":2}',
["mods/uikit/main.lua"] = "return function(mod) _G.MOD_UI_API = mod end", ["mods/uikit/main.lua"] = "return function(mod) mod.exports.api = mod end",
} }
local uiFs = { local uiFs = {
read = function(path) return uiFiles[path] end, read = function(path) return uiFiles[path] end,
@@ -1267,8 +1282,7 @@ local uiFs = {
} }
local uiLoader = Loader.new({ fs = uiFs }) local uiLoader = Loader.new({ fs = uiFs })
check(uiLoader:load({}) == true, "the uikit fixture loads clean") check(uiLoader:load({}) == true, "the uikit fixture loads clean")
local uiApi = _G.MOD_UI_API local uiApi = (uiLoader.exports.uikit or {}).api
_G.MOD_UI_API = nil
check(uiApi ~= nil, "the entry chunk received its api") check(uiApi ~= nil, "the entry chunk received its api")
check(uiApi.ui == ModUI, "mod.ui is the toolkit facade") check(uiApi.ui == ModUI, "mod.ui is the toolkit facade")
check(uiApi.ui.Theme == Theme, "mod.ui.Theme reaches the theme module") check(uiApi.ui.Theme == Theme, "mod.ui.Theme reaches the theme module")
+1
View File
@@ -125,6 +125,7 @@ do
function tb:enemyAction() return { special = "bound" } end function tb:enemyAction() return { special = "bound" } end
tb:resolveSwitch(Game.save.party[2]) tb:resolveSwitch(Game.save.party[2])
acts[1]() -- send-out clears foe trap acts[1]() -- send-out clears foe trap
acts[#acts]()
eq(tb.enemy.trappingTurns, nil, "player switch clears foe Wrap/Bind/etc.") eq(tb.enemy.trappingTurns, nil, "player switch clears foe Wrap/Bind/etc.")
eq(tb.enemy.trapMove, nil, "player switch clears trapMove") eq(tb.enemy.trapMove, nil, "player switch clears trapMove")
check(tb:fightLockedAction(tb.player) == nil, check(tb:fightLockedAction(tb.player) == nil,
+2 -2
View File
@@ -222,7 +222,7 @@ eq(next(t), nil, "already-left Route 25 enter is a no-op")
Commands.hide_object = realHide Commands.hide_object = realHide
Commands.show_object = realShow Commands.show_object = realShow
package.loaded["src.ui.Menu"] = realMenu package.loaded["src.ui.Menu"] = realMenu
if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic end package.loaded["src.core.Music"] = realMusic
if realSound ~= nil then package.loaded["src.core.Sound"] = realSound end package.loaded["src.core.Sound"] = realSound
S.finish() S.finish()
+11
View File
@@ -35,6 +35,13 @@ local WILD_SONG = Data.audio.battle.wild
-- every song request in order: "the theme was never restored" and "the theme -- every song request in order: "the theme was never restored" and "the theme
-- was never started" have to read differently. Music.playMap still sets the -- was never started" have to read differently. Music.playMap still sets the
-- state Music.restoreMap reads, so the real restore path is under test. -- state Music.restoreMap reads, so the real restore path is under test.
-- singletons this file stands doubles on; the originals go back at the tail,
-- or a later suite in the same process inherits them (a stubbed startWarpTo
-- eats every warp after this one)
local realPlay, realPlayBattle = Music.play, Music.playBattle
local realStartWarpTo = OW.startWarpTo
local realEvents = Runtime.events
local songs = {} local songs = {}
Music.play = function(_, song) songs[#songs + 1] = song end Music.play = function(_, song) songs[#songs + 1] = song end
local function lastSong() return songs[#songs] end local function lastSong() return songs[#songs] end
@@ -160,4 +167,8 @@ eq(labResult, "lose", "it still finishes as a loss")
eq(Game.save.money, 3000, "no money is lost in the lab") eq(Game.save.money, 3000, "no money is lost in the lab")
eq(warp, nil, "and the player stays in the lab for OaksLabRivalEndBattleScript") eq(warp, nil, "and the player stays in the lab for OaksLabRivalEndBattleScript")
OW.startWarpTo = realStartWarpTo
Music.play, Music.playBattle = realPlay, realPlayBattle
Runtime.install(realEvents, Runtime.hooks)
S.finish() S.finish()
+3 -2
View File
@@ -33,7 +33,8 @@ local function battleWith(partyHP, result)
for i, hp in ipairs(partyHP) do for i, hp in ipairs(partyHP) do
party[i] = { species = "SQUIRTLE", hp = hp, stats = { hp = 20 } } party[i] = { species = "SQUIRTLE", hp = hp, stats = { hp = 20 } }
end end
return { -- the metatable so playerMonFainted can reach playerPartyView
return setmetatable({
kind = "wild", kind = "wild",
result = result, result = result,
afterQueue = nil, afterQueue = nil,
@@ -44,7 +45,7 @@ local function battleWith(partyHP, result)
sayNext = function(self, m) self.said[#self.said + 1] = m end, sayNext = function(self, m) self.said[#self.said + 1] = m end,
say = function(self, m) self.said[#self.said + 1] = m end, say = function(self, m) self.said[#self.said + 1] = m end,
ui = function() end, ui = function() end,
} }, BattleState)
end end
local function saidBlackout(b) local function saidBlackout(b)
+36 -41
View File
@@ -2642,7 +2642,6 @@ do
local Tilt = require("src.render.Tilt") local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX") local GBCFX = require("src.render.GBCFX")
local GameSpeed = require("src.core.GameSpeed") local GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap") local FrameCap = require("src.core.FrameCap")
local SD = require("src.core.SaveData") local SD = require("src.core.SaveData")
-- Isolate from earlier save/options writes in this suite -- Isolate from earlier save/options writes in this suite
@@ -2663,6 +2662,19 @@ do
om:update(1 / 60) om:update(1 / 60)
OInput.pressed = {} OInput.pressed = {}
end end
-- walk the cursor down to a row by id, so a row added to OptionsMenu
-- shifts these blocks instead of silently retargeting them
local function seek(id)
local want = -1
for i, row in ipairs(om.rows) do
if row.id == id then want = i end
end
for _ = 1, #om.rows do
if om.index == want then break end
press("down")
end
return om.index == want
end
eq(og.save.options.textSpeed, 3, eq(og.save.options.textSpeed, 3,
"new saves default to MEDIUM text (InitOptions TEXT_DELAY_MEDIUM)") "new saves default to MEDIUM text (InitOptions TEXT_DELAY_MEDIUM)")
eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC") eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC")
@@ -2673,65 +2685,58 @@ do
eq(og.save.options.videoMode, "windowed", eq(og.save.options.videoMode, "windowed",
"new saves default VIDEO MODE to WINDOWED") "new saves default VIDEO MODE to WINDOWED")
eq(om.scroll, 0, "options viewport starts at the top") eq(om.scroll, 0, "options viewport starts at the top")
for _ = 1, 3 do press("down") end check(seek("battleLayout"), "cursor reaches BATTLE LAYOUT")
eq(om.index, 4, "cursor reaches BATTLE LAYOUT")
press("a") press("a")
eq(og.save.options.battleLayout, "wide", eq(og.save.options.battleLayout, "wide",
"A switches the battle screen to the WIDE layout") "A switches the battle screen to the WIDE layout")
press("a") press("a")
eq(og.save.options.battleLayout, "og", "BATTLE LAYOUT wraps back to OG") eq(og.save.options.battleLayout, "og", "BATTLE LAYOUT wraps back to OG")
for _ = 1, 5 do press("down") end check(seek("musicVol"), "cursor reaches MUSIC VOL")
eq(om.index, 9, "cursor reaches MUSIC VOL") eq(om.scroll, om.index - require("src.ui.OptionRows").VISIBLE,
eq(om.scroll, 5, "viewport scrolls to keep MUSIC VOL on screen") "viewport scrolls to keep MUSIC VOL on screen")
press("left") press("left")
eq(og.save.options.musicVol, 6, "left lowers MUSIC VOL") eq(og.save.options.musicVol, 6, "left lowers MUSIC VOL")
press("right") press("right")
eq(og.save.options.musicVol, 7, "right raises MUSIC VOL back") eq(og.save.options.musicVol, 7, "right raises MUSIC VOL back")
press("right") press("right")
eq(og.save.options.musicVol, 7, "MUSIC VOL clamps at 7") eq(og.save.options.musicVol, 7, "MUSIC VOL clamps at 7")
press("down"); press("left") seek("sfxVol"); press("left")
eq(og.save.options.sfxVol, 6, "SFX VOL adjusts on its own row") eq(og.save.options.sfxVol, 6, "SFX VOL adjusts on its own row")
press("down") seek("musicFilter")
for _ = 1, 3 do press("a") end for _ = 1, 3 do press("a") end
eq(og.save.options.musicFilter, 3, "A cycles MUSIC FILTER to 3X") eq(og.save.options.musicFilter, 3, "A cycles MUSIC FILTER to 3X")
press("a") press("a")
eq(og.save.options.musicFilter, 0, "MUSIC FILTER wraps back to OFF") eq(og.save.options.musicFilter, 0, "MUSIC FILTER wraps back to OFF")
press("down") check(seek("performance"), "cursor reaches PERFORMANCE")
eq(om.index, 12, "cursor reaches PERFORMANCE")
press("a") press("a")
eq(og.save.options.performance, "high", "A cycles PERFORMANCE to HIGH") eq(og.save.options.performance, "high", "A cycles PERFORMANCE to HIGH")
eq(require("src.core.Performance").tier, "high", eq(require("src.core.Performance").tier, "high",
"the live tier tracks the PERFORMANCE option") "the live tier tracks the PERFORMANCE option")
for _ = 1, 3 do press("a") end for _ = 1, 3 do press("a") end
eq(og.save.options.performance, "auto", "PERFORMANCE wraps back to AUTO") eq(og.save.options.performance, "auto", "PERFORMANCE wraps back to AUTO")
press("down") check(seek("colors"), "cursor reaches COLORS")
eq(om.index, 13, "cursor reaches COLORS")
press("a") press("a")
for _ = 1, 4 do press("a") end for _ = 1, 4 do press("a") end
press("down") check(seek("tilt"), "cursor reaches TILT")
eq(om.index, 14, "cursor reaches TILT")
press("a") press("a")
eq(og.save.options.tilt, 1, "A cycles TILT to 15") eq(og.save.options.tilt, 1, "A cycles TILT to 15")
eq(Tilt.level, 1, "Tilt level tracks TILT option") eq(Tilt.level, 1, "Tilt level tracks TILT option")
press("a"); press("a"); press("a") press("a"); press("a"); press("a")
eq(og.save.options.tilt, 0, "TILT wraps back to OFF") eq(og.save.options.tilt, 0, "TILT wraps back to OFF")
press("down") check(seek("gbcfx"), "cursor reaches GBC FX")
eq(om.index, 15, "cursor reaches GBC FX")
press("a") press("a")
eq(og.save.options.gbcfx, 1, "A cycles GBC FX to 1") eq(og.save.options.gbcfx, 1, "A cycles GBC FX to 1")
eq(GBCFX.level, 1, "GBCFX level tracks GBC FX option") eq(GBCFX.level, 1, "GBCFX level tracks GBC FX option")
for _ = 1, 4 do press("a") end for _ = 1, 4 do press("a") end
eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF") eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF")
press("down") check(seek("zoom"), "cursor reaches ZOOM")
eq(om.index, 16, "cursor reaches ZOOM")
local ZoomOpt = require("src.render.Zoom") local ZoomOpt = require("src.render.Zoom")
press("a") press("a")
eq(og.save.options.zoom, 1, "A cycles ZOOM to IN1") eq(og.save.options.zoom, 1, "A cycles ZOOM to IN1")
eq(ZoomOpt.offset, 1, "Zoom.offset tracks ZOOM option") eq(ZoomOpt.offset, 1, "Zoom.offset tracks ZOOM option")
press("left") press("left")
eq(og.save.options.zoom, 0, "left steps ZOOM back to FIT") eq(og.save.options.zoom, 0, "left steps ZOOM back to FIT")
press("down") check(seek("voidFill"), "cursor reaches VOID FILL")
eq(om.index, 17, "cursor reaches VOID FILL")
local TR = require("src.render.TileRenderer") local TR = require("src.render.TileRenderer")
press("a") press("a")
eq(og.save.options.voidFill, "water", "A cycles VOID FILL to WATER") eq(og.save.options.voidFill, "water", "A cycles VOID FILL to WATER")
@@ -2740,18 +2745,15 @@ do
eq(og.save.options.voidFill, "black", "A cycles VOID FILL to BLACK") eq(og.save.options.voidFill, "black", "A cycles VOID FILL to BLACK")
press("a") press("a")
eq(og.save.options.voidFill, "trees", "VOID FILL wraps back to TREES") eq(og.save.options.voidFill, "trees", "VOID FILL wraps back to TREES")
press("down") check(seek("videoMode"), "cursor reaches VIDEO MODE")
eq(om.index, 18, "cursor reaches VIDEO MODE")
press("a") press("a")
eq(og.save.options.videoMode, "borderless", eq(og.save.options.videoMode, "borderless",
"A cycles VIDEO MODE to BORDERLESS") "A cycles VIDEO MODE to BORDERLESS")
press("a") press("a")
eq(og.save.options.videoMode, "windowed", eq(og.save.options.videoMode, "windowed",
"VIDEO MODE wraps back to WINDOWED") "VIDEO MODE wraps back to WINDOWED")
press("down") check(seek("faithfulRes"), "cursor reaches FAITHFUL RATIO")
eq(om.index, 19, "cursor reaches FAITHFUL RATIO") check(seek("fpsCap"), "cursor reaches MAX FPS")
press("down")
eq(om.index, 20, "cursor reaches MAX FPS")
press("a") press("a")
eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75") eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75")
eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option") eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option")
@@ -2761,8 +2763,7 @@ do
eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60") eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60")
-- RFC 0007: the single GAME SPEED row is now three independent rows, -- RFC 0007: the single GAME SPEED row is now three independent rows,
-- one per GameSpeed.CATEGORIES entry. -- one per GameSpeed.CATEGORIES entry.
press("down") check(seek("speedOverworld"), "cursor reaches OVERWORLD SPEED")
eq(om.index, 21, "cursor reaches OVERWORLD SPEED")
press("a") press("a")
eq(og.save.options.speedOverworld, 2, "A cycles OVERWORLD SPEED to 2X") eq(og.save.options.speedOverworld, 2, "A cycles OVERWORLD SPEED to 2X")
-- Driven by the level list rather than a literal press count: adding a -- Driven by the level list rather than a literal press count: adding a
@@ -2770,26 +2771,20 @@ do
-- bug when the cycling is fine and the row is simply one longer. -- bug when the cycling is fine and the row is simply one longer.
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speedOverworld, 1, "OVERWORLD SPEED wraps back to NORMAL") eq(og.save.options.speedOverworld, 1, "OVERWORLD SPEED wraps back to NORMAL")
press("down") check(seek("speedBattle"), "cursor reaches BATTLE SPEED")
eq(om.index, 22, "cursor reaches BATTLE SPEED")
press("a") press("a")
eq(og.save.options.speedBattle, 2, "A cycles BATTLE SPEED to 2X") eq(og.save.options.speedBattle, 2, "A cycles BATTLE SPEED to 2X")
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speedBattle, 1, "BATTLE SPEED wraps back to NORMAL") eq(og.save.options.speedBattle, 1, "BATTLE SPEED wraps back to NORMAL")
press("down") check(seek("speedMenu"), "cursor reaches MENU SPEED")
eq(om.index, 23, "cursor reaches MENU SPEED")
press("a") press("a")
eq(og.save.options.speedMenu, 2, "A cycles MENU SPEED to 2X") eq(og.save.options.speedMenu, 2, "A cycles MENU SPEED to 2X")
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speedMenu, 1, "MENU SPEED wraps back to NORMAL") eq(og.save.options.speedMenu, 1, "MENU SPEED wraps back to NORMAL")
press("down") check(seek("mods"), "cursor reaches MODS")
eq(om.index, 24, "cursor reaches MODS") check(seek("controls"), "cursor reaches CONTROLS")
press("down") check(seek("dateFormat"), "cursor reaches DATE FORMAT")
eq(om.index, 25, "cursor reaches CONTROLS") check(seek("timeFormat"), "cursor reaches TIME FORMAT")
press("down")
eq(om.index, 26, "cursor reaches DATE FORMAT")
press("down")
eq(om.index, 27, "cursor reaches TIME FORMAT")
press("down") press("down")
-- CANCEL is appended after the descriptor list rather than living in it, so -- CANCEL is appended after the descriptor list rather than living in it, so
-- it lands one past #rows and the window holds the last six boxes. Counted -- it lands one past #rows and the window holds the last six boxes. Counted
@@ -2812,7 +2807,7 @@ do
GBCFX.applyOptions(og.save.options) GBCFX.applyOptions(og.save.options)
require("src.render.Zoom").applyOptions(og.save.options) require("src.render.Zoom").applyOptions(og.save.options)
require("src.render.TileRenderer").applyOptions(og.save.options) require("src.render.TileRenderer").applyOptions(og.save.options)
VideoMode.applyOptions(og.save.options) require("src.core.VideoMode").applyOptions(og.save.options)
end end
end end