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..ce58b197 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,10 @@ imported side by side. Gen 2 support is still under construction. [latest release](https://github.com/bryanthaboi/gen1recomp/releases/latest). 2. Launch it. The packaged app contains no ROM and no game data, so the launcher will ask for one. -3. Choose your legally obtained `.gb` / `.gbc` / `.gba` file, or drop it onto - the window. Import takes a few seconds and the game starts automatically. +3. Choose your legally obtained `.gb` / `.gbc` / `.gba` file, a `.zip` / + `.7z` that holds one (offered only where your platform's build supports + archives), or drop it onto the window. Import takes a few seconds and the + game starts automatically. 4. Repeat for any other game you own. Each one gets its own tab in the launcher. Only the canonical US English ROMs below are accepted. The importer checks the @@ -134,7 +136,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..a781bea4 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -21,6 +21,8 @@ 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) +* **Full controller support**: both thumbsticks move and navigate menus, and stick directions, triggers, stick clicks and extra SDL buttons can be bound in CONTROLS +* **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/main.lua b/main.lua index d5343f2f..360f3d06 100644 --- a/main.lua +++ b/main.lua @@ -230,6 +230,26 @@ end local launchedIntoGame = false local RELAUNCH_MARKER = "relaunch_to_launcher.txt" local launchOptionsSuppressed = false +local quitToLauncher = false +local processEnded = false + +local function endProcessOnce() + if processEnded then return end + processEnded = true + pcall(function() + require("src.core.DiscordPresence").shutdown() + end) + require("src.core.SessionLifecycle").endProcess() +end + +local function readRelaunchHandoff() + if not love.filesystem.getInfo(RELAUNCH_MARKER) then return nil end + local raw = love.filesystem.read(RELAUNCH_MARKER) + pcall(love.filesystem.remove, RELAUNCH_MARKER) + local data = require("src.core.SaveSerializer").decode(raw or "", + { maxBytes = 65536 }) + return type(data) == "table" and data or {} +end local onlineClient, onlineClientResolved local function onlineClientModule() @@ -506,58 +526,25 @@ local function makeLauncher(launcherOpts) end local function returnToLauncher(opts) - if not Game then return end + if not Game or quitToLauncher then return end + quitToLauncher = true + opts = opts or {} + local handoff = { tab = opts.tab, invite = opts.invite, request = opts.request } + local okEncode, body = pcall(require("src.core.SaveSerializer").encode, handoff) + pcall(love.filesystem.write, RELAUNCH_MARKER, okEncode and body or "return {}\n") - if require("src.core.RequireGuard").repair() then - print("boot: restored love.filesystem searcher (see #2001)") - end - - local GameVersion = require("src.core.GameVersion") - local currentVersion = GameVersion.get() SessionLifecycle.endGameSession(Game) Game = nil - pcall(function() require("src.online.Trade").hostIsLive = nil end) - local syncEngine = package.loaded["src.sync.SyncEngine"] - if type(syncEngine) == "table" and type(syncEngine._shared) == "table" then - pcall(syncEngine._shared.protectPlaythrough, syncEngine._shared, nil, nil) - end autopilot = nil driverCo = nil - -- Leave the cart's scope behind: the launcher's own settings and slots are - -- the base game's, not the cart's. The speed ladder is cart state too, so - -- a 1x/2x cart must not pin the launcher or the next game. - local SaveData = require("src.core.SaveData") - local cartId = SaveData.getCart() - SaveData.setCart(nil) - require("src.core.GameSpeed").setAllowed(nil) - - SessionLifecycle.endMountedSession(currentVersion) - - -- Slot lists are resolved once per process. Invalidate only the game - -- (and cart, if any) we just left so the new launcher can migrate a flat - -- in-game SAVE into a visible slot -- nothing else is rewritten. - SaveData.refreshSlotResolution(currentVersion) - if cartId then SaveData.refreshSlotResolution("cart_" .. cartId) end - - applySavedOrientation() - - local preload = require("src.mods.LauncherMods").translationStrings() - if preload then require("src.core.Strings").load({ strings = preload }) end - - if love.window and love.window.setTitle then - local Version = require("src.core.Version") - love.window.setTitle(Version.title("Gen 1 Recompilation Project")) - end - - Importer = makeLauncher({ initialTab = opts and opts.tab or nil, - invite = opts and opts.invite or nil }) - -- Finger that confirmed EXIT GAME is often still down over Import Save. - if Importer.ignoreReturningPointer then - Importer:ignoreReturningPointer() - end + endProcessOnce() + require("src.core.HostShell").restart() end local pendingLauncherReturn +local driverHost = { + returnToLauncher = function(o) pendingLauncherReturn = o or {} end, +} function bootGame(version, cartId, opts) opts = opts or {} @@ -701,19 +688,19 @@ local function startLaunchRequest(request) return true end + if Game then + returnToLauncher({ request = request }) + return true + end + local version = request.game if request.launcher or not version then - if Game then - returnToLauncher({ tab = version }) - else - showLauncher(version) - end + showLauncher(version) autoUpdateMods(request, not version and "mods" or nil) return true end local RomImporter = require("src.import.RomImporter") - if Game then returnToLauncher() end Importer = nil if Prelaunch then return true end @@ -819,6 +806,7 @@ function love.load(args) end end love.graphics.setDefaultFilter("nearest", "nearest") + pcall(function() require("src.core.GamepadMap").initGamepadMappings() end) -- NX: handheld 720p / docked 1080p. Runs for every boot path (launcher, -- editor, scripted); no-op on desktop/mobile. NxDisplay.sync() @@ -873,6 +861,10 @@ function love.load(args) scriptedOpts = { arena = spec } end + local handoff = readRelaunchHandoff() + local relaunched = handoff ~= nil + if relaunched and os.getenv("POKEPORT_DRIVER") then scripted = nil end + if scripted then if forceImport or not ready then -- The importer detects the dropped/loaded ROM's version by SHA-1 and @@ -917,11 +909,7 @@ function love.load(args) -- marker behind: consume it and stay on the launcher, or the shortcut below -- would boot the same game again and that close would restart again, -- forever (#887). Consumed on read, so the very next launch is normal. - local relaunched = love.filesystem.getInfo(RELAUNCH_MARKER) ~= nil - if relaunched then - launchOptionsSuppressed = true - pcall(love.filesystem.remove, RELAUNCH_MARKER) - end + if relaunched then launchOptionsSuppressed = true end if not relaunched and resolvedLaunch.game and not resolvedLaunch.launcher and startLaunchRequest(resolvedLaunch) then @@ -937,12 +925,23 @@ function love.load(args) -- by its SHA-1 (GameVersion.forSha1); pressing Play boots that game (Gold -- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md). -- Edit on a save row opens the bundled editor on that slot (openEditor). - Importer = makeLauncher() if not relaunched then + Importer = makeLauncher() launcherSplash = require("src.import.LauncherSplash").new() autoUpdateMods(resolvedLaunch, not resolvedLaunch.game and "mods" or nil) + return end + Importer = makeLauncher({ + initialTab = type(handoff.tab) == "string" and handoff.tab or nil, + invite = type(handoff.invite) == "string" and handoff.invite or nil, + }) + if Importer.ignoreReturningPointer then Importer:ignoreReturningPointer() end + local driverPath = os.getenv("POKEPORT_DRIVER") + if driverPath then + driverCo = coroutine.create(assert(loadfile(driverPath))()) + end + if type(handoff.request) == "table" then startLaunchRequest(handoff.request) end end function love.update(dt) @@ -986,6 +985,18 @@ function love.update(dt) if Importer then require("src.import.LauncherWindow").observe(dt) Importer._inputBlocked = splashBlocksInput() + if driverCo and not Game then + local ok, err = coroutine.resume(driverCo, Importer) + if not ok then + print("driver error: " .. tostring(err)) + love.event.quit(1) + return + end + if coroutine.status(driverCo) == "dead" then + love.event.quit() + return + end + end return Importer:update(dt) end if not Game then return end @@ -1010,7 +1021,7 @@ function love.update(dt) local i = 0 while i < iterations do i = i + 1 - local ok, err = coroutine.resume(driverCo, Game) + local ok, err = coroutine.resume(driverCo, Game, driverHost) if not ok then print("driver error: " .. tostring(err)) love.event.quit(1) @@ -1563,12 +1574,6 @@ function love.textinput(text) end end --- #785: set once love.quit has routed a window close into HostShell.restart, --- so the follow-up quit event the restart itself raises (quit("restart") on --- desktop; AppImage and Android relaunch the process instead, #575) falls --- through to the normal shutdown below instead of restarting forever. -local quitToLauncher = false - function love.quit() if launcherSplash then launcherSplash:release(); launcherSplash = nil end if Importer and Importer._themeVideo then @@ -1605,36 +1610,17 @@ function love.quit() -- docs/modding.md's core.quit_to_launcher entry) may veto returning to -- this Lua launcher via that hook. Vanilla behavior (used when no mod -- claims the hook) is exactly the condition below. - -- - -- Android and iOS both tear down LOVE in-process rather than - -- love.event.quit("restart"): Android's vendored love.cpp PHYSFS-crashes - -- on a second init (#575), and iOS's love.cpp forces DONE_RESTART for - -- every quit while warning that leftover threads make that unreliable. - -- SessionLifecycle workers (ChipAudio / Fetch / Check) make that warning - -- real -- endProcess joins them, then the native restart still blows up. local osName = love.system and love.system.getOS and love.system.getOS() - local inProcessReturn = (osName == "Android" or osName == "iOS") + local mobile = (osName == "Android" or osName == "iOS") local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function() return Game and not Importer and not quitToLauncher and not scripted - and (inProcessReturn or not launchedIntoGame) + and (mobile or not launchedIntoGame) end) if wouldReturnToLauncher then - if inProcessReturn then - returnToLauncher() - return true -- abort this quit; stay in the same LOVE run - end - quitToLauncher = true - -- Tell the fresh boot to ignore any boot-straight-into-a-game option this - -- once, so the restart really does land in the launcher (#887). A failed - -- write only costs that suppression, so it must never block the restart. - pcall(love.filesystem.write, RELAUNCH_MARKER, "1") - require("src.core.HostShell").restart() - return true -- abort this quit; the restart lands back in the launcher + returnToLauncher() + return true end - pcall(function() - require("src.core.DiscordPresence").shutdown() - end) - SessionLifecycle.endProcess() + endProcessOnce() end function love.filedropped(file) @@ -1725,7 +1711,7 @@ function love.run() -- teardown, so the relaunched task re-enters an activity whose -- native main already returned; end the process outright once the -- love.quit hook has run (#339) - if love.system and love.system.getOS() == "Android" then + if a ~= "restart" and love.system and love.system.getOS() == "Android" then os.exit(a or 0) end return a or 0 diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index facaf3fd..42adee8b 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1251,21 +1251,19 @@ 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 + local roundUp = b.drainRoundUp + b.drainCycles = nil + b.drainRoundUp = nil + if goal ~= 0 then cycles = cycles + Timing.HP_BAR_STEP_CYCLES end + -- engine/gfx/hp_bar.asm:133-134 + b.shownPx = Timing.hpBarPixels(goal, math.max(1, b.mon.stats.hp)) + return Timing.hpDrainClosingFrames(b == self.player, cycles, roundUp) +end + +-- engine/gfx/hp_bar.asm:81-120 function BattleState:stepHPDrain() local busy = false local only = self.drainOnly @@ -1279,7 +1277,8 @@ function BattleState:stepHPDrain() end local maxHP = math.max(1, b.mon.stats.hp) local playerSide = (b == self.player) - local targetPx = Timing.hpBarPixels(b.shownHP, maxHP) + local barPx = b.draining and Timing.hpBarLength or Timing.hpBarPixels + local targetPx = barPx(b.shownHP, maxHP) if not b.shownPx then b.shownPx = targetPx end if (b.drainHold or 0) > 0 then b.drainHold = b.drainHold - 1 @@ -1306,26 +1305,33 @@ 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) - targetPx = Timing.hpBarPixels(b.shownHP, maxHP) + 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.hpBarLength(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 + local lag, tie = Timing.hpBarCpuLag(b.drainCycles, b.drainRoundUp) + if tie then b.drainRoundUp = (b.drainRoundUp == false) end + b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1 + lag + 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 @@ -7029,10 +7035,12 @@ function BattleState:drawTextArea() and self:arrowOwnsCell() for li, line in ipairs(self.shown or {}) do local y = (ys[li] or 128) + off + local pen = 8 for i = 1, #line do if not (arrowCell and li == 2 and i == 18) then - drawGlyph(line[i], 8 + (i - 1) * 8, y) + drawGlyph(line[i], pen, y) end + pen = pen + Font.advanceOf(line[i]) end end -- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 2df412e4..73448989 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -189,8 +189,10 @@ local function drawMessageBox(battle) local ys = { 112, 128 } for li, line in ipairs(battle.shown or {}) do local y = (ys[li] or 128) + off + local pen = 8 for i = 1, #line do - Font.drawCode(line[i], 8 + (i - 1) * 8, y) + Font.drawCode(line[i], pen, y) + pen = pen + Font.advanceOf(line[i]) end end if (battle.msgWaiting or battle.msgPrompt) and battle.frame % 60 < 30 then diff --git a/src/core/DeferredWrite.lua b/src/core/DeferredWrite.lua new file mode 100644 index 00000000..71da4be6 --- /dev/null +++ b/src/core/DeferredWrite.lua @@ -0,0 +1,56 @@ +local DeferredWrite = { DELAY = 0.5 } + +local pending = {} + +function DeferredWrite.clock() + local timer = love and love.timer + if timer and timer.getTime then return timer.getTime() end + return os.clock() +end + +function DeferredWrite.schedule(key, fn, now) + pending[key] = { fn = fn, at = (now or DeferredWrite.clock()) + DeferredWrite.DELAY } +end + +local function run(key) + local entry = pending[key] + if not entry then return end + pending[key] = nil + pcall(entry.fn) +end + +function DeferredWrite.tick(now) + if next(pending) == nil then return end + now = now or DeferredWrite.clock() + local due + for key, entry in pairs(pending) do + if now >= entry.at then + due = due or {} + due[#due + 1] = key + end + end + if due then + for _, key in ipairs(due) do run(key) end + end +end + +function DeferredWrite.flush(key) + if key ~= nil then return run(key) end + local keys = {} + for k in pairs(pending) do keys[#keys + 1] = k end + for _, k in ipairs(keys) do run(k) end +end + +function DeferredWrite.isPending(key) + return pending[key] ~= nil +end + +function DeferredWrite.cancel(key) + pending[key] = nil +end + +require("src.core.SessionLifecycle").registerProcessShutdown(function() + DeferredWrite.flush() +end) + +return DeferredWrite diff --git a/src/core/Game.lua b/src/core/Game.lua index fd75d1d4..2e67e762 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -3,6 +3,7 @@ local Data = require("src.core.Data") local FixedStep = require("src.core.FixedStep") +local DeferredWrite = require("src.core.DeferredWrite") local Input = require("src.core.Input") local Logger = require("src.core.Logger") local Renderer = require("src.render.Renderer") @@ -428,6 +429,7 @@ function Game:update(dt) -- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua). -- Give the accumulator room for one full frame at the current speed, -- or the anti-spiral clamp quietly caps every level above ~15X. + DeferredWrite.tick() local speed = self:logicSpeed() self._frameSpeed = speed FixedStep.maxAccum = FixedStep.catchupLimit(speed, dt) @@ -777,7 +779,7 @@ function Game:zoomStep(delta) local offset = Zoom.step(delta, Renderer:fitScale()) if self.save and self.save.options then self.save.options.zoom = offset - self:writeOptions() + DeferredWrite.schedule("options", function() self:writeOptions() end) end end @@ -883,6 +885,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 @@ -1047,6 +1052,18 @@ function Game:gamepadaxis(joystick, axis, value) end return end + local stickEvents = Input.stickAxisEvents and Input:stickAxisEvents(axis, value) + if stickEvents then + for i = 1, #stickEvents do + local ev = stickEvents[i] + if ev.phase == "pressed" then + padPressedBody(self, joystick, ev.button) + elseif ev.phase == "released" then + padReleasedBody(self, joystick, ev.button) + end + end + return + end Input:gamepadaxis(joystick, axis, value) end if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end @@ -1124,6 +1141,8 @@ function Game:focus(f) Input:reconcile() local eng = self:syncEngine() if eng then pcall(eng.noteResumed, eng) end + else + DeferredWrite.flush("options") end TouchControls:reset() self:cancelPointers() @@ -1133,6 +1152,7 @@ function Game:visible(v) if v then self:onResume() else + DeferredWrite.flush("options") Input:reset() TouchControls:reset() self:cancelPointers() @@ -1582,6 +1602,7 @@ end -- been observed to leave Game.load nil after EXIT GAME on Android. -- Explicit GPU owners are released below; everything else is just dropped. function Game:reset() + DeferredWrite.flush("options") if self.stack and self.stack.clear then pcall(function() self.stack:clear() end) end diff --git a/src/core/Game2.lua b/src/core/Game2.lua index c63f4580..4cd831f1 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -1375,6 +1375,7 @@ function Game2:logicSpeed() end function Game2:update(dt) + require("src.core.DeferredWrite").tick() -- _UpdateSound is a VBlank job, so it runs at 60Hz off real time whatever the -- logic multiplier is (audio/engine.asm:84, home/vblank.asm:141-143). local step = FixedStep.STEP @@ -2133,7 +2134,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 +2164,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") @@ -2198,7 +2205,7 @@ function Game2:storeZoom() self.options = options options.zoom = require("src.render.Zoom").offset if self.save then self.save.options = options end - self:persistOptions() + require("src.core.DeferredWrite").schedule("options", function() self:persistOptions() end) end function Game2:zoomStep(delta) @@ -2411,7 +2418,7 @@ end function Game2:focus(f) Input:reset() TouchControls:reset() - if f then Input:reconcile() end + if f then Input:reconcile() else require("src.core.DeferredWrite").flush("options") end self:cancelPointers() end @@ -2419,6 +2426,7 @@ function Game2:visible(v) if v then self:onResume() else + require("src.core.DeferredWrite").flush("options") Input:reset() TouchControls:reset() self:cancelPointers() @@ -2587,6 +2595,18 @@ function Game2:gamepadaxis(joystick, axis, value) end return end + local stickEvents = Input.stickAxisEvents and Input:stickAxisEvents(axis, value) + if stickEvents then + for i = 1, #stickEvents do + local ev = stickEvents[i] + if ev.phase == "pressed" then + padPressedBody(self, joystick, ev.button) + elseif ev.phase == "released" then + padReleasedBody(self, joystick, ev.button) + end + end + return + end Input:gamepadaxis(joystick, axis, value) end if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end @@ -2668,6 +2688,7 @@ end -- Same rule as Gen1: only release known GPU owners -- never fan out -- arbitrary field:release() (shared modules use :release as a handle API). function Game2:reset() + require("src.core.DeferredWrite").flush("options") if self.stack and self.stack.clear then pcall(function() self.stack:clear() end) end diff --git a/src/core/Game3.lua b/src/core/Game3.lua index 59599475..96abe3bb 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -736,11 +736,12 @@ function Game3:zoomStep(delta) local offset = Zoom.step(delta, Renderer:fitScale()) if type(self.options) == "table" then self.options.zoom = offset - self:writeOptions() + lazyReq("src.core.DeferredWrite").schedule("options", function() self:writeOptions() end) end end function Game3:update(dt) + lazyReq("src.core.DeferredWrite").tick() local speed = self:logicSpeed() self._frameSpeed = speed FixedStep.maxAccum = FixedStep.catchupLimit(speed, dt) @@ -886,6 +887,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") @@ -1120,6 +1124,18 @@ function Game3:gamepadaxis(joystick, axis, value) return end end + local stickEvents = self.input and self.input.stickAxisEvents and self.input:stickAxisEvents(axis, value) + if stickEvents then + for i = 1, #stickEvents do + local ev = stickEvents[i] + if ev.phase == "pressed" then + self:_padPressedBody(joystick, ev.button) + elseif ev.phase == "released" then + self:_padReleasedBody(joystick, ev.button) + end + end + return + end if self.input and self.input.gamepadaxis then self.input:gamepadaxis(joystick, axis, value) end end if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end @@ -1184,6 +1200,8 @@ function Game3:focus(f) if f then if self.input then self.input:reconcile() end Audio.onFocusGained() + else + lazyReq("src.core.DeferredWrite").flush("options") end end @@ -1191,6 +1209,7 @@ function Game3:visible(v) if v then self:onResume() else + lazyReq("src.core.DeferredWrite").flush("options") if self.input then self.input:reset() end if self.touchControls then self.touchControls:reset() end end @@ -1442,6 +1461,7 @@ function Game3:returnToTitle(opts) end function Game3:reset() + lazyReq("src.core.DeferredWrite").flush("options") self.questPlayback = nil Help.reset() Audio.endSession() @@ -1480,6 +1500,7 @@ function Game3:reset() end function Game3:quit() + lazyReq("src.core.DeferredWrite").flush("options") if self:quickSaveAllowed() then self:saveGame() end end diff --git a/src/core/GamepadMap.lua b/src/core/GamepadMap.lua index 20557cdf..7dce2757 100644 --- a/src/core/GamepadMap.lua +++ b/src/core/GamepadMap.lua @@ -51,12 +51,41 @@ GamepadMap.NX_RAW_BUTTON_BINDINGS = { [9] = "select", [10] = "start", } +GamepadMap.PAD_ALIASES = { + lefttrigger = "triggerleft", + righttrigger = "triggerright", + lsup = "leftstick_up", lsdown = "leftstick_down", + lsleft = "leftstick_left", lsright = "leftstick_right", + rsup = "rightstick_up", rsdown = "rightstick_down", + rsleft = "rightstick_left", rsright = "rightstick_right", +} + +function GamepadMap.normalizePad(button) + if not button then return nil end + return GamepadMap.TRIGGER_AXES[button] or GamepadMap.PAD_ALIASES[button] or button +end + +function GamepadMap.initGamepadMappings() + local js = love and love.joystick + if not (js and js.loadGamepadMappings) then return end + local envConfig = os.getenv and (os.getenv("SDL_GAMECONTROLLERCONFIG") or os.getenv("SDL_GAMECONTROLLERCONFIG_FILE")) + if envConfig and envConfig ~= "" then + pcall(js.loadGamepadMappings, envConfig) + end + local fs = love.filesystem + if fs and fs.getInfo and fs.getInfo("gamecontrollerdb.txt") then + pcall(js.loadGamepadMappings, "gamecontrollerdb.txt") + end +end + -- Raw index -> gamepad button *name* for RomImporter (then NX face swap applies). GamepadMap.RAW_TO_GAMEPAD_BUTTON = { [1] = "a", [2] = "b", [3] = "x", [4] = "y", [5] = "leftshoulder", [6] = "rightshoulder", [7] = "back", [8] = "start", [9] = "back", [10] = "start", [11] = "triggerleft", [12] = "triggerright", + [13] = "leftstick", [14] = "rightstick", + [15] = "guide", } GamepadMap.NX_RAW_TO_GAMEPAD_BUTTON = { diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 56ec8f40..3f1b47d7 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -218,28 +218,22 @@ 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 + local fs = love and love.filesystem + if not (fs and fs.load) then return nil end + local okLoad, chunk = pcall(fs.load, "src/core/WinApi.lua") + if not okLoad or type(chunk) ~= "function" then return nil end + local okRun, loaded = pcall(chunk) + if not okRun or type(loaded) ~= "table" then return nil end + package.loaded["src.core.WinApi"] = loaded + return loaded +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 @@ -248,42 +242,18 @@ end -- ("Failed to initialize filesystem: already initialized") and the relaunch -- crashes. So on an AppImage we relaunch the executable; the fresh process's -- Boot step mounts any downloaded update exactly as a manual relaunch would. --- Android hits the same wall (#575): the vendored love.cpp loops runlove() --- in-process on "restart", and PHYSFS_deinit in the old Filesystem module's --- destructor fails ("files still open") whenever any physfs handle survives --- lua_close, so the second PHYSFS_init throws the same "already initialized" --- and the app dies. There we relaunch through the GameActivity.restartApp --- JNI bridge (love.system.restartApp), which schedules our launch intent --- and kills the process so no native state can leak into the fresh run. --- iOS is the same class of problem with a sharper edge: love.cpp under --- LOVE_IOS forces DONE_RESTART for *every* quit (Apple forbids programmatic --- exit) and comments that leftover threads make that restart unreliable -- --- which our ChipAudio / Fetch / Check workers are. There is no --- restartApp bridge on iOS, so callers that want "back to launcher" must --- use main.lua's in-process returnToLauncher (love.quit aborts the quit); --- HostShell.restart itself refuses quit("restart") and falls back to a --- bare quit() so a mod that still calls restart does not pick the worst --- path on purpose. +-- Android (#575) and iOS restart in-process: love.quit joins every worker +-- first, so no physfs handle outlives lua_close. iOS turns every quit into +-- DONE_RESTART in love.cpp, so a bare quit() is the restart there. function HostShell.restart() if not (love and love.event and love.event.quit) then return end local osName = love.system and love.system.getOS and love.system.getOS() if osName == "Android" then - -- restartApp kills the process on success, so a true return is never - -- observed; false means the bridge could not schedule the relaunch. - -- An older APK whose liblove predates the bridge (love.system.restartApp - -- is nil) has no crash-free in-process restart, so quit to the OS - -- cleanly and let the player relaunch by hand -- worse than restarting, - -- but better than the guaranteed crash of quit("restart") (#575). - if love.system.restartApp and love.system.restartApp() then return end - love.event.quit() + love.event.quit("restart") return end if osName == "iOS" then - -- No process-kill bridge. A bare quit still becomes DONE_RESTART in - -- love.cpp, but quit("restart") is the path that also runs our - -- endProcess worker joins first and then re-enters runlove -- the - -- combination that crashes EXIT GAME. Prefer the softer quit. love.event.quit() return end @@ -324,9 +294,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 @@ -438,10 +409,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 @@ -456,16 +423,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 @@ -490,10 +460,122 @@ function HostShell.haveCurl() return curlAvailable end -local function curlCmd(flags) +function HostShell.winRelative(paths) + local split = {} + for i, p in ipairs(paths) do + local parts = {} + for c in tostring(p):gmatch("[^/\\]+") do parts[#parts + 1] = c end + split[i] = parts + end + if #split == 0 then return nil end + local limit = math.huge + for _, parts in ipairs(split) do limit = math.min(limit, #parts - 1) end + local common = 0 + while common < limit do + local c = split[1][common + 1] + local same = true + for i = 2, #split do + if split[i][common + 1] ~= c then same = false break end + end + if not same then break end + common = common + 1 + end + if common == 0 then return nil end + local cwd = table.concat(split[1], "\\", 1, common) + if tostring(paths[1]):match("^[/\\][/\\]") then cwd = "\\\\" .. cwd end + if cwd:match("^%a:$") then cwd = cwd .. "\\" end + local rels = {} + for i, parts in ipairs(split) do + local rel = table.concat(parts, "\\", common + 1) + if not rel:find("[\128-\255]") then rels[i] = rel end + end + return cwd, rels +end + +local function shellArgs(argv) + local parts = {} + for i, a in ipairs(argv) do + parts[i] = a:match("^[%w%-=,%.]+$") and a or HostShell.quote(a) + end + return table.concat(parts, " ") +end + +local function resolveArgs(argv, relative) + local out = {} + for i, a in ipairs(argv) do + if type(a) == "table" then + out[i] = (a.prefix or "") .. ((relative and a.rel) or a.abs) + else + out[i] = a + end + end + return out +end + +local function pathArgs(argv) + local list = {} + for _, a in ipairs(argv) do + if type(a) == "table" then list[#list + 1] = a end + end + return list +end + +local function runCurlWindows(argv) + local api = winApi() + if not (api and api.run) then return nil end + local specs = pathArgs(argv) + local cwd = nil + if #specs > 0 then + local abs = {} + for i, s in ipairs(specs) do abs[i] = s.abs end + local rels + cwd, rels = HostShell.winRelative(abs) + for i, s in ipairs(specs) do s.rel = cwd and rels[i] or nil end + end + HostShell.releasePointerGrab() + local path = HostShell.resolveCurl() + return api.run(path, resolveArgs(argv, cwd ~= nil), { + cwd = cwd, useApplicationName = false, flags = api.CREATE_NO_WINDOW, + lock = withPopenLock, + }) +end + +local function runCurl(argv) + if HostShell.isWindows() then + local out = runCurlWindows(argv) + if out ~= nil then return true, true, out end + end local path, kind = HostShell.resolveCurl() - return HostShell.quote(path) .. " " .. flags, - { envPrefix = HostShell.curlEnvPrefix(kind) } + local cmd = HostShell.quote(path) .. " " .. shellArgs(resolveArgs(argv, false)) .. " 2>&1" + local pipe = HostShell.popen(cmd, "r", { envPrefix = HostShell.curlEnvPrefix(kind) }) + if not pipe then return false end + local readOk, out = pcall(function() return pipe:read("*a") end) + HostShell.pclose(pipe) + return true, readOk, out +end + +local function curlBase(fail, connectTimeout, maxTime) + return { fail and "-fsSL" or "-sSL", "--proto", "=http,https", + "--proto-redir", "=http,https", "--connect-timeout", tostring(connectTimeout), + "--max-time", tostring(maxTime) } +end + +local function push(list, ...) + for i = 1, select("#", ...) do list[#list + 1] = select(i, ...) end +end + +local function winStage(kind, text) + local fs = love and love.filesystem + if not (HostShell.isWindows() and fs and fs.write and fs.getSaveDirectory) then + return nil + end + local okDir, saveDir = pcall(fs.getSaveDirectory) + if not okDir or type(saveDir) ~= "string" or saveDir == "" then return nil end + local name = ("gen1recomp-%s-%d-%d.tmp"):format(kind, os.time() % 1000000, + math.random(0, 999999)) + local okWrite, wrote = pcall(fs.write, name, text) + if not okWrite or not wrote then return nil end + return saveDir .. "/" .. name, function() pcall(fs.remove, name) end end -- An older mobile build reports nil here and falls back to the "no transport" @@ -556,26 +638,18 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPa if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end userAgent = userAgent or "gen1recomp" if HostShell.haveCurl() then - local head, popts = curlCmd( - ("-fsSL --proto =http,https --proto-redir =http,https " - .. "--connect-timeout 15 --max-time %d ") - :format(tonumber(maxTime) or 300)) - local cmd = head - .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " + local argv = curlBase(true, 15, tonumber(maxTime) or 300) + push(argv, "-H", "User-Agent: " .. userAgent) if accept then - cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " " + push(argv, "-H", "Accept: " .. accept) end if type(etagPath) == "string" and etagPath ~= "" then - cmd = cmd .. "--etag-compare " .. HostShell.quote(etagPath) .. " " - .. "--etag-save " .. HostShell.quote(etagPath) .. " " + local etag = { abs = etagPath } + push(argv, "--etag-compare", etag, "--etag-save", etag) end - cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " " - .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " - .. HostShell.quote(url) .. " 2>&1" - local pipe = HostShell.popen(cmd, "r", popts) - if not pipe then return nil, "could not start download" end - local readOk, out = pcall(function() return pipe:read("*a") end) - HostShell.pclose(pipe) + push(argv, "-o", { abs = absPath }, "-w", HTTP_MARK_FMT, url) + local started, readOk, out = runCurl(argv) + if not started then return nil, "could not start download" end -- The file is still what the caller judges success by (-f writes nothing -- on an HTTP error, and the callers all check the file anyway). The -- status is here purely so the failure can NAME itself: "download failed" @@ -615,21 +689,14 @@ function HostShell.httpGet(url, userAgent, accept, maxTime) -- BODY, and on the two services this talks to that body is the whole -- diagnosis: GitHub's 403 says "API rate limit exceeded for ", which -- tells a user to wait rather than to go hunting for a broken index. - local head, popts = curlCmd( - ("-sSL --proto =http,https --proto-redir =http,https " - .. "--connect-timeout 10 --max-time %d ") - :format(tonumber(maxTime) or 40)) - local cmd = head - .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " + local argv = curlBase(false, 10, tonumber(maxTime) or 40) + push(argv, "-H", "User-Agent: " .. userAgent) if accept then - cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " " + push(argv, "-H", "Accept: " .. accept) end - cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " - .. HostShell.quote(url) .. " 2>&1" - local pipe = HostShell.popen(cmd, "r", popts) - if not pipe then return nil, "could not run curl" end - local readOk, out = pcall(function() return pipe:read("*a") end) - HostShell.pclose(pipe) + push(argv, "-w", HTTP_MARK_FMT, url) + local started, readOk, out = runCurl(argv) + if not started then return nil, "could not run curl" end if not readOk then return nil, fetchError(url, nil, tostring(out)) end @@ -683,8 +750,7 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime) -- working directory, and a game installed under Program Files has no -- writable CWD -- io.open would fail before curl ever runs and postLog -- would silently drop the send. TEMP/TMP are per-user writable on - -- Windows; TMPDIR (with /tmp fallback) covers POSIX. No love.filesystem: - -- the sandbox-era transport stays on plain io/os. + -- Windows; TMPDIR (with /tmp fallback) covers POSIX. local function stagingPath() local dir = os.getenv("TEMP") or os.getenv("TMP") if not dir or dir == "" then dir = os.getenv("TMPDIR") or "/tmp" end @@ -692,20 +758,24 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime) return dir .. sep .. ("gen1recomp-post-%d.tmp"):format( (os.time() % 1000000) * 100 + math.random(0, 99)) end - local bodyPath = stagingPath() - local bodyFile, bodyOpenErr = io.open(bodyPath, "wb") - if not bodyFile then - pcall(os.remove, bodyPath) - return nil, "could not create request body: " .. tostring(bodyOpenErr) - end - local bodyOk, bodyErr = pcall(function() - assert(bodyFile:write(body)) - assert(bodyFile:close()) - end) - if not bodyOk then - pcall(function() bodyFile:close() end) - pcall(os.remove, bodyPath) - return nil, "could not write body: " .. tostring(bodyErr) + local bodyPath, removeBody = winStage("post", body) + if not bodyPath then + bodyPath = stagingPath() + removeBody = function() pcall(os.remove, bodyPath) end + local bodyFile, bodyOpenErr = io.open(bodyPath, "wb") + if not bodyFile then + removeBody() + return nil, "could not create request body: " .. tostring(bodyOpenErr) + end + local bodyOk, bodyErr = pcall(function() + assert(bodyFile:write(body)) + assert(bodyFile:close()) + end) + if not bodyOk then + pcall(function() bodyFile:close() end) + removeBody() + return nil, "could not write body: " .. tostring(bodyErr) + end end -- --data-binary @ keeps the payload out of argv (command-line length @@ -713,28 +783,19 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime) -- newlines. The body is staged above because io.popen cannot be opened -- for both writing and reading. No -f, matching httpGet: the response -- body is discarded anyway, and curl's stderr carries the diagnosis. - local head, popts = curlCmd( - ("-sSL --proto =http,https --proto-redir =http,https " - .. "--connect-timeout 10 --max-time %d ") - :format(tonumber(maxTime) or 40)) - local cmd = head - .. "-X POST " - .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " + local argv = curlBase(false, 10, tonumber(maxTime) or 40) + push(argv, "-X", "POST", "-H", "User-Agent: " .. userAgent) if contentType then - cmd = cmd .. "-H " .. HostShell.quote("Content-Type: " .. contentType) .. " " + push(argv, "-H", "Content-Type: " .. contentType) end - cmd = cmd .. "-H " .. HostShell.quote("Content-Length: " .. tostring(#body)) .. " " - .. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " " - .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " - .. HostShell.quote(url) .. " 2>&1" - local pipe = HostShell.popen(cmd, "r", popts) - if not pipe then - pcall(os.remove, bodyPath) + push(argv, "-H", "Content-Length: " .. tostring(#body), + "--data-binary", { abs = bodyPath, prefix = "@" }, + "-w", HTTP_MARK_FMT, url) + local started, readOk, out = runCurl(argv) + removeBody() + if not started then return nil, "could not run curl" end - local readOk, out = pcall(function() return pipe:read("*a") end) - HostShell.pclose(pipe) - pcall(os.remove, bodyPath) if not readOk then return nil, fetchError(url, nil, tostring(out)) end @@ -836,6 +897,8 @@ local function requestStagingPath(kind) end local function writeStagingFile(kind, text) + local staged, removeStaged = winStage("req-" .. kind, text) + if staged then return staged, removeStaged end local path = requestStagingPath(kind) local file, openErr = io.open(path, "wb") if not file then @@ -850,7 +913,7 @@ local function writeStagingFile(kind, text) pcall(os.remove, path) return nil, "could not write the request " .. kind .. ": " .. tostring(writeErr) end - return path + return path, function() pcall(os.remove, path) end end function HostShell.httpRequest(url, opts) @@ -880,10 +943,10 @@ function HostShell.httpRequest(url, opts) return nil, "no request transport on this platform" end - local bodyPath, stageErr + local bodyPath, removeBody, stageErr if body then - bodyPath, stageErr = writeStagingFile("body", body) - if not bodyPath then return nil, stageErr end + bodyPath, removeBody = writeStagingFile("body", body) + if not bodyPath then return nil, removeBody end end local lines = { "User-Agent: " .. userAgent } @@ -891,39 +954,31 @@ function HostShell.httpRequest(url, opts) if body then lines[#lines + 1] = "Content-Length: " .. tostring(#body) end - local headerPath - headerPath, stageErr = writeStagingFile("head", + local headerPath, removeHeader = writeStagingFile("head", table.concat(lines, "\n") .. "\n") if not headerPath then - if bodyPath then pcall(os.remove, bodyPath) end + stageErr = removeHeader + if removeBody then removeBody() end return nil, stageErr end local function cleanup() - if bodyPath then pcall(os.remove, bodyPath) end - pcall(os.remove, headerPath) + if removeBody then removeBody() end + removeHeader() end - local head, popts = curlCmd( - ("-sSL --proto =http,https --proto-redir =http,https " - .. "--connect-timeout 10 --max-time %d "):format(maxTime)) - local cmd = head - .. "-X " .. HostShell.quote(method) .. " " - .. "-H " .. HostShell.quote("@" .. headerPath) .. " " + local argv = curlBase(false, 10, maxTime) + push(argv, "-X", method, "-H", { abs = headerPath, prefix = "@" }) if body then - cmd = cmd .. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " " + push(argv, "--data-binary", { abs = bodyPath, prefix = "@" }) end - cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " - .. HostShell.quote(url) .. " 2>&1" + push(argv, "-w", HTTP_MARK_FMT, url) - local pipe = HostShell.popen(cmd, "r", popts) - if not pipe then - cleanup() + local started, readOk, out = runCurl(argv) + cleanup() + if not started then return nil, "could not run curl" end - local readOk, out = pcall(function() return pipe:read("*a") end) - HostShell.pclose(pipe) - cleanup() if not readOk then return nil, fetchError(url, nil, tostring(out)) end diff --git a/src/core/Input.lua b/src/core/Input.lua index ce5a7c63..72f56568 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -60,10 +60,24 @@ local STICK_OFF = 0.3 Input.PAD_ACTIONS = { speedUp = true, speedDown = true } +local STICK_DEFAULT_BINDINGS = { + leftstick_up = "up", leftstick_down = "down", + leftstick_left = "left", leftstick_right = "right", + rightstick_up = "up", rightstick_down = "down", + rightstick_left = "left", rightstick_right = "right", + lsup = "up", lsdown = "down", lsleft = "left", lsright = "right", + rsup = "up", rsdown = "down", rsleft = "left", rsright = "right", +} + local POLLABLE_PAD_BUTTONS = { a = true, b = true, x = true, y = true, back = true, guide = true, start = true, leftstick = true, rightstick = true, leftshoulder = true, rightshoulder = true, dpup = true, dpdown = true, dpleft = true, dpright = true, + triggerleft = true, triggerright = true, + leftstick_up = true, leftstick_down = true, leftstick_left = true, leftstick_right = true, + rightstick_up = true, rightstick_down = true, rightstick_left = true, rightstick_right = true, + misc1 = true, paddle1 = true, paddle2 = true, paddle3 = true, paddle4 = true, + touchpad = true, } local HAT_DIRECTIONS = { @@ -101,7 +115,7 @@ function Input:applyBindings(overlay) if type(binding) == "table" then if binding.key then keys[binding.key] = id end if binding.pad then - local button = GamepadMap.TRIGGER_AXES[binding.pad] or binding.pad + local button = GamepadMap.normalizePad and GamepadMap.normalizePad(binding.pad) or GamepadMap.TRIGGER_AXES[binding.pad] or binding.pad pads[button], acts[button] = id, nil end elseif type(binding) == "string" then keys[binding] = id end @@ -117,7 +131,7 @@ function Input:applyBindings(overlay) for _, id in ipairs(ids) do local binding = overlay[id] if Input.PAD_ACTIONS[id] and type(binding) == "table" and binding.pad then - local button = GamepadMap.TRIGGER_AXES[binding.pad] or binding.pad + local button = GamepadMap.normalizePad and GamepadMap.normalizePad(binding.pad) or GamepadMap.TRIGGER_AXES[binding.pad] or binding.pad acts[button], explicit[button], pads[button] = id, true, nil end end @@ -161,7 +175,7 @@ function Input.hotkeyKey(key) end function Input:padAction(button, shoulderGameplay) - button = GamepadMap.TRIGGER_AXES[button] or button + button = GamepadMap.normalizePad and GamepadMap.normalizePad(button) or GamepadMap.TRIGGER_AXES[button] or button if shoulderGameplay and (button == "leftshoulder" or button == "rightshoulder") and not (self.explicitPadActions and self.explicitPadActions[button]) then return nil end return self.padActions and self.padActions[button] or nil @@ -183,6 +197,8 @@ function Input:reset() self.sources = {} self.stickAxis = { x = 0, y = 0 } self.stickDir = nil + self.rightStickAxis = { x = 0, y = 0 } + self.rightStickDir = nil self.hatDirs = {} self.triggerHeld = {} self.captureArmed = false @@ -322,8 +338,32 @@ end local function anyPadDown(pads, button) for k = 1, #pads do local j = pads[k] - local ok, down = pcall(j.isGamepadDown, j, button) - if ok and down then return true end + if button == "triggerleft" or button == "triggerright" then + if j.getGamepadAxis then + local ok, v = pcall(j.getGamepadAxis, j, button) + if ok and type(v) == "number" and v >= GamepadMap.TRIGGER_ON then return true end + end + elseif button == "leftstick_up" or button == "leftstick_down" + or button == "leftstick_left" or button == "leftstick_right" + or button == "rightstick_up" or button == "rightstick_down" + or button == "rightstick_left" or button == "rightstick_right" then + if j.getGamepadAxis then + local axisPrefix = button:sub(1, 9) == "leftstick" and "left" or "right" + local isY = (button:sub(-2) == "up" or button:sub(-4) == "down") + local axis = axisPrefix .. (isY and "y" or "x") + local ok, v = pcall(j.getGamepadAxis, j, axis) + if ok and type(v) == "number" then + if button:sub(-2) == "up" and v <= -STICK_ON then return true + elseif button:sub(-4) == "down" and v >= STICK_ON then return true + elseif button:sub(-4) == "left" and v <= -STICK_ON then return true + elseif button:sub(-5) == "right" and v >= STICK_ON then return true + end + end + end + else + local ok, down = pcall(j.isGamepadDown, j, button) + if ok and down then return true end + end end return false end @@ -488,17 +528,17 @@ function Input:sourceRelease(btn, source) end function Input:gamepadpressed(joystick, button) - button = GamepadMap.TRIGGER_AXES[button] or button + button = GamepadMap.normalizePad and GamepadMap.normalizePad(button) or GamepadMap.TRIGGER_AXES[button] or button if self.padSuppress then self.padSuppress[button] = nil end noteCapture(self, "pad", "pressed", button) - local btn = self.padBindings[button] + local btn = self.padBindings[button] or STICK_DEFAULT_BINDINGS[button] if btn and not self:padAction(button, self.shoulderGameplay) then press(self, btn, "pad:" .. button) end end function Input:gamepadreleased(joystick, button) - button = GamepadMap.TRIGGER_AXES[button] or button + button = GamepadMap.normalizePad and GamepadMap.normalizePad(button) or GamepadMap.TRIGGER_AXES[button] or button noteCapture(self, "pad", "released", button) local source = "pad:" .. button for btn, sources in pairs(self.sources) do @@ -550,29 +590,27 @@ function Input:triggerAxis(axis, value) return name end --- left stick treated as a continuous held direction, same 4-way rule as --- the touch swipe d-pad: whichever axis has the larger magnitude wins. -function Input:gamepadaxis(joystick, axis, value) - local trigger, phase = self:triggerAxis(axis, value) - if trigger then - if phase == "pressed" then - self:gamepadpressed(joystick, trigger) - elseif phase == "released" then - self:gamepadreleased(joystick, trigger) - end - return - end - if axis == "leftx" then - self.stickAxis.x = value - elseif axis == "lefty" then - self.stickAxis.y = value +function Input:stickAxisEvents(axis, value) + local isLeft = (axis == "leftx" or axis == "lefty") + local isRight = (axis == "rightx" or axis == "righty") + if not isLeft and not isRight then return nil end + + local prefix = isLeft and "leftstick" or "rightstick" + local stateKey = isLeft and "stickAxis" or "rightStickAxis" + local dirKey = isLeft and "stickDir" or "rightStickDir" + + self[stateKey] = self[stateKey] or { x = 0, y = 0 } + if axis:sub(-1) == "x" then + self[stateKey].x = value else - return + self[stateKey].y = value end - local x, y = self.stickAxis.x, self.stickAxis.y + local x, y = self[stateKey].x, self[stateKey].y local ax, ay = math.abs(x), math.abs(y) - local newDir = self.stickDir + local currentDir = self[dirKey] + local newDir = currentDir + if ax > STICK_ON or ay > STICK_ON then if ax >= ay then newDir = x > 0 and "right" or "left" @@ -583,14 +621,40 @@ function Input:gamepadaxis(joystick, axis, value) newDir = nil end - if newDir ~= self.stickDir then - if self.stickDir then - release(self, self.stickDir, "stick") + if newDir == currentDir then return nil end + + self[dirKey] = newDir + local events = {} + if currentDir then + events[#events + 1] = { phase = "released", button = prefix .. "_" .. currentDir } + end + if newDir then + events[#events + 1] = { phase = "pressed", button = prefix .. "_" .. newDir } + end + return events +end + +function Input:gamepadaxis(joystick, axis, value) + local trigger, phase = self:triggerAxis(axis, value) + if trigger then + if phase == "pressed" then + self:gamepadpressed(joystick, trigger) + elseif phase == "released" then + self:gamepadreleased(joystick, trigger) end - if newDir then - press(self, newDir, "stick") + return + end + local events = self:stickAxisEvents(axis, value) + if events then + for i = 1, #events do + local ev = events[i] + if ev.phase == "pressed" then + self:gamepadpressed(joystick, ev.button) + elseif ev.phase == "released" then + self:gamepadreleased(joystick, ev.button) + end end - self.stickDir = newDir + return end end @@ -662,7 +726,7 @@ function Input:reconcile() end end if j.getGamepadAxis then - local axes = { "leftx", "lefty", "triggerleft", "triggerright" } + local axes = { "leftx", "lefty", "rightx", "righty", "triggerleft", "triggerright" } for _, axis in ipairs(axes) do local ok2, v = pcall(j.getGamepadAxis, j, axis) if ok2 and type(v) == "number" then self:gamepadaxis(j, axis, v) end diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 2d52c3f5..acf63848 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,156 @@ 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 + +local function playthroughIdTaken(fs, opts, key, slotId, id) + if type(opts.playthroughIds) == "table" then + for scope, ids in pairs(opts.playthroughIds) do + if type(ids) == "table" then + for sid, pid in pairs(ids) do + if pid == id and not (scope == key and sid == slotId) then return true end + end + end + end + end + local reg = registryOf(opts, key) + for _, other in ipairs(reg and type(reg.list) == "table" and reg.list or {}) do + if other ~= slotId then + local body = readSlotSourceIn(key, other, fs) + local save = body and SaveSerializer.decode(body) + local meta = type(save) == "table" and save.meta + if type(meta) == "table" and meta.playthroughId == id then return true end + end + end + return false +end + +function SaveData.claimImportPlaythroughId(version, slotId, save) + version = version or GameVersion.get() + if not knownVersion(version) or type(slotId) ~= "string" then return nil end + local meta = type(save) == "table" and save.meta + local id = type(meta) == "table" and meta.playthroughId + if type(id) ~= "string" or id == "" then return nil end + local fs = persistFs(nil) + ensureSlots(version, fs) + local opts = SaveData.loadOptions(fs) + if playthroughIdTaken(fs, opts, version, slotId, id) then + local newId = SaveData.newPlaythroughId() + if id:match("^[%w_-]+$") and version:match("^[%w_-]+$") then + copyTree(fs, "mod_storage/" .. version .. "/" .. id, + "mod_storage/" .. version .. "/" .. newId) + end + id = newId + meta.playthroughId = id + end + opts.playthroughIds = type(opts.playthroughIds) == "table" and opts.playthroughIds or {} + opts.playthroughIds[version] = opts.playthroughIds[version] or {} + if opts.playthroughIds[version][slotId] ~= id then + opts.playthroughIds[version][slotId] = id + SaveData.saveOptions(opts, fs) + end + return id +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..9dbd6dbe 100644 --- a/src/core/Timing.lua +++ b/src/core/Timing.lua @@ -138,66 +138,78 @@ 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 = 6144 -- engine/gfx/hp_bar.asm:81-120, 244-269 +Timing.VBLANK_CYCLES = 2196 -- home/vblank.asm:1 +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 --- (engine/gfx/hp_bar.asm:42-45); an empty bar is 0. -function Timing.hpBarPixels(hp, maxHP) +-- engine/gfx/hp_bar.asm:6-45 +function Timing.hpBarLength(hp, maxHP) if not maxHP or maxHP <= 0 then return 0 end - if hp <= 0 then return 0 end - local px = math.floor(hp * Timing.HP_BAR_PIXELS / maxHP) + local px + if maxHP >= 256 then + -- engine/gfx/hp_bar.asm:19-34 + px = math.floor(math.floor(hp * Timing.HP_BAR_PIXELS / 4) + / math.floor(maxHP / 4)) + else + px = math.floor(hp * Timing.HP_BAR_PIXELS / maxHP) + end if px < 1 then px = 1 end 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.hpBarPixels(hp, maxHP) + if not maxHP or maxHP <= 0 then return 0 end + if hp <= 0 then return 0 end + return Timing.hpBarLength(hp, maxHP) 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) +function Timing.hpBarCpuLag(cycles, roundUp) + if not cycles or cycles <= 0 then return 0, false end + local budget = Timing.FRAME_CYCLES - Timing.VBLANK_CYCLES + local whole = math.floor(cycles / budget) + if cycles % budget ~= 0 then return whole, false end + if roundUp == false then return whole - 1, true end + return whole, true +end + +-- engine/gfx/hp_bar.asm:121-135 +function Timing.hpDrainClosingFrames(playerSide, cycles, roundUp) 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, roundUp) 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, roundUp = 0, 0, true 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.hpBarLength(nextHP, maxHP) + - Timing.hpBarLength(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 + local lag, tie = Timing.hpBarCpuLag(cycles, roundUp) + if tie then roundUp = not roundUp end + total = total + pixels * Timing.HP_BAR_PIXEL_STEP + lag + 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, roundUp) end return Timing diff --git a/src/core/WinApi.lua b/src/core/WinApi.lua new file mode 100644 index 00000000..e37fa350 --- /dev/null +++ b/src/core/WinApi.lua @@ -0,0 +1,235 @@ +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);", + -- SECURITY_ATTRIBUTES: 24 bytes on x64, 12 on x86 + [[typedef struct { + uint32_t nLength; void *lpSecurityDescriptor; int bInheritHandle; + } PP_SECURITY_ATTRIBUTES;]], + "int CreatePipe(void **rd, void **wr, PP_SECURITY_ATTRIBUTES *sa, uint32_t size);", + "int SetHandleInformation(void *h, uint32_t mask, uint32_t flags);", + "int ReadFile(void *h, void *buf, uint32_t n, uint32_t *got, void *ov);", + "uint32_t WaitForSingleObject(void *h, uint32_t ms);", + "int GetExitCodeProcess(void *h, uint32_t *code);", +} + +local HANDLE_FLAG_INHERIT = 0x00000001 +local STARTF_USESTDHANDLES = 0x00000100 +local INFINITE = 0xFFFFFFFF + +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 + +function WinApi.run(exe, args, opts) + local ffi = lib() + if not ffi or type(exe) ~= "string" or exe == "" then return nil end + opts = opts or {} + local C = ffi.C + local ok, out, code = pcall(function() + local cmd = WinApi.wide(opts.commandLine or WinApi.commandLine(exe, args)) + if not cmd then return nil end + local app = nil + if opts.useApplicationName ~= false then + app = WinApi.wide(exe) + if not app then return nil end + end + local cwd = nil + if type(opts.cwd) == "string" and opts.cwd ~= "" then + cwd = WinApi.wide(opts.cwd) + if not cwd then return nil end + end + local sa = ffi.new("PP_SECURITY_ATTRIBUTES") + sa.nLength = ffi.sizeof("PP_SECURITY_ATTRIBUTES") + sa.bInheritHandle = 1 + local rd, wr = ffi.new("void *[1]"), ffi.new("void *[1]") + local pi = ffi.new("PP_PROCESS_INFORMATION") + local flags = opts.flags or WinApi.CREATE_NO_WINDOW + local started, attempted = false, false + local function launch() + if attempted then return end + attempted = true + pcall(function() + if C.CreatePipe(rd, wr, sa, 0) == 0 then return end + C.SetHandleInformation(rd[0], HANDLE_FLAG_INHERIT, 0) + local si = ffi.new("PP_STARTUPINFOW") + si.cb = ffi.sizeof("PP_STARTUPINFOW") + si.dwFlags = STARTF_USESTDHANDLES + si.hStdOutput = wr[0] + si.hStdError = wr[0] + started = C.CreateProcessW(app, cmd, nil, nil, 1, flags, nil, cwd, si, pi) ~= 0 + C.CloseHandle(wr[0]) + if not started then C.CloseHandle(rd[0]) end + end) + end + if type(opts.lock) == "function" then opts.lock(launch) else launch() end + if not started then return nil end + C.CloseHandle(pi.hThread) + local chunks = {} + local size = 65536 + local buf = ffi.new("uint8_t[?]", size) + local got = ffi.new("uint32_t[1]") + while C.ReadFile(rd[0], buf, size, got, nil) ~= 0 and got[0] > 0 do + chunks[#chunks + 1] = ffi.string(buf, got[0]) + end + C.CloseHandle(rd[0]) + C.WaitForSingleObject(pi.hProcess, INFINITE) + local exit = ffi.new("uint32_t[1]") + local status = nil + if C.GetExitCodeProcess(pi.hProcess, exit) ~= 0 then status = tonumber(exit[0]) end + C.CloseHandle(pi.hProcess) + return table.concat(chunks), status + end) + if ok and type(out) == "string" then return out, code end + return nil +end + +return WinApi diff --git a/src/core/game3/rs/easy_chat_contracts.lua b/src/core/game3/rs/easy_chat_contracts.lua index f2e75383..8b238795 100644 --- a/src/core/game3/rs/easy_chat_contracts.lua +++ b/src/core/game3/rs/easy_chat_contracts.lua @@ -231,7 +231,7 @@ function M.commit(token, words) elseif token.type == 6 then writeBard(token, words) elseif token.type == 10 then token.session.gabbyAndTyData.quote[0] = words[1] elseif token.type == 9 then - out.stringVar2 = Easy.rawWord(words[1]) .. " " .. Easy.rawWord(words[2]) + out.stringVar2 = Easy.word(words[1]) .. " " .. Easy.word(words[2]) out.input = require("src.core.game3.rs.dewford_trend").trySetTrendyPhrase(words, token.session) and 1 or 0 elseif token.type ~= 13 then writeTv(token, words) end if token.type == 0 then out.input = M.passphrase(words) and 1 or 0 end diff --git a/src/core/game3/rs/tv_playback.lua b/src/core/game3/rs/tv_playback.lua index 081e6517..9dfef818 100644 --- a/src/core/game3/rs/tv_playback.lua +++ b/src/core/game3/rs/tv_playback.lua @@ -118,7 +118,7 @@ function M.word(id) local E = require("src.core.game3.easy_chat_text") local group = E.group(math.floor(value / 512) % 128) if group then - for _, word in ipairs(group.words) do if word.id == value then return word.text end end + for _, word in ipairs(group.words) do if word.id == value then return E.word(value) end end end return romText("gOtherText_ThreeQuestions") end, id) diff --git a/src/core/game3/scripting/natives_rs_tv_routes.lua b/src/core/game3/scripting/natives_rs_tv_routes.lua index 196f245b..693eedfa 100644 --- a/src/core/game3/scripting/natives_rs_tv_routes.lua +++ b/src/core/game3/scripting/natives_rs_tv_routes.lua @@ -79,7 +79,12 @@ M.BY_NAME.GabbyAndTyGetLastQuote = function(ctx, adapters) local session, g = R.session(), gabby() local quote = n(g.quote[0]) % 65536 if quote == 65535 then return false, 0 end - stringVar(ctx, adapters, 1, session, Q.encode(session, Playback.word(quote))) + local word = Playback.word(quote) + local bytes = Q.encode(session, word) + if Playback.displayBytes(session, bytes) ~= word then + bytes = Q.encode(session, require("src.core.game3.easy_chat_text").rawWord(quote)) + end + stringVar(ctx, adapters, 1, session, bytes) g.quote[0] = 65535 return false, 1 end diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index aeb0969b..3222801f 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -15,9 +15,10 @@ CacheContract.VERSION_FORMAT = { -- export re-anchoring a save onto another map writes back into -- wCurMapObjectEventsPointer. A v10 cache has no address to write, and -- such an export is refused until the ROM re-imports. - gold = "rom-cache-v13:", - silver = "rom-cache-v13:", - crystal = "rom-cache-v13-crystal6:", + -- gfx/sgb/predef.pal:28 + gold = "rom-cache-v14:", + silver = "rom-cache-v14:", + crystal = "rom-cache-v14-crystal6:", -- engine/overworld/map_sprites.asm:181, engine/battle/animations.asm:2600 -- data/pikachu/pikachu_pic_animation.asm:340 yellow = "rom-cache-v12-yellow2:", @@ -135,6 +136,8 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = { "data/generated/tilesets.lua", "data/generated/audio.lua", "data/generated/marts.lua", + -- engine/gfx/cgb_layouts.asm:495 + "data/generated/diploma.lua", "assets/generated/fonts/font.png", "assets/generated/fonts/frames.png", "assets/generated/title/pokemon_logo.png", @@ -184,6 +187,8 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = { "data/generated/oak_speech.lua", "data/generated/title.lua", "data/generated/intro.lua", + -- engine/gfx/cgb_layouts.asm:517 + "data/generated/diploma.lua", "assets/generated/fonts/font.png", "assets/generated/fonts/frames.png", -- ../pokecrystal/gfx/font.asm:60 diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index e7ab2d2e..d1898a25 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -420,12 +420,10 @@ local function discoverModSchemas(opts) local flag = require("src.core.SaveData").modEnabled(opts, m.id) local enabled = flag == true or (flag == nil and not m.experimental) if enabled and not SaveData.isSafeMode(opts) then - local chunk = fs.load(path .. "/" .. m.options_schema) - if chunk then - local okR, schema = pcall(chunk) - if okR and type(schema) == "table" then - out[#out + 1] = { id = m.id, name = m.name or m.id, schema = schema } - end + local rel = path .. "/" .. m.options_schema + local schema = require("src.mods.Sandbox").evalData(fs.read(rel), "@" .. rel) + if schema then + out[#out + 1] = { id = m.id, name = m.name or m.id, schema = schema } end end end 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/RomArchive.lua b/src/import/RomArchive.lua new file mode 100644 index 00000000..a9a326ee --- /dev/null +++ b/src/import/RomArchive.lua @@ -0,0 +1,179 @@ +local RomArchive = {} + +RomArchive.MAX_ARCHIVE_BYTES = 64 * 1024 * 1024 + +local capsCache = {} + +function RomArchive._resetForTests() + capsCache = {} +end + +function RomArchive.kind(data) + if type(data) ~= "string" or #data < 8 then return nil end + local head = data:sub(1, 4) + if head == "PK\3\4" or head == "PK\5\6" then return "zip" end + if data:sub(1, 6) == "7z\188\175\39\28" then return "7z" end + return nil +end + +local function u16(n) + return string.char(n % 256, math.floor(n / 256) % 256) +end + +local function u32(n) + return string.char(n % 256, math.floor(n / 256) % 256, + math.floor(n / 65536) % 256, math.floor(n / 16777216) % 256) +end + +function RomArchive._zipProbeBytes() + local name, body = "probe", "ok" + local localHeader = "PK\3\4" .. u16(20) .. u16(0) .. u16(0) .. u16(0) + .. u16(0) .. u32(2044517703) .. u32(#body) .. u32(#body) .. u16(#name) + .. u16(0) .. name .. body + local central = "PK\1\2" .. u16(20) .. u16(20) .. u16(0) .. u16(0) + .. u16(0) .. u16(0) .. u32(2044517703) .. u32(#body) .. u32(#body) + .. u16(#name) .. u16(0) .. u16(0) .. u16(0) .. u16(0) .. u32(0) + .. u32(0) .. name + local eocd = "PK\5\6" .. u16(0) .. u16(0) .. u16(1) .. u16(1) + .. u32(#central) .. u32(#localHeader) .. u16(0) + return localHeader .. central .. eocd +end + +local SEVENZ_PROBE_HEX = + "377abcaf271c00044e999ef910000000000000005200000000000000f427ec00" + .. "67656e317265636f6d702d70726f62650104060001091000070b0100010100" + .. "0c1000080a01451423990000050111170070002e0072006f006d0070007200" + .. "6f00620065000000190400000000140a0100332a3077cb55dd011506010020" + .. "80a4810000" + +local function sevenZProbeBytes() + return (SEVENZ_PROBE_HEX:gsub("%x%x", function(h) + return string.char(tonumber(h, 16)) + end)) +end + +local function probeFormat(fs, ext, bytes) + local okFd, fd = pcall(fs.newFileData, bytes, "rom_probe." .. ext) + if not okFd or not fd then return false end + local point = "_rom_probe_" .. ext + local okMount, mounted = pcall(fs.mount, fd, point) + local found = false + if okMount and mounted then + local okList, items = pcall(fs.getDirectoryItems, point) + found = okList and type(items) == "table" and #items > 0 + end + pcall(fs.unmount, fd) + return found and true or false +end + +function RomArchive.capabilities(fs) + fs = fs or (love and love.filesystem) + if type(fs) ~= "table" or not (fs.newFileData and fs.mount + and fs.getDirectoryItems and fs.unmount) then + return { zip = false, z7 = false } + end + local cached = capsCache[fs] + if cached then return cached end + local caps = { + zip = probeFormat(fs, "zip", RomArchive._zipProbeBytes()), + z7 = probeFormat(fs, "7z", sevenZProbeBytes()), + } + capsCache[fs] = caps + return caps +end + +function RomArchive.unwrap(data, displayName, opts) + opts = opts or {} + local fs = opts.fs or (love and love.filesystem) + local kind = RomArchive.kind(data) + if not kind then return nil, "not a .zip or .7z file" end + local caps = RomArchive.capabilities(fs) + if (kind == "zip" and not caps.zip) or (kind == "7z" and not caps.z7) then + return nil, ("this platform cannot open .%s files; drop the raw " + .. ".gb/.gbc/.gba instead"):format(kind) + end + + local isRomName = opts.isRomName or function(name) + return type(name) == "string" and name:lower():match("%.gb[ac]?$") ~= nil + end + local acceptedSize = opts.acceptedSize or function(n) + return n == 1048576 or n == 2097152 or n == 16777216 + end + + local mount = "_rom_pick_" .. kind + local fd, staged + if fs.newFileData then + local okFd, made = pcall(fs.newFileData, data, "rom_pick." .. kind) + if okFd and made and fs.mount(made, mount) then fd = made end + end + if not fd then + staged = "rom_pick_tmp." .. kind + local wrote = fs.write and fs.write(staged, data) + if not wrote or not fs.mount(staged, mount) then + if fs.remove then pcall(fs.remove, staged) end + return nil, "the archive could not be opened" + end + end + local function cleanup() + pcall(fs.unmount, fd or staged) + if staged and fs.remove then pcall(fs.remove, staged) end + end + + local candidates = {} + local function scan(dir) + local okItems, items = pcall(fs.getDirectoryItems, dir) + if not okItems or type(items) ~= "table" then return end + for _, item in ipairs(items) do + if item ~= "." and item ~= ".." then + local path = dir .. "/" .. item + local okInfo, info = pcall(fs.getInfo, path) + if okInfo and info then + if info.type == "directory" then + scan(path) + elseif item:sub(1, 1) ~= "." and not dir:find("__MACOSX", 1, true) + and isRomName(item) and type(info.size) == "number" then + candidates[#candidates + 1] = + { path = path, name = item, size = info.size } + end + end + end + end + end + scan(mount) + + if #candidates == 0 then + cleanup() + return nil, ("no .gb/.gbc/.gba file found inside %s") + :format(tostring(displayName or "the archive")) + end + table.sort(candidates, function(a, b) return a.path < b.path end) + + local fallback, chosen + for _, cand in ipairs(candidates) do + if acceptedSize(cand.size) then + local okRead, bytes = pcall(fs.read, cand.path) + if okRead and type(bytes) == "string" and #bytes == cand.size then + fallback = fallback or { bytes = bytes, name = cand.name } + if opts.prefer then + local okPref, hit = pcall(opts.prefer, bytes) + if okPref and hit then + chosen = { bytes = bytes, name = cand.name } + break + end + end + end + end + end + chosen = chosen or fallback + cleanup() + if chosen then return chosen.bytes, chosen.name end + + local sizes = {} + for _, cand in ipairs(candidates) do + sizes[#sizes + 1] = string.format("%s (%d bytes)", cand.name, cand.size) + end + return nil, ("no 1 MiB / 2 MiB / 16 MiB ROM inside %s: %s") + :format(tostring(displayName or "the archive"), table.concat(sizes, ", ")) +end + +return RomArchive diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 94d449c2..1126dcc6 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -2598,16 +2598,11 @@ end -- Game Boy Printer's second sheet and is not read -- here, the same way PrintDiploma stays stubbed. -- DiplomaPalettes gfx/diploma/diploma.pal, eight four-colour sets. --- _CGB_Diploma (engine/gfx/cgb_layouts.asm) loads all --- eight and then WipeAttrmap zeroes the attrmap, so --- set 0 is the one the whole screen actually draws --- through; the rest are written out because the block --- is one table. --- --- Tolerated rather than required, like credits: a manifest built before these --- three symbols were listed still imports, it just leaves src/ui/gen2/ --- Diploma.lua on its placeholder frame. +-- bgPalette gfx/sgb/predef.pal:28 PREDEFPAL_DIPLOMA +-- engine/gfx/cgb_layouts.asm:495 local DIPLOMA_TILES = 112 +-- constants/scgb_constants.asm:71 +local PREDEFPAL_DIPLOMA = 27 local DIPLOMA_SHEET_TILES = 16 -- tiles per row of the sheet local DIPLOMA_SCREEN_W, DIPLOMA_SCREEN_H = 20, 18 local DIPLOMA_PALETTE_SETS = 8 @@ -2652,6 +2647,7 @@ function RomExtractorGen2:extractDiploma() end data.palettes = palettes end + data.bgPalette = self:predefPal(PREDEFPAL_DIPLOMA) self:tick("Diploma", 3, 3) self:write("diploma", data) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 052857db..ca4de77c 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1240,19 +1240,37 @@ local function consumePick(self, name, safName, ok) self.pickSkip[name] = true end +local function romPickerExts() + local exts = { "gb", "gbc", "gba" } + local ok, RomArchive = pcall(require, "src.import.RomArchive") + if ok then + local caps = RomArchive.capabilities() + if caps.zip then exts[#exts + 1] = "zip" end + if caps.z7 then exts[#exts + 1] = "7z" end + end + return exts +end + local function chooseRom(promptName) promptName = promptName or "Pokemon" local prompt = shellSafe("Choose your " .. promptName .. " ROM") local platform = love.system.getOS() + local quoted, globs, semis = {}, {}, {} + for _, ext in ipairs(romPickerExts()) do + quoted[#quoted + 1] = ('"%s"'):format(ext) + globs[#globs + 1] = "*." .. ext + semis[#semis + 1] = "*." .. ext + end if platform == "OS X" then return commandOutput( - ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"gb", "gbc", "gba"})' 2>/dev/null]]) - :format(prompt)) + ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {%s})' 2>/dev/null]]) + :format(prompt, table.concat(quoted, ", "))) elseif platform == "Windows" then local script = table.concat({ HostPicker.WIN_OPEN_DIALOG, "$d.Title='" .. prompt .. "';", - "$d.Filter='Game Boy / GBA ROM (*.gb;*.gbc;*.gba)|*.gb;*.gbc;*.gba|All files (*.*)|*.*';", + "$d.Filter='Game Boy / GBA ROM (" .. table.concat(semis, ";") .. ")|" + .. table.concat(semis, ";") .. "|All files (*.*)|*.*';", -- copy the pick to a plain-ASCII temp name and answer with that: -- the console's OEM codepage would mangle a non-ASCII path -- (Pokémon -> Pok\x82mon) and io.open on Windows needs ANSI bytes, @@ -1268,11 +1286,12 @@ local function chooseRom(promptName) 'powershell -NoProfile -STA -Command "' .. script .. '"') elseif platform == "Linux" then local path = commandOutput( - ([[zenity --file-selection --title="%s" --file-filter="Game Boy / GBA ROM | *.gb *.gbc *.gba" 2>/dev/null]]) - :format(prompt)) + ([[zenity --file-selection --title="%s" --file-filter="Game Boy / GBA ROM | %s" 2>/dev/null]]) + :format(prompt, table.concat(globs, " "))) if path then return path end return commandOutput( - [[kdialog --getopenfilename "$HOME" "*.gb *.gbc *.gba|Game Boy / GBA ROM" 2>/dev/null]]) + ([[kdialog --getopenfilename "$HOME" "%s|Game Boy / GBA ROM" 2>/dev/null]]) + :format(table.concat(globs, " "))) end return nil end @@ -1400,7 +1419,7 @@ local function chooseSav() local platform = love.system.getOS() if platform == "OS X" then return commandOutput( - ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"sav", "lua"})' 2>/dev/null]]) + ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"sav", "srm", "lua"})' 2>/dev/null]]) :format(prompt)) elseif platform == "Windows" then local script = table.concat({ @@ -1420,11 +1439,11 @@ local function chooseSav() 'powershell -NoProfile -STA -Command "' .. script .. '"') elseif platform == "Linux" then local path = commandOutput( - ([[zenity --file-selection --title="%s" --file-filter="Save file | *.sav *.lua" 2>/dev/null]]) + ([[zenity --file-selection --title="%s" --file-filter="Save file | *.sav *.srm *.lua" 2>/dev/null]]) :format(prompt)) if path then return path end return commandOutput( - [[kdialog --getopenfilename "$HOME" "*.sav *.lua|Save file" 2>/dev/null]]) + [[kdialog --getopenfilename "$HOME" "*.sav *.srm *.lua|Save file" 2>/dev/null]]) end return nil end @@ -1862,11 +1881,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 +1905,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 +1963,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 +1978,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) @@ -2064,6 +2099,29 @@ function RomImporter:startData(data, displayName, sourcePath) self:setError("The selected file could not be read.") return end + local RomArchive = require("src.import.RomArchive") + local kind = RomArchive.kind(data) + if kind then + if #data > RomArchive.MAX_ARCHIVE_BYTES then + self:setError(("That .%s is %.1f MiB; a ROM archive only has to hold " + .. "one small cart. Drop the raw .gb/.gbc/.gba instead.") + :format(kind, #data / 1024 / 1024)) + return + end + local bytes, entry = RomArchive.unwrap(data, displayName, { + isRomName = isRomFilename, + acceptedSize = isAcceptedRomSize, + prefer = function(candidate) + return self:_versionForSha1(sha1(candidate)) ~= nil + end, + }) + if not bytes then + self:setError(entry) + return + end + data = bytes + displayName = displayName and (displayName .. " / " .. entry) or entry + end if not isAcceptedRomSize(#data) then self:setError(("Expected a 1 MiB Game Boy ROM (%s), a " .. "2 MiB Game Boy Color ROM (%s), or a 16 MiB Game Boy Advance ROM (%s); " @@ -2505,6 +2563,21 @@ function RomImporter:startPath(path) require("src.import.RomSources").absolute(path)) end +function RomImporter:_zipHoldsRom(data) + local ok, RomArchive = pcall(require, "src.import.RomArchive") + if not ok then return false end + if RomArchive.kind(data) ~= "zip" then return false end + if not RomArchive.capabilities().zip then return false end + local bytes = RomArchive.unwrap(data, nil, { + isRomName = isRomFilename, + acceptedSize = isAcceptedRomSize, + prefer = function(candidate) + return self:_versionForSha1(sha1(candidate)) ~= nil + end, + }) + return bytes ~= nil +end + function RomImporter:filedropped(file) if self.workState == "working" then return end -- A dropped .zip is a mod archive: hand it straight to the mods installer @@ -2524,9 +2597,21 @@ function RomImporter:filedropped(file) return end if name:lower():match("%.zip$") then - -- On the SKINS tab a zip is a skin; everywhere else it is a mod archive. + -- On the SKINS tab a zip is a skin; elsewhere a cart inside wins, else + -- it stays a mod archive. if self.tab == "skins" then self:_installSkinZip(file) + elseif GameVersion.VERSIONS[self.tab] then + local data, readError = readDroppedFile(file) + if not data then + self:setError("Could not read the dropped file: " .. tostring(readError)) + return + end + if self:_zipHoldsRom(data) then + self:startData(data, name, name) + return + end + self:_installMod(file) else self:_installMod(file) end @@ -2705,7 +2790,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 +3320,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, @@ -3250,7 +3341,7 @@ function RomImporter:chooseSaveImport(version) if okKit and Kit.FileBrowser then self._padCursorActive = false Kit.FileBrowser.open({ - title = "Select Save (.sav / .lua)", + title = "Select Save (.sav / .srm / .lua)", mode = "save", onSelect = function(pickedPath) self:_importSave(version, pickedPath) @@ -3269,7 +3360,7 @@ function RomImporter:chooseSaveImport(version) if okKit and Kit.FileBrowser then self._padCursorActive = false Kit.FileBrowser.open({ - title = "Select Save (.sav / .lua)", + title = "Select Save (.sav / .srm / .lua)", mode = "save", onSelect = function(pickedPath) self:_importSave(version, pickedPath) @@ -3416,9 +3507,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 +5806,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 +7018,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 +8831,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/import/SaveFileIO.lua b/src/import/SaveFileIO.lua index 84e1422b..a1e490fa 100644 --- a/src/import/SaveFileIO.lua +++ b/src/import/SaveFileIO.lua @@ -18,6 +18,7 @@ local SaveConvert = require("src.save_convert.SaveConvert") local SaveData = require("src.core.SaveData") local GameVersion = require("src.core.GameVersion") local SaveSerializer = require("src.core.SaveSerializer") +local SrmDecompress = require("src.import.SrmDecompress") local SaveFileIO = {} @@ -102,8 +103,18 @@ local SAVE_SIZE = SaveConvert.SAVE_SIZE -- * a raw 32768-byte string (the tests and the in-memory path) used as-is; -- * any other string treated as an absolute picker path opened with io.open. -- A picker path is never 32768 bytes long, so the length test disambiguates it --- from a raw image cleanly. Returns bytes, or nil + an error string. +-- from a raw image cleanly. RetroArch RZIP-compressed images are unwrapped +-- here; size policy stays in importToSlot. Returns bytes, or nil + an error. local function readSource(source) + local function handleBytes(data) + if type(data) ~= "string" then return nil, "the save file was empty" end + if SrmDecompress.isCompressed(data) then + local d, derr = SrmDecompress.decompress(data) + if not d then return nil, "could not decompress the save file: " .. tostring(derr) end + return d + end + return data + end local t = type(source) if t == "table" or t == "userdata" then if type(source.read) ~= "function" then @@ -114,26 +125,25 @@ local function readSource(source) local data, readErr = source:read(source:getSize()) source:close() if not data then return nil, "could not read the dropped file: " .. tostring(readErr) end - return data + return handleBytes(data) end if t ~= "string" then return nil, "no save file was provided" end - if #source == SAVE_SIZE then + if #source == SAVE_SIZE and not SrmDecompress.isCompressed(source) then return source end local f, openErr = io.open(source, "rb") if f then local data = f:read("*a") f:close() - if type(data) ~= "string" then return nil, "the save file was empty" end - return data + return handleBytes(data) end -- Android SAF drops (picked_save.sav) and USB copies land in the LOVE save -- directory; io.open cannot see them, so fall back to love.filesystem. if love and love.filesystem and love.filesystem.read then local data = love.filesystem.read(source) - if type(data) == "string" then return data end + if type(data) == "string" then return handleBytes(data) end end return nil, "could not read the save file: " .. tostring(openErr) end @@ -174,6 +184,7 @@ local function importLuaSlot(bytes, version) end local slotId = SaveData.createSlot(version) if not slotId then return false, "this game has no save slots to import into" end + SaveData.claimImportPlaythroughId(version, slotId, save) local ok, writeErr = SaveData.writeSlot(version, slotId, save) if not ok then SaveData.deleteSlot(version, slotId) @@ -194,6 +205,7 @@ local function importGen3Cart(bytes, version) save.meta = SaveData.buildMeta(nil, save.meta) local slotId = SaveData.createSlot(version) if not slotId then return false, "this game has no save slots to import into" end + SaveData.claimImportPlaythroughId(version, slotId, save) local ok, writeErr = SaveData.writeSlot(version, slotId, save) if not ok then SaveData.deleteSlot(version, slotId) @@ -265,8 +277,10 @@ function SaveFileIO.importToSlot(source, version, force) save.meta = SaveData.buildMeta(nil, save.meta) local slotId = SaveData.createSlot(version) if not slotId then return false, "this game has no save slots to import into" end + SaveData.claimImportPlaythroughId(version, slotId, save) local ok, writeErr = SaveData.writeSlot(version, slotId, save) if not ok then + SaveData.deleteSlot(version, slotId) return false, "could not write the imported save: " .. tostring(writeErr) end SaveData.setActiveSlot(version, slotId) diff --git a/src/import/SrmDecompress.lua b/src/import/SrmDecompress.lua new file mode 100644 index 00000000..b80ab675 --- /dev/null +++ b/src/import/SrmDecompress.lua @@ -0,0 +1,53 @@ + +local SrmDecompress = {} + +local function read_le_u32(b, off) + return (string.byte(b, off) or 0) + + (string.byte(b, off+1) or 0) * 256 + + (string.byte(b, off+2) or 0) * 65536 + + (string.byte(b, off+3) or 0) * 16777216 +end + +local function read_le_u64(b, off) + local low = read_le_u32(b, off) + local high = read_le_u32(b, off+4) + return low + high * 4294967296 +end + +local function has_rzip_header(bytes) + if not bytes or #bytes < 8 then return false end + local b1,b2,b3,b4,b5 = string.byte(bytes,1),string.byte(bytes,2),string.byte(bytes,3),string.byte(bytes,4),string.byte(bytes,5) + if b1==35 and b2==82 and b3==90 and b4==73 and b5==80 then return true end + return false +end + +function SrmDecompress.isCompressed(bytes) + return has_rzip_header(bytes) +end + +function SrmDecompress.decompress(bytes) + if not has_rzip_header(bytes) then return nil, "not compressed SRM" end + if #bytes < 20 then return nil, "truncated SRM header" end + local chunk_size = read_le_u32(bytes, 9) + local total_size = read_le_u64(bytes, 13) + if chunk_size == 0 or chunk_size > 64*1024*1024 then return nil, "invalid chunk size" end + if total_size == 0 or total_size > 256*1024*1024 then return nil, "invalid total size" end + local pos = 21 + local parts = {} + while pos + 4 <= #bytes do + local csize = read_le_u32(bytes, pos) + pos = pos + 4 + if csize == 0 then break end + if pos + csize - 1 > #bytes then return nil, "truncated chunk" end + local cdata = bytes:sub(pos, pos + csize - 1) + pos = pos + csize + local ok, d = pcall(love.data.decompress, "string", "zlib", cdata) + if not ok then return nil, "decompress failed" end + table.insert(parts, d) + if #table.concat(parts) >= total_size then break end + end + local res = table.concat(parts) + return res +end + +return SrmDecompress diff --git a/src/import/gba/games/rs/text.lua b/src/import/gba/games/rs/text.lua index d27b59cd..b7699f30 100644 --- a/src/import/gba/games/rs/text.lua +++ b/src/import/gba/games/rs/text.lua @@ -17,19 +17,7 @@ return function(V) } end - local stems = { - UNKNOWN = "Empty", KUN_MALE = "Kun", KUN_FEMALE = "Chan", - RIVAL_MALE = "May", RIVAL_FEMALE = "Brendan", - VERSION = V.GAME == "sapphire" and "Sapphire" or "Ruby", - AQUA = "Aqua", MAGMA = "Magma", ARCHIE = "Archie", MAXIE = "Maxie", - KYOGRE = "Kyogre", GROUDON = "Groudon", - } - -- pokeruby/src/string_util.c:476 - local evil, good = { "Magma", "Maxie", "Groudon" }, { "Aqua", "Archie", "Kyogre" } - if V.GAME == "sapphire" then evil, good = good, evil end - for i, name in ipairs({ "TEAM", "LEADER", "LEGENDARY" }) do - stems["EVIL_" .. name], stems["GOOD_" .. name] = evil[i], good[i] - end V.TEXT_PLACEHOLDERS = {} - for name, stem in pairs(stems) do V.TEXT_PLACEHOLDERS[name] = V.sym("gExpandedPlaceholder_" .. stem) end + local Placeholders = require("src.import.gba.text_placeholders_extract") + for name, label in pairs(Placeholders.rsSymbols(V.GAME)) do V.TEXT_PLACEHOLDERS[name] = V.sym(label) end end diff --git a/src/import/gba/pokedex_chrome_extract.lua b/src/import/gba/pokedex_chrome_extract.lua index 4d6b55f9..b58987ad 100644 --- a/src/import/gba/pokedex_chrome_extract.lua +++ b/src/import/gba/pokedex_chrome_extract.lua @@ -153,6 +153,12 @@ function PokedexChromeExtract.extractEntries(rom, cache, root) end end + local syms = Versions.SYMS + local function symbolAt(ptr) + if not (syms and syms.namesAt and ptr and ptr >= 0x08000000 and ptr < 0x0A000000) then return nil end + return syms.namesAt(ptr - 0x08000000)[1] + end + local entries = {} -- src/data/pokemon/pokedex_entries.h:3 for nat = 0, natDexCount do @@ -177,6 +183,8 @@ function PokedexChromeExtract.extractEntries(rom, cache, root) weight = weight, description = desc1, description2 = desc2, + descriptionLabel = symbolAt(descPtr1), + descriptionLabel2 = E.desc2 and symbolAt(descPtr2) or nil, pokemonScale = pokemonScale, pokemonOffset = pokemonOffset, trainerScale = trainerScale, @@ -195,8 +203,12 @@ function PokedexChromeExtract.extractEntries(rom, cache, root) table.sort(ids) for _, id in ipairs(ids) do local e = entries[id] + local labels = "" + for _, field in ipairs({ "descriptionLabel", "descriptionLabel2" }) do + if e[field] then labels = labels .. string.format(", %s = \"%s\"", field, escape_lua(e[field])) end + end lines[#lines + 1] = string.format( - " [%d] = { category = \"%s\", height = %d, weight = %d, description = \"%s\", description2 = \"%s\", pokemonScale = %d, pokemonOffset = %d, trainerScale = %d, trainerOffset = %d },", + " [%d] = { category = \"%s\", height = %d, weight = %d, description = \"%s\", description2 = \"%s\", pokemonScale = %d, pokemonOffset = %d, trainerScale = %d, trainerOffset = %d%s },", id, escape_lua(e.category), e.height or 0, @@ -206,7 +218,8 @@ function PokedexChromeExtract.extractEntries(rom, cache, root) e.pokemonScale or 256, e.pokemonOffset or 0, e.trainerScale or 256, - e.trainerOffset or 0 + e.trainerOffset or 0, + labels ) end lines[#lines + 1] = "}" diff --git a/src/import/gba/pokedex_entries_extract.lua b/src/import/gba/pokedex_entries_extract.lua index d8f87ef3..aa7af8af 100644 --- a/src/import/gba/pokedex_entries_extract.lua +++ b/src/import/gba/pokedex_entries_extract.lua @@ -23,6 +23,11 @@ function PokedexEntriesExtract.ready(cache, cacheRoot) for _, f in ipairs(PokedexEntriesExtract.files()) do if not cache:exists(root .. f) then return false end end + local okL, L = pcall(function() return require("src.import.gba.layouts.registry").active() end) + if okL and type(L) == "table" and L.id == "rs" then + local body = cache.read and cache:read(root .. "entries.lua") + return body ~= nil and body:find("descriptionLabel", 1, true) ~= nil + end return true end diff --git a/src/import/gba/rs/assets.lua b/src/import/gba/rs/assets.lua index cb63866c..88578459 100644 --- a/src/import/gba/rs/assets.lua +++ b/src/import/gba/rs/assets.lua @@ -25,6 +25,9 @@ function M.text(c, off) end error("RS text is unterminated") end +function M.label(c, off) + return off and c.S.namesAt(off)[1] or nil +end function M.pairs(c, name, signed) local off, out = c:off(name), {} for i = 0, c.S.size(name) / 2 - 1 do diff --git a/src/import/gba/rs/cache_readiness.lua b/src/import/gba/rs/cache_readiness.lua index 1ed2666f..5524401d 100644 --- a/src/import/gba/rs/cache_readiness.lua +++ b/src/import/gba/rs/cache_readiness.lua @@ -19,7 +19,7 @@ local PACKS = { {"naming", {version = 3}}, {"rse/menus"}, {"rse/common_ui"}, {"rse/bag"}, {"rse/berry_tag"}, {"rse/party"}, {"rse/summary"}, {"rse/mail"}, {"rse/easy_chat"}, {"rse/trendy_phrase"}, {"rse/easy_chat_editor", {editorVersion = 2}}, {"rse/rs_egg_hatch"}, - {"pokemon/storage", {version = 5}}, {"rse/pokedex"}, {"rse/pokedex_detail"}, {"rse/diploma"}, + {"pokemon/storage", {version = 5}}, {"rse/pokedex", {packVersion = 1}}, {"rse/pokedex_detail"}, {"rse/diploma"}, {"rse/trainer_card"}, {"rse/trade"}, {"rse/pokenav"}, {"rse/pokenav_detail"}, {"rse/pokenav_shell"}, {"rse/pokenav_condition"}, {"rse/pokenav_ribbons"}, {"rs/assets"}, } diff --git a/src/import/gba/rs/extract_pokedex.lua b/src/import/gba/rs/extract_pokedex.lua index cfedae3f..ecd31859 100644 --- a/src/import/gba/rs/extract_pokedex.lua +++ b/src/import/gba/rs/extract_pokedex.lua @@ -1,6 +1,6 @@ local K = require("src.import.gba.rse.boot_gfx") local A = require("src.import.gba.rs.assets") -local M = {SUB = "rse/pokedex"} +local M = {SUB = "rse/pokedex", packVersion = 1} local N = "pokedex.o:" local maps = {{"list", "gUnknown_08E96738"}, {"list_underlay", "gUnknown_08E9C6DC"}, {"start_menu_main", "gPokedexStartMenuMain_Tilemap"}, {"start_menu_search", "gPokedexStartMenuSearchResults_Tilemap"}, @@ -18,18 +18,24 @@ local function bytes(c, name, stride) for i = 0, c.S.count(name, stride or 1) - 1 do out[i + 1] = stride == 2 and c:u16(off + i * 2) or c:u8(off + i) end return out end +-- pokeruby/src/pokedex.c:1116 +local function ownLabel(c, off) + local label = A.label(c, off) + return label and label:match("^DexText_") and label or nil +end local function options(c, name) local out, off = {}, c:off(N .. name) for i = 0, c.S.count(N .. name, 8) - 1 do local a, b = c:ptr(off + i * 8), c:ptr(off + i * 8 + 4) if not b then break end - out[#out + 1] = {description = a and A.text(c, a) or "", title = A.text(c, b)} + out[#out + 1] = {description = a and A.text(c, a) or "", title = A.text(c, b), + descriptionKey = ownLabel(c, a), titleKey = ownLabel(c, b)} end return out end function M.run(rom, cache, opts) local c = A.context(rom, cache, opts, M.SUB) - local man = {screen = "pokedex", coverage = "native_list_detail_search_backgrounds_and_search_tables", gfx = {}, maps = {}, layers = {}, palettes = {}} + local man = {screen = "pokedex", packVersion = M.packVersion, coverage = "native_list_detail_search_backgrounds_and_search_tables", gfx = {}, maps = {}, layers = {}, palettes = {}} -- pokeruby/src/pokedex.c:1967 local pals = {hoenn = c:palAt(c:off("gPokedexMenu_Pal"), 96), national = c:pal(N .. "sNationalPokedexPalette", 96), searchResults = c:pal(N .. "sPokedexSearchPalette", 96), search = c:pal("gPokedexMenuSearch_Pal", 64)} @@ -54,12 +60,14 @@ function M.run(rom, cache, opts) local top = c:off(N .. "sSearchMenuTopBarItems") for i = 0, c.S.count(N .. "sSearchMenuTopBarItems", 8) - 1 do local o = top + i * 8 - search.topBar[i + 1] = {description = A.text(c, assert(c:ptr(o))), x = c:u8(o + 4), y = c:u8(o + 5), width = c:u8(o + 6)} + search.topBar[i + 1] = {description = A.text(c, assert(c:ptr(o))), descriptionKey = A.label(c, c:ptr(o)), + x = c:u8(o + 4), y = c:u8(o + 5), width = c:u8(o + 6)} end local io = c:off(N .. "sSearchMenuItems") for i = 0, c.S.count(N .. "sSearchMenuItems", 12) - 1 do local o = io + i * 12 - search.items[i + 1] = {description = A.text(c, assert(c:ptr(o))), titleX = c:u8(o + 4), titleY = c:u8(o + 5), titleWidth = c:u8(o + 6), + search.items[i + 1] = {description = A.text(c, assert(c:ptr(o))), descriptionKey = A.label(c, c:ptr(o)), + titleX = c:u8(o + 4), titleY = c:u8(o + 5), titleWidth = c:u8(o + 6), selX = c:u8(o + 7), selY = c:u8(o + 8), selWidth = c:u8(o + 9)} end for _, k in ipairs({"SearchNatDex", "ShiftNatDex", "SearchHoennDex", "ShiftHoennDex"}) do @@ -74,5 +82,9 @@ function M.run(rom, cache, opts) c.files[#c.files + 1] = "orders.lua" return A.finish(c, man) end -function M.ready(cache, root) return A.ready(M.SUB, cache, root) end +function M.ready(cache, root) + if not A.ready(M.SUB, cache, root) then return false end + local body = cache:read((root or "data/generated/gba") .. "/" .. M.SUB .. "/manifest.lua") + return body:find("packVersion = " .. M.packVersion, 1, true) ~= nil +end return M diff --git a/src/import/gba/text_placeholders_extract.lua b/src/import/gba/text_placeholders_extract.lua index 9f43bfbe..42d78fdd 100644 --- a/src/import/gba/text_placeholders_extract.lua +++ b/src/import/gba/text_placeholders_extract.lua @@ -20,6 +20,30 @@ TextPlaceholders.SYMBOLS = { GROUDON = "gText_ExpandedPlaceholder_Groudon", } +-- pokeruby/src/string_util.c:476 +function TextPlaceholders.rsSymbols(game) + local stems = { + UNKNOWN = "Empty", KUN_MALE = "Kun", KUN_FEMALE = "Chan", + RIVAL_MALE = "May", RIVAL_FEMALE = "Brendan", + VERSION = game == "sapphire" and "Sapphire" or "Ruby", + AQUA = "Aqua", MAGMA = "Magma", ARCHIE = "Archie", MAXIE = "Maxie", + KYOGRE = "Kyogre", GROUDON = "Groudon", + } + local evil, good = { "Magma", "Maxie", "Groudon" }, { "Aqua", "Archie", "Kyogre" } + if game == "sapphire" then evil, good = good, evil end + for i, name in ipairs({ "TEAM", "LEADER", "LEGENDARY" }) do + stems["EVIL_" .. name], stems["GOOD_" .. name] = evil[i], good[i] + end + local out = {} + for name, stem in pairs(stems) do out[name] = "gExpandedPlaceholder_" .. stem end + return out +end + +function TextPlaceholders.symbolsFor(game) + if game == "ruby" or game == "sapphire" then return TextPlaceholders.rsSymbols(game) end + return TextPlaceholders.SYMBOLS +end + -- pokeemerald/src/string_util.c:448 TextPlaceholders.BY_GENDER = { KUN = { male = "KUN_MALE", female = "KUN_FEMALE" }, diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 94cd5704..63e0da6d 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -93,14 +93,11 @@ function ItemEffects.healsHP(id) or id == "REVIVE" or id == "MAX_REVIVE" end --- .useRareCandy prints over the still-drawn party menu --- (engine/items/item_effects.asm:1392-1418); .useVitamin ends at --- RemoveUsedItem the same way (engine/items/item_effects.asm:1315-1322); --- stones keep the menu on screen (engine/items/item_effects.asm:772-793, +-- engine/items/item_effects.asm:1223-1237, :1392-1418, :1315-1322, :772-793 -- engine/pokemon/evos_moves.asm:120-128 --- (engine/items/item_effects.asm:2022-2039) +-- engine/items/item_effects.asm:2022-2039 function ItemEffects.keepsPartyMenuOpen(id) - return ItemEffects.healsHP(id) or id == "RARE_CANDY" + return ItemEffects.isBattleMedicine(id) or id == "RARE_CANDY" or VITAMINS[id] ~= nil or ItemEffects.isStone(id) or id == "ELIXER" or id == "MAX_ELIXER" or id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP" diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index e627edaa..af8fc963 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -589,15 +589,7 @@ local function readStringsCatalog(path) local fs = love and love.filesystem if not (fs and fs.read) then return nil end local rel = path .. "/" .. STRINGS_CATALOG - local raw = fs.read(rel) - if type(raw) ~= "string" or raw == "" then return nil end - local chunk = loadstring(raw, "@" .. rel) - if not chunk then return nil end - -- Lua 5.1/LuaJIT: no _ENV, so setfenv is the sandbox. - if setfenv then setfenv(chunk, {}) end - local ok, result = pcall(chunk) - if not ok or type(result) ~= "table" then return nil end - return result + return require("src.mods.Sandbox").evalData(fs.read(rel), "@" .. rel) end -- deriveStrings(rows, byId, read) -> the merged catalog, pure. 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/Sandbox.lua b/src/mods/Sandbox.lua index d6f8b701..6cde6419 100644 --- a/src/mods/Sandbox.lua +++ b/src/mods/Sandbox.lua @@ -293,6 +293,15 @@ function Sandbox.loadFile(fs, path, env) return Sandbox.compile(source, "@" .. path, env) end +function Sandbox.evalData(source, chunkname) + if type(source) ~= "string" or source == "" then return nil end + local chunk = Sandbox.compile(source, chunkname, {}) + if not chunk then return nil end + local ok, result = pcall(chunk) + if ok and type(result) == "table" then return result end + return nil +end + Sandbox.safePath = SafePath.safe Sandbox.requirePath = SafePath.require 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/BagMenu.lua b/src/ui/BagMenu.lua index 5b04cea3..cc533c13 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -475,6 +475,18 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) end) return end + -- engine/items/item_effects.asm:1223-1237 + if picker and picker.keepOpen and target and ItemEffects.isBattleMedicine(id) then + picker:eraseCursors() + showMessages(game, payload, function() + closePicker() + if battle then + list:close() + spent({}) + end + end) + return + end if battle then closePicker() list:close() @@ -529,12 +541,10 @@ local function pickTargetAndUse(game, battle, id, list) -- NORMAL_PARTY_MENU (item_effects.asm:813, :768). #1610 itemUse = true, battle = battle, - -- HP medicine animates with the picker up (#252), RARE CANDY prints over - -- the party menu (item_effects.asm:1392-1418); TM/HM stays up through - -- `predef LearnMove` (item_effects.asm:2238) (#1686) - -- engine/items/item_effects.asm:805 ItemUseMedicine, :1244 .done (#1946) + -- engine/items/item_effects.asm:1392-1418, :2238 + -- engine/items/item_effects.asm:805 ItemUseMedicine, :1244 .done -- engine/items/item_effects.asm:1959 - keepOpen = ItemEffects.healsHP(id) or wantsMove + keepOpen = ItemEffects.isBattleMedicine(id) or wantsMove or ((not battle) and (ItemEffects.keepsPartyMenuOpen(id) or (def and def.machine ~= nil))), onSwitch = function(mon, picker) diff --git a/src/ui/BindingsMenu.lua b/src/ui/BindingsMenu.lua index 7acb1b75..4cc91c99 100644 --- a/src/ui/BindingsMenu.lua +++ b/src/ui/BindingsMenu.lua @@ -9,6 +9,7 @@ local ListMenu = require("src.ui.ListMenu") local ChoiceBox = require("src.ui.ChoiceBox") local Input = require("src.core.Input") local Strings = require("src.core.Strings") +local GamepadMap = require("src.core.GamepadMap") local BindingsMenu = setmetatable({}, { __index = ListMenu }) BindingsMenu.__index = BindingsMenu @@ -25,8 +26,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 +36,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 @@ -68,7 +69,17 @@ local PAD_SHORT = { leftshoulder = "LB", rightshoulder = "RB", triggerleft = "L2", triggerright = "R2", lefttrigger = "L2", righttrigger = "R2", - leftstick = "LS", rightstick = "RS", guide = "GUIDE", + leftstick = "LS", rightstick = "RS", + leftstick_up = "LS-UP", leftstick_down = "LS-DN", + leftstick_left = "LS-LT", leftstick_right = "LS-RT", + lsup = "LS-UP", lsdown = "LS-DN", lsleft = "LS-LT", lsright = "LS-RT", + rightstick_up = "RS-UP", rightstick_down = "RS-DN", + rightstick_left = "RS-LT", rightstick_right = "RS-RT", + rsup = "RS-UP", rsdown = "RS-DN", rsleft = "RS-LT", rsright = "RS-RT", + guide = "GUIDE", back = "BACK", start = "START", + misc1 = "MISC1", touchpad = "PAD", + paddle1 = "P1", paddle2 = "P2", paddle3 = "P3", paddle4 = "P4", + x = "X", y = "Y", a = "A", b = "B", } local function shortName(name, shorts) local s = shorts[name] @@ -84,7 +95,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 @@ -222,6 +234,9 @@ function BindingsMenu:storeBinding(slot, value) self:endCapture() local game = self.game if not (item and value and game.save and game.save.options) then return end + if slot == "pad" and GamepadMap.normalizePad then + value = GamepadMap.normalizePad(value) + end local opts = game.save.options opts.bindings = opts.bindings or {} -- Swap, never steal (#589): when the captured input is another row's @@ -235,7 +250,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/OakSpeech.lua b/src/ui/OakSpeech.lua index 60cd4321..75fc4c66 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -842,8 +842,10 @@ function OakSpeech:draw() love.graphics.setColor(0, 0, 0, 1) for i, line in ipairs(self.shrinkText) do local y = (12 + 2 * i) * 8 - for j, code in ipairs(line) do - Font.drawCode(code, 8 + (j - 1) * 8, y) + local pen = 8 + for _, code in ipairs(line) do + Font.drawCode(code, pen, y) + pen = pen + Font.advanceOf(code) end end love.graphics.setColor(1, 1, 1, 1) 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/game3/message.lua b/src/ui/game3/message.lua index c1b24df5..5bb979cd 100644 --- a/src/ui/game3/message.lua +++ b/src/ui/game3/message.lua @@ -42,17 +42,18 @@ end local placeholderCache = {} -- pokeemerald/src/strings.c:6 -local function cartPlaceholders(extracted) +local function cartPlaceholders(extracted, game) local Extract = require("src.import.gba.text_placeholders_extract") local RomText = require("src.core.game3.rom_text") + local symbols = Extract.symbolsFor(game) local function value(name) - local label = Extract.SYMBOLS[name] + local label = symbols[name] if label and RomText.has(label) then return RomText.plain(label) end return extracted[name] end local out, byGender = {}, {} for name, v in pairs(extracted) do out[name] = v end - for name in pairs(Extract.SYMBOLS) do out[name] = value(name) end + for name in pairs(symbols) do out[name] = value(name) end for name, pair in pairs(extracted.byGender or {}) do byGender[name] = pair end for name, pair in pairs(Extract.BY_GENDER) do byGender[name] = { male = out[pair.male], female = out[pair.female] } @@ -94,7 +95,7 @@ TextIR.setContextProvider(function(kind, dialect, ctx) local okC, CacheFs = pcall(require, "src.import.CacheFs") local t = okC and CacheFs.loadActive(dialect.placeholders) or nil if type(t) == "table" then - placeholderCache[id] = { bundle = bundle, values = cartPlaceholders(t) } + placeholderCache[id] = { bundle = bundle, values = cartPlaceholders(t, id) } return placeholderCache[id].values end return nil diff --git a/src/ui/game3/party_menu.lua b/src/ui/game3/party_menu.lua index 83cc27a7..306cf006 100644 --- a/src/ui/game3/party_menu.lua +++ b/src/ui/game3/party_menu.lua @@ -137,6 +137,7 @@ local function action_texts(list) PartyMenu._actionTexts = { list = list, texts = texts } return texts end +PartyMenu._actionTextsFor = action_texts local FR_INSETS = { msgX = 2, msgY = 2, actX = 9, actY = 2, cursorX = 1 } local function textInsets() @@ -944,6 +945,7 @@ function PartyMenu.show(sessionParty, moveOverlay, opts) end opts = opts or {} PartyMenu._order = nil + PartyMenu._rsPrompt = nil if opts.mode == "battle_switch" or opts.mode == "battle_faint" or (opts.mode == "use" and opts.battleOrder) then local party0 = sessionParty or (opts.session and opts.session.party) local ov0 = moveOverlay or (opts.session and (opts.session.move_overlay or opts.session.moveOverlay)) diff --git a/src/ui/game3/rs/contest_painting.lua b/src/ui/game3/rs/contest_painting.lua index c0be9d62..34618ce9 100644 --- a/src/ui/game3/rs/contest_painting.lua +++ b/src/ui/game3/rs/contest_painting.lua @@ -1,5 +1,6 @@ local Base = require("src.ui.game3.rse.contest_painting") local IR = require("src.core.game3.scripting.text_ir") +local RomText = require("src.core.game3.rom_text") local Kit = require("src.ui.game3.rse.scene_kit") local Font = require("src.ui.game3.frlg_font") local Painting = {ID = Base.ID, SUB = Base.SUB} @@ -11,9 +12,9 @@ local function manifest() return man end -local function render(bytes) +local function render(ir) local out = {} - for _, seg in ipairs(IR.decode(bytes, {dialect = "rs"})) do + for _, seg in ipairs(ir) do if seg.t == "ext" then out[#out + 1] = string.char(252, seg.cmd) for _, arg in ipairs(seg.args or {}) do out[#out + 1] = string.char(arg) end @@ -21,7 +22,10 @@ local function render(bytes) end return table.concat(out) end -local function text(man, key) return render(assert(man.textBytes[key], "native painting caption missing: " .. key)) end +local function text(man, key) + local bytes = assert(man.textBytes[key], "native painting caption missing: " .. key) + return render(RomText.irOr(key, IR.decode(bytes, {dialect = "rs"}))) +end local function name(value, limit) if type(value) == "table" then local bytes = {} @@ -30,7 +34,7 @@ local function name(value, limit) bytes[#bytes + 1] = value[i] end bytes[#bytes + 1] = 255 - return render(bytes) + return render(IR.decode(bytes, {dialect = "rs"})) end if type(value) ~= "string" then return "" end if not limit then return value end @@ -48,9 +52,11 @@ function Painting.caption(saveIdx, winner, artist, man) local nickname = name(winner.monName or winner.nickname, man.captionLayout.nicknameBytes) local category = tonumber(winner.contestCategory) or 0 if saveIdx < man.captionLayout.museumStart then + -- pokeruby/src/contest_painting.c:234 + local possessive, first, second = text(man, man.hallPossessive), name(winner.trainerName), nickname + if possessive:find("^[ ,]") then first, second = nickname, name(winner.trainerName) end return text(man, assert(man.rankNames[category], "native painting category")) .. text(man, man.hallCaption) - .. name(winner.trainerName) .. string.char(unpack(man.captionLayout.hallLatinControl)) - .. text(man, man.hallPossessive) .. nickname + .. first .. string.char(unpack(man.captionLayout.hallLatinControl)) .. possessive .. second end local pair = assert(man.captionParts[category], "native museum caption pair") return text(man, pair.prefix) .. nickname .. text(man, pair.suffix) diff --git a/src/ui/game3/rs/decoration.lua b/src/ui/game3/rs/decoration.lua index ebcb86dd..4407424d 100644 --- a/src/ui/game3/rs/decoration.lua +++ b/src/ui/game3/rs/decoration.lua @@ -27,9 +27,16 @@ local aliases = { UI.TEXT_ALIASES = {gText_ApostropheSBase = "gOtherText_PlayersBase"} local cats = {gText_Desk = 0, gText_Chair = 1, gText_Plant = 2, gText_Ornament = 3, gText_Mat = 4, gText_Poster = 5, gText_Doll = 6, gText_Cushion = 7} +local resolved = {} local function nativeText(key, vars) if cats[key] ~= nil then return Inv.categoryName(cats[key]) end local native, m = aliases[key] or key, Decor.manifest() + if not vars and resolved[native] then return resolved[native] end + if Text.has(native) then + local value = Text.plain(native, vars and {stringVars = vars} or nil) + if not vars then resolved[native] = value end + return value + end if m.textBytes and m.textBytes[native] then return IR.toPlain(IR.decode(m.textBytes[native], {dialect = "rs"}), {stringVars = vars or {}}) end @@ -116,6 +123,7 @@ function UI.draw(d, host) end local function options(opts) opts = opts or {} + resolved = {} opts.text, opts.draw = nativeText, UI.draw opts.recordSecretBaseVisit = false opts.avatarGfx = function(s, female) diff --git a/src/ui/game3/rs/easy_chat_editor.lua b/src/ui/game3/rs/easy_chat_editor.lua index 0e59546a..be8014e4 100644 --- a/src/ui/game3/rs/easy_chat_editor.lua +++ b/src/ui/game3/rs/easy_chat_editor.lua @@ -6,6 +6,15 @@ local Cursor = require("src.ui.game3.rs.menu_cursor") local Pal = require("src.core.game3.pal_fade") local Fx = require("src.core.game3.gba_fx") local Easy = require("src.core.game3.easy_chat_text") +local words, groups = {}, {} +local function wordText(id) + if words[id] == nil then words[id] = Easy.word(id) end + return words[id] +end +local function groupText(group) + if groups[group] == nil then groups[group] = Easy.groupName(group) end + return groups[group] +end local Policy = require("src.ui.game3.rs.easy_chat_editor_policy") local Frames = require("src.ui.game3.rs.easy_chat_editor_frames") local C = {isMenu = true, open = false} @@ -33,6 +42,7 @@ function C.manifest(kind) end function C.show(opts) opts = opts or {} + words, groups = {}, {} assert(opts.token and opts.type ~= 9, "native RS editor requires its save contract; type9 uses its separate host") C._man, C._session, C._opts = C.manifest(opts.type), assert(opts.session), opts C._before, C.words = copyWords(opts.words), copyWords(opts.words) @@ -347,7 +357,7 @@ local function phraseWords() local line = {} for col = 0, C._man.columns - 1 do local i = row * C._man.columns + col + 1 - if i <= C._man.wordCount and C.words[i] ~= Policy.EMPTY then line[#line + 1] = Easy.rawWord(C.words[i]) .. " " end + if i <= C._man.wordCount and C.words[i] ~= Policy.EMPTY then line[#line + 1] = wordText(C.words[i]) .. " " end end local pen = C._man.wordPens[row * C._man.columns + 1] if pen then text(table.concat(line), pen.x, pen.y, "phrase") end @@ -360,7 +370,7 @@ local function phraseWords() local image = assert(Kit.image(C._man.blankWord)) love.graphics.setColor(1, 1, 1, 1) Fx.draw(function() love.graphics.draw(image, x, y) end, C._pal:fx(0)) - else text(Easy.rawWord(C.words[i]), x, y, "phrase") end + else text(wordText(C.words[i]), x, y, "phrase") end end end local function pickerText() @@ -382,7 +392,7 @@ local function pickerText() local word = (view == "groups" and C.groups or C._list)[row * 2 + col + 1] if word then local x = (view == "groups" and 16 or 48) + col * 88 - text(view == "groups" and word.name or word.text, x, 88 + (row - state.top) * 16 + offset, "picker") + text(view == "groups" and groupText(word) or wordText(word.id), x, 88 + (row - state.top) * 16 + offset, "picker") end end end diff --git a/src/ui/game3/rs/mail_composer.lua b/src/ui/game3/rs/mail_composer.lua index bac46f43..050ded2d 100644 --- a/src/ui/game3/rs/mail_composer.lua +++ b/src/ui/game3/rs/mail_composer.lua @@ -6,6 +6,15 @@ local Cursor = require("src.ui.game3.rs.menu_cursor") local Pal = require("src.core.game3.pal_fade") local Fx = require("src.core.game3.gba_fx") local Easy = require("src.core.game3.easy_chat_text") +local words, groups = {}, {} +local function wordText(id) + if words[id] == nil then words[id] = Easy.word(id) end + return words[id] +end +local function groupText(group) + if groups[group] == nil then groups[group] = Easy.groupName(group) end + return groups[group] +end local Policy = require("src.ui.game3.rs.mail_composer_policy") local Frames = require("src.ui.game3.rs.mail_composer_frames") local C = {isMenu = true, open = false} @@ -23,6 +32,7 @@ function C.manifest() end function C.write(record, opts) opts = opts or {} + words, groups = {}, {} C._man, C._record, C._session, C._opts = C.manifest(), assert(record), assert(opts.session), opts C._before, C.words = copyWords(record.words), copyWords(record.words) C.groups = Policy.groups(C._session, opts.gates) @@ -259,7 +269,7 @@ local function phraseWords() local parts = {} for col = 0, 1 do local i = row * 2 + col + 1 - if i <= 9 and C.words[i] ~= Policy.EMPTY then parts[#parts + 1] = Easy.rawWord(C.words[i]) .. " " end + if i <= 9 and C.words[i] ~= Policy.EMPTY then parts[#parts + 1] = wordText(C.words[i]) .. " " end end text(table.concat(parts), 40, row * 16, "phrase") end @@ -271,7 +281,7 @@ local function phraseWords() local image = assert(Kit.image(C._man.blankWord)) love.graphics.setColor(1, 1, 1, 1) Fx.draw(function() love.graphics.draw(image, x, y) end, C._pal:fx(0)) - else text(Easy.rawWord(C.words[i]), x, y, "phrase") end + else text(wordText(C.words[i]), x, y, "phrase") end end end local function pickerText() @@ -293,7 +303,7 @@ local function pickerText() local word = (view == "groups" and C.groups or C._list)[row * 2 + col + 1] if word then local x = (view == "groups" and 16 or 48) + col * 88 - text(view == "groups" and word.name or word.text, x, 88 + (row - state.top) * 16 + offset, "picker") + text(view == "groups" and groupText(word) or wordText(word.id), x, 88 + (row - state.top) * 16 + offset, "picker") end end end diff --git a/src/ui/game3/rs/mail_reader.lua b/src/ui/game3/rs/mail_reader.lua index b587e5f2..5da71047 100644 --- a/src/ui/game3/rs/mail_reader.lua +++ b/src/ui/game3/rs/mail_reader.lua @@ -43,7 +43,7 @@ function Reader.content(mail, man) local parts = {} for i = 1, line.words do local id = tonumber(mail.words and mail.words[word]) or 65535 - parts[#parts + 1] = Easy.rawWord(id) + parts[#parts + 1] = Easy.word(id) if i < line.words and id ~= 65535 then parts[#parts + 1] = " " end word = word + 1 end diff --git a/src/ui/game3/rs/move_relearner.lua b/src/ui/game3/rs/move_relearner.lua index 174b7bab..5abc762c 100644 --- a/src/ui/game3/rs/move_relearner.lua +++ b/src/ui/game3/rs/move_relearner.lua @@ -3,6 +3,7 @@ local Kit = require("src.ui.game3.rse.scene_kit") local Font = require("src.ui.game3.frlg_font") local Pokemon = require("src.core.game3.pokemon") local IR = require("src.core.game3.scripting.text_ir") +local RomText = require("src.core.game3.rom_text") local bit = require("bit") local UI = {} local keys = {gText_TeachWhichMoveToPkmn = "gOtherText_TeachWhichMove", @@ -13,13 +14,20 @@ local function manifest() assert(m.assetLayout == "rs", "native RS move tutor pack required") return m end +local resolved = {} +local function cartIr(native) + if not resolved[native] then + resolved[native] = RomText.irOr(native, + IR.decode(assert(manifest().textBytes[native], "native tutor text " .. native), {dialect = "rs"})) + end + return resolved[native] +end local function text(key, ctx) - local m, native = manifest(), keys[key] or key - return IR.toPlain(IR.decode(assert(m.textBytes[native], "native tutor text " .. native), {dialect = "rs"}), ctx or {}) + return IR.toPlain(cartIr(keys[key] or key), ctx or {}) end local function pages(key, vars) local out = {} - local value = IR.toAscii(IR.decode(assert(manifest().textBytes[key]), {dialect = "rs"}), {stringVars = vars}) + local value = IR.toAscii(cartIr(key), {stringVars = vars}) for page in (value .. "\\p"):gmatch("(.-)\\p") do if page ~= "" then out[#out + 1] = page end end return out end @@ -164,6 +172,7 @@ function UI.draw(st) end function UI.show(mon, opts) manifest(); opts = opts or {} + resolved = {} opts.maxShown, opts.text, opts.draw, opts.startLearn = 3, text, UI.draw, startLearn return Base.show(mon, opts) end diff --git a/src/ui/game3/rs/party_menu_data.lua b/src/ui/game3/rs/party_menu_data.lua index 1134d402..5805a602 100644 --- a/src/ui/game3/rs/party_menu_data.lua +++ b/src/ui/game3/rs/party_menu_data.lua @@ -12,12 +12,23 @@ local function menuData() return assert(require("src.ui.game3.rse.scene_kit").manifest(Data.manifest).party) end +-- pokeruby/src/pokemon_menu.c:126 +Data.actionLabels = {"OtherText_Summary", "OtherText_Switch2", "OtherText_Item", "gOtherText_CancelNoTerminator", + "OtherText_Give2", "OtherText_Take2", "OtherText_Take", "OtherText_Mail", "OtherText_Read2", + "gOtherText_CancelNoTerminator"} + function Data.actionText(action, fields) + local RomText = require("src.core.game3.rom_text") local nativeId = Data.actionIds[action] - if nativeId then return assert(menuData().cursorOptions[nativeId + 1]) end - if Data.extraLabels[action] then return require("src.core.game3.rom_text").plain(Data.extraLabels[action]) end + if nativeId then + local label = Data.actionLabels[nativeId + 1] + if RomText.has(label) then return RomText.plain(label) end + return assert(menuData().cursorOptions[nativeId + 1]) + end + if Data.extraLabels[action] then return RomText.plain(Data.extraLabels[action]) end assert(fields and fields.index[action] ~= nil, "unknown native RS party action: " .. tostring(action)) - return action + local move = fields.moves and fields.moves[fields.index[action] + 1] + return move and require("src.core.game3.pokemon").moveName(move) or action end function Data.buildActions(mon, party, fields) @@ -152,11 +163,23 @@ function Data.drawDescription(desc, x, y) love.graphics.draw(image, quad, x, y) end -local function prompt(id, width) +local function promptText(id, name) + local RomText = require("src.core.game3.rom_text") + local key = RomText.key("PartyMenuPromptTexts", id) + if RomText.has(key) then return RomText.plain(key, {stringVars = {name or ""}}) end + return assert(require("src.ui.game3.rse.scene_kit").manifest("rse/party").prompts[id + 1]) +end + +local function prompt(ui, anchor, id, width) + local memo = ui._rsPrompt + if not (memo and memo.anchor == anchor and memo.id == id) then + local mon = id == 5 and ui._party and ui._party[ui.cursor] + memo = {anchor = anchor, id = id, text = promptText(id, mon and require("src.core.game3.pokemon").displayName(mon))} + ui._rsPrompt = memo + end local Chrome = require("src.ui.game3.chrome") Chrome.stdFrame(1, 17, width, 2) - require("src.ui.game3.frlg_font").draw(assert(require("src.ui.game3.rse.scene_kit").manifest("rse/party").prompts[id + 1]), 8, 136, - require("src.ui.game3.rs.party_chrome").textOptions("menu")) + require("src.ui.game3.frlg_font").draw(memo.text, 8, 136, require("src.ui.game3.rs.party_chrome").textOptions("menu")) end function Data.drawActions(ui, item) @@ -166,9 +189,10 @@ function Data.drawActions(ui, item) local left = item and (30 - (width + 1)) or 19 local top = item and (20 - (count * 2 + 2)) or (18 - count * 2) local Font = require("src.ui.game3.frlg_font") - prompt(item and ui._submenuKind ~= "MAIL" and 13 or 5, item and (ui._submenuKind == "MAIL" and 18 or 21) or 17) + prompt(ui, actions, item and ui._submenuKind ~= "MAIL" and 13 or 5, item and (ui._submenuKind == "MAIL" and 18 or 21) or 17) require("src.ui.game3.chrome").stdFrame(left + 1, top + 1, width - 1, count * 2) - for i, action in ipairs(actions) do Font.draw(Data.actionText(action, ui._fieldMoveData), (left + 1) * 8, (top + 1) * 8 + (i - 1) * 16, + local texts = ui._actionTextsFor(actions) + for i in ipairs(actions) do Font.draw(texts[i], (left + 1) * 8, (top + 1) * 8 + (i - 1) * 16, require("src.ui.game3.rs.party_chrome").textOptions("menu")) end local cursor = item and ui.itemActionCursor or ui.actionCursor require("src.ui.game3.rs.menu_cursor").draw((left + 1) * 8, (top + 1) * 8 + (cursor - 1) * 16, (width - 1) * 8) @@ -180,7 +204,7 @@ function Data.drawPrompt(ui) elseif ui.mode == "give" then id = 4 elseif ui.mode == "move_tutor" then id = 20 elseif ui.mode == "use" then id = require("src.core.game3.items_data").isTm(ui._item) and 2 or 3 end - prompt(id, 22) + prompt(ui, ui.mode, id, 22) end function Data.yesNoLabels() diff --git a/src/ui/game3/rs/shop_menu.lua b/src/ui/game3/rs/shop_menu.lua index 55e59b8c..5c7e8877 100644 --- a/src/ui/game3/rs/shop_menu.lua +++ b/src/ui/game3/rs/shop_menu.lua @@ -5,6 +5,7 @@ local Chrome = require("src.ui.game3.chrome") local Cursor = require("src.ui.game3.rs.menu_cursor") local Window = require("src.ui.game3.window") local TextIR = require("src.core.game3.scripting.text_ir") +local RomText = require("src.core.game3.rom_text") local Items = require("src.core.game3.items_data") local UI = {CAMERA_OFFSET = {x = -3, y = -3}} UI.WIN = {money = Window.template(1, 1, 12, 2), qty = Window.template(1, 11, 12, 2), @@ -15,10 +16,14 @@ local function manifest() assert(man.assetLayout == "rs" and man.shopVersion == 2, "native RS shop schema required") return man end +local resolved = {} function UI.ir(key) + if resolved[key] then return resolved[key] end local man = manifest() local native = assert(man.textAliases[key] or (man.textBytes[key] and key), "native RS shop text policy missing: " .. key) - return TextIR.decode(assert(man.textBytes[native], "native RS shop text bytes missing: " .. native), {dialect = "rs"}) + resolved[key] = RomText.irOr(native, + TextIR.decode(assert(man.textBytes[native], "native RS shop text bytes missing: " .. native), {dialect = "rs"})) + return resolved[key] end function UI.plain(key, ctx) return TextIR.toPlain(UI.ir(key), ctx or {}) end function UI.box(key, ctx) return UI.plain(key, ctx) end @@ -176,6 +181,7 @@ function UI.drawSell(sale) end function UI.show(shop, opts) opts = opts or {}; opts.nativePolicy = UI + resolved = {} manifest() shop._rsShopFrame = 0 return Base.show(shop, opts) diff --git a/src/ui/game3/rs/summary_menu.lua b/src/ui/game3/rs/summary_menu.lua index 4aff20cf..2ba53e66 100644 --- a/src/ui/game3/rs/summary_menu.lua +++ b/src/ui/game3/rs/summary_menu.lua @@ -50,6 +50,7 @@ local function detailRows(open) S._detailRowsOpen = open end function S.openMenu(party, index, opts) opts = opts or {} S._man, S._party, S._opts = S.manifest(), party or {}, opts + S._contestDescriptions = {} S._cursor = index or 1 S._playerState = opts.playerState or opts.session or require("src.core.game3.runtime").getSession() @@ -500,7 +501,10 @@ local function moves() local amount = value == 255 and 0 or math.floor(value / 10) for i = 0, 7 do frame(S._man.hearts, row == 1 and (i < amount and 1 or 0) or (i < amount and 3 or 4), 48 + i % 4 * 8, 120 + (row - 1) * 16 + math.floor(i / 4) * 8) end end - print(effect.description, 88, 120, 255) + local descriptions = S._contestDescriptions or {} + S._contestDescriptions = descriptions + descriptions[effect] = descriptions[effect] or SummaryData.contestEffectDescription(effect) + print(descriptions[effect], 88, 120, 255) else local def = Pokemon.battleMove(id) text("gOtherText_Power2", 8, 120); text("gOtherText_Accuracy2", 8, 136) diff --git a/src/ui/game3/rs/trainer_card.lua b/src/ui/game3/rs/trainer_card.lua index 6640066b..caadbd55 100644 --- a/src/ui/game3/rs/trainer_card.lua +++ b/src/ui/game3/rs/trainer_card.lua @@ -18,6 +18,12 @@ local function copiedCard(card) out.stars = math.max(0, math.min(4, math.floor(tonumber(out.stars) or 0))) return out end +local function profilePhrase(card) + local Easy, words = require("src.core.game3.easy_chat_text"), card.easyChatProfile or {} + local out = {} + for row = 0, 1 do out[row + 1] = Easy.word(words[row * 2 + 1] or 65535) .. " " .. Easy.word(words[row * 2 + 2] or 65535) end + return out +end function T.show(opts) opts = opts or {} local rt = package.loaded["src.core.game3.runtime"] @@ -25,6 +31,7 @@ function T.show(opts) T._clockSession = opts.clockSession or (rt and rt.getSession and rt.getSession()) or T._session T._link = opts.linkCard == true or (T._session.dex == nil and T._session.stars ~= nil) T._card = T._link and copiedCard(T._session) or Policy.generate(T._session) + T._phrase = T._link and profilePhrase(T._card) or nil T._man, T._onClose = T.manifest(), opts.onClose T.open, T.side, T._phase, T._setup = true, "front", "setup", 0 T._pal, T._stepper = Pal.new(), Kit.stepper() @@ -143,8 +150,8 @@ function T.texts(card, side, colon) local time = T._link and c or T._session put("time", tostring(tonumber(time.playTimeHours) or 0) .. (colon and " : " or " ") .. string.format("%02d", tonumber(time.playTimeMinutes) or 0), 80, 96, "values", false, 48) if T._link then - local Easy, words = require("src.core.game3.easy_chat_text"), c.easyChatProfile or {} - for row = 0, 1 do put("phrase" .. row, Easy.rawWord(words[row * 2 + 1] or 65535) .. " " .. Easy.rawWord(words[row * 2 + 2] or 65535), 16, 112 + row * 16) end + local phrase = T._phrase or profilePhrase(c) + for row = 0, 1 do put("phrase" .. row, phrase[row + 1], 16, 112 + row * 16) end end else put("name", c.name .. s.nameSuffix, 224, 16, "values", true) diff --git a/src/ui/game3/rs/trendy_phrase.lua b/src/ui/game3/rs/trendy_phrase.lua index 5e3407e7..37ee7b6c 100644 --- a/src/ui/game3/rs/trendy_phrase.lua +++ b/src/ui/game3/rs/trendy_phrase.lua @@ -6,6 +6,15 @@ local Cursor = require("src.ui.game3.rs.menu_cursor") local Pal = require("src.core.game3.pal_fade") local Fx = require("src.core.game3.gba_fx") local Easy = require("src.core.game3.easy_chat_text") +local words, groups = {}, {} +local function wordText(id) + if words[id] == nil then words[id] = Easy.word(id) end + return words[id] +end +local function groupText(group) + if groups[group] == nil then groups[group] = Easy.groupName(group) end + return groups[group] +end local Policy = require("src.ui.game3.rs.trendy_phrase_policy") local Frames = require("src.ui.game3.rs.trendy_phrase_frames") local C = {isMenu = true, open = false} @@ -27,6 +36,7 @@ function C.manifest() end function C.show(opts) opts = opts or {} + words, groups = {}, {} C._man, C._session, C._opts = C.manifest(), assert(opts.session), opts C._before, C.words = copyWords(opts.words), copyWords(opts.words) C.groups = Policy.groups(C._session, opts.gates) @@ -264,7 +274,7 @@ local function triangle(x, y) end local function phraseWords() if C._confirm and C._confirm.kind == "save" then - text(Easy.rawWord(C.words[1]) .. " " .. Easy.rawWord(C.words[2]) .. " ", 48, 24, "phrase") + text(wordText(C.words[1]) .. " " .. wordText(C.words[2]) .. " ", 48, 24, "phrase") return end for i = 1, 2 do @@ -273,7 +283,7 @@ local function phraseWords() local image = assert(Kit.image(C._man.blankWord)) love.graphics.setColor(1, 1, 1, 1) Fx.draw(function() love.graphics.draw(image, x, y) end, C._pal:fx(0)) - else text(Easy.rawWord(C.words[i]), x, y, "phrase") end + else text(wordText(C.words[i]), x, y, "phrase") end end end local function pickerText() @@ -295,7 +305,7 @@ local function pickerText() local word = (view == "groups" and C.groups or C._list)[row * 2 + col + 1] if word then local x = (view == "groups" and 16 or 48) + col * 88 - text(view == "groups" and word.name or word.text, x, 88 + (row - state.top) * 16 + offset, "picker") + text(view == "groups" and groupText(word) or wordText(word.id), x, 88 + (row - state.top) * 16 + offset, "picker") end end end diff --git a/src/ui/game3/rse/pokedex.lua b/src/ui/game3/rse/pokedex.lua index ca8d6294..8a067251 100644 --- a/src/ui/game3/rse/pokedex.lua +++ b/src/ui/game3/rse/pokedex.lua @@ -12,8 +12,32 @@ local RsPolicy = require("src.ui.game3.rs.pokedex_policy") local function nativeRs() return Gfx.manifest().assetLayout == "rs" end local rsTextNames = {gText_CryOf = "CryOf", gText_SizeComparedTo = "SizeComparedTo", gText_SelectorArrow = "RightPointingTriangle", gText_SearchingPleaseWait = "Searching", gText_SearchCompleted = "SearchComplete", gText_NoMatchingPkmnWereFound = "NoMatching"} +local function cached(label, copy) + if label and RomText.has(label) then return RomText.plain(label) end + return copy +end +local RS_LABELS = {RightPointingTriangle = "DexText_RightPointingTriangle"} +local function rsString(key) + local copy = assert(Gfx.manifest().strings[key], "native RS dex text " .. tostring(key)) + local text = cached(RS_LABELS[key] or ("gDexText_" .. key), nil) + if text == nil then return copy end + return (copy:match("^\252\019.") or "") .. text +end +-- pokeruby/src/pokedex.c:4228 +local function categorySuffix(unknown) + local i, marks = 1, 0 + while i <= #unknown do + local c = unknown:sub(i, i) + if c == "?" then marks, i = marks + 1, i + 1 + elseif c == " " then i = i + 1 + elseif unknown:sub(i, i + 2) == "?" then marks, i = marks + 1, i + 3 + else break end + end + if marks == 0 or i > #unknown then return "" end + return (unknown:sub(i - 1, i - 1) == " " and " " or "") .. unknown:sub(i) +end local function dexText(key) - return nativeRs() and assert(Gfx.manifest().strings[assert(rsTextNames[key], "native RS dex text alias")]) or RomText.plain(key) + return nativeRs() and rsString(assert(rsTextNames[key], "native RS dex text alias")) or RomText.plain(key) end local Pokedex = {} @@ -910,20 +934,26 @@ end local function monInfo(s, nat, nationalNumber, owned, newEntry) local e = entryFor(nat) if nativeRs() then - local strings, out = Gfx.manifest().strings, {} + local out = {} if newEntry then - local t = strings.RegisterComplete + local t = rsString("RegisterComplete") out[#out + 1] = {text = t, x = 16 + math.floor((208 - FrlgFont.measure(t)) / 2), y = 0} end local num = nationalNumber and nat or (Pokedex.hoennNumber(nat) or nat) out[#out + 1] = {text = string.format("%03d", num), x = 104, y = 24} out[#out + 1] = {text = pokemon().name(Pokedex.speciesOf(nat)) or Gfx.manifest().tenDashes, x = 128, y = 24} - local category = owned and ((e.category or "") .. " " .. strings.UnknownPoke:match("[^? ]+.*$")) or strings.UnknownPoke - local cx = 88 + (owned and (FrlgFont.measure(strings.UnknownPoke) - FrlgFont.measure(category)) or 0) + local unknown = rsString("UnknownPoke") + local category = owned and (Strings(e.category or "") .. categorySuffix(unknown)) or unknown + local cx = 88 + (owned and (FrlgFont.measure(unknown) - FrlgFont.measure(category)) or 0) out[#out + 1] = {text = category, x = cx, y = 40} - out[#out + 1] = {text = owned and Pokedex.heightText(e.height or 0) or strings.UnknownHeight, x = 128, y = 56} - out[#out + 1] = {text = owned and RsPolicy.weightText(e.weight or 0) or strings.UnknownWeight, x = 128, y = 72} - local desc = s.descriptionPage == 1 and e.description2 or e.description + out[#out + 1] = {text = owned and Pokedex.heightText(e.height or 0) or rsString("UnknownHeight"), x = 128, y = 56} + out[#out + 1] = {text = owned and RsPolicy.weightText(e.weight or 0) or rsString("UnknownWeight"), x = 128, y = 72} + local desc + if s.descriptionPage == 1 then + desc = cached(e.descriptionLabel2, e.description2) + else + desc = cached(e.descriptionLabel, e.description) + end out[#out + 1] = {text = owned and (desc or "") or "", x = 16, y = 104} return out end @@ -1535,7 +1565,8 @@ function tasks.loadSize(s) s.state = 3 elseif st == 3 then local name = s.session and (s.session.name or s.session.playerName) or "" - local t = dexText("gText_SizeComparedTo") .. tostring(name) + local label = dexText("gText_SizeComparedTo") + local t = label:find("^の") and tostring(name) .. label or label .. tostring(name) local w = FrlgFont.measure(t) or 0 s.sizeText = { text = t, x = nativeRs() and (24 + 96 - math.floor(w / 2)) or (w < 240 and math.floor((240 - w) / 2) or 0), y = nativeRs() and 120 or 121 } s.state = 4 @@ -1593,10 +1624,7 @@ function tasks.switchFromSize(s) end end -local function cartText(key, fallback) - if key and RomText.has(key) then return RomText.plain(key) end - return fallback -end +local cartText = cached -- pokeemerald/src/pokedex.c:1330 local TYPE_OPTION = { "gText_DexSearchTypeNone" } @@ -1632,11 +1660,13 @@ local ITEM_DESCRIPTIONS = { "gText_ListByFirstLetter", "gText_ListByBodyColor", "gText_ListByType", "gText_SelectPokedexListingMode", "gText_SelectPokedexMode", "gText_ExecuteSearchSwitch" } local function topBarDescription(i) - return cartText(TOPBAR_DESCRIPTIONS[i + 1], Gfx.manifest().search.topBar[i + 1].description) + local row = Gfx.manifest().search.topBar[i + 1] + return cartText(row.descriptionKey or TOPBAR_DESCRIPTIONS[i + 1], row.description) end local function itemDescription(i) - return cartText(ITEM_DESCRIPTIONS[i + 1], Gfx.manifest().search.items[i + 1].description) + local row = Gfx.manifest().search.items[i + 1] + return cartText(row.descriptionKey or ITEM_DESCRIPTIONS[i + 1], row.description) end -- pokeemerald/src/pokedex.c:1437 @@ -1657,8 +1687,8 @@ local function searchOptionTexts(which) local out = {} for i, t in ipairs(list) do out[i] = { - title = cartText(keys.titles[i], t.title), - description = cartText(keys.descriptions and keys.descriptions[i], t.description), + title = cartText(t.titleKey or keys.titles[i], t.title), + description = cartText(t.descriptionKey or (keys.descriptions and keys.descriptions[i]), t.description), } end return out 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..c70d3f99 100644 --- a/src/ui/gen2/BoxMenu.lua +++ b/src/ui/gen2/BoxMenu.lua @@ -649,14 +649,6 @@ function BoxMenu:update(_dt) self:stepBox(1) elseif input:wasPressed("a") then self:act() - -- RELEASE and the nickname keyboard are BillsPC_WithdrawMenu's and the - -- CHANGE BOX menu's rows; .MoveMonWOMailSubmenu has neither, and a stray - -- SELECT on the move screen must not put "Release ?" in front of a - -- player who only meant to reorder a box. - elseif input:wasPressed("select") and self.mode == "withdraw" then - self:askRelease() - elseif input:wasPressed("start") and self.mode == "withdraw" then - self:askNickname() elseif input:wasPressed("b") then if self.onClose then self.onClose() end end @@ -684,9 +676,6 @@ function BoxMenu:playMonCry(mon) end end --- BillsPC's RELEASE, which the model has always supported and nothing on --- screen reached. The cart asks first and starts the prompt on NO, the way --- every irreversible choice in the game does. function BoxMenu:askRelease() if self:isCancel() then return end local mon = self:selected() @@ -736,31 +725,6 @@ function BoxMenu:askRelease() end, { defaultNo = true })) end --- The naming screen the cart opens from BillsPC's own nickname option. -function BoxMenu:askNickname() - if self:isCancel() then return end - local mon = self:selected() - if not mon then return end - local game = self.game - if not (game and game.stack) then return end - -- The resolve is guarded rather than the construction: a keyboard that will - -- not even load is a nickname the player cannot type, not a crash. Same - -- shape as the openscreen script command (src/script/Commands.lua). - if not pcall(Screens.get, game, "Gen2NamingScreen") then return end - Screens.push(game, "Gen2NamingScreen", { - -- The "nickname" kind is MON_NAME_LENGTH - 1 wide and takes its header - -- from the mon rather than from a fixed prompt. - type = "nickname", - monName = mon.name or mon.species, - initial = mon.nickname or "", - onDone = function(name) - game.stack:pop() - if name and #name > 0 then mon.nickname = name end - end, - onCancel = function() game.stack:pop() end, - }) -end - function BoxMenu:image(path) if not path then return nil end local cached = self.picCache[path] diff --git a/src/ui/gen2/Diploma.lua b/src/ui/gen2/Diploma.lua index 73ea6ba3..fecf3ab6 100644 --- a/src/ui/gen2/Diploma.lua +++ b/src/ui/gen2/Diploma.lua @@ -25,13 +25,6 @@ -- hlcoord 2, 8 .Certification, five `next`-joined lines that PlaceString -- walks one row down at column 2 apiece: rows 8-12 -- --- COLOUR. _CGB_Diploma (engine/gfx/cgb_layouts.asm) loads all eight --- DiplomaPalettes sets and then WipeAttrmap zeroes the attrmap, so every tile --- on the screen -- art and text alike -- draws through set 0. The strings go --- down with Chrome.printThrough rather than Chrome.print for that reason: a --- black print over the art would be the one thing on screen not going through --- the palette. --- -- WaitPressAorB_BlinkCursor just parks on A or B; there is no menu here. local Chrome = require("src.ui.gen2.Chrome") @@ -82,10 +75,9 @@ function Diploma:update(_dt) end end --- Set 0 of DiplomaPalettes, the one WipeAttrmap leaves the whole screen on. +-- engine/gfx/cgb_layouts.asm:495 function Diploma:palette() - local palettes = self.gfx and self.gfx.palettes - return palettes and palettes[1] or nil + return self.gfx and self.gfx.bgPalette end function Diploma:image(path) diff --git a/src/ui/gen2/NamingScreen.lua b/src/ui/gen2/NamingScreen.lua index 6e01ea5f..87a902ee 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,12 +139,17 @@ 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 - -- engine/gfx/cgb_layouts.asm:488 + -- engine/gfx/cgb_layouts.asm:495 local diploma = data.gen2Diploma - self.palette = diploma and diploma.palettes and diploma.palettes[1] + self.palette = diploma and diploma.bgPalette self.tiles = {} if self.gfx then for _, key in ipairs({ "border", "middleLine", "underLine", "cursor" }) do @@ -157,6 +162,53 @@ 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, trueColor = require("src.pokemon.Sprites").iconPath( + data, mon, entry and entry.image, + { name = iconId, trueColor = entry and entry.trueColor }) + self.iconTrueColor = trueColor + 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 +334,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 +549,35 @@ 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 - if self.iconColors and GbcPalette.available() then - -- GbcPalette.with without the closure: set, draw, restore. + local ix, iy = self:iconOrigin() + if self.iconColors and GbcPalette.available() + and not (self.iconTrueColor and GbcPalette.mode == "gbc") then 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/ui/gen2/PartyMenu.lua b/src/ui/gen2/PartyMenu.lua index 82f65011..b2987eac 100644 --- a/src/ui/gen2/PartyMenu.lua +++ b/src/ui/gen2/PartyMenu.lua @@ -6,8 +6,8 @@ -- prompt text is a parameter (.Strings: "Choose a #MON.", "Use on which -- ?", "Teach which ?", ...). -- --- Icons come from icons.lua: one 16x32 sheet per ICON_*, two 16x16 frames that --- alternate roughly twice a second, and MonMenuIcons maps species -> icon. +-- Icons come from icons.lua: one 16x32 sheet per ICON_*, two 16x16 frames, and +-- MonMenuIcons maps species -> icon. -- -- Choosing a mon from the FIELD list opens the action submenu (MonSubmenu, -- engine/pokemon/mon_submenu.asm) rather than answering straight away; every @@ -55,9 +55,10 @@ local EGG_LABEL = Strings.source("EGG") local ABLE_LABEL = Strings.source("ABLE") local NOT_ABLE_LABEL = Strings.source("NOT ABLE") --- The icon's two frames swap every 16 logic steps, close to the cart's --- SPRITE_ANIM cadence. -local ICON_FRAME_STEPS = 16 +-- data/sprite_anims/framesets.asm:66 +PartyMenu.ICON_FRAME_DURATION = 8 +-- engine/gfx/mon_icons.asm:132 +PartyMenu.ICON_DURATION_OFFSET = { green = 0x00, yellow = 0x40, red = 0x80 } -- engine/items/item_effects.asm:1748 PartyMenu.ACTION_TEXT_DELAY = 50 @@ -791,23 +792,30 @@ function PartyMenu:iconIdFor(mon) and self.icons.species[mon.species] or nil end --- The icon image for a mon, plus which 16x16 frame to show. --- --- The path goes out through pokemon.icon before it is loaded -- the SAME hook --- name, the same (data, mon, vanillaPath, { name = iconId }) call and the same --- ctx the Gen 1 party list makes (src/ui/PartyMenu.lua:186 through --- src/pokemon/Sprites.lua iconPath), so one skin mod repaints the party icons --- in both games. Sprites.iconPath does its own Runtime.wantsHook check and --- hands the vanilla path straight back on an unhooked boot, so this costs a --- table lookup per row; the cache is keyed on the RESOLVED path so a mod's --- image does not ride the built-in's entry. +-- engine/gfx/mon_icons.asm:122 +function PartyMenu.iconHpBand(mon) + if type(mon) ~= "table" then return "green" end + local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) + return HpBar.paletteFor(mon.hp, maxHp) +end + +-- engine/sprite_anims/core.asm:400-433 +function PartyMenu.iconFrameSteps(mon) + local offset = PartyMenu.ICON_DURATION_OFFSET[PartyMenu.iconHpBand(mon)] + return PartyMenu.ICON_FRAME_DURATION + offset + 1 +end + +function PartyMenu:iconFrame(mon) + return math.floor(self.clock / PartyMenu.iconFrameSteps(mon)) % 2 +end + function PartyMenu:iconFor(mon) local iconId = self:iconIdFor(mon) local entry = iconId and self.icons and self.icons.icons and self.icons.icons[iconId] - local path = entry and entry.image - path = require("src.pokemon.Sprites").iconPath( - self.game and self.game.data, mon, path, { name = iconId }) + local path, trueColor = require("src.pokemon.Sprites").iconPath( + self.game and self.game.data, mon, entry and entry.image, + { name = iconId, trueColor = entry and entry.trueColor }) if not path then return nil end local cached = self.iconCache[path] if cached == nil then @@ -816,8 +824,7 @@ function PartyMenu:iconFor(mon) self.iconCache[path] = cached end if not cached then return nil end - local frame = math.floor(self.clock / ICON_FRAME_STEPS) % 2 - return cached, frame + return cached, self:iconFrame(mon), trueColor end -- .SpawnItemIcon (engine/gfx/mon_icons.asm): a mon carrying something does not @@ -861,12 +868,14 @@ function PartyMenu:iconX(index) return index == self.index and 8 or 0 end --- ...and AnimSeq_PartyMonSwitch bobs that one: VAR1 counts frames and, every --- sixteenth, bit 4 decides whether YOFFSET is 0 or negative. So the selected --- icon rides two pixels high for half of each 32-frame cycle. -function PartyMenu:iconBob(index) +-- engine/sprite_anims/functions.asm:81-121 +-- engine/gfx/mon_icons.asm:115-119 +PartyMenu.ICON_BOB = { green = -2, yellow = -1, red = 0 } + +function PartyMenu:iconBob(index, mon) if index ~= self.index then return 0 end - return (math.floor(self.clock / 16) % 2 == 1) and -2 or 0 + if math.floor(self.clock / 16) % 2 ~= 1 then return 0 end + return PartyMenu.ICON_BOB[PartyMenu.iconHpBand(mon)] end -- One reusable quad per role, re-aimed per draw: an icon draws every frame @@ -888,25 +897,21 @@ local function reusedQuad(self, slot, x, y, w, h, sw, sh) end function PartyMenu:drawIcon(mon, px, py) - local image, frame = self:iconFor(mon) + local image, frame, trueColor = self:iconFor(mon) if not image then return end local G = love.graphics local iw, ih = image:getDimensions() local markerRow = PartyMenu.heldMarkerRow(mon) local marker = markerRow and self:heldMarkerImage() or nil G.setColor(1, 1, 1, 1) - -- Every party icon OAM entry is PAL_OW_RED (data/sprite_anims/oam.asm:315-355) - -- and InitPartyMenuOBPals loads PartyMenuOBPals into OBJ 0 for the whole list, - -- species and EGG alike (engine/gfx/color.asm:593-598, :1228-1229). + -- data/sprite_anims/oam.asm:315-355 + -- engine/gfx/color.asm:593-598 local pals = self.palettes and self.palettes.partyMenu local colors = pals and pals[1] or nil local shaded = colors and GbcPalette.available() - local previous - if shaded then - -- GbcPalette.with without the closure: set, draw, restore. - previous = G.getShader and G.getShader() or nil - GbcPalette.use(colors) - end + local iconShaded = shaded and not (trueColor and GbcPalette.mode == "gbc") + local previous = G.getShader and G.getShader() or nil + if iconShaded then GbcPalette.use(colors) end if marker then -- The _WITH_ITEM / _WITH_MAIL OAM sets (data/sprite_anims/oam.asm) are the -- ordinary four quadrants with the `dbsprite -1, 0` entry -- the bottom @@ -922,6 +927,7 @@ function PartyMenu:drawIcon(mon, px, py) px + 8, py) G.draw(image, reusedQuad(self, "bottomRight", 8, frame * 16 + 8, 8, 8, iw, ih), px + 8, py + 8) + if shaded and not iconShaded then GbcPalette.use(colors) end G.draw(marker, reusedQuad(self, "held", 0, markerRow * 8, 8, 8, mw, mh), px, py + 8) else @@ -1041,7 +1047,7 @@ function PartyMenu:drawPanel() -- cursor overwrites it whenever it sits there. Chrome.cursor(0, nameY, true) end - self:drawIcon(mon, self:iconX(i), 4 + (i - 1) * 16 + self:iconBob(i)) + self:drawIcon(mon, self:iconX(i), 4 + (i - 1) * 16 + self:iconBob(i, mon)) local hp = self:shownHpFor(i, mon) local row = PartyMenu.rowFor(mon, hp, self.game and self.game.data and self.game.data.gen2Statuses) diff --git a/src/ui/kit/FileBrowser.lua b/src/ui/kit/FileBrowser.lua index b7ec8eef..2db39732 100755 --- a/src/ui/kit/FileBrowser.lua +++ b/src/ui/kit/FileBrowser.lua @@ -95,9 +95,17 @@ local function isMatchingFilter(name, isDir, mode) if not ext then return (mode == "all") end ext = ext:lower() if mode == "rom" then - return (ext == "gb" or ext == "gbc" or ext == "gba" or ext == "zip") + if ext == "gb" or ext == "gbc" or ext == "gba" then return true end + if ext == "zip" or ext == "7z" then + local ok, RomArchive = pcall(require, "src.import.RomArchive") + if not ok or type(RomArchive.capabilities) ~= "function" then return false end + local okCaps, caps = pcall(RomArchive.capabilities) + if not okCaps or type(caps) ~= "table" then return false end + return (ext == "zip" and caps.zip) or (ext == "7z" and caps.z7) or false + end + return false elseif mode == "save" then - return (ext == "sav" or ext == "lua") + return (ext == "sav" or ext == "srm" or ext == "lua") elseif mode == "box" then return (ext == "gci" or ext == "sav") elseif mode == "mod" then 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..d962cdad 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -1814,7 +1814,7 @@ end function World:isCrystal() local GameVersion = loaded("src.core.GameVersion") -- Rendering follows the loaded ROM column (BorderFill / MapPreview do the - -- same), not save.version — a Crystal save under the Gold column still bakes + -- same), not save.version: a Crystal save under the Gold column still bakes -- and draws with Gold's single-bank tilesets. return GameVersion.engine() == "crystal" end @@ -3176,13 +3176,6 @@ function World:bankOfMomAmount(kind, saved, held, onDone) return true end --- The rename half of the Goldenrod NAME RATER (engine/events/name_rater.asm, --- src/script/gen2/Specials.lua H.NameRater). Same keyboard World:nameHatchling --- opens for a freshly-hatched egg, but the header is the species name loaded --- by GetBaseData (`ld b, NAME_MON / ld de, wStringBuffer2 / farcall --- _NamingScreen`), not a fixed prompt -- BoxMenu:askNickname's screen is the --- same shape for the same reason. `onDone(name)` gets the typed string or nil --- for B; IsNewNameEmpty/CompareNewToOld both live in the special, not here. -- SetDayOfWeek's wheel (src/ui/gen2/InitClock.lua day mode). `onDone(day)` is -- the special's own resume, the same shape World:nameRival hands H.NameRival: -- the screen's close is what starts the script again. @@ -3223,6 +3216,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 ""), @@ -9390,7 +9384,7 @@ function World:blitBgOverRegion(mapDef, ox, oy, s, rx0, ry0, rx1, ry1, keyed, ti -- originX/Y is the screen position of map pixel (rx0, ry0): ox/oy are the -- playfield-local offset of map (0,0), so a tile at (tx, ty) lands at - -- origin + (tx - rx0) * s — same convention as drawGrassOverGoldSilver's + -- origin + (tx - rx0) * s, same convention as drawGrassOverGoldSilver's -- ox + cx0 * s with absolute map coordinates. self:blitBgOverRegionLocal(mapDef, math.floor(ox + rx0 * s), math.floor(oy + ry0 * s), @@ -11149,9 +11143,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 +11154,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/gen2_party_icon_cadence.lua b/tests/drivers/gen2_party_icon_cadence.lua new file mode 100644 index 00000000..62b93fef --- /dev/null +++ b/tests/drivers/gen2_party_icon_cadence.lua @@ -0,0 +1,85 @@ +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") +local PartyMenu = require("src.ui.gen2.PartyMenu") + +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") + for _ = 1, 1200 do + if game.world and game.world.map and not game.stack:top() then break end + U.wait(1) + end + assert(game.world and game.world.map and not game.stack:top(), "world did not settle") + + local function new(species, fraction) + local m = assert(Mon.new(game.data, species, 30)) + local maxHp = m.maxHp or (m.stats and m.stats.hp) + m.hp = math.floor(maxHp * fraction) + return m + end + local save = game.save + save.party = { + new("CYNDAQUIL", 1.0), + new("TOTODILE", 0.3), + new("CHIKORITA", 0.1), + new("PIDGEY", 0), + } + check(PartyMenu.iconHpBand(save.party[1]) == "green", "S3 slot 1 green") + check(PartyMenu.iconHpBand(save.party[2]) == "yellow", "S3 slot 2 yellow") + check(PartyMenu.iconHpBand(save.party[3]) == "red", "S3 slot 3 red") + check(PartyMenu.iconHpBand(save.party[4]) == "red", "S3 slot 4 fainted is red") + + local menu = PartyMenu.new(game, { party = save.party }) + game.stack:push(menu) + U.wait(2) + check(game.stack:top() == menu, "S3 party list open") + + local function frames() + local t = {} + for i, m in ipairs(save.party) do t[i] = menu:iconFrame(m) end + return t + end + local function waitClock(target) + for _ = 1, 400 do + if menu.clock >= target then return end + U.wait(1) + end + end + + waitClock(4) + local a = frames() + U.still(game, out .. "/s3_01_all_frame0.png") + check(a[1] == 0 and a[2] == 0 and a[3] == 0 and a[4] == 0, "S3 all icons start on frame 0") + + waitClock(10) + local b = frames() + U.still(game, out .. "/s3_02_green_flipped_tick10.png") + check(b[1] == 1 and b[2] == 0 and b[3] == 0, "S3 green flips after 9 ticks, others hold") + + waitClock(74) + local c = frames() + U.still(game, out .. "/s3_03_yellow_flipped_tick74.png") + check(c[2] == 1 and c[3] == 0, "S3 yellow flips after 73 ticks, red holds") + + waitClock(138) + local d = frames() + U.still(game, out .. "/s3_04_red_flipped_tick138.png") + check(d[3] == 1 and d[4] == 1, "S3 red and fainted flip after 137 ticks") + + game.stack:pop() + U.wait(2) + end, debug.traceback) + if not ok then + print("FAIL S3 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..72997988 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) @@ -56,6 +58,11 @@ return function(game) check(id .. " does NOT animate the bar (status cure / PP)", ItemEffects.healsHP(id) ~= true) end + for _, id in ipairs({ "ANTIDOTE", "PARLYZ_HEAL", "AWAKENING", "BURN_HEAL", + "ICE_HEAL", "FULL_HEAL", "FULL_RESTORE" }) do + check(id .. " keeps the party menu up for its message", + ItemEffects.keepsPartyMenuOpen(id) == true) + end end for _, id in ipairs({ "POTION", "MAX_POTION", "REVIVE", "ANTIDOTE" }) do check(id .. " resolves in the item table", game.data.items[id] ~= nil) @@ -82,8 +89,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) @@ -160,6 +165,18 @@ return function(game) return top() == game.overworld end + local function dismissMessage() + for _ = 1, 30 do + if not inStack(isPicker) then return true end + U.tap(game, "a") + for _ = 1, 8 do + if not inStack(isPicker) then return true end + U.wait(1) + end + end + return not inStack(isPicker) + end + -- ======== scripted run: MAX_POTION on the 1 HP lead ====================== U.log("======== #252 scripted run: MAX POTION on a 1 HP CHARIZARD ========") local picker, why = openPickerFor("MAX_POTION") @@ -190,18 +207,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 +232,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) @@ -236,17 +261,10 @@ return function(game) U.wait(60) -- let the line type out, so the shot shows the text not an empty box U.shot(game, DIR .. "/bug252_message_over_party.png") - -- TextBox pops BEFORE it fires onDone, which is what makes - -- PartyMenu:close's identity check land on the picker - for _ = 1, 30 do - if not inStack(isPicker) then break end - U.tap(game, "a") - U.wait(8) - end - check("the picker is gone once the message is dismissed", not inStack(isPicker)) + check("the picker is gone once the message is dismissed", dismissMessage()) local flashed = isFlash(top()) check("the return to the bag whites out (#2125)", flashed) - if flashed then U.shot(game, DIR .. "/bug2125_white.png") end + if flashed then U.still(game, DIR .. "/bug2125_white.png") end for _ = 1, 40 do if not isFlash(top()) then break end U.wait(1) @@ -257,38 +275,51 @@ return function(game) end backToOverworld() - -- ======== contrast: ANTIDOTE must NOT animate =========================== - U.log("======== #252 contrast: ANTIDOTE (no bar fill at all) ========") + -- engine/items/item_effects.asm:1223-1237 + U.log("======== #252 ANTIDOTE: cure message over the party menu ========") local cure = openPickerFor("ANTIDOTE") if check("party picker opened for ANTIDOTE", cure ~= nil) then - check("keepOpen is off for a status cure", cure.keepOpen ~= true) - cursorTo(cure, 3) -- the poisoned SNORLAX + check("keepOpen is on for a status cure", cure.keepOpen == true) + cursorTo(cure, 3) U.tap(game, "a") U.wait(6) check("no fill was started for a status cure", cure.heal == nil) - check("the picker popped itself, like every non-medicine item", - not inStack(isPicker)) - U.shot(game, DIR .. "/bug252_antidote_message.png") check("PSN was cured", poisoned.status == nil) + for _ = 1, 60 do + if isBox(top()) then break end + U.wait(1) + end + local box = top() + check("the cure message opened", isBox(box)) + check("...over the still-drawn party menu", inStack(isPicker)) + check("...with the menu cursor erased", cure.cursorsErased == true) + if isBox(box) then + local out = {} + for _, page in ipairs(box.pages or {}) do + for _, line in ipairs(page) do out[#out + 1] = line end + end + local said = table.concat(out, " ") + U.log("box reads:", said) + check("...and it is the poison-cured line", + said:find("poison", 1, true) ~= nil) + end + U.wait(60) + U.shot(game, DIR .. "/bug252_antidote_message.png") + check("the picker is gone once the cure message is dismissed", + dismissMessage()) + local flashed = isFlash(top()) + check("the return to the bag whites out after the cure", flashed) + if flashed then U.still(game, DIR .. "/bug252_antidote_white.png") end + for _ = 1, 40 do + if not isFlash(top()) then break end + U.wait(1) + end + local back = top() + check("and the cure returns to the ITEM list", + back ~= nil and back.screenId == "BagMenu") 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/crystal_buena_cache_2639.lua b/tests/engine/crystal_buena_cache_2639.lua index 264aa978..1b8da38e 100644 --- a/tests/engine/crystal_buena_cache_2639.lua +++ b/tests/engine/crystal_buena_cache_2639.lua @@ -31,7 +31,7 @@ for _, revision in ipairs(Version.revisions("crystal")) do end for _, version in ipairs({ "gold", "silver" }) do - T.eq(Contract.formatFor(version), "rom-cache-v13:", version .. " marker stays unchanged") + T.eq(Contract.formatFor(version), "rom-cache-v14:", version .. " marker stays unchanged") files[Version.cachePrefix(version) .. Contract.MARKER_PATH] = Contract.markerFor(version) files[Version.cachePrefix(version) .. "data/generated/events.lua"] = nil T.check(Contract.isReady(version, fs), version .. " does not require Crystal password output") diff --git a/tests/engine/game3_file_browser_quote_test.lua b/tests/engine/game3_file_browser_quote_test.lua index 6468a52c..9bb66975 100644 --- a/tests/engine/game3_file_browser_quote_test.lua +++ b/tests/engine/game3_file_browser_quote_test.lua @@ -16,6 +16,11 @@ love = love or require("tests.love_stub") local FileBrowser = require("src.ui.kit.FileBrowser") local HostShell = require("src.core.HostShell") +local realRomArchive = package.loaded["src.import.RomArchive"] +package.loaded["src.import.RomArchive"] = { + capabilities = function() return { zip = true, z7 = false } end, +} + -- 1. Injection: a directory named with a command substitution must not run. -- The marker must not pre-exist (os.tmpname creates its file, so name it here). local marker = "/tmp/gen1recomp-inject-" .. tostring(os.time()) @@ -50,7 +55,8 @@ end local realItems = love.filesystem.getDirectoryItems local realInfo = love.filesystem.getInfo local listing = { "images", "videos", "Red.gb", "Crystal.GBC", "FireRed.gba", - "Emerald.GBA", "roms.zip", "save.sav", "save.lua", "demo.g1rcart", "notes.txt" } + "Emerald.GBA", "roms.zip", "pack.7z", "save.sav", "save.lua", + "demo.g1rcart", "notes.txt" } local function listingText() local out = {} for _, name in ipairs(listing) do @@ -84,6 +90,8 @@ for _, backend in ipairs({ "shell", "love" }) do end check(found["notes.txt"] == nil and found["save.sav"] == nil, backend .. " excludes unrelated files from ROM mode") + check(found["pack.7z"] == nil, + backend .. " hides .7z when the platform caps disallow it") if found["FireRed.gba"] then FileBrowser.selectedIdx = found["FireRed.gba"] check(FileBrowser.gamepadpressed(button), backend .. " consumes " .. button) @@ -108,5 +116,6 @@ end io.popen = realPopen love.filesystem.getDirectoryItems = realItems love.filesystem.getInfo = realInfo +package.loaded["src.import.RomArchive"] = realRomArchive T.finish("game3_file_browser_quote_test") diff --git a/tests/engine/game3_rs_translation_runtime_test.lua b/tests/engine/game3_rs_translation_runtime_test.lua new file mode 100644 index 00000000..dfd0aa10 --- /dev/null +++ b/tests/engine/game3_rs_translation_runtime_test.lua @@ -0,0 +1,258 @@ + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local function ir(s) return { { t = "text", s = s }, { t = "eos" } } end + +local BUNDLE = { text = {} } +package.loaded["src.core.game3.scripting.space"] = { ensureBundle = function() return BUNDLE end } + +local TextIR = require("src.core.game3.scripting.text_ir") + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +-- pokeruby/src/string_util.c:476 +local Extract = require("src.import.gba.text_placeholders_extract") +local ruby, sapphire = Extract.rsSymbols("ruby"), Extract.rsSymbols("sapphire") +check(ruby.EVIL_TEAM == "gExpandedPlaceholder_Magma" and sapphire.EVIL_TEAM == "gExpandedPlaceholder_Aqua", + "the evil team is Magma in Ruby and Aqua in Sapphire") +check(ruby.GOOD_LEADER == "gExpandedPlaceholder_Archie" and sapphire.GOOD_LEADER == "gExpandedPlaceholder_Maxie", + "the good team's leader follows the edition") +check(ruby.VERSION == "gExpandedPlaceholder_Ruby" and sapphire.VERSION == "gExpandedPlaceholder_Sapphire", + "the version names the edition") +check(Extract.symbolsFor("emerald") == Extract.SYMBOLS, "Emerald keeps its own labels") + +local Message = require("src.ui.game3.message") +BUNDLE.text = { + gExpandedPlaceholder_Maxie = ir("MAX"), gExpandedPlaceholder_Archie = ir("ARTHUR"), + gExpandedPlaceholder_Ruby = ir("RUBIS"), gExpandedPlaceholder_May = ir("FLORA"), + gExpandedPlaceholder_Kun = ir("くん"), +} +local placeholders = Message.cartPlaceholders({ + EVIL_TEAM = "MAGMA", EVIL_LEADER = "MAXIE", GOOD_LEADER = "ARCHIE", VERSION = "RUBY", + RIVAL_MALE = "MAY", RIVAL_FEMALE = "BRENDAN", KUN_MALE = "", KUN_FEMALE = "", + byGender = { RIVAL = { male = "MAY", female = "BRENDAN" }, KUN = { male = "", female = "" } }, +}, "ruby") +check(placeholders.EVIL_LEADER == "MAX" and placeholders.GOOD_LEADER == "ARTHUR", + "the leaders' names read the cache by the edition's labels") +check(placeholders.VERSION == "RUBIS", "the version's name reads the cache") +check(placeholders.EVIL_TEAM == "MAGMA", "a placeholder missing from the cache keeps the copy") +check(placeholders.byGender.RIVAL.male == "FLORA", "the rival's name reads the cache") +local line = { { t = "player" }, { t = "ph", code = 5, name = "KUN" }, { t = "text", s = ": " }, + { t = "ph", code = 10, name = "EVIL_LEADER" }, { t = "text", s = " / " }, + { t = "ph", code = 6, name = "RIVAL" }, { t = "eos" } } +check(TextIR.toPlain(line, { dialect = "rs", playerName = "RED", playerGender = 0, placeholders = placeholders }) + == "REDくん: MAX / FLORA", "a Ruby line expands the honorific, the leader and the rival from the cache") +BUNDLE.text = { gExpandedPlaceholder_Archie = ir("ARTHUR"), gExpandedPlaceholder_Maxie = ir("MAX") } +local sapphireLine = Message.cartPlaceholders({ EVIL_LEADER = "ARCHIE", byGender = {} }, "sapphire") +check(sapphireLine.EVIL_LEADER == "ARTHUR", "Sapphire's evil leader is Archie's row") + +local MANIFEST = { + assetLayout = "rs", + strings = { UnknownPoke = " ????? POKéMON", CryOf = "\252\019\002CRY OF", RegisterComplete = "REGISTERED", + UnknownHeight = "\252\019\012??'??\"", UnknownWeight = "????.? lbs.", SizeComparedTo = "SIZE COMPARED TO ", + Searching = "Searching...", SearchComplete = "Search completed.", NoMatching = "No matching POKéMON.", + RightPointingTriangle = ">" }, + search = { + topBar = { { description = "Search for POKéMON.", descriptionKey = "DexText_SearchForPoke" } }, + items = { { description = "List by the first letter.", descriptionKey = "DexText_ListByABC" } }, + names = { { title = "ABC", titleKey = "DexText_ABC", description = "" } }, + colors = {}, types = {}, orders = {}, modes = {}, + }, +} +package.loaded["src.ui.game3.rse.pokedex_gfx"] = setmetatable({ manifest = function() return MANIFEST end }, + { __index = function() return function() end end }) +package.loaded["src.ui.game3.rse.mapsec"] = setmetatable({ readLua = function(path) + if path:find("entries", 1, true) then + return { [277] = { category = "WOOD GECKO", height = 5, weight = 50, description = "Ruby page one.", + description2 = "Ruby page two.", descriptionLabel = "DexDescription_Treecko_1", + descriptionLabel2 = "DexDescription_Treecko_2" } } + end + return { nationalToRegional = {} } +end }, { __index = function() return function() end end }) +package.loaded["src.core.game3.pokemon"] = { speciesFromNational = function() return 277 end, + name = function() return "ARCKO" end } +local Pokedex = require("src.ui.game3.rse.pokedex") +BUNDLE.text = { + DexDescription_Treecko_2 = ir("Page deux."), gDexText_UnknownPoke = ir(" ????? POKéMON"), + gDexText_CryOf = ir("CRI DE"), DexText_SearchForPoke = ir("Chercher des POKéMON."), DexText_ABC = ir("ABC FR"), +} +local function texts(rows) + local out = {} + for _, row in ipairs(rows) do out[#out + 1] = row.text end + return table.concat(out, "|") +end +local page1 = texts(Pokedex.monInfo({ descriptionPage = 0 }, 252, true, true, false)) +local page2 = texts(Pokedex.monInfo({ descriptionPage = 1 }, 252, true, true, false)) +check(page1:find("Ruby page one.", 1, true) ~= nil, "a page missing from the cache keeps the pack's copy") +check(page2:find("Page deux.", 1, true) ~= nil, "the second page reads the cache by its own label") +check(page1:find("WOOD GECKO POKéMON", 1, true) ~= nil, "the category keeps the cart's POKéMON after it") +check(Pokedex.topBarDescription(0) == "Chercher des POKéMON.", "the search top bar reads its own label") +check(Pokedex.itemDescription(0) == "List by the first letter.", "a search line missing from the cache keeps the copy") +check(Pokedex.searchOptionTexts(Pokedex.SEARCH.NAME)[1].title == "ABC FR", "a search option reads its own label") +-- pokeruby/src/pokedex.c:4228 +local function infoRow(owned, y) + for _, row in ipairs(Pokedex.monInfo({ descriptionPage = 0 }, 252, true, owned, false)) do + if row.y == y then return row.text end + end +end +local function category(unknown) + BUNDLE.text.gDexText_UnknownPoke = ir(unknown) + return infoRow(true, 40) +end +check(category(" ????? POKéMON") == "WOOD GECKO POKéMON", "the English cart follows the category with POKéMON") +check(category("?????") == "WOOD GECKO", "the French and German carts print the category alone") +check(category("POKéMON ?????") == "WOOD GECKO", "so does the Italian cart, whose word comes first") +check(category("?????ポケモン") == "WOOD GECKOポケモン", "the Japanese word follows the category without a space") +BUNDLE.text.gDexText_UnknownHeight = ir("???,? m") +check(infoRow(false, 56) == "\252\019\012???,? m", "a label keeps its copy's CLEAR_TO in front of the cache's text") +local Registry = require("src.import.gba.layouts.registry") +local active = Registry.active +Registry.active = function() return { id = "rs" } end +local Entries = require("src.import.gba.pokedex_entries_extract") +local function entriesCache(body) return { exists = function() return true end, read = function() return body end } end +check(not Entries.ready(entriesCache("return { [277] = { description = \"x\" } }")), + "an R/S cache without the description labels extracts its entries again") +check(Entries.ready(entriesCache("return { [277] = { descriptionLabel = \"DexDescription_Treecko_1\" } }")), + "an R/S cache with the labels keeps its entries") +Registry.active = active + +local COPY = { 0xBD, 0xC9, 0xCA, 0xD3, 0xFF } +local Kit = require("src.ui.game3.rse.scene_kit") +local MANIFESTS = { + ["items/shop"] = { assetLayout = "rs", shopVersion = 2, textAliases = { gText_HowMayIServeYou = "gOtherText_HowMayIServe" }, + textBytes = { gOtherText_HowMayIServe = COPY, gOtherText_Unnamed = COPY } }, +} +Kit.manifest = function(sub) + if MANIFESTS[sub] then return MANIFESTS[sub] end + return { assetLayout = "rs", textBytes = { gOtherText_TeachWhichMove = COPY, gOtherText_Unnamed = COPY } } +end +local Shop = require("src.ui.game3.rs.shop_menu") +BUNDLE.text = { gOtherText_HowMayIServe = ir("Que puis-je faire pour vous ?"), + gOtherText_TeachWhichMove = ir("Quelle capacité apprendre ?") } +check(Shop.plain("gText_HowMayIServeYou") == "Que puis-je faire pour vous ?", "the shop reads the cache by its label") +check(Shop.plain("gOtherText_Unnamed") == "COPY", "a shop line missing from the cache keeps the pack's copy") + +package.loaded["src.ui.game3.rse.move_relearner"] = { SUB = "tutor", show = function(_, opts) return opts end } +local tutorText = require("src.ui.game3.rs.move_relearner").show({}, {}).text +check(tutorText("gText_TeachWhichMoveToPkmn") == "Quelle capacité apprendre ?", "the move relearner reads the cache by its label") +check(tutorText("gOtherText_Unnamed") == "COPY", "a move relearner line missing from the cache keeps the pack's copy") +BUNDLE.text.gOtherText_TeachWhichMove = ir("Autre texte") +check(tutorText("gText_TeachWhichMoveToPkmn") == "Quelle capacité apprendre ?", "a screen resolves its text once while it is open") +tutorText = require("src.ui.game3.rs.move_relearner").show({}, {}).text +check(tutorText("gText_TeachWhichMoveToPkmn") == "Autre texte", "and again when it opens") + +package.loaded["src.ui.game3.rse.decoration"] = { open = function(opts) return opts end } +package.loaded["src.core.game3.rse.decoration"] = { manifest = function() + return { textBytes = { gSecretBaseText_DecorReturned = COPY, gSecretBaseText_NoDecor = COPY }, strings = {} } +end } +package.loaded["src.core.game3.rse.decoration_inventory"] = { categoryName = function() return "" end } +local decorText = require("src.ui.game3.rs.decoration").open().text +BUNDLE.text = { gSecretBaseText_DecorReturned = ir("La décoration est retournée au PC.") } +check(decorText("gText_DecorationReturnedToPC") == "La décoration est retournée au PC.", "the decoration menus read the cache by their labels") +check(decorText("gText_NoDecorationHere") == "COPY", "a decoration line missing from the cache keeps the pack's copy") + +package.loaded["src.ui.game3.rse.contest_painting"] = { ID = "contest_painting", SUB = "contest_painting" } +local Painting = require("src.ui.game3.rs.contest_painting") +local PAINT = { captionLayout = { museumStart = 5, nicknameBytes = 10 }, + captionParts = { [0] = { prefix = "gContestPaintingCool1", suffix = "gContestPaintingCool2" } }, + textBytes = { gContestPaintingCool1 = COPY, gContestPaintingCool2 = COPY } } +BUNDLE.text = { gContestPaintingCool1 = ir("Le POKéMON ") } +check(Painting.caption(5, { nickname = "ZIGZATON", contestCategory = 0 }, nil, PAINT) == "Le POKéMON ZIGZATONCOPY", + "a painting's caption reads the cache by its labels, the pack's copy where the cache has none") +-- pokeruby/src/contest_painting.c:234 +PAINT.rankNames = { [0] = "gContestRankNormal" } +PAINT.hallCaption, PAINT.hallPossessive = "gContestText_ContestWinner", "gOtherText_Unknown1" +PAINT.captionLayout.hallLatinControl = {} +PAINT.textBytes.gContestRankNormal, PAINT.textBytes.gContestText_ContestWinner, PAINT.textBytes.gOtherText_Unknown1 = COPY, COPY, COPY +local winner = { nickname = "ZIGZATON", trainerName = "MAY", contestCategory = 0 } +BUNDLE.text = { gContestRankNormal = ir("NORMAL "), gContestText_ContestWinner = ir("WINNER "), gOtherText_Unknown1 = ir("'s ") } +check(Painting.caption(0, winner, nil, PAINT) == "NORMAL WINNER MAY's ZIGZATON", "the US hall caption names the trainer first") +BUNDLE.text.gOtherText_Unknown1 = ir(" von ") +check(Painting.caption(0, winner, nil, PAINT) == "NORMAL WINNER ZIGZATON von MAY", "a European hall caption names the POKéMON first") + +package.loaded["src.core.game3.easy_chat_text"] = { + rawWord = function(id) return "RAW" .. id % 1000 end, word = function(id) return "MOT" .. id end, + group = function() return { words = { { id = 513, text = "RAW513" } } } end, +} +package.loaded["src.core.game3.rs.dewford_trend"] = { editorValid = function() return true end, + trySetTrendyPhrase = function() return true end } +local Contracts = require("src.core.game3.rs.easy_chat_contracts") +local trend = Contracts.commit({ type = 9, wordCount = 2, before = { 0xFFFF, 0xFFFF }, session = {} }, { 513, 514 }) +check(trend.stringVar2 == "MOT513 MOT514", "the trendy phrase the script prints is translated") +check(trend.result == 1, "a new trend counts as a change") +local same = Contracts.commit({ type = 9, wordCount = 2, before = { 1513, 514 }, session = {} }, { 513, 514 }) +check(same.result == 0, "the change is measured on the cart's words, not their translation") +check(require("src.core.game3.rs.tv_playback").word(513) == "MOT513", "the TV prints a word translated") +local Routes = require("src.core.game3.scripting.natives_rs_tv_routes") +local Rse, Tv = require("src.core.game3.rse.init"), require("src.core.game3.rse.tv") +local session, gabbyData = { version = "ruby" }, { quote = {} } +local rseSession, tvState = Rse.session, Tv.state +Rse.session = function() return session end +Tv.state = function() return { gabbyAndTyData = gabbyData } end +local function lastQuote() + gabbyData.quote[0] = 513 + local ctx = {} + Routes.BY_NAME.GabbyAndTyGetLastQuote(ctx, nil) + return ctx.stringVars[1] +end +check(lastQuote() == "MOT513", "Gabby and Ty quote a word translated") +local EASY = package.loaded["src.core.game3.easy_chat_text"] +local word = EASY.word +EASY.word = function() return "ともだち" end +check(lastQuote() == "RAW513", "a word the cart cannot print keeps the cart's word") +EASY.word, Rse.session, Tv.state = word, rseSession, tvState + +-- pokeruby/src/pokemon_menu.c:126 +MANIFESTS["rse/menus"] = { party = { cursorOptions = { "SUMMARY", "SWITCH", "ITEM", "CANCEL", "GIVE", "TAKE", + "TAKE", "MAIL", "READ", "CANCEL", "CUT" } } } +package.loaded["src.core.game3.pokemon"].moveName = function(move) return move == 15 and "COUPE" or nil end +local PartyData = require("src.ui.game3.rs.party_menu_data") +BUNDLE.text = { OtherText_Summary = ir("RESUME"), OtherText_Take = ir("PRENDRE") } +check(PartyData.actionText("SUMMARY") == "RESUME", "a party action reads the cache by its label") +check(PartyData.actionText("TAKE_MAIL") == "PRENDRE", "the mail's TAKE reads its own label") +check(PartyData.actionText("SWITCH") == "SWITCH", "an action missing from the cache keeps the pack's copy") +check(PartyData.actionText("CUT", { index = { CUT = 0 }, moves = { 15 } }) == "COUPE", "a field move prints its move's name") +MANIFESTS["rse/party"] = { prompts = setmetatable({}, { __index = function() return "PROMPT" end }) } +local drawn, resolved = {}, 0 +local font, chrome, cursor, partyChrome = package.loaded["src.ui.game3.frlg_font"], package.loaded["src.ui.game3.chrome"], + package.loaded["src.ui.game3.rs.menu_cursor"], package.loaded["src.ui.game3.rs.party_chrome"] +package.loaded["src.ui.game3.frlg_font"] = { draw = function(text) drawn[#drawn + 1] = text end } +package.loaded["src.ui.game3.chrome"] = { stdFrame = function() end } +package.loaded["src.ui.game3.rs.menu_cursor"] = { draw = function() end } +package.loaded["src.ui.game3.rs.party_chrome"] = { textOptions = function() return {} end } +local menu = { ACTIONS = { "SUMMARY", "CANCEL" }, actionCursor = 1, + _actionTextsFor = function(list) resolved = resolved + 1 return { "RESUME", "RETOUR" } end } +PartyData.drawActions(menu, false) +check(drawn[2] == "RESUME" and drawn[3] == "RETOUR" and resolved == 1, "the action menu draws the texts resolved for its list") +local displayName = package.loaded["src.core.game3.pokemon"].displayName +package.loaded["src.core.game3.pokemon"].displayName = function(mon) return mon.nickname end +BUNDLE.text = { ["PartyMenuPromptTexts[5]"] = { { t = "text", s = "Que faire avec " }, { t = "strvar", n = 1 }, + { t = "text", s = "?" }, { t = "eos" } } } +drawn, menu.ACTIONS, menu._party, menu.cursor = {}, { "SUMMARY", "CANCEL" }, { { nickname = "POUSSIFEU" } }, 1 +PartyData.drawActions(menu, false) +check(drawn[1] == "Que faire avec POUSSIFEU?", "the action prompt reads the cache and names the POKeMON") +BUNDLE.text = {} +drawn = {} +PartyData.drawActions(menu, false) +check(drawn[1] == "Que faire avec POUSSIFEU?", "the prompt resolved for this menu is drawn again without a lookup") +BUNDLE.text = { ["PartyMenuPromptTexts[5]"] = { { t = "text", s = "Que faire avec " }, { t = "strvar", n = 1 }, + { t = "text", s = "?" }, { t = "eos" } } } +drawn, menu.ACTIONS, menu._party = {}, { "SUMMARY", "CANCEL" }, { { nickname = "GOBOU" } } +PartyData.drawActions(menu, false) +check(drawn[1] == "Que faire avec GOBOU?", "a new action menu names its own POKeMON") +package.loaded["src.core.game3.pokemon"].displayName = displayName +package.loaded["src.ui.game3.frlg_font"], package.loaded["src.ui.game3.chrome"] = font, chrome +package.loaded["src.ui.game3.rs.menu_cursor"], package.loaded["src.ui.game3.rs.party_chrome"] = cursor, partyChrome +if failed > 0 then + print(failed .. " check(s) failed") + os.exit(1) +end +print("all checks passed") 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_diploma_bg_palette_s4.lua b/tests/engine/gen2_diploma_bg_palette_s4.lua new file mode 100644 index 00000000..7cd0397e --- /dev/null +++ b/tests/engine/gen2_diploma_bg_palette_s4.lua @@ -0,0 +1,83 @@ +-- engine/gfx/cgb_layouts.asm:488-499 _CGB_Diploma, gfx/sgb/predef.pal:28 +-- GOLD_CACHE= CRYSTAL_CACHE= luajit tests/engine/gen2_diploma_bg_palette_s4.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local CacheContract = require("src.import.CacheContract") +local NamingScreen = require("src.ui.gen2.NamingScreen") +local Diploma = require("src.ui.gen2.Diploma") + +local SET0 = { { 222, 255, 222 }, { 173, 173, 173 }, { 107, 107, 107 }, { 0, 0, 0 } } +local PREDEF = { { 255, 255, 255 }, { 247, 181, 140 }, { 132, 115, 156 }, { 0, 0, 0 } } +local gfx = { palettes = { SET0 }, bgPalette = PREDEF } + +local naming = NamingScreen.new({ data = { gen2Diploma = gfx } }, { type = "player" }) +eq(naming.palette, PREDEF, "naming screen BG uses PREDEFPAL_DIPLOMA, not DiplomaPalettes set 0") +local box = NamingScreen.new({ data = { gen2Diploma = gfx } }, { type = "box" }) +eq(box.palette, PREDEF, "box naming path uses the same layout") +local diploma = Diploma.new(nil, { playerName = "GOLD", gfx = gfx }) +eq(diploma:palette(), PREDEF, "diploma page uses PREDEFPAL_DIPLOMA") + +for _, version in ipairs({ "gold", "silver", "crystal" }) do + local found = false + for _, path in ipairs(CacheContract.requiredFiles(version)) do + if path == "data/generated/diploma.lua" then found = true end + end + check(found, version .. " requires data/generated/diploma.lua") +end + +local function scale5(v) return math.floor(v * 255 / 31 + 0.5) end + +local function decompPredef(repo) + local f = io.open(repo .. "/gfx/sgb/predef.pal", "r") + if not f then return nil end + for line in f:lines() do + if line:find("PREDEFPAL_DIPLOMA", 1, true) then + f:close() + local n = {} + for v in line:gmatch("%d+") do n[#n + 1] = tonumber(v) end + local out = {} + for i = 0, 3 do + out[i + 1] = { scale5(n[i * 3 + 1]), scale5(n[i * 3 + 2]), scale5(n[i * 3 + 3]) } + end + return out + end + end + f:close() + return nil +end + +local appSupport = (os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/" +local caches = { + { "gold", os.getenv("GOLD_CACHE") or (appSupport .. "gold-dev/gold"), "../pokegold" }, + { "crystal", os.getenv("CRYSTAL_CACHE") or (appSupport .. "crystal-dev/crystal"), "../pokecrystal" }, +} + +for _, entry in ipairs(caches) do + local version, dir, repo = entry[1], entry[2], entry[3] + local mf = io.open(dir .. "/" .. CacheContract.MARKER_PATH, "r") + local marker = mf and mf:read("*a") + if mf then mf:close() end + local chunk = marker and CacheContract.markerMatches(version, marker) + and loadfile(dir .. "/data/generated/diploma.lua") + if not chunk then + print("[skip] no current " .. version .. " cache at " .. dir) + else + local data = chunk() + local want = decompPredef(repo) + check(type(data.bgPalette) == "table", version .. " cache carries bgPalette") + if not want then + print("[skip] no " .. repo .. "/gfx/sgb/predef.pal") + else + for i = 1, 4 do + T.same(data.bgPalette[i], want[i], version .. " bgPalette colour " .. (i - 1) .. " matches predef.pal") + end + end + end +end + +T.finish("gen2 diploma bg palette S4") 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/gen2_party_icon_siblings_test.lua b/tests/engine/gen2_party_icon_siblings_test.lua new file mode 100644 index 00000000..e949cc9c --- /dev/null +++ b/tests/engine/gen2_party_icon_siblings_test.lua @@ -0,0 +1,157 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +package.loaded["src.render.Assets"] = { + register = function() end, + image = function(path) + return { + path = path, + getDimensions = function() return 16, 32 end, + getWidth = function() return 16 end, + getHeight = function() return 32 end, + } + end, +} + +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 + +local GbcPalette = require("src.render.GbcPalette") +local Runtime = require("src.mods.Runtime") +local PartyMenu = require("src.ui.gen2.PartyMenu") +local NamingScreen = require("src.ui.gen2.NamingScreen") + +local RED = { { 1, 2, 3 }, { 4, 5, 6 } } + +local function newGame(trueColorIcon) + return { + data = { + gen2Icons = { + species = { CHIKORITA = "ICON_ODDISH", MAGNEMITE = "ICON_VOLTORB" }, + icons = { + ICON_ODDISH = { image = "icons/oddish.png" }, + ICON_VOLTORB = { image = "icons/voltorb.png", + trueColor = trueColorIcon or nil }, + ICON_EGG = { image = "icons/egg.png" }, + }, + }, + gen2Palettes = { partyMenu = { RED } }, + }, + } +end + +local function mon(hp, maxHp, species) + return { species = species or "CHIKORITA", name = "CHIKORITA", + hp = hp, maxHp = maxHp, gender = "male" } +end + +local function frameAt(menu, m, clock) + menu.clock = clock + return menu:iconFrame(m) +end + +do + local menu = PartyMenu.new(newGame(), { party = {} }) + local green = mon(48, 48) + local yellow = mon(15, 48) + local red = mon(4, 48) + local fainted = mon(0, 48) + + T.eq(PartyMenu.iconHpBand(green), "green", "full HP is HP_GREEN") + T.eq(PartyMenu.iconHpBand(yellow), "yellow", "15/48 is HP_YELLOW") + T.eq(PartyMenu.iconHpBand(red), "red", "4/48 is HP_RED") + T.eq(PartyMenu.iconHpBand(fainted), "red", "a fainted mon is HP_RED") + + T.eq(PartyMenu.iconFrameSteps(green), 9, "green pose holds duration 8 + 1") + T.eq(PartyMenu.iconFrameSteps(yellow), 73, "yellow adds $40") + T.eq(PartyMenu.iconFrameSteps(red), 137, "red adds $80") + T.eq(PartyMenu.iconFrameSteps(fainted), 137, "fainted runs at the red rate") + + T.eq(frameAt(menu, green, 8), 0, "green still frame 0 at tick 8") + T.eq(frameAt(menu, green, 9), 1, "green frame 1 at tick 9") + T.eq(frameAt(menu, green, 18), 0, "green back to frame 0 at tick 18") + T.eq(frameAt(menu, yellow, 72), 0, "yellow still frame 0 at tick 72") + T.eq(frameAt(menu, yellow, 73), 1, "yellow frame 1 at tick 73") + T.eq(frameAt(menu, red, 136), 0, "red still frame 0 at tick 136") + T.eq(frameAt(menu, red, 137), 1, "red frame 1 at tick 137") + T.eq(frameAt(menu, fainted, 137), 1, "a fainted icon still animates") + + menu.index = 1 + menu.clock = 16 + T.eq(menu:iconBob(1, green), -2, "selected green icon bobs 2px") + T.eq(menu:iconBob(1, yellow), -1, "selected yellow icon bobs 1px") + T.eq(menu:iconBob(1, red), 0, "selected red icon does not bob") + T.eq(menu:iconBob(2, green), 0, "unselected icon does not bob") + menu.clock = 15 + T.eq(menu:iconBob(1, green), 0, "no bob in the first 16 ticks") +end + +local used +local realAvailable, realUse, realMode = + GbcPalette.available, GbcPalette.use, GbcPalette.mode +GbcPalette.available = function() return true end +GbcPalette.use = function(colors) used[#used + 1] = colors end +GbcPalette.mode = "gbc" + +do + local menu = PartyMenu.new(newGame(true), { party = {} }) + used = {} + menu:drawIcon(mon(48, 48), 0, 0) + T.eq(#used, 1, "a ROM icon in the party list wears PAL_OW_RED") + used = {} + menu:drawIcon(mon(48, 48, "MAGNEMITE"), 0, 0) + T.eq(#used, 0, "a trueColor icon record skips the party palette") +end + +do + used = {} + local screen = NamingScreen.new(newGame(true), + { type = "nickname", mon = mon(48, 48, "MAGNEMITE") }) + screen:drawPanel() + local hitRed = false + for _, c in ipairs(used) do if c == RED then hitRed = true end end + T.eq(hitRed, false, "a trueColor icon skips the naming screen palette") + + used = {} + screen = NamingScreen.new(newGame(true), { type = "nickname", mon = mon(48, 48) }) + screen:drawPanel() + hitRed = false + for _, c in ipairs(used) do if c == RED then hitRed = true end end + T.eq(hitRed, true, "a ROM icon on the naming screen wears PAL_OW_RED") +end + +do + local realWants, realCall = Runtime.wantsHook, Runtime.call + Runtime.wantsHook = function(name) return name == "pokemon.icon" end + Runtime.call = function(_, _, _, ctx) + ctx.trueColor = true + return "mods/skin/icon.png" + end + local menu = PartyMenu.new(newGame(), { party = {} }) + used = {} + menu:drawIcon(mon(48, 48), 0, 0) + T.eq(#used, 0, "a pokemon.icon hook's trueColor skips the party palette") + + used = {} + local screen = NamingScreen.new(newGame(), { type = "nickname", mon = mon(48, 48) }) + screen:drawPanel() + local hitRed = false + for _, c in ipairs(used) do if c == RED then hitRed = true end end + T.eq(hitRed, false, "and the naming screen palette") + Runtime.wantsHook, Runtime.call = realWants, realCall +end + +GbcPalette.available, GbcPalette.use, GbcPalette.mode = + realAvailable, realUse, realMode + +T.finish("gen2_party_icon_siblings_test") diff --git a/tests/engine/gen2_save_import_message.lua b/tests/engine/gen2_save_import_message.lua index d8f2949a..4756c56c 100644 --- a/tests/engine/gen2_save_import_message.lua +++ b/tests/engine/gen2_save_import_message.lua @@ -30,6 +30,7 @@ package.loaded["src.core.SaveData"] = { activeSlot = function() return "slot1" end, buildMeta = function(_, m) return m or {} end, createSlot = function() return "slot1" end, + claimImportPlaythroughId = function() return nil end, writeSlot = function(_, _, save) written[#written + 1] = save return true end, setActiveSlot = function() return true end, } diff --git a/tests/engine/gen2_wheel_zoom_persists_bug2166.lua b/tests/engine/gen2_wheel_zoom_persists_bug2166.lua index 564764f8..7325d9f2 100644 --- a/tests/engine/gen2_wheel_zoom_persists_bug2166.lua +++ b/tests/engine/gen2_wheel_zoom_persists_bug2166.lua @@ -11,6 +11,7 @@ local check, eq = T.check, T.eq local Zoom = require("src.render.Zoom") local Game2 = require("src.core.Game2") +local DeferredWrite = require("src.core.DeferredWrite") local function fakeGame() local persisted = 0 @@ -35,6 +36,7 @@ do eq(Zoom.offset, -1, "wheel down zooms the live world one step out") eq(game.options.zoom, -1, "BUG FIX: wheel zoom is written into options.zoom") eq(game.save.options, game.options, "the save's options table follows") + DeferredWrite.flush() eq(persisted(), 1, "and persisted once") Game2.applyOptions(game) @@ -43,7 +45,8 @@ do Game2.wheelmoved(game, 0, 1) Game2.wheelmoved(game, 0, 1) eq(game.options.zoom, 1, "wheel up steps back in and is stored each time") - eq(persisted(), 3, "one persist per wheel step") + DeferredWrite.flush() + eq(persisted(), 2, "a burst of wheel steps persists once") end do @@ -51,8 +54,10 @@ do local game, persisted = fakeGame() Game2.hotkey(game, "-") eq(game.options.zoom, -1, "the - hotkey still stores the offset") + DeferredWrite.flush() Game2.hotkey(game, "4") eq(game.options.zoom, Zoom.offset, "the 4 hotkey still stores the offset") + DeferredWrite.flush() eq(persisted(), 2, "hotkeys persist once each") end diff --git a/tests/engine/host_restart_android_bug575.lua b/tests/engine/host_restart_android_bug575.lua index 3d5c7dc0..8eba0487 100644 --- a/tests/engine/host_restart_android_bug575.lua +++ b/tests/engine/host_restart_android_bug575.lua @@ -1,13 +1,8 @@ --- #575: HostShell.restart on Android must never reach love.event.quit --- ("restart") -- the vendored love.cpp loops runlove() in-process on --- "restart" and the second PHYSFS_init crashes ("already initialized"). --- The fix prefers the love.system.restartApp JNI bridge (which kills the --- process, so a true return is never observed live) and, on an old APK --- whose liblove lacks the bridge, falls back to a CLEAN quit with no --- argument. iOS has no restartApp bridge and love.cpp forces DONE_RESTART --- for every quit; HostShell.restart must still refuse quit("restart") so a --- leftover caller does not pick the worker-join + native-restart path that --- crashes EXIT GAME. Desktop keeps the in-process quit("restart"). +-- #575: Android restarts in-process. The restartApp alarm relaunch is a +-- background activity start that Android 14 blocks, so the app closed to the +-- home screen instead of coming back; love.quit joins every worker before +-- the restart, so the second PHYSFS_init no longer finds open handles. +-- iOS turns every quit into DONE_RESTART, so it keeps a bare quit(). -- luajit tests/engine/host_restart_android_bug575.lua package.path = "./?.lua;./?/init.lua;" .. package.path @@ -20,8 +15,6 @@ local HostShell = require("src.core.HostShell") local quits = {} love.event = { - -- record the argument distinctly from "called with none": quit() and - -- quit("restart") are the whole difference this test pins quit = function(...) quits[#quits + 1] = { n = select("#", ...), arg = (...) } end, @@ -31,38 +24,28 @@ local osName = "Android" local restartCalls = 0 love.system = love.system or {} love.system.getOS = function() return osName end - --- bridge present and schedulable: restart goes through it, quit untouched love.system.restartApp = function() restartCalls = restartCalls + 1 return true end -HostShell.restart() -eq(restartCalls, 1, "Android restart prefers the restartApp bridge (#575)") -eq(#quits, 0, "a scheduled relaunch never touches love.event.quit") --- bridge present but could not schedule: clean quit, never quit("restart") -love.system.restartApp = function() restartCalls = restartCalls + 1 return false end HostShell.restart() -eq(restartCalls, 2, "the bridge is still tried first") -eq(#quits, 1, "a failed schedule falls back to one quit") -eq(quits[1].n, 0, "and it is a bare quit(), not quit(\"restart\")") +eq(restartCalls, 0, "Android never schedules the blocked restartApp relaunch") +eq(#quits, 1, "Android restart quits once") +eq(quits[1].arg, "restart", "and it is the in-process quit(\"restart\")") --- old APK, no bridge compiled in: same clean quit fallback -love.system.restartApp = nil -HostShell.restart() -eq(#quits, 2, "a bridge-less APK quits cleanly instead of crashing") -eq(quits[2].n, 0, "again with no restart argument") - --- iOS: no process-kill bridge; never quit("restart") osName = "iOS" HostShell.restart() -eq(#quits, 3, "iOS HostShell.restart still quits once") -eq(quits[3].n, 0, "iOS uses a bare quit(), never quit(\"restart\")") +eq(#quits, 2, "iOS HostShell.restart quits once") +eq(quits[2].n, 0, "iOS uses a bare quit(), which love.cpp turns into a restart") --- desktop (no AppImage in a test environment) keeps the in-process restart if not os.getenv("APPIMAGE") then osName = "OS X" HostShell.restart() - eq(quits[4] and quits[4].arg, "restart", - "non-mobile still restarts in-process") + eq(quits[3] and quits[3].arg, "restart", "desktop restarts in-process") end +local f = assert(io.open("main.lua", "rb")) +local mainSrc = f:read("*a") +f:close() +check(mainSrc:find('a ~= "restart" and love.system and love.system.getOS() == "Android"', 1, true) ~= nil, + "love.run lets an Android quit(\"restart\") reach LOVE's boot loop instead of os.exit") + T.finish("host_restart_android_bug575") 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_http_nonascii.lua b/tests/engine/hostshell_win_http_nonascii.lua new file mode 100644 index 00000000..0618cde6 --- /dev/null +++ b/tests/engine/hostshell_win_http_nonascii.lua @@ -0,0 +1,161 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local SAVE = "C:/Users/H\xC3\xBCseyin/AppData/Roaming/LOVE/gen1recomp" +local SAVE_WIN = "C:\\Users\\H\xC3\xBCseyin\\AppData\\Roaming\\LOVE\\gen1recomp" +local MARK = "\n__gen1recomp_http__" +local URL = "https://example.com/a.zip" + +local creates, closes, pipeOutput, createResult = {}, {}, "", 1 + +local fakeFfi = { os = "Windows" } +function fakeFfi.cdef() end +function fakeFfi.new(ct) + if ct == "PP_STARTUPINFOW" then return { cb = 0, dwFlags = 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 + if ct == "PP_SECURITY_ATTRIBUTES" then return 24 end + return 0 +end +function fakeFfi.string(buf, n) return buf.data:sub(1, n) end +fakeFfi.C = { + MultiByteToWideChar = function(cp, _, s, _, w) + assert(cp == 65001, "CP_UTF8") + if w ~= nil then w.src = s end + return #s + end, + CreatePipe = function(rd, wr, sa) + assert(sa.bInheritHandle == 1, "inheritable write end") + rd[0], wr[0] = "rd", "wr" + return 1 + end, + SetHandleInformation = function() return 1 end, + CreateProcessW = function(app, cmd, _, _, inherit, flags, _, cwd, si) + creates[#creates + 1] = { + app = app and app.src, cmd = cmd and cmd.src, cwd = cwd and cwd.src, + inherit = inherit, flags = flags, si = si, + } + return createResult + end, + ReadFile = function(_, buf, _, got) + if pipeOutput == "" then got[0] = 0 return 0 end + buf.data, got[0], pipeOutput = pipeOutput, #pipeOutput, "" + return 1 + end, + WaitForSingleObject = function() return 0 end, + GetExitCodeProcess = function(_, code) code[0] = 0 return 1 end, + CloseHandle = function(h) closes[#closes + 1] = h return 1 end, +} +package.loaded.ffi = fakeFfi + +local files, removed = {}, {} +local osName = "Windows" +love = { + system = { getOS = function() return osName end }, + filesystem = { + getSaveDirectory = function() return SAVE end, + write = function(name, data) files[name] = data return true end, + remove = function(name) removed[#removed + 1] = name files[name] = nil return true end, + }, +} + +local shellCalls, lastShell = 0, nil +os.execute = function() shellCalls = shellCalls + 1 return 0 end +io.popen = function(cmd) + shellCalls, lastShell = shellCalls + 1, cmd + return { + read = function() return "body" .. MARK .. "200" end, + close = function() return true end, + } +end + +local HostShell = require("src.core.HostShell") +HostShell.haveCurl = function() return true end + +local function ascii(s) return not s:find("[\128-\255]") end + +local cwd, rels = HostShell.winRelative({ SAVE .. "/mods/x.zip", SAVE .. "/etag/x.etag" }) +eq(cwd, SAVE_WIN, "common folder of the paths becomes the cwd") +eq(rels[1], "mods\\x.zip", "first path relative to it") +eq(rels[2], "etag\\x.etag", "second path relative to it") +cwd = HostShell.winRelative({ "D:/x.zip" }) +eq(cwd, "D:\\", "drive root keeps its backslash") +cwd = HostShell.winRelative({ "\\\\server\\share\\x.zip" }) +eq(cwd, "\\\\server\\share", "UNC prefix kept") +cwd, rels = HostShell.winRelative({ SAVE .. "/Pok\xC3\xA9/x.zip", SAVE .. "/a.etag" }) +eq(rels[1], nil, "a non-ASCII relative name is not offered") +eq(rels[2], "a.etag", "the ASCII one still is") + +pipeOutput = MARK .. "200" +local ok, err = HostShell.httpDownload(URL, SAVE .. "/mods/x.zip", "ua", nil, 90, + SAVE .. "/etag/x.etag") +eq(ok, true, "download succeeds: " .. tostring(err)) +eq(shellCalls, 0, "download never goes through cmd.exe or the ANSI CRT") +eq(#creates, 1, "curl spawned once") +local c = creates[1] or {} +eq(c.app, nil, "curl resolved through the search path") +eq(c.cwd, SAVE_WIN, "curl runs in the save folder, passed wide") +check(c.cmd and ascii(c.cmd), "no non-ASCII byte reaches curl's argv") +check(c.cmd and c.cmd:find(" -o mods\\x.zip ", 1, true) ~= nil, "-o is relative") +check(c.cmd and c.cmd:find("--etag-compare etag\\x.etag --etag-save etag\\x.etag", 1, true) ~= nil, + "etag paths are relative") +check(c.cmd and c.cmd:find('"User-Agent: ua"', 1, true) ~= nil, "header quoted per CommandLineToArgvW") +check(c.cmd and c.cmd:sub(-#URL) == URL, "url last") +eq(c.flags, 0x08000000, "CREATE_NO_WINDOW") +eq(c.inherit, 1, "pipe write end inherited") +eq(c.si and c.si.dwFlags, 0x100, "STARTF_USESTDHANDLES") +eq(c.si and c.si.hStdOutput, "wr", "stdout to the pipe") +eq(c.si and c.si.hStdError, "wr", "stderr merged into the pipe") +eq(table.concat(closes, ","), "wr,thread,rd,proc", "every handle closed") + +closes = {} +pipeOutput = "{\"tag\":1}" .. MARK .. "200" +local body = HostShell.httpGet("https://example.com/api", "ua") +eq(body, "{\"tag\":1}", "httpGet returns the body read from the pipe") +eq(creates[2] and creates[2].cwd, nil, "a GET with no paths keeps the inherited cwd") +eq(shellCalls, 0, "httpGet never shells out") + +pipeOutput = MARK .. "201" +ok, err = HostShell.httpPost("https://example.com/logs", "log body", "text/plain", "ua", 10) +eq(ok, true, "post succeeds: " .. tostring(err)) +local p = creates[3] or {} +eq(p.cwd, SAVE_WIN, "post runs in the save folder") +local staged = p.cmd and p.cmd:match("%-%-data%-binary @(%S+)") +check(staged and staged:find("^gen1recomp%-post%-") ~= nil, "body is staged by a relative name") +check(p.cmd and ascii(p.cmd), "post argv is ASCII") +eq(removed[#removed], staged, "staged body removed through love.filesystem") +eq(shellCalls, 0, "post never shells out") + +pipeOutput = "ok" .. MARK .. "200" +local resp, rerr, status = HostShell.httpRequest("https://example.com/sync", + { method = "PUT", headers = { ["x-token"] = "t" }, body = "{}" }) +eq(resp, "ok", "request body returned: " .. tostring(rerr)) +eq(status, 200, "request status returned") +local r = creates[4] or {} +eq(r.cwd, SAVE_WIN, "request runs in the save folder") +check(r.cmd and r.cmd:find("-H @gen1recomp-req-head-", 1, true) ~= nil, "header file relative") +check(r.cmd and r.cmd:find("--data-binary @gen1recomp-req-body-", 1, true) ~= nil, "body file relative") +check(r.cmd and ascii(r.cmd), "request argv is ASCII") +eq(next(files), nil, "every staged file removed") +eq(shellCalls, 0, "request never shells out") + +createResult = 0 +body = HostShell.httpGet("https://example.com/api", "ua") +eq(shellCalls, 1, "a failed wide spawn falls back to the shell") +eq(body, "body", "and the fallback still answers") +createResult = 1 + +osName = "OS X" +shellCalls = 0 +HostShell.httpGet("https://x/y", "ua") +eq(lastShell, "'curl' -sSL --proto =http,https --proto-redir =http,https --connect-timeout 10 " + .. "--max-time 40 -H 'User-Agent: ua' -w '\\n__gen1recomp_http__%{http_code}' 'https://x/y' 2>&1", + "POSIX command line unchanged") +eq(#creates, 5, "POSIX never spawns through WinApi") + +T.finish("hostshell_win_http_nonascii") 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..fb9b29f7 --- /dev/null +++ b/tests/engine/hp_bar_speed_bug2723.lua @@ -0,0 +1,141 @@ +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 + +local function cartDrain(side, maxHP, fromHP, toHP) + local b = newBattle(100) + local battler = b[side] + battler.mon.stats.hp = maxHP + battler.mon.hp = fromHP + battler.shownHP = fromHP + battler.shownPx = nil + battler.drainHold = nil + return drainFrames(b, battler, toHP), battler +end + +-- engine/gfx/hp_bar.asm:81-135 +for _, m in ipairs({ + { "enemy", 200, 200, 1, 152 }, { "enemy", 150, 150, 0, 147 }, + { "enemy", 400, 400, 0, 243 }, { "enemy", 300, 300, 263, 29 }, + { "enemy", 240, 240, 1, 171 }, + { "player", 150, 150, 0, 250 }, { "player", 399, 399, 199, 254 }, + { "player", 256, 256, 0, 356 }, +}) do + local frames, battler = cartDrain(m[1], m[2], m[3], m[4]) + T.eq(frames, m[5], string.format("%s stepper %d -> %d of %d runs %d frames", + m[1], m[3], m[4], m[2], m[5])) + T.eq(frames, Timing.hpDrainFrames(m[3], m[4], m[2], m[1] == "player"), + string.format("%s stepper %d -> %d of %d matches the closed form", + m[1], m[3], m[4], m[2])) + T.eq(battler.shownPx, Timing.hpBarPixels(m[4], m[2]), + string.format("%s bar settles on %d px", m[1], Timing.hpBarPixels(m[4], m[2]))) +end + +T.finish("hp bar speed") diff --git a/tests/engine/launcher_return_pointer_bug2079.lua b/tests/engine/launcher_return_pointer_bug2079.lua index 2a865248..c9dbdfcd 100644 --- a/tests/engine/launcher_return_pointer_bug2079.lua +++ b/tests/engine/launcher_return_pointer_bug2079.lua @@ -116,9 +116,9 @@ end do local main = assert(io.open("main.lua")):read("*a") - local body = main:match("local function returnToLauncher%(opts%)(.-)\nend\n") - check(body ~= nil, "main.lua still has returnToLauncher") - check(body:find("ignoreReturningPointer", 1, true) ~= nil, + local body = main:match("Importer = makeLauncher%(%{%s*initialTab = type%(handoff%.tab%)(.-)\nend\n") + check(body ~= nil, "main.lua rebuilds the launcher from the relaunch handoff") + check(body and body:find("ignoreReturningPointer", 1, true) ~= nil, "EXIT GAME asks the new launcher to ignore the leftover pointer (#2079)") end 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/quit_thread_shutdown.lua b/tests/engine/quit_thread_shutdown.lua index 8f3fe744..3f0a2bf6 100644 --- a/tests/engine/quit_thread_shutdown.lua +++ b/tests/engine/quit_thread_shutdown.lua @@ -196,8 +196,10 @@ check(checkSrc:match('cmd%.cmd == "quit"%s*then%s*\n%s*break') ~= nil, local mainSrc = source("main.lua") local quitHook = mainSrc:match("\nfunction love%.quit%(%).-\nend\n") check(quitHook ~= nil, "love.quit is still a single top-level function") -check(mainSrc:find("SessionLifecycle.endProcess()", 1, true) ~= nil, - "love.quit shuts workers down via SessionLifecycle.endProcess") +check(mainSrc:find('require("src.core.SessionLifecycle").endProcess()', 1, true) ~= nil, + "endProcessOnce shuts workers down via SessionLifecycle.endProcess") +check(quitHook and quitHook:find("endProcessOnce()", 1, true) ~= nil, + "love.quit shuts workers down via endProcessOnce") local lifecycleSrc = source("src/core/SessionLifecycle.lua") check(lifecycleSrc:find("registerProcessShutdown", 1, true) ~= nil, @@ -213,15 +215,15 @@ check(source("src/update/Check.lua"):find("registerProcessShutdown(Check.shutdow check(source("src/net/Fetch.lua"):find("registerProcessShutdown(Fetch.shutdown)", 1, true) ~= nil, "Fetch registers its shutdown hook at load") --- iOS EXIT GAME must share Android's in-process returnToLauncher: love.cpp --- under LOVE_IOS forces DONE_RESTART for every quit and warns that leftover --- threads make that restart unreliable (ChipAudio / Fetch / Check). -check(quitHook:find('osName == "Android" or osName == "iOS"', 1, true) ~= nil, - "love.quit treats Android and iOS as in-process return platforms") -check(quitHook:find("inProcessReturn", 1, true) ~= nil, - "love.quit gates returnToLauncher on inProcessReturn") -check(quitHook:find('require("src.core.HostShell").restart()', 1, true) ~= nil, - "desktop return-to-launcher still reaches HostShell.restart") +local returnBody = mainSrc:match("local function returnToLauncher%(opts%)(.-)\nend\n") or "" +check(quitHook:find("returnToLauncher()", 1, true) ~= nil, + "love.quit routes a game window close to returnToLauncher on every platform") +check(quitHook:find("inProcessReturn", 1, true) == nil, + "no platform keeps the old in-process launcher swap") +local joinAt = returnBody:find("endProcessOnce()", 1, true) +local restartAt = returnBody:find('require("src.core.HostShell").restart()', 1, true) +check(joinAt ~= nil and restartAt ~= nil and joinAt < restartAt, + "returnToLauncher joins every worker before HostShell.restart") -- The Android half: LOVE keeps the JVM process after the native main returns, -- so the quit event exits the process outright. It has to sit after the diff --git a/tests/engine/require_guard_test.lua b/tests/engine/require_guard_test.lua index e01b84ec..9611658a 100644 --- a/tests/engine/require_guard_test.lua +++ b/tests/engine/require_guard_test.lua @@ -90,8 +90,8 @@ check(bootBody:find('require("src.core.RequireGuard").repair()', 1, true) ~= nil local returnBody = functionBody("local function returnToLauncher%(") check(returnBody ~= "", "main.lua still defines returnToLauncher") -check(returnBody:find('require("src.core.RequireGuard").repair()', 1, true) ~= nil, - "returnToLauncher repairs the loader chain") +check(returnBody:find('require("src.core.HostShell").restart()', 1, true) ~= nil, + "returnToLauncher restarts into a fresh Lua state, whose love.load captures the loader chain again") local issueBody = readFile("src/core/IssueReport.lua") or "" check(issueBody:find("RequireGuard", 1, true) ~= nil diff --git a/tests/engine/save_import_playthrough_id_s7.lua b/tests/engine/save_import_playthrough_id_s7.lua new file mode 100644 index 00000000..48b4dffa --- /dev/null +++ b/tests/engine/save_import_playthrough_id_s7.lua @@ -0,0 +1,161 @@ +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 SaveFileIO = require("src.import.SaveFileIO") + +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, + getSaveDirectory = function() return "/fake/save" 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() + local files = {} + love.filesystem = memfs(files) + SaveData.resetSlotState() + GameVersion.set("red") + return files +end + +local ID = "0123456789abcdef0123456789abcdef" + +local function sample(version, id) + return { + version = version, + player = { name = "ASH", map = "PALLET_TOWN", x = 1, y = 1 }, + party = { { species = "BULBASAUR", level = 7 } }, + money = 1234, + meta = { format = 5, mods = {}, playthroughId = id }, + } +end + +local function slotId(version, slot) + local save = SaveSerializer.decode(SaveData.readSlotSource(version, slot) or "") + return save and save.meta and save.meta.playthroughId +end + +do + local files = fresh() + local src = SaveData.createSlot("red") + SaveData.writeSlot("red", src, sample("red", ID)) + SaveData.setActiveSlot("red", src) + T.eq(SaveData.slotPlaythroughId("red", src, sample("red", ID)), ID, "red: the source owns the id") + files["mod_storage/red/" .. ID .. "/m/data.lua"] = "return { n = 1 }" + + local ok, rel = SaveFileIO.exportLuaSlot("red", src) + T.eq(ok, true, "red: the slot exports as .lua") + rel = tostring(rel):match("(exports/.+)$") + local imported, copy = SaveFileIO.importToSlot(rel, "red") + T.eq(imported, true, "red: the export imports back beside its source") + local newId = slotId("red", copy) + T.check(type(newId) == "string" and newId ~= ID, "red: the import gets its own playthroughId") + T.eq(SaveData.loadOptions().playthroughIds.red[copy], newId, "red: the new slot is mapped to the new id") + T.eq(SaveData.loadOptions().playthroughIds.red[src], ID, "red: the source keeps its mapping") + T.eq(slotId("red", src), ID, "red: the source file 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(SaveData.deleteSlot("red", copy), true, "red: the import deletes") + T.eq(SaveData.loadOptions().playthroughIds.red[src], ID, "red: deleting the import leaves the source mapping") + T.eq(files["mod_storage/red/" .. ID .. "/m/data.lua"], "return { n = 1 }", + "red: and the source's mod storage") +end + +do + fresh() + local src = SaveData.createSlot("crystal") + SaveData.writeSlot("crystal", src, { version = "crystal", generation = 2, party = {}, meta = {} }) + local opts = SaveData.loadOptions() + opts.playthroughIds = { crystal = { [src] = ID } } + SaveData.saveOptions(opts) + local slot = SaveData.createSlot("crystal") + local save = sample("crystal", ID) + local got = SaveData.claimImportPlaythroughId("crystal", slot, save) + T.check(got ~= ID and save.meta.playthroughId == got, + "crystal: an id only the options mapping holds still counts as taken") +end + +do + fresh() + local slot = SaveData.createSlot("firered") + local opts = SaveData.loadOptions() + opts.playthroughIds = { firered = { [slot] = ID } } + SaveData.saveOptions(opts) + local save = sample("firered", ID) + T.eq(SaveData.claimImportPlaythroughId("firered", slot, save), ID, + "firered: re-importing into the slot that owns the id keeps it") + T.eq(save.meta.playthroughId, ID, "firered: the save is not re-stamped") +end + +do + fresh() + local slot = SaveData.createSlot("red") + local save = sample("red", nil) + T.eq(SaveData.claimImportPlaythroughId("red", slot, save), nil, "red: an id-less import is left for lazy minting") + T.eq(save.meta.playthroughId, nil, "red: and is not stamped") +end + +if loadfile("data/generated/pokemon.lua") then + local data = require("tests.save_compat._codec").gen1Data("red") + local stampMapWindow = loadfile("tests/fixture_data/map_window.lua")() + for mapId in pairs(data.maps) do stampMapWindow(data, mapId) end + local files = fresh() + local slot = SaveData.createSlot("red") + SaveData.setActiveSlot("red", slot) + local save = SaveData.newGame({ playerName = "ASH", rivalName = "GARY" }) + save.meta = SaveData.buildMeta({}, save.meta, os.time() - 60) + T.check(SaveData.save(save), "red .sav: the source saves") + local ok = SaveFileIO.exportActiveSlot("red") + T.eq(ok, true, "red .sav: the slot exports") + local id = SaveData.loadOptions().playthroughIds.red[slot] + local out = files["exports/red/gen1recomp-red-" .. slot .. ".sav"] + local imported, copy = SaveFileIO.importToSlot(out, "red") + T.eq(imported, true, "red .sav: the export imports beside its source") + local newId = SaveData.loadOptions().playthroughIds.red[copy] + T.check(type(id) == "string" and type(newId) == "string" and newId ~= id, + "red .sav: a same-install round trip gets its own playthroughId") + T.eq(slotId("red", copy), newId, "red .sav: stamped into the imported slot") +else + print("[skip] red .sav round trip (needs data/generated/ for the Gen1 save codec)") +end + +love.filesystem = realFS +SaveData.resetSlotState() +GameVersion.set("red") + +T.finish("save_import_playthrough_id_s7") diff --git a/tests/engine/save_lua_import_tests.lua b/tests/engine/save_lua_import_tests.lua index 01efaf82..31a6cf42 100644 --- a/tests/engine/save_lua_import_tests.lua +++ b/tests/engine/save_lua_import_tests.lua @@ -237,8 +237,26 @@ for _, version in ipairs(GameVersion.ORDER) do check(slotId ~= nil and slotId ~= src, version .. " the import is a new slot") eq(SaveData.activeSlot(version), slotId, version .. " the imported slot is made active") local back = SaveSerializer.decode(SaveData.readSlotSource(version, slotId) or "") + local newId = back and back.meta and back.meta.playthroughId + check(type(newId) == "string" and newId ~= "" and newId ~= original.meta.playthroughId, + version .. " an import beside its source gets its own playthroughId") + eq(SaveData.loadOptions().playthroughIds[version][slotId], newId, + version .. " and the new slot is mapped to it") + if back then back.meta.playthroughId = original.meta.playthroughId end eq(back and SaveSerializer.encode(back), SaveSerializer.encode(original), - version .. " the imported slot matches the exported save") + version .. " the imported slot matches the exported save apart from its id") + local srcBack = SaveSerializer.decode(SaveData.readSlotSource(version, src) or "") + eq(srcBack and SaveSerializer.encode(srcBack), SaveSerializer.encode(original), + version .. " the source slot is untouched") + + local body = files[rel] + local files2 = fresh() + local path = writeTmp(files2, "picked_save.sav", body) + local again, slot2 = SaveFileIO.importToSlot(path, version) + eq(again, true, version .. " the export imports on a fresh install") + local restored = SaveSerializer.decode(SaveData.readSlotSource(version, slot2) or "") + eq(restored and SaveSerializer.encode(restored), SaveSerializer.encode(original), + version .. " a fresh-install restore keeps the save byte-identical, id included") end local mismatches = { 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/save_slots.lua b/tests/engine/save_slots.lua index 00054006..2a70ffc8 100644 --- a/tests/engine/save_slots.lua +++ b/tests/engine/save_slots.lua @@ -335,8 +335,8 @@ do local main = assert(io.open("main.lua")):read("*a") local body = main:match("local function returnToLauncher%(opts%)(.-)\nend\n") T.check(body ~= nil, "main.lua still has returnToLauncher") - T.check(body:find("refreshSlotResolution%(currentVersion%)", 1, false) ~= nil, - "EXIT GAME refreshes only the version just left") + T.check(body and body:find('require("src.core.HostShell").restart()', 1, true) ~= nil, + "EXIT GAME restarts, so the launcher resolves every slot list fresh") T.check(body:find("resetSlotState", 1, true) == nil, "and does not call resetSlotState (that would clear cart/seal state)") end diff --git a/tests/engine/sdl_controller_support_test.lua b/tests/engine/sdl_controller_support_test.lua new file mode 100644 index 00000000..69b8ff78 --- /dev/null +++ b/tests/engine/sdl_controller_support_test.lua @@ -0,0 +1,294 @@ +-- luajit tests/engine/sdl_controller_support_test.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 Input = require("src.core.Input") +local GamepadMap = require("src.core.GamepadMap") +local BindingsMenu = require("src.ui.BindingsMenu") +local Game = require("src.core.Game") + +local pad = { isGamepad = function() return true end } + +local function newGame() + local game = { save = { options = {} }, data = {}, wroteOptions = 0 } + 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 = Input + function game:writeOptions() self.wroteOptions = self.wroteOptions + 1 end + function game:_cycleSpeed(dir) self.cycledSpeed = (self.cycledSpeed or 0) + dir end + return game +end + +Input:init() + +Input:reset() +Input:gamepadaxis(pad, "leftx", -0.9) +Input:step() +check(Input:wasPressed("left"), "left stick left presses left") +check(Input:isDown("left"), "left stick left holds left") + +Input:gamepadaxis(pad, "leftx", 0) +check(not Input:isDown("left"), "centering left stick releases left") + +Input:reset() +Input:gamepadaxis(pad, "lefty", -0.8) +Input:step() +check(Input:wasPressed("up"), "left stick up presses up") +check(Input:isDown("up"), "left stick up holds up") + +Input:gamepadaxis(pad, "lefty", 0) +check(not Input:isDown("up"), "centering left stick releases up") + +Input:reset() +Input:gamepadaxis(pad, "rightx", 0.9) +Input:step() +check(Input:wasPressed("right"), "right stick right presses right") +check(Input:isDown("right"), "right stick right holds right") + +Input:gamepadaxis(pad, "rightx", 0) +check(not Input:isDown("right"), "centering right stick releases right") + +Input:reset() +Input:gamepadaxis(pad, "righty", 0.8) +Input:step() +check(Input:wasPressed("down"), "right stick down presses down") +check(Input:isDown("down"), "right stick down holds down") + +Input:gamepadaxis(pad, "righty", 0) +check(not Input:isDown("down"), "centering right stick releases down") + +Input:reset() +Input:gamepadaxis(pad, "lefty", -0.4) +check(not Input:isDown("up"), "lefty -0.4 is below STICK_ON (0.5), does not trigger") + +Input:gamepadaxis(pad, "lefty", -0.7) +check(Input:isDown("up"), "lefty -0.7 triggers up") + +Input:gamepadaxis(pad, "lefty", -0.4) +check(Input:isDown("up"), "lefty -0.4 stays held above STICK_OFF (0.3)") + +Input:gamepadaxis(pad, "lefty", -0.2) +check(not Input:isDown("up"), "lefty -0.2 drops below STICK_OFF (0.3), releases up") + +Input:reset() +Input:gamepadaxis(pad, "leftx", 0.9) +Input:step() +check(Input:isDown("right"), "stick tilted right") +check(not Input:isDown("down"), "down not held") + +Input:gamepadaxis(pad, "lefty", 0.95) +Input:step() +check(not Input:isDown("right"), "rolling to down releases right") +check(Input:isDown("down"), "and presses down") + +Input:gamepadaxis(pad, "leftx", 0) +Input:gamepadaxis(pad, "lefty", 0) +check(not Input:isDown("down"), "centering releases down") + +Input:reset() +Input:gamepadpressed(pad, "dpup") +Input:gamepadaxis(pad, "lefty", -0.9) +Input:step() +check(Input:isDown("up"), "both D-pad and stick hold up") + +Input:gamepadreleased(pad, "dpup") +check(Input:isDown("up"), "releasing D-pad still keeps up held via stick") + +Input:gamepadaxis(pad, "lefty", 0) +check(not Input:isDown("up"), "releasing stick finally clears up") + +Input:reset() +local name, phase = Input:triggerAxis("triggerleft", 0.3) +check(phase == nil, "trigger at 0.3 is below threshold") + +name, phase = Input:triggerAxis("triggerleft", 0.6) +eq(phase, "pressed", "trigger at 0.6 fires pressed") + +name, phase = Input:triggerAxis("triggerleft", 0.3) +check(phase == nil, "trigger releasing to 0.3 stays held (hysteresis)") + +name, phase = Input:triggerAxis("triggerleft", 0.1) +eq(phase, "released", "trigger dropping to 0.1 fires released") + +local game = newGame() +local bm = BindingsMenu.new(game) +game.stack:push(bm) + +bm.index = 5 +bm.onChoose(bm.items[5]) +check(bm.capture == bm.items[5], "A row capture armed") + +bm:onGamepadPressed("triggerright") +check(game.save.options.bindings == nil, "pending trigger release") +bm:onGamepadReleased("triggerright") + +local bindings = game.save.options.bindings +eq(bindings.a.pad, "triggerright", "triggerright stored on row A") +eq(bm.items[5].right, "Z/R2", "row A displays Z/R2") + +Input:applyBindings(bindings) +Input:reset() +Input:gamepadaxis(pad, "triggerright", 0.8) +Input:step() +check(Input:isDown("a"), "pulling R2 presses A") +Input:gamepadaxis(pad, "triggerright", 0) +check(not Input:isDown("a"), "releasing R2 releases A") + +bm.index = 1 +bm.onChoose(bm.items[1]) +bm:onGamepadPressed("leftstick_up") +bm:onGamepadReleased("leftstick_up") + +eq(bindings.up.pad, "leftstick_up", "leftstick_up stored on row UP") +eq(bm.items[1].right, "UP/LS-UP", "row UP displays UP/LS-UP") + +bm.index = 2 +bm.onChoose(bm.items[2]) +bm:onGamepadPressed("rightstick_down") +bm:onGamepadReleased("rightstick_down") + +eq(bindings.down.pad, "rightstick_down", "rightstick_down stored on row DOWN") +eq(bm.items[2].right, "DOWN/RS-DN", "row DOWN displays DOWN/RS-DN") + +bm.index = 10 +bm.onChoose(bm.items[10]) +bm:onGamepadPressed("rightstick") +bm:onGamepadReleased("rightstick") + +eq(bindings.speedUp.pad, "rightstick", "rightstick stored on SPEED +") +eq(bm.items[10].right, "1/RS", "row SPEED + displays 1/RS") + +Input:applyBindings(bindings) +eq(Input:padAction("rightstick"), "speedUp", "rightstick triggers speedUp action") + +bm.index = 9 +bm.onChoose(bm.items[9]) +bm:onGamepadPressed("triggerleft") +bm:onGamepadReleased("triggerleft") + +eq(bindings.speedDown.pad, "triggerleft", "triggerleft stored on SPEED -") +eq(bm.items[9].right, "0/L2", "row SPEED - displays 0/L2") +Input:applyBindings(bindings) +eq(Input:padAction("triggerleft"), "speedDown", "triggerleft triggers speedDown action") + +local gameInstance = setmetatable(newGame(), { __index = Game }) +local bm2 = BindingsMenu.new(gameInstance) +gameInstance.stack:push(bm2) + +bm2.index = 6 +bm2.onChoose(bm2.items[6]) +check(bm2.capture == bm2.items[6], "Row B capture armed") + +Game.gamepadaxis(gameInstance, pad, "rightx", -0.8) +check(bm2.pending ~= nil and bm2.pending.value == "rightstick_left", + "Game:gamepadaxis routes stick tilt into capture as rightstick_left") + +Game.gamepadaxis(gameInstance, pad, "rightx", 0) +check(bm2.capture == nil, "centering stick completes capture") +eq(gameInstance.save.options.bindings.b.pad, "rightstick_left", "Row B bound to rightstick_left") +eq(bm2.items[6].right, "X/RS-LT", "Row B displays X/RS-LT") + +local bm3 = BindingsMenu.new(game) +game.stack:push(bm3) + +bm3.index = 5 +bm3.onChoose(bm3.items[5]) +bm3:onGamepadPressed("leftstick") +bm3:onGamepadReleased("leftstick") +eq(bm3.items[5].right, "Z/LS", "Row A bound to leftstick shows Z/LS") + +bm3.index = 6 +bm3.onChoose(bm3.items[6]) +bm3:onGamepadPressed("x") +bm3:onGamepadReleased("x") +eq(bm3.items[6].right, "X/X", "Row B bound to x shows X/X") + +bm3.index = 7 +bm3.onChoose(bm3.items[7]) +bm3:onGamepadPressed("guide") +bm3:onGamepadReleased("guide") +eq(bm3.items[7].right, "ESC/GUIDE", "Row START bound to guide shows ESC/GUIDE") + +bm3.index = 8 +bm3.onChoose(bm3.items[8]) +bm3:onGamepadPressed("touchpad") +bm3:onGamepadReleased("touchpad") +eq(bm3.items[8].right, "TAB/PAD", "Row SELECT bound to touchpad shows TAB/PAD") + +bm3.index = 5 +bm3.onSelectKey(bm3.items[5]) +eq(bm3.items[5].right, "Z/A", "clearing row A restores default Z/A") + +game.save.options.bindings = { + a = { pad = "leftstick" }, + b = { pad = "rightstick_up" }, + up = { pad = "triggerright" }, +} +bm3.confirmReset(bm3) +local confirmBox = game.stack:top() +check(confirmBox ~= nil, "confirm reset dialog pushed") +if confirmBox and confirmBox.onChoose then confirmBox.onChoose(true) end +check(game.save.options.bindings == nil, "reset all cleared options.bindings") + +local Game2 = require("src.core.Game2") +local Game3 = require("src.core.Game3") + +local g2 = setmetatable(newGame(), { __index = Game2 }) +local bmG2 = BindingsMenu.new(g2) +g2.stack:push(bmG2) +bmG2.index = 5 +bmG2.onChoose(bmG2.items[5]) +Game2.gamepadaxis(g2, pad, "lefty", -0.9) +check(bmG2.pending ~= nil and bmG2.pending.value == "leftstick_up", + "Game2:gamepadaxis routes stick tilt into menu capture") +Game2.gamepadaxis(g2, pad, "lefty", 0) +check(bmG2.capture == nil, "Game2 stick release completes capture") +eq(bmG2.items[5].right, "Z/LS-UP", "Game2 row A displays Z/LS-UP") + +local g3 = setmetatable(newGame(), { __index = Game3 }) +g3.input = Input +Input:reset() +Input:armCapture() +Game3.gamepadaxis(g3, pad, "righty", 0.9) +local capEvents = Input:takeCaptureEvents() +check(capEvents ~= nil and #capEvents > 0, "Game3:gamepadaxis records captureEvents") +check(capEvents[1].kind == "pad" and capEvents[1].phase == "pressed" and capEvents[1].value == "rightstick_down", + "Game3 capture event is rightstick_down pressed") +Game3.gamepadaxis(g3, pad, "righty", 0) +local relEvents = Input:takeCaptureEvents() +check(relEvents ~= nil and #relEvents > 0 and relEvents[1].phase == "released" and relEvents[1].value == "rightstick_down", + "Game3 capture event is rightstick_down released") +Input:disarmCapture() + +local mockPad = { + isGamepad = function() return true end, + isGamepadDown = function(_, b) return false end, + getGamepadAxis = function(_, axis) + if axis == "triggerleft" then return 0.9 end + if axis == "lefty" then return -0.9 end + return 0 + end, +} + +Input:init() +Input:applyBindings({ + speedDown = { pad = "triggerleft" }, + up = { pad = "leftstick_up" }, +}) + +Input:reconcile() +Input:gamepadaxis(mockPad, "triggerleft", 0.9) +Input:gamepadaxis(mockPad, "lefty", -0.9) +Input:step() +check(Input:isDown("up"), "reconciled stick axis holds up") + +Input:init() +T.finish("sdl_controller_support_test") diff --git a/tests/engine/status_cure_party_message_s2.lua b/tests/engine/status_cure_party_message_s2.lua new file mode 100644 index 00000000..1404ff90 --- /dev/null +++ b/tests/engine/status_cure_party_message_s2.lua @@ -0,0 +1,189 @@ +-- engine/items/item_effects.asm:1223-1237 +-- luajit tests/engine/status_cure_party_message_s2.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") + +package.loaded["src.core.Sound"] = { + play = function() end, + playCry = function() end, + stopLoop = function() end, +} +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { textBox = true, text = text, done = done } end, + strip = function(text) + if type(text) ~= "string" then return text end + return (text:gsub("{DONE}", ""):gsub("{PROMPT}", "")) + end, + soundOpts = function(_, _, opts) return opts or {} end, +} +package.loaded["src.ui.BagMenu"] = nil +package.loaded["src.ui.PartyMenu"] = nil +local BagMenu = require("src.ui.BagMenu") +local PartyMenu = require("src.ui.PartyMenu") +require("src.ui.Screens").invalidate() + +local Fixtures = require("tests.modkit.fixtures") +local Bag = require("src.inventory.Bag") +local Pokemon = require("src.pokemon.Pokemon") +local ItemEffects = require("src.inventory.ItemEffects") + +local Data = Fixtures.fresh() +local function addItem(id, index, name) + Data.items[id] = { id = id, index = index, name = name, price = 100, tossable = true } +end +addItem("ANTIDOTE", 11, "ANTIDOTE") +addItem("PARLYZ_HEAL", 15, "PARLYZ HEAL") +addItem("AWAKENING", 14, "AWAKENING") +addItem("BURN_HEAL", 12, "BURN HEAL") +addItem("ICE_HEAL", 13, "ICE HEAL") +addItem("FULL_HEAL", 52, "FULL HEAL") +addItem("FULL_RESTORE", 16, "FULL RESTORE") + +for _, id in ipairs({ "ANTIDOTE", "PARLYZ_HEAL", "AWAKENING", "BURN_HEAL", + "ICE_HEAL", "FULL_HEAL", "FULL_RESTORE" }) do + eq(ItemEffects.keepsPartyMenuOpen(id), true, id .. " keeps the party menu up") +end + +local function freshGame(id, status) + local mon = Pokemon.new(Data, "FIXMON_C", 5) + mon.status = status + local game = { + data = Data, + save = { + party = { mon }, + player = { name = "RED", id = 1 }, + inventory = {}, + options = {}, + flags = {}, + money = 0, + }, + } + 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 = { pressed = nil } + function game.input:wasPressed(b) return self.pressed == b end + Bag.add(game.save, id, 3) + return game, mon +end + +local function isPicker(s) return getmetatable(s) == PartyMenu end +local function isBox(s) return type(s) == "table" and s.textBox == true end +local function inStack(stack, pred) + for _, s in ipairs(stack.states) do + if pred(s) then return true end + end + return false +end + +local function useFromBag(game, battle, id) + local list = BagMenu.new(game, { battle = battle }) + game.stack:push(list) + local row + for i, r in ipairs(list.items) do + if r.value == id then row = i break end + end + if not row then return nil, nil, "no " .. id .. " row" end + list.index = row + list.onChoose(list.items[row], list) + local sub = game.stack:top() + if not battle and sub and sub.items and sub.items[1] + and sub.items[1].onSelect then + game.stack:pop() + sub.items[1].onSelect() + end + local picker = game.stack:top() + if not isPicker(picker) then return nil, nil, "party picker never opened" end + game.input.pressed = "a" + picker:update(1 / 60) + game.input.pressed = nil + return list, picker +end + +local cases = { + { "ANTIDOTE", "PSN" }, { "PARLYZ_HEAL", "PAR" }, { "AWAKENING", "SLP" }, + { "BURN_HEAL", "BRN" }, { "ICE_HEAL", "FRZ" }, { "FULL_HEAL", "PSN" }, +} + +for _, c in ipairs(cases) do + local id, status = c[1], c[2] + local game, mon = freshGame(id, status) + local list, picker, why = useFromBag(game, nil, id) + if check(list ~= nil, id .. " reached the picker: " .. tostring(why)) then + eq(mon.status, nil, id .. " cured " .. status) + eq(picker.keepOpen, true, id .. " opened the picker with keepOpen") + local box = game.stack:top() + check(isBox(box), id .. " prints its cure line") + check(inStack(game.stack, isPicker), + id .. " prints over the still-drawn party menu") + eq(picker.cursorsErased, true, id .. " erases the party cursor first") + eq(game.save.inventory[id], 2, id .. " was consumed") + if isBox(box) then + game.stack:pop() + box.done() + check(not inStack(game.stack, isPicker), + id .. " closes the picker once the line is dismissed") + local flash = game.stack:top() + check(type(flash) == "table" and flash.frames ~= nil and flash.t ~= nil, + id .. " whites out on the way back to the bag") + check(inStack(game.stack, function(s) return s == list end), + id .. " returns to the ITEM list") + end + end +end + +do + local game, mon = freshGame("FULL_RESTORE", "PSN") + mon.hp = mon.stats.hp + local list, picker = useFromBag(game, nil, "FULL_RESTORE") + if check(list ~= nil, "FULL RESTORE at full HP reached the picker") then + eq(mon.status, nil, "FULL RESTORE at full HP cures PSN") + check(isBox(game.stack:top()), "and prints its cure line") + check(inStack(game.stack, isPicker), "over the still-drawn party menu") + eq(picker.cursorsErased, true, "with the cursor erased") + end +end + +do + local game, mon = freshGame("ANTIDOTE", nil) + local list = useFromBag(game, nil, "ANTIDOTE") + if check(list ~= nil, "a refused ANTIDOTE reached the picker") then + eq(mon.status, nil, "nothing to cure") + eq(game.save.inventory.ANTIDOTE, 3, "the refused ANTIDOTE is kept") + local box = game.stack:top() + check(isBox(box), "the no-effect line prints") + check(inStack(game.stack, isPicker), "over the still-drawn party menu") + end +end + +do + local spent + local battle = { itemUsed = function(_, msgs, o) spent = { msgs = msgs, o = o } end } + local game, mon = freshGame("ANTIDOTE", "PSN") + local list, picker = useFromBag(game, battle, "ANTIDOTE") + if check(list ~= nil, "the battle bag reached the picker") then + eq(mon.status, nil, "ANTIDOTE in battle cures PSN") + eq(picker.keepOpen, true, "the battle picker stays up for a cure") + local box = game.stack:top() + check(isBox(box), "the cure line prints in battle") + check(inStack(game.stack, isPicker), "over the battle party menu") + eq(spent, nil, "the turn is not spent until the line is dismissed") + if isBox(box) then + game.stack:pop() + box.done() + check(not inStack(game.stack, isPicker), "the picker closes after the line") + check(not inStack(game.stack, function(s) return s == list end), + "and the battle bag comes down with it") + check(spent ~= nil, "then the item spends the turn") + end + end +end + +T.finish() diff --git a/tests/engine/sync_engine_test.lua b/tests/engine/sync_engine_test.lua index e97d6885..a8b822c1 100644 --- a/tests/engine/sync_engine_test.lua +++ b/tests/engine/sync_engine_test.lua @@ -231,8 +231,12 @@ do local main = assert(io.open("main.lua")):read("*a") local body = main:match("local function returnToLauncher%(opts%)(.-)\nend\n") T.check(body ~= nil, "main.lua still has returnToLauncher") - T.check(body and body:find("protectPlaythrough", 1, true) ~= nil, - "and returning to the launcher clears the played key on the shared engine") + T.check(body and body:find('require("src.core.HostShell").restart()', 1, true) ~= nil, + "and returning to the launcher restarts, so no played key survives on the shared engine") + local importer = assert(io.open("src/import/RomImporter.lua")):read("*a") + local pump = importer:match("function RomImporter:_pumpSync%(dt%)(.-)\nend\n") + T.check(pump and pump:find("booted.syncNow", 1, true) ~= nil, + "and the restarted launcher syncs on its first frame") end do diff --git a/tests/engine/timing_parity.lua b/tests/engine/timing_parity.lua index 3ad52617..d40f46f2 100644 --- a/tests/engine/timing_parity.lua +++ b/tests/engine/timing_parity.lua @@ -37,19 +37,58 @@ 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") T.eq(Timing.hpBarPixels(1, 150), 1, "GetHPBarLength clamps a sliver to 1 px") +-- engine/gfx/hp_bar.asm:19-34 +T.eq(Timing.hpBarPixels(199, 399), 24, "max HP >= 256 divides both sides by 4 first") +T.eq(Timing.hpBarPixels(349, 399), 42, "so 349/399 is 42 px, not 41") +-- engine/gfx/hp_bar.asm:42-45 +T.eq(Timing.hpBarLength(0, 150), 1, "GetHPBarLength also clamps 0 HP 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, true), 150 + 94 + 6, + "a 150 HP player mon drains in D + 2P + 6 = 250 frames, 1 -> 0 moves no pixel") +T.eq(Timing.hpDrainFrames(150, 0, 150, false), 147, + "the same drain on the enemy HUD lags a frame per pixel: 147") +T.eq(Timing.hpDrainFrames(40, 0, 40, false), 2 * 47 + 5, + "a sub-48 HP enemy crosses a pixel every step, so no lag") + +-- engine/gfx/hp_bar.asm:81-135 +local measured = { + { 100, 1, 100, false, 103 }, { 100, 0, 100, false, 103 }, + { 150, 1, 150, false, 147 }, { 150, 0, 150, false, 147 }, + { 193, 1, 193, false, 148 }, { 193, 0, 193, false, 148 }, + { 200, 1, 200, false, 152 }, { 200, 0, 200, false, 152 }, + { 255, 1, 255, false, 179 }, { 255, 0, 255, false, 179 }, + { 300, 1, 300, false, 195 }, { 300, 0, 300, false, 195 }, + { 400, 1, 400, false, 243 }, { 400, 0, 400, false, 243 }, + { 150, 90, 200, false, 50 }, { 300, 180, 400, false, 78 }, + { 300, 263, 300, false, 29 }, { 90, 1, 150, false, 88 }, + { 150, 0, 150, true, 250 }, { 150, 50, 150, true, 170 }, + { 200, 120, 200, true, 126 }, { 255, 1, 255, true, 354 }, + { 256, 0, 256, true, 356 }, { 399, 0, 399, true, 499 }, + { 399, 199, 399, true, 254 }, { 703, 303, 703, true, 462 }, + { 1, 415, 415, true, 514 }, { 1, 150, 150, true, 249 }, + { 37, 200, 200, true, 249 }, { 100, 255, 255, true, 221 }, +} +for _, m in ipairs(measured) do + T.eq(Timing.hpDrainFrames(m[1], m[2], m[3], m[4]), m[5], + string.format("%s %d -> %d of %d matches the cart's %d frames", + m[4] and "player" or "enemy", m[1], m[2], m[3], m[5])) +end +local knifeEdge = { + { 240, 1, 240, 172 }, { 240, 0, 240, 172 }, { 256, 1, 256, 186 }, + { 256, 0, 256, 186 }, { 500, 1, 500, 283 }, { 500, 0, 500, 283 }, + { 703, 1, 703, 362 }, { 703, 0, 703, 362 }, +} +for _, m in ipairs(knifeEdge) do + local got = Timing.hpDrainFrames(m[1], m[2], m[3], false) + T.check(math.abs(got - m[4]) <= 10, + string.format("enemy %d -> %d of %d is within 10 of the cart's %d (got %d)", + m[1], m[2], m[3], m[4], got)) +end -- 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/engine/ttf_font_mode.lua b/tests/engine/ttf_font_mode.lua index 351f9a89..d1354581 100644 --- a/tests/engine/ttf_font_mode.lua +++ b/tests/engine/ttf_font_mode.lua @@ -208,6 +208,41 @@ do T.eq(pens[3].x, 8 + latin * 2, "the ligature tile sits after the TTF pair") end +-- ------------------------------------------------- so does the battle box + +do + local BattleState = require("src.battle.BattleState") + local WideBattle = require("src.battle.WideBattle") + local function battle() + return setmetatable({ + phase = "messages", current = {}, frame = 0, fieldCleared = true, + shown = { Font.encode("AB'd") }, + bottomUIVisible = function() return true end, + extendedHUD = function() return false end, + drawPicsLayer = function() end, + }, BattleState) + end + local function pensOf(draw) + local pens = {} + local oldDraw = Font.drawCode + Font.drawCode = function(code, x) + if code >= BASE or code == 0xD0 then pens[#pens + 1] = x end + end + local ok, err = pcall(draw) + Font.drawCode = oldDraw + if not ok then error(err, 0) end + return pens + end + local classic = pensOf(function() battle():drawTextArea() end) + T.eq(#classic, 3, "the battle box draws three glyphs for AB'd") + T.eq(classic[2], 8 + latin, "its pen moves by the TTF advance, not 8px") + T.eq(classic[3], 8 + latin * 2, "and the ligature tile follows the pair") + local wide = pensOf(function() WideBattle.draw(battle()) end) + T.eq(#wide, 3, "the wide battle box draws the same three glyphs") + T.eq(wide[2], 8 + latin, "with the same per-glyph pen") + T.eq(wide[3], 8 + latin * 2, "up to the ligature tile") +end + -- ------------------------------------------------- the registry entry do diff --git a/tests/engine/wheel_zoom_persist_debounce.lua b/tests/engine/wheel_zoom_persist_debounce.lua new file mode 100644 index 00000000..ace35018 --- /dev/null +++ b/tests/engine/wheel_zoom_persist_debounce.lua @@ -0,0 +1,94 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +package.loaded["src.core.game3.battle"] = package.loaded["src.core.game3.battle"] + or { isActive = function() return false end } + +local Zoom = require("src.render.Zoom") +local Renderer = require("src.render.Renderer") +local DeferredWrite = require("src.core.DeferredWrite") +local SessionLifecycle = require("src.core.SessionLifecycle") +local Game = require("src.core.Game") +local Game2 = require("src.core.Game2") +local Game3 = require("src.core.Game3") + +Renderer.fitScale = function() return 4 end + +local clock = 100 +DeferredWrite.clock = function() return clock end + +local function gen1() + local writes = 0 + local ow = {} + local g = setmetatable({ + save = { options = { zoom = 0 } }, + overworld = ow, + stack = { top = function() return ow end }, + writeOptions = function() writes = writes + 1 end, + }, { __index = Game }) + return g, function() return writes end, function() return g.save.options.zoom end +end + +local function gen2() + local writes = 0 + local world = { map = {}, fitScale = function() return 4 end } + function world:zoomStep(delta) Zoom.step(delta, self:fitScale()) end + local g = setmetatable({ + world = world, + stack = { top = function() return nil end }, + options = { zoom = 0 }, + save = {}, + persistOptions = function() writes = writes + 1 end, + }, { __index = Game2 }) + return g, function() return writes end, function() return g.options.zoom end +end + +local function gen3() + local writes = 0 + local g = setmetatable({ + phase = "field", + options = { zoom = 0 }, + writeOptions = function() writes = writes + 1 end, + }, { __index = Game3 }) + return g, function() return writes end, function() return g.options.zoom end +end + +for _, case in ipairs({ { "gen1", gen1, Game }, { "gen2", gen2, Game2 }, { "gen3", gen3, Game3 } }) do + local name, make, cls = case[1], case[2], case[3] + + Zoom.reset() + DeferredWrite.flush() + local g, writes, zoom = make() + for _ = 1, 12 do + cls.wheelmoved(g, 0, -1) + clock = clock + 0.03 + end + eq(writes(), 0, name .. ": a fast wheel spin writes nothing while it is still spinning") + check(zoom() < 0, name .. ": the live options table still tracks the zoom") + check(DeferredWrite.isPending("options"), name .. ": the write is pending") + DeferredWrite.tick(clock + DeferredWrite.DELAY / 2) + eq(writes(), 0, name .. ": not yet idle long enough") + DeferredWrite.tick(clock + DeferredWrite.DELAY + 0.01) + eq(writes(), 1, name .. ": one write once the wheel goes idle") + DeferredWrite.tick(clock + 10) + eq(writes(), 1, name .. ": and only one") + + cls.wheelmoved(g, 0, 1) + cls.focus(g, false) + eq(writes(), 2, name .. ": focus loss flushes the pending write") + + cls.wheelmoved(g, 0, 1) + cls.visible(g, false) + eq(writes(), 3, name .. ": minimize / mobile suspend flushes the pending write") + + cls.wheelmoved(g, 0, 1) + SessionLifecycle.endProcess() + eq(writes(), 4, name .. ": process exit flushes the pending write") + check(not DeferredWrite.isPending("options"), name .. ": nothing left pending") +end + +Zoom.reset() +T.finish("wheel zoom persist is debounced") 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/gen2_diploma_test.lua b/tests/gen2_diploma_test.lua index 3f670afd..683931b8 100644 --- a/tests/gen2_diploma_test.lua +++ b/tests/gen2_diploma_test.lua @@ -111,9 +111,7 @@ else "every id in it indexes DiplomaGFX's own 112 tiles, nothing past them") end --- gfx/diploma/diploma.pal, colour by colour, so the extracted palette can be --- checked against the source rather than against itself. DiplomaPalettes is --- eight sets; only set 0 is reachable because _CGB_Diploma calls WipeAttrmap. +-- gfx/diploma/diploma.pal local DECOMP_PAL_SET0 = { { 27, 31, 27 }, { 21, 21, 21 }, { 13, 13, 13 }, { 0, 0, 0 } } local function scale5(value) return math.floor(value * 255 / 31 + 0.5) end @@ -217,13 +215,15 @@ local fakeGfx = { page1 = fakePage1, palettes = { { { 222, 255, 222 }, { 173, 173, 173 }, { 107, 107, 107 }, { 0, 0, 0 } } }, + bgPalette = { { 255, 255, 255 }, { 247, 181, 140 }, { 132, 115, 156 }, + { 0, 0, 0 } }, } local screen = Diploma.new(nil, { playerName = "GOLD", gfx = fakeGfx }) eq(screen.playerName, "GOLD", "the screen takes the player's name") local palette = screen:palette() -check(palette ~= nil and palette[1][2] == 255, - "and draws through DiplomaPalettes set 0, the one WipeAttrmap leaves it on") +check(palette == fakeGfx.bgPalette, + "and draws through PREDEFPAL_DIPLOMA, which CopyFourPalettes puts on BG 0") local batch = screen:batch() if not batch then 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..4488e44d 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 @@ -286,6 +291,7 @@ tests/engine/gen2_map_bake_dpi.lua tests/engine/gen2_menu_whites_s6.lua tests/engine/gen2_mod_battle_theme.lua tests/engine/gen2_mod_options_persist.lua +tests/engine/gen2_naming_header_2708_test.lua tests/engine/gen2_naming_screen_translation_test.lua tests/engine/gen2_new_seams.lua tests/engine/gen2_night_shade_text_pal_bug1961.lua @@ -360,6 +366,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 +390,9 @@ 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_http_nonascii.lua +tests/engine/hostshell_win_restart_2721.lua +tests/engine/hp_bar_speed_bug2723.lua tests/engine/importer_worker_failure_cleanup.lua tests/engine/importers_asset_packs.lua tests/engine/indigo_statues_bug2683.lua @@ -514,6 +525,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 +617,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 @@ -669,6 +684,7 @@ tests/engine/textbox_arrow_cell_bug2674.lua tests/engine/textbox_pause_bug1818.lua tests/engine/textbox_sfx_speed_bug2087_test.lua tests/engine/thrash_setup_anim_bug1532.lua +tests/engine/timing_parity.lua tests/engine/title_fill_scale.lua tests/engine/title_fill_skin_lock.lua tests/engine/title_mon_cycle.lua @@ -717,6 +733,7 @@ tests/engine/warp_fade_obp0_identity_bug2689.lua tests/engine/warp_fade_palette_2180.lua tests/engine/warp_sprite_hidden_bug916.lua tests/engine/wave_channel_mix_bug429.lua +tests/engine/wheel_zoom_persist_debounce.lua tests/engine/wide_battle_layout.lua tests/engine/wide_battle_shake_bug562.lua tests/engine/worker_fs_nx_test.lua @@ -727,6 +744,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 +762,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 +799,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 +812,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 @@ -807,6 +830,7 @@ tests/game3_weather_test.lua tests/gen2_fly_map_test.lua tests/gen2_menus_test.lua tests/modkit/cases/bag_capacity.lua +tests/modkit/cases/mod_map_outdoor_encounters.lua tests/parity_F.lua tests/parity_ball_shake_anim.lua tests/parity_battle_intro_chrome.lua diff --git a/tests/rom_archive_import_test.lua b/tests/rom_archive_import_test.lua new file mode 100644 index 00000000..09b1c775 --- /dev/null +++ b/tests/rom_archive_import_test.lua @@ -0,0 +1,279 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("rom import from archives") +local check, eq = S.check, S.eq + +local RomArchive = require("src.import.RomArchive") +local RomImporter = require("src.import.RomImporter") + +local KIND_7Z = "7z\188\175\39\28" +local zipProbe = RomArchive._zipProbeBytes() +local userArchive = "PK\3\4" .. "user-archive-body" +local sevenZFile = KIND_7Z .. "user-7z-body" + +local goodBody = string.rep("R", 1048576) +local SUPPORT_Z7 = false +local userEntries = { ["Red.gb"] = goodBody } + +local function makeVfs() + local vfs = {} + local arch = {} + local mounted = {} + local staged = {} + + local function keyOf(archive) + if type(archive) == "table" then return archive end + return staged[archive] and archive or nil + end + + function vfs.newFileData(data, name) + return { _fileData = true, data = data, name = name } + end + + function vfs.write(path, data) + staged[path] = data + return true + end + + function vfs.remove(path) + staged[path] = nil + return true + end + + function vfs.mount(archive, point) + local data + if type(archive) == "table" then + data = archive.data + else + data = staged[archive] + end + if not data then return false end + local entries + if data == userArchive then + entries = userEntries + elseif data == sevenZFile then + entries = userEntries + elseif data == zipProbe then + entries = { probe = "ok" } + elseif RomArchive.kind(data) == "7z" then + if not SUPPORT_Z7 then return false end + entries = { ["p.romprobe"] = "gen1recomp-probe" } + else + return false + end + arch[point] = { dir = true } + for rel, body in pairs(entries) do + arch[point .. "/" .. rel] = { body = body } + end + mounted[keyOf(archive) or data] = point + return true + end + + function vfs.unmount(archive) + local point = mounted[archive] or mounted[archive.data and archive or nil] + if not point then + for k, v in pairs(mounted) do + if k == archive or (type(k) == "table" and k.data == archive) then + point = v + end + end + end + if not point then return false end + for path in pairs(arch) do + if path == point or path:sub(1, #point + 1) == point .. "/" then + arch[path] = nil + end + end + mounted[archive] = nil + for k, v in pairs(mounted) do + if v == point then mounted[k] = nil end + end + return true + end + + function vfs.getDirectoryItems(dir) + if not (arch[dir] and arch[dir].dir) then return {} end + local out = {} + for path in pairs(arch) do + if path ~= dir and path:sub(1, #dir + 1) == dir .. "/" then + local rest = path:sub(#dir + 2) + if not rest:find("/", 1, true) and not arch[path].dir then + out[#out + 1] = rest + end + end + end + table.sort(out) + return out + end + + function vfs.getInfo(path) + local node = arch[path] + if node then + if node.dir then return { type = "directory" } end + return { type = "file", size = #node.body } + end + for other in pairs(arch) do + if other:sub(1, #path + 1) == path .. "/" then + return { type = "directory" } + end + end + return nil + end + + function vfs.read(path) + local node = arch[path] + if node and node.body then return node.body end + return nil, "not found" + end + + return vfs +end + +local savedFs = love.filesystem +local vfs = makeVfs() +love.filesystem = vfs +RomArchive._resetForTests() + +eq(RomArchive.kind("GBROM" .. string.rep("x", 20)), nil, "raw ROM is not an archive") +eq(RomArchive.kind("PK\3\4rest"), "zip", "PK\\3\\4 is a zip") +eq(RomArchive.kind("PK\5\6rest"), "zip", "empty zip is a zip") +eq(RomArchive.kind(KIND_7Z .. "rest"), "7z", "7z magic recognised") +eq(RomArchive.kind("PK"), nil, "truncated input is not an archive") +eq(RomArchive.kind(nil), nil, "non-string input is not an archive") + +local caps = RomArchive.capabilities(vfs) +check(caps.zip == true, "zip probe mounts here") +check(caps.z7 == false, "7z probe refuses while SUPPORT_Z7 is off") + +SUPPORT_Z7 = true +RomArchive._resetForTests() +caps = RomArchive.capabilities(vfs) +check(caps.z7 == true, "7z probe mounts once the platform supports it") +SUPPORT_Z7 = false +RomArchive._resetForTests() +check(RomArchive.capabilities(vfs).z7 == false, "caps are re-probed after reset") + +userEntries = { ["._Red.gb"] = "APPLEDOUBLE", ["Red.gb"] = goodBody } +local bytes, entry = RomArchive.unwrap(userArchive, "pack.zip", { fs = vfs }) +eq(bytes, goodBody, "cart bytes come out") +eq(entry, "Red.gb", "AppleDouble ._Red.gb is skipped, Red.gb wins") +check(RomArchive.unwrap("hello", "x.zip", { fs = vfs }) == nil, "not-an-archive refused") + +userEntries = { ["A.gb"] = "AAA", ["Y.gb"] = "YYY" } +bytes, entry = RomArchive.unwrap(userArchive, "pack.zip", { + fs = vfs, + acceptedSize = function(n) return n >= 3 end, + prefer = function(b) return b:sub(1, 1) == "Y" end, +}) +eq(entry, "Y.gb", "prefer() picks the known-version cart over sort order") + +userEntries = { ["notes.txt"] = "text" } +local noRom, noRomErr = RomArchive.unwrap(userArchive, "pack.zip", { fs = vfs }) +eq(noRom, nil, "an archive with no cart refuses") +check(tostring(noRomErr):find("no .gb/.gbc/.gba", 1, true) ~= nil, + "the no-cart message says so: " .. tostring(noRomErr)) + +userEntries = { ["Red.gb"] = string.rep("R", 2000) } +local wrongSize, wrongErr = RomArchive.unwrap(userArchive, "pack.zip", { fs = vfs }) +eq(wrongSize, nil, "a wrong-size cart is never read (zip bomb guard)") +check(tostring(wrongErr):find("2000 bytes", 1, true) ~= nil, + "the wrong-size message lists sizes: " .. tostring(wrongErr)) + +userEntries = { ["Red.gb"] = goodBody } +local z7bytes, z7err = RomArchive.unwrap(sevenZFile, "cart.7z", { fs = vfs }) +eq(z7bytes, nil, "7z refused while the platform lacks the archiver") +check(tostring(z7err):find("cannot open .7z", 1, true) ~= nil, + "the caps refusal names the format: " .. tostring(z7err)) + +SUPPORT_Z7 = true +RomArchive._resetForTests() +local z7ok, z7entry = RomArchive.unwrap(sevenZFile, "cart.7z", { fs = vfs }) +eq(z7ok, goodBody, "7z opens once the platform supports it") +eq(z7entry, "Red.gb", "and yields its cart") +SUPPORT_Z7 = false +RomArchive._resetForTests() + +love.data = love.data or {} +local savedData = { hash = love.data.hash, encode = love.data.encode } +love.data.hash = function(_, data) + return { tag = data:sub(1, 1) } +end +love.data.encode = function(_, _, digest) + if type(digest) == "table" and digest.tag == "R" then + return require("src.core.GameVersion").info("red").sha1 + end + return "0000000000000000000000000000000000000000" +end + +local function freshImporter(extra) + local ri = setmetatable({ tab = "red", workState = nil }, RomImporter) + for k, v in pairs(extra or {}) do ri[k] = v end + return ri +end + +userEntries = { ["Red.gb"] = string.rep("R", 2000) } +local ri = freshImporter() +ri:startData(userArchive, "pack.zip") +eq(ri.workState, "error", "wrong-size cart in a zip lands in the error state") +check(tostring(ri.detail):find("2000 bytes", 1, true) ~= nil, + "the size filter runs before any read: " .. tostring(ri.detail)) + +userEntries = { ["Red.gb"] = string.rep("X", 1048576) } +ri = freshImporter() +ri:startData(userArchive, "pack.zip") +eq(ri.workState, "error", "unknown-sha cart in a zip errors too") +check(tostring(ri.detail):find("Unsupported ROM (SHA-1", 1, true) ~= nil, + "the SHA-1 gate runs on the unwrapped bytes: " .. tostring(ri.detail)) + +local realMax = RomArchive.MAX_ARCHIVE_BYTES +RomArchive.MAX_ARCHIVE_BYTES = 10 +ri = freshImporter() +ri:startData(userArchive, "pack.zip") +RomArchive.MAX_ARCHIVE_BYTES = realMax +eq(ri.workState, "error", "an oversized archive is refused before mounting") +check(tostring(ri.detail):find(".zip", 1, true) ~= nil, + "the oversize message names the format: " .. tostring(ri.detail)) + +ri = freshImporter() +ri:startData(sevenZFile, "cart.7z") +eq(ri.workState, "error", "a 7z drop takes the same startData path") +check(tostring(ri.detail):find("cannot open .7z", 1, true) ~= nil, + "and is refused by caps: " .. tostring(ri.detail)) + +userEntries = { ["Red.gb"] = goodBody } + +local function fakeFile(path, content) + return { + open = function() return true end, + read = function() return content end, + close = function() end, + getSize = function() return #content end, + getFilename = function() return path end, + } +end + +ri = freshImporter({ tab = "red" }) +local started +ri.startData = function(self, data, name, src) + started = { data = data, name = name, src = src } +end +ri:filedropped(fakeFile("/tmp/packs/cart-pack.zip", userArchive)) +check(started ~= nil, "a game-tab zip holding a cart routes to startData") +if started then + eq(started.data, userArchive, "startData receives the archive payload (it unwraps)") + eq(started.name, "/tmp/packs/cart-pack.zip", "and the dropped path for display") +end + +ri = freshImporter({ tab = "red" }) +ri:filedropped(fakeFile("/tmp/cart.7z", sevenZFile)) +eq(ri.workState, "error", "a dropped .7z reaches startData via the generic path") +check(tostring(ri.detail):find("cannot open .7z", 1, true) ~= nil, + "and is refused when the platform lacks 7z: " .. tostring(ri.detail)) + +love.filesystem = savedFs +love.data.hash = savedData.hash +love.data.encode = savedData.encode +RomArchive._resetForTests() + +S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 9224390d..83b6f847 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -3856,6 +3856,10 @@ runSuites({ "tests/rom_importer_android_mod_pick_test.lua" }) -- ---------------------------------------------- import with no picker (#482) runSuites({ "tests/rom_importer_no_picker_test.lua" }) runSuites({ "tests/rom_importer_double_pick_test.lua" }) +-- ---------------------------------------------- ROM from a .zip / .7z archive +runSuites({ "tests/rom_archive_import_test.lua" }) +-- ---------------------------------------------- RZIP-compressed .srm saves +runSuites({ "tests/save_rzip_test.lua" }) -- the same pickerless scan, asked for one version in particular (#1274) runSuites({ "tests/rom_importer_choose_version_test.lua" }) -- ---------------------------------------------- Switch platform capabilities diff --git a/tests/save_rzip_test.lua b/tests/save_rzip_test.lua new file mode 100644 index 00000000..16fdf4b9 --- /dev/null +++ b/tests/save_rzip_test.lua @@ -0,0 +1,55 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("RZIP compressed save") +local check, eq = S.check, S.eq + +local SrmDecompress = require("src.import.SrmDecompress") + +love.data = love.data or {} +local realDecompress = love.data.decompress +love.data.decompress = function(_, _, cdata) + return "D(" .. cdata .. ")" +end + +local function le32(n) + local b1 = n % 256; n = math.floor(n / 256) + local b2 = n % 256; n = math.floor(n / 256) + local b3 = n % 256; n = math.floor(n / 256) + return string.char(b1, b2, b3, n % 256) +end + +local function rzip(chunkSize, totalSize, chunks) + local out = { "#RZIPv", string.char(1), "#", + le32(chunkSize), le32(totalSize), le32(0) } + for _, c in ipairs(chunks) do + out[#out + 1] = le32(#c) + out[#out + 1] = c + end + return table.concat(out) +end + +local blob = rzip(131072, 131072, { "hello" }) +check(SrmDecompress.isCompressed(blob), "rzip header detected") +check(not SrmDecompress.isCompressed("PK\3\4body"), "zip magic is not rzip") +check(not SrmDecompress.isCompressed("RIP"), "too short to sniff") +check(not SrmDecompress.isCompressed(nil), "nil is not rzip") + +local got, err = SrmDecompress.decompress(blob) +eq(got, "D(hello)", "chunk ending exactly at EOF decompresses") +eq(err, nil, "no error for exact-EOF chunk") + +local g2, e2 = SrmDecompress.decompress(blob:sub(1, #blob - 2)) +eq(g2, nil, "chunk claiming bytes past EOF rejected") +eq(e2, "truncated chunk", "truncated error message") + +local two = rzip(4, 99, { "ab", "cd" }) +eq(SrmDecompress.decompress(two), "D(ab)D(cd)", "all chunks concatenated") + +local raw = string.rep("\255", 32768) +local g3, e3 = SrmDecompress.decompress(raw) +eq(g3, nil, "raw save not decompressible") +eq(e3, "not compressed SRM", "raw save error message") + +love.data.decompress = realDecompress +S.finish()