mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 20:20:19 +02:00
@@ -0,0 +1 @@
|
||||
* @bryanthaboi
|
||||
@@ -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
|
||||
|
||||
[](https://www.youtube.com/watch?v=8IOgqbe4YvA)
|
||||
[](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 |
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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."
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)")
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)")
|
||||
@@ -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)")
|
||||
@@ -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)")
|
||||
@@ -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)")
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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 ----------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user