CLOSES #644, CLOSES #703, CLOSES #726, CLOSES #737, CLOSES #750, CLOSES #752, CLOSES #764, CLOSES #765, CLOSES #768, CLOSES #773, CLOSES #774, CLOSES #775, CLOSES #777, CLOSES #780, CLOSES #782

This commit is contained in:
bryanthaboi
2026-08-04 09:25:28 -04:00
parent 6cfc85ca83
commit f2d9b74490
52 changed files with 3099 additions and 252 deletions
+10 -8
View File
@@ -218,14 +218,13 @@ function LauncherView.update(imp, dt)
if not imp._flex then return end
FlexLove.update(dt)
-- Drain the action queue OUTSIDE FlexLove's dispatch, so an action is free
-- to destroy the view (Play/Edit) or block in a native picker.
-- to destroy the view (Play/Edit) or block in a native picker. The batch
-- is resolved by RomImporter:runActions so the drop/disarm rules are
-- testable without a live FlexLove tree (#780).
local queue = imp._uiActions
if queue and #queue > 0 then
imp._uiActions = {}
for _, fn in ipairs(queue) do
local ok, err = pcall(fn)
if not ok then print("launcher action error: " .. tostring(err)) end
end
imp:runActions(queue)
end
end
@@ -292,9 +291,12 @@ local function queueAction(imp, key, fn, keepArm)
if last and now - last < ACT_DEDUP then return end
imp._actAt[key] = now
-- Any press that is not a Delete's own second click disarms the pending
-- delete confirm (#433's rule, preserved from the hit-rect launcher).
if not keepArm then imp._confirmDelete = nil end
imp._uiActions[#imp._uiActions + 1] = fn
-- delete confirm (#433's rule, preserved from the hit-rect launcher). The
-- disarm itself is applied by RomImporter:runActions when the batch drains,
-- not here: one touch lands on a row AND on the chip inside it, and
-- clearing the arm as the row queued left Delete stuck on its first press
-- (#780).
imp._uiActions[#imp._uiActions + 1] = { key = key, fn = fn, keepArm = keepArm }
end
local function handler(imp, key, action, keepArm)
+95 -1
View File
@@ -1639,9 +1639,100 @@ function RomExtractor:raw1bpp(label, width, height, relative, transparent)
return image
end
-- Trading animation art: gfx/trade.asm TradingAnimationGraphics is one
-- 49-tile atlas (game_boy.2bpp, built with --remove-duplicates, then
-- link_cable.2bpp), and the Game Boy and open-cable plates are painted out
-- of it through the tilemaps in data/tilemaps.asm (GameBoyTiles 6x8,
-- LinkCableTiles 12x3), whose ids are absolute vChars2 ids starting at $31
-- because trade.asm reaches them through
-- CopyTileIDsFromList_ZeroBaseTileID. Only the developer-only Python path
-- ever wrote these files, so an imported cache had none of them and
-- TradeAnim drew the whole cinematic as plain rectangles (#750).
function RomExtractor:extractTradeArt()
local BASE, COUNT = 0x31, 49
local gfx = self:symbol("TradingAnimationGraphics")
local atlas = ImageWriter.decode2bpp(
self.rom:bytes(gfx.bank, gfx.address, COUNT * 16), COUNT * 8, 8)
local function tileX(id)
local index = id - BASE
assert(index >= 0 and index < COUNT,
("trade tile $%02X is outside the animation atlas"):format(id))
return index * 8
end
local function plate(label, tilesWide, tilesHigh, relative, matte)
local map = self:symbol(label)
local ids = self.rom:bytes(map.bank, map.address, tilesWide * tilesHigh)
local image = ImageWriter.blank(tilesWide * 8, tilesHigh * 8, 1, 1, 1, 1)
for index, id in ipairs(ids) do
ImageWriter.blit(image, atlas,
(index - 1) % tilesWide * 8,
math.floor((index - 1) / tilesWide) * 8, tileX(id), 0, 8, 8)
end
if matte then image = ImageWriter.matteColor0(image) end
self:save(image, relative)
end
plate("GameBoyTiles", 6, 8, "trade/game_boy.png", true)
plate("LinkCableTiles", 12, 3, "trade/open_cable.png", false)
for _, spec in ipairs({
{ 0x5D, "cable_conn" }, { 0x5E, "cable_seg" }, { 0x5F, "cable_corner" },
{ 0x60, "cable_end" }, { 0x61, "cable_vert" },
}) do
local tile = ImageWriter.blank(8, 8, 1, 1, 1, 1)
ImageWriter.blit(tile, atlas, 0, 0, tileX(spec[1]), 0, 8, 8)
self:save(tile, "trade/" .. spec[2] .. ".png")
end
-- Trade_DrawCableAcrossScreen fills a whole 20-tile row with tile $5e.
local horizontal = ImageWriter.blank(160, 8, 1, 1, 1, 1)
for column = 0, 19 do
ImageWriter.blit(horizontal, atlas, column * 8, 0, tileX(0x5E), 0, 8, 8)
end
self:save(horizontal, "trade/cable_horiz.png")
-- Trade_BallInsideLinkCableOAMBlock draws one tile four times with the
-- X/Y flips, so each of the two frames -- $7e travelling, $7f bulging,
-- the bottom row of TradingAnimationGraphics2 -- makes a 16x16 ball.
local ball = self:symbol("TradingAnimationGraphics2")
local frames = ImageWriter.decode2bpp(
self.rom:bytes(ball.bank, ball.address, 64), 16, 16, true)
for index, name in ipairs({ "cable_ball", "cable_ball_alt" }) do
local image = ImageWriter.blank(16, 16, 1, 1, 1, 0)
for y = 0, 7 do
for x = 0, 7 do
local r, g, b, a = frames:getPixel((index - 1) * 8 + x, 8 + y)
image:setPixel(x, y, r, g, b, a)
image:setPixel(15 - x, y, r, g, b, a)
image:setPixel(x, 15 - y, r, g, b, a)
image:setPixel(15 - x, 15 - y, r, g, b, a)
end
end
self:save(image, "trade/" .. name .. ".png")
end
-- The ring around the travelling mon: one 16x16 quadrant per animation
-- frame (engine/gfx/mon_icons.asm TradeBubbleIconGFX), mirrored into a
-- 32x32 circle by the OAM attributes in Trade_CircleOAMBlocks.
local bubble = self:symbol("TradeBubbleIconGFX")
self:write2bpp(self.rom:bytes(bubble.bank, bubble.address, 128),
16, 32, "trade/bubble.png", true)
return {
gameBoy = "assets/generated/trade/game_boy.png",
openCable = "assets/generated/trade/open_cable.png",
cableHoriz = "assets/generated/trade/cable_horiz.png",
cableConn = "assets/generated/trade/cable_conn.png",
cableVert = "assets/generated/trade/cable_vert.png",
cableCorner = "assets/generated/trade/cable_corner.png",
cableEnd = "assets/generated/trade/cable_end.png",
cableBall = "assets/generated/trade/cable_ball.png",
cableBallAlt = "assets/generated/trade/cable_ball_alt.png",
bubble = "assets/generated/trade/bubble.png",
source = "ROM:TradingAnimationGraphics + ROM:TradeBubbleIconGFX"
.. " (engine/movie/trade.asm InternalClockTradeAnim)",
}
end
function RomExtractor:extractField()
self:beginStage("Interface artwork")
local done, total = 0, 49
local done, total = 0, 50
local function tick()
done = done + 1
self:tick("Interface artwork", math.min(done, total), total)
@@ -1832,6 +1923,8 @@ function RomExtractor:extractField()
end
self:save(emotes, "emotes.png"); tick()
local tradeArt = self:extractTradeArt(); tick()
-- Yellow-only: the Surfing Pikachu minigame sheets
-- (gfx/surfing_pikachu.asm) at pret's canvas widths, so
-- src/ui/SurfingMinigame.lua's quads can be read off the source pngs.
@@ -1962,6 +2055,7 @@ function RomExtractor:extractField()
local converted = {}
for index, values in pairs(adjacency) do converted[tonumber(index)] = values end
data.hiddenExtras.trashCans.adjacent = converted
data.tradeArt = tradeArt
data.source = "canonical Pokemon Red ROM + bundled port metadata"
self:write("field", data)
self:tick("Interface artwork", total, total)
+37
View File
@@ -57,6 +57,10 @@ local REQUIRED_FILES = {
"assets/generated/battle/anims/move_anim_0.png",
"assets/generated/battle/anims/move_anim_1.png",
"assets/generated/audio/programs.bin",
-- The trade cinematic's Game Boy / cable art. Caches built before #750
-- carry none of it and fall back to plain rectangles, so listing one of
-- the files re-imports them without a CACHE_FORMAT bump.
"assets/generated/trade/game_boy.png",
}
-- Files only one version's cache carries. A version that predates one of
@@ -2242,6 +2246,39 @@ function RomImporter:pressDelete(kind, id, version, commit)
return false
end
-- Drain one frame's queued launcher actions; LauncherView.update hands the
-- batch straight over. A touch tap fires on EVERY element whose bounds hold
-- the finger, not only the topmost one: FlexLove gates its mouse path on
-- Context.findInteractiveAtPosition (libs/flexlove/modules/behaviors/
-- Clickable.lua) but polls touches per element with a bare bounds test
-- (EventHandler:processTouchEvents), so a phone tap on a save row's Delete
-- chip also lands on the row behind it. Control keys inside a row are the
-- row's key plus "-<what>", so a row's own action is dropped whenever a
-- control inside that row queued in the same batch, and #433's disarm runs
-- here instead of at queue time. Without both halves an Android tap on
-- Delete selected the slot and wiped the arm it had just set, so a secondary
-- slot became the loaded one and could never be deleted (#780).
function RomImporter:runActions(queue)
for i = 1, #queue do
local entry = queue[i]
local key = type(entry.key) == "string" and entry.key or ""
local superseded = false
for j = 1, #queue do
local other = queue[j]
if j ~= i and type(other.key) == "string"
and other.key:sub(1, #key + 1) == key .. "-" then
superseded = true
break
end
end
if not superseded then
if not entry.keepArm then self._confirmDelete = nil end
local ok, err = pcall(entry.fn)
if not ok then print("launcher action error: " .. tostring(err)) end
end
end
end
-- Clicks are polled inside FlexLove (mouse + love.touch); host-forwarded
-- mousepressed stays inert so Android's synthesized mouse path cannot
-- double-fire a tap (#553). Touch move/press/release must still reach
+24 -6
View File
@@ -6,8 +6,10 @@
-- bytes), runs them through SaveConvert.importSav (32768-byte + checksum
-- validated), then registers a fresh slot, writes it, and makes it active.
-- Export loads the active slot, encodes it back to a 32768-byte SRAM image, and
-- drops it in the save directory's exports/<version>/ folder, returning the
-- absolute path so the launcher can offer an "open folder" affordance.
-- drops it in exports/<version>/ under the same root SaveData's persistFs
-- writes slots to -- the portable game folder when portable.txt marks the
-- install, otherwise the LOVE save directory (#752) -- returning the absolute
-- path so the launcher can offer an "open folder" affordance.
--
-- Every failure returns false + a friendly one-line message (never raises), so
-- the card can surface it as a red notice line rather than crashing.
@@ -99,9 +101,10 @@ end
-- exportActiveSlot(version) -> ok, pathOrErr
-- Loads the version's active slot save (SaveData.load semantics), encodes it
-- back to a 32768-byte SRAM image, and writes it to
-- exports/<version>/gen1recomp-<version>-<slotId>.sav in the save directory
-- (created if absent). Returns true + the absolute path on success, false + a
-- friendly message otherwise.
-- exports/<version>/gen1recomp-<version>-<slotId>.sav under the portable game
-- folder when portable mode is on, otherwise the save directory (created if
-- absent). Returns true + the absolute path on success, false + a friendly
-- message otherwise.
function SaveFileIO.exportActiveSlot(version)
version = version or GameVersion.get()
local save = SaveData.load(version)
@@ -109,7 +112,14 @@ function SaveFileIO.exportActiveSlot(version)
local bytes, exportErr = SaveConvert.exportSav(save, version)
if not bytes then return false, exportErr end
local slotId = SaveData.activeSlot(version) or "save"
local fs = love and love.filesystem
-- Portable mode is the same seam SaveData's own persistFs uses: when
-- portable.txt marks the install every persistent write leaves the OS save
-- directory for the game folder, and an export is no exception. Writing
-- through love.filesystem here dropped the .sav in AppData while the slots
-- it came from lived on the stick, and the desktop "Open folder" affordance
-- (RomImporter:exportSave) followed the returned path straight there (#752).
local portableFs = SaveData.portableFs()
local fs = portableFs or (love and love.filesystem)
if not (fs and fs.write) then return false, "no filesystem available to export to" end
if fs.createDirectory then
fs.createDirectory("exports")
@@ -119,6 +129,14 @@ function SaveFileIO.exportActiveSlot(version)
local rel = ("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, slotId)
local ok, writeErr = fs.write(rel, bytes)
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
-- Absolute path for the notice line, resolved against whichever root took
-- the write. Portable paths use the OS separator (slotDiskPath does the
-- same); LOVE save-directory paths stay "/"-joined as before.
local portableBase = SaveData.portableBaseDir()
if portableBase then
local sep = package.config:sub(1, 1)
return true, portableBase .. sep .. rel:gsub("/", sep)
end
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
if base ~= "" then return true, base .. "/" .. rel end
return true, rel