From e7d5b86c93b1595fcf00ab7e40a725fa9370796b Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 6 Oct 2026 19:00:50 -0400 Subject: [PATCH] CLOSES #2484, CLOSES #2600, CLOSES #2694, CLOSES #2708, CLOSES #2713, CLOSES #2723 --- CONTRIBUTING-mods.md | 24 +++ README.md | 2 +- docs/modding/reference/registries.md | 1 + docs/new-features.md | 1 + src/battle/BattleState.lua | 46 ++-- src/core/Game.lua | 3 + src/core/Game2.lua | 8 +- src/core/Game3.lua | 3 + src/core/HostShell.lua | 55 ++--- src/core/SaveData.lua | 110 +++++++++- src/core/Timing.lua | 64 +++--- src/core/WinApi.lua | 159 ++++++++++++++ src/import/LauncherView.lua | 4 +- src/import/RomImporter.lua | 79 ++++++- src/mods/ModIndex.lua | 133 ++++++++++-- src/mods/ModUpdate.lua | 125 +++++++++-- src/mods/Schemas.lua | 1 + src/ui/BindingsMenu.lua | 14 +- src/ui/PartyMenu.lua | 63 ++++-- src/ui/gen2/BattleState.lua | 5 +- src/ui/gen2/BoxMenu.lua | 1 + src/ui/gen2/NamingScreen.lua | 75 ++++++- src/update/check_worker.lua | 25 ++- src/world/OverworldController.lua | 13 +- src/world/gen2/World.lua | 6 +- tests/drivers/game3_speed_hotkey_h1.lua | 7 + tests/drivers/gen2_naming_header_2708.lua | 85 ++++++++ tests/drivers/gold_speed_hotkey_2600.lua | 37 ++++ tests/drivers/hp_bar_speed_2723.lua | 183 ++++++++++++++++ tests/drivers/party_heal_bug252_test.lua | 48 ++--- tests/drivers/red_speed_hotkey_h1.lua | 5 + tests/drivers/red_speed_stall_2694.lua | 70 +++++++ tests/engine/cart_import_picker.lua | 86 +++++++- tests/engine/game3_speed_hotkey_test.lua | 49 +++++ tests/engine/gen2_naming_header_2708_test.lua | 129 ++++++++++++ tests/engine/hostshell_spawn_self.lua | 14 +- tests/engine/hostshell_win_restart_2721.lua | 137 ++++++++++++ tests/engine/hp_bar_speed_bug2723.lua | 112 ++++++++++ tests/engine/mod_index_tests.lua | 47 ++++- tests/engine/options_size_budget_2694.lua | 130 ++++++++++++ tests/engine/save_slot_duplicate_2484.lua | 197 ++++++++++++++++++ tests/engine/timing_parity.lua | 11 +- tests/game3_controls_menu_test.lua | 27 ++- tests/mod_ui_tests.lua | 6 +- .../cases/mod_map_outdoor_encounters.lua | 113 ++++++++++ tests/quick.list | 18 ++ 46 files changed, 2280 insertions(+), 251 deletions(-) create mode 100644 src/core/WinApi.lua create mode 100644 tests/drivers/gen2_naming_header_2708.lua create mode 100644 tests/drivers/gold_speed_hotkey_2600.lua create mode 100644 tests/drivers/hp_bar_speed_2723.lua create mode 100644 tests/drivers/red_speed_stall_2694.lua create mode 100644 tests/engine/gen2_naming_header_2708_test.lua create mode 100644 tests/engine/hostshell_win_restart_2721.lua create mode 100644 tests/engine/hp_bar_speed_bug2723.lua create mode 100644 tests/engine/options_size_budget_2694.lua create mode 100644 tests/engine/save_slot_duplicate_2484.lua create mode 100644 tests/modkit/cases/mod_map_outdoor_encounters.lua diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index 7e0515ca..e213f065 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -357,6 +357,30 @@ Legal posture is unchanged and non-negotiable: no pack, and nothing derived from one, is ever redistributed. An importer reads a dump the player already owns, on their machine, into their own save directory. +### 4c. Authored maps (`mod.content.maps:register`) + +Pick an `index` of 1000 or above for a new Gen 1 map, so it never collides +with a vanilla map number. The cart treats every map from `FIRST_INDOOR_MAP` +(37) up as indoor for wild encounters: unless the tileset is `FOREST`, every +step rolls, not just steps in grass. A mod map is held to that rule unless +it says otherwise: + +| Map record | Wild encounters off grass and water | +| --- | --- | +| `outdoor = true` | never, like a vanilla route | +| tileset `OVERWORLD`, no `outdoor` field | never | +| `outdoor = false`, or any other tileset with no `outdoor` field | on every step (caves, towers) | +| tileset `FOREST` | never | + +The same `outdoor` field also lets Fly and Teleport be used on the map, so +an outdoor route on a custom tileset sets `outdoor = true` once. + +```lua +mod.content.maps:register("MY_ROUTE", { + index = 1000, tileset = "MY_TILES", outdoor = true, ... +}) +``` + ### 5. What a mod's code can reach Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the diff --git a/README.md b/README.md index e1d20f83..8e8db566 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ Rebind any of these in-game under **OPTIONS > CONTROLS**. | Key | What it does | | --------- | ------------------------------------------------------ | | `-` / `=` | Zoom out / in (overworld; also mouse wheel) | -| `1` | Cycle GAME SPEED up (controller: R2 faster, L2 slower) | +| `1` / `0` | GAME SPEED up / down (controller: R2 faster, L2 slower) | | `2` | Cycle COLORS | | `3` | Cycle TILT (free-roam overworld) | | `4` | Cycle ZOOM through every level (free-roam overworld) | diff --git a/docs/modding/reference/registries.md b/docs/modding/reference/registries.md index ed9b33d4..abfcc4e5 100644 --- a/docs/modding/reference/registries.md +++ b/docs/modding/reference/registries.md @@ -686,6 +686,7 @@ mod.content.map_songs:override("PALLET_TOWN", "Music_Routes1") | `index` | integer >= 0 | no | | `label` | string | no | | `objects` | list of {pokemon?, ...} | no | +| `outdoor` | boolean | no | | `palette` | string | no | | `signs` | list of any value | no | | `tileset` | tilesets id | yes | diff --git a/docs/new-features.md b/docs/new-features.md index b2f67315..1fcbd9f2 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -21,6 +21,7 @@ Features intentionally added to the games themselves, beyond what the original c * **Idle Union Room trainers wander**, a few steps around their spot and back * **Translation mods stay online** on Gen 3: mods that only change text, fonts, names or visuals can still link, trade and battle * **Button remapping** on every game: OPTION -> CONTROLS (keys, pads, L/R on FireRed/LeafGreen/Emerald, fast-forward buttons) +* **Duplicate a save slot** from the launcher, a one-tap independent copy for backups or trimming a save before export ## Gen 2 Specifics diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index facaf3fd..57f05189 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1251,21 +1251,15 @@ function BattleState:waitNext(frames) table.insert(self.queue, self.nextInsert, { wait = frames }) end --- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar). --- --- The original walks the bar ONE HP POINT per loop iteration (:81-120), and --- what each iteration costs depends on the side: --- * UpdateHPBar_PrintHPNumber spends a DelayFrame (:234) reprinting the --- number, but only when wHPBarType is nonzero (:207-209) -- the player's --- own HUD and the party menu, never the enemy's; --- * UpdateHPBar_AnimateHPBar spends 2 frames for each pixel the bar --- actually moved (:147-148), and most single-HP steps move none. --- So the player's bar drains at 1 HP per frame plus 2 frames per pixel, --- while the enemy's costs nothing until it crosses a pixel boundary. The --- old flat maxHP/96 rate was the enemy-side formula applied to both, which --- ran a 150 HP mon's full drain in 96 frames against hardware's 249. --- --- Returns true while animating. +-- engine/gfx/hp_bar.asm:121-135 +function BattleState:hpDrainClose(b, goal) + local cycles = b.drainCycles or 0 + b.drainCycles = nil + if goal ~= 0 then cycles = cycles + Timing.HP_BAR_STEP_CYCLES end + return Timing.hpDrainClosingFrames(b == self.player, cycles) +end + +-- engine/gfx/hp_bar.asm:81-120 function BattleState:stepHPDrain() local busy = false local only = self.drainOnly @@ -1306,26 +1300,32 @@ function BattleState:stepHPDrain() -- enemy HUD several free steps can land in the same frame repeat b.shownHP = b.shownHP + ((b.shownHP > goal) and -1 or 1) - spent = spent + (playerSide and Timing.HP_BAR_HP_STEP or 0) + if playerSide then + spent = spent + Timing.HP_BAR_HP_STEP + else + -- engine/gfx/hp_bar.asm:96 + b.drainCycles = (b.drainCycles or 0) + Timing.HP_BAR_STEP_CYCLES + end targetPx = Timing.hpBarPixels(b.shownHP, maxHP) until b.shownHP == goal or targetPx ~= b.shownPx or spent >= 1 + b.draining = true if spent > 0 then b.drainHold = spent - 1 elseif targetPx ~= b.shownPx then - -- the enemy HUD printed no number, so this frame is already the - -- first of the pixel step the crossing just asked for + -- engine/gfx/hp_bar.asm:140-148 b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1) b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1 + + Timing.hpBarCpuLag(b.drainCycles) + b.drainCycles = 0 else - b.drainHold = 0 + b.draining = nil + b.drainHold = self:hpDrainClose(b, goal) - 1 end - b.draining = true busy = true elseif b.draining then - -- .animateHPBarDone's final number print, one more pixel step and - -- Delay3 (hp_bar.asm:132-135); this frame is the first of them + -- engine/gfx/hp_bar.asm:121-135 b.draining = nil - b.drainHold = Timing.hpDrainClosingFrames(b == self.player) - 1 + b.drainHold = self:hpDrainClose(b, goal) - 1 busy = true end end diff --git a/src/core/Game.lua b/src/core/Game.lua index fd75d1d4..1f39cc96 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -883,6 +883,9 @@ function Game:keypressed(key) -- shoulders/triggers on gamepad do the same (see gamepadpressed) self:_cycleSpeed(1) return + elseif hk == "0" then + self:_cycleSpeed(-1) + return elseif hk == "2" then -- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change -- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so diff --git a/src/core/Game2.lua b/src/core/Game2.lua index c63f4580..40bf1f97 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -2133,7 +2133,7 @@ end -- path binds them in (src/core/Game.lua keypressed), driving the same shared -- modules so a player's muscle memory carries between the two games: -- --- F1/F2 write / reload the save 1 GAME SPEED +-- F1/F2 write / reload the save 1 GAME SPEED (0 steps down) -- - = zoom one step out / in 2 COLOR -- 4 cycle ZOOM 3 TILT (mnemonic: 3D) -- @@ -2163,6 +2163,12 @@ function Game2:hotkey(key) options.speed = GameSpeed.cycle(options.speed, 1) persist() return true + elseif hk == "0" then + if self:speedLocked() then return true end + local GameSpeed = require("src.core.GameSpeed") + options.speed = GameSpeed.cycle(options.speed, -1) + persist() + return true elseif hk == "2" then local GbcPalette = require("src.render.GbcPalette") GbcPalette.setMode(options.color or "gbc") diff --git a/src/core/Game3.lua b/src/core/Game3.lua index 59599475..63f08f79 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -886,6 +886,9 @@ function Game3:_hotkey(key) elseif hk == "1" then self:_cycleSpeed(1) return true + elseif hk == "0" then + self:_cycleSpeed(-1) + return true elseif hk == "3" then if self:zoomGateOK() then local Tilt = lazyReq("src.render.Tilt") diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 90f41061..280ae738 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -218,28 +218,15 @@ function HostShell.pumpHostEvents() pcall(love.event.pump) end +local function winApi() + local ok, mod = pcall(require, "src.core.WinApi") + if ok and type(mod) == "table" then return mod end + return nil +end + local function windowsModulePath() - local ok, ffi = pcall(require, "ffi") - if not ok then return nil end - pcall(ffi.cdef, [[ - unsigned long GetModuleFileNameW(void *hModule, wchar_t *lpFilename, unsigned long nSize); - int WideCharToMultiByte(unsigned int CodePage, unsigned long dwFlags, - const wchar_t *lpWideCharStr, int cchWideChar, - char *lpMultiByteStr, int cbMultiByte, - const char *lpDefaultChar, int *lpUsedDefaultChar); - ]]) - local okk, k32 = pcall(ffi.load, "kernel32") - if not okk or not k32 then return nil end - local buf = ffi.new("wchar_t[32768]") - local n = k32.GetModuleFileNameW(nil, buf, 32768) - if n == 0 then return nil end - local bytes = k32.WideCharToMultiByte(65001, 0, buf, n, nil, 0, nil, nil) - if not bytes or bytes <= 0 then return nil end - local out = ffi.new("char[?]", bytes) - if k32.WideCharToMultiByte(65001, 0, buf, n, out, bytes, nil, nil) <= 0 then - return nil - end - return ffi.string(out, bytes) + local api = winApi() + return api and api.modulePath() or nil end -- Restart the whole app. The obvious love.event.quit("restart") re-runs LÖVE's @@ -300,9 +287,10 @@ function HostShell.restart() if type(exe) ~= "string" or exe == "" then exe = windowsModulePath() end - if exe and exe ~= "" then - local cmd = 'start "" "' .. exe:gsub("/", "\\") .. '"' - if os.execute(cmd) then + local api = winApi() + if api and exe and exe ~= "" then + exe = exe:gsub("/", "\\") + if api.spawn(exe, {}, { cwd = api.dirOf(exe) }) then love.event.quit() return end @@ -414,10 +402,6 @@ function HostShell.quote(s) return "'" .. s:gsub("'", "'\\''") .. "'" end --- Launch another instance of this packaged app without waiting for it. The --- same path works on all process-capable desktop hosts; only the shell's --- background spelling differs. Source checkouts include their game folder, --- while fused releases and AppImages already carry it in the executable. function HostShell.spawnSelfDetached(args) if not require("src.core.Platform").canSpawnProcess() then return false end local fs = love and love.filesystem @@ -432,16 +416,19 @@ function HostShell.spawnSelfDetached(args) end for _, value in ipairs(args or {}) do argv[#argv + 1] = tostring(value) end + local osName = love.system and love.system.getOS and love.system.getOS() + if osName == "Windows" then + local api = winApi() + if not api then return false end + executable = executable:gsub("/", "\\") + return api.spawn(executable, argv, { cwd = api.dirOf(executable) }) + end + local command = HostShell.quote(executable) for _, value in ipairs(argv) do command = command .. " " .. HostShell.quote(value) end - local osName = love.system and love.system.getOS and love.system.getOS() - if osName == "Windows" then - command = 'start "" /b ' .. command .. " >NUL 2>&1" - else - command = HostShell.envPrefix() .. command .. " >/dev/null 2>&1 &" - end + command = HostShell.envPrefix() .. command .. " >/dev/null 2>&1 &" local ok, _, code = os.execute(command) return ok == true or ok == 0 or code == 0 end diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 2d52c3f5..e85f0e64 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -38,6 +38,7 @@ local OPTIONS_FILENAME = "options.lua" -- "reset" at once. Same .bak/.tmp witness names the save files use. local OPTIONS_BACKUP_FILENAME = OPTIONS_FILENAME .. ".bak" local OPTIONS_TMP_FILENAME = OPTIONS_FILENAME .. ".tmp" +SaveData.OPTIONS_FILENAME = OPTIONS_FILENAME -- Main / backup / staged-witness names for a version (defaults to the active -- one). The backup is a rolling copy and .tmp is the staged-write witness; @@ -669,17 +670,11 @@ function SaveData.defaultOptions() modProfiles = {}, modProfilesSeeded = false, modOrder = {}, - -- GitHub release checks for mods with a manifest "github" field - -- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours. - modUpdateCache = {}, -- Player-added mod indexes, in their chosen order. ModIndex.sources() -- includes the permanent main index alongside these and reuses any -- main-index row saved by an older launcher. Rows are -- { url, feed, base, fallback, label }. modIndexes = {}, - -- Parsed index listings keyed by feed URL; TTL is 24 hours, matching how - -- often the feeds themselves rebuild. - modIndexCache = {}, -- On-screen touch overlay (Android/iOS; see src/core/TouchControls.lua). -- enabled=false hides it permanently (distinct from auto-hide-on-gamepad). -- layouts.portrait / layouts.landscape each hold optional normalized @@ -743,6 +738,8 @@ function SaveData.mergeOptions(loaded) opts.speedMenu = loaded.speed end opts.speed = nil + opts.modIndexCache = nil + opts.modUpdateCache = nil end return opts end @@ -1855,6 +1852,107 @@ function SaveData.deleteSlot(version, slotId) return deleteSlotIn(version, slotId) end +local function copyTree(fs, from, to) + if not (fs.getDirectoryItems and fs.getInfo(from)) then return end + for _, name in ipairs(fs.getDirectoryItems(from)) do + local src, dst = from .. "/" .. name, to .. "/" .. name + local info = fs.getInfo(src) + if info and info.type == "directory" then + if fs.createDirectory then fs.createDirectory(dst) end + copyTree(fs, src, dst) + elseif info then + local body = fs.read(src) + if type(body) == "string" then + ensureParentDir(fs, dst) + fs.write(dst, body) + end + end + end +end + +local function pendingTradeSent(fs, main) + local journal = main:gsub("%.lua$", "") .. "_trade.lua" + if not fs.getInfo(journal) then return false end + local data = SaveSerializer.decode(fs.read(journal) or "") + if type(data) ~= "table" or type(data.entries) ~= "table" then return false end + for _, e in ipairs(data.entries) do + if type(e) == "table" and type(e.sent) == "table" then return true end + end + return false +end + +local function duplicateSlotIn(key, srcId, label) + local fs = persistFs(nil) + ensureSlots(key, fs) + local opts = SaveData.loadOptions(fs) + local reg = registryOf(opts, key) + local found = false + for _, id in ipairs(reg and type(reg.list) == "table" and reg.list or {}) do + if id == srcId then found = true break end + end + if not found then return nil, "slot not registered" end + local main = slotNames(key, srcId) + if not main then return nil, "invalid slot id" end + local body = readSlotSourceIn(key, srcId, fs) + local save = body and SaveSerializer.decode(body) + if type(save) ~= "table" then return nil, "that save could not be read" end + if pendingTradeSent(fs, main) then + return nil, "finish this save's pending trade first" + end + save.meta = type(save.meta) == "table" and save.meta or {} + local oldId = save.meta.playthroughId + if type(oldId) ~= "string" or oldId == "" then + local mapped = type(opts.playthroughIds) == "table" and opts.playthroughIds[key] + oldId = type(mapped) == "table" and mapped[srcId] or nil + end + local newId = SaveData.newPlaythroughId() + save.meta.playthroughId = newId + local id = createSlotIn(key) + local ok, err = writeSlotIn(key, id, save) + if not ok or not readSlotSourceIn(key, id, fs) then + deleteSlotIn(key, id) + return nil, err or "the copy did not read back" + end + local cart = slotDir(key) .. "/" .. srcId .. ".cart" + if fs.getInfo(cart) then + local bytes = fs.read(cart) + if type(bytes) == "string" then fs.write(slotDir(key) .. "/" .. id .. ".cart", bytes) end + end + opts = SaveData.loadOptions(fs) + reg = registryOf(opts, key) + local name = type(label) == "string" and label:match("^%s*(.-)%s*$") or "" + if name ~= "" then + reg.names = type(reg.names) == "table" and reg.names or {} + reg.names[id] = name + end + if type(reg.hashes) == "table" and reg.hashes[srcId] then reg.hashes[id] = reg.hashes[srcId] end + if type(reg.broken) == "table" and reg.broken[srcId] then reg.broken[id] = true end + putRegistry(opts, key, reg) + opts.playthroughIds = type(opts.playthroughIds) == "table" and opts.playthroughIds or {} + opts.playthroughIds[key] = opts.playthroughIds[key] or {} + opts.playthroughIds[key][id] = newId + SaveData.saveOptions(opts, fs) + local version = save.version == nil and not isCartKey(key) and key or save.version + if type(oldId) == "string" and oldId:match("^[%w_-]+$") + and type(version) == "string" and version:match("^[%w_-]+$") then + copyTree(fs, "mod_storage/" .. version .. "/" .. oldId, + "mod_storage/" .. version .. "/" .. newId) + end + return id +end + +function SaveData.duplicateSlot(version, slotId, label) + version = version or GameVersion.get() + if not knownVersion(version) then return nil, "unknown version" end + return duplicateSlotIn(version, slotId, label) +end + +function SaveData.duplicateCartSlot(cartId, slotId, label) + local key = cartKey(cartId or activeCart) + if not key then return nil, "unknown cart" end + return duplicateSlotIn(key, slotId, label) +end + -- Drop the process-global "have we resolved slots for this scope" cache so -- the next listSlots/saveNames re-reads disk (and can migrate a flat legacy -- SAVE into slot1). Pass a version id or cart scope key to invalidate just diff --git a/src/core/Timing.lua b/src/core/Timing.lua index 86e60703..a44ea99f 100644 --- a/src/core/Timing.lua +++ b/src/core/Timing.lua @@ -138,23 +138,12 @@ Timing.FAINT_SLIDE_STEP = 8 / Timing.FAINT_SLIDE_ROW -- 4px per frame at 1x Timing.TRAINER_SLIDE_COL = 2 -- core.asm:1267-1268, per column -- HP bar (engine/gfx/hp_bar.asm) --------------------------------------------- --- --- UpdateHPBar steps ONE HP point per loop iteration (:81-120). Each --- iteration pays: --- * 1 frame in UpdateHPBar_PrintHPNumber's DelayFrame (:234) -- but only --- when wHPBarType is nonzero (:207-209), i.e. the player's own HUD and --- the party menu, never the enemy HUD; and --- * 2 frames per pixel the bar actually moved, from --- UpdateHPBar_AnimateHPBar's `ld c, 2 / call DelayFrames` (:147-148). --- The drain closes with one more pixel step and a Delay3 (:133-135). --- --- So a player-side drain of D HP across P pixels costs D + 2P + 6 frames, --- while the same drain on the enemy HUD costs only 2P + 5. A 150 HP mon --- losing everything takes 150 + 96 + 6 = 252 frames on hardware. Timing.HP_BAR_PIXELS = 48 -- the bar is 48 px wide (GetHPBarLength) -Timing.HP_BAR_PIXEL_STEP = 2 -- frames per pixel of bar movement -Timing.HP_BAR_HP_STEP = 1 -- frames per HP point, player-side HUD only +Timing.HP_BAR_PIXEL_STEP = 2 -- engine/gfx/hp_bar.asm:147-148 +Timing.HP_BAR_HP_STEP = 1 -- engine/gfx/hp_bar.asm:207-209, 234 +Timing.HP_BAR_STEP_CYCLES = 7000 -- engine/gfx/hp_bar.asm:244-269 +Timing.FRAME_CYCLES = 17556 -- Pixels the bar shows for `hp` out of `maxHP`. GetHPBarLength floors the -- 48ths and clamps the result to at least 1 for any nonzero HP @@ -167,37 +156,44 @@ function Timing.hpBarPixels(hp, maxHP) return px end --- Frames one single-HP step of the drain costs: the per-HP number print --- (player side only) plus two frames for every pixel that step moved. -function Timing.hpDrainStepFrames(fromHP, toHP, maxHP, playerSide) - local pixels = math.abs(Timing.hpBarPixels(toHP, maxHP) - - Timing.hpBarPixels(fromHP, maxHP)) - local frames = pixels * Timing.HP_BAR_PIXEL_STEP - if playerSide then frames = frames + Timing.HP_BAR_HP_STEP end - return frames +function Timing.hpBarCpuLag(cycles) + if not cycles or cycles <= 0 then return 0 end + return math.ceil(cycles / Timing.FRAME_CYCLES) - 1 end --- After the loop, .animateHPBarDone prints the number one last time, runs --- AnimateHPBar for a single pixel and falls into Delay3 (hp_bar.asm:132-135) --- -- so the tail costs 6 frames on the player's HUD and 5 on the enemy's. -function Timing.hpDrainClosingFrames(playerSide) +-- engine/gfx/hp_bar.asm:121-135 +function Timing.hpDrainClosingFrames(playerSide, cycles) local frames = Timing.HP_BAR_PIXEL_STEP + Timing.DELAY3 - if playerSide then frames = frames + Timing.HP_BAR_HP_STEP end - return frames + if playerSide then return frames + Timing.HP_BAR_HP_STEP end + return frames + Timing.hpBarCpuLag(cycles) end --- Total cost of draining `fromHP` to `toHP`, for tests and for anything that --- needs to budget the whole animation up front. +-- engine/gfx/hp_bar.asm:81-120 function Timing.hpDrainFrames(fromHP, toHP, maxHP, playerSide) - local total = 0 + local total, cycles = 0, 0 local hp = fromHP local dir = (toHP < fromHP) and -1 or 1 while hp ~= toHP do local nextHP = hp + dir - total = total + Timing.hpDrainStepFrames(hp, nextHP, maxHP, playerSide) + local pixels = math.abs(Timing.hpBarPixels(nextHP, maxHP) + - Timing.hpBarPixels(hp, maxHP)) + if playerSide then + total = total + Timing.HP_BAR_HP_STEP + pixels * Timing.HP_BAR_PIXEL_STEP + else + cycles = cycles + Timing.HP_BAR_STEP_CYCLES + if pixels > 0 then + total = total + pixels * Timing.HP_BAR_PIXEL_STEP + + Timing.hpBarCpuLag(cycles) + cycles = 0 + end + end hp = nextHP end - return total + Timing.hpDrainClosingFrames(playerSide) + -- engine/gfx/hp_bar.asm:127-129 + if not playerSide and toHP ~= 0 then + cycles = cycles + Timing.HP_BAR_STEP_CYCLES + end + return total + Timing.hpDrainClosingFrames(playerSide, cycles) end return Timing diff --git a/src/core/WinApi.lua b/src/core/WinApi.lua new file mode 100644 index 00000000..29ea3841 --- /dev/null +++ b/src/core/WinApi.lua @@ -0,0 +1,159 @@ +local WinApi = {} + +WinApi.DETACHED_PROCESS = 0x00000008 +WinApi.CREATE_NEW_PROCESS_GROUP = 0x00000200 +WinApi.CREATE_NO_WINDOW = 0x08000000 + +local CP_UTF8 = 65001 + +local DECLS = { + "int MultiByteToWideChar(uint32_t cp, uint32_t flags, const char *s, int n, uint16_t *w, int wn);", + "int WideCharToMultiByte(uint32_t cp, uint32_t flags, const uint16_t *w, int wn, char *s, int n, const char *def, int *used);", + "int CloseHandle(void *h);", + "uint32_t GetModuleFileNameW(void *mod, uint16_t *buf, uint32_t n);", + -- STARTUPINFOW: 104 bytes on x64, 68 on x86, natural alignment + [[typedef struct { + uint32_t cb; uint16_t *lpReserved; uint16_t *lpDesktop; uint16_t *lpTitle; + uint32_t dwX; uint32_t dwY; uint32_t dwXSize; uint32_t dwYSize; + uint32_t dwXCountChars; uint32_t dwYCountChars; uint32_t dwFillAttribute; + uint32_t dwFlags; uint16_t wShowWindow; uint16_t cbReserved2; + uint8_t *lpReserved2; void *hStdInput; void *hStdOutput; void *hStdError; + } PP_STARTUPINFOW;]], + [[typedef struct { + void *hProcess; void *hThread; uint32_t dwProcessId; uint32_t dwThreadId; + } PP_PROCESS_INFORMATION;]], + "int CreateProcessW(const uint16_t *app, uint16_t *cmd, void *pa, void *ta, int inherit, uint32_t flags, void *env, const uint16_t *cwd, PP_STARTUPINFOW *si, PP_PROCESS_INFORMATION *pi);", +} + +local loaded = nil + +local function lib() + if loaded ~= nil then return loaded or nil end + loaded = false + local okFfi, ffi = pcall(require, "ffi") + if not (okFfi and ffi and ffi.os == "Windows") then return nil end + for _, decl in ipairs(DECLS) do pcall(ffi.cdef, decl) end + local C = ffi.C + if not pcall(function() + return C.MultiByteToWideChar, C.CreateProcessW, C.CloseHandle + end) then + return nil + end + loaded = ffi + return ffi +end + +function WinApi.available() + return lib() ~= nil +end + +function WinApi.wide(s) + local ffi = lib() + if not ffi or type(s) ~= "string" then return nil end + local C = ffi.C + local n = C.MultiByteToWideChar(CP_UTF8, 0, s, #s, nil, 0) + if s ~= "" and (not n or n <= 0) then return nil end + local buf = ffi.new("uint16_t[?]", n + 1) + if n > 0 and C.MultiByteToWideChar(CP_UTF8, 0, s, #s, buf, n) <= 0 then + return nil + end + buf[n] = 0 + return buf +end + +function WinApi.narrow(w, count) + local ffi = lib() + if not ffi or w == nil then return nil end + local C = ffi.C + local wn = count or -1 + local n = C.WideCharToMultiByte(CP_UTF8, 0, w, wn, nil, 0, nil, nil) + if not n or n <= 0 then return nil end + local buf = ffi.new("char[?]", n) + if C.WideCharToMultiByte(CP_UTF8, 0, w, wn, buf, n, nil, nil) <= 0 then + return nil + end + if wn == -1 then n = n - 1 end + return ffi.string(buf, n) +end + +function WinApi.modulePath() + local ffi = lib() + if not ffi then return nil end + local ok, path = pcall(function() + local buf = ffi.new("uint16_t[32768]") + local n = ffi.C.GetModuleFileNameW(nil, buf, 32768) + if n == 0 then return nil end + return WinApi.narrow(buf, n) + end) + if ok and type(path) == "string" and path ~= "" then return path end + return nil +end + +function WinApi.quoteArg(arg) + arg = tostring(arg) + if arg ~= "" and not arg:find('[ \t\n\v"]') then return arg end + local out, slashes = { '"' }, 0 + for i = 1, #arg do + local c = arg:sub(i, i) + if c == "\\" then + slashes = slashes + 1 + elseif c == '"' then + out[#out + 1] = string.rep("\\", slashes * 2 + 1) .. '"' + slashes = 0 + else + out[#out + 1] = string.rep("\\", slashes) .. c + slashes = 0 + end + end + out[#out + 1] = string.rep("\\", slashes * 2) .. '"' + return table.concat(out) +end + +function WinApi.commandLine(exe, args) + local parts = { '"' .. tostring(exe):gsub('"', "") .. '"' } + for _, arg in ipairs(args or {}) do + parts[#parts + 1] = WinApi.quoteArg(arg) + end + return table.concat(parts, " ") +end + +function WinApi.dirOf(path) + if type(path) ~= "string" then return nil end + local dir = path:match("^(.*)[\\/][^\\/]*$") + if not dir or dir == "" then return nil end + if dir:match("^%a:$") then dir = dir .. "\\" end + return dir +end + +function WinApi.spawn(exe, args, opts) + local ffi = lib() + if not ffi or type(exe) ~= "string" or exe == "" then return false end + opts = opts or {} + local ok, spawned = pcall(function() + local cmd = WinApi.wide(opts.commandLine or WinApi.commandLine(exe, args)) + if not cmd then return false end + local app = nil + if opts.useApplicationName ~= false then + app = WinApi.wide(exe) + if not app then return false end + end + local cwd = nil + if type(opts.cwd) == "string" and opts.cwd ~= "" then + cwd = WinApi.wide(opts.cwd) + if not cwd then return false end + end + local si = ffi.new("PP_STARTUPINFOW") + si.cb = ffi.sizeof("PP_STARTUPINFOW") + local pi = ffi.new("PP_PROCESS_INFORMATION") + local flags = opts.flags or (WinApi.DETACHED_PROCESS + WinApi.CREATE_NEW_PROCESS_GROUP) + if ffi.C.CreateProcessW(app, cmd, nil, nil, 0, flags, nil, cwd, si, pi) == 0 then + return false + end + ffi.C.CloseHandle(pi.hThread) + ffi.C.CloseHandle(pi.hProcess) + return true + end) + return ok and spawned == true +end + +return WinApi diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 1a07b70f..f9fa5e58 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -2034,6 +2034,8 @@ local function saveActions(imp, scope, version, slot) imp._saveExport = { scope = scope, version = version, slotId = slot.id, label = slot.label or slot.name or Strings("NEW GAME") } end } + actions[#actions + 1] = { label = Strings("Duplicate"), icon = "copy", key = key .. "-dup", + action = function() imp:_duplicateSlot(scope, slot.id) end } end if not imp.android then actions[#actions + 1] = { label = Strings("Rename"), icon = "pencil", key = key .. "-rename", @@ -2446,7 +2448,7 @@ end -- A row's control key is a pure function of its id, but concatenating it per -- visible row per frame is ~1200 strings a second. Memoised on the launcher, -- NOT on the entry: index entries are the same tables ModIndex.writeCache --- persists into options.modIndexCache, and view state must not ride along. +-- persists into mod_index_cache.lua, and view state must not ride along. local function rowKeyFor(imp, prefix, id) local keys = imp._rowKeys if not keys then keys = {}; imp._rowKeys = keys end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 052857db..08eea6c9 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1862,11 +1862,17 @@ function RomImporter:focus(f) self.pickerPendingModId = nil self.pickerPendingImportId = nil self.requiredImportLegacyRomPick = nil + elseif self.cartLegacyModPick and self.pickerPendingKind == "cart" + and pickError:find("picked_mod", 1, true) then + self.pickerPendingKind, self.pickerPendingVersion = nil, nil + self.cartLegacyModPick = nil + self._cartNotice = text elseif pickError:find("picked_mod", 1, true) then if self.pickerPendingKind == "skin" then self.pickerPendingKind = nil self._skinNotice = { ok = false, text = text } else + self.pickerPendingKind = nil self.modNotice = { ok = false, text = text } end elseif self.pickerPendingKind == "box" then @@ -1880,6 +1886,7 @@ function RomImporter:focus(f) or self.pickerPendingKind == "cart" then self.pickerPendingKind = nil self.pickerPendingVersion = nil + self.cartLegacyModPick = nil self._cartNotice = text else self:setError(text) @@ -1937,6 +1944,14 @@ function RomImporter:focus(f) end local modName = findPendingMod(false, self.pickSkip) if modName then + if self.pickerPendingKind == "cart" and self.cartLegacyModPick then + local version = self.pickerPendingVersion or self._cartPopup or self.tab + self.pickerPendingKind, self.pickerPendingVersion = nil, nil + self.cartLegacyModPick = nil + consumePick(self, modName, "picked_mod.zip", + self:_installCartFile(modName, version)) + return + end if self.pickerPendingKind == "skin" then self.pickerPendingKind = nil self:_installSkinZip(modName) @@ -1944,6 +1959,7 @@ function RomImporter:focus(f) self._skinNotice and self._skinNotice.ok) return end + self.pickerPendingKind = nil self:_installMod(modName) consumePick(self, modName, "picked_mod.zip", self.modNotice and self.modNotice.ok) @@ -2705,7 +2721,11 @@ function RomImporter:chooseMod() return end self._modPickOpenWait = nil + self.pickerPendingKind = "mod" + self.pickerPendingVersion = nil + self.cartLegacyModPick = nil if not pickFile("mod") then + self.pickerPendingKind = nil self._modPickStalled = true self.modNotice = { ok = false, text = self:_modInboxHint(Strings("Could not open the file picker.")) } @@ -3231,6 +3251,8 @@ function RomImporter:chooseSaveImport(version) return end self.androidPendingVersion = version + self.pickerPendingKind, self.pickerPendingVersion = nil, nil + self.cartLegacyModPick = nil if not pickFile("sav") then self.androidPendingVersion = nil self.saveNotice[version] = { ok = false, @@ -3416,9 +3438,14 @@ function RomImporter:choose(version) local name, data = findPendingRom(self, self.chooseVersion) if name then self:startData(data, name) - elseif consumePickedRomError(self) then + return + end + if consumePickedRomError(self) then return -- a rejected pick explains itself instead of silently reopening - elseif not pickFile() then + end + self.pickerPendingKind, self.pickerPendingVersion = nil, nil + self.cartLegacyModPick = nil + if not pickFile() then -- Picker unavailable (API < 19, or no document-picker app installed): -- fall back to the USB folder-drop path as a friendly notice, not an -- error (which would read as a rejected file). @@ -5710,16 +5737,25 @@ function RomImporter:importCartFile(version) consumePick(self, name, PICKED_CART, installed) return installed end - if pickerHasKind("cart") and pickFile("cart") then - self.pickerPendingKind = "cart" - self.pickerPendingVersion = version + local kind + if pickerHasKind("cart") then + kind = "cart" + elseif pickerHasKind("mod") or love.system.pickFileKinds == nil then + kind = "mod" + end + self.pickerPendingKind = "cart" + self.pickerPendingVersion = version + self.cartLegacyModPick = (kind == "mod") or nil + if kind and pickFile(kind) then self.pickPending = true self.pickTimer = 0 return true end + self.pickerPendingKind, self.pickerPendingVersion = nil, nil + self.cartLegacyModPick = nil local dir = love.filesystem.getSaveDirectory() self._cartNotice = Strings( - "Could not open the file picker. Copy a .g1rcart into:\n%s", dir) + "Could not open the file picker. Update the app, or copy a .g1rcart into:\n%s", dir) return false end if handheldHost() and self:_openCartBrowser(version) then return true end @@ -6913,6 +6949,35 @@ function RomImporter:_newSlot(scope) self.slotScroll[scope] = math.huge end +function RomImporter:_duplicateSlot(scope, id) + if self.workState == "working" then return end + local SaveData = require("src.core.SaveData") + local cart = cartOfScope(scope) + local base + for _, slot in ipairs(self.slots[scope] or {}) do + if slot.id == id then base = slot.label or slot.name break end + end + base = (type(base) == "string" and base ~= "") and base or tostring(id) + local label = utf8Cap(Strings("%s copy", utf8Cap(base, MAX_SLOT_LABEL - 5)), + MAX_SLOT_LABEL) + local newId, err + if cart then + newId, err = SaveData.duplicateCartSlot(cart, id, label) + else + newId, err = SaveData.duplicateSlot(scope, id, label) + end + if not newId then + self.saveNotice[scope] = { ok = false, text = tostring(err) } + return + end + self:_selectSlot(scope, newId) + self:_refreshSlots(scope) + self.activeSlot[scope] = newId + local eng = self:_syncEngine() + if eng then pcall(eng.noteSaveWritten, eng) end + self.saveNotice[scope] = { ok = true, text = Strings("Copied to %s.", label) } +end + -- Mouse wheel: forwarded into the FlexLove view (installed onto the global -- love.wheelmoved in new(); see the chain there). Scroll containers and the -- modal scrollers all resolve inside the toolkit. @@ -8697,7 +8762,7 @@ end -- Release stats for a FIND MODS row, resolved the same way the MODS tab -- does it: the mod's own GitHub releases through ModUpdate's cached fetch, -- so an installed mod's repo is instant and every result lands in --- options.modUpdateCache for six hours. A feed that publishes stats wins +-- mod_update_cache.lua for six hours. A feed that publishes stats wins -- outright (fresher, zero network); otherwise the repo is fetched, one -- entry per frame so opening the tab cannot stall for the whole listing. -- The result is memoized per id for the session; a repo with no releases diff --git a/src/mods/ModIndex.lua b/src/mods/ModIndex.lua index 48f7f5ea..e04e7bce 100644 --- a/src/mods/ModIndex.lua +++ b/src/mods/ModIndex.lua @@ -706,27 +706,131 @@ function ModIndex.removeSource(feed) end if not found then return nil end opts.modIndexes = kept - if type(opts.modIndexCache) == "table" then opts.modIndexCache[feed] = nil end SaveData.saveOptions(opts) return true end) if not ok then return nil, tostring(result) end if not result then return nil, "that index is not in the list" end + pcall(function() + local all = ModIndex._cacheTree() + if all and all[feed] ~= nil then + all[feed] = nil + ModIndex._writeCacheTree(all) + end + end) return true end --- ------- cache (options.modIndexCache[feed]) +-- ------- cache (mod_index_cache.lua beside options.lua) + +ModIndex.CACHE_FILE = "mod_index_cache.lua" + +local cacheTrees = setmetatable({}, { __mode = "k" }) +local migrated = setmetatable({}, { __mode = "k" }) + +local function deepCopy(v) + if type(v) ~= "table" then return v end + local out = {} + for k, val in pairs(v) do out[k] = deepCopy(val) end + return out +end + +local function cacheFs() + local SaveData = require("src.core.SaveData") + if type(SaveData.persistenceFs) ~= "function" then return nil end + return SaveData.persistenceFs() +end + +local function readCacheFile(fs) + if not (fs.getInfo and fs.getInfo(ModIndex.CACHE_FILE)) then return {} end + local body = fs.read(ModIndex.CACHE_FILE) + if type(body) ~= "string" then return {} end + local ok, t = pcall(require("src.core.SaveSerializer").decode, body) + return (ok and type(t) == "table") and t or {} +end + +function ModIndex._writeCacheTree(all) + local fs = cacheFs() + if not fs then return false end + local ok = fs.write(ModIndex.CACHE_FILE, require("src.core.SaveSerializer").encode(all)) + cacheTrees[fs] = ok and all or nil + return ok and true or false +end + +local function readRawOptions(fs) + local name = require("src.core.SaveData").OPTIONS_FILENAME + if type(name) ~= "string" or not (fs.getInfo and fs.getInfo(name)) then return nil end + local body = fs.read(name) + if type(body) ~= "string" then return nil end + local ok, t = pcall(require("src.core.SaveSerializer").decode, body) + return (ok and type(t) == "table") and t or nil +end + +local function moveLegacy(fs, all) + if migrated[fs] then return false end + migrated[fs] = true + local raw = readRawOptions(fs) + local legacy = raw and raw.modIndexCache + if type(legacy) ~= "table" or next(legacy) == nil then return false end + for feed, entry in pairs(legacy) do + local have = all[feed] + if type(entry) == "table" and (type(have) ~= "table" + or (tonumber(entry.checkedAt) or 0) > (tonumber(have.checkedAt) or 0)) then + all[feed] = entry + end + end + ModIndex._writeCacheTree(all) + return true +end + +local function loadedTree(fs) + local all = cacheTrees[fs] + if not all then + all = readCacheFile(fs) + cacheTrees[fs] = all + end + return all +end + +function ModIndex._migrateLegacy() + local fs = cacheFs() + if not fs then return false end + return moveLegacy(fs, loadedTree(fs)) +end + +local function stripLegacyOptions() + pcall(function() require("src.mods.ModUpdate")._migrateLegacy() end) + local SaveData = require("src.core.SaveData") + local ok, opts = pcall(SaveData.loadOptions) + if not ok or type(opts) ~= "table" then return end + opts.modIndexCache = nil + opts.modUpdateCache = nil + pcall(SaveData.saveOptions, opts) +end + +function ModIndex._cacheTree() + local fs = cacheFs() + if not fs then return nil end + local all = loadedTree(fs) + if moveLegacy(fs, all) then stripLegacyOptions() end + return cacheTrees[fs] or all +end + +function ModIndex._resetCacheForTests() + cacheTrees = setmetatable({}, { __mode = "k" }) + migrated = setmetatable({}, { __mode = "k" }) +end function ModIndex.readCache(feed) if type(feed) ~= "string" or feed == "" then return nil end - local ok, opts = pcall(loadOptions) - if not ok or type(opts) ~= "table" then return nil end - local entry = opts.modIndexCache and opts.modIndexCache[feed] + local ok, all = pcall(ModIndex._cacheTree) + if not ok or type(all) ~= "table" then return nil end + local entry = all[feed] if type(entry) ~= "table" or type(entry.checkedAt) ~= "number" then return nil end if type(entry.mods) ~= "table" then return nil end - return entry + return deepCopy(entry) end function ModIndex.cacheFresh(entry, now, ttl) @@ -739,11 +843,10 @@ end function ModIndex.writeCache(feed, index) if type(feed) ~= "string" or feed == "" then return false end - local ok = pcall(function() - local SaveData = require("src.core.SaveData") - local opts = loadOptions() - opts.modIndexCache = opts.modIndexCache or {} - opts.modIndexCache[feed] = { + local ok, wrote = pcall(function() + local all = ModIndex._cacheTree() + if not all then return false end + all[feed] = deepCopy({ checkedAt = os.time(), version = ModIndex.CACHE_VERSION, generatedAt = index.generatedAt, @@ -751,10 +854,10 @@ function ModIndex.writeCache(feed, index) baseGames = index.baseGames, mods = index.mods, carts = index.carts, - } - SaveData.saveOptions(opts) + }) + return ModIndex._writeCacheTree(all) end) - return ok + return ok and wrote == true end -- ------- host I/O (HostShell's transport: curl, or the Android JNI bridge) @@ -849,7 +952,7 @@ function ModIndex.beginFetch(source, opts) end -- Shared with the sync path's `cached` closure: read whatever is in the --- options cache and shape it like a parsed index. +-- index cache and shape it like a parsed index. local function cachedIndex(feed, stale) local entry = ModIndex.readCache(feed) if not entry then return nil end diff --git a/src/mods/ModUpdate.lua b/src/mods/ModUpdate.lua index d839a0f7..42e9c3a1 100644 --- a/src/mods/ModUpdate.lua +++ b/src/mods/ModUpdate.lua @@ -1,6 +1,6 @@ -- GitHub release helpers for mod auto-update / other-versions. -- Pure parsing is love-free; fetch/download use HostShell + curl when available. --- Release lists are cached in options.modUpdateCache for CACHE_TTL seconds +-- Release lists are cached in mod_update_cache.lua for CACHE_TTL seconds -- (default 6 hours). The launcher owns UI and install. local ModUpdate = {} @@ -311,27 +311,116 @@ function ModUpdate.statsLine(total, first, latest) return table.concat(parts, " - ") end --- ------- cache (options.modUpdateCache[repo]) +-- ------- cache (mod_update_cache.lua beside options.lua) -local function cacheStore() +ModUpdate.CACHE_FILE = "mod_update_cache.lua" + +local cacheTrees = setmetatable({}, { __mode = "k" }) +local migrated = setmetatable({}, { __mode = "k" }) + +local function deepCopy(v) + if type(v) ~= "table" then return v end + local out = {} + for k, val in pairs(v) do out[k] = deepCopy(val) end + return out +end + +local function cacheFs() local SaveData = require("src.core.SaveData") - local opts = SaveData.loadOptions() - opts.modUpdateCache = opts.modUpdateCache or {} - return opts + if type(SaveData.persistenceFs) ~= "function" then return nil end + return SaveData.persistenceFs() +end + +local function readCacheFile(fs) + if not (fs.getInfo and fs.getInfo(ModUpdate.CACHE_FILE)) then return {} end + local body = fs.read(ModUpdate.CACHE_FILE) + if type(body) ~= "string" then return {} end + local ok, t = pcall(require("src.core.SaveSerializer").decode, body) + return (ok and type(t) == "table") and t or {} +end + +local function writeCacheTree(all) + local fs = cacheFs() + if not fs then return false end + local ok = fs.write(ModUpdate.CACHE_FILE, require("src.core.SaveSerializer").encode(all)) + cacheTrees[fs] = ok and all or nil + return ok and true or false +end + +local function readRawOptions(fs) + local name = require("src.core.SaveData").OPTIONS_FILENAME + if type(name) ~= "string" or not (fs.getInfo and fs.getInfo(name)) then return nil end + local body = fs.read(name) + if type(body) ~= "string" then return nil end + local ok, t = pcall(require("src.core.SaveSerializer").decode, body) + return (ok and type(t) == "table") and t or nil +end + +local function moveLegacy(fs, all) + if migrated[fs] then return false end + migrated[fs] = true + local raw = readRawOptions(fs) + local legacy = raw and raw.modUpdateCache + if type(legacy) ~= "table" or next(legacy) == nil then return false end + for repo, entry in pairs(legacy) do + local have = all[repo] + if type(entry) == "table" and (type(have) ~= "table" + or (tonumber(entry.checkedAt) or 0) > (tonumber(have.checkedAt) or 0)) then + all[repo] = entry + end + end + writeCacheTree(all) + return true +end + +local function loadedTree(fs) + local all = cacheTrees[fs] + if not all then + all = readCacheFile(fs) + cacheTrees[fs] = all + end + return all +end + +function ModUpdate._migrateLegacy() + local fs = cacheFs() + if not fs then return false end + return moveLegacy(fs, loadedTree(fs)) +end + +local function stripLegacyOptions() + pcall(function() require("src.mods.ModIndex")._migrateLegacy() end) + local SaveData = require("src.core.SaveData") + local ok, opts = pcall(SaveData.loadOptions) + if not ok or type(opts) ~= "table" then return end + opts.modIndexCache = nil + opts.modUpdateCache = nil + pcall(SaveData.saveOptions, opts) +end + +local function cacheTree() + local fs = cacheFs() + if not fs then return nil end + local all = loadedTree(fs) + if moveLegacy(fs, all) then stripLegacyOptions() end + return cacheTrees[fs] or all +end + +function ModUpdate._resetCacheForTests() + cacheTrees = setmetatable({}, { __mode = "k" }) + migrated = setmetatable({}, { __mode = "k" }) end function ModUpdate.readCache(repo) if type(repo) ~= "string" or repo == "" then return nil end - local ok, opts = pcall(function() - return require("src.core.SaveData").loadOptions() - end) - if not ok or type(opts) ~= "table" then return nil end - local entry = opts.modUpdateCache and opts.modUpdateCache[repo] + local ok, all = pcall(cacheTree) + if not ok or type(all) ~= "table" then return nil end + local entry = all[repo] if type(entry) ~= "table" or type(entry.checkedAt) ~= "number" then return nil end if type(entry.releases) ~= "table" then return nil end - return entry + return deepCopy(entry) end function ModUpdate.cacheFresh(entry, now, ttl) @@ -359,9 +448,9 @@ end function ModUpdate.writeCache(repo, releases) if type(repo) ~= "string" or repo == "" then return false end - local ok = pcall(function() - local SaveData = require("src.core.SaveData") - local opts = cacheStore() + local ok, wrote = pcall(function() + local all = cacheTree() + if not all then return false end local best = ModUpdate.pickBest(releases) -- Persist a lean copy: enough to paint the UI and reinstall without -- re-fetching within the TTL. @@ -382,14 +471,14 @@ function ModUpdate.writeCache(repo, releases) } or nil, } end - opts.modUpdateCache[repo] = { + all[repo] = { checkedAt = os.time(), latest = best and best.version or nil, releases = lean, } - SaveData.saveOptions(opts) + return writeCacheTree(all) end) - return ok + return ok and wrote == true end -- Status vs an installed version using a cache entry / release list. diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index ae7f7763..ba37d848 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1928,6 +1928,7 @@ R.maps = { width = f.int(1), height = f.int(1), blocks = f.list(f.int(0, 255)), borderBlock = f.opt(f.int(0, 255)), + outdoor = f.opt(f.bool), -- A named SGB palette, which wins over the field.palettes cascade -- (OverworldController.lua:506 reads map.def.palette first). Deliberately -- a plain string rather than f.id("palettes"): the ROM-free fixture base diff --git a/src/ui/BindingsMenu.lua b/src/ui/BindingsMenu.lua index 7acb1b75..62a5d069 100644 --- a/src/ui/BindingsMenu.lua +++ b/src/ui/BindingsMenu.lua @@ -25,8 +25,8 @@ local BUTTONS = { { id = "b", label = "B", key = "x", pad = "b" }, { id = "start", label = "START", key = "escape", pad = "start" }, { id = "select", label = "SELECT", key = "tab", pad = "back" }, - { id = "speedDown", label = "SPEED -", pad = "leftshoulder", action = true }, - { id = "speedUp", label = "SPEED +", pad = "rightshoulder", action = true }, + { id = "speedDown", label = "SPEED -", key = "0", pad = "leftshoulder", action = true }, + { id = "speedUp", label = "SPEED +", key = "1", pad = "rightshoulder", action = true }, } BindingsMenu.BUTTONS = BUTTONS @@ -35,8 +35,8 @@ BindingsMenu.GEN3_BUTTONS = { BUTTONS[5], BUTTONS[6], BUTTONS[7], BUTTONS[8], { id = "l", label = "L", key = "q", pad = "leftshoulder" }, { id = "r", label = "R", key = "e", pad = "rightshoulder" }, - { id = "speedDown", label = "SPEED -", pad = "triggerleft", action = true }, - { id = "speedUp", label = "SPEED +", pad = "triggerright", action = true }, + { id = "speedDown", label = "SPEED -", key = "0", pad = "triggerleft", action = true }, + { id = "speedUp", label = "SPEED +", key = "1", pad = "triggerright", action = true }, } -- a binding is a plain key string or { key, pad }; absent = the fixed @@ -84,7 +84,8 @@ end local function boundRight(overlay, def) local pad = boundPad(overlay, def) if def.action then - return pad and shortName(pad, PAD_SHORT) or Strings("OFF") + local p = pad and shortName(pad, PAD_SHORT) or Strings("OFF") + return def.key and (def.key .. "/" .. p) or p end local key = shortName(boundKey(overlay, def), KEY_SHORT) if pad then return key .. "/" .. shortName(pad, PAD_SHORT) end @@ -235,7 +236,8 @@ function BindingsMenu:storeBinding(slot, value) if handover == nil and item.button.action then handover = item.button.pad end if value ~= prev then for _, other in ipairs(self.items) do - if other ~= item and effective(opts.bindings, other.button) == value then + if other ~= item and not (slot == "key" and other.button.action) + and effective(opts.bindings, other.button) == value then if other.button.action then opts.bindings[other.button.id] = false else diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index d675e357..9d4ba62f 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -20,6 +20,7 @@ local FieldDefaults = require("src.world.FieldDefaults") local Map = require("src.world.Map") local Strings = require("src.core.Strings") local Status = require("src.battle.Status") +local Timing = require("src.core.Timing") local PartyMenu = { isMenu = true } PartyMenu.__index = PartyMenu @@ -411,12 +412,7 @@ function PartyMenu.new(game, opts) return self end --- UpdateHPBar2 (engine/gfx/hp_bar.asm, predef'd from item_effects.asm's --- .doneHealing): UpdateHPBar_AnimateHPBar is documented "for (a) ticks (two --- waiting frames each)" over a 48-pixel bar, so the shown HP walks --- maxHP/96 per frame -- the same rate the battle HUD drains at --- (BattleState:stepHPDrain). onDone fires on the frame it lands, which is --- when the caller prints its message. #252 +-- engine/items/item_effects.asm:1208, engine/gfx/hp_bar.asm:81-135 function PartyMenu:animateTo(mon, fromHP, onDone) if not (mon and mon.stats) then if onDone then onDone() end @@ -425,7 +421,44 @@ function PartyMenu:animateTo(mon, fromHP, onDone) local from = math.max(0, fromHP or mon.hp) -- `from` outlives `shown`: sgbPalettes above needs the pre-heal HP for the -- whole fill, because the SGB bar color does not move until the redraw. - self.heal = { mon = mon, from = from, shown = from, onDone = onDone } + self.heal = { mon = mon, from = from, shown = from, hold = 0, + px = Timing.hpBarPixels(from, math.max(1, mon.stats.hp)), + onDone = onDone } +end + +function PartyMenu:finishHeal() + local heal = self.heal + self.heal = nil + if heal.onDone then heal.onDone() end +end + +function PartyMenu:stepHeal() + local heal = self.heal + local maxHP = math.max(1, heal.mon.stats.hp) + local goal = heal.mon.hp + if heal.hold > 0 then + heal.hold = heal.hold - 1 + if heal.hold == 0 and heal.closing then self:finishHeal() end + return + end + local targetPx = Timing.hpBarPixels(heal.shown, maxHP) + if heal.px ~= targetPx then + -- engine/gfx/hp_bar.asm:140-148 + heal.px = heal.px + ((heal.px > targetPx) and -1 or 1) + heal.hold = Timing.HP_BAR_PIXEL_STEP - 1 + elseif heal.shown ~= goal then + -- engine/gfx/hp_bar.asm:100-110 + heal.shown = heal.shown + ((heal.shown > goal) and -1 or 1) + heal.started = true + heal.hold = Timing.HP_BAR_HP_STEP - 1 + elseif heal.started then + -- engine/gfx/hp_bar.asm:121-135 + heal.closing = true + heal.hold = Timing.hpDrainClosingFrames(true) - 1 + else + -- engine/gfx/hp_bar.asm:69-70 + self:finishHeal() + end end -- Close a picker the caller kept open (see self.keepOpen). A TextBox pops @@ -469,14 +502,8 @@ function PartyMenu:update(dt) end -- The bar fill owns the menu while it runs: UpdateHPBar2 is a blocking -- predef in item_effects.asm, so no button is read until it lands (#252). - local heal = self.heal - if heal then - heal.shown = math.min(heal.mon.hp, - heal.shown + math.max(1, heal.mon.stats.hp) / 96) - if heal.shown >= heal.mon.hp then - self.heal = nil - if heal.onDone then heal.onDone() end - end + if self.heal then + self:stepHeal() return end -- SwitchPartyMon_ClearGfx (engine/menus/start_sub_menus.asm:690), then @@ -1011,13 +1038,15 @@ function PartyMenu:draw() -- animation has reached rather than the final value; drawHPBar reads -- only .hp and .stats, so a shim table is enough and the real mon is -- never mutated for display (#252). - local shown = mon + local shown, shownPx = mon, nil if self.heal and self.heal.mon == mon then shown = { hp = math.floor(self.heal.shown), stats = mon.stats } + shownPx = self.heal.px end love.graphics.setColor(1, 1, 1, 1) -- engine/menus/party_menu.asm:71 - HudTiles.drawHPBar(self.game.data, 4, (y + 8) / 8, shown, nil, barZoned) + HudTiles.drawHPBar(self.game.data, 4, (y + 8) / 8, shown, nil, barZoned, + nil, shownPx) love.graphics.setColor(0, 0, 0, 1) Font.draw(("%3d/%3d"):format(shown.hp, mon.stats.hp), 104, y + 8) end diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 7e032b9c..d9235adf 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -3783,9 +3783,6 @@ function BattleState:answerNickname(yes) if not (yes and mon and stack) then return self:advanceQueue() end self.phase = "submenu" local data = (self.game and self.game.data) or {} - local icons = data.gen2Icons - local iconId = icons and icons.species and icons.species[mon.species] - local entry = iconId and icons.icons and icons.icons[iconId] local done = function(name) stack:pop() -- InitName: an empty entry keeps whatever was already in the buffer, which @@ -3796,8 +3793,8 @@ function BattleState:answerNickname(yes) end Screens.push(self.game, "Gen2NamingScreen", { type = "nickname", + mon = mon, monName = mon.name or mon.species, - iconPath = entry and entry.image or nil, menuGfx = data.gen2MenuGfx, onDone = done, onCancel = function() done(nil) end, diff --git a/src/ui/gen2/BoxMenu.lua b/src/ui/gen2/BoxMenu.lua index 56332e9b..0a99ba00 100644 --- a/src/ui/gen2/BoxMenu.lua +++ b/src/ui/gen2/BoxMenu.lua @@ -751,6 +751,7 @@ function BoxMenu:askNickname() -- The "nickname" kind is MON_NAME_LENGTH - 1 wide and takes its header -- from the mon rather than from a fixed prompt. type = "nickname", + mon = mon, monName = mon.name or mon.species, initial = mon.nickname or "", onDone = function(name) diff --git a/src/ui/gen2/NamingScreen.lua b/src/ui/gen2/NamingScreen.lua index 6e01ea5f..af4d0886 100644 --- a/src/ui/gen2/NamingScreen.lua +++ b/src/ui/gen2/NamingScreen.lua @@ -109,7 +109,7 @@ function NamingScreen:wantsFillScale() return true end function NamingScreen:drawsWidescreen() return true end -- opts: type ("player"/"rival"/"mom"/"box"/"nickname"), prompt, maxLength, --- initial, monName (nickname header), icon/sprite image path, gender, +-- initial, mon (nickname header), monName, icon/sprite image path, gender, -- onDone(name), onCancel(). function NamingScreen.new(game, opts) opts = opts or {} @@ -139,6 +139,11 @@ function NamingScreen.new(game, opts) -- one it would draw in raw DMG shades next to a colored world. self.iconColors = opts.iconColors local data = game and game.data or {} + self.clock = 0 + self.mon = opts.mon + if self.mon then + self:loadMonHeader(data, self.mon) + end self.gfx = opts.menuGfx or data.gen2MenuGfx if self.gfx and self.gfx.naming then self.gfx = self.gfx.naming end -- engine/menus/naming_screen.asm:47 @@ -157,6 +162,51 @@ function NamingScreen.new(game, opts) return self end +-- engine/menus/naming_screen.asm:88 +function NamingScreen:loadMonHeader(data, mon) + self.monName = self.monName or mon.name or mon.species + -- engine/gfx/mon_icons.asm:137 + local icons = data.gen2Icons + local PartyMenu = require("src.ui.gen2.PartyMenu") + local iconId = PartyMenu.iconIdFor({ icons = icons }, mon) + local entry = iconId and icons and icons.icons and icons.icons[iconId] + local path = require("src.pokemon.Sprites").iconPath( + data, mon, entry and entry.image, { name = iconId }) + self.iconImage = nil + if path then + local ok, img = pcall(Assets.image, path) + if ok then self.iconImage = img end + end + self.monIcon = true + -- engine/gfx/cgb_layouts.asm:491 + local pals = data.gen2Palettes and data.gen2Palettes.partyMenu + self.iconColors = pals and pals[1] or nil + self.monGender = mon.gender +end + +-- engine/menus/naming_screen.asm:107-114 +function NamingScreen:genderGlyph() + if self.monGender == "male" then return "\xe2\x99\x82" end + if self.monGender == "female" then return "\xe2\x99\x80" end + return nil +end + +-- data/sprite_anims/framesets.asm:66 +-- engine/sprite_anims/core.asm:400 +-- engine/menus/naming_screen.asm:308-310 +NamingScreen.ICON_FRAME_STEPS = 18 + +function NamingScreen:iconFrame() + if not self.monIcon then return 0 end + return math.floor(self.clock / NamingScreen.ICON_FRAME_STEPS) % 2 +end + +-- engine/gfx/mon_icons.asm:143 +function NamingScreen:iconOrigin() + if self.monIcon then return 16, 12 end + return 16, 16 +end + -- ui.naming.grid identity: unhooked, the board is the one the cart ships. local function sameGrid(grid) return grid end @@ -282,6 +332,8 @@ function NamingScreen:moveVertical(delta) end function NamingScreen:update(_dt) + -- engine/menus/naming_screen.asm:308 + self.clock = (self.clock or 0) + 1 local input = self.game and self.game.input if not input then return end @@ -495,29 +547,34 @@ function NamingScreen:drawPanel() -- letters and the case/DEL/END strip. self:clearPanel(1, 16, 18, 1) - -- Header: the standing-down frame of a 16x96 OW sheet (or the first 16x16 of - -- a mon icon) on the left, and the prompt at (5,2). Quad it: blitting the - -- whole sheet paints every walk frame down the screen. if self.iconImage then G.setColor(1, 1, 1, 1) local w, h = self.iconImage:getDimensions() - -- Cut once per icon image rather than every frame. + local frameY = self:iconFrame() * 16 + if frameY + 16 > h then frameY = 0 end local quad = self.iconQuad if not quad or self.iconQuadImage ~= self.iconImage then - quad = love.graphics.newQuad(0, 0, math.min(16, w), math.min(16, h), + quad = love.graphics.newQuad(0, frameY, math.min(16, w), math.min(16, h), w, h) self.iconQuad, self.iconQuadImage = quad, self.iconImage + else + quad:setViewport(0, frameY, math.min(16, w), math.min(16, h), w, h) end + local ix, iy = self:iconOrigin() if self.iconColors and GbcPalette.available() then - -- GbcPalette.with without the closure: set, draw, restore. local previous = G.getShader and G.getShader() or nil GbcPalette.use(self.iconColors) - G.draw(self.iconImage, quad, 16, 16) + G.draw(self.iconImage, quad, ix, iy) G.setShader(previous) else - G.draw(self.iconImage, quad, 16, 16) + G.draw(self.iconImage, quad, ix, iy) end end + local glyph = self:genderGlyph() + if glyph then + -- engine/menus/naming_screen.asm:113 + Chrome.printThrough(glyph, 1, 2, self.palette) + end local pal = self.palette if self.monName then -- Nickname header is two lines: "'S" then "NICKNAME?". Kept as two diff --git a/src/update/check_worker.lua b/src/update/check_worker.lua index 66fddf79..6274645f 100644 --- a/src/update/check_worker.lua +++ b/src/update/check_worker.lua @@ -35,6 +35,8 @@ local Json = loadModule("src/link/Json.lua") local Check = loadModule("src/update/Check.lua") local Version = loadModule("src/core/Version.lua") local Semver = loadModule("src/update/Semver.lua") +local WinApi = loadModule("src/core/WinApi.lua") +if WinApi then package.loaded["src.core.WinApi"] = WinApi end local HostShell = loadModule("src/core/HostShell.lua") -- Boot's top-level require("src.update.Semver") cannot resolve in this thread -- (no src.* searcher), which would leave Boot nil and the minShell gate @@ -304,19 +306,26 @@ end -- failed transfer simply fails the checksum below, which is the real gate. local function launchDownload(url, partAbs, doneAbs) if isWindows then - -- a tiny batch file sidesteps cmd.exe's nested-quote madness local batRel = "updates/dl.bat" + local partName = partAbs:match("[^/\\]+$") + local doneName = doneAbs:match("[^/\\]+$") love.filesystem.write(batRel, "@echo off\r\n" - -- start /b hands the child our cwd, the install folder, and the - -- detached cmd.exe held that folder un-movable for the rest of the - -- transfer after the game exited (#727). Every path below is - -- absolute, so park the child in its own directory (the save dir). .. "cd /d \"%~dp0\"\r\n" .. "curl -fsSL --connect-timeout 15 --max-time 900 -o \"" - .. partAbs .. "\" \"" .. url .. "\"\r\n" - .. "type nul > \"" .. doneAbs .. "\"\r\n") - os.execute('start "" /b ' .. shq(saveDir .. "/" .. batRel)) + .. partName .. "\" \"" .. url:gsub("%%", "%%%%") .. "\"\r\n" + .. "type nul > \"" .. doneName .. "\"\r\n") + local updatesDir = (saveDir .. "/updates"):gsub("/", "\\") + local comspec = os.getenv("ComSpec") + if type(comspec) ~= "string" or comspec == "" or comspec:find("[\128-\255]") then + comspec = (os.getenv("SystemRoot") or "C:\\Windows") .. "\\System32\\cmd.exe" + end + if not (WinApi and WinApi.spawn(comspec, { "/d", "/c", "dl.bat" }, { + cwd = updatesDir, + flags = WinApi.CREATE_NO_WINDOW + WinApi.CREATE_NEW_PROCESS_GROUP, + })) then + os.execute('start "" /b ' .. shq(saveDir .. "/" .. batRel)) + end else -- ( ... ) & backgrounds the whole group so os.execute returns at once. -- Use the same dual-env curl resolution as HostShell.http*: bundled diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 309a078e..65ca742b 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -4673,6 +4673,14 @@ function OverworldState:rollEncounter(encDef, terrain) return enc end +function OverworldState.rollsIndoorEncounters(def, indoor) + if not indoor or Map.isOutdoor(def) then return false end + -- engine/battle/wild_encounters.asm:41 + if def.index ~= nil and def.index < indoor.firstIndoorMap then return false end + -- engine/battle/wild_encounters.asm:44 + return def.tileset ~= indoor.excludedTileset +end + function OverworldState:onStepComplete() local p = self.player -- Defaulted: a state built without the constructor (a mod harness, a test @@ -4808,13 +4816,12 @@ function OverworldState:onStepComplete() if suppressWildEncounter then return end local encDef = Game.data.encounters[self.map.id] local enc - local indoor = Game.data.field.indoorEncounters if self.map:isGrassCell(p.cellX, p.cellY) then enc = self:rollEncounter(encDef, "grass") elseif p.surfing and self.map:isWaterCell(p.cellX, p.cellY) then enc = self:rollEncounter({ grass = encDef and encDef.water }, "water") - elseif indoor and self.map.def.index >= indoor.firstIndoorMap - and self.map.def.tileset ~= indoor.excludedTileset then + elseif OverworldState.rollsIndoorEncounters(self.map.def, + Game.data.field.indoorEncounters) then enc = self:rollEncounter(encDef, "indoor") end if enc then diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 0724182c..46157d8b 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -3223,6 +3223,7 @@ function World:renameMon(mon, onDone, opts) end local ok = self:pushScreen("Gen2NamingScreen", { type = "nickname", + mon = mon, monName = mon.name or mon.species, initial = (opts and opts.blank) and "" or (mon.nickname or mon.name or mon.species or ""), @@ -11149,9 +11150,6 @@ function World:nameHatchling(mon, onDone) local game = self.game if not (game and game.stack) then return onDone() end local data = game.data or {} - local icons = data.gen2Icons - local iconId = icons and icons.species and icons.species[mon.species] - local entry = iconId and icons.icons and icons.icons[iconId] local done = function(name) game.stack:pop() -- _InitString's blank test, not a length one: "zero or more spaces @@ -11163,8 +11161,8 @@ function World:nameHatchling(mon, onDone) end Screens.push(game, "Gen2NamingScreen", { type = "nickname", + mon = mon, monName = mon.name or mon.species, - iconPath = entry and entry.image or nil, menuGfx = data.gen2MenuGfx, onDone = done, onCancel = function() done(nil) end, diff --git a/tests/drivers/game3_speed_hotkey_h1.lua b/tests/drivers/game3_speed_hotkey_h1.lua index 38e0f4a5..42d655c0 100644 --- a/tests/drivers/game3_speed_hotkey_h1.lua +++ b/tests/drivers/game3_speed_hotkey_h1.lua @@ -67,6 +67,13 @@ return function(game) package.loaded["src.core.game3.link"].link = nil expect(game:logicSpeed() == 3, "h1_link_unlocked") + key("0") + expect(game.options.speedOverworld == 2 and game.options.speedBattle == 2 + and game.options.speedMenu == 2, "h1_0_lowers_all_categories") + key("kp0") + expect(game.options.speedOverworld == 1, "h1_numpad_0_lowers") + expect(game:logicSpeed() == 1 and steps(game, 10) == 10, "h1_0_back_to_walk_1x") + game.speedOverride = 1 love.event.quit(failed and 1 or 0) end diff --git a/tests/drivers/gen2_naming_header_2708.lua b/tests/drivers/gen2_naming_header_2708.lua new file mode 100644 index 00000000..fc085e91 --- /dev/null +++ b/tests/drivers/gen2_naming_header_2708.lua @@ -0,0 +1,85 @@ +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") +local BattleState = require("src.ui.gen2.BattleState") +local NamingScreen = require("src.ui.gen2.NamingScreen") + +return function(game) + local fails = 0 + local function check(cond, label) + print((cond and "PASS " or "FAIL ") .. label) + if not cond then fails = fails + 1 end + return cond + end + local ok, err = xpcall(function() + local identity = os.getenv("POKEPORT_IDENTITY") or "" + assert(identity ~= "" and identity ~= "pokemon-love2d", "isolated identity required") + local out = assert(os.getenv("POKEPORT_SHOT_DIR"), "shot dir required") + local version = (game.save and game.save.version) or "gen2" + for _ = 1, 1200 do + if game.world and game.world.map and not game.stack:top() then break end + U.wait(1) + end + local world = assert(game.world, "no world") + assert(world.map and not game.stack:top(), "world did not settle") + + local function new(species, attack) + return assert(Mon.new(game.data, species, 5, + { dvs = { attack = attack, defense = 10, speed = 10, special = 10 } })) + end + local function glyphFor(gender) + if gender == "male" then return "\xe2\x99\x82" end + if gender == "female" then return "\xe2\x99\x80" end + return nil + end + + local starter = new("CHIKORITA", 15) + local named + world:renameMon(starter, function(name) named = name end, { blank = true }) + U.wait(6) + local screen = game.stack:top() + check(screen and screen.screenId == "Gen2NamingScreen", "2708 starter keyboard open") + check(screen and screen.iconImage ~= nil and screen.monIcon, "2708 starter icon present") + check(starter.gender == "male", "2708 starter is male") + check(screen and screen:genderGlyph() == glyphFor(starter.gender), "2708 starter gender drawn") + local x, y = screen:iconOrigin() + check(x == 16 and y == 12, "2708 icon at (16,12)") + local f0 = screen:iconFrame() + U.still(game, out .. "/2708_01_starter_nickname_icon_gender.png") + local flipped = false + for _ = 1, NamingScreen.ICON_FRAME_STEPS + 2 do + U.wait(1) + if screen:iconFrame() ~= f0 then flipped = true break end + end + check(flipped, "2708 icon frame toggles") + U.still(game, out .. "/2708_01b_starter_nickname_other_frame.png") + U.tap(game, "start") U.wait(3) + U.tap(game, "a") U.wait(6) + check(named ~= nil and game.stack:top() ~= screen, "2708 starter keyboard closes on END") + + local caught = new("SENTRET", 0) + local advanced = false + local fake = setmetatable({ + game = game, + nicknameMon = caught, + advanceQueue = function() advanced = true end, + }, { __index = BattleState }) + BattleState.answerNickname(fake, true) + U.wait(6) + screen = game.stack:top() + check(screen and screen.screenId == "Gen2NamingScreen", "2708 catch keyboard open") + check(screen and screen.iconImage ~= nil and screen.monIcon, "2708 catch icon present") + check(caught.gender == "female", "2708 catch is female") + check(screen and screen:genderGlyph() == glyphFor(caught.gender), "2708 catch gender drawn") + check(screen and screen.iconColors ~= nil, "2708 catch icon paletted") + U.still(game, out .. "/2708_02_catch_nickname.png") + U.tap(game, "start") U.wait(3) + U.tap(game, "a") U.wait(6) + check(advanced, "2708 catch keyboard hands back to the battle") + print("2708 version " .. tostring(version)) + end, debug.traceback) + if not ok then + print("FAIL 2708 driver error: " .. tostring(err)) + fails = fails + 1 + end + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_speed_hotkey_2600.lua b/tests/drivers/gold_speed_hotkey_2600.lua new file mode 100644 index 00000000..8c279d0a --- /dev/null +++ b/tests/drivers/gold_speed_hotkey_2600.lua @@ -0,0 +1,37 @@ +local U = require("tests.drivers.util") + +local failed = false +local function expect(cond, label) + print((cond and "PASS " or "FAIL ") .. label) + if not cond then failed = true end +end + +local function key(k) + love.keypressed(k, k, false) + love.keyreleased(k, k) +end + +return function(game) + U.wait(45) + expect(game.world and game.world.map ~= nil, "gold_world_booted") + game.speedOverride = nil + game.options.speed = 1 + key("1") + expect(game.options.speed == 2, "gold_1_raises") + key("1") + expect(game.options.speed == 3, "gold_1_raises_again") + key("0") + expect(game.options.speed == 2, "gold_0_lowers") + expect(game:logicSpeed() == 2, "gold_logic_follows_0") + key("kp0") + expect(game.options.speed == 1, "gold_numpad_0_lowers") + local saved = require("src.core.SaveData").loadOptions() + expect(saved.speed == 1 or saved.speedOverworld == 1, "gold_0_persists") + game.linkNet = { closed = false } + game.options.speed = 3 + key("0") + expect(game.options.speed == 3, "gold_0_ignored_in_link") + game.linkNet = nil + game.speedOverride = 1 + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/hp_bar_speed_2723.lua b/tests/drivers/hp_bar_speed_2723.lua new file mode 100644 index 00000000..d1404636 --- /dev/null +++ b/tests/drivers/hp_bar_speed_2723.lua @@ -0,0 +1,183 @@ +-- engine/gfx/hp_bar.asm:81-135, engine/items/item_effects.asm:1208 +-- tools/run_driver.sh red tests/drivers/hp_bar_speed_2723.lua +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local Bag = require("src.inventory.Bag") + local PartyMenu = require("src.ui.PartyMenu") + local BattleState = require("src.battle.BattleState") + local Timing = require("src.core.Timing") + local TextBox = require("src.render.TextBox") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1 else fail = fail + 1 end + print((ok and "PASS " or "FAIL ") .. label) + return ok + end + local function top() return game.stack:top() end + local function isPicker(s) + return s ~= nil and (s.screenId == "PartyMenu" or getmetatable(s) == PartyMenu) + end + local function cursorTo(menu, want) + for _ = 1, 40 do + if not menu or menu.index == want then return menu and menu.index == want end + U.tap(game, menu.index < want and "down" or "up") + U.wait(3) + end + return menu.index == want + end + + local lead = Pokemon.new(game.data, "MEWTWO", 100) + lead.stats.hp = 415 + lead.hp = 1 + game.save.party = { lead } + Bag.add(game.save, "MAX_POTION", 3) + local maxHP = lead.stats.hp + U.log(("lead MEWTWO 1/%d HP"):format(maxHP)) + check("lead is pinned to the reporter's 415 max HP", maxHP == 415) + + U.teleport(game, "PALLET_TOWN", 5, 6, "down") + U.wait(10) + + local function openPickerFor(id) + U.tap(game, "start") + U.wait(10) + local menu = top() + if not (menu and menu.screenId == "StartMenu") then return nil end + local itemRow + for i, it in ipairs(menu.items or {}) do + if it.label == "ITEM" then itemRow = i break end + end + if not itemRow or not cursorTo(menu, itemRow) then return nil end + U.tap(game, "a") + U.wait(10) + local bag = top() + if not (bag and bag.screenId == "BagMenu") then return nil end + local bagRow + for i, r in ipairs(bag.items or {}) do + if r.value == id then bagRow = i break end + end + if not bagRow or not cursorTo(bag, bagRow) then return nil end + U.tap(game, "a") + U.wait(10) + local ut = top() + if ut and ut.items and ut.items[1] and ut.items[1].label == "USE" then + cursorTo(ut, 1) + U.tap(game, "a") + U.wait(10) + end + local picker = top() + if not isPicker(picker) then return nil end + return picker + end + + local picker = openPickerFor("MAX_POTION") + check("party picker opened for MAX POTION", picker ~= nil) + if picker then + cursorTo(picker, 1) + U.tap(game, "a") + check("the fill started from 1 HP", + picker.heal ~= nil and picker.heal.shown == 1) + local expect = Timing.hpDrainFrames(1, maxHP, maxHP, true) + local frames, jumps, last = 0, 0, 1 + local shot1, shot2 = false, false + while picker.heal and frames < 2000 do + local shown = picker.heal.shown + if not shot1 and shown >= math.floor(maxHP / 3) then + shot1 = true + U.still(game, DIR .. "/2723_01_party_fill_third.png") + elseif not shot2 and shown >= math.floor(maxHP * 2 / 3) then + shot2 = true + U.still(game, DIR .. "/2723_02_party_fill_two_thirds.png") + end + U.wait(1) + frames = frames + 1 + if picker.heal then + if picker.heal.shown - last > 1 then jumps = jumps + 1 end + last = picker.heal.shown + end + end + U.log(("party fill: %d frames, UpdateHPBar2 budget %d"):format(frames, expect)) + check(("party heal 1 -> %d runs the D + 2P + 6 budget (%d ~ %d)") + :format(maxHP, frames, expect), math.abs(frames - expect) <= 2) + check(("party heal 1 -> 415 is exactly 514 frames (got %d)"):format(frames), + frames == 514 and expect == 514) + check("party heal takes several seconds, not ~1.6", frames > 400) + check("party heal shown HP climbs one point per step", jumps == 0) + check("mon is at full HP after the fill", lead.hp == maxHP) + for _ = 1, 60 do + if not isPicker(top()) then break end + U.wait(1) + end + local box + for _ = 1, 400 do + box = top() + if getmetatable(box) == TextBox and box.done then break end + U.wait(1) + end + check("heal message finished printing before the shot", + getmetatable(box) == TextBox and box.done == true) + U.still(game, DIR .. "/2723_03_party_heal_message.png") + end + + for _ = 1, 30 do + if top() == game.overworld then break end + U.tap(game, "a") + U.wait(8) + if top() ~= game.overworld then U.tap(game, "b") U.wait(6) end + end + + lead.moves = { { id = "SEISMIC_TOSS", pp = 20 } } + U.teleport(game, "ROUTE_1", 5, 5, "down") + local battle = BattleState.newWild(game, "CHANSEY", 60) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + for _ = 1, 200 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(4) + end + check("battle reached the move menu", battle.phase == "menu") + local enemy = battle.enemy + local eMax = enemy.mon.stats.hp + U.log(("enemy CHANSEY L60 %d/%d HP"):format(enemy.mon.hp, eMax)) + check("enemy has more than 4 HP per bar pixel", eMax > 4 * 48) + + local startHP = enemy.mon.hp + U.tap(game, "a") + U.wait(10) + U.tap(game, "a") + local frames, started, shot = 0, false, false + for _ = 1, 4000 do + U.wait(1) + if not started and (enemy.shownHP or startHP) < startHP then started = true end + if started then + frames = frames + 1 + if not shot and enemy.shownHP <= startHP - (startHP - enemy.mon.hp) / 2 then + shot = true + U.still(game, DIR .. "/2723_04_enemy_drain_mid.png") + end + if enemy.shownHP == enemy.mon.hp and enemy.drainHold == nil then break end + end + end + local endHP = enemy.mon.hp + local expect = Timing.hpDrainFrames(startHP, endHP, eMax, false) + local lagless = 2 * math.abs(Timing.hpBarPixels(startHP, eMax) + - Timing.hpBarPixels(endHP, eMax)) + 5 + U.log(("enemy drain %d -> %d: %d frames, budget %d, lag-free 2P+5 %d") + :format(startHP, endHP, frames, expect, lagless)) + check("enemy drain happened", started and endHP < startHP) + check("SEISMIC TOSS took the level in HP", startHP - endHP == lead.level) + check(("enemy drain matches the per-step CPU budget (%d ~ %d)") + :format(frames, expect), math.abs(frames - expect) <= 2) + check("enemy drain is slower than the lag-free 2P + 5", + endHP == startHP or frames > lagless) + U.wait(4) + U.still(game, DIR .. "/2723_05_enemy_drain_done.png") + + print(("hp_bar_speed_2723: %d passed, %d failed"):format(pass, fail)) + love.event.quit(fail == 0 and 0 or 1) +end diff --git a/tests/drivers/party_heal_bug252_test.lua b/tests/drivers/party_heal_bug252_test.lua index 0dcc0153..ee8046a1 100644 --- a/tests/drivers/party_heal_bug252_test.lua +++ b/tests/drivers/party_heal_bug252_test.lua @@ -6,12 +6,14 @@ -- POKEPORT_IDENTITY=bug252 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . return function(game) local U = dofile("tests/drivers/util.lua") - local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/shots" local Pokemon = require("src.pokemon.Pokemon") local Bag = require("src.inventory.Bag") local ItemEffects = require("src.inventory.ItemEffects") local PartyMenu = require("src.ui.PartyMenu") local TextBox = require("src.render.TextBox") + local Timing = require("src.core.Timing") local pass, fail = 0, 0 local function check(label, ok) @@ -82,8 +84,6 @@ return function(game) end -- ---- the fixture -------------------------------------------------------- - -- CHARIZARD L50 sits near 150 max HP, so a MAX_POTION from 1 HP is the full - -- 96-frame fill across the whole 48-pixel bar. local lead = Pokemon.new(game.data, "CHARIZARD", 50) local fainted = Pokemon.new(game.data, "PIKACHU", 30) local poisoned = Pokemon.new(game.data, "SNORLAX", 40) @@ -190,18 +190,23 @@ return function(game) -- (UpdateHPBar2 blocks). U.frame() is the real yield count: the taps below -- each burn a frame, so an iteration counter would under-report. local startFrame, samples, blocked = U.frame(), {}, true - local iter, shot1, shot2 = 0, false, false - for _ = 1, 400 do - if not picker.heal then break end + local iter, shot1, shot2, paused = 0, false, false, 0 + local expect = Timing.hpDrainFrames(hpBefore, lead.stats.hp, lead.stats.hp, true) + local function stillShot(path) + local before = U.frame() + U.still(game, path) + paused = paused + (U.frame() - before) + end + while picker.heal and U.frame() - startFrame - paused < 2000 do local shown = picker.heal.shown samples[#samples + 1] = shown local frac = shown / math.max(1, lead.stats.hp) if not shot1 and frac > 0.33 then shot1 = true - U.shot(game, DIR .. "/bug252_fill_third.png") + stillShot(DIR .. "/bug252_fill_third.png") elseif not shot2 and frac > 0.66 then shot2 = true - U.shot(game, DIR .. "/bug252_fill_two_thirds.png") + stillShot(DIR .. "/bug252_fill_two_thirds.png") else -- mash B and A: neither may do anything while the bar is filling U.tap(game, (iter % 2 == 0) and "b" or "a") @@ -210,11 +215,14 @@ return function(game) iter = iter + 1 U.wait(1) end - local frames = U.frame() - startFrame - U.log(("fill ran ~%d frames (%.2f s at 60 Hz)"):format(frames, frames / 60)) + local frames = U.frame() - startFrame - paused + U.log(("fill ran ~%d frames (%.2f s at 60 Hz), UpdateHPBar2 budget %d") + :format(frames, frames / 60, expect)) check("the fill took more than half a second (it animates, not snaps)", frames > 30) - check("the fill is not absurdly long (< 3 s)", frames < 180) + -- engine/gfx/hp_bar.asm:81-135 + check(("the fill runs the D + 2P + 6 budget (%d ~ %d)"):format(frames, expect), + math.abs(frames - expect) <= 2) check("A and B did nothing while the bar filled", blocked) local rose = #samples >= 2 and samples[#samples] > samples[1] check("the drawn HP climbed over those frames", rose) @@ -273,22 +281,6 @@ return function(game) end backToOverworld() - -- ---- verdict, then re-arm and hand off ---------------------------------- U.log(("======== machine checks: %d passed, %d failed ========"):format(pass, fail)) - - lead.hp = 1 - fainted.hp = 0 - poisoned.status = "PSN" - local rearmed = openPickerFor("MAX_POTION") - if rearmed then cursorTo(rearmed, 1) end - - U.log("The bag, USE and the party picker are re-opened with the cursor on a") - U.log("1 HP CHARIZARD and a MAX POTION chosen. Press A, watch slot 1's bar:") - U.log("the list stays up, the bar lengthens over ~1.5s with the number, and") - U.log("buttons do nothing until it lands. #252 was the list snapping shut.") - U.log("Spare items are in the bag if you want to run it again.") - - while true do - coroutine.yield() - end + love.event.quit(fail == 0 and 0 or 1) end diff --git a/tests/drivers/red_speed_hotkey_h1.lua b/tests/drivers/red_speed_hotkey_h1.lua index 522fbf85..bc321cf4 100644 --- a/tests/drivers/red_speed_hotkey_h1.lua +++ b/tests/drivers/red_speed_hotkey_h1.lua @@ -27,6 +27,11 @@ return function(game) game:gamepadaxis(nil, "triggerleft", 0) expect(o.speedOverworld == 3, "red_lt_lowers") expect(game:logicSpeed() == 3, "red_logic_follows_overworld") + key("0") + expect(o.speedOverworld == 2, "red_0_lowers_overworld") + key("kp0") + expect(o.speedOverworld == 1, "red_numpad_0_lowers") + expect(o.speedBattle == 1 and o.speedMenu == 1, "red_0_syncs_categories") game.speedOverride = 1 love.event.quit(failed and 1 or 0) end diff --git a/tests/drivers/red_speed_stall_2694.lua b/tests/drivers/red_speed_stall_2694.lua new file mode 100644 index 00000000..d069dc2d --- /dev/null +++ b/tests/drivers/red_speed_stall_2694.lua @@ -0,0 +1,70 @@ +local U = require("tests.drivers.util") +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") +local ModIndex = require("src.mods.ModIndex") + +local failed = false +local function expect(cond, label) + print((cond and "PASS " or "FAIL ") .. label) + if not cond then failed = true end +end + +local function key(k) + love.keypressed(k, k, false) + love.keyreleased(k, k) +end + +local function timePresses(n) + local t = love.timer.getTime() + for _ = 1, n do key("1") end + return (love.timer.getTime() - t) / n * 1000 +end + +local function bigIndex(n) + local mods = {} + for i = 1, n do + mods[i] = { id = ("author%d@mod%d"):format(i, i), name = ("Mod number %d"):format(i), + description = ("A fairly long description for mod %d that pads the entry out"):format(i), + github = ("author%d/mod%d"):format(i, i), tags = { "GAMEPLAY", "ART" } } + end + return { checkedAt = os.time(), version = ModIndex.CACHE_VERSION, + generatedAt = "2026-10-06T00:00:00Z", categories = { "GAMEPLAY" }, + baseGames = { "red" }, mods = mods, carts = {} } +end + +return function(game) + U.newGame(game) + game.speedOverride = nil + local feed = ModIndex.resolveSource("bryanthaboi/gen1recomp-mod-index").feed + game.save.options.speedOverworld = 1 + game:writeOptions() + local small = timePresses(10) + print(("INFO per-press ms, no index cached: %.2f"):format(small)) + + expect(small < 10, "red_speed_press_under_10ms") + + local fs = SaveData.persistenceFs() + local legacy = SaveData.loadOptions() + legacy.modIndexCache = { [feed] = bigIndex(1500) } + SaveData.saveOptions(legacy) + fs.write("options.lua", SaveSerializer.encode(legacy)) + local seeded = fs.getInfo("options.lua") + print(("INFO options.lua bytes with the legacy blob: %d"):format(seeded and seeded.size or 0)) + expect(seeded and seeded.size > 32 * 1024, "red_legacy_blob_seeded") + if ModIndex._resetCacheForTests then ModIndex._resetCacheForTests() end + local entry = ModIndex.readCache(feed) + expect(entry and #entry.mods == 1500, "red_index_cache_still_reads") + game.save.options = SaveData.loadOptions() + expect(game.save.options.modIndexCache == nil, "red_live_options_never_carry_the_index") + game.save.options.speedOverworld = 1 + local heavy = timePresses(10) + print(("INFO per-press ms, 1500-mod index cached: %.2f"):format(heavy)) + expect(heavy < small * 3 + 2, "red_speed_press_cost_independent_of_index") + expect(heavy < 10, "red_speed_press_with_index_under_10ms") + local info = fs.getInfo("options.lua") + local size = info and info.size or 0 + print(("INFO options.lua bytes: %d"):format(size)) + expect(size > 0 and size < 32 * 1024, "red_options_lua_stays_small") + game.speedOverride = 1 + love.event.quit(failed and 1 or 0) +end diff --git a/tests/engine/cart_import_picker.lua b/tests/engine/cart_import_picker.lua index 3968c566..aea4f721 100644 --- a/tests/engine/cart_import_picker.lua +++ b/tests/engine/cart_import_picker.lua @@ -50,16 +50,82 @@ eq(imp.installed, "picked_cart.g1rcart", "focus installs the staged cart") check(love.filesystem.getInfo("picked_cart.g1rcart") == nil, "and retires the staged file") -love.system.pickFileKinds = function() return "rom,mod,sav" end +local function legacyCartPick(kinds, label) + love.system.pickFileKinds = kinds + Platform._resetForTests() + local legacy = RomImporter.new(function() end, { launcher = true }) + legacy._installCartFile = imp._installCartFile + local modInstalls = 0 + legacy._installMod = function() modInstalls = modInstalls + 1 end + picks = {} + eq(legacy:importCartFile("red"), true, label .. ": Import .g1rcart opens the picker") + eq(picks[1], "mod", label .. ": through the mod kind the old bridge has") + eq(#picks, 1, label .. ": with one pick and never a ROM pick") + eq(legacy.pickerPendingKind, "cart", label .. ": focus knows the pick is a cart") + love.filesystem.write("picked_mod.zip", "cart-bytes") + legacy:focus(true) + eq(legacy.installed, "picked_mod.zip", label .. ": focus installs the pick as a cart") + eq(modInstalls, 0, label .. ": and never as a mod") + check(love.filesystem.getInfo("picked_mod.zip") == nil, + label .. ": and retires the staged file") + eq(legacy.pickerPendingKind, nil, label .. ": and clears the pending kind") + eq(legacy.cartLegacyModPick, nil, label .. ": and the legacy flag") + + picks = {} + eq(legacy:importCartFile("red"), true, label .. ": a second import reopens the picker") + love.filesystem.write("pick_error.flag", "cancelled:picked_mod.zip") + legacy:focus(true) + check(type(legacy._cartNotice) == "string" + and legacy._cartNotice:find("did not return a file", 1, true), + label .. ": a cancelled pick lands on the cart notice") + check(legacy.modNotice == nil or legacy.modNotice.text == nil, + label .. ": and not on the mod notice") + + picks = {} + legacy.installed = nil + eq(legacy:importCartFile("red"), true, label .. ": a third import reopens the picker") + legacy:focus(true) + eq(legacy.pickerPendingKind, "cart", + label .. ": a silent cancel (old APK, no flag) leaves the cart pick armed") + legacy:chooseMod() + eq(picks[#picks], "mod", label .. ": the Mods tab import opens the mod picker") + eq(legacy.pickerPendingKind, "mod", label .. ": and claims the pending kind") + eq(legacy.cartLegacyModPick, nil, label .. ": and disarms the legacy cart flag") + love.filesystem.write("picked_mod.zip", "real-mod-bytes") + legacy:focus(true) + eq(modInstalls, 1, label .. ": a real mod picked after the silent cancel installs as a mod") + eq(legacy.installed, nil, label .. ": and never reaches the cart installer") + eq(legacy.pickerPendingKind, nil, label .. ": and clears the pending kind after install") + + picks = {} + eq(legacy:importCartFile("red"), true, label .. ": a fourth import reopens the picker") + legacy:focus(true) + legacy:chooseMod() + love.filesystem.write("pick_error.flag", "cancelled:picked_mod.zip") + legacy._cartNotice = nil + legacy:focus(true) + check(legacy.modNotice and legacy.modNotice.ok == false, + label .. ": a cancelled Mods-tab pick after a silent cart cancel lands on the mod notice") + eq(legacy._cartNotice, nil, label .. ": and not on the cart notice") + eq(legacy.pickerPendingKind, nil, label .. ": and leaves nothing armed") +end + +legacyCartPick(function() return "rom,mod,sav" end, "pre-cart bridge") +legacyCartPick(nil, "pre-kinds bridge") + +love.system.pickFileKinds = nil +love.system.pickFile = nil Platform._resetForTests() -local legacy = RomImporter.new(function() end, { launcher = true }) -legacy._installCartFile = imp._installCartFile -picks = {} -eq(legacy:importCartFile("red"), false, - "an Android build that predates the cart kind does not open a ROM picker") -eq(#picks, 0, "and never calls pickFile") -check(type(legacy._cartNotice) == "string" and legacy._cartNotice:find(".g1rcart", 1, true), - "it tells the player to copy a .g1rcart instead") +local bare = RomImporter.new(function() end, { launcher = true }) +eq(bare:importCartFile("red"), false, "an APK with no picker bridge cannot open one") +check(type(bare._cartNotice) == "string" + and bare._cartNotice:find("Update the app", 1, true) ~= nil, + "and the notice tells the player to update the app") +eq(bare.pickerPendingKind, nil, "and leaves no pending cart pick") +love.system.pickFile = function(kind) + picks[#picks + 1] = kind + return true +end love.system.getOS = function() return "NX" end love.system.pickFile = nil @@ -86,4 +152,4 @@ love.system.pickFileKinds = saved.pickFileKinds love.system.getPickedFile = saved.getPickedFile Platform._resetForTests() -print("ok cart import picker") +S.finish() diff --git a/tests/engine/game3_speed_hotkey_test.lua b/tests/engine/game3_speed_hotkey_test.lua index cec94a43..c5161bee 100644 --- a/tests/engine/game3_speed_hotkey_test.lua +++ b/tests/engine/game3_speed_hotkey_test.lua @@ -66,6 +66,55 @@ do eq(Input.hotkeyKey("z"), "z", "letters pass through") end +do + local g = newGame3("field") + g.options.speedOverworld, g.options.speedBattle, g.options.speedMenu = 4, 4, 4 + g:keypressed("0") + eq(g.options.speedOverworld, 3, "0 steps OVERWORLD SPEED down") + eq(g.options.speedBattle, 3, "0 syncs BATTLE SPEED") + eq(g.options.speedMenu, 3, "0 syncs MENU SPEED") + g:keypressed("kp0") + eq(g.options.speedOverworld, 2, "numpad 0 steps speed down") + local realKb = love.keyboard + love.keyboard = setmetatable({ + getScancodeFromKey = function(key) return key == "à" and "0" or key end, + }, { __index = realKb }) + g:keypressed("à") + eq(g.options.speedOverworld, 1, "AZERTY top-row 0 steps speed down by scancode") + love.keyboard = realKb + Input:armCapture() + g.options.speedOverworld = 3 + g:keypressed("0") + eq(g.options.speedOverworld, 3, "0 is ignored while a capture is armed") + Input:takeCaptureEvents() + Input:disarmCapture() + Input:reset() +end + +do + local g1 = setmetatable({ save = { options = { speedOverworld = 3, speedBattle = 3, speedMenu = 3 } }, + stack = { states = {}, top = function() return nil end }, + writeOptions = function() end }, { __index = Game }) + g1:keypressed("0") + eq(g1.save.options.speedOverworld, 2, "Gen 1 0 steps speed down") + g1:keypressed("kp0") + eq(g1.save.options.speedOverworld, 1, "Gen 1 numpad 0 steps speed down") + g1:keypressed("1") + eq(g1.save.options.speedOverworld, 2, "Gen 1 1 still steps speed up") +end + +do + local Game2 = require("src.core.Game2") + local g2 = setmetatable({ options = { speed = 3 }, persistOptions = function() end }, + { __index = Game2 }) + check(g2:hotkey("0"), "Gen 2 0 is a hotkey") + eq(g2.options.speed, 2, "Gen 2 0 steps speed down") + g2:hotkey("kp0") + eq(g2.options.speed, 1, "Gen 2 numpad 0 steps speed down") + g2:hotkey("1") + eq(g2.options.speed, 2, "Gen 2 1 still steps speed up") +end + do Input:applyBindings({ left = { key = "kp4" }, down = { key = "kp2" }, up = { key = "kp8" }, diff --git a/tests/engine/gen2_naming_header_2708_test.lua b/tests/engine/gen2_naming_header_2708_test.lua new file mode 100644 index 00000000..5aaee335 --- /dev/null +++ b/tests/engine/gen2_naming_header_2708_test.lua @@ -0,0 +1,129 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local drawn +package.loaded["src.render.Font"] = { + draw = function(text, x, y) + drawn[#drawn + 1] = { text = text, x = x, y = y } + end, + drawCode = function() end, + drawBox = function() end, +} + +local loaded = {} +package.loaded["src.render.Assets"] = { + register = function() end, + image = function(path) + loaded[#loaded + 1] = path + return { + path = path, + getDimensions = function() return 16, 32 end, + getWidth = function() return 16 end, + getHeight = function() return 32 end, + } + end, +} + +local blits +love.graphics.newQuad = function(x, y, w, h) + local q = { x = x, y = y, w = w, h = h } + function q:setViewport(nx, ny, nw, nh) + self.x, self.y, self.w, self.h = nx, ny, nw, nh + end + return q +end +love.graphics.draw = function(img, quad, x, y) + if type(img) == "table" and img.path then + blits[#blits + 1] = { path = img.path, qy = quad and quad.y, x = x, y = y } + end +end + +local NamingScreen = require("src.ui.gen2.NamingScreen") + +local function drawnAt(x, y) + for _, d in ipairs(drawn) do + if d.x == x and d.y == y then return d.text end + end + return nil +end + +local function render(screen) + drawn, blits = {}, {} + screen:drawPanel() + return blits[1] +end + +local game = { + data = { + gen2Icons = { + species = { CHIKORITA = "ICON_ODDISH", MAGNEMITE = "ICON_VOLTORB" }, + icons = { + ICON_ODDISH = { image = "icons/oddish.png" }, + ICON_VOLTORB = { image = "icons/voltorb.png" }, + ICON_EGG = { image = "icons/egg.png" }, + }, + }, + gen2Palettes = { partyMenu = { { { 1, 2, 3 }, { 4, 5, 6 } } } }, + }, +} + +do + local mon = { species = "CHIKORITA", name = "CHIKORITA", gender = "male" } + local screen = NamingScreen.new(game, { type = "nickname", mon = mon }) + local blit = render(screen) + T.check(blit ~= nil, "the nickname header draws the mon's party icon") + T.eq(blit and blit.path, "icons/oddish.png", "resolved through gen2Icons") + T.eq(blit and blit.x, 16, "icon x is 16") + T.eq(blit and blit.y, 12, "icon y is 12 (depixel 4,4,4,0)") + T.eq(blit and blit.qy, 0, "frame 0 first") + T.eq(screen.iconColors, game.data.gen2Palettes.partyMenu[1], + "the icon wears PAL_OW_RED") + T.eq(drawnAt(8, 16), "\xe2\x99\x82", "male symbol at tile (1,2)") + T.eq(drawnAt(5 * 8, 2 * 8), "CHIKORITA'S", "species line still drawn") + + for _ = 1, NamingScreen.ICON_FRAME_STEPS - 1 do screen:update() end + blit = render(screen) + T.eq(blit and blit.qy, 0, "still frame 0 one step before the swap") + screen:update() + blit = render(screen) + T.eq(blit and blit.qy, 16, "frame 1 after the oamframe runs out") + for _ = 1, NamingScreen.ICON_FRAME_STEPS do screen:update() end + blit = render(screen) + T.eq(blit and blit.qy, 0, "and back to frame 0") +end + +do + local mon = { species = "CHIKORITA", name = "CHIKORITA", gender = "female" } + render(NamingScreen.new(game, { type = "nickname", mon = mon })) + T.eq(drawnAt(8, 16), "\xe2\x99\x80", "female symbol at tile (1,2)") +end + +do + local mon = { species = "MAGNEMITE", name = "MAGNEMITE", gender = "unknown" } + local blit = render(NamingScreen.new(game, { type = "nickname", mon = mon })) + T.eq(drawnAt(8, 16), nil, "genderless draws nothing at tile (1,2)") + T.eq(blit and blit.path, "icons/voltorb.png", "genderless still gets its icon") +end + +do + local mon = { species = "CHIKORITA", isEgg = true, gender = "male" } + local blit = render(NamingScreen.new(game, { type = "nickname", mon = mon })) + T.eq(blit and blit.path, "icons/egg.png", "an egg reads ICON_EGG") +end + +do + local screen = NamingScreen.new(game, { type = "rival", iconPath = "ow/rival.png" }) + for _ = 1, NamingScreen.ICON_FRAME_STEPS * 3 do screen:update() end + local blit = render(screen) + T.eq(blit and blit.path, "ow/rival.png", "iconPath callers still work") + T.eq(blit and blit.qy, 0, "an OW sheet stays on its standing frame") + T.eq(blit and blit.y, 16, "at its old spot") + T.eq(drawnAt(8, 16), nil, "and no gender symbol") +end + +T.finish("gen2_naming_header_2708_test") diff --git a/tests/engine/hostshell_spawn_self.lua b/tests/engine/hostshell_spawn_self.lua index e3f2b937..627540b3 100644 --- a/tests/engine/hostshell_spawn_self.lua +++ b/tests/engine/hostshell_spawn_self.lua @@ -10,14 +10,22 @@ love = { }, } package.loaded["src.core.Platform"] = { canSpawnProcess = function() return true end } +local realWinApi = require("src.core.WinApi") +local spawned = nil +package.loaded["src.core.WinApi"] = { + dirOf = realWinApi.dirOf, + spawn = function(exe, args, opts) spawned = { exe = exe, args = args, opts = opts } return true end, +} local execute = os.execute os.execute = function(value) command = value return 0 end local HostShell = require("src.core.HostShell") assert(HostShell.spawnSelfDetached({ "--display-companion=50000,token" })) -assert(command:find('start "" /b ', 1, true) - and command:find('"C:\\Game\\gen1recomp.exe"', 1, true), - "Windows launches the fused app detached") +assert(command == nil, "Windows never goes through cmd.exe") +assert(spawned and spawned.exe == "C:\\Game\\gen1recomp.exe" + and #spawned.args == 1 and spawned.args[1] == "--display-companion=50000,token" + and spawned.opts.cwd == "C:\\Game", + "Windows launches the fused app through the wide spawner") osName, fused = "Linux", false assert(HostShell.spawnSelfDetached({ "--display-companion=50000,token" })) diff --git a/tests/engine/hostshell_win_restart_2721.lua b/tests/engine/hostshell_win_restart_2721.lua new file mode 100644 index 00000000..ef320107 --- /dev/null +++ b/tests/engine/hostshell_win_restart_2721.lua @@ -0,0 +1,137 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local EXE = "D:\\- Oyun install\\0 - Pokemon Gameboy D\xC4\xB1\xC5\x9F\xC4\xB1 Oyunlar" + .. "\\- Pok\xC3\xA9mon Gen 1 Recompilation Project\\gen1recomp-win64\\gen1recomp.exe" +local DIR = "D:\\- Oyun install\\0 - Pokemon Gameboy D\xC4\xB1\xC5\x9F\xC4\xB1 Oyunlar" + .. "\\- Pok\xC3\xA9mon Gen 1 Recompilation Project\\gen1recomp-win64" + +local function utf16Units(s) + local n, i = 0, 1 + while i <= #s do + local b = s:byte(i) + if b < 0x80 then i = i + 1 + elseif b < 0xE0 then i = i + 2 + elseif b < 0xF0 then i = i + 3 + else i = i + 4 n = n + 1 end + n = n + 1 + end + return n +end + +local conversions, creates, closes = {}, {}, 0 +local createResult = 1 + +local fakeFfi = { os = "Windows" } +function fakeFfi.cdef() end +function fakeFfi.new(ct) + if ct == "PP_STARTUPINFOW" then return { cb = 0 } end + if ct == "PP_PROCESS_INFORMATION" then return { hProcess = "proc", hThread = "thread" } end + return {} +end +function fakeFfi.sizeof(ct) + if ct == "PP_STARTUPINFOW" then return 104 end + return 0 +end +fakeFfi.C = { + MultiByteToWideChar = function(cp, flags, s, n, w, wn) + conversions[#conversions + 1] = { cp = cp, flags = flags, s = s, n = n, fill = w ~= nil } + local units = utf16Units(s) + if w ~= nil then + assert(wn == units, "buffer sized from the probe") + w.src = s + end + return units + end, + CreateProcessW = function(app, cmd, pa, ta, inherit, flags, env, cwd, si, pi) + creates[#creates + 1] = { + app = app and app.src, cmd = cmd and cmd.src, cwd = cwd and cwd.src, + inherit = inherit, flags = flags, cb = si.cb, pa = pa, ta = ta, env = env, pi = pi, + } + return createResult + end, + CloseHandle = function() closes = closes + 1 return 1 end, +} +package.loaded.ffi = fakeFfi + +local quits = {} +love = { + system = { getOS = function() return "Windows" end }, + filesystem = { + isFused = function() return true end, + getSource = function() return EXE end, + getExecutablePath = function() return EXE end, + }, + event = { + quit = function(...) + quits[#quits + 1] = { n = select("#", ...), arg = (...) } + end, + }, +} +package.loaded["src.core.Platform"] = { canSpawnProcess = function() return true end } + +local shellCalls = 0 +os.execute = function() shellCalls = shellCalls + 1 return 0 end +io.popen = function() shellCalls = shellCalls + 1 return nil end + +local HostShell = require("src.core.HostShell") +local WinApi = require("src.core.WinApi") + +eq(WinApi.quoteArg("plain"), "plain", "bare argument stays bare") +eq(WinApi.quoteArg("two words"), '"two words"', "space forces quotes") +eq(WinApi.quoteArg(""), '""', "empty argument is an explicit empty string") +eq(WinApi.quoteArg("C:\\dir with space\\"), '"C:\\dir with space\\\\"', + "trailing backslash doubled before the closing quote") +eq(WinApi.quoteArg('say "hi"'), '"say \\"hi\\""', "embedded quote escaped") +eq(WinApi.quoteArg('a\\"b'), '"a\\\\\\"b"', "backslashes before a quote doubled plus one") +eq(WinApi.quoteArg("a\\\\b c"), '"a\\\\b c"', "backslashes not before a quote stay literal") +eq(WinApi.quoteArg("Pok\xC3\xA9mon"), "Pok\xC3\xA9mon", "non-ASCII bytes pass through untouched") +eq(WinApi.commandLine(EXE, { "--x=1", "a b" }), '"' .. EXE .. '" --x=1 "a b"', + "argv0 always quoted, args quoted per CommandLineToArgvW") +eq(WinApi.dirOf(EXE), DIR, "cwd is the exe's folder") +eq(WinApi.dirOf("D:\\gen1recomp.exe"), "D:\\", "drive root keeps its backslash") + +HostShell.restart() +eq(shellCalls, 0, "restart never goes through cmd.exe or the ANSI CRT") +eq(#creates, 1, "restart spawns the exe once") +local c = creates[1] or {} +eq(c.app, EXE, "CreateProcessW gets the exe as the exact UTF-8 bytes, widened") +eq(c.cmd, '"' .. EXE .. '"', "command line is the quoted exe") +eq(c.cwd, DIR, "working directory is the install folder") +eq(c.flags, 0x00000008 + 0x00000200, "DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP") +eq(c.inherit, 0, "no inherited handles") +eq(c.cb, 104, "STARTUPINFOW.cb set") +local sawExe = false +for _, conv in ipairs(conversions) do + check(conv.cp == 65001, "every conversion is CP_UTF8, got " .. tostring(conv.cp)) + if conv.s == EXE and conv.fill then + sawExe = true + eq(conv.n, #EXE, "conversion covers every UTF-8 byte") + end +end +check(sawExe, "MultiByteToWideChar asked to widen the reporter's UTF-8 path") +eq(closes, 2, "process and thread handles closed") +eq(#quits, 1, "restart quits once after spawning") +eq(quits[1] and quits[1].n, 0, "plain quit, the new process is the restart") + +createResult = 0 +HostShell.restart() +eq(#creates, 2, "second restart attempts the spawn") +eq(closes, 2, "failed spawn closes nothing") +eq(#quits, 2, "failed spawn still quits once") +eq(quits[2] and quits[2].arg, "restart", "failed spawn falls back to the in-process restart") +eq(shellCalls, 0, "fallback never shells out") + +createResult = 1 +check(HostShell.spawnSelfDetached({ "--display-companion=50000,tok en" }) == true, + "companion spawn succeeds") +local s = creates[3] or {} +eq(s.cmd, '"' .. EXE .. '" "--display-companion=50000,tok en"', "companion argv quoted") +eq(s.cwd, DIR, "companion runs from the install folder") +createResult = 0 +check(HostShell.spawnSelfDetached({}) == false, "companion spawn failure reported") +eq(shellCalls, 0, "companion never shells out") + +T.finish("hostshell_win_restart_2721") diff --git a/tests/engine/hp_bar_speed_bug2723.lua b/tests/engine/hp_bar_speed_bug2723.lua new file mode 100644 index 00000000..0f430e73 --- /dev/null +++ b/tests/engine/hp_bar_speed_bug2723.lua @@ -0,0 +1,112 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +love = love or require("tests.love_stub") +local Data = T.fixtures.fresh() + +local Timing = require("src.core.Timing") +local PartyMenu = require("src.ui.PartyMenu") + +local function partyGame(mon) + local game = { + data = { pokemon = { MEWTWO = { name = "MEWTWO" } } }, + save = { party = { mon }, inventory = {}, options = {}, flags = {} }, + overworld = { map = { def = { tileset = "OVERWORLD" }, id = "PALLET_TOWN" }, + dark = false, partyKnows = function() return nil end }, + } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + game.input = { wasPressed = function() return false end, + isDown = function() return false end } + return game +end + +local function healFrames(fromHP, maxHP) + local mon = { species = "MEWTWO", hp = maxHP, stats = { hp = maxHP }, + level = 100, moves = {} } + local game = partyGame(mon) + local pm = PartyMenu.new(game, {}) + game.stack:push(pm) + local done, frames, lastShown, jumps = 0, 0, fromHP, 0 + pm:animateTo(mon, fromHP, function() done = done + 1 end) + while pm.heal and frames < 5000 do + pm:update(1 / 60) + frames = frames + 1 + if pm.heal then + if pm.heal.shown - lastShown > 1 then jumps = jumps + 1 end + lastShown = pm.heal.shown + end + end + return frames, done, jumps +end + +-- engine/items/item_effects.asm:1208, engine/gfx/hp_bar.asm:81-135 +do + local frames, done, jumps = healFrames(1, 415) + T.eq(frames, Timing.hpDrainFrames(1, 415, 415, true), + "a 1 -> 415 party heal runs UpdateHPBar2's D + 2P + 6 frames") + T.eq(frames, 414 + 2 * 47 + 6, "which is 514 frames for 414 HP over 47 px") + T.eq(done, 1, "onDone fires once") + T.eq(jumps, 0, "the shown HP walks one point at a time") +end + +do + local frames = healFrames(20, 40) + T.eq(frames, Timing.hpDrainFrames(20, 40, 40, true), + "a small-HP heal pays two frames per pixel per point") +end + +do + local frames, done = healFrames(415, 415) + T.eq(done, 1, "a heal with nothing to fill returns at once") + T.check(frames <= 1, "and takes no animation frames") +end + +-- engine/gfx/hp_bar.asm:244-269 +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(level) + 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 } } + return BattleState.newWild(game, "FIXMON_C", level) +end + +local function drainFrames(battle, battler, toHP) + battler.mon.hp = toHP + local frames = 0 + while battle:stepHPDrain() and frames < 20000 do frames = frames + 1 end + return frames +end + +do + local b = newBattle(100) + local eMax = b.enemy.mon.stats.hp + T.check(eMax > 96, "the enemy has more than two HP per pixel (" .. eMax .. ")") + local frames = drainFrames(b, b.enemy, 0) + T.eq(frames, Timing.hpDrainFrames(eMax, 0, eMax, false), + "the enemy drain stepper matches the closed form") + T.check(frames > 2 * 48 + 5, + "and a big enemy drain pays the per-step CPU lag on top of 2P + 5") +end + +do + local b = newBattle(100) + local eMax = b.enemy.mon.stats.hp + local target = eMax - math.floor(eMax / 3) + T.eq(drainFrames(b, b.enemy, target), + Timing.hpDrainFrames(eMax, target, eMax, false), + "a partial enemy drain matches the closed form exactly") +end + +T.finish("hp bar speed") diff --git a/tests/engine/mod_index_tests.lua b/tests/engine/mod_index_tests.lua index a08c0b3e..18492f68 100644 --- a/tests/engine/mod_index_tests.lua +++ b/tests/engine/mod_index_tests.lua @@ -8,7 +8,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.harness") -local check, eq = T.check, T.eq +local check, eq, same = T.check, T.eq, T.same local ModIndex = require("src.mods.ModIndex") local Json = require("src.link.Json") @@ -53,10 +53,26 @@ end do local oldSaveData = package.loaded["src.core.SaveData"] local opts, writes = {}, 0 + local files = {} + local cachefs = { + getInfo = function(p) return files[p] and { type = "file" } or nil end, + read = function(p) return files[p] end, + write = function(p, c) files[p] = c return true end, + } package.loaded["src.core.SaveData"] = { + OPTIONS_FILENAME = "options.lua", loadOptions = function() return opts end, saveOptions = function(saved) opts = saved; writes = writes + 1; return saved end, + persistenceFs = function() return cachefs end, } + local Ser = require("src.core.SaveSerializer") + local function seedCache(tree) + files[ModIndex.CACHE_FILE] = Ser.encode(tree) + ModIndex._resetCacheForTests() + end + local function cacheFile() + return Ser.decode(files[ModIndex.CACHE_FILE]) + end local main = ModIndex.resolveSource("bryanthaboi/gen1recomp-mod-index") local custom = ModIndex.resolveSource("other/community-index") local cached = { checkedAt = 123, mods = { { id = "existing" } } } @@ -74,23 +90,24 @@ do main.base, main.base:sub(1, -2), main.feed }) do local savedMain = ModIndex.resolveSource(url) savedMain.url = url - opts = { modIndexes = { custom, savedMain, main }, - modIndexCache = { [main.feed] = cached } } + opts = { modIndexes = { custom, savedMain, main } } + seedCache({ [main.feed] = cached }) sources = ModIndex.sources() eq(#sources, 2, "an already-added main index is listed once: " .. url) eq(sources[1], custom, "the existing source precedence is preserved") eq(sources[2], savedMain, "the first saved main-index row is reused") eq(sources[2].url, url, "the player's original URL is preserved") - eq(ModIndex.readCache(main.feed), cached, "the existing main-index cache survives") + same(ModIndex.readCache(main.feed), cached, "the existing main-index cache survives") local added, addErr = ModIndex.addSource(url) check(added == nil and addErr ~= nil, "the built-in source cannot be added again") local removed, removeErr = ModIndex.removeSource(main.feed) check(removed == nil and removeErr ~= nil, "an older main-index row cannot be removed") - eq(opts.modIndexCache[main.feed], cached, "blocked removal keeps its cache") + same(cacheFile()[main.feed], cached, "blocked removal keeps its cache") end eq(writes, 0, "duplicate additions and blocked removals do not write options") - opts = { modIndexes = { custom }, modIndexCache = { [custom.feed] = cached } } + opts = { modIndexes = { custom } } + seedCache({ [custom.feed] = cached }) sources = ModIndex.sources() eq(#sources, 2, "existing custom-only options gain the default") eq(sources[1], custom, "adding the default preserves custom-source precedence") @@ -98,11 +115,27 @@ do check(ModIndex.isBuiltIn(main.feed), "the main index is protected") check(not ModIndex.isBuiltIn(custom.feed), "a custom index remains removable") check(ModIndex.removeSource(custom.feed), "a custom source can still be removed") - eq(opts.modIndexCache[custom.feed], nil, "custom-source removal clears its cache") + eq(cacheFile()[custom.feed], nil, "custom-source removal clears its cache") + eq(opts.modIndexCache, nil, "custom-source removal leaves options without a listing") + eq(#ModIndex.sources(), 1, "removing the last custom source leaves the main index") check(ModIndex.addSource("other/community-index") ~= nil, "custom sources can still be added") eq(#ModIndex.sources(), 2, "the added custom source appears beside the main index") + files["options.lua"] = Ser.encode({ modIndexes = { custom }, + modIndexCache = { [custom.feed] = cached } }) + opts = { modIndexes = { custom } } + seedCache({}) + writes = 0 + same(ModIndex.readCache(custom.feed), cached, "a legacy options listing still reads") + same(cacheFile()[custom.feed], cached, "the legacy listing moves into the cache file") + eq(opts.modIndexCache, nil, "the legacy listing leaves options") + eq(writes, 1, "the move rewrites options once") + files["options.lua"] = nil + ModIndex.readCache(custom.feed) + check(ModIndex.writeCache(custom.feed, { mods = {} }), "writeCache lands in the cache file") + eq(writes, 1, "cache reads and writes never rewrite options") + package.loaded["src.core.SaveData"].loadOptions = function() error("unavailable options") end eq(ModIndex.sources()[1].feed, main.feed, "the main index remains available if options cannot load") package.loaded["src.core.SaveData"] = oldSaveData diff --git a/tests/engine/options_size_budget_2694.lua b/tests/engine/options_size_budget_2694.lua new file mode 100644 index 00000000..3a7524f0 --- /dev/null +++ b/tests/engine/options_size_budget_2694.lua @@ -0,0 +1,130 @@ +-- luajit tests/engine/options_size_budget_2694.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") +local ModIndex = require("src.mods.ModIndex") +local ModUpdate = require("src.mods.ModUpdate") + +local OPTIONS = "options.lua" +local BUDGET = 32 * 1024 + +local function memfs() + local files = {} + return { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] ~= nil then return { type = "file", size = #files[path] } end + return nil + end, + } +end + +local realLoad, realSave, realFs = SaveData.loadOptions, SaveData.saveOptions, SaveData.persistenceFs +local fs + +local function resetCaches() + if ModIndex._resetCacheForTests then ModIndex._resetCacheForTests() end + if ModUpdate._resetCacheForTests then ModUpdate._resetCacheForTests() end +end + +local function useFs(f) + fs = f + resetCaches() +end + +SaveData.persistenceFs = function(f) return f or fs end +SaveData.loadOptions = function(f) return realLoad(f or fs) end +SaveData.saveOptions = function(o, f) return realSave(o, f or fs) end + +local function bigIndex(n) + local mods = {} + for i = 1, n do + mods[i] = { id = ("author%d@mod%d"):format(i, i), name = ("Mod number %d"):format(i), + description = ("A fairly long description for mod %d that pads the entry out"):format(i), + github = ("author%d/mod%d"):format(i, i), tags = { "GAMEPLAY", "ART" }, + versions = { "red", "blue", "yellow" } } + end + return { generatedAt = "2026-10-06T00:00:00Z", categories = { "GAMEPLAY", "ART" }, + baseGames = { "red" }, mods = mods, carts = {} } +end + +local feed = ModIndex.resolveSource("bryanthaboi/gen1recomp-mod-index").feed + +do + useFs(memfs()) + realSave({ battleLayout = "wide" }, fs) + check(ModIndex.writeCache(feed, bigIndex(2000)), "writeCache reports success") + local opts = SaveData.loadOptions() + opts.speedOverworld = 2 + check(SaveData.saveOptions(opts) ~= nil, "a hotkey-style full options write lands") + local size = #(fs.files[OPTIONS] or "") + check(size > 0 and size < BUDGET, + ("options.lua stays under budget after an index fetch (%d bytes)"):format(size)) + local entry = ModIndex.readCache(feed) + eq(entry and #entry.mods, 2000, "the index cache still reads back every mod") + eq(SaveData.loadOptions().battleLayout, "wide", "unrelated options survive") + resetCaches() + entry = ModIndex.readCache(feed) + eq(entry and #entry.mods, 2000, "the index cache survives a fresh session") +end + +do + useFs(memfs()) + check(ModUpdate.writeCache("someone/somemod", + { { version = "1.0.0", tag = "v1.0.0", body = string.rep("notes ", 2000), downloads = 3 } }), + "ModUpdate.writeCache reports success") + local size = #(fs.files[OPTIONS] or "") + check(size < 1024, ("release notes do not land in options.lua (%d bytes)"):format(size)) + local entry = ModUpdate.readCache("someone/somemod") + eq(entry and entry.releases[1].version, "1.0.0", "the release cache reads back") +end + +do + useFs(memfs()) + local legacy = bigIndex(2000) + legacy.checkedAt, legacy.version = os.time(), ModIndex.CACHE_VERSION + fs.write(OPTIONS, SaveSerializer.encode({ battleLayout = "wide", + modIndexCache = { [feed] = legacy }, + modUpdateCache = { ["someone/somemod"] = { checkedAt = os.time(), + releases = { { version = "2.0.0", body = string.rep("x", 4000) } } } } })) + check(#fs.files[OPTIONS] > BUDGET, "the legacy fixture is over budget") + local direct = SaveData.loadOptions() + eq(direct.modIndexCache, nil, "a direct boot never loads the legacy index listing") + eq(direct.modUpdateCache, nil, "nor the legacy release cache") + direct.speedOverworld = 2 + SaveData.saveOptions(direct) + check(#fs.files[OPTIONS] < BUDGET, + ("a direct-boot options write drops the legacy blob (%d bytes)"):format(#fs.files[OPTIONS])) + fs.write(OPTIONS, SaveSerializer.encode({ battleLayout = "wide", + modIndexCache = { [feed] = legacy }, + modUpdateCache = { ["someone/somemod"] = { checkedAt = os.time(), + releases = { { version = "2.0.0", body = string.rep("x", 4000) } } } } })) + useFs(fs) + resetCaches() + local entry = ModIndex.readCache(feed) + eq(entry and #entry.mods, 2000, "a legacy options cache migrates into the cache file") + check(fs.files[ModUpdate.CACHE_FILE] ~= nil, + "the index migration moves the release cache before stripping options") + local upd = ModUpdate.readCache("someone/somemod") + eq(upd and upd.releases[1].version, "2.0.0", "a legacy release cache migrates") + local size = #(fs.files[OPTIONS] or "") + check(size < BUDGET, ("the legacy key leaves options.lua (%d bytes)"):format(size)) + local opts = SaveData.loadOptions() + check(type(opts.modIndexCache) ~= "table" or next(opts.modIndexCache) == nil, + "options no longer carry the index listing") + eq(opts.battleLayout, "wide", "migration keeps the player's settings") + check(fs.files[ModIndex.CACHE_FILE] ~= nil, "the index cache file exists") + check(fs.files[ModUpdate.CACHE_FILE] ~= nil, "the release cache file exists") +end + +SaveData.loadOptions, SaveData.saveOptions, SaveData.persistenceFs = realLoad, realSave, realFs +T.finish() diff --git a/tests/engine/save_slot_duplicate_2484.lua b/tests/engine/save_slot_duplicate_2484.lua new file mode 100644 index 00000000..9b2fb289 --- /dev/null +++ b/tests/engine/save_slot_duplicate_2484.lua @@ -0,0 +1,197 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local SaveSerializer = require("src.core.SaveSerializer") +local SaveData = require("src.core.SaveData") +local GameVersion = require("src.core.GameVersion") + +local realFS = love.filesystem + +local function memfs(files) + return { + files = files, + read = function(path) return files[path] end, + write = function(path, content) files[path] = content return true end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + createDirectory = function() return true end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path == "" and "" or path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } +end + +local function fresh(version, scope, extra) + local files = {} + local reg = { list = { "slot1" }, active = "slot1", names = { slot1 = "MAIN" } } + local opts = { playthroughIds = { [scope] = { slot1 = "aaaa" } } } + if scope:sub(1, 5) == "cart_" then + reg.hashes = { slot1 = "cafe" } + reg.broken = { slot1 = true } + opts.cartSlots = { [scope:sub(6)] = reg } + else + opts.saveSlots = { [scope] = reg } + end + files["options.lua"] = SaveSerializer.encode(opts) + local save = { + version = version, + player = { name = "ASH", map = "PALLET_TOWN", x = 1, y = 1 }, + party = {}, + pokedex = { seen = {}, owned = {} }, + inventory = {}, + meta = { playthroughId = "aaaa" }, + } + for k, v in pairs(extra or {}) do save[k] = v end + files["saves/" .. scope .. "/slot1.lua"] = SaveSerializer.encode(save) + love.filesystem = memfs(files) + SaveData.resetSlotState() + GameVersion.set(version) + return files +end + +local function decode(files, path) + return files[path] and SaveSerializer.decode(files[path]) or nil +end + +do + local files = fresh("red", "red") + files["mod_storage/red/aaaa/m/data.lua"] = "return { n = 1 }" + local before = files["saves/red/slot1.lua"] + local id, err = SaveData.duplicateSlot("red", "slot1", "MAIN copy") + T.eq(id, "slot2", "red: duplicateSlot returns the next slot id (" .. tostring(err) .. ")") + local copy = decode(files, "saves/red/slot2.lua") + T.check(type(copy) == "table", "red: the copy decodes") + T.eq(copy and copy.player.name, "ASH", "red: the copy holds the same progress") + local newId = copy and copy.meta.playthroughId + T.check(type(newId) == "string" and newId ~= "" and newId ~= "aaaa", + "red: the copy gets its own playthroughId") + T.eq(files["saves/red/slot1.lua"], before, "red: the original file is untouched") + local opts = SaveData.loadOptions() + T.eq(opts.playthroughIds.red.slot2, newId, "red: options map slot2 to the new id") + T.eq(opts.playthroughIds.red.slot1, "aaaa", "red: and slot1 keeps its id") + T.eq(files["mod_storage/red/" .. tostring(newId) .. "/m/data.lua"], "return { n = 1 }", + "red: mod storage is copied under the new id") + T.eq(files["mod_storage/red/aaaa/m/data.lua"], "return { n = 1 }", + "red: and the original mod storage stays") + local label + for _, s in ipairs(SaveData.listSlots("red")) do + if s.id == "slot2" then label = s.label end + end + T.eq(label, "MAIN copy", "red: listSlots shows the copy's label") + T.eq(files["saves/red/slot2.lua.bak"], nil, "red: the copy starts with no .bak") + + copy.player.name = "GARY" + T.check(SaveData.writeSlot("red", "slot2", copy), "red: the copy can be written") + T.eq(decode(files, "saves/red/slot1.lua").player.name, "ASH", + "red: writing the copy leaves the original alone") + + T.check(SaveData.deleteSlot("red", "slot2"), "red: the copy can be deleted") + opts = SaveData.loadOptions() + T.eq(opts.playthroughIds.red.slot1, "aaaa", "red: deleting the copy keeps slot1's id") + T.check(files["saves/red/slot1.lua"] ~= nil, "red: and slot1's file") +end + +do + local files = fresh("crystal", "crystal", { + generation = 2, rtc = { day = 3, hour = 7 }, rawImport = "RAWCART" }) + files["saves/crystal/slot1.cart"] = "LEGACYCART" + local id = SaveData.duplicateSlot("crystal", "slot1", "MAIN copy") + T.eq(id, "slot2", "crystal: duplicate works for Gen 2") + local copy = decode(files, "saves/crystal/slot2.lua") + T.eq(copy and copy.rtc and copy.rtc.hour, 7, "crystal: RTC bookkeeping survives") + T.eq(copy and copy.rawImport, "RAWCART", "crystal: the raw cart image survives") + T.eq(files["saves/crystal/slot2.cart"], "LEGACYCART", "crystal: the legacy .cart sidecar is copied") +end + +do + local files = fresh("firered", "firered", { + engine = "game3", generation = 3, modData = { cartImage = "FRCART", cartKey = 1234 } }) + local id = SaveData.duplicateSlot("firered", "slot1", "MAIN copy") + T.eq(id, "slot2", "firered: duplicate works for Gen 3") + local copy = decode(files, "saves/firered/slot2.lua") + T.eq(copy and copy.modData and copy.modData.cartImage, "FRCART", "firered: the cart template survives") + T.eq(copy and copy.modData and copy.modData.cartKey, 1234, "firered: the stored key survives") +end + +do + local files = fresh("red", "cart_x") + local id = SaveData.duplicateCartSlot("x", "slot1", "MAIN copy") + T.eq(id, "slot2", "cart: duplicateCartSlot works for a cart scope") + T.check(files["saves/cart_x/slot2.lua"] ~= nil, "cart: the copy lands in the cart scope") + T.eq(SaveData.slotCartHash("x", "slot2"), "cafe", "cart: the cart hash is copied") + T.eq(SaveData.slotSealBroken("x", "slot2"), true, "cart: the broken seal is copied") +end + +do + local files = fresh("red", "red") + files["saves/red/slot1_trade.lua"] = SaveSerializer.encode({ + entries = { { sent = { species = "PIKACHU" } } } }) + local optsBefore = files["options.lua"] + local id, err = SaveData.duplicateSlot("red", "slot1", "MAIN copy") + T.eq(id, nil, "trade: a pending trade blocks the copy") + T.check(type(err) == "string" and err:find("trade", 1, true) ~= nil, "trade: and says why") + T.eq(files["options.lua"], optsBefore, "trade: the registry is untouched") + T.eq(files["saves/red/slot2.lua"], nil, "trade: no copy is written") + T.eq(files["saves/red/slot2_trade.lua"], nil, "trade: the journal is never copied") + + files["saves/red/slot1_trade.lua"] = SaveSerializer.encode({ entries = {} }) + id = SaveData.duplicateSlot("red", "slot1", "MAIN copy") + T.eq(id, "slot2", "trade: a settled journal does not block the copy") + T.eq(files["saves/red/slot2_trade.lua"], nil, "trade: and the journal stays behind") +end + +do + fresh("red", "red") + local id, err = SaveData.duplicateSlot("red", "slot9", "x") + T.eq(id, nil, "missing: an unregistered slot is refused") + T.check(type(err) == "string", "missing: with a reason") +end + +do + local files = fresh("red", "red") + local RomImporter = require("src.import.RomImporter") + local imp = RomImporter.new(function() end, { launcher = true }) + imp._sync = false + imp:_refreshSlots("red") + imp:_duplicateSlot("red", "slot1") + T.eq(imp.activeSlot.red, "slot2", "launcher: Duplicate selects the copy") + T.eq(imp.saveNotice.red and imp.saveNotice.red.ok, true, "launcher: and reports success") + local label + for _, s in ipairs(imp.slots.red or {}) do + if s.id == "slot2" then label = s.label end + end + T.eq(label, "MAIN copy", "launcher: the copy is labelled after the original") + T.check(files["saves/red/slot2.lua"] ~= nil, "launcher: and written to disk") +end + +do + local src = assert(io.open("src/import/LauncherView.lua")):read("*a") + local body = src:match("local function saveActions%(.-\nend\n") + T.check(body and body:find("_duplicateSlot(scope, slot.id)", 1, true) ~= nil, + "launcher: the save card offers a Duplicate action") +end + +love.filesystem = realFS + +T.finish("save_slot_duplicate_2484") diff --git a/tests/engine/timing_parity.lua b/tests/engine/timing_parity.lua index 3ad52617..ba987e18 100644 --- a/tests/engine/timing_parity.lua +++ b/tests/engine/timing_parity.lua @@ -37,10 +37,7 @@ T.eq(Timing.WARP_FADE_IN, 0, "there is no fade in: LoadGBPal restores in one wri -- ---------------------------------------------------------------- HP bar --- UpdateHPBar walks one HP point per iteration. On the player's HUD each --- point costs a frame (PrintHPNumber's DelayFrame, gated on wHPBarType) and --- each pixel of bar movement costs two more; on the enemy HUD only the --- pixels cost anything. +-- engine/gfx/hp_bar.asm:81-135 T.eq(Timing.hpBarPixels(150, 150), 48, "a full bar is 48 px") T.eq(Timing.hpBarPixels(75, 150), 24, "half HP is half the bar") T.eq(Timing.hpBarPixels(0, 150), 0, "an empty bar is 0 px") @@ -48,8 +45,10 @@ T.eq(Timing.hpBarPixels(1, 150), 1, "GetHPBarLength clamps a sliver to 1 px") T.eq(Timing.hpDrainFrames(150, 0, 150, true), 150 + 96 + 6, "a 150 HP player mon drains in D + 2P + 6 = 252 frames") -T.eq(Timing.hpDrainFrames(150, 0, 150, false), 96 + 5, - "the same drain on the enemy HUD costs only 2P + 5 = 101 frames") +T.eq(Timing.hpDrainFrames(150, 0, 150, false), 3 * 48 + 5, + "the same drain on the enemy HUD lags a frame per pixel: 3P + 5 = 149") +T.eq(Timing.hpDrainFrames(40, 0, 40, false), 2 * 48 + 5, + "a sub-48 HP enemy crosses a pixel every step, so no lag: 2P + 5") -- The engine's per-frame stepper has to agree with that closed form, or the -- bar is animating at a rate nothing else measures. diff --git a/tests/game3_controls_menu_test.lua b/tests/game3_controls_menu_test.lua index 7794cf89..557dcfdf 100644 --- a/tests/game3_controls_menu_test.lua +++ b/tests/game3_controls_menu_test.lua @@ -139,8 +139,8 @@ try("gen3 rows", function() check(#bm.items == 12, "12 rows on Gen 3") check(rowIndex("l") and rowIndex("r"), "L and R rows present") check(bm.items[rowIndex("l")].right == "Q/LB", "L shows Q/LB (got " .. tostring(bm.items[rowIndex("l")].right) .. ")") - check(bm.items[rowIndex("speedUp")].right == "R2", "SPEED + shows R2") - check(bm.items[rowIndex("speedDown")].right == "L2", "SPEED - shows L2") + check(bm.items[rowIndex("speedUp")].right == "1/R2", "SPEED + shows 1/R2") + check(bm.items[rowIndex("speedDown")].right == "0/L2", "SPEED - shows 0/L2") try("draw", function() Controls.draw() end) Controls.close() end) @@ -279,7 +279,28 @@ try("gen1", function() check(table.concat(ids, ",") == "up,down,left,right,a,b,start,select,speedDown,speedUp", "Gen 1 row order unchanged") check(bm.rows == 6, "Gen 1 keeps 6 visible rows") - check(bm.items[9].right == "LB" and bm.items[10].right == "RB", "Gen 1 speed rows stay LB/RB") + check(bm.items[9].right == "0/LB" and bm.items[10].right == "1/RB", "Gen 1 speed rows show 0/LB and 1/RB") +end) + +print("[test] 9b. Binding the speed key to a button leaves the speed rows alone") +try("speed key swap", function() + local g = { save = { options = {} }, input = Input, + stack = { push = function() end, pop = function() end } } + function g:writeOptions() end + local bm = BindingsMenu.new(g) + bm:beginCapture(bm.items[5]) + bm:captureKey("1") + bm:captureKeyRelease("1") + local b = g.save.options.bindings + check(b and b.a and b.a.key == "1", "A takes the 1 key") + check(b and b.speedUp == nil, "SPEED + keeps its pad binding") + check(bm.items[10].right == "1/RB", "SPEED + row still reads 1/RB") + bm:beginCapture(bm.items[6]) + bm:captureKey("0") + bm:captureKeyRelease("0") + check(b.speedDown == nil, "SPEED - keeps its pad binding") + check(b.b and b.b.key == "0", "B takes the 0 key") + Input:init() end) print("[test] 10. controls screen never draws with Gen 1 Font") diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 80261564..93791e6c 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -495,9 +495,9 @@ check(bm.items[1].label == "UP" and bm.items[1].right == "UP/D-UP" and bm.items[7].label == "START" and bm.items[7].right == "ESC/START" and bm.items[8].label == "SELECT" and bm.items[8].right == "TAB/BACK", "with no rebind the rows mirror the fixed map, key and pad both (#589)") -check(bm.items[9].label == "SPEED -" and bm.items[9].right == "LB" - and bm.items[10].label == "SPEED +" and bm.items[10].right == "RB", - "and the GAME SPEED shortcuts show the shoulders they sit on (#1922)") +check(bm.items[9].label == "SPEED -" and bm.items[9].right == "0/LB" + and bm.items[10].label == "SPEED +" and bm.items[10].right == "1/RB", + "and the GAME SPEED shortcuts show their keys and the shoulders they sit on (#1922)") check(cbGame.save.options.bindings == nil, "opening the screen alone writes nothing") diff --git a/tests/modkit/cases/mod_map_outdoor_encounters.lua b/tests/modkit/cases/mod_map_outdoor_encounters.lua new file mode 100644 index 00000000..31d56a60 --- /dev/null +++ b/tests/modkit/cases/mod_map_outdoor_encounters.lua @@ -0,0 +1,113 @@ +-- engine/battle/wild_encounters.asm:38-46 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local check, eq = T.check, T.eq + +local FIXTURE = { + ["mods/meadow/manifest.json"] = [[{ + "id": "meadow", + "name": "Meadow", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/meadow/main.lua"] = [[ + local mod = ... + local function flat(block) + local b = {} + for i = 1, 16 do b[i] = block end + return b + end + mod.content.maps:register("MOD_MEADOW", { + id = "MOD_MEADOW", label = "ModMeadow", index = 1100, + tileset = "FIX_OUT", width = 4, height = 4, blocks = flat(1), + borderBlock = 0, warps = {}, signs = {}, objects = {}, + outdoor = true, + }) + mod.content.maps:register("MOD_CAVE", { + id = "MOD_CAVE", label = "ModCave", index = 1101, + tileset = "FIX_OUT", width = 4, height = 4, blocks = flat(1), + borderBlock = 0, warps = {}, signs = {}, objects = {}, + }) + ]], +} + +local run = T.sdk.loadMods({ "mods/meadow" }, { fs = T.sdk.memfs(FIXTURE) }) +eq(#run.errors, 0, "the outdoor-map mod loads cleanly") +local data = run.data +check(data.maps.MOD_MEADOW and data.maps.MOD_MEADOW.outdoor == true, + "maps:register keeps the outdoor field") +data.field.indoorEncounters = { firstIndoorMap = 37, excludedTileset = "FOREST" } + +local OW = require("src.world.OverworldController") +local MapLoader = require("src.world.MapLoader") +local SaveData = require("src.core.SaveData") + +local indoor = data.field.indoorEncounters +local rolls = OW.rollsIndoorEncounters +check(type(rolls) == "function", "OverworldState exposes the indoor-encounter rule") +if type(rolls) == "function" then + check(not rolls({ id = "ROUTE_1", index = 12, tileset = "OVERWORLD" }, indoor), + "a vanilla route below FIRST_INDOOR_MAP rolls only in grass") + check(not rolls({ id = "ROUTE_23", index = 34, tileset = "PLATEAU" }, indoor), + "a non-OVERWORLD vanilla map below FIRST_INDOOR_MAP rolls only in grass") + check(rolls({ id = "MT_MOON_1F", index = 59, tileset = "CAVERN" }, indoor), + "a vanilla cave rolls on every tile") + check(not rolls({ id = "VIRIDIAN_FOREST", index = 51, tileset = "FOREST" }, indoor), + "the FOREST tileset rolls only in grass") + check(not rolls({ id = "MOD_FIELD", index = 1000, tileset = "OVERWORLD" }, indoor), + "a mod map on the OVERWORLD tileset is outdoor without declaring it") + check(not rolls(data.maps.MOD_MEADOW, indoor), + "a mod map declaring outdoor = true rolls only in grass") + check(rolls(data.maps.MOD_CAVE, indoor), + "a mod map that does not declare outdoor keeps the indoor rule") + check(rolls({ id = "MOD_TOWER", index = 1000, tileset = "OVERWORLD", outdoor = false }, + indoor), "outdoor = false opts an OVERWORLD map into indoor encounters") + check(rolls({ id = "MOD_NOINDEX", tileset = "CAVERN" }, indoor), + "a mod map with no index is not a comparison error") + check(not rolls(data.maps.MOD_CAVE, nil), "no indoor rule in the dataset rolls nothing") +end + +local function bind(fn, name, value) + 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, value) return true end + i = i + 1 + end +end + +local save = SaveData.newGame() +save.party = {} +local game = { data = data, save = save, + stack = { push = function() end }, + input = { isDown = function() return false end } } +check(bind(OW.onStepComplete, "Game", game), "onStepComplete binds Game") +bind(OW.onStepComplete, "mapScripts", { get = function() return nil end }) + +local function terrainOn(mapId) + MapLoader.invalidate(mapId) + local map = MapLoader.load(data, mapId) + local seen = {} + local noop = function() return false end + local state = setmetatable({ + map = map, player = { cellX = 2, cellY = 2, facing = "down" }, + scriptMoves = {}, wildEncounterGraceSteps = 0, + syncLastMapRewrite = noop, checkSpinner = noop, checkBadgeGate = noop, + checkForcedMovement = noop, checkSeafoamCurrent = noop, safariStep = noop, + applyFieldPoison = noop, refreshStandingOnWarp = noop, dirHeld = noop, + rollEncounter = function(_, _, terrain) seen[#seen + 1] = terrain end, + }, { __index = OW }) + local ok, err = pcall(OW.onStepComplete, state) + check(ok, mapId .. " step completes: " .. tostring(err)) + check(not map:isGrassCell(2, 2), mapId .. " step lands off grass") + return seen[1] +end + +eq(terrainOn("MOD_MEADOW"), nil, "an outdoor mod map does not roll off grass") +eq(terrainOn("MOD_CAVE"), "indoor", "an indoor mod map rolls on every tile") + +run.release() +T.finish("mod map outdoor encounters") diff --git a/tests/quick.list b/tests/quick.list index 05cc4ba7..75710293 100644 --- a/tests/quick.list +++ b/tests/quick.list @@ -114,8 +114,13 @@ tests/engine/game3_em_bike_doors_2637.lua tests/engine/game3_em_catch_prompt_2637.lua tests/engine/game3_frontier_obedience_2638.lua tests/engine/game3_post_dex_background_2637.lua +tests/engine/game3_rs_bag_cursor_width_2709.lua +tests/engine/game3_rs_item_use_text_2699_test.lua +tests/engine/game3_rs_starter_choose_scale_2703.lua +tests/engine/game3_rs_summary_memo_location_2707.lua tests/engine/game3_speed_hotkey_test.lua tests/engine/game3_tent_save_reload_2638.lua +tests/engine/game3_wailmer_pail_2697_test.lua tests/engine/game_display_chord_test.lua tests/engine/game_speed_categories_test.lua tests/engine/game2_faithful_res.lua @@ -360,6 +365,8 @@ tests/engine/gen2_world_overdraw_perf_test.lua tests/engine/gen2_world_scissor_bug2191.lua tests/engine/gen2_yesno_typing_bug2085.lua tests/engine/gen3_dex_summary_keys_2461.lua +tests/engine/gen3_jp_names_2706.lua +tests/engine/gen3_pokemon_icon_hook_bug2693.lua tests/engine/gen3_save_rs_tv_test.lua tests/engine/gen3_shim_engine_require.lua tests/engine/generated_data_decoder_test.lua @@ -382,6 +389,7 @@ tests/engine/host_shell_fetch_errors.lua tests/engine/host_shell_postlog.lua tests/engine/host_shell_request_headers.lua tests/engine/hostshell_spawn_self.lua +tests/engine/hostshell_win_restart_2721.lua tests/engine/importer_worker_failure_cleanup.lua tests/engine/importers_asset_packs.lua tests/engine/indigo_statues_bug2683.lua @@ -514,6 +522,7 @@ tests/engine/online_trade.lua tests/engine/open_menu_bugs_949_1149.lua tests/engine/options_backup_rollforward_bug828.lua tests/engine/options_partial_write_bug932.lua +tests/engine/options_size_budget_2694.lua tests/engine/options_submenu_marquee_test.lua tests/engine/options_write_readback_bug828.lua tests/engine/orientation_option.lua @@ -605,13 +614,16 @@ tests/engine/saffron_cries_bug2248_2249.lua tests/engine/save_confirm_layout_bug1522.lua tests/engine/save_convert_extra_flags.lua tests/engine/save_convert_toggle_objects.lua +tests/engine/save_editor_berry_powder_2698.lua tests/engine/save_editor_buena_points_2640.lua +tests/engine/save_editor_custom_maps.lua tests/engine/save_export_map_window_bug1691.lua tests/engine/save_export_portable_bug752.lua tests/engine/save_export_same_map_moved_2468.lua tests/engine/save_map_context_bug889.lua tests/engine/save_menu_badges_bug2405.lua tests/engine/save_menu_location_bug2328.lua +tests/engine/save_slot_duplicate_2484.lua tests/engine/save_slots.lua tests/engine/screen_position.lua tests/engine/scripted_battle_checkpoint_test.lua @@ -727,6 +739,7 @@ tests/engine/yellow_gifts_gotmon_2159.lua tests/engine/yellow_oak_back_pikapic.lua tests/engine/yellow_viridian_old_man_bug2122.lua tests/engine/zero_damage_miss_ruleset_bug2153.lua +tests/game3_back_pic_anims_2702_test.lua tests/game3_battle_anims_phase1_test.lua tests/game3_battle_rendering_test.lua tests/game3_battle_shiny_test.lua @@ -744,6 +757,7 @@ tests/game3_corner_screen_test.lua tests/game3_cry_modes_test.lua tests/game3_cut_grass_mow_test.lua tests/game3_cycling_road_bike_test.lua +tests/game3_daycare_menu_emerald_test.lua tests/game3_display_fit_test.lua tests/game3_doors_viewport_test.lua tests/game3_elevation_oam_priority_test.lua @@ -780,6 +794,9 @@ tests/game3_printer_clip_test.lua tests/game3_quest_log_integration_test.lua tests/game3_reflection_neighbor_test.lua tests/game3_renewable_hidden_items_test.lua +tests/game3_resident_caches_2695_test.lua +tests/game3_rs_naming_kb_crop_2701_test.lua +tests/game3_rs_pokedex_palette_2710_test.lua tests/game3_runtime_reset_test.lua tests/game3_safari_zone_warp_exit_test.lua tests/game3_save_frames_test.lua @@ -790,6 +807,7 @@ tests/game3_scenario_menu_test.lua tests/game3_se_source_cache_test.lua tests/game3_seagallop_anim_test.lua tests/game3_seam_warm_budget_test.lua +tests/game3_set_invisible_2712_test.lua tests/game3_special_ids_test.lua tests/game3_ss_anne_departure_test.lua tests/game3_stitchfield_flash_test.lua