Merge pull request #2728 from bryanthaboi/dev

This commit is contained in:
bryanthaboi
2026-10-06 20:18:46 -04:00
committed by GitHub
114 changed files with 5731 additions and 823 deletions
+24
View File
@@ -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
+5 -3
View File
@@ -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) |
+1
View File
@@ -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 |
+2
View File
@@ -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
+74 -88
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -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
+56
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+29
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+235
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+8 -3
View File
@@ -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
+4 -6
View File
@@ -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
+3 -1
View File
@@ -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
+179
View File
@@ -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
+5 -9
View File
@@ -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
View File
@@ -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
+20 -6
View File
@@ -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)
+53
View File
@@ -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
+2 -14
View File
@@ -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
+15 -2
View File
@@ -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
+3
View File
@@ -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
+1 -1
View File
@@ -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"},
}
+18 -6
View File
@@ -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" },
+3 -6
View File
@@ -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"
+1 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+9
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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
View File
@@ -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
+5 -4
View File
@@ -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
+2
View File
@@ -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))
+12 -6
View File
@@ -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)
+8
View File
@@ -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)
+13 -3
View File
@@ -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
+13 -3
View File
@@ -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
+1 -1
View File
@@ -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
+12 -3
View File
@@ -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
+33 -9
View File
@@ -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()
+7 -1
View File
@@ -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)
+5 -1
View File
@@ -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)
+9 -2
View File
@@ -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)
+13 -3
View File
@@ -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
+47 -17
View File
@@ -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
+1 -4
View File
@@ -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,
-36
View File
@@ -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
View File
@@ -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)
+72 -12
View File
@@ -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
View File
@@ -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)
+10 -2
View File
@@ -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
+17 -8
View File
@@ -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
+10 -3
View File
@@ -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
+4 -13
View File
@@ -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,
+7
View File
@@ -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
+85
View File
@@ -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
+85
View File
@@ -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
+37
View File
@@ -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
+183
View File
@@ -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
+75 -44
View File
@@ -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
+5
View File
@@ -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
+70
View File
@@ -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
+76 -10
View File
@@ -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()
+1 -1
View File
@@ -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")
+10 -1
View File
@@ -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")
+49
View File
@@ -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
+17 -34
View File
@@ -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")
+11 -3
View File
@@ -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")
+137
View File
@@ -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")
+141
View File
@@ -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
+40 -7
View File
@@ -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
+130
View File
@@ -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()
+13 -11
View File
@@ -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
+2 -2
View File
@@ -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")
+19 -1
View File
@@ -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 = {
+197
View File
@@ -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")
+2 -2
View File
@@ -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