From 9ab80adaca88e0e6625d221e13783a771d0e4613 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:37:27 +0200 Subject: [PATCH 1/7] feat(launcher): add bug tab and native device reporting --- mobile/ios/native/GRBootstrap.m | 21 ++++ mobile/ios/patch_love_src.py | 29 +++++ src/core/IssueReport.lua | 99 ++++++++++++++--- src/import/LauncherSettings.lua | 27 ----- src/import/LauncherView.lua | 101 +++++++++++++++++- src/import/RomImporter.lua | 52 +++++---- .../ios_required_import_picker_test.lua | 8 ++ tests/engine/launcher_skins_tab.lua | 21 ++++ tests/engine/safe_mode_issue_report.lua | 20 +++- 9 files changed, 311 insertions(+), 67 deletions(-) diff --git a/mobile/ios/native/GRBootstrap.m b/mobile/ios/native/GRBootstrap.m index de7a59e8..f41439a4 100644 --- a/mobile/ios/native/GRBootstrap.m +++ b/mobile/ios/native/GRBootstrap.m @@ -6,6 +6,27 @@ // Registered from a constructor so no LÖVE/SDL source needs to know about it. #import +#import + +@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) diff --git a/mobile/ios/patch_love_src.py b/mobile/ios/patch_love_src.py index 4f715c7c..837210d8 100644 --- a/mobile/ios/patch_love_src.py +++ b/mobile/ios/patch_love_src.py @@ -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); diff --git a/src/core/IssueReport.lua b/src/core/IssueReport.lua index 910c1f74..4f63ffe2 100644 --- a/src/core/IssueReport.lua +++ b/src/core/IssueReport.lua @@ -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) diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index a73d7eed..dc14a5c1 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -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, diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 32593ab8..1a76fe69 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -875,6 +875,35 @@ local function drawSyncGlyph(x, y, w, h, hot) byy + head) love.graphics.pop() end + +local function drawBugGlyph(x, y, w, h, hot) + local box = math.min(w, h) + local bx = x + (w - box) / 2 + local by = y + (h - box) / 2 + local ink = hot and PAL.inverse or PAL.ink + local bodyW = box * 0.28 + local bodyH = box * 0.46 + local bodyX = bx + (box - bodyW) / 2 + local bodyY = by + box * 0.30 + local radius = math.max(1, box * 0.12) + Theme.fillRounded(bodyX, bodyY, bodyW, bodyH, ink, 1, radius) + Theme.fillRounded(bx + box * 0.38, by + box * 0.17, + box * 0.24, box * 0.24, ink, 1, box * 0.12) + love.graphics.push("all") + love.graphics.setColor(ink) + love.graphics.setLineWidth(math.max(1, box * 0.07)) + love.graphics.setLineJoin("bevel") + for _, offset in ipairs({ 0.35, 0.50, 0.65 }) do + love.graphics.line(bodyX, by + box * offset, + bx + box * 0.12, by + box * (offset - 0.07)) + love.graphics.line(bodyX + bodyW, by + box * offset, + bx + box * 0.88, by + box * (offset - 0.07)) + end + love.graphics.line(bx + box * 0.44, by + box * 0.18, + bx + box * 0.30, by + box * 0.08) + love.graphics.line(bx + box * 0.56, by + box * 0.18, + bx + box * 0.70, by + box * 0.08) + love.graphics.pop() end local function drawCross(x, y, size, color) @@ -926,10 +955,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", glyph = true }, } 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 = t.id == "bug" and drawBugGlyph or drawSkinGlyph + end end -- Which cartridge the dropdown is showing: the open game tab, else the last @@ -1923,7 +1955,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 +2354,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("Bug reports"), 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("Open GitHub with the bug form and the available system information filled in.") + 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("Report an issue"), 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 +4917,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 diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 8b1e3bea..a06aa5f5 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -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") diff --git a/tests/engine/ios_required_import_picker_test.lua b/tests/engine/ios_required_import_picker_test.lua index 56a13b1c..db66b9c1 100644 --- a/tests/engine/ios_required_import_picker_test.lua +++ b/tests/engine/ios_required_import_picker_test.lua @@ -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") diff --git a/tests/engine/launcher_skins_tab.lua b/tests/engine/launcher_skins_tab.lua index 3c69039e..154b4af5 100644 --- a/tests/engine/launcher_skins_tab.lua +++ b/tests/engine/launcher_skins_tab.lua @@ -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("drawBugGlyph", 1, true) ~= nil, + "the bug tab draws its own glyph rather than shipping an 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") diff --git a/tests/engine/safe_mode_issue_report.lua b/tests/engine/safe_mode_issue_report.lua index 428bbcd8..64154df5 100644 --- a/tests/engine/safe_mode_issue_report.lua +++ b/tests/engine/safe_mode_issue_report.lua @@ -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 }, { From a7c19be88ff9b7f63099f7b107fa9185b8376a46 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:18:54 +0200 Subject: [PATCH 2/7] fix(launcher): use standard bug report icon --- assets/launcher/bug.png | Bin 0 -> 2963 bytes src/import/LauncherView.lua | 42 ++++++---------------------- tests/engine/launcher_skins_tab.lua | 4 +-- 3 files changed, 10 insertions(+), 36 deletions(-) create mode 100644 assets/launcher/bug.png diff --git a/assets/launcher/bug.png b/assets/launcher/bug.png new file mode 100644 index 0000000000000000000000000000000000000000..b435b7fd1533084beb2048acfa41a5b0b431f59c GIT binary patch literal 2963 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 Date: Wed, 19 Aug 2026 15:22:50 +0200 Subject: [PATCH 3/7] fix(launcher): use rounded bug report icon --- assets/launcher/bug.png | Bin 2963 -> 3151 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/launcher/bug.png b/assets/launcher/bug.png index b435b7fd1533084beb2048acfa41a5b0b431f59c..6c7bc24d28a0dd4e4cb92dc72e097b3876f57216 100644 GIT binary patch delta 2979 zcmV;U3taS*7ta`wYz7r3^+<*o!157zV zAc%g})AFL@{Z0G4n*<}k2!9Hk0N;xuK$#4vJ24i3IUkFp@YIQL0vtPWMzI2>fLZTP zjA;1vQ{c$|KJz}uj{4sIfoJZ z9`MBbdqC-@zDhh%w=OfIbI8k$()3!Ck;4@GNq^*H%m_;-YjY+84Y6tK5G!WM@3TU2 zF}6b=N887CLKx$*t%M>OkP^R*N%Jn|?Q9Xgx(HNHh_*;bXX*ttk|qYEp$pj@g?~C~ zJ>$`s_JJ=xjy=NNO_@CwH1gjfGkQH05vNIPelC)0&7SYplkZG>oAS?O2b#&XX}aS~ zy;6UI8S_uTJKzuCwZCp)f^kG_#>T>gqE|q=vut9jVT$nmRTyu%2;4!j3G z6P}iAz}Qf<5!Q}2RU7*3D&k#-+K|`!-7{5n3X6<&1r+UuDk`bK*h9?CeCDtB{aO*@ zYIp;lEfK-<& z#nDD5wJgyq%8Oil*67i}%!XuKcPzqDQ zI;|9~oJ<4G!+A=b8YYEh9uTPe*zRLGzvkm?5gRkb zo;0UpzqW&UO$Qj0PW+yk)K(q_NPo(pJ|Ge-kBOLz44RTLb53wEY=!aa`>bgCm|H%^ z7UqhIsAq&)I>R<`n}qxN1!D#bV6^ivK*260SDs;U{a=U#%M@YMq<^yH;-xC92%X;# z3Sl=fp^!>RUD1Zm;}qM(y-mI&hvu9CatOmRKqn03#yuypO|OWIQ zYiz;{VTf-U>jlQ72H`QuE*xWaM5^jWS zy3QM2U}|eOYiFZoz<&cwk4}P4A2+5j8&)S`T8G3zf=tM30~UyspCn&;|29eeFz65m z${LulR-QRCUCTb27_;fGqi=P8*C7uB&WO_sABhtT%l`LWM-7wuZ&(ENqtoJ$0YVpf z81Nl)#n!Q9r{jFj{JwYH4xD1fb^~)q|8QD-Wq@NWqhdgvIDbK=9dhc1juM0Vr{mBq z7uYWNBS!e&h@3(p4Cr7IaDeRqb%0iJk~6=yOn3-NJq`=4FlEjZceyITuK3g}wo%d; zhDDn2KPJ@98zPPQ&S`N#93&eX1MmSIVH{VwQSUd1bC@m6JzZq@@V8n^`u9GL_uf8YY0O>!zd~$)j*)9M9Qq*MVF9-1z`y+e z=Dp>~KOFf){|)>PlfV-~+96iO3>NJBrxRUA(uajMSR259fd3M?<2~X?i%wD$5)0Ci z4lU1PEEx64C%jJtwMKE)UP<(~z^_gWotl$+Q4V(wbAJZ(dNOEdJ7yBWoL5st+S?U zKBqQjW3E#{iU9XT-h_R?7as}XMbck`g`b9mD3h9tKDPmFW4A!QIe2D^R(DCqCcjgd6%Bah$zQ z_m2!S5ph_8r1^js#1Acg;&MJ<6wn#ZLA{w#Y{WEQ{lH|EI6kAicMT(c>G^;bm-7Lm z#3V#nl@Dk!Vfg^5ox2_ff+`)N&RX*U#?^gMMDj$$(ta(x81A2&2HmZEU z1fa?Xl#G$&119G80lnw*NBtYIe*Kk&QRf5BiK2IR9W~6@ej{Q;6Oj*iN_Y%+8OjH| zXMY0o0W#1-|4g++WErdS0h>%nK0tLLRBPwl2YAyDwY5rATcXQt9kor!p!3rW)Ty^D zKh*x6D05bLKA=ta&-0Zd*yQR`1*#F6mMU1q!Ukmr1{L$)B|-`+RiFe=mQn?63bFwg zM3`5<5Z}lA;OmNYED^yv73)wbo3cGJ5r0()m(N77ZJ)u4bz}}l=S|4yRV9XZeDGJ5 zt5MD92iTrrkMN}zF$R@o4^%^L8&f5({dE(Q>^*FC3{tLU>C-w6iK8W|qPR}DmSVuj3pN#1vP@6FNK1jG)zB5lN(+3Cdw(@_ zFrw~YgkHxC>hBl>K48S(#Taz!AK(B{EL9Nk_52pCq*=yql+S%E?=j-9W8vjD{@Ipa z&zsJ?5oHwbZgV+=o5s(TVf%q@Cfrmj^d?;OJv}GjGe-E=gxdLl>HXek6pRH$GeCO+ zJ*`M9K>9eX1ATzmhP1G2Gx?vipnqP9i=@eimp`w5+C+l=PoiS(CL#Gp+`;nof}UEG zJ|!vLM{Hq{#U+dhYC7IBhZ*pzY?xNn3EkD%+ezE^K0aVlzk#iWuXNXk8#~JMy+x#b z;*61Y)^D&-#yw0GrEL}Srl30^FQoU{!;I)oPy6a^+uIHy^#l3wc~K?<6o1Z;4QM;D zHi=rEDslM;+qgZ%^zv!a^wbN8^GPE9HpbA;-adIty&G+w6oMFQsp4Lg%>e0WF+j1T z8A2*`mQW($+9ro238wLGjg!M?0X~HAY_Y@k@;}DOMrqz@_ z$DmY9S}Z;tS6W8C$z?ZGgzqhXpFWnh*9}c;ffn}2+I|zg4H(rpODA}R3Kc3;s8C@- Z@PFUe%F>J+MY{k1002ovPDHLkV1oUoq_6-0 delta 2790 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 From b27e5ab017b0ee82ef683bb6b4ccc0b8672d154b Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:24:31 +0200 Subject: [PATCH 4/7] fix(launcher): simplify bug report card title --- src/import/LauncherView.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 3900680c..38c75357 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -2377,7 +2377,7 @@ local function buildBugPanel(imp, x, y, w, availH, m) 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("GitHub bug form"), textX, cy + pad, PAL.heading) + 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) From 7d9e99ea1863818fb48d7c1499d7ea008a68129d Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:59:42 +0200 Subject: [PATCH 5/7] chore(repo): add code owners --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..7f9a1ae7 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @bryanthaboi From 4c8c1cf36b92773a188eb31ab8218e880a4d2f54 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 19 Aug 2026 11:19:54 -0400 Subject: [PATCH 6/7] CLOSES #998, CLOSES #1472, CLOSES #1526, CLOSES #1529, CLOSES #1530, CLOSES #1532, CLOSES #1534, CLOSES #1547, CLOSES #1549, CLOSES #1550, CLOSES #1551 --- data/scripts/flavor/bike_shop.lua | 19 ++ data/scripts/oaks_lab.lua | 1 + data/scripts/story4.lua | 12 +- data/scripts/story6.lua | 9 +- data/scripts/yellow_beach_house.lua | 12 +- .../example_mew_starter/README.md | 0 .../assets/mew_back_inverted.png | Bin .../assets/mew_front_inverted.png | Bin .../example_mew_starter/main.lua | 0 .../example_mew_starter/manifest.json | 0 .../example_mew_starter/mod.card | 0 mods/examples/example_silly_oak/CHANGELOG.md | 12 ++ mods/examples/example_silly_oak/README.md | 43 +++++ .../example_silly_oak/assets/toast_kid.png | Bin 0 -> 245 bytes mods/examples/example_silly_oak/main.lua | 107 +++++++++++ mods/examples/example_silly_oak/manifest.json | 15 ++ mods/examples/example_silly_oak/mod.card | 21 ++ .../tests/example_silly_oak_test.lua | 159 ++++++++++++++++ src/battle/BattleState.lua | 179 ++++++++++++------ src/battle/EffectRegistry.lua | 21 +- src/battle/MoveEffects.lua | 9 + src/import/LauncherView.lua | 94 +++++---- src/render/TextBox.lua | 13 ++ src/script/Commands.lua | 3 +- src/world/OverworldController.lua | 27 ++- src/world/gen2/World.lua | 4 + tests/engine/bike_shop_displays_bug1530.lua | 50 +++++ tests/engine/give_item_jingle.lua | 28 ++- tests/engine/pc_accessed_text_bug1529.lua | 100 ++++++++++ .../poison_side_effect_shake_bug1526.lua | 152 +++++++++++++++ .../restore_standing_trigger_bug1547.lua | 94 +++++++++ tests/engine/route24_rocket_bug1550.lua | 87 +++++++++ tests/engine/switch_withdraw_text_bug1534.lua | 122 ++++++++++++ tests/engine/thrash_setup_anim_bug1532.lua | 109 +++++++++++ tests/engine/trainer_sendout_auto_bug1472.lua | 120 ++++++++++++ tests/gen2_summary_test.lua | 14 +- tests/gen2_time_routing_test.lua | 4 + tests/gen2_world_test.lua | 27 ++- tests/mod_constants_tests.lua | 3 +- tests/mod_scripting_tests.lua | 2 + tests/mod_ui_tests.lua | 74 +++++--- tests/parity_J.lua | 1 + tests/parity_bills_pc.lua | 4 +- tests/parity_blackout_no_party.lua | 11 ++ tests/parity_double_faint.lua | 5 +- tests/run_tests.lua | 77 ++++---- 46 files changed, 1650 insertions(+), 194 deletions(-) rename mods/{examples => }/example_mew_starter/README.md (100%) rename mods/{examples => }/example_mew_starter/assets/mew_back_inverted.png (100%) rename mods/{examples => }/example_mew_starter/assets/mew_front_inverted.png (100%) rename mods/{examples => }/example_mew_starter/main.lua (100%) rename mods/{examples => }/example_mew_starter/manifest.json (100%) rename mods/{examples => }/example_mew_starter/mod.card (100%) create mode 100644 mods/examples/example_silly_oak/CHANGELOG.md create mode 100644 mods/examples/example_silly_oak/README.md create mode 100644 mods/examples/example_silly_oak/assets/toast_kid.png create mode 100644 mods/examples/example_silly_oak/main.lua create mode 100644 mods/examples/example_silly_oak/manifest.json create mode 100644 mods/examples/example_silly_oak/mod.card create mode 100644 mods/examples/example_silly_oak/tests/example_silly_oak_test.lua create mode 100644 tests/engine/bike_shop_displays_bug1530.lua create mode 100644 tests/engine/pc_accessed_text_bug1529.lua create mode 100644 tests/engine/poison_side_effect_shake_bug1526.lua create mode 100644 tests/engine/restore_standing_trigger_bug1547.lua create mode 100644 tests/engine/route24_rocket_bug1550.lua create mode 100644 tests/engine/switch_withdraw_text_bug1534.lua create mode 100644 tests/engine/thrash_setup_anim_bug1532.lua create mode 100644 tests/engine/trainer_sendout_auto_bug1472.lua diff --git a/data/scripts/flavor/bike_shop.lua b/data/scripts/flavor/bike_shop.lua index 9c8ee186..2d63c950 100644 --- a/data/scripts/flavor/bike_shop.lua +++ b/data/scripts/flavor/bike_shop.lua @@ -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. diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index 039e519a..9cb8e18b 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -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" }, diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index f6a759d6..3536dd8f 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -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 diff --git a/data/scripts/story6.lua b/data/scripts/story6.lua index 21de982e..05a01211 100644 --- a/data/scripts/story6.lua +++ b/data/scripts/story6.lua @@ -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") diff --git a/data/scripts/yellow_beach_house.lua b/data/scripts/yellow_beach_house.lua index 3352bee1..418cc5fd 100644 --- a/data/scripts/yellow_beach_house.lua +++ b/data/scripts/yellow_beach_house.lua @@ -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), diff --git a/mods/examples/example_mew_starter/README.md b/mods/example_mew_starter/README.md similarity index 100% rename from mods/examples/example_mew_starter/README.md rename to mods/example_mew_starter/README.md diff --git a/mods/examples/example_mew_starter/assets/mew_back_inverted.png b/mods/example_mew_starter/assets/mew_back_inverted.png similarity index 100% rename from mods/examples/example_mew_starter/assets/mew_back_inverted.png rename to mods/example_mew_starter/assets/mew_back_inverted.png diff --git a/mods/examples/example_mew_starter/assets/mew_front_inverted.png b/mods/example_mew_starter/assets/mew_front_inverted.png similarity index 100% rename from mods/examples/example_mew_starter/assets/mew_front_inverted.png rename to mods/example_mew_starter/assets/mew_front_inverted.png diff --git a/mods/examples/example_mew_starter/main.lua b/mods/example_mew_starter/main.lua similarity index 100% rename from mods/examples/example_mew_starter/main.lua rename to mods/example_mew_starter/main.lua diff --git a/mods/examples/example_mew_starter/manifest.json b/mods/example_mew_starter/manifest.json similarity index 100% rename from mods/examples/example_mew_starter/manifest.json rename to mods/example_mew_starter/manifest.json diff --git a/mods/examples/example_mew_starter/mod.card b/mods/example_mew_starter/mod.card similarity index 100% rename from mods/examples/example_mew_starter/mod.card rename to mods/example_mew_starter/mod.card diff --git a/mods/examples/example_silly_oak/CHANGELOG.md b/mods/examples/example_silly_oak/CHANGELOG.md new file mode 100644 index 00000000..ec81d39b --- /dev/null +++ b/mods/examples/example_silly_oak/CHANGELOG.md @@ -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. diff --git a/mods/examples/example_silly_oak/README.md b/mods/examples/example_silly_oak/README.md new file mode 100644 index 00000000..3a9e77ce --- /dev/null +++ b/mods/examples/example_silly_oak/README.md @@ -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` | diff --git a/mods/examples/example_silly_oak/assets/toast_kid.png b/mods/examples/example_silly_oak/assets/toast_kid.png new file mode 100644 index 0000000000000000000000000000000000000000..4778bdddf59057a01281609e5789eeffe4debe60 GIT binary patch literal 245 zcmeAS@N?(olHy`uVBq!ia0vp^79h;Q1|(OsS<3;bJ)SO(Ar-gY-r6X5$bg6SLeB$1 ziN-*QMnTDDLy2ZSHIDK=)uWv21780r*Gzf2Cr1Bm@w2FZY(MI3{X!cMU^8QM_G(ed zf-lx!vE2-&RSpgZ0v>#>i(Y%`z+OuRMn=Yd#x*;-moZfzN?}-@@ui_*GV|hzg6T^c z-J4zKPB!v&VOlObecQjr7j?6&_`kKC-J!FDf6xAHNutp^H+All+V=aD5_gI^GY1e@ gFnxLTZ^2hqwsQ+i8bzHh0R6$>>FVdQ&MBb@088Xwe*gdg literal 0 HcmV?d00001 diff --git a/mods/examples/example_silly_oak/main.lua b/mods/examples/example_silly_oak/main.lua new file mode 100644 index 00000000..8fe9ae02 --- /dev/null +++ b/mods/examples/example_silly_oak/main.lua @@ -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 diff --git a/mods/examples/example_silly_oak/manifest.json b/mods/examples/example_silly_oak/manifest.json new file mode 100644 index 00000000..928b5889 --- /dev/null +++ b/mods/examples/example_silly_oak/manifest.json @@ -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." +} diff --git a/mods/examples/example_silly_oak/mod.card b/mods/examples/example_silly_oak/mod.card new file mode 100644 index 00000000..93a0bef0 --- /dev/null +++ b/mods/examples/example_silly_oak/mod.card @@ -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 }, +} diff --git a/mods/examples/example_silly_oak/tests/example_silly_oak_test.lua b/mods/examples/example_silly_oak/tests/example_silly_oak_test.lua new file mode 100644 index 00000000..1bceba2c --- /dev/null +++ b/mods/examples/example_silly_oak/tests/example_silly_oak_test.lua @@ -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") diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index b976ffb4..e510a171 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -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, }) diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index ebe4f2d4..09699e0b 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -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 diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index a283a68f..b07ae63c 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -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 diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 32593ab8..b66b8844 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -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 diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 75fbbe40..a3dea27b 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -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. diff --git a/src/script/Commands.lua b/src/script/Commands.lua index e6d58a57..c3520a34 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -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 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 9a395acc..3af3a100 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -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, }) diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 45739943..e9ec799b 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -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, diff --git a/tests/engine/bike_shop_displays_bug1530.lua b/tests/engine/bike_shop_displays_bug1530.lua new file mode 100644 index 00000000..bf6e1004 --- /dev/null +++ b/tests/engine/bike_shop_displays_bug1530.lua @@ -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)") diff --git a/tests/engine/give_item_jingle.lua b/tests/engine/give_item_jingle.lua index 7e8309dc..12f102de 100644 --- a/tests/engine/give_item_jingle.lua +++ b/tests/engine/give_item_jingle.lua @@ -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 diff --git a/tests/engine/pc_accessed_text_bug1529.lua b/tests/engine/pc_accessed_text_bug1529.lua new file mode 100644 index 00000000..cf1b4942 --- /dev/null +++ b/tests/engine/pc_accessed_text_bug1529.lua @@ -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)") diff --git a/tests/engine/poison_side_effect_shake_bug1526.lua b/tests/engine/poison_side_effect_shake_bug1526.lua new file mode 100644 index 00000000..ce0f50ad --- /dev/null +++ b/tests/engine/poison_side_effect_shake_bug1526.lua @@ -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)") diff --git a/tests/engine/restore_standing_trigger_bug1547.lua b/tests/engine/restore_standing_trigger_bug1547.lua new file mode 100644 index 00000000..11a90793 --- /dev/null +++ b/tests/engine/restore_standing_trigger_bug1547.lua @@ -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)") diff --git a/tests/engine/route24_rocket_bug1550.lua b/tests/engine/route24_rocket_bug1550.lua new file mode 100644 index 00000000..25ed8034 --- /dev/null +++ b/tests/engine/route24_rocket_bug1550.lua @@ -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)") diff --git a/tests/engine/switch_withdraw_text_bug1534.lua b/tests/engine/switch_withdraw_text_bug1534.lua new file mode 100644 index 00000000..57d78beb --- /dev/null +++ b/tests/engine/switch_withdraw_text_bug1534.lua @@ -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)") diff --git a/tests/engine/thrash_setup_anim_bug1532.lua b/tests/engine/thrash_setup_anim_bug1532.lua new file mode 100644 index 00000000..8e5ad7aa --- /dev/null +++ b/tests/engine/thrash_setup_anim_bug1532.lua @@ -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)") diff --git a/tests/engine/trainer_sendout_auto_bug1472.lua b/tests/engine/trainer_sendout_auto_bug1472.lua new file mode 100644 index 00000000..d9739780 --- /dev/null +++ b/tests/engine/trainer_sendout_auto_bug1472.lua @@ -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)") diff --git a/tests/gen2_summary_test.lua b/tests/gen2_summary_test.lua index 3342d472..64138f97 100644 --- a/tests/gen2_summary_test.lua +++ b/tests/gen2_summary_test.lua @@ -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 } } diff --git a/tests/gen2_time_routing_test.lua b/tests/gen2_time_routing_test.lua index 8d8944fc..0b5dded9 100644 --- a/tests/gen2_time_routing_test.lua +++ b/tests/gen2_time_routing_test.lua @@ -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 ---------------------------------------------------- diff --git a/tests/gen2_world_test.lua b/tests/gen2_world_test.lua index a8bc0987..79649797 100644 --- a/tests/gen2_world_test.lua +++ b/tests/gen2_world_test.lua @@ -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 diff --git a/tests/mod_constants_tests.lua b/tests/mod_constants_tests.lua index ba8f94ae..f397947c 100644 --- a/tests/mod_constants_tests.lua +++ b/tests/mod_constants_tests.lua @@ -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") diff --git a/tests/mod_scripting_tests.lua b/tests/mod_scripting_tests.lua index 5b1e5cf0..031174fe 100644 --- a/tests/mod_scripting_tests.lua +++ b/tests/mod_scripting_tests.lua @@ -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 diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index bf10702b..0071ce65 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -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") diff --git a/tests/parity_J.lua b/tests/parity_J.lua index 00d513cd..0435a87f 100644 --- a/tests/parity_J.lua +++ b/tests/parity_J.lua @@ -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, diff --git a/tests/parity_bills_pc.lua b/tests/parity_bills_pc.lua index fd6ca010..482f9b31 100644 --- a/tests/parity_bills_pc.lua +++ b/tests/parity_bills_pc.lua @@ -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() diff --git a/tests/parity_blackout_no_party.lua b/tests/parity_blackout_no_party.lua index ce1adc40..1d5696df 100644 --- a/tests/parity_blackout_no_party.lua +++ b/tests/parity_blackout_no_party.lua @@ -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() diff --git a/tests/parity_double_faint.lua b/tests/parity_double_faint.lua index c8ceb7cf..cb136229 100644 --- a/tests/parity_double_faint.lua +++ b/tests/parity_double_faint.lua @@ -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) diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 24b6a5fe..84900f8a 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -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 From 93e336b7cbd60fb0c1ed8fa36cd7f763f19bb814 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 19 Aug 2026 13:36:35 -0400 Subject: [PATCH 7/7] Update video link and thumbnail in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 630c6fc6..c7640938 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. * ### Watch the latest update video -[![Watch the latest update video](https://img.youtube.com/vi/8IOgqbe4YvA/maxresdefault.jpg)](https://www.youtube.com/watch?v=8IOgqbe4YvA) +[![Watch the latest update video](https://img.youtube.com/vi/yi7LkWQPKKM/maxresdefault.jpg)](https://youtu.be/yi7LkWQPKKM) This project does not include a ROM, emulate the Game Boy, transpile assembly,