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](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,
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
-- 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 {
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 = {
-- BikeShopMiddleAgedWomanText (pokered/scripts/BikeShop.asm):
-- always shows the same flavor line, no branching.
+1
View File
@@ -168,6 +168,7 @@ return {
{ "jump_if_true", "come_see" },
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "give_item", "POKE_BALL", 5, false },
{ "text_sound", "Get_Key_Item" }, -- OaksLab.asm:1060
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
{ "jump", "end" },
+11 -1
View File
@@ -516,7 +516,17 @@ M.ROUTE_24 = {
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
done)
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
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 push(game, s, done)
local function push(game, s, done, opts)
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, done))
game.stack:push(TextBox.new(game, s, done, opts))
end
-- 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
-- CinnabarGymQuizCorrectText: item jingle, then the gate
-- slides open (SFX_GO_INSIDE) if it was still locked
Sound.play(game.data, "Get_Item1")
push(game, t._CinnabarGymQuizCorrectText
or "You're absolutely\ncorrect!\fGo on through!", function()
if not game.save.flags[gymGateFlag(index)] then
@@ -244,7 +243,9 @@ M.CINNABAR_GYM = {
Sound.play(game.data, "Go_Inside")
end
applyGymGates(game, ow)
end)
end, { preSound = function()
return Sound.play(game.data, "Get_Item1")
end })
return
end
Sound.play(game.data, "Denied")
+6 -6
View File
@@ -17,9 +17,9 @@ local function surfingPikachu(game)
return nil
end
local function push(game, text, done)
local function push(game, text, done, opts)
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, text, done))
game.stack:push(TextBox.new(game, text, done, opts))
end
-- the two-variant posters: the surf-capable line once a surfing
@@ -69,11 +69,11 @@ return {
TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done)
local t = game.data.text
-- scripts/SummerBeachHouse.asm:68
push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!",
function()
require("src.core.Sound").playCry(game.data, "PIKACHU")
done()
end)
done, { auto = { wait = true, delay = 0, sound = function()
return require("src.core.Sound").playCry(game.data, "PIKACHU")
end } })
end,
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.
#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))
static void GRBootstrapInstall(void)
+29
View File
@@ -156,6 +156,7 @@ int w_syncHealthSteps(lua_State *L)
""" % MARKER
WRAP_REGISTRATION = """#ifdef LOVE_IOS
{ "getDeviceModel", w_getDeviceModel },
{ "pickFile", w_pickFile },
{ "pickFileKinds", w_pickFileKinds },
{ "createFile", w_createFile },
@@ -202,6 +203,7 @@ int w_syncHealthSteps(lua_State *L)
"""
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
{ "getDeviceModel", w_getDeviceModel },
{ "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload },
{ "httpRequest", w_httpRequest },
@@ -210,6 +212,33 @@ WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
BRIDGE_EXTRA_FUNCS = """
#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)
{
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)
function BattleState:animNext(name, isPlayer, shakes, ball)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert,
{ anim = name, attackerIsPlayer = isPlayer, shakes = shakes,
ball = ball })
local row = { anim = name, attackerIsPlayer = isPlayer, shakes = shakes,
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
-- insert an act right after the current queue item
@@ -1554,8 +1568,13 @@ end
function BattleState:sendOutText(name)
local e = self.enemy and self.enemy.mon
local pct = 100
if e and e.hp > 0 and math.floor(e.stats.hp / 4) > 0 then
pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4))
if e and e.hp > 0 then
-- 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
if pct >= 70 then return Strings("Go! %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)
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
-- starter Pikachu through PlayCry at all: SendOutMon branches to
-- .starterPikachu (engine/battle/core.asm:1807-1817) and voices PCM
@@ -1816,7 +1856,8 @@ function BattleState:enter()
self.enemySendingOut = true
self:slidePic("foe")
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()
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
@@ -1845,7 +1886,8 @@ function BattleState:enter()
self.sendingOut = true
self:slidePic("back")
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
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
self:queueSendOutAnim(true)
@@ -2659,22 +2701,28 @@ function BattleState:resolveSwitch(newMon)
self.phase = "messages"
self.afterQueue = "menu"
self:act(function()
self:restoreMimicked(self.player) -- the battle copy leaves with it
local previous = self.player
self.player = makeBattler(self.data, newMon, true, self.game.save)
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player,
previous = previous,
})
self:markParticipant()
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:queueSendOutAnim(false)
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
-- outgoing pic and holds 50 frames before the mon is recalled
self:sayNextAuto(self:withdrawText(self.player.name),
Timing.SWITCH_PLAYER_MON)
self:actNext(function()
self:restoreMimicked(self.player) -- the battle copy leaves with it
local previous = self.player
self.player = makeBattler(self.data, newMon, true, self.game.save)
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player,
previous = previous,
})
self:markParticipant()
sendOutMonCursors(self)
self.sendingOut = true
self:sayNextAuto(self:sendOutText(self.player.name))
self:queueSendOutAnim(false)
end)
end)
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
@@ -2703,7 +2751,12 @@ function BattleState:residualFor(b, opp)
if b.residualDone then return end
b.residualDone = true
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
-- 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
-- the drain plays the ABSORB animation from the healing side
-- (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)
-- _AIBattleWithdrawText: "X with-/drew Y!"
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
end
@@ -4277,6 +4340,9 @@ function BattleState:enemyMonFainted()
self:act(function()
local previous = self.enemy
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
clearTrapping(self.player)
self:syncSides()
@@ -4296,7 +4362,7 @@ function BattleState:enemyMonFainted()
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
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.enemySendingOut = false
self:startGrowIn(self.enemy)
@@ -4309,34 +4375,41 @@ function BattleState:enemyMonFainted()
self:act(function()
local mon = shiftSwitchMon
if not mon then return end
local previous = self.player
self.player = makeBattler(self.data, mon, true, self.game.save)
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
battler = self.player, previous = previous,
})
-- 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()
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
-- hold, then the recall and the send-out
self.nextInsert = 0
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:queueSendOutAnim(false)
self:sayNextAuto(self:withdrawText(self.player.name),
Timing.SWITCH_PLAYER_MON)
self:actNext(function()
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)
return
end
@@ -4526,7 +4599,7 @@ function BattleState:openReplacementMenu()
self.nextInsert = 0
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:sayNextAuto(self:sendOutText(self.player.name))
self:queueSendOutAnim(false)
end,
})
+20 -1
View File
@@ -92,6 +92,20 @@ local function hitCount(ctx, record)
return dist[r + 1]
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
-- stage keeps the original's exact check order and rng consumption
-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy ->
@@ -318,7 +332,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- secondary side effects (blocked by fainting)
if record and record.run and record.kind ~= "primary"
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)
end
end
+9
View File
@@ -581,6 +581,15 @@ MoveEffects.full = {
end,
},
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)
local user = ctx.user
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 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)
if value == nil then return nil end
local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "")
@@ -36,6 +79,16 @@ local function commandValue(command)
return clean(value)
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 text = tostring(value or "")
return (text:gsub("([^%w%-_%.~])", function(char)
@@ -66,6 +119,25 @@ local function loveVersion()
return result
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 version = clean(Version.engine)
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
local function deviceModel(rawOS, system)
local model = clean(call(system.getModel))
if model then return model end
local nativeModel = clean(call(system.getDeviceModel))
if nativeModel then return friendlyModel(nativeModel) end
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
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
if rawOS == "Linux" then
return commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
or commandValue("cat /sys/devices/virtual/dmi/id/model 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"))
end
if rawOS == "Android" then
return commandValue("getprop ro.product.model 2>/dev/null")
return friendlyModel(commandValue("getprop ro.product.model 2>/dev/null"))
end
return nil
end
@@ -121,7 +198,7 @@ local function metadata(options, context)
local window = love and love.window or {}
local rawOS = clean(call(system.getOS))
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 pixelWidth, pixelHeight = call(graphics.getPixelDimensions)
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
end
add("Platform", formOS(rawOS))
local hardware = model
if rendererDevice and rendererDevice ~= model then
hardware = hardware and (hardware .. " (" .. rendererDevice .. ")") or rendererDevice
end
add("Device", hardware)
add("Device", model)
local rendererDetails = clean(renderer)
if rendererDetails and clean(rendererVersion) then
rendererDetails = rendererDetails .. " " .. clean(rendererVersion)
-27
View File
@@ -541,29 +541,6 @@ local function modRows(opts, mod)
return rows
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)
--
-- 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 }
end
end
sections[#sections + 1] = {
title = Strings("TROUBLESHOOTING"),
rows = troubleshootingRows(opts, hooks),
}
return {
opts = opts,
version = version,
+131 -38
View File
@@ -406,6 +406,15 @@ local function cartQuad(project, x, y, w, h, z)
}
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 points, radius = {}, h / 2
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),
math.floor(shell[3] * 0.54) }
cartPolygon(mainBack, side, 1)
cartPolygon(capBack, side, 1)
local frontFacing = cartFacing(mainFront)
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[3], mainFront[4], mainBack[4], mainBack[3] }, side, 1)
cartPolygon({ mainFront[1], mainFront[2], mainBack[2], mainBack[1] }, side, 1)
cartPolygon({ capFront[2], capFront[3], capBack[3], capBack[2] }, side, 1)
cartPolygon({ capFront[1], capFront[2], capBack[2], capBack[1] }, side, 1)
cartPolygon({ capFront[4], capFront[1], capBack[1], capBack[4] }, side, 1)
cartPolygon(mainFront, shell, 1)
cartPolygon(capFront, shell, 1)
local faceZ = depth + 0.8
for i = 0, 4 do
local ry = mainTop + 7 + i * h * 0.025
cartPolygon(cartQuad(project, -halfW + 2, ry, w * 0.13, 2, faceZ), side, 0.7)
cartPolygon(cartQuad(project, halfW - w * 0.13 - 2, ry, w * 0.13, 2, faceZ), side, 0.7)
if frontFacing then
cartPolygon(mainFront, shell, 1)
cartPolygon(capFront, shell, 1)
else
cartPolygon(mainBack, side, 1)
cartPolygon(capBack, side, 1)
end
local recessX, recessY = -w * 0.32, mainTop + h * 0.023
local recessW, recessH = w * 0.64, h * 0.24
cartPolygon(cartQuad(project, recessX, recessY, recessW, recessH, faceZ), shell, 0.88)
cartPill(project, recessX + w * 0.025, recessY + h * 0.025,
recessW - w * 0.05, h * 0.12, faceZ + 0.5, shell, 0.7)
cartPill(project, recessX + w * 0.045, recessY + h * 0.043,
recessW - w * 0.09, h * 0.083, faceZ + 0.8, side, 0.42)
local labelX, labelY = -w * 0.33, -h * 0.20
local labelW, labelH = w * 0.66, h * 0.55
local plate = cartQuad(project, labelX - 2, labelY - 2, labelW + 4, labelH + 4, faceZ + 0.8)
cartPolygon(plate, side, 0.95)
local labelPoints = cartQuad(project, labelX, labelY, labelW, labelH, faceZ + 1.2)
local label = cartridgeLabel(imp, version)
local mesh = label and cartLabelMesh(imp, version, label, labelPoints)
if mesh then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(mesh)
elseif label then
local artScale = math.min(labelW / label.width, labelH / label.height)
love.graphics.draw(label.image, labelPoints[1][1], labelPoints[1][2],
0, artScale, artScale)
if frontFacing then
local faceZ = depth + 0.8
for i = 0, 4 do
local ry = mainTop + 7 + i * h * 0.025
cartPolygon(cartQuad(project, -halfW + 2, ry, w * 0.13, 2, faceZ), side, 0.7)
cartPolygon(cartQuad(project, halfW - w * 0.13 - 2, ry, w * 0.13, 2, faceZ), side, 0.7)
end
local recessX, recessY = -w * 0.32, mainTop + h * 0.023
local recessW, recessH = w * 0.64, h * 0.24
cartPolygon(cartQuad(project, recessX, recessY, recessW, recessH, faceZ), shell, 0.88)
cartPill(project, recessX + w * 0.025, recessY + h * 0.025,
recessW - w * 0.05, h * 0.12, faceZ + 0.5, shell, 0.7)
cartPill(project, recessX + w * 0.045, recessY + h * 0.043,
recessW - w * 0.09, h * 0.083, faceZ + 0.8, side, 0.42)
local labelX, labelY = -w * 0.33, -h * 0.20
local labelW, labelH = w * 0.66, h * 0.55
local plate = cartQuad(project, labelX - 2, labelY - 2, labelW + 4, labelH + 4, faceZ + 0.8)
cartPolygon(plate, side, 0.95)
local labelPoints = cartQuad(project, labelX, labelY, labelW, labelH, faceZ + 1.2)
local label = cartridgeLabel(imp, version)
local mesh = label and cartLabelMesh(imp, version, label, labelPoints)
if mesh then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(mesh)
elseif label then
local artScale = math.min(labelW / label.width, labelH / label.height)
love.graphics.draw(label.image, labelPoints[1][1], labelPoints[1][2],
0, artScale, artScale)
end
cartPolygon({
{ project(-w * 0.07, h * 0.37, faceZ + 1) },
{ project(w * 0.07, h * 0.37, faceZ + 1) },
{ project(0, h * 0.43, faceZ + 1) },
}, side, 0.70)
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()
if not state.active and (Kit._activateId == key) then
@@ -926,10 +948,13 @@ local HEADER_TABS = {
{ id = "mods", key = "tab-mods" },
{ id = "find", key = "tab-find" },
{ id = "skins", key = "tab-skins", glyph = true },
{ id = "bug", key = "tab-bug" },
}
for _, t in ipairs(HEADER_TABS) do
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
-- 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")
imp._findIcon = imp._findIcon
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
-- 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
@@ -1077,6 +1104,7 @@ local function buildHeader(imp, m)
for _, t in ipairs(tabs) do
if t.id == "mods" then t.icon = imp._modsIcon end
if t.id == "find" then t.icon = imp._findIcon end
if t.id == "bug" then t.icon = imp._bugIcon end
end
local tabH = m.chip
local tx = m.x + m.pad
@@ -1923,7 +1951,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
-- notice line
local noticeText, noticeCol
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
noticeText = imp.modNotice.text
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
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)
imp:_ensureFind()
imp:_ensureMods()
@@ -4822,6 +4913,8 @@ function LauncherView.draw(imp)
contentH = buildFindPanel(imp, x, py, panelW, budgetH, m)
elseif imp.tab == "skins" then
contentH = buildSkinsPanel(imp, x, py, panelW, budgetH, m)
elseif imp.tab == "bug" then
contentH = buildBugPanel(imp, x, py, panelW, budgetH, m)
else
contentH = buildGamePanel(imp, x, py, panelW, availH, m, imp.tab, budgetH)
end
+32 -20
View File
@@ -1266,6 +1266,7 @@ end
function RomImporter:_applyLastVersionTab()
local okLO, LO = pcall(require, "src.core.LaunchOptions")
if okLO and LO.pendingTab then return end
if os.getenv("POKEPORT_LAUNCHER_TAB") then return end
local okOpt, opts = pcall(function()
return require("src.core.SaveData").loadOptions()
end)
@@ -1337,7 +1338,7 @@ function RomImporter.new(onComplete, opts)
-- player at least arrives on the tab they asked for (src/core/LaunchOptions).
tab = (function()
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)(),
logo = love.graphics.newImage("assets/logo/logo.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 }.
-- requiredImportNotice stays inside the imported-files modal so validation
-- 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 =
-- every game). Rows resolve their enable-state and their "runs here"
-- verdict against it (src/mods/ModTargets.lua).
@@ -2755,7 +2757,7 @@ function RomImporter:resumeAfterOverlay()
end
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
for i, id in ipairs(order) do
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).
local hooks = {}
local version = self.tab
hooks.reportIssue = function(opts)
return self:_reportIssue(opts, version)
end
if self.onEditTouchControls then
local version = self.tab
hooks.editTouchControls = function()
@@ -3655,7 +3654,6 @@ function RomImporter:_openSettings()
end)
if ok and model then
self._settings = model
self._settingsSafeModeAtOpen = require("src.core.SaveData").isSafeMode(model.opts)
end
end
@@ -3670,22 +3668,36 @@ function RomImporter:_closeSettings()
local model = self._settings
if model then
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
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
function RomImporter:_reportIssue(options, version)
self.issueNotice = nil
local ok, IssueReport = pcall(require, "src.core.IssueReport")
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
end
local opened, url, reason = IssueReport.open(options, {
@@ -3693,11 +3705,11 @@ function RomImporter:_reportIssue(options, version)
mods = self.mods,
})
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
end
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
end
@@ -4180,7 +4192,7 @@ end
-- Enabling an experimental mod arms a confirmation for that same game.
function RomImporter:_toggleMod(id, confirmed, version)
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
end
local LauncherMods = require("src.mods.LauncherMods")
@@ -4224,7 +4236,7 @@ end
-- recovery action, and Delete is the only destructive one on this panel.
function RomImporter:_setAllMods(want, confirmed)
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
end
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.auto = opts and opts.auto
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
-- 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
@@ -270,6 +272,17 @@ end
function TextBox:update(dt)
local input = self.game.input
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
-- and clear run (src/core/Timing.lua TEXT_SCROLL_PAIR / TEXT_PAGE_CLEAR).
-- 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
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
else
Sound.play(ctx.game.data, jingle)
-- scripts/OaksLab.asm:1058
Commands.text_sound(ctx, jingle)
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
-- too (not only on step), so a save/load mid-gate keeps exits correct
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
-- Neighbor maps drawn at the composed connection offsets: at least the
@@ -2910,7 +2919,15 @@ function OverworldState:openPC(onDone)
keepOpen = true,
onSelect = function()
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()
end,
})
@@ -2920,9 +2937,13 @@ function OverworldState:openPC(onDone)
label = (Game.save.player.name or "RED") .. "'s PC",
keepOpen = true,
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")
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()
end,
})
+4
View File
@@ -126,6 +126,10 @@ local PLAYER_STATE_BY_ID = {
[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 = {
CANLOSE = 1,
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
-- 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 = {}
Commands.give_item(ctx, "FIX_POTION", 1, false)
T.eq(jingles(), 1, "the no-text gift still plays its jingle immediately")
T.eq(plays[1], "item.wav", "with the plain-item sound")
T.eq(jingles(), 0, "the no-text gift plays nothing on the spot")
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
-- 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),
"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")
+21
View File
@@ -50,20 +50,34 @@ local imp = read("src/import/RomImporter.lua")
check(view:find('id = "skins"', 1, true) ~= nil,
"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,
"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
local order = view:match("local HEADER_TABS = %{(.-)%}\n")
check(order ~= nil, "HEADER_TABS found")
if order then
local findAt = order:find('id = "find"', 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,
"the skins tab sits immediately after Find")
check(skinsAt and bugAt and bugAt > skinsAt,
"the bug tab sits after Skins")
end
check(view:find('imp.tab == "skins"', 1, true) ~= nil,
"the panel dispatch routes the skins tab")
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
check(view:find("if imp.onOpenSkinStudio then", 1, true) ~= nil,
"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 = %{(.-)%}")
check(cycle and cycle:find('"skins"', 1, true) ~= nil,
"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")
check(switch and switch:find("_ensureSkins(true)", 1, true) ~= nil,
"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")
+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,
system = {
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,
},
graphics = {
@@ -78,10 +79,13 @@ check(not url:find("game=", 1, true)
check(fields.summary == "" and fields.location == "" and fields.screenshot == ""
and fields.steps == "" and fields.expected == "",
"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("Safe mode: on", 1, true) ~= nil,
"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),
"report metadata omits unknown values")
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 previousModel = love.system.getModel
local previousDeviceModel = love.system.getDeviceModel
local previousIO = _G.io
love.system.getOS = function() return "OS X" end
love.system.getModel = nil
love.system.getDeviceModel = nil
_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 {
read = function() return "MacBookPro18,3" end,
read = function() return output end,
close = function() end,
}
end,
}
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")
love.system.getOS = function() return "UWP" end
local xboxInfo = IssueReport.metadata({}, { mods = {} })
check(xboxInfo.os == "Xbox", "report maps the Xbox runtime platform")
love.system.getOS = previousOS
love.system.getModel = previousModel
love.system.getDeviceModel = previousDeviceModel
_G.io = previousIO
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 = {
id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155, index = 155,
growthRate = "GROWTH_MEDIUM_SLOW",
-- data/pokemon/base_stats/cyndaquil.asm:10 GENDER_F12_5
genderRatio = 31,
types = { "FIRE", "FIRE" },
baseStats = {
hp = 39, attack = 52, defense = 43, speed = 65,
@@ -105,6 +107,8 @@ local DATA = {
TOTODILE = {
id = "TOTODILE", name = "TOTODILE", dex = 158, index = 158,
growthRate = "GROWTH_MEDIUM_SLOW",
-- data/pokemon/base_stats/totodile.asm:10 GENDER_F12_5
genderRatio = 31,
types = { "WATER", "WATER" },
baseStats = {
hp = 50, attack = 65, defense = 64, speed = 43,
@@ -163,8 +167,11 @@ end
-- is `levelMoves` and Gen 1 reads level1Moves / learnset.
local function mon(species, level, opts)
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, {
dvs = { attack = 15, defense = 15, speed = 15, special = 15 },
dvs = opts.dvs
or { attack = 15, defense = 15, speed = 15, special = 15 },
moves = opts.moves,
})
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 = "LEER", pp = 7, maxPp = 30 },
},
fields = { nickname = "CYNDAQUIL", gender = "male", item = "BERRY",
fields = { nickname = "CYNDAQUIL", item = "BERRY",
otName = "GOLD", otId = 12345 },
})
local TOTO = mon("TOTODILE", 10, {
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 } }
+4
View File
@@ -226,6 +226,10 @@ return { spawns = { SPAWN_NEW_BARK = { map = "TEST_MAP", x = 1, y = 1 } } }
spawnAfterChampion = "SPAWN_LANCE",
position = { map = "PLAYERS_HOUSE_2F", x = 1, y = 1, facing = "down" } }),
"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
-- ---- the two clock faces ----------------------------------------------------
+20 -7
View File
@@ -2876,8 +2876,9 @@ check(selGame.save.registeredItem == nil,
eq(selWorld:useSelectItem(), "not_registered",
"SELECT with nothing registered answers not_registered")
-- The PACK side: SELECT on a highlighted row is RegisterItem, the one PACK
-- button this port left unbound.
-- The PACK side: the item submenu's SEL row is RegisterItem's only door --
-- 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.input = stubInput()
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")
selGame.input:press("select")
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",
"and World:registerItem actually ran")
selGame.input:press("a")
@@ -2897,7 +2909,9 @@ selPack.index = selPack:total()
selGame.save.registeredItem = nil
selGame.input:press("select")
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")
-- CantRegisterText: a TM/HM row refuses from the PACK too.
@@ -2906,9 +2920,8 @@ selPack:rebuild()
local tmPack = PackMenu.new(selGame, { pocket = "TM_HM" })
tmPack.index = 1
check(tmPack.rows[1].id == "HM_CUT", "the TM/HM pocket row under test")
selGame.input:press("select")
tmPack:update(0)
check(tmPack.message ~= nil, "SELECT on the HM still opens a message")
tmPack:registerSelected()
check(tmPack.message ~= nil, "SEL on the HM still opens a message")
check(selGame.save.registeredItem == nil,
"CantRegisterText: the HM never becomes the registered item")
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
-- 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.
-- Red/Blue land facing up (#944); only Yellow keeps boot.startFacing.
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")
check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE"
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")
if game.stringBuffer then
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
text = text:gsub("{[%w_:]+}", "")
return text
+44 -30
View File
@@ -282,13 +282,19 @@ local function optGame()
end
local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
"battleFit", "battleBg", "uiLayout",
"battleFit", "battleHud", "battleBg", "uiLayout",
"ruleset", "musicVol", "sfxVol", "musicFilter",
"performance", "colors",
"tilt", "gbcfx", "zoom", "voidFill", "videoMode",
"faithfulRes", "fpsCap",
"speedOverworld", "speedBattle", "speedMenu",
"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)")
for i, id in ipairs(WANT_IDS) do
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
om.game.save.options.ruleset = "gen1_faithful"
check(om.rows[8].value(om.game) == "GEN 1", "ruleset row shows record.name")
om.rows[8].step(om.game, 1)
check(orow(om, "ruleset").value(om.game) == "GEN 1",
"ruleset row shows record.name")
orow(om, "ruleset").step(om.game, 1)
check(om.game.save.options.ruleset == "modern_clean",
"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",
"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")
om.rows[4].step(om.game, 1)
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")
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")
-- ZOOM / VOID FILL rows (indices track WANT_IDS above; the battle
-- composition rows -- BATTLE SIZE / BATTLE BG / UI LAYOUT -- sit ahead of
-- RULESET, and FAITHFUL RATIO lands between VIDEO MODE and MAX FPS)
-- ZOOM / VOID FILL rows (looked up by id; WANT_IDS above pins the order)
local Zoom = require("src.render.Zoom")
local TileRenderer = require("src.render.TileRenderer")
om.game.save.options.zoom = 0
Zoom.offset = 0
check(om.rows[16].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
om.rows[16].step(om.game, 1)
check(orow(om, "zoom").value(om.game) == "FIT",
"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,
"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"
and TileRenderer.voidFill == "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")
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")
-- the MAX FPS row cycles the render-cap steps and shows the value plain
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")
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.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.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")
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")
-- ------- 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
local mgGame = optGame()
om = OptionsMenu.new(mgGame)
om.rows[24].activate(mgGame)
orow(om, "mods").activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState",
@@ -394,7 +401,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame()
om = OptionsMenu.new(cbGame)
om.rows[25].activate(cbGame)
orow(om, "controls").activate(cbGame)
local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu,
"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
om.game.save.options.dateFormat = "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")
om.rows[26].step(om.game, 1)
orow(om, "dateFormat").step(om.game, 1)
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")
check(om.rows[27].value(om.game) == "DEVICE",
check(orow(om, "timeFormat").value(om.game) == "DEVICE",
"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"
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")
check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil,
"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
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,
map = { def = { tileset = "OVERWORLD" } }, dark = false,
partyKnows = function(self, id) return self.knows == id end,
knows = "STRENGTH" }
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.data.text = {} -- the strength texts fall back to Strings sources
sgame.save.inventory.RAINBOWBADGE = 1
@@ -1233,7 +1248,7 @@ local Loader = require("src.mods.Loader")
local uiFiles = {
["mods/uikit/manifest.json"] =
'{"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 = {
read = function(path) return uiFiles[path] end,
@@ -1267,8 +1282,7 @@ local uiFs = {
}
local uiLoader = Loader.new({ fs = uiFs })
check(uiLoader:load({}) == true, "the uikit fixture loads clean")
local uiApi = _G.MOD_UI_API
_G.MOD_UI_API = nil
local uiApi = (uiLoader.exports.uikit or {}).api
check(uiApi ~= nil, "the entry chunk received its api")
check(uiApi.ui == ModUI, "mod.ui is the toolkit facade")
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
tb:resolveSwitch(Game.save.party[2])
acts[1]() -- send-out clears foe trap
acts[#acts]()
eq(tb.enemy.trappingTurns, nil, "player switch clears foe Wrap/Bind/etc.")
eq(tb.enemy.trapMove, nil, "player switch clears trapMove")
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.show_object = realShow
package.loaded["src.ui.Menu"] = realMenu
if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic end
if realSound ~= nil then package.loaded["src.core.Sound"] = realSound end
package.loaded["src.core.Music"] = realMusic
package.loaded["src.core.Sound"] = realSound
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
-- was never started" have to read differently. Music.playMap still sets the
-- 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 = {}
Music.play = function(_, song) songs[#songs + 1] = song 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(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()
+3 -2
View File
@@ -33,7 +33,8 @@ local function battleWith(partyHP, result)
for i, hp in ipairs(partyHP) do
party[i] = { species = "SQUIRTLE", hp = hp, stats = { hp = 20 } }
end
return {
-- the metatable so playerMonFainted can reach playerPartyView
return setmetatable({
kind = "wild",
result = result,
afterQueue = nil,
@@ -44,7 +45,7 @@ local function battleWith(partyHP, result)
sayNext = function(self, m) self.said[#self.said + 1] = m end,
say = function(self, m) self.said[#self.said + 1] = m end,
ui = function() end,
}
}, BattleState)
end
local function saidBlackout(b)
+36 -41
View File
@@ -2642,7 +2642,6 @@ do
local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap")
local SD = require("src.core.SaveData")
-- Isolate from earlier save/options writes in this suite
@@ -2663,6 +2662,19 @@ do
om:update(1 / 60)
OInput.pressed = {}
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,
"new saves default to MEDIUM text (InitOptions TEXT_DELAY_MEDIUM)")
eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC")
@@ -2673,65 +2685,58 @@ do
eq(og.save.options.videoMode, "windowed",
"new saves default VIDEO MODE to WINDOWED")
eq(om.scroll, 0, "options viewport starts at the top")
for _ = 1, 3 do press("down") end
eq(om.index, 4, "cursor reaches BATTLE LAYOUT")
check(seek("battleLayout"), "cursor reaches BATTLE LAYOUT")
press("a")
eq(og.save.options.battleLayout, "wide",
"A switches the battle screen to the WIDE layout")
press("a")
eq(og.save.options.battleLayout, "og", "BATTLE LAYOUT wraps back to OG")
for _ = 1, 5 do press("down") end
eq(om.index, 9, "cursor reaches MUSIC VOL")
eq(om.scroll, 5, "viewport scrolls to keep MUSIC VOL on screen")
check(seek("musicVol"), "cursor reaches MUSIC VOL")
eq(om.scroll, om.index - require("src.ui.OptionRows").VISIBLE,
"viewport scrolls to keep MUSIC VOL on screen")
press("left")
eq(og.save.options.musicVol, 6, "left lowers MUSIC VOL")
press("right")
eq(og.save.options.musicVol, 7, "right raises MUSIC VOL back")
press("right")
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")
press("down")
seek("musicFilter")
for _ = 1, 3 do press("a") end
eq(og.save.options.musicFilter, 3, "A cycles MUSIC FILTER to 3X")
press("a")
eq(og.save.options.musicFilter, 0, "MUSIC FILTER wraps back to OFF")
press("down")
eq(om.index, 12, "cursor reaches PERFORMANCE")
check(seek("performance"), "cursor reaches PERFORMANCE")
press("a")
eq(og.save.options.performance, "high", "A cycles PERFORMANCE to HIGH")
eq(require("src.core.Performance").tier, "high",
"the live tier tracks the PERFORMANCE option")
for _ = 1, 3 do press("a") end
eq(og.save.options.performance, "auto", "PERFORMANCE wraps back to AUTO")
press("down")
eq(om.index, 13, "cursor reaches COLORS")
check(seek("colors"), "cursor reaches COLORS")
press("a")
for _ = 1, 4 do press("a") end
press("down")
eq(om.index, 14, "cursor reaches TILT")
check(seek("tilt"), "cursor reaches TILT")
press("a")
eq(og.save.options.tilt, 1, "A cycles TILT to 15")
eq(Tilt.level, 1, "Tilt level tracks TILT option")
press("a"); press("a"); press("a")
eq(og.save.options.tilt, 0, "TILT wraps back to OFF")
press("down")
eq(om.index, 15, "cursor reaches GBC FX")
check(seek("gbcfx"), "cursor reaches GBC FX")
press("a")
eq(og.save.options.gbcfx, 1, "A cycles GBC FX to 1")
eq(GBCFX.level, 1, "GBCFX level tracks GBC FX option")
for _ = 1, 4 do press("a") end
eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF")
press("down")
eq(om.index, 16, "cursor reaches ZOOM")
check(seek("zoom"), "cursor reaches ZOOM")
local ZoomOpt = require("src.render.Zoom")
press("a")
eq(og.save.options.zoom, 1, "A cycles ZOOM to IN1")
eq(ZoomOpt.offset, 1, "Zoom.offset tracks ZOOM option")
press("left")
eq(og.save.options.zoom, 0, "left steps ZOOM back to FIT")
press("down")
eq(om.index, 17, "cursor reaches VOID FILL")
check(seek("voidFill"), "cursor reaches VOID FILL")
local TR = require("src.render.TileRenderer")
press("a")
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")
press("a")
eq(og.save.options.voidFill, "trees", "VOID FILL wraps back to TREES")
press("down")
eq(om.index, 18, "cursor reaches VIDEO MODE")
check(seek("videoMode"), "cursor reaches VIDEO MODE")
press("a")
eq(og.save.options.videoMode, "borderless",
"A cycles VIDEO MODE to BORDERLESS")
press("a")
eq(og.save.options.videoMode, "windowed",
"VIDEO MODE wraps back to WINDOWED")
press("down")
eq(om.index, 19, "cursor reaches FAITHFUL RATIO")
press("down")
eq(om.index, 20, "cursor reaches MAX FPS")
check(seek("faithfulRes"), "cursor reaches FAITHFUL RATIO")
check(seek("fpsCap"), "cursor reaches MAX FPS")
press("a")
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")
@@ -2761,8 +2763,7 @@ do
eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60")
-- RFC 0007: the single GAME SPEED row is now three independent rows,
-- one per GameSpeed.CATEGORIES entry.
press("down")
eq(om.index, 21, "cursor reaches OVERWORLD SPEED")
check(seek("speedOverworld"), "cursor reaches OVERWORLD SPEED")
press("a")
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
@@ -2770,26 +2771,20 @@ do
-- bug when the cycling is fine and the row is simply one longer.
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speedOverworld, 1, "OVERWORLD SPEED wraps back to NORMAL")
press("down")
eq(om.index, 22, "cursor reaches BATTLE SPEED")
check(seek("speedBattle"), "cursor reaches BATTLE SPEED")
press("a")
eq(og.save.options.speedBattle, 2, "A cycles BATTLE SPEED to 2X")
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speedBattle, 1, "BATTLE SPEED wraps back to NORMAL")
press("down")
eq(om.index, 23, "cursor reaches MENU SPEED")
check(seek("speedMenu"), "cursor reaches MENU SPEED")
press("a")
eq(og.save.options.speedMenu, 2, "A cycles MENU SPEED to 2X")
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speedMenu, 1, "MENU SPEED wraps back to NORMAL")
press("down")
eq(om.index, 24, "cursor reaches MODS")
press("down")
eq(om.index, 25, "cursor reaches CONTROLS")
press("down")
eq(om.index, 26, "cursor reaches DATE FORMAT")
press("down")
eq(om.index, 27, "cursor reaches TIME FORMAT")
check(seek("mods"), "cursor reaches MODS")
check(seek("controls"), "cursor reaches CONTROLS")
check(seek("dateFormat"), "cursor reaches DATE FORMAT")
check(seek("timeFormat"), "cursor reaches TIME FORMAT")
press("down")
-- 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
@@ -2812,7 +2807,7 @@ do
GBCFX.applyOptions(og.save.options)
require("src.render.Zoom").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