mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-08 09:42:55 -04:00
Merge pull request #2728 from bryanthaboi/dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+35
-27
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+22
-1
@@ -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
|
||||
|
||||
+24
-3
@@ -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
|
||||
|
||||
+22
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+207
-152
@@ -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 <ip>", 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 @<file> 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
|
||||
|
||||
+98
-34
@@ -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
|
||||
|
||||
+153
-6
@@ -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
|
||||
|
||||
+52
-40
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
+153
-19
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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] = "}"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
+118
-15
@@ -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
|
||||
|
||||
+107
-18
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-5
@@ -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)
|
||||
|
||||
+23
-7
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+46
-17
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <PK><MN>?" 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]
|
||||
|
||||
+2
-10
@@ -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)
|
||||
|
||||
@@ -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: "<MON>'S" then "NICKNAME?". Kept as two
|
||||
|
||||
+42
-36
@@ -6,8 +6,8 @@
|
||||
-- prompt text is a parameter (.Strings: "Choose a #MON.", "Use on which
|
||||
-- <PK><MN>?", "Teach which <PK><MN>?", ...).
|
||||
--
|
||||
-- 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,183 @@
|
||||
-- engine/gfx/hp_bar.asm:81-135, engine/items/item_effects.asm:1208
|
||||
-- tools/run_driver.sh red <identity> tests/drivers/hp_bar_speed_2723.lua <shotdir>
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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" },
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
-- engine/gfx/cgb_layouts.asm:488-499 _CGB_Diploma, gfx/sgb/predef.pal:28
|
||||
-- GOLD_CACHE=<dir> CRYSTAL_CACHE=<dir> 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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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" }))
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user