The new experience (#201)

* new launcher and save converts and pipeline

* fixing bugs
This commit is contained in:
bryanthaboi
2026-07-25 12:36:53 -04:00
committed by GitHub
parent 6625391f76
commit 3069b2e2a9
135 changed files with 16596 additions and 1508 deletions
+330 -159
View File
@@ -173,6 +173,14 @@ local function grayImage(img)
return getImage(meta.path) or img
end
-- the asset path a loaded battle image came from (nil for the headless
-- stub images), so the battle_sprite_scales registry can be looked up by
-- the same path data references
local function imagePathOf(img)
local m = imageMeta[img]
return m and m.path
end
-- the image a battler pic actually draws with this frame
function BattleState:picImage(img)
if self.grayPics then return grayImage(img) end
@@ -267,6 +275,10 @@ local function makeBattler(data, mon, isPlayer, save)
badgeBoosts = badgeBoosts,
statuses = data.statuses,
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain)
-- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move
-- while the tilemap still shows the prior condition until the next
-- post-action HUD refresh (core.asm after Execute*Move)
shownStatus = mon.status,
stages = {},
-- volatile state; Transform/Conversion/Mimic override the cur* fields
curStats = mon.stats,
@@ -548,6 +560,12 @@ function BattleState:say(text)
table.insert(self.queue, { text = text })
end
-- Message that opens YES/NO once typed out, keeping the text visible
-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice).
function BattleState:sayChoice(text, onChoose)
table.insert(self.queue, { text = text, choice = onChoose })
end
function BattleState:act(fn)
table.insert(self.queue, { fn = fn })
end
@@ -808,8 +826,24 @@ function BattleState:updateQueue()
if self.charIndex < self.total then
self.charIndex = math.min(self.total, self.charIndex + 2)
else
local item = self.current
-- TrainerAboutToUseText ends in `done` then DisplayTextBoxID: YES/NO
-- overlays the still-visible "Will … change POKéMON?" page.
if item and item.choice and not item.choiceOpen then
item.choiceOpen = true
self.waitingUI = true
local ChoiceBox = require("src.ui.ChoiceBox")
local battle = self
self.game.stack:push(ChoiceBox.new(self.game, function(yes)
local fn = item.choice
battle.current = nil
fn(yes)
end))
return true
end
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b") then
if not (item and item.choice)
and (input:wasPressed("a") or input:wasPressed("b")) then
self.current = nil
end
end
@@ -1060,11 +1094,14 @@ function BattleState:update(dt)
self:updateFx()
local input = self.game.input
-- safety net: HP changed outside a queued drain (level-up heals,
-- field effects) snaps once the queue is idle
-- safety net: HP/status changed outside a queued drain (level-up heals,
-- field effects, bag cures) snaps once the queue is idle
if self.phase == "menu" then
for _, b in ipairs({ self.player, self.enemy }) do
if b and b.shownHP then b.shownHP = b.mon.hp end
if b then
if b.shownHP then b.shownHP = b.mon.hp end
b.shownStatus = b.mon.status
end
end
end
@@ -2145,90 +2182,105 @@ end
-- move execution pipeline
-- ---------------------------------------------------------------------
-- Mirror DrawHUDsAndHPBars: reveal mon.status on the HUD only after the
-- current action's queued anim/text have played (PoisonEffect sets the
-- bit before PlayCurrentMoveAnimation2 + PrintText, but the HUD redraw
-- waits until after Execute*Move returns).
function BattleState:syncShownStatus()
for _, b in ipairs({ self.player, self.enemy }) do
if b then b.shownStatus = b.mon.status end
end
end
function BattleState:executeAction(user, target, action)
if user.mon.hp <= 0 or target.mon.hp <= 0 then return end
if not action then return end
-- ghost battles: the ghost never attacks; its whole turn is the
-- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463)
if self.ghost and not user.isPlayer then
self:sayNext(self.data.text._GetOutText or "GHOST: Get out...\nGet out...")
return
end
-- refresh the held-in-place mirror before the status checks (see
-- lockedAction): the victim is held exactly while the opponent's
-- trapping bit is set -- including a counter sitting at 0 until the
-- end-of-turn CheckNumAttacksLeft clear
user.boundTurns = target.trappingTurns
and math.max(1, target.trappingTurns) or nil
-- trainer class AI actions (engine/battle/trainer_ai.asm)
if action.special == "aiItem" then
self.aiUses = (self.aiUses or 1) - 1
for _, m in ipairs(TrainerAI.useItem(self, action.item)) do
self:sayNext(prefixEnemy(m, self.enemy))
end
self:drainNext()
require("src.core.Sound").play(self.data, "Heal_Ailment")
return
end
if action.special == "aiSwitch" then
self.aiUses = (self.aiUses or 1) - 1
local previous = self.enemy
local oldName = self.enemy.name
self.enemyIndex = action.index
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy,
previous = previous,
})
self.aiUses = self:aiUsesFor()
markSeen(self.game, self.enemy.mon.species)
-- _AIBattleWithdrawText: "X with-/drew Y!"
self:sayNext(("%s with-\ndrew %s!"):format(self.trainer.name, oldName))
self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name))
return
end
-- special locked actions. All of them still run the status gauntlet:
-- CheckPlayerStatusConditions (core.asm:3328-3583) evaluates sleep ->
-- freeze -> held-in-place -> flinch -> recharge -> disable tick ->
-- confusion -> paralysis BEFORE the bide/thrash/trapping handling.
if action.special == "recharge" then
-- only reaching .HyperBeamCheck consumes the flag (core.asm:3384-
-- 3392): sleep/freeze/held/flinch keep the mon recharging next turn
if self:preRechargeChecks(user, target) then return end
user.mustRecharge = nil
self:sayNext(("%s\nmust recharge!"):format(displayName(user)))
return
end
if action.special == "bound" then
if not target.trappingTurns then
-- the trap ended earlier this turn: the CANNOT_MOVE selection is
-- simply lost (ExecutePlayerMove returns immediately on $ff)
local function run()
-- ghost battles: the ghost never attacks; its whole turn is the
-- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463)
if self.ghost and not user.isPlayer then
self:sayNext(self.data.text._GetOutText or "GHOST: Get out...\nGet out...")
return
end
-- sleep/freeze take precedence over the held-in-place message
if self:statusInterrupt(user, target) then return end
return
end
if action.special == "trapping" then
if self:statusInterrupt(user, target) then return end
self:continueTrapping(user, target)
return
end
if action.special == "bide" then
if self:statusInterrupt(user, target) then return end
self:continueBide(user, target)
return
end
if self:statusInterrupt(user, target) then return end
self:performMove(user, target, action, false)
-- refresh the held-in-place mirror before the status checks (see
-- lockedAction): the victim is held exactly while the opponent's
-- trapping bit is set -- including a counter sitting at 0 until the
-- end-of-turn CheckNumAttacksLeft clear
user.boundTurns = target.trappingTurns
and math.max(1, target.trappingTurns) or nil
-- trainer class AI actions (engine/battle/trainer_ai.asm)
if action.special == "aiItem" then
self.aiUses = (self.aiUses or 1) - 1
for _, m in ipairs(TrainerAI.useItem(self, action.item)) do
self:sayNext(prefixEnemy(m, self.enemy))
end
self:drainNext()
require("src.core.Sound").play(self.data, "Heal_Ailment")
return
end
if action.special == "aiSwitch" then
self.aiUses = (self.aiUses or 1) - 1
local previous = self.enemy
local oldName = self.enemy.name
self.enemyIndex = action.index
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy,
previous = previous,
})
self.aiUses = self:aiUsesFor()
markSeen(self.game, self.enemy.mon.species)
-- _AIBattleWithdrawText: "X with-/drew Y!"
self:sayNext(("%s with-\ndrew %s!"):format(self.trainer.name, oldName))
self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name))
return
end
-- special locked actions. All of them still run the status gauntlet:
-- CheckPlayerStatusConditions (core.asm:3328-3583) evaluates sleep ->
-- freeze -> held-in-place -> flinch -> recharge -> disable tick ->
-- confusion -> paralysis BEFORE the bide/thrash/trapping handling.
if action.special == "recharge" then
-- only reaching .HyperBeamCheck consumes the flag (core.asm:3384-
-- 3392): sleep/freeze/held/flinch keep the mon recharging next turn
if self:preRechargeChecks(user, target) then return end
user.mustRecharge = nil
self:sayNext(("%s\nmust recharge!"):format(displayName(user)))
return
end
if action.special == "bound" then
if not target.trappingTurns then
-- the trap ended earlier this turn: the CANNOT_MOVE selection is
-- simply lost (ExecutePlayerMove returns immediately on $ff)
return
end
-- sleep/freeze take precedence over the held-in-place message
if self:statusInterrupt(user, target) then return end
return
end
if action.special == "trapping" then
if self:statusInterrupt(user, target) then return end
self:continueTrapping(user, target)
return
end
if action.special == "bide" then
if self:statusInterrupt(user, target) then return end
self:continueBide(user, target)
return
end
if self:statusInterrupt(user, target) then return end
self:performMove(user, target, action, false)
end
run()
-- after announce/anim/effect text (pokered DrawHUDsAndHPBars)
self:actNext(function() self:syncShownStatus() end)
end
-- Sleep / confusion onomatopoeia from Check*StatusConditions
@@ -2724,51 +2776,37 @@ function BattleState:enemyMonFainted()
end
if nextIndex then
self.enemyIndex = nextIndex
-- SHIFT battle style (the default): announce the next mon and
-- offer a free switch (SET skips the prompt)
-- EnemySendOutFirstMon (core.asm:1366-1443): SHIFT offers a free
-- switch when party count > 1, the active mon is alive, and the
-- battle-style bit is clear. pokered counts party slots (not
-- remaining HP); SET / single-mon / fainted active skip the prompt.
local nextMon = self.enemyParty[self.enemyIndex]
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
local style = (self.game.save.options or {}).battleStyle or "shift"
local healthy = 0
for _, mon in ipairs(self.game.save.party) do
if mon.hp > 0 then healthy = healthy + 1 end
end
if style ~= "set" and healthy > 1 and self.player.mon.hp > 0 then
self:say(("%s is\nabout to use\n%s!"):format(self.trainer.name, nextName))
self:say(("Will %s\nchange POKéMON?"):format(self.game.save.player.name))
local game = self.game
self:ui(function()
local ChoiceBox = require("src.ui.ChoiceBox")
return ChoiceBox.new(game, function(yes)
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
:lower()
local partyCount = #self.game.save.party
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
local shiftSwitchMon = nil
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
-- _TrainerAboutToUseText: "X is" / "about to use" then cont nick,
-- then para "Will PLAYER" / "change POKéMON?" with YES/NO.
self:say(("%s is\nabout to use"):format(self.trainer.name))
self:say(("%s!"):format(nextName))
self:sayChoice(
("Will %s\nchange POKéMON?"):format(self.game.save.player.name),
function(yes)
if not yes then return end
local game = self.game
Screens.push(game, "PartyMenu", {
battle = self,
forceSwitch = true,
onSwitch = function(mon)
if mon ~= self.player.mon and mon.hp > 0 then
local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
battler = self.player, previous = previous,
})
self:markParticipant()
self.nextInsert = 0
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
require("src.core.Sound").playCry(self.data, self.player.mon.species)
end)
shiftSwitchMon = mon
end
end,
})
end)
end)
end
self:act(function()
local previous = self.enemy
@@ -2797,6 +2835,29 @@ function BattleState:enemyMonFainted()
end)
end)
end)
-- SwitchPlayerMon after the enemy is out (core.asm:1436-1443)
self:act(function()
local mon = shiftSwitchMon
if not mon then return end
local previous = self.player
self.player = makeBattler(self.data, mon, true, self.game.save)
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
battler = self.player, previous = previous,
})
self:markParticipant()
self.nextInsert = 0
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
self:startGrowIn(self.player)
require("src.core.Sound").playCry(self.data, self.player.mon.species)
end)
end)
return
end
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
@@ -2920,6 +2981,8 @@ function BattleState:openReplacementMenu()
self:ui(function()
return self:buildScreen("PartyMenu", {
battle = self,
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
forceSwitch = true,
onSwitch = function(mon)
if mon.hp <= 0 then
self:say("There's no will\nto fight!")
@@ -3130,6 +3193,9 @@ end
-- called by BagMenu after an item is used in battle (consumes the turn)
function BattleState:itemUsed(messages)
-- bag cures clear mon.status before the message UI; refresh the HUD
-- once control returns (pokered DrawHUDsAndHPBars after item use)
self:syncShownStatus()
for _, m in ipairs(messages or {}) do self:say(m) end
table.insert(self.queue, { drain = true }) -- potions animate the bar
self:act(function()
@@ -3151,10 +3217,39 @@ function BattleState:ballMissMessage(shakes)
return t._ItemUseBallText04 or "Shoot! It was so\nclose too!"
end
-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
-- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party
-- AddPartyMon and SendNewMonToBox (#172).
function BattleState:askNicknameUI(mon, displayName)
local game = self.game
self.lockedBall = nil
self.blankForAskName = true
local TextBox = require("src.render.TextBox")
local text = ("Do you want to\ngive a nickname\nto %s?"):format(displayName)
local label = game.data.text and game.data.text._DoYouWantToNicknameText
if label then
-- extractor CONT is \t; TextBox scrolls on \n/\v
text = label:gsub("\t", "\n"):gsub("{RAM:?[%w_]*}", displayName)
end
return TextBox.new(game, text, nil, {
choice = function(yes)
self.blankForAskName = false
if not yes then return end
pcall(Screens.push, game, "NamingScreen", {
title = "NICKNAME?", maxLen = 10,
onDone = function(name)
if name and #name > 0 then mon.nickname = name end
end,
})
end,
})
end
-- The caught mon joins the party or a PC box (ItemUseBall .captured,
-- item_effects.asm:518-566): the caught text, then for a NEW species
-- "New POKéDEX data will be added" + the dex entry page, then the
-- party add (with the nickname ask) or the PC transfer text.
-- "New POKéDEX data will be added" + the dex entry page, then
-- AddPartyMon or SendNewMonToBox (both call AskName), then the PC
-- transfer text when the party was full.
function BattleState:storeCaughtMon()
-- ItemUseBall reloads the caught mon via LoadEnemyMonData
-- (item_effects.asm:472-501), regenerating its move list from the
@@ -3174,31 +3269,20 @@ function BattleState:storeCaughtMon()
return self:buildScreen("DexEntryMenu", species)
end)
end
if Party.add(game.save.party, self.enemy.mon) then
-- nickname prompt (AskName runs inside AddPartyMon; box mons are
-- never offered a nickname)
local function askCaughtNickname()
local caught = self.enemy.mon
local enemyName = self.enemy.name
self:uiNext(function()
local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
return TextBox.new(game, ("Do you want to\ngive a nickname\nto %s?")
:format(enemyName), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end
pcall(Screens.push, game, "NamingScreen", {
title = "NICKNAME?", maxLen = 10,
onDone = function(name)
if name and #name > 0 then caught.nickname = name end
end,
})
end))
end)
return self:askNicknameUI(caught, enemyName)
end)
end
if Party.add(game.save.party, self.enemy.mon) then
askCaughtNickname()
else
destination = "box"
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
if boxNum then
askCaughtNickname()
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
and "BILL's PC" or "someone's PC"
@@ -3449,12 +3533,15 @@ function BattleState:fxFaintActive(battler)
and fx.faint.frames > 0 or false
end
-- vertical slide offset for a fainting battler (the player's pic is
-- drawn 2x, so it slides 2x as fast to sink at the same visual rate)
function BattleState:fxFaintOffset(battler)
-- vertical slide offset for a fainting battler. The offset is in screen
-- pixels, so it scales with the pic's draw scale (the player's default 2x
-- sinks 2x as fast to sink at the same visual rate); a mod scale composes
-- the same way. scale defaults to the vanilla side scale when unknown.
function BattleState:fxFaintOffset(battler, scale)
local fx = self.fx
if self:fxFaintActive(battler) then
return (30 - fx.faint.frames) * 2 * (battler.isPlayer and 2 or 1)
scale = scale or (battler.isPlayer and 2 or 1)
return (30 - fx.faint.frames) * 2 * scale
end
return 0
end
@@ -3550,7 +3637,7 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
return
end
if self:fxFaintActive(battler) then
local off = self:fxFaintOffset(battler)
local off = self:fxFaintOffset(battler, scale)
local visible = img:getHeight() - math.floor(off / scale)
if visible > 0 then
local quad = love.graphics.newQuad(0, 0, img:getWidth(), visible,
@@ -3847,6 +3934,63 @@ function BattleState:drawAnimLayer(colorized)
end
end
-- ------------------------------------------------------------------
-- Mod-facing battle sprite scaling. The enemy front pic draws at 1x and
-- the player back pic at 2x on the GB; a mod can override either per
-- species (pokemon.battleScaleFront / battleScaleBack) or per image path
-- (the battle_sprite_scales registry, which is the only handle on the
-- non-species pics like the trainer back). These resolvers and the
-- placement math are pure (no love.*) so the grounding contract -- feet
-- pinned at any scale -- is unit-tested directly.
-- ------------------------------------------------------------------
-- the vanilla scale for a side: enemy front 1x, player back 2x
BattleState.BATTLE_SCALE_DEFAULT = { front = 1, back = 2 }
-- image-level override for an asset path, or nil. scales is the merged
-- data.battle_sprite_scales table (record id -> { path, scale }).
function BattleState.imageBattleScale(scales, path)
if not scales or not path then return nil end
for id, rec in pairs(scales) do
if id ~= "_owners" and type(rec) == "table" and rec.path == path then
return rec.scale
end
end
return nil
end
-- effective battle scale for a pic: image-level override, else the
-- species-level override for the side, else the side default. side is
-- "front" (enemy) or "back" (player); species may be nil (a non-species
-- pic like the trainer back, which only image-level scaling reaches).
function BattleState.resolveBattleScale(data, side, path, species)
local img = data and BattleState.imageBattleScale(data.battle_sprite_scales, path)
if img then return img end
local def = species and data and data.pokemon and data.pokemon[species]
local field = side == "back" and "battleScaleBack" or "battleScaleFront"
local override = def and def[field]
if override then return override end
return BattleState.BATTLE_SCALE_DEFAULT[side] or 1
end
-- Player (back) placement: feet flush on the text-box top (y=96) at any
-- scale, with the left transparent columns pulled back so opaque pixels
-- land where hardware's white-on-white columns left them. Returns the
-- top-left x, y and the scale (slide/shake offsets are added by the
-- caller). Feet stay at 96 for every scale: y + (h - pad) * scale == 96.
function BattleState.backPlacement(w, h, pad, padL, scale)
return 8 - padL * scale, 96 - (h - pad) * scale, scale
end
-- Enemy (front) placement: given the s=1 slot origin (ex, ey) from the
-- 7x7 tile layout, keep the bottom edge and horizontal centre pinned as
-- the pic scales -- the same compensation AnimateSendingOutMon's grow
-- uses. Returns top-left x, y and the scale. The bottom edge stays put
-- for every scale: y + h * scale == ey + h.
function BattleState.frontPlacement(ex, ey, w, h, scale)
return ex + w * (1 - scale) / 2, ey + h * (1 - scale), scale
end
-- Front/trainer pics: LoadUncompressedSpriteData centers the sprite in
-- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that
-- buffer at hlcoord 12,0. Horizontal pad is floor((8-w)/2) tiles;
@@ -3890,16 +4034,23 @@ function BattleState:drawPicsLayer(slide, sx, sy)
local img = self:picImage(self.enemy.sprite)
love.graphics.setColor(1, 1, 1, 1)
local ex, ey = enemyPicXY(img, slide, sx, sy)
local s = BattleState.resolveBattleScale(self.data, "front",
imagePathOf(img), self.enemy.mon and self.enemy.mon.species)
local gs = self:growInScale(self.enemy)
if gs then
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
-- and horizontal center pinned to the mon's slot while it grows
if gs > 0 then
love.graphics.draw(img, ex + img:getWidth() * (1 - gs) / 2,
ey + img:getHeight() * (1 - gs), 0, gs, gs)
-- and horizontal center pinned to the mon's slot while it grows --
-- the mod scale composes multiplicatively with the grow stage
local eff = s * gs
if eff > 0 then
local dx, dy = BattleState.frontPlacement(ex, ey,
img:getWidth(), img:getHeight(), eff)
love.graphics.draw(img, dx, dy, 0, eff, eff)
end
else
self:drawBattlerPic(self.enemy, ex, ey, 1)
local dx, dy = BattleState.frontPlacement(ex, ey,
img:getWidth(), img:getHeight(), s)
self:drawBattlerPic(self.enemy, dx, dy, s)
end
end
@@ -3913,9 +4064,14 @@ function BattleState:drawPicsLayer(slide, sx, sy)
local img = self:picImage(self.playerBackPic)
local pad = imagePadBottom[self.playerBackPic] or 0
local padL = imagePadLeft[self.playerBackPic] or 0
-- the trainer back is a bare pic, not species-keyed, so only an
-- image-level battle_sprite_scales entry can rescale it
local s = BattleState.resolveBattleScale(self.data, "back",
imagePathOf(self.playerBackPic), nil)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, 8 - padL * 2 + slide + sx,
96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
local dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(),
pad, padL, s)
love.graphics.draw(img, dx + slide + sx, dy + sy, 0, s, s)
elseif self.player and self.player.sprite and not hidePlayer
and not self.sendingOut and not self:fxHidden(self.player) then
local img = self:picImage(self.player.sprite)
@@ -3923,19 +4079,24 @@ function BattleState:drawPicsLayer(slide, sx, sy)
-- feet flush on the text box top (y=96), ignoring baked-in padding
local pad = imagePadBottom[self.player.sprite] or 0
local padL = imagePadLeft[self.player.sprite] or 0
local px = 8 - padL * 2 + sx
local s = BattleState.resolveBattleScale(self.data, "back",
imagePathOf(self.player.sprite),
self.player.mon and self.player.mon.species)
local gs = self:growInScale(self.player)
if gs then
-- the player-side AnimateSendingOutMon grow (after the poof,
-- core.asm:1757-1762): feet pinned at y=96, center at x=8+w
if gs > 0 then
love.graphics.draw(img, px + img:getWidth() * (1 - gs),
96 - (img:getHeight() - pad) * 2 * gs + sy,
0, 2 * gs, 2 * gs)
-- core.asm:1757-1762): feet pinned at y=96, horizontal centre
-- pinned, mod scale composed with the grow stage
local eff = s * gs
if eff > 0 then
love.graphics.draw(img,
8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
end
else
self:drawBattlerPic(self.player, px,
96 - (img:getHeight() - pad) * 2 + sy, 2)
local dx, dy = BattleState.backPlacement(img:getWidth(),
img:getHeight(), pad, padL, s)
self:drawBattlerPic(self.player, dx + sx, dy + sy, s)
end
end
if clipped then
@@ -3956,8 +4117,11 @@ function BattleState:drawHUDs(slide)
local barData = self:colorMode() and {} or self.data -- gray fill when zoned
local fx = self.fx
local hudShake = (fx and fx.hudShakeX) or 0
-- FaintEnemyPokemon clears the enemy HUD area; it stays blank through
-- TrainerAboutToUseText until DrawEnemyHUDAndHPBar after the next send-out
if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut
and not self:growInScale(self.enemy) and slide == 0 then
and not self:growInScale(self.enemy) and slide == 0
and not self.enemy.fainted then
-- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1),
-- HP bar (2,2) with the vertical tick at (1,2), underline row 3;
-- AnimationShakeEnemyHUD nudges just this block via SCX
@@ -3967,8 +4131,8 @@ function BattleState:drawHUDs(slide)
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0)
if self.enemy.mon.status then
Font.draw(self:statusLabel(self.enemy.mon), 40, 8)
if self.enemy.shownStatus then
Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8)
else
hudTile(0x6E, 32, 8) -- <LV>
Font.draw(tostring(self.enemy.mon.level), 40, 8)
@@ -4015,8 +4179,8 @@ function BattleState:drawHUDs(slide)
-- the tick at (18,10) and the triangle at (9,11)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.player.name, nameX(10, self.player.name), 56)
if self.player.mon.status then
Font.draw(self:statusLabel(self.player.mon), 120, 64)
if self.player.shownStatus then
Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64)
else
hudTile(0x6E, 112, 64) -- <LV>
Font.draw(tostring(self.player.mon.level), 120, 64)
@@ -4122,6 +4286,13 @@ function BattleState:drawTextArea()
end
function BattleState:draw()
-- AskName: ClearSprites + wild ClearScreenArea — white field under the
-- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top.
if self.blankForAskName then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
return
end
local fx = self.fx
-- window shakes (SE_SHAKE_SCREEN / the enemy-hit vertical shake);
-- the animations-off fallback keeps the old +-2 alternation
+2
View File
@@ -102,6 +102,8 @@ function Data:seedDefaults()
-- after-battle rows so Blaine's SetEventRange deactivation and talk
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
self:seedCinnabarGymTrainerHeaders()
-- #189: 1F cabin door order vs rooms map (survey zoom)
require("src.world.SsAnneLayout").apply(self.maps)
end
function Data:seedCinnabarGymTrainerHeaders()
+20
View File
@@ -37,6 +37,9 @@ function Game:load()
self.mods = ModLoader.new()
self.mods:load(Data)
self.modStatus = self.mods:status()
-- render pipelines dispatch off the merged dataset; point them at the
-- one the mods just merged into before anything can draw a frame
require("src.render.Pipelines").install(Data)
self.input = Input
Input:init()
@@ -198,6 +201,9 @@ function Game:update(dt)
-- Overworld tilt toggle tween: presentational, so it runs on the real
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
require("src.render.Tilt").update(dt)
-- mod render pipelines tween on the same real-frame clock, for the same
-- reason: they are presentational, so fast-forward must not speed them up
require("src.render.Pipelines").update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end)
-- Steady-state memory backstop: advance the incremental collector one
-- small step every rendered frame. The heavy GPU objects are now freed
@@ -341,6 +347,17 @@ function Game:keypressed(key)
self:writeOptions()
return
end
-- Mod render pipelines claim their hotkeys last, so one can never shadow
-- an engine display key however a mod declares it (12 §rendering
-- pipelines). syncOptions writes the whole ladder back, including the
-- tilt exclusion a world pipeline forces.
local Pipelines = require("src.render.Pipelines")
if Pipelines.hotkey(key, self.stack:top(), self.overworld) then
Pipelines.syncOptions(self.save.options)
require("src.render.Tilt").setLevel(self.save.options.tilt or 0)
self:writeOptions()
return
end
Input:keypressed(key)
end
@@ -457,6 +474,9 @@ function Game:applyOptions(opts)
if Sound.applyOptions then Sound.applyOptions(opts) end
require("src.render.PaletteFX").applyOptions(opts)
require("src.render.Tilt").applyOptions(opts)
-- after Tilt, so a persisted world pipeline can switch the tilt level it
-- just restored back off (the two are mutually exclusive)
require("src.render.Pipelines").applyOptions(opts)
require("src.render.Zoom").applyOptions(opts)
require("src.render.TileRenderer").applyOptions(opts)
-- returns true when a persisted GBC FX level was cleared on mobile
+284 -4
View File
@@ -17,6 +17,7 @@ local Runtime = require("src.mods.Runtime")
local Semver = require("src.mods.Semver")
local Boxes = require("src.pokemon.Boxes")
local Bag = require("src.inventory.Bag")
local Badges = require("src.inventory.Badges")
local GameVersion = require("src.core.GameVersion")
@@ -31,10 +32,11 @@ local OPTIONS_FILENAME = "options.lua"
-- 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;
-- load promotes either when the main file is missing or fails to parse.
local function saveNames(version)
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- Forward-declared here so saveFilename (just below) and every save/load
-- caller share the one upvalue; the body is filled in under "save slots"
-- once the options IO it depends on exists, because it now resolves the
-- ACTIVE slot for a version rather than the fixed flat name.
local saveNames
-- The main save filename for a version -- used by the title screen's
-- CONTINUE gate so it looks for the right game's save.
@@ -91,6 +93,18 @@ local function makePortableFs(dir)
os.remove(full(name))
return true
end,
createDirectory = function(name)
-- portable mode writes real files through io.*, which will not
-- create missing parent directories; mkdir the tree so a slot path
-- like "saves/red" exists before a write lands inside it
local osPath = full(name):gsub("/", SEP)
if SEP == "\\" then
os.execute('mkdir "' .. osPath .. '" 2>nul')
else
os.execute('mkdir -p "' .. osPath .. '" 2>/dev/null')
end
return true
end,
}
end
@@ -198,6 +212,11 @@ function SaveData.defaultOptions()
videoMode = "windowed",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- Per-pipeline display levels, keyed by render_pipelines id (see
-- src/render/Pipelines.lua). A level for a mod that is not installed
-- is kept rather than pruned, so re-enabling the mod restores the mode
-- the player left it in.
pipelines = {},
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
@@ -283,6 +302,264 @@ function SaveData.loadOptions(fs)
return SaveData.mergeOptions(data)
end
-- ------- save slots
-- A version's playthroughs live in numbered slots under saves/<version>/;
-- the active slot is where in-game SAVE and CONTINUE land. The registry
-- (the ordered slot list plus which one is active) persists in options.lua
-- under options.saveSlots[version]; the active slot is also cached
-- process-wide (like GameVersion.current) so the hot saveNames path does
-- not re-read options every call. A false cache entry means "no slot in
-- use" and the flat legacy path (save.lua / save_blue.lua) is used, which
-- keeps a brand-new install and every pre-slots caller working unchanged.
local activeSlotCache = {} -- version -> slotId in use, or false when none
local slotsChecked = {} -- version -> true once resolved this process
local function slotDir(version) return "saves/" .. version end
local function slotNames(version, id)
local main = slotDir(version) .. "/" .. id .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- the pre-slots flat names a version always used (save.lua for Red,
-- save_blue.lua for Blue); still the destination before any slot exists
local function legacyNames(version)
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- Slot resolution is only meaningful for versions GameVersion actually knows
-- (red/blue). The launcher also renders a locked placeholder tab ("yellow")
-- that has no info entry and therefore no saveSuffix; resolving its legacy
-- names would index a nil info table and crash. Treat any unknown version as
-- having no slots so the slot APIs degrade to empty/no-op instead.
local function knownVersion(version)
return GameVersion.info(version) ~= nil
end
-- Create the parent directory of a slot path when the fs supports it.
-- love.filesystem.createDirectory makes the whole tree; the injected memfs
-- stub keys files by full path and exposes no such method, so this is a
-- no-op there.
local function ensureParentDir(fs, name)
local dir = name:match("^(.*)/[^/]+$")
if dir and fs.createDirectory then fs.createDirectory(dir) end
end
-- Decode a slot's save using the same recovery order load() uses -- main,
-- then the .tmp write-witness, then the .bak -- so a slot mid-crash still
-- summarizes. nil when nothing readable is present.
local function decodeSlot(fs, version, id)
local main, bak, tmp = slotNames(version, id)
local data = fs.getInfo(main) and SaveSerializer.decode(fs.read(main) or "")
if data then return data end
data = fs.getInfo(tmp) and SaveSerializer.decode(fs.read(tmp) or "")
if data then return data end
data = fs.getInfo(bak) and SaveSerializer.decode(fs.read(bak) or "")
return data or nil
end
-- One-time legacy consolidation: a pre-slots install has a flat save file
-- (+ .bak) and no saves/<version>/ registry. Copy both into slot1, verify
-- the copy reads back, then remove the originals and register slot1 as the
-- active slot. Returns the new slot id, or nil when there is nothing to
-- migrate or the copy could not be verified (originals left in place so no
-- data is ever lost to a failed move).
local function tryMigrateLegacy(version, fs)
local lmain, lbak, ltmp = legacyNames(version)
local mainBody = fs.getInfo(lmain) and fs.read(lmain)
local bakBody = fs.getInfo(lbak) and fs.read(lbak)
if not (mainBody or bakBody) then return nil end
local id = "slot1"
local dmain, dbak = slotNames(version, id)
ensureParentDir(fs, dmain)
if mainBody then fs.write(dmain, mainBody) end
if bakBody then fs.write(dbak, bakBody) end
-- refuse to delete the originals unless the new slot is loadable (from
-- the main copy or, failing that, the backup)
if not decodeSlot(fs, version, id) then return nil end
remove(fs, lmain)
remove(fs, lbak)
remove(fs, ltmp)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
opts.saveSlots[version] = { list = { id }, active = id }
SaveData.saveOptions(opts, fs)
return id
end
-- Resolve (once per version per process) which slot in-game saves use: an
-- existing registry wins; otherwise a lazy legacy migration may create
-- slot1; otherwise false, meaning the flat legacy path.
local function ensureVersionSlots(version, fs)
if slotsChecked[version] then return end
slotsChecked[version] = true
if not knownVersion(version) then
activeSlotCache[version] = false
return
end
local opts = SaveData.loadOptions(fs)
local reg = opts.saveSlots and opts.saveSlots[version]
if reg and type(reg.list) == "table" and #reg.list > 0 then
activeSlotCache[version] = reg.active or reg.list[1]
return
end
activeSlotCache[version] = tryMigrateLegacy(version, fs) or false
end
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
-- the version, falling back to the flat legacy names when no slot is in use.
function saveNames(version)
version = version or GameVersion.get()
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local slot = activeSlotCache[version]
if slot then return slotNames(version, slot) end
return legacyNames(version)
end
-- Pure extraction of the launcher's per-slot summary from a decoded save,
-- factored out so it is unit-testable with no filesystem: the player name
-- (nil for an empty slot) and { badges, timeText, dexCount } -- the same
-- fields the title screen's ContinueInfo derives. Badges resolve against
-- the vanilla gym list (launcher has no loaded Data), which is what the
-- flat launcher meta line needs.
function SaveData.slotSummary(save)
if type(save) ~= "table" then return nil, nil end
local name = save.player and save.player.name or nil
local dexCount = 0
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
dexCount = dexCount + 1
end
local t = math.floor(save.playTime or 0)
local timeText = ("%d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60)
return name, {
badges = Badges.count(nil, save),
timeText = timeText,
dexCount = dexCount,
}
end
-- Slots visible to the launcher: every registered slot for a version, each
-- with whether it holds a save and the cheap summary above. A fresh
-- install with nothing registered returns an empty array; a legacy install
-- is migrated to slot1 first.
function SaveData.listSlots(version)
version = version or GameVersion.get()
if not knownVersion(version) then return {} end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local opts = SaveData.loadOptions(fs)
local reg = opts.saveSlots and opts.saveSlots[version]
local list = (reg and reg.list) or {}
local out = {}
for _, id in ipairs(list) do
local save = decodeSlot(fs, version, id)
local name, meta = SaveData.slotSummary(save)
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta }
end
return out
end
-- Point the active slot at slotId (registering it if new) and persist the
-- choice to options.lua; also update the process-global cache so the very
-- next save/load lands in the chosen slot.
function SaveData.setActiveSlot(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
local fs = persistFs(nil)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version] or { list = {}, active = nil }
local found = false
for _, id in ipairs(reg.list) do
if id == slotId then found = true break end
end
if not found then reg.list[#reg.list + 1] = slotId end
reg.active = slotId
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
slotsChecked[version] = true
activeSlotCache[version] = slotId
return slotId
end
-- Register a new empty slot for the version and return its id. Does NOT
-- write a save file and does NOT change the active slot: an empty slot
-- means the title screen offers NEW GAME only. Ids are "slot%d+",
-- allocated one past the highest existing number so a reused id can never
-- collide with a lingering file.
function SaveData.createSlot(version)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version] or { list = {}, active = nil }
local maxN = 0
for _, id in ipairs(reg.list) do
local n = tonumber(tostring(id):match("^slot(%d+)$"))
if n and n > maxN then maxN = n end
end
local id = "slot" .. (maxN + 1)
reg.list[#reg.list + 1] = id
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
return id
end
-- The active slot id in use for a version (resolved once per process like
-- saveNames does), or nil when none is registered and the flat legacy path is
-- in use. Public so the launcher's save Import/Export glue can name an export
-- after the slot it came from without reaching into the private cache.
function SaveData.activeSlot(version)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
return activeSlotCache[version] or nil
end
-- Write saveTable into an existing slot's file (SaveSerializer.encode), through
-- the same fs seam every other save/load call uses (so portable mode keeps
-- working) and the same .tmp-witness / .bak recovery discipline SaveData.save
-- uses for the flat path. Used by the launcher's save-import glue, which has
-- already registered the slot via createSlot but written no bytes yet; unlike
-- SaveData.save this targets a specific slot and never rebuilds meta or touches
-- options. Returns true, or false + an error string on a failed write.
function SaveData.writeSlot(version, slotId, saveTable)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
if type(slotId) ~= "string" then return false, "missing slot id" end
if type(saveTable) ~= "table" then return false, "missing save table" end
local main, bak, tmp = slotNames(version, slotId)
local encoded = SaveSerializer.encode(saveTable)
local fs = persistFs(nil)
ensureParentDir(fs, main)
if fs.getInfo(main) then
local prev = fs.read(main)
if prev then fs.write(bak, prev) end
end
local ok, err = fs.write(tmp, encoded)
if not ok then return false, err end
remove(fs, main)
ok, err = fs.write(main, encoded)
if not ok then return false, err end
remove(fs, tmp)
return true
end
-- Test seam: drop the process-global slot cache so a suite can exercise
-- migration/resolution against a freshly injected filesystem. Unused by
-- the game, which resolves each version exactly once per boot.
function SaveData.resetSlotState()
for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end
for k in pairs(slotsChecked) do slotsChecked[k] = nil end
end
-- ------- meta
-- the version/engine/mod-set stamp every v2 save carries; mods is the
@@ -525,6 +802,9 @@ function SaveData.save(data, mods)
end
local encoded = SaveSerializer.encode(gameOnly)
local fs = persistFs(nil)
-- the active slot may live in saves/<version>/, which must exist before
-- the .tmp/.bak/main writes land (a no-op for the flat legacy path)
ensureParentDir(fs, FILENAME)
if fs.getInfo(FILENAME) then
local prev = fs.read(FILENAME)
if prev then fs.write(BACKUP_FILENAME, prev) end
+10 -2
View File
@@ -4,14 +4,22 @@
-- tests.
local Version = {
engine = "1.0.0", -- game/engine release (semver triple)
engine = "0.0.0-dev", -- game/engine release (semver). Repo default is the
-- "-dev" placeholder; CI stamps the real X.Y.Z into
-- the packed game.love only, never the working tree.
shell = 1, -- native-shell contract this build implements
minShell = 1, -- lowest shell contract that can RUN this payload.
-- Bump only when a payload needs a newer native
-- binary (e.g. a LOVE version bump); an older shell
-- refuses to chainload a payload whose minShell
-- exceeds the shell it provides.
modApi = 2, -- mod API major (manifest `api`)
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
saveFormat = 4, -- save.meta.format
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
}
-- "gen1recomp v1.0.0"
-- "gen1recomp v0.0.0-dev" (or the stamped release version in shipped builds)
function Version.title(base)
return (base or "gen1recomp")
.. " v" .. Version.engine
+2
View File
@@ -375,6 +375,8 @@ function RomExtractor:extractMaps()
}
self:tick("Maps", mapIndex, #keys)
end
-- keep the extract ROM-faithful; Data:seedDefaults applies the #189
-- S.S. Anne 1F cabin reorder at load time
self:write("maps", out)
return out
end
+1516 -290
View File
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
-- SaveFileIO -- the launcher's glue between a raw Gen1 .sav battery image and
-- this project's save slots. Keeps RomImporter lean: the SAVE FILES card just
-- calls importToSlot / exportActiveSlot and renders the {ok, result} outcome.
--
-- Import reads bytes (an absolute picker path, a dropped LOVE file, or raw
-- 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/ folder, 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.
local SaveConvert = require("src.save_convert.SaveConvert")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local SaveFileIO = {}
local SAVE_SIZE = SaveConvert.SAVE_SIZE
-- Resolve raw save bytes from whatever the launcher hands us:
-- * a LOVE DroppedFile (a table/userdata with :open/:read/:getSize), read the
-- way RomImporter reads a dropped ROM;
-- * 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.
local function readSource(source)
local t = type(source)
if t == "table" or t == "userdata" then
if type(source.read) ~= "function" then
return nil, "that file could not be read"
end
local ok, openErr = source:open("r")
if not ok then return nil, "could not open the dropped file: " .. tostring(openErr) end
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
end
if t ~= "string" then
return nil, "no save file was provided"
end
if #source == SAVE_SIZE then
return source
end
local f, openErr = io.open(source, "rb")
if not f then return nil, "could not read the save file: " .. tostring(openErr) end
local data = f:read("*a")
f:close()
if type(data) ~= "string" then return nil, "the save file was empty" end
return data
end
-- importToSlot(source, version) -> ok, slotIdOrErr
-- source: an absolute path, a LOVE DroppedFile, or raw 32768 bytes. On success
-- registers a new slot for the version, writes the imported save into it, makes
-- it the active slot, and returns true + the new slot id. On any failure
-- returns false + a friendly message.
function SaveFileIO.importToSlot(source, version)
version = version or GameVersion.get()
local bytes, readErr = readSource(source)
if not bytes then return false, readErr end
if #bytes ~= SAVE_SIZE then
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
:format(SAVE_SIZE, #bytes)
end
local save, convertErr = SaveConvert.importSav(bytes, version)
if not save then return false, convertErr end
-- Tag the game version and normalize the meta stamp: SaveConvert leaves
-- meta.format = "gen1_import", but SaveData.load's migration pass compares
-- the format numerically, so re-stamp it to the current format (the imported
-- table is already current-shaped, so no migration is skipped by doing so).
save.version = 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
local ok, writeErr = SaveData.writeSlot(version, slotId, save)
if not ok then
return false, "could not write the imported save: " .. tostring(writeErr)
end
SaveData.setActiveSlot(version, slotId)
return true, slotId
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/gen1recomp-<version>-<slotId>.sav in 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)
if not save then return false, "this game has no save to export yet" end
local bytes, exportErr = SaveConvert.exportSav(save)
if not bytes then return false, exportErr end
local slotId = SaveData.activeSlot(version) or "save"
local fs = 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") end
local rel = ("exports/gen1recomp-%s-%s.sav"):format(version, slotId)
local ok, writeErr = fs.write(rel, bytes)
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
if base ~= "" then return true, base .. "/" .. rel end
return true, rel
end
return SaveFileIO
+4 -3
View File
@@ -114,10 +114,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local itemDef = data.items[itemId]
local name = itemDef and itemDef.name or itemId
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase
-- all refuse mid-battle (jp nz, ItemUseNotTime)
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase /
-- ItemUseTMHM all refuse mid-battle (jp nz, ItemUseNotTime)
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
or itemId == "RARE_CANDY" or itemId == "COIN_CASE") then
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
or (itemDef and itemDef.machine)) then
return "failed", { "OAK: " .. save.player.name
.. "!\nThis isn't the\ntime to use that!" }
end
+4
View File
@@ -501,6 +501,10 @@ function LinkState:updateTrade(input)
require("src.core.Sound").play(game.data, "Trade_Machine")
Screens.push(game, "TradeAnim", {
sent = sent, received = received,
enemyName = (self.peerName or "TRAINER"),
playerOt = game.save.player.name,
playerOtId = sent.otId or game.save.player.id,
enemyOtId = received.otId,
onDone = function()
game.stack:push(TextBox.new(game,
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
+335
View File
@@ -0,0 +1,335 @@
-- Launcher-side mod surface (18/launcher redesign): the mods panel runs
-- BEFORE Game:load, so this NEVER loads a mod entry chunk -- it scans
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real
-- load at boot; this reads the same options.mods enable-state the loader
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
-- and installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree.
--
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
-- no filesystem, so the engine tier can table-drive it; the discovery and
-- install paths reach for love.filesystem and SaveData.
local Manifest = require("src.mods.Manifest")
local ManagerState = require("src.mods.ManagerState")
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
local SaveData = require("src.core.SaveData")
local LauncherMods = {}
-- ------- pure status derivation
-- A hard-dependency / conflict / version verdict for one manifest. mods is
-- the id -> validated-manifest map resolveToggle reads (its dependencySpecs,
-- conflictSpecs, version and game_version are exactly the fields the loader's
-- Manifest.validate produced); enabledSet is the current desired enable-set.
local function statusFor(mods, id, enabledSet, enabled)
local m = mods[id]
-- conflict only bites an enabled mod: resolveToggle's conflict list is
-- bidirectional (this mod's conflicts spec vs an enabled other, and an
-- enabled other's spec vs this mod), which is exactly the launcher chip.
if enabled then
local r = ManagerState.resolveToggle(mods, id, true, enabledSet)
if #r.conflicts > 0 then
local otherId = r.conflicts[1]
local other = mods[otherId]
return "conflict",
"Conflicts with " .. ((other and other.name) or otherId)
end
end
-- warn: the engine is outside the mod's game_version range
if m.game_version
and not Semver.satisfies(Version.engine, m.game_version) then
return "warn", "Needs engine " .. m.game_version
.. " (have " .. Version.engine .. ")"
end
-- warn: a hard dependency is absent, switched off, or the wrong version.
-- resolveToggle would cascade-enable a merely-disabled dep rather than flag
-- it, so the disabled case is judged straight off the manifest here.
for _, spec in ipairs(m.dependencySpecs or {}) do
local dep = mods[spec.id]
if not dep then
return "warn", "Needs " .. spec.id .. " (not installed)"
elseif not enabledSet[spec.id] then
return "warn", "Needs " .. spec.id .. " (disabled)"
elseif spec.range
and not Semver.satisfies(dep.version, spec.range) then
return "warn", "Needs " .. spec.id .. " " .. spec.range
end
end
return "ok", "Ready"
end
-- deriveList(manifests, options) -> the panel row list, pure.
-- manifests is an array of validated manifests (Manifest.validate output);
-- options is the options table (only options.mods is read). Rows come back
-- sorted by id so the panel order is stable.
function LauncherMods.deriveList(manifests, options)
local mods = options and options.mods or {}
local ordered = {}
for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end
table.sort(ordered, function(a, b) return a.id < b.id end)
local byId, enabledSet = {}, {}
for _, m in ipairs(ordered) do
byId[m.id] = m
-- missing entry means enabled, matching the loader's default
if mods[m.id] ~= false then enabledSet[m.id] = true end
end
local out = {}
for _, m in ipairs(ordered) do
local enabled = enabledSet[m.id] == true
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
local raw = m.raw or {}
out[#out + 1] = {
id = m.id,
name = m.name or m.id,
version = m.version,
-- category, then profile, then a generic fallback -- uppercased
badge = tostring(raw.category or m.profile or "MOD"):upper(),
description = m.description or "",
enabled = enabled,
status = status,
statusDetail = detail,
}
end
return out
end
-- locateRoot(paths) -> the mod-root prefix inside a mounted archive, pure.
-- paths is a shallow listing: top-level file names as-is, and for a top-level
-- directory a "<dir>/manifest.json" entry when it holds one. Returns "" when
-- the manifest sits at the archive root, "<dir>" when a single top-level
-- folder holds it, or nil + a user-presentable reason.
function LauncherMods.locateRoot(paths)
for _, p in ipairs(paths) do
if p == "manifest.json" then return "" end
end
local topDirs, seen, hasManifest = {}, {}, {}
for _, p in ipairs(paths) do
local top, rest = p:match("^([^/]+)/(.+)$")
if top then
if not seen[top] then
seen[top] = true
topDirs[#topDirs + 1] = top
end
if rest == "manifest.json" then hasManifest[top] = true end
end
end
if #topDirs == 1 and hasManifest[topDirs[1]] then return topDirs[1] end
if #topDirs > 1 then
return nil, "the .zip must contain a single mod folder"
end
return nil, "no manifest.json found in the .zip"
end
-- ------- discovery (love.filesystem)
local function decodeManifest(raw, path)
local Json = require("src.link.Json")
local data, decodeErr = Json.decode(raw)
if not data then return nil, decodeErr end
local ok, manifest = pcall(Manifest.validate, data, path)
if not ok then return nil, manifest end
return manifest
end
-- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover,
-- but validates only -- no entry chunk is ever loaded). First id wins on a
-- duplicate. Returns an array of validated manifests.
local function discover()
local fs = love and love.filesystem
local out = {}
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
if not fs.getInfo("mods") then return out end
local seen = {}
for _, name in ipairs(fs.getDirectoryItems("mods")) do
local path = "mods/" .. name
local info = fs.getInfo(path)
if info and info.type == "directory" then
local raw = fs.read(path .. "/manifest.json")
if raw then
local manifest = decodeManifest(raw, path)
if manifest and not seen[manifest.id] then
seen[manifest.id] = true
out[#out + 1] = manifest
end
end
end
end
return out
end
-- list() -> the mods-panel rows for the current install. Reads the same
-- options.mods enable-state the loader persists, so a toggle here is what the
-- game sees on its next boot.
function LauncherMods.list()
local options = SaveData.loadOptions()
return LauncherMods.deriveList(discover(), options)
end
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
-- Loader:_saveState writes (a plain boolean), so the running game and the
-- in-game ManagerState pick it up unchanged.
function LauncherMods.setEnabled(id, enabled)
local options = SaveData.loadOptions()
options.mods = options.mods or {}
options.mods[id] = enabled and true or false
SaveData.saveOptions(options)
return true
end
-- ------- install (love.filesystem)
-- Read a .zip source into bytes. A string is an external absolute path (like
-- a chosen ROM) read with io.*, falling back to a save-dir-relative
-- love.filesystem read; a love DroppedFile is opened the way RomImporter
-- ingests dropped ROMs.
local function readArchive(source)
local t = type(source)
if (t == "userdata" or t == "table") and type(source.open) == "function" then
local ok = source:open("r")
if not ok then return nil, "could not open the dropped file" end
local data = source:read(source:getSize())
source:close()
if not data then return nil, "the dropped file could not be read" end
return data
end
if t == "string" then
local f = io.open(source, "rb")
if f then
local data = f:read("*a")
f:close()
if not data then return nil, "could not read " .. source end
return data
end
if love and love.filesystem then
local data = love.filesystem.read(source)
if data then return data end
end
return nil, "could not open " .. source
end
return nil, "unsupported archive source"
end
-- Shallow listing of a mounted archive shaped for locateRoot: files by name,
-- and for each top-level directory a "<dir>/manifest.json" marker only when it
-- actually holds one (so a lone folder with no manifest still reads as empty).
local function topLevelPaths(mount)
local fs = love.filesystem
local paths = {}
for _, name in ipairs(fs.getDirectoryItems(mount)) do
local info = fs.getInfo(mount .. "/" .. name)
if info and info.type == "directory" then
if fs.getInfo(mount .. "/" .. name .. "/manifest.json", "file") then
paths[#paths + 1] = name .. "/manifest.json"
end
else
paths[#paths + 1] = name
end
end
return paths
end
local function copyTree(src, dst)
local fs = love.filesystem
if not fs.createDirectory(dst) then
return nil, "could not create " .. dst
end
for _, name in ipairs(fs.getDirectoryItems(src)) do
local s = src .. "/" .. name
local d = dst .. "/" .. name
local info = fs.getInfo(s)
if info and info.type == "directory" then
local ok, err = copyTree(s, d)
if not ok then return nil, err end
else
local data = fs.read(s)
if data == nil then return nil, "could not read " .. name end
local ok, err = fs.write(d, data)
if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end
end
end
return true
end
local function removeTree(path)
local fs = love.filesystem
local info = fs.getInfo(path)
if not info then return end
if info.type == "directory" then
for _, child in ipairs(fs.getDirectoryItems(path)) do
removeTree(path .. "/" .. child)
end
end
fs.remove(path)
end
-- installZip(source) -> true, id | nil, errString
-- source is an external path or a love DroppedFile. The archive is validated
-- BEFORE anything is copied; every path unmounts and clears the staged temp
-- file, and a failed copy rolls its partial tree back. A dropped file outside
-- the save dir is staged into a save-dir temp first, because
-- love.filesystem.mount only reaches a save-directory-relative path.
function LauncherMods.installZip(source)
if not (love and love.filesystem) then
return nil, "mod install needs LOVE"
end
local fs = love.filesystem
local data, readErr = readArchive(source)
if not data then return nil, readErr end
-- stage into a save-dir temp so mount can reach it
local tmp = ("mod_import_%d_%d.zip"):format(os.time(), math.random(0, 999999))
local ok, writeErr = fs.write(tmp, data)
if not ok then
return nil, "could not stage the .zip: " .. tostring(writeErr)
end
local mount = "mod_import_mount"
if not fs.mount(tmp, mount) then
fs.remove(tmp)
return nil, "that .zip could not be opened"
end
local function cleanup()
pcall(fs.unmount, tmp)
fs.remove(tmp)
end
local prefix, rootErr = LauncherMods.locateRoot(topLevelPaths(mount))
if not prefix then
cleanup()
return nil, rootErr
end
local root = prefix == "" and mount or (mount .. "/" .. prefix)
local raw = fs.read(root .. "/manifest.json")
if not raw then
cleanup()
return nil, "the .zip has no readable manifest.json"
end
local manifest, manifestErr = decodeManifest(raw, root)
if not manifest then
cleanup()
return nil, "invalid mod manifest: " .. tostring(manifestErr)
end
-- reject a duplicate before touching the mods tree
local dest = "mods/" .. manifest.id
if fs.getInfo(dest) then
cleanup()
return nil, "a mod named '" .. manifest.id .. "' is already installed"
end
fs.createDirectory("mods")
local copied, copyErr = copyTree(root, dest)
if not copied then
removeTree(dest)
cleanup()
return nil, copyErr or "could not copy the mod files"
end
cleanup()
return true, manifest.id
end
return LauncherMods
+131
View File
@@ -40,6 +40,19 @@ function f.int(min, max)
end }
end
-- a bounded float; like f.int but keeps the fractional part (scales,
-- gains). Rejects out-of-range with the "expected number a..b" message.
function f.numRange(min, max)
local desc = "number"
if min and max then desc = ("number %s..%s"):format(min, max)
elseif min then desc = ("number >= %s"):format(min) end
return { kind = "num", min = min, max = max, desc = desc,
check = function(v)
return type(v) == "number"
and (min == nil or v >= min) and (max == nil or v <= max)
end }
end
function f.enum(values)
local set = {}
for _, value in ipairs(values) do set[value] = true end
@@ -420,6 +433,12 @@ R.pokemon = {
frames = f.opt(f.int(1)) } }),
cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")),
trueColor = f.opt(f.bool),
-- battle-pic scale overrides for this species' own pics: front is the
-- enemy pic (default 1x), back is the player pic (default 2x). An
-- image-level battle_sprite_scales entry for the same path beats these.
-- The pic stays grounded (feet pinned) at any scale; see docs/modding.md.
battleScaleFront = f.opt(f.numRange(0.25, 4.0)),
battleScaleBack = f.opt(f.numRange(0.25, 4.0)),
},
example = 'mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } })',
}
@@ -791,6 +810,118 @@ R.transitions = {
example = 'mod.content.transitions:register("dissolve", { frames = 30, draw = fn })',
}
-- ------- rendering pipelines
--
-- A pipeline is a display mode that owns part of the frame: it may replace
-- the overworld's world pass with geometry of its own (drawWorld) and/or
-- post-process the finished composite (present). Everything around that --
-- the OFF/1/2/3 ladder, its options row, its hotkey, persistence in
-- save.options.pipelines and the gating that keeps it out of battles and
-- menus -- is engine plumbing driven from this record, so a renderer mod
-- declares what it is and writes only the two draw functions.
--
-- Both callbacks are optional and independent: a present-only pipeline is a
-- post-process (bloom, tilt-shift, a CRT curve) that leaves whatever
-- rendered the frame alone, and a drawWorld-only pipeline is a world
-- renderer that composites straight. See src/render/Pipelines.lua for the
-- ctx each receives and docs/modding.md for the worked example.
R.render_pipelines = {
semantics = "record", target = "render_pipelines",
fields = {
-- shown in the options menu; the ladder labels default to OFF/ON
label = f.str,
levels = f.opt(f.list(f.str)),
-- keyboard key that cycles the ladder, checked after the engine's own
-- display hotkeys so a pipeline can never shadow one
hotkey = f.opt(f.str),
-- higher wins when two world pipelines are somehow active at once;
-- also the options-row order, so a mode and its post-process sort
-- together instead of by id
priority = f.opt(f.num),
-- hardware/driver gate, checked every frame: false keeps the vanilla
-- 2D path, which is what a headless run and a driver with no depth
-- canvas both get
available = f.opt(f.fn),
-- (top, overworld) -> boolean: whether the player may CHANGE the mode
-- right now. Defaults to the survey-zoom gate (free-roam overworld
-- only), which keeps a hotkey press from switching modes mid-warp or
-- mid-cutscene. It has no say over whether an already-on mode draws:
-- a mode that stopped rendering during a warp would flash the flat 2D
-- world every time the player walked through a door.
gate = f.opt(f.fn),
-- (dt, level): presentational tweens, ticked on real frame time
update = f.opt(f.fn),
-- (ctx) -> canvas | nil: render the world. nil falls back to the
-- vanilla flat/tilt draw for this frame.
drawWorld = f.opt(f.fn),
-- (canvas, ctx) -> canvas: post-process the WORLD image, before the UI
-- composites over it -- a depth-of-field or colour grade that must not
-- touch the dialog boxes and menus sitting on top. Only runs when some
-- pipeline rendered the world, since the vanilla world pass has no
-- single finished image to hand over.
worldPresent = f.opt(f.fn),
-- (canvas, ctx) -> canvas: post-process the whole finished composite,
-- world and UI alike (a CRT curve, a full-screen grade). Must return a
-- canvas; the input unchanged is the correct answer when the effect is
-- off.
present = f.opt(f.fn),
-- drop GPU objects (window resize, hot reload, mode switch)
invalidate = f.opt(f.fn),
},
-- a pipeline that does neither half is dead weight and would silently
-- occupy an options row and a hotkey
extra = function(_, value)
if value.drawWorld == nil and value.present == nil
and value.worldPresent == nil then
return "a render pipeline needs drawWorld, worldPresent or present"
end
end,
-- A pipeline callback fails at play time, long after the load phase has
-- handed its report to the mod manager, so the merge leaves behind who
-- wrote each record for Pipelines to name in the failure -- the same
-- provenance trick the audio registries use (Loader.stampAudioOwners).
-- Placement is otherwise the default record merge.
write = function(target, registry)
local owners, tombstones = {}, {}
for id in pairs(registry.ops) do
local value = registry:get(id)
if value == nil then
tombstones[#tombstones + 1] = id
else
target[id] = value
local owner = registry.owners[id]
if owner and owner ~= Schemas.ENGINE then owners[id] = owner end
end
end
for _, id in ipairs(tombstones) do target[id] = nil end
target._owners = owners
end,
example = 'mod.content.render_pipelines:register("voxel", ' ..
'{ label = "VOXEL", levels = { "OFF", "15", "35", "50" }, drawWorld = fn })',
}
-- ------- battle sprite scales
--
-- Per-image battle-pic scale overrides, keyed by record id and consulted
-- by asset path at draw time. Where a species' battleScaleFront /
-- battleScaleBack scales its own front/back pic, this scales ANY battle
-- pic by the path it is drawn from -- the only handle on the non-species
-- pics like the player's trainer back sprite. Image-level beats
-- species-level; both compose with the send-out grow and keep the sprite
-- grounded (feet pinned) at whatever scale. See docs/modding.md.
R.battle_sprite_scales = {
semantics = "record", target = "battle_sprite_scales",
fields = {
-- the asset path exactly as data references it, e.g.
-- "assets/generated/battle/back/abrab.png"
path = f.path,
-- 1 = native pixels; the drawn size relative to the pic's own pixels
scale = f.numRange(0.25, 4.0),
},
example = 'mod.content.battle_sprite_scales:register("abra_back", ' ..
'{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })',
}
-- ------- progression
R.evolution_methods = {
+28
View File
@@ -29,6 +29,34 @@ function Pokemon.movesAtLevel(speciesDef, level)
return moves
end
-- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare):
-- grant learnset moves with startLevel < moveLevel <= newLevel, shifting
-- the oldest slot out when full. Silent — no LearnMove prompts.
function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel)
if not (speciesDef and speciesDef.learnset and mon) then return end
mon.moves = mon.moves or {}
for _, entry in ipairs(speciesDef.learnset) do
local moveLevel = entry.level
if moveLevel > newLevel then break end
if moveLevel > startLevel then
local known = false
for _, mv in ipairs(mon.moves) do
if mv.id == entry.move then known = true break end
end
if not known then
local mdef = data.moves[entry.move]
local slot = { id = entry.move, pp = mdef and mdef.pp or 0 }
if #mon.moves < 4 then
mon.moves[#mon.moves + 1] = slot
else
table.remove(mon.moves, 1)
mon.moves[#mon.moves + 1] = slot
end
end
end
end
end
function Pokemon.new(data, species, level, rng)
local def = data.pokemon[species]
assert(def, "unknown species " .. tostring(species))
+423
View File
@@ -0,0 +1,423 @@
-- Rendering pipelines: the engine side of the render_pipelines registry.
--
-- A pipeline is a display mode a mod owns. It may replace the overworld's
-- world pass with geometry of its own (drawWorld) and/or post-process the
-- finished composite (present). Everything else about being a display mode
-- -- the OFF/1/2/3 ladder, the options row, the hotkey, persistence, the
-- free-roam gate, and never letting a mod's error take the frame down -- is
-- engine plumbing and lives here, so a renderer mod writes the two draw
-- functions and declares the rest.
--
-- The two halves compose independently and in priority order: the highest
-- priority eligible drawWorld renders the world, then every eligible
-- present folds over whatever came out (the world pipeline's canvas, or the
-- vanilla flat/tilt composite when none ran). A present that is switched
-- off returns its input, so a full ladder of them costs nothing at level 0.
--
-- Nothing here reaches collision, movement, triggers or scripts: like
-- survey zoom and tilt, a pipeline is purely presentational, which is why
-- its level rides in save.options rather than the save proper.
--
-- Spec: docs/modding.md (rendering pipelines)
local Data = require("src.core.Data")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Zoom = require("src.render.Zoom")
local Pipelines = {}
-- id -> level. Levels live here rather than on the records because the
-- records are merged content: frozen after load, and shared with whatever
-- else reads Data.
local levels = {}
-- ids whose callbacks have already thrown, so a pipeline that fails every
-- frame reports once instead of filling the log at 60Hz
local broken = {}
Pipelines.DEFAULT_LEVELS = { "OFF", "ON" }
-- The merged dataset the records live in. The boot singleton is the
-- default -- Game.data is that very table -- and install() lets a headless
-- caller (the SDK harness, a tool) point this at a dataset of its own.
local source = Data
function Pipelines.install(data)
source = data or Data
Pipelines.reset()
end
-- ------- catalog
-- Every registered pipeline as { id = ..., def = ... }, ordered by priority
-- (descending, ties by id) so selection, the options rows and the present
-- fold all walk the same sequence.
--
-- Memoized on the namespace table's identity. Content freezes at the merge
-- boundary, so the answer cannot change for a given table -- and this is
-- read several times per frame by update(), worldPipeline() and the
-- endFrame present check, which is no place to allocate and sort. An empty
-- list is cached too, so a mod-free boot pays one table for the process.
local listCache, listSource = nil, nil
function Pipelines.list()
local defs = source and source.render_pipelines
if defs == listSource and listCache then return listCache end
local out = {}
if type(defs) == "table" then
for id, def in pairs(defs) do
-- the merge writes provenance under _owners; skip the bookkeeping
-- keys rather than treating them as pipelines
if type(id) == "string" and id:sub(1, 1) ~= "_" and type(def) == "table" then
out[#out + 1] = { id = id, def = def }
end
end
table.sort(out, function(a, b)
local pa, pb = a.def.priority or 0, b.def.priority or 0
if pa ~= pb then return pa > pb end
return a.id < b.id
end)
end
listCache, listSource = out, defs
return out
end
function Pipelines.get(id)
local defs = source and source.render_pipelines
local def = type(defs) == "table" and defs[id] or nil
return type(def) == "table" and def or nil
end
-- the mod that registered a pipeline, so a runtime failure lands in the
-- feed the mod manager shows instead of only in the console
local function ownerOf(id)
local defs = source and source.render_pipelines
local owners = type(defs) == "table" and defs._owners or nil
return owners and owners[id] or nil
end
-- Run one of a pipeline's callbacks under pcall. A mod that throws mid-
-- frame must not take the frame with it: the pipeline is marked broken,
-- attributed once, and treated as absent from then on -- which degrades to
-- the vanilla 2D path rather than a black screen.
local function guard(id, fn, ...)
if broken[id] then return nil end
local ok, result = pcall(fn, ...)
if ok then return result end
broken[id] = true
Logger.error("render pipeline %s failed: %s -- disabled for this session",
id, tostring(result))
Runtime.reportError(ownerOf(id), "render pipeline failed: " .. tostring(result))
return nil
end
-- Whether a callback's return is a real Canvas we can composite. A mod that
-- forgets a return, or hands back a shade string / flag / number, must be
-- ignored rather than trusted -- draw() on a non-canvas takes the frame down.
-- Real LOVE canvases are userdata answering typeOf("Canvas"); the headless
-- test stub (tests/love_stub) fakes them as tables carrying the Canvas method
-- shape (love.graphics.newCanvas), so accept either and nothing else.
local function isCanvas(v)
if type(v) == "userdata" then
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
end
if type(v) == "table" then
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
end
return false
end
-- Dispatch a mod render callback with its GPU state fenced off: push("all")
-- before and pop() after, so a callback that returns cleanly but leaves a
-- shader bound, the canvas redirected, or blend/colour changed cannot corrupt
-- the engine composite that follows. guard() catches a callback that throws;
-- this catches one that dirties state. A pipeline already retired skips the
-- push/pop entirely, so the stack stays balanced.
local function guardRender(id, fn, ...)
if broken[id] then return nil end
love.graphics.push("all")
local out = guard(id, fn, ...)
love.graphics.pop()
return out
end
-- ------- levels
function Pipelines.levelLabels(id)
local def = Pipelines.get(id)
local labels = def and def.levels
if type(labels) ~= "table" or labels[1] == nil then
return Pipelines.DEFAULT_LEVELS
end
return labels
end
-- highest selectable level: one less than the label count, so a two-label
-- ladder is a plain OFF/ON toggle
function Pipelines.maxLevel(id)
return #Pipelines.levelLabels(id) - 1
end
function Pipelines.level(id)
return levels[id] or 0
end
function Pipelines.levelLabel(id, level)
local labels = Pipelines.levelLabels(id)
return labels[(level or Pipelines.level(id)) + 1] or labels[1] or "OFF"
end
-- A world pipeline and the engine's own tilt mode are two answers to the
-- same question, so switching one on switches the other off -- the rule
-- tilt and survey zoom already follow between themselves. Present-only
-- pipelines (post-processes) compose with tilt and are left alone.
local function excludeTilt(id, level)
local def = Pipelines.get(id)
if not (def and def.drawWorld) or level <= 0 then return end
local Tilt = require("src.render.Tilt")
if Tilt.level > 0 then Tilt.setLevel(0) end
-- one world pipeline at a time, for the same reason
for _, entry in ipairs(Pipelines.list()) do
if entry.id ~= id and entry.def.drawWorld and Pipelines.level(entry.id) > 0 then
levels[entry.id] = 0
end
end
end
function Pipelines.setLevel(id, level)
if not Pipelines.get(id) then return 0 end
level = math.floor(tonumber(level) or 0)
if level < 0 then level = 0 end
local max = Pipelines.maxLevel(id)
if level > max then level = max end
levels[id] = level
excludeTilt(id, level)
return level
end
-- Advance the ladder and wrap to OFF, the shape every display hotkey walks.
function Pipelines.cycle(id, dir)
local max = Pipelines.maxLevel(id)
if max < 1 then return 0 end
local span = max + 1
local target = (Pipelines.level(id) + (dir or 1)) % span
if target < 0 then target = target + span end
return Pipelines.setLevel(id, target)
end
-- Turning a world pipeline on must switch tilt off in the save too, not
-- just in the live module, or the next boot restores both. Call sites hand
-- over the options table so this stays the one place that rule lives.
function Pipelines.syncOptions(opts)
if type(opts) ~= "table" then return end
local bucket = opts.pipelines
if type(bucket) ~= "table" then
bucket = {}
opts.pipelines = bucket
end
for _, entry in ipairs(Pipelines.list()) do
bucket[entry.id] = Pipelines.level(entry.id)
if entry.def.drawWorld and Pipelines.level(entry.id) > 0 then
opts.tilt = 0
end
end
end
-- Restore levels from a loaded options table. A pipeline whose mod is gone
-- keeps its stored level untouched in the bucket (so re-enabling the mod
-- restores the mode) but contributes nothing while absent.
function Pipelines.applyOptions(opts)
local bucket = type(opts) == "table" and opts.pipelines or nil
levels = {}
broken = {}
local world = nil
for _, entry in ipairs(Pipelines.list()) do
local stored = type(bucket) == "table" and bucket[entry.id] or 0
local level = math.floor(tonumber(stored) or 0)
if level < 0 then level = 0 end
local max = Pipelines.maxLevel(entry.id)
if level > max then level = max end
-- list() is priority order, so the first world pipeline with a stored
-- level is the one that wins; the rest restore to OFF rather than
-- sitting on a level that can never render
if entry.def.drawWorld and level > 0 then
if world then level = 0 else world = entry.id end
end
levels[entry.id] = level
end
-- a restored world pipeline and tilt are two answers to the same
-- question; the pipeline wins, as it does at every place that sets one
if world then require("src.render.Tilt").setLevel(0) end
end
function Pipelines.reset()
levels = {}
broken = {}
end
-- ------- per-frame
-- Presentational tweens run on real frame time, like Tilt's. Every
-- pipeline ticks, not just the active ones: a mode easing back OUT still
-- has an angle to retire.
function Pipelines.update(dt)
for _, entry in ipairs(Pipelines.list()) do
if entry.def.update then
guardRender(entry.id, entry.def.update, dt, Pipelines.level(entry.id))
end
end
end
-- A pipeline may run this frame when it is switched on, has not thrown, and
-- its hardware gate says yes. `available` is consulted every frame rather
-- than cached: a driver that loses its depth canvas on a resize has to be
-- able to change its mind.
--
-- Deliberately NOT gated on the state stack. `gate` governs whether the
-- player may CHANGE the mode, never whether it draws -- a display mode that
-- stopped rendering during a warp, a scripted cutscene or an open menu
-- would flash the flat 2D world for those frames every time the player
-- walked through a door. Once a mode is on it renders until it is off.
function Pipelines.eligible(id)
local def = Pipelines.get(id)
if not def or broken[id] then return false end
if Pipelines.level(id) <= 0 then return false end
if def.available and guard(id, def.available) ~= true then return false end
return true
end
-- Whether the player may cycle this mode right now: the free-roam gate,
-- which keeps a hotkey press from switching modes mid-warp or mid-cutscene.
-- Input only -- see eligible() for why the draw path does not consult it.
function Pipelines.canToggle(id, top, overworld)
local def = Pipelines.get(id)
if not def then return false end
local gate = def.gate or Zoom.gateOK
return guard(id, gate, top, overworld) == true
end
-- The pipeline that owns the world pass right now, or nil for the vanilla
-- flat/tilt draw. Highest priority wins; the exclusion rules above mean
-- there is normally only one candidate anyway.
function Pipelines.worldPipeline()
for _, entry in ipairs(Pipelines.list()) do
if entry.def.drawWorld and Pipelines.eligible(entry.id) then
return entry.id, entry.def
end
end
return nil
end
-- Render the world through `id`. Returns the canvas to composite, or nil
-- when the pipeline declined this frame (nothing to draw, a transient
-- failure), which the caller treats as "fall back to the 2D path".
function Pipelines.drawWorld(id, ctx)
local def = Pipelines.get(id)
if not (def and def.drawWorld) then return nil end
return guardRender(id, def.drawWorld, ctx)
end
-- Fold every eligible world post-process over a pipeline's world image,
-- before the UI composites on top. This is where a depth-of-field or a
-- colour grade belongs when it must leave the dialog boxes and menus crisp;
-- `present` below is the whole-frame counterpart. Only reachable once some
-- pipeline rendered the world, so it is gated on the overworld state the
-- same way drawWorld is.
function Pipelines.worldPresent(canvas, ctx)
if canvas == nil then return nil end
for _, entry in ipairs(Pipelines.list()) do
if entry.def.worldPresent and Pipelines.eligible(entry.id) then
local out = guardRender(entry.id, entry.def.worldPresent, canvas, ctx)
-- accept only a real Canvas: a pass that returns a non-canvas (a
-- forgotten return, a shade string) is ignored, not folded in
if isCanvas(out) then canvas = out end
end
end
return canvas
end
-- Fold every eligible post-process over the finished frame. Present
-- pipelines are not gated on the overworld state -- a CRT curve or a colour
-- grade applies to menus and battles too -- so eligibility here is just
-- "switched on and available". A pass that returns a non-canvas is
-- ignored rather than trusted, so a mod cannot blank the screen by
-- forgetting a return.
function Pipelines.present(canvas, ctx)
if canvas == nil then return nil end
for _, entry in ipairs(Pipelines.list()) do
if entry.def.present and Pipelines.eligible(entry.id) then
local out = guardRender(entry.id, entry.def.present, canvas, ctx)
-- accept only a real Canvas: a pass that returns a non-canvas is
-- ignored (docstring above), so a mod cannot blank or crash the frame
-- by forgetting a return or handing back a truthy non-canvas
if isCanvas(out) then canvas = out end
end
end
return canvas
end
-- true when any present-only pass wants to run, so the composite path can
-- skip allocating a target it would not use
function Pipelines.wantsPresent()
for _, entry in ipairs(Pipelines.list()) do
if entry.def.present and Pipelines.eligible(entry.id) then return true end
end
return false
end
-- ------- input and UI
-- Cycle whichever pipeline claims `key`. Returns the id when one did, so
-- the caller knows the key was consumed. Checked after the engine's own
-- display hotkeys, so a mod can never shadow one.
function Pipelines.hotkey(key, top, overworld)
for _, entry in ipairs(Pipelines.list()) do
if entry.def.hotkey == key then
-- the gate belongs here and nowhere else: it stops the player
-- flipping modes mid-warp or mid-cutscene, and has no say over
-- whether an already-on mode draws
if Pipelines.canToggle(entry.id, top, overworld) then
Pipelines.cycle(entry.id)
return entry.id
end
return nil
end
end
return nil
end
-- Options rows for every registered pipeline, in the same priority order,
-- in the descriptor shape src/ui/OptionRows.lua renders.
function Pipelines.rows(game)
local rows = {}
for _, entry in ipairs(Pipelines.list()) do
local id = entry.id
rows[#rows + 1] = {
id = "pipeline:" .. id,
label = entry.def.label or id:upper(),
value = function() return Pipelines.levelLabel(id) end,
step = function(g, dir)
Pipelines.cycle(id, dir)
local opts = g and g.save and g.save.options
if opts then
Pipelines.syncOptions(opts)
-- the exclusion above may have switched tilt off; keep the live
-- module in step with the option it just wrote
require("src.render.Tilt").setLevel(opts.tilt or 0)
end
return true
end,
}
end
return rows
end
-- Drop every pipeline's GPU objects (window resize, hot reload).
function Pipelines.invalidate()
for _, entry in ipairs(Pipelines.list()) do
if entry.def.invalidate then guardRender(entry.id, entry.def.invalidate) end
end
end
return Pipelines
+76 -4
View File
@@ -9,12 +9,28 @@
local Zoom = require("src.render.Zoom")
local Tilt = require("src.render.Tilt")
local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local Renderer = {}
Renderer.WIDTH = 160
Renderer.HEIGHT = 144
-- Whether a value is a real Canvas we can composite. Real LOVE canvases are
-- userdata answering typeOf("Canvas"); the headless test stub fakes them as
-- tables carrying the Canvas method shape. A mod pipeline handing back a
-- non-canvas must be rejected before it reaches love.graphics.draw, which
-- would otherwise take the frame down with it.
local function isCanvas(v)
if type(v) == "userdata" then
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
end
if type(v) == "table" then
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
end
return false
end
-- Tilt mode: the upright billboard canvas is grown by this many world
-- pixels on every side beyond the ground world view, so a structure or
-- sprite standing near a view edge still draws in full instead of being
@@ -57,6 +73,22 @@ function Renderer:init()
-- the projected ground in endFrame; never touched while tilt is off.
self.uprightCanvas = nil
self.uprightActive = false
-- a render pipeline's finished world image, already at window resolution
-- (see src/render/Pipelines.lua). nil is "no pipeline rendered this
-- frame", which is every vanilla frame.
self.worldOverride = nil
end
-- Hand endFrame a pipeline's world image to composite instead of the world
-- canvas. Cleared every frame, so a pipeline that declines one frame falls
-- straight back to the 2D path rather than showing a stale image.
function Renderer:setWorldOverride(canvas)
-- Defensive: a pipeline that hands back a non-canvas (forgotten return, a
-- truthy sentinel) must not reach the worldOverride blit in endFrame, where
-- love.graphics.draw on it would crash the frame. Reject it and fall back
-- to the 2D path rather than trust it.
if canvas ~= nil and not isCanvas(canvas) then canvas = nil end
self.worldOverride = canvas
end
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
@@ -91,6 +123,7 @@ end
function Renderer:beginFrame(transparent)
self.worldActive = false
self.uprightActive = false
self.worldOverride = nil
-- warp-fade overlay from Transition (issue #121); cleared each frame so
-- a popped transition cannot leave a sticky black veil
self.worldFadeAlpha = nil
@@ -380,7 +413,11 @@ function Renderer:endFrame(zones, worldZones)
zones = withTrueColor(zones, "ui")
worldZones = withTrueColor(worldZones, "world")
local needPresent = GBCFX.active()
-- A post-process pipeline needs the whole composite in a canvas for the
-- same reason GBC FX does, so either one alone is enough to take the
-- present path; with neither, the frame draws straight to the screen
-- exactly as it always did.
local needPresent = GBCFX.active() or Pipelines.wantsPresent()
local present = nil
if needPresent then
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
@@ -441,7 +478,27 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setShader()
end
if self.worldActive then
if self.worldOverride then
-- A render pipeline already produced the whole world -- terrain,
-- characters and its own FX overlay -- as one window-resolution image,
-- so it composites with a straight 1:1 blit and the world canvas is
-- skipped entirely (nothing drew into it). The UI blit below still
-- runs, so dialogs, menus and the HUD sit on top as usual.
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpi, 1 / dpi)
love.graphics.setScissor()
-- the screen-space overlays the flat path draws over its composite
local fade = self.worldFadeAlpha
if fade and fade > 0 then
love.graphics.setColor(0, 0, 0, fade)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1)
end
if self.battleCascadeProg then
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S)
end
elseif self.worldActive then
local sp = Zoom.scale(Sp)
local s = sp / dpi
local wvw = self.worldCanvas:getWidth()
@@ -526,11 +583,26 @@ function Renderer:endFrame(zones, worldZones)
if present then
love.graphics.setCanvas()
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(present, Sp)
-- Post-process pipelines run over the finished composite -- world, UI
-- and all -- and before GBC FX, so a blur or colour grade is what the
-- LCD grid is then drawn over rather than something that smears the
-- grid itself. Each pass hands back a canvas; with none registered
-- this returns `present` unchanged and the frame is byte-identical.
local composed = Pipelines.present(present,
{ width = ww, height = wh, scale = Sp, dpi = dpi }) or present
if GBCFX.active() then
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(composed, Sp)
else
-- the present canvas only existed for the post-process, so put the
-- result on the screen at the same 1:1 unit mapping it was built at
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(composed, 0, 0)
end
end
self.worldActive = false
self.uprightActive = false
self.worldOverride = nil
PaletteFX.setPass(nil)
end
+27
View File
@@ -65,8 +65,12 @@ end
Assets.register(SpriteRenderer.invalidate)
-- exported: a render pipeline's own sprite geometry picks frames by the
-- same tables, so a 3D pose can never drift from the 2D one
local STAND = { down = 0, up = 1, left = 2, right = 2 }
local WALK = { down = 3, up = 4, left = 5, right = 5 }
SpriteRenderer.STAND = STAND
SpriteRenderer.WALK = WALK
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
@@ -83,6 +87,29 @@ function SpriteRenderer.new(spriteDef, seed)
return self
end
-- The image this sprite would draw from right now: the plain sheet, or the
-- OBP-recolored bake of it. Exposed so a render pipeline can texture its
-- own geometry from the very same image -- the geometry carries sheet pixel
-- coordinates rather than baked colors, so sharing this one resolver is
-- what makes palette modes and sprite-replacing mods apply to 2D and 3D
-- alike.
--
-- Deliberately free of draw's bookkeeping: markTrueColor and
-- markSpriteRedraw exist to patch up the screen-space zone shader, and a
-- pipeline that renders into its own canvas never runs through it. For the
-- same reason the OG-RED bake is returned unconditionally here rather than
-- only during a redraw pass -- there is no later pass to restore it.
function SpriteRenderer:resolveImage()
if self.def.trueColor then return self.image end
if PaletteFX.usesGbcPack() then
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then return getObpImage(self.def.image, colors, group) end
elseif PaletteFX.usesSpriteObp() then
return getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
end
return self.image
end
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
local function blitFrame(image, quad, x, y, flip, redraw)
+68 -23
View File
@@ -50,6 +50,7 @@ function TextBox.new(game, text, onDone, opts)
self.charIndex = 0
self.shown = {} -- visible lines (max 2), each a list of glyph codes
self.waiting = false
self.contAdvance = false
self.done = false
self.blink = 0
self:beginLine()
@@ -65,7 +66,11 @@ TextBox.TOKENS = {
PLAYER = function(game) return game.save.player.name or "RED" end,
RIVAL = function(game) return game.save.player.rival or "BLUE" end,
RAM = function(game, arg)
return arg == "wStringBuffer" and game.stringBuffer or nil
if arg == "wStringBuffer" then return game.stringBuffer end
if arg == "wBoxNumString" then return game.boxNumString end
-- SendNewMonToBox / _SentToBoxText reads the deposited nick here
if arg == "wBoxMonNicks" then return game.boxMonNicks end
return nil
end,
}
@@ -83,31 +88,55 @@ end
-- Split marked-up text into pages of lines. \v-scrolled lines become
-- additional lines on the same page (the box scrolls them).
-- pages.contBefore[p][i] is true when line i was preceded by \v (cont):
-- pokered ContText waits for A/B + ▼ before scrolling that line in.
function TextBox.paginate(text, maxCols)
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS
local pages = {}
local contBefore = {}
local function pushLine(lines, conts, line, wait)
while #line > maxCols do
local cut = maxCols
for i = maxCols, 1, -1 do
if line:sub(i, i) == " " then cut = i break end
end
table.insert(lines, line:sub(1, cut))
table.insert(conts, wait)
wait = false
line = line:sub(cut + 1)
end
table.insert(lines, line)
table.insert(conts, wait)
end
for pageText in (text .. "\f"):gmatch("(.-)\f") do
if pageText ~= "" then
local lines = {}
for chunk in (pageText .. "\n"):gmatch("(.-)[\n\v]") do
local line = chunk
-- wrap long lines defensively (the source rarely needs it)
while #line > maxCols do
local cut = maxCols
for i = maxCols, 1, -1 do
if line:sub(i, i) == " " then cut = i break end
end
table.insert(lines, line:sub(1, cut))
line = line:sub(cut + 1)
local lines, conts = {}, {}
local pos, waitNext = 1, false
while true do
local npos = pageText:find("[\n\v]", pos)
if not npos then
pushLine(lines, conts, pageText:sub(pos), waitNext)
break
end
table.insert(lines, line)
pushLine(lines, conts, pageText:sub(pos, npos - 1), waitNext)
waitNext = pageText:sub(npos, npos) == "\v"
pos = npos + 1
end
if lines[#lines] == "" then
table.remove(lines)
table.remove(conts)
end
if #lines > 0 then
table.insert(pages, lines)
table.insert(contBefore, conts)
end
-- drop trailing empty line from the final gmatch round
if lines[#lines] == "" then table.remove(lines) end
if #lines > 0 then table.insert(pages, lines) end
end
end
if #pages == 0 then pages = { { "" } } end
if #pages == 0 then
pages = { { "" } }
contBefore = { { false } }
end
pages.contBefore = contBefore
return pages
end
@@ -179,10 +208,17 @@ function TextBox:update(dt)
if input:wasPressed("a") or input:wasPressed("b") then
require("src.core.Sound").play(self.game.data, "Press_AB")
self.waiting = false
self.shown = {}
self.pageIndex = self.pageIndex + 1
self.lineIndex = 1
self:beginLine()
if self.contAdvance then
-- ContText / ManualTextScroll: keep the box, scroll one line
self.contAdvance = false
self.lineIndex = self.lineIndex + 1
self:beginLine()
else
self.shown = {}
self.pageIndex = self.pageIndex + 1
self.lineIndex = 1
self:beginLine()
end
end
return
end
@@ -203,10 +239,19 @@ function TextBox:update(dt)
-- line finished
local page = self.pages[self.pageIndex]
if self.lineIndex < #page then
self.lineIndex = self.lineIndex + 1
self:beginLine()
local nextIdx = self.lineIndex + 1
local conts = self.pages.contBefore and self.pages.contBefore[self.pageIndex]
if conts and conts[nextIdx] then
-- pokered <CONT>: ▼ + WaitForTextScrollButtonPress before scroll
self.waiting = true
self.contAdvance = true
else
self.lineIndex = nextIdx
self:beginLine()
end
elseif self.pageIndex < #self.pages then
self.waiting = true
self.contAdvance = false
else
self.done = true
end
+5
View File
@@ -179,6 +179,11 @@ local function recolorSample(r, g, b, a, colors)
return col[1] / 255, col[2] / 255, col[3] / 255, a
end
-- exported: a render pipeline bakes a map's palette into its own texture
-- atlas the same way, and has to land on the identical colors as the 2D
-- tiles it is standing in for
TileRenderer.recolorSample = recolorSample
-- the 8 shifted variants of one tile (built once per sheet + tile id [+
-- gbcKey, when `colors` recolors it for RED++ -- see buildAnim])
local shiftVariants = {}
+813
View File
@@ -0,0 +1,813 @@
-- Vanilla Gen1 (Red/Blue, international) raw SRAM save (32768 bytes) <->
-- this project's save.lua shape (src/core/SaveData.lua / SaveData.newGame).
--
-- Pure Lua, no love.* dependency -- runs under plain luajit for the CLI
-- (tools/save_convert/convert.lua) and headless tests alike.
--
-- Every offset below was derived mechanically from the authoritative
-- source (../pokered/ram/wram.asm, ram/sram.asm, macros/ram.asm) and
-- cross-checked against three independently well-known Gen1 save
-- addresses: money @ 0x25F3, badges @ 0x2602, party data @ 0x2F2C --
-- all three fall out exactly right from the single sPlayerName anchor
-- below, strong triangulated confirmation the whole chain (SRAM bank 1
-- layout, wMainData field order, party_struct/box_struct sizes) is right.
--
-- SRAM layout (32768 bytes = 4 banks x 8192): bank 0 is sprite buffers +
-- Hall of Fame (not modeled -- see "explicitly out of scope" in the
-- save-converter plan); bank 1 is "Save Data" (sPlayerName through
-- sMainDataCheckSum); banks 2/3 are the 12 PC boxes (6 each) + checksums.
--
-- Fields with no equivalent in save.lua (current sprite/animation state,
-- connection-header cache, Day Care, Safari Zone, HOF roster) are
-- intentionally not modeled: on export, encode() starts from the
-- ORIGINAL imported bytes as a template when available (GenSave.decode
-- stashes them) so that scratch state round-trips untouched instead of
-- being invented; with no template (a save that originated in this
-- project) those bytes stay zero-filled, which is safe because the real
-- game regenerates all of it from wCurMap on the next map load anyway.
local bit = require("bit")
local GenSave = {}
-- ------------------------------------------------------------------
-- Absolute byte offsets (0-based, matching a raw 32768-byte .sav file)
-- ------------------------------------------------------------------
local NAME_LENGTH = 11
local PARTY_LENGTH = 6
local MONS_PER_BOX = 20
local NUM_BADGES = 8
local BOX_STRUCT_SIZE = 33 -- Species,HP,Level,Status,Type1,Type2,CatchRate,
-- Moves x4,OTID,Exp x3,HPExp,AtkExp,DefExp,
-- SpdExp,SpcExp,DVs,PP x4 (macros/ram.asm box_struct)
local PARTY_STRUCT_SIZE = 44 -- box_struct + Level + Stats x5 (party_struct)
local BOX_REGION_SIZE = 1 + (MONS_PER_BOX + 1) + MONS_PER_BOX * BOX_STRUCT_SIZE
+ MONS_PER_BOX * NAME_LENGTH + MONS_PER_BOX * NAME_LENGTH -- 1122
local O = {}
O.playerName = 9624 -- sPlayerName (11B) = 0x2598
O.mainData = O.playerName + NAME_LENGTH -- sMainData (wMainDataStart mirror)
O.pokedexOwned = O.mainData + 0 -- 19B (flag_array 151)
O.pokedexSeen = O.mainData + 19 -- 19B
O.numBagItems = O.mainData + 38 -- 1B
O.bagItems = O.mainData + 39 -- 41B (20 x (id,qty) + $FF term)
O.money = O.mainData + 80 -- 3B BCD = 0x25F3
O.rivalName = O.mainData + 83 -- 11B
O.options = O.mainData + 94 -- 1B
O.badges = O.mainData + 95 -- 1B = 0x2602
O.playerId = O.mainData + 98 -- 2B (big-endian)
O.curMap = O.mainData + 103 -- 1B
O.yCoord = O.mainData + 106 -- 1B
O.xCoord = O.mainData + 107 -- 1B
O.lastMap = O.mainData + 110 -- 1B
O.numPcItems = O.mainData + 579 -- 1B
O.pcItems = O.mainData + 580 -- 101B (50 x (id,qty) + $FF term)
O.currentBoxNum = O.mainData + 681 -- 1B (bits 0-6: box 0-11, bit 7: unused here)
O.coins = O.mainData + 685 -- 2B BCD
O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits)
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
-- wEventFlags anchor: 320 (event flag_array) + 293 (the wGrassRate/enemy-party
-- battle UNION) + 66 + 66 (wEnemyMonOT/Nicks, 6 x NAME_LENGTH each; the
-- rgbds FOR n,1,PARTY_LENGTH+1 loop is end-exclusive => 6 mons, not 7) + 2
-- (wTrainerHeaderPtr) + 6 (ds) + 1 (wOpponentAfterWrongAnswer) + 1
-- (wCurMapScript) + 7 (ds) = 762. Confirmed on the real fixture: those five
-- bytes read 201h 30m 07s, a sane completed-save clock.
O.playTimeHours = O.mainData + 1866 -- 1B
O.playTimeMaxed = O.mainData + 1867 -- 1B (set once past 255h)
O.playTimeMinutes = O.mainData + 1868 -- 1B (0-59)
O.playTimeSeconds = O.mainData + 1869 -- 1B (0-59)
O.playTimeFrames = O.mainData + 1870 -- 1B (0-59, 1/60s ticks)
O.mainDataSize = 1929 -- wMainDataEnd - wMainDataStart
O.spriteData = O.mainData + O.mainDataSize
O.spriteDataSize = 512 -- 2 x 16 sprites x 16B
O.partyData = O.spriteData + O.spriteDataSize -- = 0x2F2C
O.partyCount = O.partyData
O.partySpecies = O.partyData + 1 -- 7B (PARTY_LENGTH+1)
O.partyMons = O.partyData + 8 -- 6 x 44B
O.partyMonOT = O.partyData + 8 + PARTY_LENGTH * PARTY_STRUCT_SIZE
O.partyMonNicks = O.partyMonOT + PARTY_LENGTH * NAME_LENGTH
O.partyDataSize = 1 + (PARTY_LENGTH + 1) + PARTY_LENGTH * PARTY_STRUCT_SIZE
+ PARTY_LENGTH * NAME_LENGTH + PARTY_LENGTH * NAME_LENGTH -- 404
O.curBoxData = O.partyData + O.partyDataSize
O.boxCount = O.curBoxData
O.boxSpecies = O.curBoxData + 1 -- 21B
O.boxMons = O.curBoxData + 22 -- 20 x 33B
O.boxMonOT = O.curBoxData + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE
O.boxMonNicks = O.boxMonOT + MONS_PER_BOX * NAME_LENGTH
O.checksumStart = O.playerName
O.checksumEnd = O.curBoxData + BOX_REGION_SIZE + 1 -- + sTileAnimations (1B)
O.mainChecksum = O.checksumEnd -- 1B
O.box1 = 16384 -- bank 2 start
O.boxBank2Checksum = O.box1 + 6 * BOX_REGION_SIZE
O.boxBank2IndividualChecksums = O.boxBank2Checksum + 1 -- 6B
O.box7 = 24576 -- bank 3 start
O.boxBank3Checksum = O.box7 + 6 * BOX_REGION_SIZE
O.boxBank3IndividualChecksums = O.boxBank3Checksum + 1 -- 6B
GenSave.OFFSETS = O
GenSave.BOX_REGION_SIZE = BOX_REGION_SIZE
GenSave.SAVE_SIZE = 32768
-- ------------------------------------------------------------------
-- Byte-level helpers. `bytes` is a 32768-byte Lua string (1-based
-- indexing, so byte offset N is string position N+1); `buf` for writing
-- is a 32768-entry array of 1-char strings, joined at the end.
-- ------------------------------------------------------------------
local function u8(bytes, off) return bytes:byte(off + 1) end
local function u16be(bytes, off) return u8(bytes, off) * 256 + u8(bytes, off + 1) end
local function u24be(bytes, off)
return u8(bytes, off) * 65536 + u8(bytes, off + 1) * 256 + u8(bytes, off + 2)
end
local function setByte(buf, off, v)
buf[off + 1] = string.char(bit.band(v, 0xFF))
end
local function setU16be(buf, off, v)
setByte(buf, off, bit.band(bit.rshift(v, 8), 0xFF))
setByte(buf, off + 1, bit.band(v, 0xFF))
end
local function setU24be(buf, off, v)
setByte(buf, off, bit.band(bit.rshift(v, 16), 0xFF))
setByte(buf, off + 1, bit.band(bit.rshift(v, 8), 0xFF))
setByte(buf, off + 2, bit.band(v, 0xFF))
end
local function setBcd(buf, off, nbytes, v)
for i = nbytes - 1, 0, -1 do
local d = v % 100
v = math.floor(v / 100)
setByte(buf, off + i, math.floor(d / 10) * 16 + (d % 10))
end
end
local function readBcd(bytes, off, nbytes)
local n = 0
for i = 0, nbytes - 1 do
local b = u8(bytes, off + i)
n = n * 100 + math.floor(b / 16) * 10 + (b % 16)
end
return n
end
-- CalcCheckSum (engine/menus/save.asm): complement of the additive sum
local function checksum(bytes, from, to)
local sum = 0
for i = from, to - 1 do sum = bit.band(sum + u8(bytes, i), 0xFF) end
return bit.band(bit.bnot(sum), 0xFF)
end
-- flag_array packs LSB-first within each byte (bit 0 of byte 0 = index 0).
-- This is pokered's runtime FlagAction convention (home/predef macros): it
-- takes flag number N, addresses byte N/8, and builds the mask by rotating
-- a 1 left N%8 times starting from bit 0 -- i.e. flag N%8==0 is the LSB.
-- Same convention PKHeX uses for Gen1 dex/event flags (FlagUtil.GetFlag:
-- data[ofs + bit/8] >> (bit%8) & 1). Cross-validated against the real save:
-- ZAPDOS (dex 145) is physically boxed there, so its owned/seen flag must be
-- set; only the LSB reading returns it set (MSB-first spuriously drops
-- exactly that one bit at the byte-18 boundary), yielding a complete 151/151
-- dex. The prior MSB-first code round-tripped self-consistently but decoded
-- every flag_array (pokedex AND event flags) to the wrong bit.
local function bitGet(bytes, base, index)
local byteOff = base + math.floor(index / 8)
local b = u8(bytes, byteOff)
return bit.band(bit.rshift(b, index % 8), 1) == 1
end
-- Set one bit directly in `buf` (0-based flag index into a flag_array
-- starting at `base`), preserving every other bit already in that byte --
-- template bytes (see encode()'s header note) survive for bits this pass
-- never explicitly touches, e.g. event-flag bits with no known name
-- sharing a byte with ones that do.
local function bitSet(buf, base, index, value)
local byteOff = base + math.floor(index / 8)
local bitIdx = index % 8
local cur = buf[byteOff + 1] and buf[byteOff + 1]:byte() or 0
local mask = bit.lshift(1, bitIdx)
setByte(buf, byteOff, value and bit.bor(cur, mask) or bit.band(cur, bit.bnot(mask)))
end
-- ------------------------------------------------------------------
-- Text (fixed-length name fields: charmap-encoded, "@" ($50) terminated,
-- $50-padded after the terminator). setCharmap(cm) must be called once
-- before decode/encode (src/save_convert/data/charmap.lua's shape).
-- ------------------------------------------------------------------
local charmap
function GenSave.setCharmap(cm) charmap = cm end
local function decodeName(bytes, off, len)
local out = {}
for i = 0, len - 1 do
local b = u8(bytes, off + i)
if b == 0x50 then break end
out[#out + 1] = charmap.byByte[b] or "?"
end
return table.concat(out)
end
local function encodeName(buf, off, len, text)
local i, pos = 0, 1
while i < len - 1 and pos <= #text do
-- a bracketed control token (e.g. "<DOT>", from decodeName reading a
-- byte with no plain-glyph mapping) is ONE game character despite
-- being several text bytes here; match it as a whole unit first, or
-- it would fall through to per-byte matching and turn into "?" x5
local bracket = text:match("^(<[^<>]*>)", pos)
local ch, clen
if bracket and charmap.byToken[bracket] then
ch, clen = bracket, #bracket
else
local b0 = text:byte(pos)
clen = (b0 < 0x80 and 1) or (b0 < 0xE0 and 2) or (b0 < 0xF0 and 3) or 4
ch = text:sub(pos, pos + clen - 1)
end
setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
i, pos = i + 1, pos + clen
end
-- Write exactly ONE $50 terminator and then STOP. The bytes after it are
-- left untouched: when encoding over a template they stay as the original
-- save's post-terminator padding (so an unchanged name round-trips
-- byte-identical), and on a templateless export they stay zero-filled. The
-- game reads a name only up to the first $50, so whatever follows is inert.
if i < len then setByte(buf, off + i, 0x50) end
end
-- ------------------------------------------------------------------
-- DV / PP packing (box_struct DVs, PP)
-- ------------------------------------------------------------------
-- DVs:: dw, packed as byte0=(Attack<<4)|Defense, byte1=(Speed<<4)|Special;
-- HP DV is derived, not stored, from each stat DV's low bit.
local function decodeDVs(bytes, off)
local b0, b1 = u8(bytes, off), u8(bytes, off + 1)
local atk, def = bit.rshift(b0, 4), bit.band(b0, 0xF)
local spe, spc = bit.rshift(b1, 4), bit.band(b1, 0xF)
local hp = bit.bor(bit.lshift(bit.band(atk, 1), 3), bit.lshift(bit.band(def, 1), 2),
bit.lshift(bit.band(spe, 1), 1), bit.band(spc, 1))
return { hp = hp, attack = atk, defense = def, speed = spe, special = spc }
end
local function encodeDVs(buf, off, dvs)
setByte(buf, off, bit.bor(bit.lshift(bit.band(dvs.attack or 0, 0xF), 4), bit.band(dvs.defense or 0, 0xF)))
setByte(buf, off + 1, bit.bor(bit.lshift(bit.band(dvs.speed or 0, 0xF), 4), bit.band(dvs.special or 0, 0xF)))
end
-- PP byte: top 2 bits = PP Up count (0-3), bottom 6 bits = current PP
local function decodePPByte(b) return bit.band(b, 0x3F), bit.rshift(b, 6) end
local function encodePPByte(pp, ppUps) return bit.bor(bit.lshift(bit.band(ppUps or 0, 3), 6), bit.band(pp or 0, 0x3F)) end
-- ------------------------------------------------------------------
-- Crosswalks (built once from `data` = {pokemon=,moves=,items=,maps=})
-- ------------------------------------------------------------------
-- pokered constants/type_constants.asm PHYSICAL/SPECIAL block; stable,
-- not worth a dedicated extractor for 15 names.
local TYPE_BY_INDEX = {
[0] = "NORMAL", [1] = "FIGHTING", [2] = "FLYING", [3] = "POISON",
[4] = "GROUND", [5] = "ROCK", [6] = "BIRD", [7] = "BUG", [8] = "GHOST",
[20] = "FIRE", [21] = "WATER", [22] = "GRASS", [23] = "ELECTRIC",
[24] = "PSYCHIC_TYPE", [25] = "ICE", [26] = "DRAGON",
}
local TYPE_INDEX = {}
for i, name in pairs(TYPE_BY_INDEX) do TYPE_INDEX[name] = i end
-- Badge bit order (constants/ram_constants.asm BIT_BOULDERBADGE=0 ..
-- BIT_EARTHBADGE=7); this project stores badges as truthy
-- save.inventory[id] entries, not a flag or a separate bitmask
-- (src/inventory/Badges.lua Badges.list's VANILLA order matches exactly).
local BADGE_BY_BIT = {
[0] = "BOULDERBADGE", [1] = "CASCADEBADGE", [2] = "THUNDERBADGE",
[3] = "RAINBOWBADGE", [4] = "SOULBADGE", [5] = "MARSHBADGE",
[6] = "VOLCANOBADGE", [7] = "EARTHBADGE",
}
local BADGE_BY_BIT_SET = {}
for _, name in pairs(BADGE_BY_BIT) do BADGE_BY_BIT_SET[name] = true end
-- STATUS_* bits (constants/battle_constants.asm): 0-2 sleep-turns-left,
-- 3 PSN, 4 BRN, 5 FRZ, 6 PAR
local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 }
local function decodeStatus(b)
if bit.band(b, 7) > 0 then return "SLP" end
for name, bitIdx in pairs(STATUS_BIT) do
if bit.band(b, bit.lshift(1, bitIdx)) ~= 0 then return name end
end
return nil
end
local function encodeStatus(status)
if status == "SLP" then return 7 end
if status and STATUS_BIT[status] then return bit.lshift(1, STATUS_BIT[status]) end
return 0
end
local function buildIndexCrosswalk(defs)
local byIndex, byId = {}, {}
for id, def in pairs(defs or {}) do
if def.index ~= nil then
byIndex[def.index] = id
byId[id] = def.index
end
end
return byIndex, byId
end
-- Pokedex bit position: NATIONAL DEX NUMBER (1-151), NOT the internal ROM
-- species byte (`def.index`, used for party/box mon structs) -- these are
-- two completely different Gen1 numbering schemes (the whole "MissingNo"
-- phenomenon is dex-number vs internal-index mismatches). This project's
-- generated data has no dedicated dex-number field, but every pokemon.lua
-- entry's `source` documents its extraction origin as "ROM:BaseStats[N]",
-- and BaseStats is declared in dex order in the disassembly -- verified
-- directly against 5 species (BULBASAUR->[1], CHARMANDER->[4],
-- SQUIRTLE->[7], PIKACHU->[25], MEWTWO->[150], all exactly their real
-- national dex numbers) before relying on it here.
local function buildDexCrosswalk(defs)
local byDex, dexOf = {}, {}
for id, def in pairs(defs or {}) do
local n = def.source and tonumber(def.source:match("BaseStats%[(%d+)%]"))
if n then
byDex[n] = id
dexOf[id] = n
end
end
return byDex, dexOf
end
-- TM/HM item entries carry no `index` (data/generated/items.lua extracts
-- them by move/slot, not by their place in the raw item-constant table),
-- so buildIndexCrosswalk alone would silently drop every TM/HM from the
-- bag/PC on encode. Their real item ids ARE derivable: pokered's
-- constants/item_constants.asm declares "HM_\1: the item id, starting at
-- $C4" and "TM_\1: the item id, starting at $C9" for slot 1, incrementing
-- per slot -- i.e. HM01=196+.. , TM01=201+(number-1).
local function addMachineIndices(defs, byIndex, byId)
for id, def in pairs(defs or {}) do
if byId[id] == nil and def.machine and def.machine.number then
local base = def.machine.kind == "HM" and 195 or 200
local idx = base + def.machine.number
byIndex[idx] = id
byId[id] = idx
end
end
end
function GenSave.crosswalks(data)
local pokemonByIndex, pokemonIndex = buildIndexCrosswalk(data.pokemon)
local movesByIndex, movesIndex = buildIndexCrosswalk(data.moves)
local itemsByIndex, itemsIndex = buildIndexCrosswalk(data.items)
addMachineIndices(data.items, itemsByIndex, itemsIndex)
local mapsByIndex, mapsIndex = buildIndexCrosswalk(data.maps)
local pokemonByDex, pokemonDex = buildDexCrosswalk(data.pokemon)
return {
pokemonByIndex = pokemonByIndex, pokemonIndex = pokemonIndex,
pokemonByDex = pokemonByDex, pokemonDex = pokemonDex,
movesByIndex = movesByIndex, movesIndex = movesIndex,
itemsByIndex = itemsByIndex, itemsIndex = itemsIndex,
mapsByIndex = mapsByIndex, mapsIndex = mapsIndex,
speciesDefs = data.pokemon or {},
}
end
-- ------------------------------------------------------------------
-- Mon struct (box_struct is a byte-for-byte prefix of party_struct;
-- decodeMon reads the box_struct fields, then Level+Stats if isParty).
-- Type1/Type2 are read for nothing (this project derives type from
-- species) but re-derived from data.pokemon[species].types on encode.
-- ------------------------------------------------------------------
local function decodeMon(bytes, off, isParty, cw)
local speciesIdx = u8(bytes, off)
if speciesIdx == 0 then return nil end -- empty slot
local species = cw.pokemonByIndex[speciesIdx]
local hp = u16be(bytes, off + 1)
local boxLevel = u8(bytes, off + 3)
local status = decodeStatus(u8(bytes, off + 4))
local catchRate = u8(bytes, off + 7)
local moves = {}
for i = 0, 3 do
local moveIdx = u8(bytes, off + 8 + i)
if moveIdx > 0 then
local pp, ppUps = decodePPByte(u8(bytes, off + 29 + i))
moves[#moves + 1] = { id = cw.movesByIndex[moveIdx], pp = pp, ppUps = ppUps }
end
end
local otId = u16be(bytes, off + 12)
local exp = u24be(bytes, off + 14)
local statExp = {
hp = u16be(bytes, off + 17), attack = u16be(bytes, off + 19),
defense = u16be(bytes, off + 21), speed = u16be(bytes, off + 23),
special = u16be(bytes, off + 25),
}
local dvs = decodeDVs(bytes, off + 27)
local mon = {
species = species, exp = exp, dvs = dvs, statExp = statExp,
hp = hp, status = status, moves = moves, otId = otId,
catchRate = catchRate, level = boxLevel,
-- Type1/Type2 as physically stored. This project derives type from species
-- for gameplay, but the raw bytes are captured so encode() can reproduce
-- them verbatim: some real saves (traded/tampered mons) carry type values
-- that do not match the ROM base stats, and re-deriving would corrupt them.
typeBytes = { u8(bytes, off + 5), u8(bytes, off + 6) },
}
if isParty then
mon.level = u8(bytes, off + 33)
mon.stats = {
hp = u16be(bytes, off + 34), attack = u16be(bytes, off + 36),
defense = u16be(bytes, off + 38), speed = u16be(bytes, off + 40),
special = u16be(bytes, off + 42),
}
end
return mon
end
local function encodeMon(buf, off, mon, isParty, cw)
if not mon then
setByte(buf, off, 0)
return
end
setByte(buf, off, cw.pokemonIndex[mon.species] or 0)
setU16be(buf, off + 1, mon.hp or 0)
setByte(buf, off + 3, mon.level or 1)
setByte(buf, off + 4, encodeStatus(mon.status))
local def = cw.speciesDefs[mon.species]
if mon.typeBytes then
-- reproduce the exact stored type bytes captured on decode (faithful
-- byte round-trip); fresh, engine-built mons have none and derive below.
setByte(buf, off + 5, mon.typeBytes[1] or 0)
setByte(buf, off + 6, mon.typeBytes[2] or 0)
else
local t = (def and def.types) or {}
setByte(buf, off + 5, TYPE_INDEX[t[1]] or 0)
setByte(buf, off + 6, TYPE_INDEX[t[2] or t[1]] or 0)
end
setByte(buf, off + 7, mon.catchRate or (def and def.catchRate) or 0)
for i = 0, 3 do
local mv = mon.moves and mon.moves[i + 1]
setByte(buf, off + 8 + i, mv and (cw.movesIndex[mv.id] or 0) or 0)
setByte(buf, off + 29 + i, mv and encodePPByte(mv.pp, mv.ppUps) or 0)
end
setU16be(buf, off + 12, mon.otId or 0)
setU24be(buf, off + 14, mon.exp or 0)
local se = mon.statExp or {}
setU16be(buf, off + 17, se.hp or 0)
setU16be(buf, off + 19, se.attack or 0)
setU16be(buf, off + 21, se.defense or 0)
setU16be(buf, off + 23, se.speed or 0)
setU16be(buf, off + 25, se.special or 0)
encodeDVs(buf, off + 27, mon.dvs or {})
if isParty then
setByte(buf, off + 33, mon.level or 1)
local st = mon.stats or {}
setU16be(buf, off + 34, st.hp or 0)
setU16be(buf, off + 36, st.attack or 0)
setU16be(buf, off + 38, st.defense or 0)
setU16be(buf, off + 40, st.speed or 0)
setU16be(buf, off + 42, st.special or 0)
end
end
-- ------------------------------------------------------------------
-- Bag / PC items: (id, qty) byte pairs, $FF-terminated
-- ------------------------------------------------------------------
local function decodeItemList(bytes, off, capacity, cw)
local inventory, order = {}, {}
for i = 0, capacity - 1 do
local idByte = u8(bytes, off + i * 2)
if idByte == 0xFF then break end
local qty = u8(bytes, off + i * 2 + 1)
local id = cw.itemsByIndex[idByte]
if id then
inventory[id] = qty
order[#order + 1] = id
end
end
return inventory, order
end
local function encodeItemList(buf, off, capacity, inventory, order, cw)
local i = 0
local seen = {}
local function put(id, qty)
if i >= capacity or not qty or qty <= 0 then return end
local idByte = cw.itemsIndex[id]
if not idByte then return end
setByte(buf, off + i * 2, idByte)
setByte(buf, off + i * 2 + 1, math.min(qty, 99))
i = i + 1
seen[id] = true
end
for _, id in ipairs(order or {}) do
if inventory[id] and not seen[id] then put(id, inventory[id]) end
end
for id, qty in pairs(inventory or {}) do
if not seen[id] then put(id, qty) end
end
setByte(buf, off + i * 2, 0xFF)
return i -- count actually written (badges etc. in `inventory` that
-- aren't real items are silently skipped by put(), so this
-- can be less than #inventory -- see the wNumBagItems caller)
end
-- ------------------------------------------------------------------
-- decode: raw 32768-byte SRAM string -> save.lua-shaped table
-- ------------------------------------------------------------------
function GenSave.decode(bytes, data, opts)
assert(#bytes == GenSave.SAVE_SIZE, "expected a 32768-byte save")
local cw = GenSave.crosswalks(data)
local warnings = {}
local function warn(msg) warnings[#warnings + 1] = msg end
if checksum(bytes, O.checksumStart, O.checksumEnd) ~= u8(bytes, O.mainChecksum) then
warn("main data checksum mismatch (importing anyway)")
end
local save = {
meta = { format = "gen1_import" },
player = {
name = decodeName(bytes, O.playerName, NAME_LENGTH),
rival = decodeName(bytes, O.rivalName, NAME_LENGTH),
id = u16be(bytes, O.playerId),
},
money = readBcd(bytes, O.money, 3),
coins = readBcd(bytes, O.coins, 2),
inventory = {},
pcItems = {},
pokedex = { seen = {}, owned = {} },
flags = {},
party = {},
boxes = {},
currentBox = 1,
}
-- pokedex (own/seen, flag_array NUM_POKEMON: bit 0 = dex #1)
for dex, species in pairs(cw.pokemonByDex) do
local bitIdx = dex - 1
if bitGet(bytes, O.pokedexOwned, bitIdx) then save.pokedex.owned[species] = true end
if bitGet(bytes, O.pokedexSeen, bitIdx) then save.pokedex.seen[species] = true end
end
save.inventory, save.bagOrder = decodeItemList(bytes, O.bagItems, 20, cw)
save.pcItems, save.pcOrder = decodeItemList(bytes, O.pcItems, 50, cw)
-- badges: truthy save.inventory[id] entries (src/inventory/Badges.lua),
-- set AFTER decodeItemList since that call replaces save.inventory
local badgesByte = u8(bytes, O.badges)
for i = 0, NUM_BADGES - 1 do
if bit.band(badgesByte, bit.lshift(1, i)) ~= 0 then
save.inventory[BADGE_BY_BIT[i]] = 1
end
end
-- party
local partyCount = u8(bytes, O.partyCount)
for i = 0, math.min(partyCount, PARTY_LENGTH) - 1 do
local mon = decodeMon(bytes, O.partyMons + i * PARTY_STRUCT_SIZE, true, cw)
if mon then
mon.ot = decodeName(bytes, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH)
mon.nickname = decodeName(bytes, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH)
save.party[#save.party + 1] = mon
end
end
-- current box (bank 1) + the 11 stored boxes (banks 2/3)
for i = 1, 12 do save.boxes[i] = {} end
local function decodeBoxRegion(base, boxNum)
local count = u8(bytes, base)
for i = 0, math.min(count, MONS_PER_BOX) - 1 do
local mon = decodeMon(bytes, base + 22 + i * BOX_STRUCT_SIZE, false, cw)
if mon then
mon.ot = decodeName(bytes, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH)
mon.nickname = decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH)
table.insert(save.boxes[boxNum], mon)
end
end
end
local curBoxNum = bit.band(u8(bytes, O.currentBoxNum), 0x7F) -- 0-based box index
curBoxNum = math.max(1, math.min(12, curBoxNum + 1))
decodeBoxRegion(O.curBoxData, curBoxNum)
for b = 1, 6 do
if b + 0 ~= curBoxNum then decodeBoxRegion(O.box1 + (b - 1) * BOX_REGION_SIZE, b) end
end
for b = 7, 12 do
if b ~= curBoxNum then decodeBoxRegion(O.box7 + (b - 7) * BOX_REGION_SIZE, b) end
end
save.currentBox = curBoxNum
-- event flags (only bits with a known name are decoded)
local events = data.eventFlags
if events then
for bitIdx, name in pairs(events.byBit) do
if bitGet(bytes, O.eventFlags, bitIdx) then save.flags[name] = true end
end
end
-- map + position
local mapIdx = u8(bytes, O.curMap)
local mapId = cw.mapsByIndex[mapIdx]
local y, x = u8(bytes, O.yCoord), u8(bytes, O.xCoord)
if mapId then
save.player.map, save.player.x, save.player.y = mapId, x, y
else
warn(("unknown map index %d, defaulting spawn"):format(mapIdx))
end
local lastMapIdx = u8(bytes, O.lastMap)
local lastMapId = cw.mapsByIndex[lastMapIdx]
if lastMapId then save.lastOutdoor = { id = lastMapId } end
-- play time: this project stores save.playTime as a single float of
-- SECONDS (src/core/Game.lua accumulates dt each frame; StartMenu /
-- TrainerCard / TitleState render it H:MM via t/3600 and (t/60)%60).
-- Fold the Gen1 H/M/S/F fields into that one number; frames are 1/60s
-- sub-second ticks, kept as a fraction so an export recovers them exactly.
save.playTime = u8(bytes, O.playTimeHours) * 3600
+ u8(bytes, O.playTimeMinutes) * 60
+ u8(bytes, O.playTimeSeconds)
+ u8(bytes, O.playTimeFrames) / 60
save.warnings = warnings
save.rawImport = bytes -- template for a later encode(); see file header
return save
end
-- ------------------------------------------------------------------
-- encode: save.lua-shaped table -> raw 32768-byte SRAM string
-- ------------------------------------------------------------------
function GenSave.encode(save, data, template)
local cw = GenSave.crosswalks(data)
local src = template or save.rawImport
local buf = {}
if src then
for i = 1, GenSave.SAVE_SIZE do buf[i] = src:sub(i, i) end
else
local zero = string.char(0)
for i = 1, GenSave.SAVE_SIZE do buf[i] = zero end
end
encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED")
encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE")
setU16be(buf, O.playerId, (save.player and save.player.id) or 0)
setBcd(buf, O.money, 3, math.min(save.money or 0, 999999))
setBcd(buf, O.coins, 2, math.min(save.coins or 0, 9999))
local badgesByte = 0
for bitIdx, name in pairs(BADGE_BY_BIT) do
if save.inventory and save.inventory[name] then
badgesByte = bit.bor(badgesByte, bit.lshift(1, bitIdx))
end
end
setByte(buf, O.badges, badgesByte)
for dex, species in pairs(cw.pokemonByDex) do
local bitIdx = dex - 1
bitSet(buf, O.pokedexOwned, bitIdx,
(save.pokedex and save.pokedex.owned and save.pokedex.owned[species]) and true or false)
bitSet(buf, O.pokedexSeen, bitIdx,
(save.pokedex and save.pokedex.seen and save.pokedex.seen[species]) and true or false)
end
-- Badges occupy real item IDs in data/generated/items.lua (Gen1's item
-- ID space includes them, $01-$08, for the "got the BOULDERBADGE!"
-- text display), but this project's save.lua stores them as truthy
-- save.inventory[id] entries alongside actual bag items (see the badge
-- block above and src/inventory/Badges.lua) -- a real save NEVER
-- writes them into wBagItems (they only ever live in wObtainedBadges,
-- already encoded above), so they must be filtered out here or they'd
-- corrupt the bag with bogus "badge items".
local bagInventory = {}
for id, qty in pairs(save.inventory or {}) do
if not BADGE_BY_BIT_SET[id] then bagInventory[id] = qty end
end
local bagN = encodeItemList(buf, O.bagItems, 20, bagInventory, save.bagOrder, cw)
setByte(buf, O.numBagItems, bagN)
local pcN = encodeItemList(buf, O.pcItems, 50, save.pcItems or {}, save.pcOrder, cw)
setByte(buf, O.numPcItems, pcN)
local events = data.eventFlags
if events and save.flags then
for name in pairs(save.flags) do
local bitIdx = events.byName[name]
if bitIdx then bitSet(buf, O.eventFlags, bitIdx, true) end
end
end
-- party
local party = save.party or {}
local partyN = math.min(#party, PARTY_LENGTH)
setByte(buf, O.partyCount, partyN)
for i = 0, partyN - 1 do
local mon = party[i + 1]
encodeMon(buf, O.partyMons + i * PARTY_STRUCT_SIZE, mon, true, cw)
setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0)
encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH,
mon.ot or (save.player and save.player.name) or "RED")
encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH,
mon.nickname or mon.species or "")
end
-- $FF-terminate the species index list right after the last real mon. The
-- struct, OT-name and nickname bytes of the empty slots past partyN are left
-- exactly as the template holds them (original stale data -> byte-identical
-- round-trip) or zero on a fresh export -- the game never reads past the
-- count, so this matches how it leaves those bytes itself.
setByte(buf, O.partySpecies + partyN, 0xFF)
-- boxes: current box mirrors save.currentBox into sCurBoxData; all 12
-- also get written into their bank-2/3 slot (sCurBoxData is a working
-- copy the real game keeps in sync on every PC visit, so keeping both
-- copies consistent here matches that invariant)
local function encodeBoxRegion(base, mons)
local n = math.min(#mons, MONS_PER_BOX)
setByte(buf, base, n)
for i = 0, n - 1 do
local mon = mons[i + 1]
encodeMon(buf, base + 22 + i * BOX_STRUCT_SIZE, mon, false, cw)
setByte(buf, base + 1 + i, cw.pokemonIndex[mon.species] or 0)
encodeName(buf, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH,
mon.ot or (save.player and save.player.name) or "RED")
encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH,
mon.nickname or mon.species or "")
end
-- $FF-terminate the species list after the last real mon; empty slots past
-- n keep their template bytes (byte-identical round-trip) or zero (fresh
-- export), just as the game leaves stale box data untouched past the count.
setByte(buf, base + 1 + n, 0xFF)
end
local boxes = save.boxes or {}
local curBoxNum = math.max(1, math.min(12, save.currentBox or 1))
encodeBoxRegion(O.curBoxData, boxes[curBoxNum] or {})
-- bit 7 of wCurBoxNum is the "box system initialized" flag, not part of the
-- 0-11 index; preserve it from the template, or set it on a templateless
-- export (any save we emit has an initialized box system).
local prevBoxByte = buf[O.currentBoxNum + 1]
local boxHiBit = (src and prevBoxByte) and bit.band(prevBoxByte:byte(), 0x80) or 0x80
setByte(buf, O.currentBoxNum, bit.bor(bit.band(curBoxNum - 1, 0x7F), boxHiBit))
for b = 1, 6 do encodeBoxRegion(O.box1 + (b - 1) * BOX_REGION_SIZE, boxes[b] or {}) end
for b = 7, 12 do encodeBoxRegion(O.box7 + (b - 7) * BOX_REGION_SIZE, boxes[b] or {}) end
-- map + position
if save.player and save.player.map then
setByte(buf, O.curMap, cw.mapsIndex[save.player.map] or 0)
setByte(buf, O.yCoord, save.player.y or 0)
setByte(buf, O.xCoord, save.player.x or 0)
end
if save.lastOutdoor and save.lastOutdoor.id then
setByte(buf, O.lastMap, cw.mapsIndex[save.lastOutdoor.id] or 0)
end
-- play time: split save.playTime (seconds) back into H/M/S/F. The real
-- game freezes the clock at 255h and sets wPlayTimeMaxed once past it, so
-- mirror that cap rather than letting hours overflow a single byte.
local totalFrames = math.floor((save.playTime or 0) * 60 + 0.5)
local hours = math.floor(totalFrames / 216000) -- 3600s * 60 frames
if hours > 255 then
setByte(buf, O.playTimeHours, 255)
setByte(buf, O.playTimeMaxed, 1)
setByte(buf, O.playTimeMinutes, 59)
setByte(buf, O.playTimeSeconds, 59)
setByte(buf, O.playTimeFrames, 59)
else
local rem = totalFrames - hours * 216000
local mins = math.floor(rem / 3600); rem = rem - mins * 3600
local secs = math.floor(rem / 60)
setByte(buf, O.playTimeHours, hours)
setByte(buf, O.playTimeMaxed, 0)
setByte(buf, O.playTimeMinutes, mins)
setByte(buf, O.playTimeSeconds, secs)
setByte(buf, O.playTimeFrames, rem - secs * 60)
end
local out = table.concat(buf)
-- checksums, computed last over the now-final bytes
local outBuf = {}
for i = 1, #out do outBuf[i] = out:sub(i, i) end
setByte(outBuf, O.mainChecksum, checksum(out, O.checksumStart, O.checksumEnd))
-- Per pokered (engine/menus/save.asm SaveSAVtoSRAM / CalcCheckSum): each box
-- gets its own checksum, and the bank aggregate is CalcCheckSum over the
-- ENTIRE six-box region (6 x 1122 bytes), not a sum of the six box sums.
local function boxChecksum(base) return checksum(out, base, base + BOX_REGION_SIZE) end
for b = 0, 5 do
setByte(outBuf, O.boxBank2IndividualChecksums + b,
boxChecksum(O.box1 + b * BOX_REGION_SIZE))
end
setByte(outBuf, O.boxBank2Checksum,
checksum(out, O.box1, O.box1 + 6 * BOX_REGION_SIZE))
for b = 0, 5 do
setByte(outBuf, O.boxBank3IndividualChecksums + b,
boxChecksum(O.box7 + b * BOX_REGION_SIZE))
end
setByte(outBuf, O.boxBank3Checksum,
checksum(out, O.box7, O.box7 + 6 * BOX_REGION_SIZE))
return table.concat(outBuf)
end
return GenSave
+189
View File
@@ -0,0 +1,189 @@
-- SaveConvert -- the runtime-facing entry point the launcher UI calls to
-- turn a vanilla Gen1 (Red/Blue, international) battery save into this
-- project's in-memory save table, and back out to a raw .sav image.
--
-- This is the ONE place the engine, the tests and the CLI
-- (tools/save_convert/convert.lua) share: the GenSave codec, the crosswalk
-- data loading, the merge over new-game defaults, and the version tag all
-- live here so every consumer behaves identically.
--
-- Pure Lua, no love.* dependency at require time: GenSave and the crosswalk
-- tables load through `require`, exactly how src/core/Data.lua pulls the
-- generated modules -- which resolves under both plain luajit (package.path
-- "./?.lua") for the headless CLI/tests and love.filesystem for a fused
-- build, with an OS-path fallback for odd working directories. The only
-- place `love` is referenced is inside a guarded fallback, so running under
-- stock Lua never touches it.
local GenSave = require("src.save_convert.GenSave")
local SaveConvert = {}
SaveConvert.SAVE_SIZE = GenSave.SAVE_SIZE
-- ------------------------------------------------------------------
-- Crosswalk data loading (cached). Mirrors src/core/Data.lua: prefer
-- `require` (works headless via package.path and fused via love's package
-- searcher); fall back to love.filesystem.load, then a plain dofile, for
-- the rare case a host has an unusual cwd or module path.
-- ------------------------------------------------------------------
-- { require-module-path, os-relative-file-path } for each table the codec
-- needs. pokemon/moves/items/maps come from the shared generated data;
-- charmap/event_flags are the save-convert-specific crosswalks.
local DATA_MODULES = {
pokemon = { "data.generated.pokemon", "data/generated/pokemon.lua" },
moves = { "data.generated.moves", "data/generated/moves.lua" },
items = { "data.generated.items", "data/generated/items.lua" },
maps = { "data.generated.maps", "data/generated/maps.lua" },
charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" },
eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" },
}
local function loadTable(requirePath, filePath)
local ok, mod = pcall(require, requirePath)
if ok and type(mod) == "table" then return mod end
-- fused build with an unexpected module path: read straight off the
-- mounted filesystem (love is a global here, only ever touched when it
-- actually exists -- stock Lua never reaches this branch)
if love and love.filesystem and love.filesystem.getInfo
and love.filesystem.getInfo(filePath) then
local chunk = love.filesystem.load(filePath)
if chunk then
local m = chunk()
if type(m) == "table" then return m end
end
end
local chunk = loadfile(filePath)
if chunk then
local m = chunk()
if type(m) == "table" then return m end
end
return nil, ("cannot load save-convert data module %q (tried require %q and file %q)")
:format(requirePath, requirePath, filePath)
end
local crosswalk -- { pokemon=, moves=, items=, maps=, eventFlags= }
local charmapReady
local function ensureData()
if not crosswalk then
local data = {}
for key, spec in pairs(DATA_MODULES) do
if key ~= "charmap" then
local mod, e = loadTable(spec[1], spec[2])
if not mod then return nil, e end
data[key] = mod
end
end
crosswalk = data
end
if not charmapReady then
local cm, err = loadTable(DATA_MODULES.charmap[1], DATA_MODULES.charmap[2])
if not cm then return nil, err end
GenSave.setCharmap(cm)
charmapReady = true
end
return crosswalk
end
-- Exposed for the CLI/tests so they can share the exact data set the codec
-- uses (and so a caller can pre-warm the cache). Returns data, err.
function SaveConvert.loadData()
return ensureData()
end
-- ------------------------------------------------------------------
-- new-game default skeleton the decoded fields merge on top of. Carried
-- verbatim from tools/save_convert/convert.lua so the CLI and the runtime
-- produce a byte-identical save table for the same input.
-- ------------------------------------------------------------------
local function defaultsSave()
return {
meta = { format = "gen1_import", mods = {} },
defeatedTrainers = {},
repelSteps = 0,
modData = {},
options = {
textSpeed = 3, animations = true, battleStyle = "shift",
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0,
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
videoMode = "windowed", mods = {},
},
}
end
-- Merge a GenSave.decode() result over the new-game defaults, exactly the
-- way convert.lua did, then stamp the requested version. The 32768-byte
-- import template GenSave stashes as `rawImport` and the decode `warnings`
-- are dropped here: neither belongs in a serialized slot file (a fresh
-- export always starts zero-filled -- see GenSave.lua's header).
local function mergeDefaults(decoded, version)
decoded.warnings = nil
decoded.rawImport = nil
local save = defaultsSave()
for k, v in pairs(decoded) do save[k] = v end
save.lastHeal = { map = save.player.map, x = save.player.x, y = save.player.y }
save.lastOutdoor = save.lastOutdoor or { id = save.player.map }
if version ~= nil then
save.meta = save.meta or {}
save.meta.version = version
end
return save
end
SaveConvert.mergeDefaults = mergeDefaults
-- ------------------------------------------------------------------
-- Public API
-- ------------------------------------------------------------------
-- importSav(bytes, version) -> saveTable, err
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
-- checksum, decodes through GenSave, and returns a save table fully merged
-- over the new-game defaults and tagged with `version`, ready to hand to
-- SaveSerializer.encode for a slot file. On any failure returns nil + a
-- message (never raises).
function SaveConvert.importSav(bytes, version)
if type(bytes) ~= "string" then
return nil, "expected raw save bytes as a string"
end
if #bytes ~= GenSave.SAVE_SIZE then
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
end
local data, derr = ensureData()
if not data then return nil, derr end
local ok, decoded = pcall(GenSave.decode, bytes, data)
if not ok then return nil, "decode failed: " .. tostring(decoded) end
-- checksum validation: GenSave.decode records a warning rather than
-- throwing (so it can still read a foreign/corrupt save), but for the
-- runtime import path a bad main-data checksum means the file is not a
-- trustworthy save, so reject it.
for _, w in ipairs(decoded.warnings or {}) do
if tostring(w):find("checksum") then
return nil, "save data checksum invalid (" .. tostring(w) .. ")"
end
end
return mergeDefaults(decoded, version)
end
-- exportSav(saveTable) -> bytes, err
-- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware:
-- if the table still carries the stashed import template (saveTable.rawImport)
-- GenSave reproduces every unmodeled region from it; otherwise those regions
-- are zero-filled. On failure returns nil + a message (never raises).
function SaveConvert.exportSav(saveTable)
if type(saveTable) ~= "table" then
return nil, "expected a save table"
end
local data, derr = ensureData()
if not data then return nil, derr end
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
if not ok then return nil, "encode failed: " .. tostring(bytes) end
return bytes
end
return SaveConvert
+579
View File
@@ -0,0 +1,579 @@
-- Generated by tools/build_data.py. DO NOT EDIT.
-- Gen1 text byte <-> glyph/token charmap (fixed-length name
-- fields: player/rival/OT names, nicknames -- box/party mon
-- names are NUL-free, '@' ($50) terminated, space-padded).
-- byToken's key is the literal glyph for ordinary characters
-- ("A", "é", ...) or a bracketed control token
-- ("<PLAYER>", "@") for terminators/substitutions -- only
-- the plain single-glyph entries are meaningful inside a name.
return {
byByte = {
[0] = "<NULL>",
[5] = "",
[6] = "",
[7] = "",
[8] = "",
[9] = "",
[10] = "",
[11] = "",
[12] = "",
[13] = "",
[14] = "",
[15] = "",
[16] = "",
[17] = "",
[18] = "",
[19] = "",
[25] = "",
[26] = "",
[27] = "",
[28] = "",
[38] = "",
[39] = "",
[40] = "",
[41] = "",
[42] = "",
[43] = "",
[44] = "",
[45] = "",
[46] = "",
[47] = "",
[48] = "",
[49] = "",
[50] = "",
[51] = "",
[52] = "",
[58] = "",
[59] = "",
[60] = "",
[61] = "",
[62] = "",
[64] = "",
[65] = "",
[66] = "",
[67] = "",
[68] = "",
[69] = "",
[70] = "",
[71] = "",
[72] = "",
[73] = "<PAGE>",
[74] = "<PKMN>",
[75] = "<_CONT>",
[76] = "<SCROLL>",
[78] = "<NEXT>",
[79] = "<LINE>",
[80] = "@",
[81] = "<PARA>",
[82] = "<PLAYER>",
[83] = "<RIVAL>",
[84] = "#",
[85] = "<CONT>",
[86] = "<……>",
[87] = "<DONE>",
[88] = "<PROMPT>",
[89] = "<TARGET>",
[90] = "<USER>",
[91] = "<PC>",
[92] = "<TM>",
[93] = "<TRAINER>",
[94] = "<ROCKET>",
[95] = "<DEXEND>",
[96] = "<BOLD_A>",
[97] = "<BOLD_B>",
[98] = "<BOLD_C>",
[99] = "<BOLD_D>",
[100] = "<BOLD_E>",
[101] = "<BOLD_F>",
[102] = "<BOLD_G>",
[103] = "<BOLD_H>",
[104] = "<BOLD_I>",
[105] = "<BOLD_V>",
[106] = "<BOLD_S>",
[107] = "<BOLD_L>",
[108] = "<BOLD_M>",
[109] = "<COLON>",
[110] = "",
[111] = "",
[112] = "",
[113] = "",
[114] = "",
[115] = "",
[116] = "·",
[117] = "",
[118] = "",
[119] = "",
[120] = "",
[121] = "",
[122] = "",
[123] = "",
[124] = "",
[125] = "",
[126] = "",
[127] = " ",
[128] = "A",
[129] = "B",
[130] = "C",
[131] = "D",
[132] = "E",
[133] = "F",
[134] = "G",
[135] = "H",
[136] = "I",
[137] = "J",
[138] = "K",
[139] = "L",
[140] = "M",
[141] = "N",
[142] = "O",
[143] = "P",
[144] = "Q",
[145] = "R",
[146] = "S",
[147] = "T",
[148] = "U",
[149] = "V",
[150] = "W",
[151] = "X",
[152] = "Y",
[153] = "Z",
[154] = "(",
[155] = ")",
[156] = ":",
[157] = ";",
[158] = "[",
[159] = "]",
[160] = "a",
[161] = "b",
[162] = "c",
[163] = "d",
[164] = "e",
[165] = "f",
[166] = "g",
[167] = "h",
[168] = "i",
[169] = "j",
[170] = "k",
[171] = "l",
[172] = "m",
[173] = "n",
[174] = "o",
[175] = "p",
[176] = "q",
[177] = "r",
[178] = "s",
[179] = "t",
[180] = "u",
[181] = "v",
[182] = "w",
[183] = "x",
[184] = "y",
[185] = "z",
[186] = "é",
[187] = "'d",
[188] = "'l",
[189] = "'s",
[190] = "'t",
[191] = "'v",
[192] = "",
[193] = "",
[194] = "",
[195] = "",
[196] = "",
[197] = "",
[198] = "",
[199] = "",
[200] = "",
[201] = "",
[202] = "",
[203] = "",
[204] = "",
[205] = "",
[206] = "",
[207] = "",
[208] = "",
[209] = "",
[210] = "",
[211] = "",
[212] = "",
[213] = "",
[214] = "",
[215] = "",
[216] = "",
[217] = "",
[218] = "",
[219] = "",
[220] = "",
[221] = "",
[222] = "",
[223] = "",
[224] = "'",
[225] = "<PK>",
[226] = "<MN>",
[227] = "-",
[228] = "'r",
[229] = "'m",
[230] = "?",
[231] = "!",
[232] = ".",
[233] = "",
[234] = "",
[235] = "",
[236] = "",
[237] = "",
[238] = "",
[239] = "",
[240] = "<ED>",
[241] = "×",
[242] = "<DOT>",
[243] = "/",
[244] = ",",
[245] = "",
[246] = "0",
[247] = "1",
[248] = "2",
[249] = "3",
[250] = "4",
[251] = "5",
[252] = "6",
[253] = "7",
[254] = "8",
[255] = "9",
},
byToken = {
[" "] = 127,
["!"] = 231,
["#"] = 84,
["'"] = 224,
["'d"] = 187,
["'l"] = 188,
["'m"] = 229,
["'r"] = 228,
["'s"] = 189,
["'t"] = 190,
["'v"] = 191,
["("] = 154,
[")"] = 155,
[","] = 244,
["-"] = 227,
["."] = 232,
["/"] = 243,
["0"] = 246,
["1"] = 247,
["2"] = 248,
["3"] = 249,
["4"] = 250,
["5"] = 251,
["6"] = 252,
["7"] = 253,
["8"] = 254,
["9"] = 255,
[":"] = 156,
[";"] = 157,
["<BOLD_A>"] = 96,
["<BOLD_B>"] = 97,
["<BOLD_C>"] = 98,
["<BOLD_D>"] = 99,
["<BOLD_E>"] = 100,
["<BOLD_F>"] = 101,
["<BOLD_G>"] = 102,
["<BOLD_H>"] = 103,
["<BOLD_I>"] = 104,
["<BOLD_L>"] = 107,
["<BOLD_M>"] = 108,
["<BOLD_P>"] = 114,
["<BOLD_S>"] = 106,
["<BOLD_V>"] = 105,
["<COLON>"] = 109,
["<CONT>"] = 85,
["<DEXEND>"] = 95,
["<DONE>"] = 87,
["<DOT>"] = 242,
["<ED>"] = 240,
["<ID>"] = 115,
["<LINE>"] = 79,
["<LV>"] = 110,
["<MN>"] = 226,
["<NEXT>"] = 78,
["<NULL>"] = 0,
["<PAGE>"] = 73,
["<PARA>"] = 81,
["<PC>"] = 91,
["<PK>"] = 225,
["<PKMN>"] = 74,
["<PLAYER>"] = 82,
["<PROMPT>"] = 88,
["<RIVAL>"] = 83,
["<ROCKET>"] = 94,
["<SCROLL>"] = 76,
["<TARGET>"] = 89,
["<TM>"] = 92,
["<TRAINER>"] = 93,
["<USER>"] = 90,
["<_CONT>"] = 75,
["<to>"] = 112,
["<……>"] = 86,
["?"] = 230,
["@"] = 80,
A = 128,
B = 129,
C = 130,
D = 131,
E = 132,
F = 133,
G = 134,
H = 135,
I = 136,
J = 137,
K = 138,
L = 139,
M = 140,
N = 141,
O = 142,
P = 143,
Q = 144,
R = 145,
S = 146,
T = 147,
U = 148,
V = 149,
W = 150,
X = 151,
Y = 152,
Z = 153,
["["] = 158,
["]"] = 159,
a = 160,
b = 161,
c = 162,
d = 163,
e = 164,
f = 165,
g = 166,
h = 167,
i = 168,
j = 169,
k = 170,
l = 171,
m = 172,
n = 173,
o = 174,
p = 175,
q = 176,
r = 177,
s = 178,
t = 179,
u = 180,
v = 181,
w = 182,
x = 183,
y = 184,
z = 185,
["¥"] = 240,
["·"] = 116,
["×"] = 241,
["é"] = 186,
[""] = 112,
[""] = 113,
[""] = 114,
[""] = 115,
[""] = 117,
[""] = 96,
[""] = 97,
[""] = 116,
[""] = 117,
[""] = 122,
[""] = 124,
[""] = 121,
[""] = 123,
[""] = 125,
[""] = 126,
[""] = 237,
[""] = 237,
[""] = 236,
[""] = 238,
[""] = 245,
[""] = 239,
[" "] = 127,
[""] = 232,
[""] = 112,
[""] = 113,
[""] = 114,
[""] = 115,
[""] = 118,
[""] = 177,
[""] = 110,
[""] = 178,
[""] = 111,
[""] = 179,
[""] = 119,
[""] = 180,
[""] = 120,
[""] = 181,
[""] = 182,
[""] = 38,
[""] = 183,
[""] = 39,
[""] = 184,
[""] = 40,
[""] = 185,
[""] = 41,
[""] = 186,
[""] = 42,
[""] = 187,
[""] = 43,
[""] = 188,
[""] = 44,
[""] = 189,
[""] = 45,
[""] = 190,
[""] = 46,
[""] = 191,
[""] = 47,
[""] = 192,
[""] = 48,
[""] = 193,
[""] = 49,
[""] = 223,
[""] = 194,
[""] = 50,
[""] = 195,
[""] = 51,
[""] = 196,
[""] = 52,
[""] = 197,
[""] = 198,
[""] = 199,
[""] = 200,
[""] = 201,
[""] = 202,
[""] = 58,
[""] = 68,
[""] = 203,
[""] = 59,
[""] = 69,
[""] = 204,
[""] = 60,
[""] = 70,
[""] = 205,
[""] = 61,
[""] = 71,
[""] = 206,
[""] = 62,
[""] = 72,
[""] = 207,
[""] = 208,
[""] = 209,
[""] = 210,
[""] = 211,
[""] = 224,
[""] = 212,
[""] = 225,
[""] = 213,
[""] = 226,
[""] = 214,
[""] = 215,
[""] = 216,
[""] = 217,
[""] = 218,
[""] = 219,
[""] = 220,
[""] = 221,
[""] = 222,
[""] = 233,
[""] = 128,
[""] = 176,
[""] = 129,
[""] = 234,
[""] = 130,
[""] = 235,
[""] = 131,
[""] = 244,
[""] = 132,
[""] = 133,
[""] = 5,
[""] = 134,
[""] = 6,
[""] = 135,
[""] = 7,
[""] = 136,
[""] = 8,
[""] = 137,
[""] = 9,
[""] = 138,
[""] = 10,
[""] = 139,
[""] = 11,
[""] = 140,
[""] = 12,
[""] = 141,
[""] = 13,
[""] = 142,
[""] = 14,
[""] = 143,
[""] = 15,
[""] = 144,
[""] = 16,
[""] = 172,
[""] = 145,
[""] = 17,
[""] = 146,
[""] = 18,
[""] = 147,
[""] = 19,
[""] = 148,
[""] = 149,
[""] = 150,
[""] = 151,
[""] = 152,
[""] = 153,
[""] = 25,
[""] = 64,
[""] = 154,
[""] = 26,
[""] = 65,
[""] = 155,
[""] = 27,
[""] = 66,
[""] = 156,
[""] = 28,
[""] = 67,
[""] = 157,
[""] = 158,
[""] = 159,
[""] = 160,
[""] = 161,
[""] = 173,
[""] = 162,
[""] = 174,
[""] = 163,
[""] = 175,
[""] = 164,
[""] = 165,
[""] = 166,
[""] = 167,
[""] = 168,
[""] = 169,
[""] = 170,
[""] = 171,
[""] = 227,
[""] = 240,
[""] = 231,
[""] = 242,
[""] = 243,
[""] = 246,
[""] = 247,
[""] = 248,
[""] = 249,
[""] = 250,
[""] = 251,
[""] = 252,
[""] = 253,
[""] = 254,
[""] = 255,
[""] = 230,
[""] = 229,
[""] = 228,
},
source = "pokered constants/charmap.asm",
}
File diff suppressed because it is too large Load Diff
+55 -9
View File
@@ -261,7 +261,9 @@ function Commands.move_player(ctx, dir, tiles)
end
function Commands.move_npc(ctx, objIndex, dir, tiles)
local npc = ctx.overworld:npcByIndex(objIndex)
local ow = ctx.overworld
if not ow then return end
local npc = ow:npcByIndex(objIndex)
if npc then walkEntity(ctx, npc, dir, tiles) end
end
@@ -308,6 +310,7 @@ end
function Commands.move_npc_to(ctx, objIndex, tx, ty)
local ow = ctx.overworld
if not ow then return end
local npc = ow:npcByIndex(objIndex)
if not npc then return end
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc)
@@ -339,6 +342,20 @@ function Commands.face_object(ctx, objIndex, dir)
if npc then npc.facing = dir end
end
-- Instantly relocate an NPC (OaksLabCalcRivalMovementScript / SetSpritePosition1).
-- facing is optional. Headless-safe no-op without an overworld.
function Commands.place_npc(ctx, objIndex, x, y, facing)
local ow = ctx.overworld
if not ow then return end
local npc = ow:npcByIndex(objIndex)
if not npc then return end
npc.cellX, npc.cellY = x, y
npc.px, npc.py = x * 16, y * 16
npc.moving = false
npc.targetX, npc.targetY = nil, nil
if facing then npc.facing = facing end
end
function Commands.face_npc(ctx)
-- make the player face the talking NPC
if ctx.npc and ctx.overworld then
@@ -485,7 +502,7 @@ function Commands.heal_party(ctx)
end
-- AskName (engine/menus/naming_screen.asm): yes/no then NamingScreen.
-- AddPartyMon only offers this for party mons (box deposits skip it).
-- AddPartyMon and SendNewMonToBox both offer this.
-- Preserves ctx.lastCheck so GivePokemon's carry is not clobbered by the
-- yes/no result (scripts jump_if_false on give failure afterwards).
local function askNickname(ctx, mon)
@@ -527,8 +544,9 @@ end
-- the asm's carry: true when the mon was given, false when both the
-- party and every box are full (that .boxFull path leaves the giver's
-- script able to offer again later, e.g. the Celadon Eevee ball).
-- Party adds run AskName (AddPartyMon) when a script runner is present;
-- mods that pre-set gift.nickname skip the prompt.
-- AskName runs for party (AddPartyMon) and box (SendNewMonToBox) when a
-- script runner is present; mods that pre-set gift.nickname skip it.
-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37).
function Commands.give_pokemon(ctx, species, level)
-- Native mods can transform a gift before the Pokémon object is created.
-- This is intentionally an event rather than a special-case starter hook:
@@ -540,14 +558,17 @@ function Commands.give_pokemon(ctx, species, level)
end
local Pokemon = require("src.pokemon.Pokemon")
local Party = require("src.pokemon.Party")
local Boxes = require("src.pokemon.Boxes")
local mon = Pokemon.new(ctx.game.data, species, level)
if gift.nickname then mon.nickname = gift.nickname end
ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species
ctx.pendingPokemonName = species
require("src.battle.BattleState").stampOT(ctx.save, mon)
local addedToParty = Party.add(ctx.save.party, mon)
local boxNum = nil
if not addedToParty then
if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then
boxNum = Boxes.deposit(ctx.save, mon)
if not boxNum then
ctx.lastCheck = false
return
end
@@ -558,11 +579,30 @@ function Commands.give_pokemon(ctx, species, level)
dex.owned[species] = true
end
ctx.lastCheck = true
-- AddPartyMon AskName: party only; skip box deposits, mod-set nicknames,
-- and callback-style callers that have no script runner to yield on.
if addedToParty and not gift.nickname and ctx.runner then
ctx.addedToParty = addedToParty
ctx.boxNum = boxNum
-- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks
-- and callback-style callers with no script runner to yield on.
if not gift.nickname and ctx.runner then
askNickname(ctx, mon)
end
if boxNum then
local name = mon.nickname
or (ctx.game.data.pokemon[species] and ctx.game.data.pokemon[species].name)
or species
ctx.game.boxMonNicks = name
ctx.game.stringBuffer = tostring(boxNum)
if ctx.runner then
Commands.show_text(ctx, "_SentToBoxText")
else
local TextBox = require("src.render.TextBox")
local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText
ctx.game.stack:push(TextBox.new(ctx.game, raw or (
"There's no more\nroom for POKéMON!\v" .. name
.. " was\vsent to POKéMON\vBOX " .. boxNum .. " on PC!"),
function() end))
end
end
end
function Commands.give_money(ctx, amount)
@@ -783,6 +823,8 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
local newMon = Pokemon.new(data, trade.get, sent.level)
newMon.nickname = trade.nickname
newMon.traded = true -- boosted exp + Name Rater refusal (different OT)
newMon.ot = "TRAINER" -- InGameTrade_TrainerString
newMon.otId = (love.math and love.math.random or math.random)(0, 65535)
table.remove(party, slot)
table.insert(party, newMon)
local dex = ctx.save.pokedex
@@ -790,9 +832,13 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
dex.seen[trade.get] = true
dex.owned[trade.get] = true
end
-- the trade machine animation (engine/movie/trade.asm)
-- InternalClockTradeAnim (engine/movie/trade.asm)
Screens.push(ctx.game, "TradeAnim", {
sent = sent, received = newMon,
enemyName = "TRAINER",
playerOt = ctx.save.player.name,
playerOtId = sent.otId or ctx.save.player.id,
enemyOtId = newMon.otId,
onDone = function() runner:resume() end,
})
runner:yield()
+6
View File
@@ -331,6 +331,12 @@ end
local function useItem(game, battle, id, list)
local def = game.data.items[id]
-- ItemUseTMHM checks wIsInBattle before BootedUpTMText
if battle and def and def.machine then
local _, payload = ItemEffects.use(game.data, game.save, id, nil, battle)
showMessages(game, payload)
return
end
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
-- TMs/HMs boot up and announce their move before the target picker
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
+98 -21
View File
@@ -3,9 +3,11 @@
-- plus CHANGE BOX.
local Boxes = require("src.pokemon.Boxes")
local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local Party = require("src.pokemon.Party")
local TextBox = require("src.render.TextBox")
local BoxMenu = {}
@@ -14,6 +16,11 @@ local function monLabel(game, mon)
return ("%s :L%d"):format(mon.nickname or def.name, mon.level)
end
local function monName(game, mon)
local def = game.data.pokemon[mon.species]
return mon.nickname or def.name
end
-- Per-mon submenu (bills_pc.asm DisplayDepositWithdrawMenu): the chosen
-- action + STATS + CANCEL. STATS shows the status screen and returns
-- here; CANCEL/B goes back to the list.
@@ -31,8 +38,26 @@ local function monSubmenu(game, action, mon, onAction)
}, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
end
-- After a successful transfer: close the mon list (BoxMenu stays beneath
-- via keepOpen) and show the taken-out / stored text (jp BillsPCMenu).
local function afterTransfer(game, list, text)
list:close()
game.stack:push(TextBox.new(game, text))
end
local function withdraw(game)
local box = Boxes.active(game.save)
local t = game.data.text
if #box == 0 then
game.stack:push(TextBox.new(game, t._NoMonText
or "What? There are\nno POKéMON here!"))
return
end
if #game.save.party >= Party.MAX then
game.stack:push(TextBox.new(game, t._CantTakeMonText
or "You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."))
return
end
local items = {}
for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i })
@@ -49,13 +74,29 @@ local function withdraw(game)
end
table.remove(box, item.value)
table.insert(game.save.party, mon)
list:close()
local name = monName(game, mon)
game.stringBuffer = name
require("src.core.Sound").playCry(game.data, mon.species)
afterTransfer(game, list, t._MonIsTakenOutText
or (name .. " is\ntaken out.\vGot " .. name .. "."))
end)
end,
}))
end
local function deposit(game)
local t = game.data.text
if #game.save.party <= 1 then
game.stack:push(TextBox.new(game, t._CantDepositLastMonText
or "You can't deposit\nthe last POKéMON!"))
return
end
local box = Boxes.active(game.save)
if #box >= Boxes.CAPACITY then
game.stack:push(TextBox.new(game, t._BoxFullText
or "Oops! This Box is\nfull of POKéMON."))
return
end
local items = {}
for i, mon in ipairs(game.save.party) do
table.insert(items, { label = monLabel(game, mon), value = i })
@@ -69,40 +110,50 @@ local function deposit(game)
list.footer = "You need at least\none POKéMON!"
return
end
local box = Boxes.active(game.save)
if #box >= Boxes.CAPACITY then
local active = Boxes.active(game.save)
if #active >= Boxes.CAPACITY then
list.footer = ("BOX %d is full!"):format(game.save.currentBox)
return
end
table.remove(game.save.party, item.value)
table.insert(box, mon)
list:close()
table.insert(active, mon)
local name = monName(game, mon)
game.stringBuffer = name
game.boxNumString = tostring(game.save.currentBox)
require("src.core.Sound").playCry(game.data, mon.species)
afterTransfer(game, list, t._MonWasStoredText
or (name .. " was\nstored in Box " .. game.boxNumString .. "."))
end)
end,
}))
end
-- RELEASE POKéMON (bills_pc.asm .release): confirm, then "Bye [MON]!"
-- RELEASE POKéMON (bills_pc.asm BillsPCRelease): confirm, then "Bye [MON]!".
-- Index by list.index (not stale item.value) after removeCurrent (#171).
local function release(game)
local box = Boxes.active(game.save)
local t = game.data.text
if #box == 0 then
game.stack:push(TextBox.new(game, t._NoMonText
or "What? There are\nno POKéMON here!"))
return
end
local items = {}
for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game,
("BOX %d (RELEASE)"):format(game.save.currentBox), items, {
onChoose = function(item, list)
local mon = box[item.value]
onChoose = function(_, list)
local mon = box[list.index]
if not mon then return end
local def = game.data.pokemon[mon.species]
local name = mon.nickname or def.name
local name = monName(game, mon)
local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
"Once released,\n" .. name .. " is\ngone forever. OK?", function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end
table.remove(box, item.value)
table.remove(box, list.index)
require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game,
("%s was\nreleased outside.\fBye %s!"):format(name, name)))
@@ -129,7 +180,6 @@ local function changeBox(game)
-- the original asks BEFORE switching ("When you change a #MON
-- BOX, data will be saved. OK?"); declining aborts the change
local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
"When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function()
game.stack:push(ChoiceBox.new(game, function(yes)
@@ -143,21 +193,48 @@ local function changeBox(game)
}))
end
-- bills_pc.asm BillsPCMenu chrome: What? text box + BOX No. overlay
local function drawChrome(game)
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("What?", 8, 112)
Font.drawBox(9, 14, 11, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("BOX No.", 80, 128)
local n = game.save.currentBox or 1
if n >= 10 then
Font.draw(tostring(n), 136, 128)
else
Font.draw(tostring(n), 144, 128)
end
love.graphics.setColor(1, 1, 1, 1)
end
function BoxMenu.new(game)
Boxes.ensure(game.save)
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
-- 12x10 → total 14x12. "CHANGE BOX" is 10 tiles and needs the
-- full interior (cursor col + label); the old tw=12 right-side box
-- drew the final glyph on the border.
return Menu.new(game, {
{ label = "WITHDRAW", onSelect = function() withdraw(game) end },
{ label = "DEPOSIT", onSelect = function() deposit(game) end },
{ label = "RELEASE", onSelect = function() release(game) end },
{ label = "CHANGE BOX", onSelect = function() changeBox(game) end },
-- 12x10 → total 14x12. "CHANGE BOX" / "WITHDRAW <PK><MN>" need the
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
local menu = Menu.new(game, {
{ label = "WITHDRAW <PK><MN>", keepOpen = true,
onSelect = function() withdraw(game) end },
{ label = "DEPOSIT <PK><MN>", keepOpen = true,
onSelect = function() deposit(game) end },
{ label = "RELEASE <PK><MN>", keepOpen = true,
onSelect = function() release(game) end },
{ label = "CHANGE BOX", keepOpen = true,
onSelect = function() changeBox(game) end },
{ label = "SEE YA!" },
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/pokemon_pc.asm)
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
local baseDraw = menu.draw
function menu:draw()
baseDraw(self)
drawChrome(game)
end
return menu
end
return BoxMenu
+12 -7
View File
@@ -1,4 +1,4 @@
-- YES/NO choice box (top-left of the text box area, like the original).
-- YES/NO choice box (InitYesNoTextBoxParameters: above the text box, right).
local Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
@@ -14,6 +14,11 @@ function ChoiceBox.new(game, onChoose, opts)
self.index = (opts and opts.defaultNo) and 2 or 1
-- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent
self.noSound = (opts and opts.noSound) or false
local box = Theme.choiceBox
self.tx = (opts and opts.tx) or box.tx
self.ty = (opts and opts.ty) or box.ty
self.tw = (opts and opts.tw) or box.tw
self.th = (opts and opts.th) or box.th
return self
end
@@ -38,13 +43,13 @@ function ChoiceBox:update(dt)
end
function ChoiceBox:draw()
local box = Theme.choiceBox
Font.drawBox(box.tx, box.ty, box.tw, box.th)
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("YES", (box.tx + 2) * 8, (box.ty + 1) * 8)
Font.draw("NO", (box.tx + 2) * 8, (box.ty + 3) * 8)
Font.drawCode(Theme.cursor, (box.tx + 1) * 8,
(box.ty + (self.index == 1 and 1 or 3)) * 8)
Font.draw("YES", (tx + 2) * 8, (ty + 1) * 8)
Font.draw("NO", (tx + 2) * 8, (ty + 3) * 8)
Font.drawCode(Theme.cursor, (tx + 1) * 8,
(ty + (self.index == 1 and 1 or 3)) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
+10 -5
View File
@@ -37,8 +37,12 @@ function ListMenu.new(game, title, items, opts)
-- text box, a money box sits top-right, and the list shortens to
-- clear them (DisplayPokemartDialogue_'s screen)
self.dialogue = opts.dialogue
-- PC item lists (players_pc.asm): PrintListMenuEntries shows 4 names
-- and PrintText footers ("How many?", stored/withdrew) use the standard
-- bottom text box — same row budget as the mart, without the money box.
self.messageBox = opts.messageBox
self.money = opts.money -- () -> current money for the box
self.rows = opts.dialogue and 4 or ROWS
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
return self
end
@@ -137,8 +141,10 @@ function ListMenu:draw()
love.graphics.setColor(0, 0, 0, 1)
local money = ("¥%d"):format(self.money and self.money() or 0)
Font.draw(money, 152 - Font.width(money), 8)
-- the clerk's line in the standard bottom text box; long prompts
-- wrap and keep their last two lines, like the GB's scrolled box
end
if self.dialogue or (self.messageBox and self.footer) then
-- standard bottom text box (PrintText); long prompts wrap and keep
-- their last two lines, like the GB's scrolled box (#115/#174)
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if self.footer then
@@ -153,8 +159,7 @@ function ListMenu:draw()
end
end
elseif self.footer then
-- PC deposit/withdraw footers use "\n"; draw the last two lines so
-- long item names are not clipped at the screen edge (#115).
-- bare footer (bag money line, etc.)
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
+24 -12
View File
@@ -22,6 +22,8 @@ function MoveLearnMenu.new(game, mon, newMoveId, onDone)
self.newMoveId = newMoveId
self.onDone = onDone
self.index = 1
-- forget-list UI only after TryingToLearn YES (learn_move.asm .loop)
self.selecting = false
return self
end
@@ -31,24 +33,32 @@ end
-- TryingToLearnText + yes/no (learn_move.asm TryingToLearn): NO offers
-- AbandonLearning, whose own NO loops back here (DontAbandonLearning).
-- Use TextBox opts.choice so YES/NO overlays the still-visible prompt;
-- pushing ChoiceBox from onDone pops the text first and leaves YES/NO
-- on "Which move should be forgotten?" (#173).
function MoveLearnMenu:enter()
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local game = self.game
local mdef = game.data.moves[self.newMoveId]
local name = self:monName()
self.selecting = false
game.stack:push(TextBox.new(game,
("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f")
:format(name, mdef.name, name) ..
("Delete an older\nmove to make room\vfor %s?"):format(mdef.name),
function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then self:confirmAbandon() end
end))
end))
nil, {
choice = function(yes)
if yes then
self.selecting = true
else
self:confirmAbandon()
end
end,
}))
end
function MoveLearnMenu:update(dt)
if not self.selecting then return end
local input = self.game.input
local n = #self.mon.moves + 1 -- moves + CANCEL
if input:wasPressed("up") then
@@ -82,15 +92,15 @@ end
-- (DontAbandonLearning)
function MoveLearnMenu:confirmAbandon()
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local game = self.game
local mdef = game.data.moves[self.newMoveId]
self.selecting = false
game.stack:push(TextBox.new(game,
("Abandon learning\n%s?"):format(mdef.name), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if yes then self:finish(false) else self:enter() end
end))
end))
("Abandon learning\n%s?"):format(mdef.name), nil, {
choice = function(yes)
if yes then self:finish(false) else self:enter() end
end,
}))
end
function MoveLearnMenu:finish(learned)
@@ -98,6 +108,7 @@ function MoveLearnMenu:finish(learned)
local game = self.game
local name = self:monName()
local mdef = game.data.moves[self.newMoveId]
self.selecting = false
game.stack:pop()
local msg
if learned then
@@ -114,6 +125,7 @@ function MoveLearnMenu:finish(learned)
end
function MoveLearnMenu:draw()
if not self.selecting then return end
-- single-spaced move list box (TryingToLearn: TextBoxBorder at 4,7)
-- plus the port's extra CANCEL row
Font.drawBox(4, 5, 16, 7)
+30
View File
@@ -10,6 +10,7 @@
-- bottom line like pokered's.
local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local Zoom = require("src.render.Zoom")
@@ -193,6 +194,15 @@ local function buildRows(game)
local o = g.save.options
o.tilt = wrapIndex((o.tilt or 0) + dir, 4)
Tilt.setLevel(o.tilt)
-- tilt and a mod's world pipeline are two answers to the same
-- question; turning this on switches that off (Pipelines does the
-- same in the other direction)
if o.tilt > 0 then
for _, entry in ipairs(Pipelines.list()) do
if entry.def.drawWorld then Pipelines.setLevel(entry.id, 0) end
end
Pipelines.syncOptions(o)
end
return true
end },
{ id = "gbcfx", label = "GBC FX",
@@ -295,6 +305,26 @@ local function buildRows(game)
end
rows = filtered
end
-- A mod's render pipelines are display modes like TILT, so their rows sit
-- with it rather than at the end of the list where a mod's own
-- ui.options.rows additions land. Nothing registered means nothing
-- spliced, so a vanilla install sees the list it always had.
local pipelineRows = Pipelines.rows(game)
if pipelineRows[1] then
local merged = {}
for _, row in ipairs(rows) do
merged[#merged + 1] = row
if row.id == "tilt" then
for _, extra in ipairs(pipelineRows) do merged[#merged + 1] = extra end
end
end
-- no TILT row to anchor to (a future build could drop it): append
-- rather than silently lose the modes
if #merged == #rows then
for _, extra in ipairs(pipelineRows) do merged[#merged + 1] = extra end
end
rows = merged
end
return rows
end
+71 -49
View File
@@ -1,8 +1,11 @@
-- Party menu: list the party, choose a member.
-- Modes:
-- default: A -> submenu (STATS / SWITCH order / CANCEL)
-- opts.onSwitch: A -> hand the chosen mon to the callback (battle
-- switch, item targeting via opts.pickOnly)
-- default: A -> submenu (STATS / SWITCH / field moves)
-- opts.onSwitch + opts.battle (voluntary PKMN): A -> SWITCH / STATS /
-- CANCEL (core.asm PartyMenuOrRockOrRun), then onSwitch on SWITCH
-- opts.onSwitch + opts.forceSwitch: A -> onSwitch immediately
-- (ChooseNextMon / SHIFT free-switch)
-- opts.pickOnly + opts.onSwitch: A -> onSwitch (item / script target)
-- opts.onCancel: fired when the menu closes without a pick (B)
-- Pops itself on B.
@@ -125,6 +128,7 @@ function PartyMenu.new(game, opts)
self.onSwitch = opts.onSwitch
self.onCancel = opts.onCancel
self.pickOnly = opts.pickOnly
self.forceSwitch = opts.forceSwitch
self.battle = opts.battle
self.party = opts.party -- link battles pass their clamped copies
self.swapFrom = nil
@@ -156,7 +160,17 @@ function PartyMenu:update(dt)
-- hook-injected entries carry a callback instead of an action id
entry.onSelect(mon, self.game)
elseif action == "stats" then
-- battle and field alike return to the party list afterwards
-- (core.asm .partyMenuWasSelected)
Screens.push(self.game, "SummaryMenu", mon)
elseif action == "battle_switch" then
self.game.stack:pop()
self.onSwitch(mon)
return
elseif action == "cancel" then
self.game.stack:pop()
if self.onCancel then self.onCancel() end
return
elseif action == "switch" then
self.swapFrom = self.index
elseif action == "fly" then
@@ -341,58 +355,66 @@ function PartyMenu:update(dt)
require("src.core.Sound").play(self.game.data, "Swap")
end
self.swapFrom = nil
elseif self.onSwitch then
elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then
self.game.stack:pop()
self.onSwitch(mon)
else
self.submenu = true
self.subIndex = 1
-- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
-- builds the same dynamic list)
local items = { { label = "STATS", action = "stats" },
{ label = "SWITCH", action = "switch" } }
local items
local ow = self.game.overworld
-- Field moves (HMs/TMs) are usable out of battle even when the mon
-- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
-- Battle still excludes this list via `not self.battle`. Softboiled
-- can appear for a fainted user; its heal transfer then no-ops.
if not self.battle and ow then
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU —
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
local outside = Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets"))
for _, mv in ipairs(mon.moves) do
if mv.id == "FLY" and outside
and self.game.save.inventory.THUNDERBADGE then
table.insert(items, { label = "FLY", action = "fly" })
elseif mv.id == "FLASH" and ow.dark
and self.game.save.inventory.BOULDERBADGE then
table.insert(items, { label = "FLASH", action = "flash" })
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
-- CUT/SURF/STRENGTH are party-menu field moves too
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
-- with the same list-time badge filter this file already uses
-- for FLY/FLASH. The facing-tile/activation check happens on
-- selection (useCutFieldMove/useSurfFieldMove).
table.insert(items, { label = "CUT", action = "cut" })
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
table.insert(items, { label = "SURF", action = "surf" })
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
table.insert(items, { label = "STRENGTH", action = "strength" })
elseif mv.id == "SOFTBOILED" then
table.insert(items, { label = "SOFTBOILED", action = "softboiled" })
elseif mv.id == "TELEPORT" and outside then
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
-- .teleport -> CheckIfInOutsideMap); dark maps don't
-- block it
table.insert(items, { label = "TELEPORT", action = "escape" })
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
and ow.map.id ~= "AGATHAS_ROOM" then
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
-- ESCAPE_ROPE): usable in the dungeon tilesets of
-- escape_rope_tilesets.asm minus Agatha's room, even in
-- the dark (Rock Tunnel)
table.insert(items, { label = "DIG", action = "escape" })
if self.battle and self.onSwitch then
-- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun)
items = { { label = "SWITCH", action = "battle_switch" },
{ label = "STATS", action = "stats" },
{ label = "CANCEL", action = "cancel" } }
else
-- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
-- builds the same dynamic list)
items = { { label = "STATS", action = "stats" },
{ label = "SWITCH", action = "switch" } }
-- Field moves (HMs/TMs) are usable out of battle even when the mon
-- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
-- Battle still excludes this list via `not self.battle`. Softboiled
-- can appear for a fainted user; its heal transfer then no-ops.
if not self.battle and ow then
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU —
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
local outside = Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets"))
for _, mv in ipairs(mon.moves) do
if mv.id == "FLY" and outside
and self.game.save.inventory.THUNDERBADGE then
table.insert(items, { label = "FLY", action = "fly" })
elseif mv.id == "FLASH" and ow.dark
and self.game.save.inventory.BOULDERBADGE then
table.insert(items, { label = "FLASH", action = "flash" })
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
-- CUT/SURF/STRENGTH are party-menu field moves too
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
-- with the same list-time badge filter this file already uses
-- for FLY/FLASH. The facing-tile/activation check happens on
-- selection (useCutFieldMove/useSurfFieldMove).
table.insert(items, { label = "CUT", action = "cut" })
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
table.insert(items, { label = "SURF", action = "surf" })
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
table.insert(items, { label = "STRENGTH", action = "strength" })
elseif mv.id == "SOFTBOILED" then
table.insert(items, { label = "SOFTBOILED", action = "softboiled" })
elseif mv.id == "TELEPORT" and outside then
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
-- .teleport -> CheckIfInOutsideMap); dark maps don't
-- block it
table.insert(items, { label = "TELEPORT", action = "escape" })
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
and ow.map.id ~= "AGATHAS_ROOM" then
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
-- ESCAPE_ROPE): usable in the dungeon tilesets of
-- escape_rope_tilesets.asm minus Agatha's room, even in
-- the dark (Rock Tunnel)
table.insert(items, { label = "DIG", action = "escape" })
end
end
end
end
+6 -3
View File
@@ -71,6 +71,7 @@ end
local function withdraw(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
messageBox = true,
onChoose = function(item, list)
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag")
@@ -107,6 +108,7 @@ local function deposit(game)
if not Bag.isBadge(id) then depositable[id] = count end
end
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
messageBox = true,
onChoose = function(item, list)
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
if pcFull(game, pc, item.value) then
@@ -126,6 +128,7 @@ end
local function toss(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
messageBox = true,
onChoose = function(item, list)
local def = game.data.items[item.value]
if (def and def.keyItem) or item.value:find("^HM_") then
@@ -161,9 +164,9 @@ function PlayerPC.new(game)
{ label = "DEPOSIT ITEM", onSelect = function() deposit(game) end },
{ label = "TOSS ITEM", onSelect = function() toss(game) end },
{ label = "LOG OFF" },
-- the whole PC session runs silent (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/players_pc.asm PlayersPCMenu)
}, { tx = 3, ty = 0, tw = 17, th = 10, noSound = true })
-- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm
-- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10
}, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true })
end
return PlayerPC
+8 -4
View File
@@ -40,15 +40,19 @@ function QuantityBox:update(dt)
end
function QuantityBox:draw()
local w = self.unitPrice and 11 or 7
local tx = 20 - w - 1
Font.drawBox(tx, 13, w, 3)
-- DisplayChooseQuantityMenu (home/list_menu.asm): non-priced box at
-- hlcoord 15,9 (interior 3x1); priced at hlcoord 7,9 (interior 11x1).
-- TextBoxBorder adds the frame, so outer size is +2 on each axis.
local tw = self.unitPrice and 13 or 5
local tx = self.unitPrice and 7 or 15
local ty = 9
Font.drawBox(tx, ty, tw, 3)
love.graphics.setColor(0, 0, 0, 1)
local s = ("×%02d"):format(self.qty) -- the multiply glyph tile
if self.unitPrice then
s = s .. (" ¥%d"):format(self.qty * self.unitPrice)
end
Font.draw(s, (tx + 1) * 8, 14 * 8)
Font.draw(s, (tx + 1) * 8, (ty + 1) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
+2 -1
View File
@@ -15,7 +15,8 @@ local Theme = {
cols = Renderer.WIDTH / 8,
rows = Renderer.HEIGHT / 8,
textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 },
choiceBox = { tx = 0, ty = 7, tw = 6, th = 5 },
-- InitYesNoTextBoxParameters / AskName: hlcoord 14, 7 (YES_NO_MENU 4x3)
choiceBox = { tx = 14, ty = 7, tw = 6, th = 5 },
}
function Theme.load(data)
+427 -45
View File
@@ -1,8 +1,7 @@
-- Link/in-game trade cinematic (engine/movie/trade.asm, trade2.asm):
-- the traded POKéMON rises away with its cry and a goodbye, then the
-- received one descends with its cry and "take good care" text.
-- A skips the slide animations ahead. Calls onDone() after popping.
-- InternalClockTradeAnim (engine/movie/trade.asm): cable-trade cinematic
-- used by in-game NPC trades and internally-clocked link trades.
local Font = require("src.render.Font")
local Sound = require("src.core.Sound")
local TextBox = require("src.render.TextBox")
@@ -10,13 +9,22 @@ local TradeAnim = {}
TradeAnim.__index = TradeAnim
TradeAnim.isOpaque = true
-- SGB: generic whole-screen palette (SET_PAL_GENERIC)
function TradeAnim:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end
local SLIDE_FRAMES = 90
local REST_Y = 44 -- resting top of the sprite, roughly screen centre
local DEFAULT_ART = {
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",
}
local function tryImage(path)
if not path then return nil end
@@ -29,11 +37,46 @@ local function nameOf(game, mon)
return mon.nickname or (def and def.name) or mon.species
end
local function speciesName(game, mon)
local def = game.data.pokemon[mon.species]
return (def and def.name) or mon.species
end
local function dexOf(game, mon)
local def = game.data.pokemon[mon.species]
return def and def.dex or 0
end
local function spriteOf(game, mon)
local def = game.data.pokemon[mon.species]
return tryImage(def and def.spriteFront)
end
local function expand(game, key, subs)
local raw = game.data.text and game.data.text[key]
if not raw then return key end
for token, value in pairs(subs or {}) do
raw = raw:gsub("{" .. token .. "}", value)
end
return TextBox.substitute(game, raw)
end
-- InternalClockTradeFuncSequence
local SEQ = {
"show_player",
"open_cable",
"ball_enter",
"transfer_lr",
"delay",
"went_to",
"for_sends",
"farewell",
"transfer_rl",
"open_cable2",
"show_enemy",
"done",
}
function TradeAnim.new(game, opts)
opts = opts or {}
local self = setmetatable({}, TradeAnim)
@@ -41,62 +84,401 @@ function TradeAnim.new(game, opts)
self.sent = opts.sent
self.received = opts.received
self.onDone = opts.onDone
self.enemyName = opts.enemyName or (self.received and self.received.ot) or "TRAINER"
self.playerName = (game.save.player and game.save.player.name) or "RED"
self.playerOt = opts.playerOt or self.playerName
self.playerOtId = opts.playerOtId
or (self.sent and self.sent.otId)
or (game.save.player and game.save.player.id)
or 0
self.enemyOtId = opts.enemyOtId
or (self.received and self.received.otId)
or love.math.random(0, 65535)
local art = (game.data.field and game.data.field.tradeArt) or DEFAULT_ART
self.img = {
gameBoy = tryImage(art.gameBoy or DEFAULT_ART.gameBoy),
openCable = tryImage(art.openCable or DEFAULT_ART.openCable),
cableHoriz = tryImage(art.cableHoriz or DEFAULT_ART.cableHoriz),
cableConn = tryImage(art.cableConn or DEFAULT_ART.cableConn),
cableVert = tryImage(art.cableVert or DEFAULT_ART.cableVert),
cableCorner = tryImage(art.cableCorner or DEFAULT_ART.cableCorner),
cableEnd = tryImage(art.cableEnd or DEFAULT_ART.cableEnd),
cableBall = tryImage(art.cableBall or DEFAULT_ART.cableBall),
cableBallAlt = tryImage(art.cableBallAlt or DEFAULT_ART.cableBallAlt),
bubble = tryImage(art.bubble or DEFAULT_ART.bubble),
}
self.sentSprite = spriteOf(game, self.sent)
self.receivedSprite = spriteOf(game, self.received)
self.phase = "out"
self.recvSprite = spriteOf(game, self.received)
self.seq = 1
self.phase = SEQ[1]
self.t = 0
self.scx = 0
self.ballX = 0
self.ballY = 0
self.monX = 0
self.monY = 0
self.flash = false
self.monVisible = true
self.waitingText = false
self.cableFlash = false
return self
end
function TradeAnim:enter()
Sound.playCry(self.game.data, self.sent.species)
Sound.play(self.game.data, "Trade_Machine")
end
function TradeAnim:advance()
self.seq = self.seq + 1
self.phase = SEQ[self.seq] or "done"
self.t = 0
self.scx = 0
self.sub = nil
self.flash = false
self.cableFlash = false
self.poof = nil
if self.phase == "done" then
self.game.stack:pop()
if self.onDone then self.onDone() end
elseif self.phase == "show_enemy" then
self.monVisible = false
elseif self.phase == "transfer_lr" then
-- wBaseCoord $54, $1c OAM -> screen (76, 12)
self.monX, self.monY = 76, 12
elseif self.phase == "transfer_rl" then
-- wBaseCoord $64, $44 OAM -> screen (92, 52), right GB on screen
self.monX, self.monY = 92, 52
self.scx = 160
elseif self.phase == "ball_enter" then
-- lb bc, $20, $60: b = Y, c = X (Trade_AnimateBallEnteringLinkCable)
self.ballX, self.ballY = 0x60, 0x20
elseif self.phase == "open_cable" or self.phase == "open_cable2" then
-- SCX $a0 -> $f0: cable slides in from the right, open end rests at x64
self.scx = 0x50
Sound.play(self.game.data, "Heal_HP")
end
end
function TradeAnim:say(text, delay, thenFn)
self.waitingText = true
self.game.stack:push(TextBox.new(self.game, text, function()
self.waitingText = false
if thenFn then thenFn() else self:advance() end
end, { auto = { delay = delay or 80 } }))
end
function TradeAnim:skipHeld()
return self.game.input:wasPressed("a") or self.game.input:isDown("a")
end
function TradeAnim:update(dt)
local input = self.game.input
if self.phase == "out" or self.phase == "in" then
self.t = self.t + 1
if input:wasPressed("a") then self.t = SLIDE_FRAMES end
if self.t < SLIDE_FRAMES then return end
if self.phase == "out" then
self.phase = "goodbye"
self.game.stack:push(TextBox.new(self.game,
("Goodbye %s!"):format(nameOf(self.game, self.sent)),
function()
self.phase = "in"
self.t = 0
Sound.playCry(self.game.data, self.received.species)
end))
if self.waitingText or self.phase == "done" then return end
local skip = self.game.input:wasPressed("a")
self.t = self.t + 1
local p = self.phase
if p == "show_player" then
-- slide from SCX $86 -> 0, hold 80, poof, cry (Trade_ShowPlayerMon)
if not self.sub then self.sub = "slide" end
if self.sub == "slide" then
self.scx = math.max(0, 0x86 - self.t * 2)
if skip or self.scx <= 0 then
self.scx = 0
self.sub = "hold"
self.t = 0
end
elseif self.sub == "hold" then
if skip or self.t >= 80 then
self.sub = "poof"
self.t = 0
self.monVisible = false
Sound.play(self.game.data, "Ball_Poof")
end
elseif self.sub == "poof" then
self.poof = math.max(0, 16 - self.t)
if skip or self.t >= 16 then
Sound.playCry(self.game.data, self.sent.species)
self.sub = nil
self:advance()
end
end
elseif p == "open_cable" or p == "open_cable2" then
-- 20 steps of 4px, matching the SCX loop
self.scx = math.max(0, 0x50 - self.t * 4)
if skip or self.scx <= 0 then
self.scx = 0
self:advance()
end
elseif p == "ball_enter" then
-- TRADE_BALL_SHAKE then ball rides the cable; X from $60 toward $a0
if self.t < 20 then
if skip then self.t = 20 end
return
end
local step = self.t - 20
if step % 3 == 0 then
self.ballX = 0x60 + math.floor(step / 3) * 4
self.flash = not self.flash
if self.ballX < 0xA0 then Sound.play(self.game.data, "Tink") end
end
if skip then self.ballX = 0xA0 end
if self.ballX >= 0xA0 then self:advance() end
elseif p == "transfer_lr" then
-- scroll left GB off while the mon rides the cable, then the sprite
-- itself moves right and down into the right GB (Trade_AnimMonMoveVertical)
if self.t <= 80 then
self.scx = math.min(160, self.t * 2)
self.monX, self.monY = 76, 12
elseif self.t <= 80 + 32 then
self.monX = 76 + math.floor((self.t - 80) / 8) * 4
elseif self.t <= 80 + 64 then
self.monX = 92
self.monY = 12 + math.floor((self.t - 112) / 8) * 10
else
self.phase = "takecare"
self.game.stack:push(TextBox.new(self.game,
("Take good care\nof %s!"):format(nameOf(self.game, self.received)),
function()
self.game.stack:pop()
if self.onDone then self.onDone() end
end))
self:advance()
return
end
if self.t % 8 == 0 then self.cableFlash = not self.cableFlash end
if skip then self:advance() end
elseif p == "transfer_rl" then
-- mirror: sprite climbs out of the right GB, then the screen scrolls
-- back to the left GB
if self.t <= 32 then
self.scx = 160
self.monX, self.monY = 92, 52 - math.floor(self.t / 8) * 10
elseif self.t <= 64 then
self.monX = 92 - math.floor((self.t - 32) / 8) * 4
self.monY = 12
elseif self.t <= 64 + 80 then
self.scx = math.max(0, 160 - (self.t - 64) * 2)
self.monX, self.monY = 76, 12
else
self:advance()
return
end
if self.t % 8 == 0 then self.cableFlash = not self.cableFlash end
if skip then self:advance() end
elseif p == "delay" then
if skip or self.t >= 100 then self:advance() end
elseif p == "went_to" then
local text = expand(self.game, "_TradeWentToText", {
["RAM:wStringBuffer"] = speciesName(self.game, self.sent),
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
})
self:say(text, 200)
elseif p == "for_sends" then
local a = expand(self.game, "_TradeForText", {
["RAM:wStringBuffer"] = speciesName(self.game, self.sent),
})
local b = expand(self.game, "_TradeSendsText", {
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
})
self.waitingText = true
self.game.stack:push(TextBox.new(self.game, a, function()
self.game.stack:push(TextBox.new(self.game, b, function()
self.waitingText = false
self:advance()
end, { auto = { delay = 80 } }))
end, { auto = { delay = 80 } }))
elseif p == "farewell" then
local a = expand(self.game, "_TradeWavesFarewellText", {
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
})
local b = expand(self.game, "_TradeTransferredText", {
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
})
self.waitingText = true
self.game.stack:push(TextBox.new(self.game, a, function()
self.game.stack:push(TextBox.new(self.game, b, function()
self.waitingText = false
self:advance()
end, { auto = { delay = 80 } }))
end, { auto = { delay = 80 } }))
elseif p == "show_enemy" then
if self.t == 1 then
Sound.play(self.game.data, "Ball_Poof")
self.monVisible = true
elseif self.t == 20 then
Sound.playCry(self.game.data, self.received.species)
elseif self.t >= 120 or skip then
local text = expand(self.game, "_TradeTakeCareText", {
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
})
self:say(text, 80)
end
end
end
local function drawCableHoriz(self, y, x0, x1)
if self.img.cableHoriz then
love.graphics.draw(self.img.cableHoriz, x0 - (self.scx % 8), y)
else
love.graphics.setColor(0.2, 0.2, 0.2, 1)
love.graphics.rectangle("fill", x0, y + 1, math.max(0, x1 - x0), 6)
love.graphics.setColor(1, 1, 1, 1)
end
end
function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
-- Trade_PrintPlayerMonInfoText: rows +0/+2/+4/+6 from the box top; the
-- No. line replaces part of the top border (hlcoord 5, 0 in trade2.asm)
Font.drawBox(4, boxTy, 12, 8)
local y0 = boxTy * 8
local no = ("No.%03d"):format(dexOf(self.game, mon))
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 56, y0, Font.width(no), 8)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(no, 56, y0)
Font.draw(speciesName(self.game, mon), 40, y0 + 16)
Font.draw("OT/" .. (ot or "????"), 40, y0 + 32)
Font.draw(("IDNo.%05d"):format(otId or 0), 40, y0 + 48)
love.graphics.setColor(1, 1, 1, 1)
end
function TradeAnim:drawIconInBubble(mon, x, y)
local spr = (mon.species == self.received.species) and self.recvSprite or self.sentSprite
if spr then
local sw, sh = spr:getDimensions()
local s = 16 / math.max(sw, sh)
love.graphics.draw(spr, x, y, 0, s, s)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 4, y + 4, 8, 8)
love.graphics.setColor(1, 1, 1, 1)
end
if self.img.bubble then
if not self.bubbleQuad then
local iw, ih = self.img.bubble:getDimensions()
self.bubbleQuad = love.graphics.newQuad(0, 0, 16, 16, iw, ih)
self.bubbleQuadAlt = ih >= 32
and love.graphics.newQuad(0, 16, 16, 16, iw, ih)
or self.bubbleQuad
end
local q = self.cableFlash and self.bubbleQuadAlt or self.bubbleQuad
love.graphics.draw(self.img.bubble, q, x - 8, y - 8)
end
end
function TradeAnim:drawGameBoy(x, y)
if self.img.gameBoy then
love.graphics.draw(self.img.gameBoy, x, y)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", x, y, 48, 64)
love.graphics.setColor(1, 1, 1, 1)
end
end
function TradeAnim:drawLeftGB()
-- cable from GB to the right edge
if self.img.cableConn then
love.graphics.draw(self.img.cableConn, 88, 32)
end
drawCableHoriz(self, 32, 96, 160)
self:drawGameBoy(40, 24)
Font.drawBox(4, 12, 9, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.playerName, 40, 112)
love.graphics.setColor(1, 1, 1, 1)
end
function TradeAnim:drawRightGB()
drawCableHoriz(self, 32, 0, 112)
if self.img.cableCorner then love.graphics.draw(self.img.cableCorner, 112, 32) end
if self.img.cableVert then
for i = 1, 4 do
love.graphics.draw(self.img.cableVert, 120, 40 + (i - 1) * 8)
end
end
if self.img.cableEnd then love.graphics.draw(self.img.cableEnd, 112, 72) end
if self.img.cableConn then love.graphics.draw(self.img.cableConn, 104, 72) end
self:drawGameBoy(56, 64)
Font.drawBox(6, 0, 9, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.enemyName, 56, 16)
love.graphics.setColor(1, 1, 1, 1)
end
function TradeAnim:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local sprite, y
if self.phase == "out" then
-- the sent mon rises up and off the screen
sprite = self.sentSprite
y = REST_Y - math.floor((self.t / SLIDE_FRAMES) * (REST_Y + 60))
elseif self.phase == "in" or self.phase == "takecare" then
-- the received mon descends into place
sprite = self.receivedSprite
local t = self.phase == "in" and self.t or SLIDE_FRAMES
y = -60 + math.floor((t / SLIDE_FRAMES) * (REST_Y + 60))
end
if sprite and y then
local w = sprite:getWidth()
love.graphics.draw(sprite, math.floor((160 - w) / 2), y)
local p = self.phase
if p == "show_player" then
love.graphics.push()
love.graphics.translate(-self.scx, 0)
-- mon pic on the BG at hlcoord 7, 2; info box on the window at
-- hWY $50, so it sits in the bottom half of the screen
if self.monVisible and self.sentSprite then
love.graphics.draw(self.sentSprite, 56, 16)
end
self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10)
love.graphics.pop()
if self.poof and self.poof > 0 then
love.graphics.setColor(0, 0, 0, self.poof / 16)
love.graphics.circle("fill", 80, 40, 20 - (self.poof or 0))
love.graphics.setColor(1, 1, 1, 1)
end
elseif p == "open_cable" or p == "open_cable2" then
love.graphics.push()
love.graphics.translate(self.scx, 0)
if self.img.openCable then
love.graphics.draw(self.img.openCable, 64, 16) -- open end at SCX $f0
end
love.graphics.pop()
elseif p == "ball_enter" then
if self.img.openCable then
love.graphics.draw(self.img.openCable, 64, 16)
end
-- OAM coords carry a (+8, +16) hardware offset
local ball = self.flash and (self.img.cableBallAlt or self.img.cableBall)
or self.img.cableBall
if ball then
love.graphics.draw(ball, self.ballX - 8, self.ballY - 16)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.circle("fill", self.ballX, self.ballY - 8, 6)
love.graphics.setColor(1, 1, 1, 1)
end
elseif p == "transfer_lr" or p == "transfer_rl" then
-- two-screen world: left GB scene at x0, right GB scene at x160;
-- the mon icon stays in screen space like the OAM sprite it ports
love.graphics.push()
love.graphics.translate(-self.scx, 0)
self:drawLeftGB()
love.graphics.translate(160, 0)
self:drawRightGB()
love.graphics.pop()
local mon = (p == "transfer_lr") and self.sent or self.received
self:drawIconInBubble(mon, self.monX, self.monY)
if self.cableFlash then
love.graphics.setColor(1, 1, 1, 0.15)
love.graphics.rectangle("fill", 0, 32, 160, 8)
love.graphics.setColor(1, 1, 1, 1)
end
elseif p == "show_enemy" then
if self.monVisible and self.recvSprite then
love.graphics.draw(self.recvSprite, 56, 16)
end
self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10)
end
-- went_to / for_sends / farewell / delay: cleared window; TextBox draws
end
return TradeAnim
+7 -7
View File
@@ -34,7 +34,8 @@ function TrainerCard.new(game)
local self = setmetatable({ game = game }, TrainerCard)
local img = tryImage("assets/generated/trainer_card/badges.png")
if img then
-- 8 pairs of [gym leader face, badge]
-- badges.2bpp is 8 stacked [face, badge] pairs (DrawBadges FaceBadgeTiles)
self.faces = { img = img, quads = quads16(img, 8, 32, 0, 0) }
self.badges = { img = img, quads = quads16(img, 8, 32, 0, 16) }
end
local nums = tryImage("assets/generated/trainer_card/badge_numbers.png")
@@ -124,7 +125,7 @@ function TrainerCard:draw()
love.graphics.setColor(0, 0, 0, 1)
end
-- numbered badge grid (rows 11-17): earned solid, unearned dimmed
-- numbered badge grid (rows 11-17): face by default, badge when owned
self:frameBox(0, 11, 20, 7)
local badges = Badges.list(self.game.data)
for i = 1, #badges do
@@ -136,12 +137,11 @@ function TrainerCard:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.nums.img, self.nums.quads[i - 1], tx, ty)
end
if self.badges and self.badges.quads[i - 1]
and save.inventory[Badges.itemFor(badges[i])] then
-- unearned badge slots stay blank (DrawBadges)
if self.faces and self.faces.quads[i - 1] then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.badges.img, self.badges.quads[i - 1],
tx + 4, ty + 6)
local owned = save.inventory[Badges.itemFor(badges[i])]
local sheet = owned and self.badges or self.faces
love.graphics.draw(sheet.img, sheet.quads[i - 1], tx + 4, ty + 6)
end
end
love.graphics.setColor(1, 1, 1, 1)
+263
View File
@@ -0,0 +1,263 @@
-- The boot shell: the heart of the self-updater. A fused build, before it
-- runs the game bundled inside it, looks in its save directory for a newer
-- payload (a downloaded gen1recomp-X.Y.Z.love), and if one is present and
-- runnable, mounts it over the bundled source and chainloads it -- so the
-- binary shipped once can keep updating the Lua it runs without a reinstall.
--
-- Only a fused build self-updates. A dev / source checkout IS the game, so
-- Boot.run is a no-op there.
--
-- Three pieces, deliberately layered so the risky part is small and the
-- decision part is testable:
-- * Boot.select -- pure: given probed candidates + the bundled version,
-- decide what to run and what to delete. No love.*.
-- * Boot.probePayload-- read one archive's advertised version, isolated.
-- * Boot.run -- orchestrates: crash-guard, enumerate, select, and
-- (if a payload wins) mount + chainload with full
-- rollback on any failure so the bundled game always
-- boots.
--
-- Known limitation: the bundled love.run keeps driving the frame loop after a
-- handoff (it has already returned its stepper to LÖVE; redefining the global
-- love.run does nothing to the running one). A payload that must change
-- love.run itself therefore requires a minShell bump so an older shell refuses
-- to chainload it.
local Semver = require("src.update.Semver")
local Boot = {}
-- Save-directory layout (identity "pokemon-love2d"), per the shared contract.
local PAYLOAD_DIR = "updates"
local PENDING = "updates/pending.txt"
-- Isolated mountpoint used only to peek at a candidate's Version.lua, so its
-- copy never collides with the running source's copy at "/".
local PROBE_MOUNT = "__pokeport_probe"
-- Downloaded payloads are named gen1recomp-<X.Y.Z>.love.
local function isPayloadName(name)
return name:match("^gen1recomp%-.+%.love$") ~= nil
end
-- The love callbacks the payload's main.lua chunk may redefine when it runs.
-- We snapshot these before a handoff and restore them if the handoff fails, so
-- the exact bundled closures (with their intact upvalues) drive the game
-- again. love.run is included: harmless to restore, and it is one of the
-- globals a payload main.lua reassigns.
local CALLBACK_NAMES = {
"load", "update", "draw", "quit", "run",
"keypressed", "keyreleased", "textinput",
"mousepressed", "mousereleased", "mousemoved", "wheelmoved",
"touchpressed", "touchmoved", "touchreleased",
"gamepadpressed", "gamepadreleased", "gamepadaxis", "joystickremoved",
"focus", "visible", "resize", "filedropped", "directorydropped",
"errorhandler", "threaderror", "lowmemory",
}
local function snapshotCallbacks()
local snap = {}
for _, k in ipairs(CALLBACK_NAMES) do snap[k] = love[k] end
return snap
end
local function restoreCallbacks(snap)
for _, k in ipairs(CALLBACK_NAMES) do love[k] = snap[k] end
end
-- Drop every bundled Lua module the payload must be allowed to re-resolve: all
-- src.* modules plus the main/conf chunks. conf.lua cached the bundled
-- Version into package.loaded["src.core.Version"]; without this purge the next
-- require would hand back the bundled copy instead of the payload's. Setting
-- existing fields to nil during a pairs traversal is explicitly permitted.
local function purgeBundledModules()
for key in pairs(package.loaded) do
if key:match("^src%.") or key == "main" or key == "conf" then
package.loaded[key] = nil
end
end
end
-- Boot.probePayload(rel) -> { engine = string, minShell = number } | nil, err
--
-- Mount the archive at rel (a save-directory-relative path) on an isolated
-- mountpoint, read its src/core/Version.lua by executing the source with
-- loadstring (NEVER require -- we must not cache or run it as a module), then
-- unmount. Version.lua is zero-require, so running its chunk is safe.
function Boot.probePayload(rel)
if not love.filesystem.mount(rel, PROBE_MOUNT) then
return nil, "could not mount " .. tostring(rel)
end
local chunkPath = PROBE_MOUNT .. "/src/core/Version.lua"
local ok, result = pcall(function()
local src = love.filesystem.read(chunkPath)
if not src then error("Version.lua missing", 0) end
local chunk = loadstring(src, "@" .. chunkPath)
if not chunk then error("Version.lua would not compile", 0) end
return chunk()
end)
love.filesystem.unmount(rel)
if not ok then return nil, tostring(result) end
local v = result
if type(v) ~= "table" or type(v.engine) ~= "string" then
return nil, "payload has no usable Version table"
end
return { engine = v.engine, minShell = tonumber(v.minShell) or 1 }
end
-- Boot.select(candidates, bundledEngine, bundledShell) -> chosen | nil, toDelete
--
-- Pure (no love.*): decide which payload to run and which to delete.
-- candidates is a list of { name = , engine = , minShell = }.
-- * chosen: the highest engine that is STRICTLY newer than bundledEngine and
-- whose minShell <= bundledShell (a payload the running shell can host).
-- * toDelete: stale payloads -- engine <= bundled (old or the same as what we
-- already ship), or superseded by the chosen one (not newer than chosen).
-- A payload newer than the chosen one but unrunnable here (minShell too
-- high) is kept: a future shell upgrade may be able to run it.
function Boot.select(candidates, bundledEngine, bundledShell)
local chosen
for _, c in ipairs(candidates) do
local newer = Semver.compare(c.engine, bundledEngine) > 0
local runnable = (c.minShell or 1) <= bundledShell
if newer and runnable then
if not chosen or Semver.compare(c.engine, chosen.engine) > 0 then
chosen = c
end
end
end
local toDelete = {}
for _, c in ipairs(candidates) do
if not (chosen and c.name == chosen.name) then
local stale = Semver.compare(c.engine, bundledEngine) <= 0
if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then
stale = true
end
if stale then toDelete[#toDelete + 1] = c.name end
end
end
return chosen and chosen.name or nil, toDelete
end
-- Mount the chosen payload and hand control to it. Returns true when the
-- payload is live and has completed its own love.load; false (with full
-- rollback) on any failure, so the caller runs the bundled game instead.
local function chainload(name, args)
local rel = PAYLOAD_DIR .. "/" .. name
-- Crash marker: if we die between here and clearing it, the next boot's
-- crash guard distrusts this payload and deletes it.
love.filesystem.write(PENDING, name)
-- Prepend-mount the payload at "/" (appendToPath = false) so its files win
-- over the fused source for every subsequent require / love.filesystem read.
if not love.filesystem.mount(rel, "/", false) then
love.filesystem.remove(PENDING)
return false
end
local snapshot = snapshotCallbacks()
purgeBundledModules()
_G.POKEPORT_PAYLOAD_MOUNTED = true
-- Chainload: run the payload's main.lua (redefines the love callbacks from
-- the NEW code), then call its love.load. The new love.load calls Boot.run
-- again, which no-ops via the flag set above.
local ok, err = pcall(function()
local chunk = assert(love.filesystem.load("main.lua"))
chunk()
love.load(args)
end)
if not ok then
-- Handoff failed after mounting. Unwind everything so the bundled game
-- boots cleanly: clear the flag, unmount the payload, purge any payload
-- modules it cached (so bundled requires reload from source), restore the
-- bundled love callbacks with their intact upvalues, and drop the marker.
-- Delete the payload too: it failed deterministically once, so leaving it
-- would re-select and re-fail it on every boot forever.
print("update: payload handoff failed, reverting to bundled: " .. tostring(err))
_G.POKEPORT_PAYLOAD_MOUNTED = nil
pcall(love.filesystem.unmount, rel)
purgeBundledModules()
restoreCallbacks(snapshot)
love.filesystem.remove(rel)
love.filesystem.remove(PENDING)
return false
end
-- Success: the payload owns the game now. Drop the marker and tell the
-- caller to stop so the bundled love.load does not run on top of it.
love.filesystem.remove(PENDING)
return true
end
-- Everything after the fused / flag guards, wrapped so an unexpected error in
-- enumeration or selection can never crash the boot.
local function runInner(args)
-- Crash guard first: a pending.txt naming a payload means a previous boot
-- crashed mid-handoff. Distrust that payload -- delete it and the marker --
-- then continue (we may still pick an older valid payload, or fall through
-- to the bundled game).
local pending = love.filesystem.read(PENDING)
if pending then
pending = pending:gsub("%s+$", "")
if pending ~= "" then
love.filesystem.remove(PAYLOAD_DIR .. "/" .. pending)
end
love.filesystem.remove(PENDING)
end
-- Enumerate and probe every payload in updates/.
local candidates = {}
if love.filesystem.getInfo(PAYLOAD_DIR, "directory") then
for _, entry in ipairs(love.filesystem.getDirectoryItems(PAYLOAD_DIR)) do
if isPayloadName(entry) then
local info = Boot.probePayload(PAYLOAD_DIR .. "/" .. entry)
if info then
candidates[#candidates + 1] = {
name = entry,
engine = info.engine,
minShell = info.minShell,
}
end
end
end
end
local Version = require("src.core.Version")
local chosen, toDelete = Boot.select(candidates, Version.engine, Version.shell)
for _, victim in ipairs(toDelete) do
love.filesystem.remove(PAYLOAD_DIR .. "/" .. victim)
end
if not chosen then return false end
return chainload(chosen, args)
end
-- Boot.run(args) -> boolean
--
-- The first line of love.load. True means a payload was mounted and
-- chainloaded and the caller must return immediately; false means boot the
-- bundled game as normal.
function Boot.run(args)
-- Dev / source checkouts never self-update.
if not (love.filesystem.isFused and love.filesystem.isFused()) then
return false
end
-- The chainloaded love.load calls Boot.run again; the flag makes it a no-op.
if _G.POKEPORT_PAYLOAD_MOUNTED then return false end
local ok, result = pcall(runInner, args)
if not ok then
-- An error escaped before any handoff mount (chainload cleans up after
-- itself), so state is still clean. Never crash the boot.
return false
end
return result
end
return Boot
+177
View File
@@ -0,0 +1,177 @@
-- Async release-check and payload-download for the self-update flow.
--
-- The heavy lifting (curl calls, sha256 verification, the Boot gate) happens
-- on a background love.thread worker (src/update/check_worker.lua); this module
-- is only the thin main-thread state machine the UI polls. Two channels carry
-- the conversation:
-- "update_check_cmd" main -> worker: { cmd = "check" | "download" | "quit" }
-- "update_check_state" worker -> main: { status, latest, progress, error }
--
-- Nothing here ever blocks or throws into the game loop: when love.thread is
-- absent (the headless test stub) or the worker cannot run (no curl, Android),
-- state() simply reports "error" and the UI hides itself. See the shared
-- contract in the task brief for the status vocabulary and the file layout.
--
-- The release-JSON extraction and the sums parsing are exported as pure
-- functions (no love.* calls) so plain-Lua tests can cover them, and so the
-- worker can reuse the exact same code path via love.filesystem.load.
local Check = {}
Check.REPO = "bryanthaboi/pokemon-gen1-recomp-project"
local CMD = "update_check_cmd"
local STATE = "update_check_state"
-- ---------------------------------------------------------------------------
-- pure helpers (no love.*) -- also used inside the worker
-- ---------------------------------------------------------------------------
-- Find the release asset named exactly `name`, returning its download URL and
-- byte size (or nil when the release has no such asset).
function Check.pickAsset(assets, name)
if type(assets) ~= "table" then return nil end
for _, a in ipairs(assets) do
if type(a) == "table" and a.name == name then
return { url = a.browser_download_url, size = tonumber(a.size) }
end
end
return nil
end
local function stripV(tag)
return (tostring(tag):gsub("^[vV]", ""))
end
-- Decode a GitHub "releases/latest" response into just the fields the updater
-- needs. Returns { version, payloadName, payload, sums } where payload/sums are
-- { url, size } tables (or nil when that asset is missing), or nil, err when the
-- document is not a release with a strict X.Y.Z tag. Json is injected so the
-- worker can pass a filesystem-loaded codec; on the main thread / in tests it
-- falls back to require.
function Check.parseRelease(jsonText, Json)
Json = Json or require("src.link.Json")
local doc = Json.decode(jsonText)
if type(doc) ~= "table" or not doc.tag_name then
return nil, "no tag_name in release json"
end
local version = stripV(doc.tag_name)
if not version:match("^%d+%.%d+%.%d+$") then
return nil, "release tag is not X.Y.Z: " .. tostring(doc.tag_name)
end
local payloadName = "gen1recomp-" .. version .. ".love"
return {
version = version,
payloadName = payloadName,
payload = Check.pickAsset(doc.assets, payloadName),
sums = Check.pickAsset(doc.assets, "sha256sums.txt"),
}
end
-- Parse a shasum -a 256 file ("<hex> <filename>", bare filenames). With a
-- `target` argument returns just that file's hash (or nil); otherwise returns
-- the whole name -> hash map. Tolerates the "*" binary marker and "./" prefix.
function Check.parseSums(text, target)
local map = {}
for line in tostring(text):gmatch("[^\r\n]+") do
local hash, file = line:match("^(%x+)%s+%*?(%S+)")
if hash and file then
map[(file:gsub("^%./", ""))] = hash:lower()
end
end
if target ~= nil then return map[target] end
return map
end
-- ---------------------------------------------------------------------------
-- main-thread state machine
-- ---------------------------------------------------------------------------
function Check.releaseUrl()
return "https://github.com/" .. Check.REPO .. "/releases/latest"
end
local worker -- the love.thread, once started
local cmdCh, stateCh -- the two channels
local workerReady -- nil = untried, true = running, false = unavailable
local requested -- a check has been asked for this session
local cache = { status = "idle" } -- newest snapshot from the worker
local function ensureWorker()
if workerReady ~= nil then return workerReady end
if not (love and love.thread and love.thread.newThread) then
workerReady = false
return false
end
local ok, th = pcall(love.thread.newThread, "src/update/check_worker.lua")
if not ok or not th then
workerReady = false
return false
end
cmdCh = love.thread.getChannel(CMD)
stateCh = love.thread.getChannel(STATE)
if not pcall(function() th:start() end) then
workerReady = false
return false
end
worker = th
workerReady = true
return true
end
-- Pull every pending snapshot off the state channel (keeping the newest) and
-- surface a worker crash as a soft error the UI can hide on.
local function drain()
if stateCh then
local msg = stateCh:pop()
while msg do
cache = msg
msg = stateCh:pop()
end
end
if worker then
local err = worker:getError()
if err then
cache = { status = "error", error = tostring(err) }
end
end
end
-- Begin (or, on a prior error, retry) an async check. Safe to call every frame:
-- once a check is in flight or has reached a terminal state it is a no-op.
function Check.start()
drain()
if cache.status == "checking" or cache.status == "downloading" then return end
if requested and cache.status ~= "error" and cache.status ~= "idle" then return end
if not ensureWorker() then
cache = { status = "error", error = "background threads unavailable" }
return
end
requested = true
cache = { status = "checking" }
cmdCh:push({ cmd = "check" })
end
-- Current snapshot: { status, latest, progress, error }. status is one of
-- idle | checking | uptodate | available | downloading | ready | needs_full | error.
function Check.state()
drain()
return {
status = cache.status or "idle",
latest = cache.latest,
progress = cache.progress,
error = cache.error,
}
end
-- Start downloading the payload announced by an "available" check. A no-op in
-- any other state (the worker still holds the release info from the check).
function Check.download()
drain()
if not cmdCh then return end
if cache.status ~= "available" then return end
cache = { status = "downloading", latest = cache.latest, progress = 0 }
cmdCh:push({ cmd = "download" })
end
return Check
+53
View File
@@ -0,0 +1,53 @@
-- Strict X.Y.Z semantic-version parsing and comparison for the self-updater.
-- Every part of the updater (Boot, Check, the release picker) agrees on this
-- one notion of "newer". Zero requires and no love.* calls, so plain-Lua
-- tests can exercise it and Boot can use it during the earliest boot step.
--
-- We only need the numeric core (major.minor.patch): the engine field is a
-- bare X.Y.Z in shipped builds and the "0.0.0-dev" placeholder in the working
-- tree. Pre-release / build metadata is intentionally not supported -- a
-- "-dev" or any other suffix makes parse fail, which is the safe answer for
-- the updater (a dev checkout never counts as a real release to chainload).
local Semver = {}
-- Parse a strict "X.Y.Z" string (an optional leading "v" is allowed) into
-- { major = n, minor = n, patch = n }. Returns nil for anything else --
-- extra components, non-numeric parts, or a trailing suffix like "-dev".
function Semver.parse(s)
if type(s) ~= "string" then return nil end
local body = s:match("^v?(.+)$")
if not body then return nil end
local maj, min, pat = body:match("^(%d+)%.(%d+)%.(%d+)$")
if not maj then return nil end
return {
major = tonumber(maj),
minor = tonumber(min),
patch = tonumber(pat),
}
end
-- Coerce an argument that is either an already-parsed table or a version
-- string into a parsed table (or nil).
local function coerce(v)
if type(v) == "table" then return v end
return Semver.parse(v)
end
-- Compare two versions, each a parsed table or an X.Y.Z string.
-- Returns -1 when a < b, 0 when equal, 1 when a > b. An unparseable side
-- sorts as the lowest possible version so a bogus value never wins a "newer"
-- test; two unparseable sides compare equal.
function Semver.compare(a, b)
local pa, pb = coerce(a), coerce(b)
if not pa and not pb then return 0 end
if not pa then return -1 end
if not pb then return 1 end
for _, field in ipairs({ "major", "minor", "patch" }) do
if pa[field] < pb[field] then return -1 end
if pa[field] > pb[field] then return 1 end
end
return 0
end
return Semver
+343
View File
@@ -0,0 +1,343 @@
-- Background worker for the self-update flow (driven by src/update/Check.lua).
--
-- Runs on a love.thread so no curl call, sha256 pass or archive probe ever
-- touches the render thread. Talks over two channels:
-- "update_check_cmd" in: { cmd = "check" | "download" | "quit" }
-- "update_check_state" out: { status, latest, progress, error }
--
-- Transport is curl shelled out via io.popen (curl ships on macOS, Windows 10+
-- and desktop Linux). Everything is wrapped so a missing curl, an HTTP error,
-- or a hung download degrades to a "error"/"needs_full" state rather than
-- blocking or crashing the game. On Android curl is absent and the check
-- soft-fails to "error", which the UI hides.
--
-- Fresh love threads do not carry the "src.*" package searcher, so sibling
-- modules are pulled in with love.filesystem.load exactly like
-- src/core/chip_worker.lua does. Semver and Boot are authored in parallel; we
-- load them defensively and degrade (a local semver fallback, a permissive
-- gate) if they are not present yet.
require("love.thread")
require("love.filesystem")
require("love.data")
require("love.timer")
require("love.system")
local function loadModule(path)
local ok, chunk = pcall(love.filesystem.load, path)
if not ok or type(chunk) ~= "function" then return nil end
local ok2, mod = pcall(chunk)
if not ok2 then return nil end
return mod
end
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")
-- 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
-- permanently permissive. Seed the loaded table first so it resolves.
if Semver then package.loaded["src.update.Semver"] = Semver end
local Boot = loadModule("src/update/Boot.lua")
local cmdCh = love.thread.getChannel("update_check_cmd")
local stateCh = love.thread.getChannel("update_check_state")
local function post(t) stateCh:push(t) end
local osName = (love.system and love.system.getOS and love.system.getOS()) or ""
local isWindows = osName == "Windows"
local saveDir = love.filesystem.getSaveDirectory()
local API_URL = "https://api.github.com/repos/bryanthaboi/pokemon-gen1-recomp-project/releases/latest"
-- the release picked by the last "check"; kept between commands so "download"
-- knows the payload url/size/name without re-fetching
local pending = nil
-- ---------------------------------------------------------------------------
-- shell / curl
-- ---------------------------------------------------------------------------
local function shq(s)
s = tostring(s)
if isWindows then
return '"' .. s:gsub('"', '') .. '"'
end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
-- run curl and return its response body (text), or nil on any failure. Used
-- for the small text resources (release JSON, sums file); -f makes curl exit
-- non-zero and emit nothing on an HTTP error, so an empty read is a failure.
local function curlCapture(url)
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
.. "-H " .. shq("User-Agent: gen1recomp-updater") .. " "
.. "-H " .. shq("Accept: application/vnd.github+json") .. " "
.. shq(url)
local ok, pipe = pcall(io.popen, cmd)
if not ok or not pipe then return nil end
local out = pipe:read("*a")
pipe:close()
if not out or out == "" then return nil end
return out
end
local function haveCurl()
local ok, pipe = pcall(io.popen, "curl --version")
if not ok or not pipe then return false end
local out = pipe:read("*a")
pipe:close()
return out ~= nil and out:find("curl", 1, true) ~= nil
end
-- ---------------------------------------------------------------------------
-- version compare (Semver per contract item 5, with a local fallback)
-- ---------------------------------------------------------------------------
local function parseTriple(s)
s = (tostring(s):gsub("^[vV]", ""))
local a, b, c = s:match("^(%d+)%.(%d+)%.(%d+)")
if not a then return nil end
return { tonumber(a), tonumber(b), tonumber(c) }
end
-- -1 | 0 | 1 for a<b | a==b | a>b
local function compareVersions(a, b)
if Semver and Semver.compare then
local ok, r = pcall(Semver.compare, a, b)
if ok and r ~= nil then return r end
end
local pa, pb = parseTriple(a), parseTriple(b)
if not pa or not pb then return 0 end
for i = 1, 3 do
if pa[i] ~= pb[i] then return pa[i] < pb[i] and -1 or 1 end
end
return 0
end
-- ---------------------------------------------------------------------------
-- verification and the shell gate
-- ---------------------------------------------------------------------------
local function sha256hex(data)
local digest = love.data.hash("sha256", data)
if type(digest) == "userdata" and digest.getString then
digest = digest:getString()
end
return love.data.encode("string", "hex", digest)
end
-- Confirm the save-dir file `rel` hashes to the sum listed for `payloadName`.
local function verifyPayload(rel, payloadName, sumsText)
local want = Check.parseSums(sumsText, payloadName)
if not want then return false, "no checksum for " .. payloadName end
local data = love.filesystem.read(rel)
if not data then return false, "cannot read downloaded payload" end
if sha256hex(data):lower() ~= want:lower() then
return false, "checksum mismatch"
end
return true
end
-- true = ok to run, false = payload needs a newer shell (needs_full). When Boot
-- cannot probe (module missing during parallel dev, or a probe failure) we allow
-- it: Boot.run's crash-guard handles a payload that turns out unrunnable.
local function gatePasses(rel)
if not (Boot and Boot.probePayload) then return true end
local info = Boot.probePayload(rel)
if not info then return true end
local shell = (Version and Version.shell) or 1
if info.minShell and info.minShell > shell then return false end
return true
end
-- ---------------------------------------------------------------------------
-- check
-- ---------------------------------------------------------------------------
local function doCheck()
post({ status = "checking" })
if not haveCurl() then
post({ status = "error", error = "curl not available" })
return
end
local body = curlCapture(API_URL)
if not body then
post({ status = "error", error = "release check failed" })
return
end
local rel, perr = Check.parseRelease(body, Json)
if not rel then
post({ status = "error", error = perr or "bad release json" })
return
end
pending = rel
-- Unstamped dev build: the working tree always looks "newer", so never
-- pester the developer with an update (contract item, Check design).
local currentEngine = (Version and Version.engine) or "0.0.0-dev"
if currentEngine == "0.0.0-dev" then
post({ status = "uptodate", latest = rel.version })
return
end
if compareVersions(rel.version, currentEngine) <= 0 then
post({ status = "uptodate", latest = rel.version })
return
end
-- A newer release, but without the .love payload or its sums we cannot do an
-- in-place update: send the user to the full installers.
if not (rel.payload and rel.payload.url and rel.sums and rel.sums.url) then
post({ status = "needs_full", latest = rel.version })
return
end
-- Already downloaded on a previous run? Verify and gate it rather than
-- pulling the bytes again.
local finalRel = "updates/" .. rel.payloadName
if love.filesystem.getInfo(finalRel) then
local sums = curlCapture(rel.sums.url)
if sums and verifyPayload(finalRel, rel.payloadName, sums) then
if gatePasses(finalRel) == false then
love.filesystem.remove(finalRel)
post({ status = "needs_full", latest = rel.version })
return
end
post({ status = "ready", latest = rel.version })
return
end
-- stale / corrupt: drop it and offer a fresh download
love.filesystem.remove(finalRel)
end
post({ status = "available", latest = rel.version })
end
-- ---------------------------------------------------------------------------
-- download
-- ---------------------------------------------------------------------------
-- Launch curl in the background writing `partAbs`, touching `doneAbs` when it
-- exits. Returns without waiting so the caller can poll the growing file for
-- progress. We deliberately do not capture curl's exit code: an incomplete or
-- 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"
love.filesystem.write(batRel,
"@echo off\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))
else
-- ( ... ) & backgrounds the whole group so os.execute returns at once
os.execute("( curl -fsSL --connect-timeout 15 --max-time 900 -o "
.. shq(partAbs) .. " " .. shq(url)
.. " ; touch " .. shq(doneAbs) .. " ) >/dev/null 2>&1 &")
end
end
local function doDownload()
if not (pending and pending.payload and pending.payload.url) then
post({ status = "error", error = "nothing to download" })
return
end
local rel = pending
post({ status = "downloading", latest = rel.version, progress = 0 })
love.filesystem.createDirectory("updates")
local partRel = "updates/" .. rel.payloadName .. ".part"
local doneRel = "updates/" .. rel.payloadName .. ".done"
local finalRel = "updates/" .. rel.payloadName
love.filesystem.remove(partRel)
love.filesystem.remove(doneRel)
local partAbs = saveDir .. "/updates/" .. rel.payloadName .. ".part"
local doneAbs = saveDir .. "/updates/" .. rel.payloadName .. ".done"
local size = rel.payload.size or 0
launchDownload(rel.payload.url, partAbs, doneAbs)
-- poll the .part size for progress until curl drops the done-marker; a
-- stalled or run-away transfer breaks out and lets verification fail cleanly
local waited, lastSize, lastChange = 0, -1, 0
while true do
if love.filesystem.getInfo(doneRel) then break end
local pinfo = love.filesystem.getInfo(partRel)
local cur = (pinfo and pinfo.size) or 0
if size > 0 then
local p = cur / size
if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready"
post({ status = "downloading", latest = rel.version, progress = p })
else
post({ status = "downloading", latest = rel.version })
end
if cur ~= lastSize then lastSize, lastChange = cur, waited end
if waited - lastChange > 60 then break end -- 60s with no growth: give up
if waited > 960 then break end -- absolute ceiling
love.timer.sleep(0.25)
waited = waited + 0.25
end
love.filesystem.remove(doneRel)
local sums = curlCapture(rel.sums and rel.sums.url or "")
if not sums then
love.filesystem.remove(partRel)
post({ status = "error", error = "checksum fetch failed" })
return
end
local ok, verr = verifyPayload(partRel, rel.payloadName, sums)
if not ok then
love.filesystem.remove(partRel)
post({ status = "error", error = verr or "verification failed" })
return
end
if gatePasses(partRel) == false then
love.filesystem.remove(partRel)
post({ status = "needs_full", latest = rel.version })
return
end
-- finalize: rename the verified .part to its real name (fall back to a
-- love.filesystem copy if os.rename is unavailable on this platform)
if not os.rename(partAbs, saveDir .. "/updates/" .. rel.payloadName) then
local data = love.filesystem.read(partRel)
if not data then
post({ status = "error", error = "finalize failed" })
return
end
love.filesystem.write(finalRel, data)
love.filesystem.remove(partRel)
end
post({ status = "ready", latest = rel.version })
end
-- ---------------------------------------------------------------------------
-- command loop
-- ---------------------------------------------------------------------------
while true do
local cmd = cmdCh:demand() -- blocks until the main thread pushes work
if type(cmd) == "table" then
if cmd.cmd == "quit" then
break
elseif cmd.cmd == "check" then
local ok, err = pcall(doCheck)
if not ok then post({ status = "error", error = tostring(err) }) end
elseif cmd.cmd == "download" then
local ok, err = pcall(doDownload)
if not ok then post({ status = "error", error = tostring(err) }) end
end
end
end
+10
View File
@@ -116,6 +116,16 @@ FieldDefaults.FIELD = {
badgeGates = { ROUTE_22_GATE = { passedFlag = "PASSED_ROUTE22_GATE" } },
-- VermilionGymSetDoorTile opens the motorized door once both locks are hit
hiddenExtras = {
-- PrintTrashText bins (#188); seeds stale caches missing the key
printTrash = {
SS_ANNE_KITCHEN = {
{ x = 13, y = 5, facing = "down" },
{ x = 13, y = 7, facing = "down" },
},
VERMILION_GYM = {
{ x = 6, y = 1, facing = "down" },
},
},
trashCans = { map = "VERMILION_GYM",
doorBlock = { bx = 2, by = 2, block = 5 } },
},
+6
View File
@@ -223,6 +223,12 @@ function Map:setBlock(bx, by, block)
self.def.blocks[by * self.def.width + bx + 1] = block
end
-- true if the cell's collision tile is a door tile
-- (pokered IsPlayerStandingOnDoorTile)
function Map:isDoorTileCell(cx, cy)
return self.doorTiles[self:cellTile(cx, cy)] or false
end
-- true if the cell's collision tile is a door or warp-activating tile
function Map:isWarpTileCell(cx, cy)
local t = self:cellTile(cx, cy)
+11 -2
View File
@@ -101,9 +101,18 @@ function NPC:walkPhase()
return (p >= 4 and p < 12) and 1 or 0
end
-- Same contract as Player:pose -- the sheet, position, facing and step
-- phase this frame renders to -- so a render pipeline can pose an NPC
-- without caring which kind of entity it is. An NPC never hops, so the
-- trailing hop flag is always false.
function NPC:pose()
return self.sprite, self.px, self.py, self.facing,
self:walkPhase(), self.stepFlip, false
end
function NPC:draw(camX, camY)
self.sprite:draw(self.px, self.py, camX, camY, self.facing,
self:walkPhase(), self.stepFlip)
local sprite, px, py, facing, phase, flip = self:pose()
sprite:draw(px, py, camX, camY, facing, phase, flip)
end
return NPC
+183 -40
View File
@@ -12,6 +12,7 @@ local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader")
local NPC = require("src.world.NPC")
local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local Player = require("src.world.Player")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
@@ -20,6 +21,7 @@ local Tilt = require("src.render.Tilt")
local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition")
local Warp = require("src.world.Warp")
local Zoom = require("src.render.Zoom")
-- isOverworld marks the live world state for WorldAPI's stack scan
local OverworldState = { isOpaque = true, isOverworld = true }
@@ -716,16 +718,13 @@ function OverworldState:update(dt)
end
if self.healAnim then
local ha = self.healAnim
local Music = require("src.core.Music")
if ha.jinglePlaying and not ha.jingleDone then
ha.jingleDone = not Music.oneShotPlaying()
end
local ev = OverworldState.stepHealAnim(ha)
if ev == "ball" then
require("src.core.Sound").play(Game.data, "Healing_Machine")
elseif ev == "jingle" then
ha.jinglePlaying = Music.playOnce(Game.data, "Music_PkmnHealed")
ha.jingleDone = not ha.jinglePlaying
-- playOnce restores the map theme when the jingle ends; we no longer
-- block the fighting-fit text on that (#157)
require("src.core.Music").playOnce(Game.data, "Music_PkmnHealed")
elseif ev == "done" then
local done = ha.onDone
self.healAnim = nil
@@ -1509,6 +1508,15 @@ function OverworldState:tryHiddenObject(fx, fy)
end
end
-- PrintTrashText: SS Anne kitchen + Vermilion Gym non-puzzle can
for _, h in ipairs(extras.printTrash and extras.printTrash[self.map.id] or {}) do
if h.x == fx and h.y == fy then
Game.stack:push(TextBox.new(Game, txt._VermilionGymTrashText
or "Nope, there's\nonly trash here."))
return true
end
end
-- the Vermilion Gym trash can lock puzzle
if self.map.id == "VERMILION_GYM" then
for _, h in ipairs(extras.trashCans.cans or {}) do
@@ -2188,13 +2196,10 @@ function OverworldState:dexRating()
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating))
end
-- AnimateHealingMachine (engine/overworld/healing_machine.asm): the
-- monitor lights, then one ball per party mon appears every 30 frames
-- (SFX_HEALING_MACHINE each); the healed jingle plays while the machine
-- sprites flash 8 times (an OBP1 xor every 10 frames), then a 32-frame
-- beat once the jingle ends. Pure per-frame step over the ha table
-- ({ balls, lit, timer, visible, jingleDone }); returns "ball"/"jingle"/
-- "done" when the caller must fire the matching side effect.
-- AnimateHealingMachine (engine/overworld/healing_machine.asm): balls
-- every 30 frames, then jingle + FlashSprite8Times (8 x 10). #157: skip
-- pokered's post-flash .waitLoop2 / DelayFrames 32 so fighting-fit is
-- immediate; jingle still plays and restoreMap runs when it ends.
function OverworldState.stepHealAnim(ha)
ha.timer = ha.timer + 1
ha.phase = ha.phase or "balls"
@@ -2217,17 +2222,11 @@ function OverworldState.stepHealAnim(ha)
ha.visible = not ha.visible
ha.flashes = ha.flashes + 1
if ha.flashes >= 8 then
ha.phase = "wait"
ha.visible = true
ha.phase = "done"
return "done"
end
end
elseif ha.phase == "wait" then
-- .waitLoop2: hold until the jingle ends, then 32 more frames
if not ha.jingleDone then
ha.timer = 0
elseif ha.timer >= 32 then
return "done"
end
end
end
@@ -2403,19 +2402,34 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
Commands.hide_object(ctx, entry[1], entry[2])
end
end
local lines = {}
if reward.badge then
Game.save.inventory[reward.badge] = 1
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
or reward.badge
table.insert(lines, ("%s received\nthe %s!"):format(Game.save.player.name, name))
end
if reward.item then
local inv = Game.save.inventory
inv[reward.item] = (inv[reward.item] or 0) + 1
local name = Game.data.items[reward.item] and Game.data.items[reward.item].name
or reward.item
table.insert(lines, ("%s received\n%s!"):format(Game.save.player.name, name))
local idef = Game.data.items[reward.item]
-- GiveItem -> CopyToStringBuffer for "{RAM:wStringBuffer}" received texts
Game.stringBuffer = idef and idef.name or reward.item
end
local lines = {}
if reward.dialogue then
local text = Game.data.text or {}
for _, label in ipairs(reward.dialogue) do
if text[label] and text[label] ~= "" then
table.insert(lines, text[label])
end
end
elseif reward.badge or reward.item then
if reward.badge then
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
or reward.badge
table.insert(lines, ("%s received\nthe %s!"):format(Game.save.player.name, name))
end
if reward.item then
local name = Game.stringBuffer or reward.item
table.insert(lines, ("%s received\n%s!"):format(Game.save.player.name, name))
end
end
if #lines > 0 then
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f")))
@@ -2437,6 +2451,8 @@ end
-- interposed NPCs / walls -- but unsigned 8-bit Y makes a sprite exactly 4
-- tiles north of the player sit at Y=$fc, so |$3c-$fc|=$c0 and a range-4
-- DOWN trainer does not engage that tile (Route 9 Bug Catcher / issue #76).
-- Off-screen sprites (IMAGEINDEX=$ff) never engage: without that gate, the
-- same 8-bit wrap makes far same-row trainers look in-range (#153/#183).
local PLAYER_SCREEN_X, PLAYER_SCREEN_Y = 0x40, 0x3c
local function u8(n) return n % 256 end
local function calcDiff(a, b)
@@ -2452,6 +2468,14 @@ local function trainerSightPixelDist(npc, player, horizontal)
u8(PLAYER_SCREEN_Y + (npc.cellY - player.cellY) * 16))
end
-- CheckSpriteAvailability (movement.asm): wXCoord/wYCoord = player - 4;
-- visible when sprite is in [wCoord, wCoord + SCREEN_*/2 - 1] (GB 10x9).
local function trainerSpriteOnScreen(npc, player)
local dx = npc.cellX - player.cellX
local dy = npc.cellY - player.cellY
return dx >= -4 and dx <= 5 and dy >= -4 and dy <= 4
end
-- STAY trainers with a facing spot the player crossing their line of
-- sight (range from the extracted trainer headers), walk up and battle.
function OverworldState:checkTrainerSight()
@@ -2464,7 +2488,8 @@ function OverworldState:checkTrainerSight()
-- walkers included (they sight between steps)
if d.trainerClass and not npc.moving
and not self:trainerDefeated(npc)
and not mapScripts.talkScript(self.map.id, d.text) then
and not mapScripts.talkScript(self.map.id, d.text)
and trainerSpriteOnScreen(npc, p) then
local header = Game.data:trainerHeader(self.map.def.label, d.index)
local range = header and header.range or 0
local vec = DIRVEC[npc.facing]
@@ -3208,7 +3233,7 @@ function OverworldState:takeWarp(warpDef)
self:startWarpTo(destMap, x, y, facing)
return
end
self.doorWarp = true -- door SFX + outdoor walk-out step
self.doorWarp = true -- door SFX + PlayerStepOutFromDoor walk-out
self:startWarpTo(destMap, x, y, facing)
end
@@ -3293,12 +3318,12 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
local outdoor = Map.isOutdoor(self.map.def)
require("src.core.Sound").play(Game.data,
outdoor and "Go_Outside" or "Go_Inside")
-- stepping out of an outdoor door/cave entrance (the original's
-- walk-out). Auto-walk leaves the mat, so the arrival disable
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
-- warp that lands on a door tile auto-steps south once, indoor or
-- outdoor. Auto-walk leaves the mat, so the arrival disable
-- (warpEntryCell / justWarped) is unnecessary -- and would let you
-- stand on the door without re-entering if you hold back into it.
if outdoor and self.player.facing == "down"
and self.map:isWarpTileCell(self.player.cellX, self.player.cellY) then
if self.map:isDoorTileCell(self.player.cellX, self.player.cellY) then
self.warpEntryCell = nil
self.justWarped = false
self:scriptMove(self.player, "down", 1)
@@ -3539,11 +3564,22 @@ function OverworldState:drawWorld()
-- tilt is active). So the ground draw calls below never change with tilt;
-- only the sprite/FX draw path below them branches. The sorts below only
-- reorder (no draws), so they run once for both paths.
local tilt = Tilt.active()
self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh)
self.map.renderer:draw(cam.x, bgY, vw, vh)
for _, nb in ipairs(self.neighbors) do
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
-- A render pipeline (src/render/Pipelines.lua) replaces the ground draw
-- entirely with geometry of its own, so it is decided before tilt and
-- wins over it. It falls back to the tilt/flat path whenever it cannot
-- run this frame -- headless, a driver with no depth canvas, or a mod
-- that threw -- so no caller ever sees a blank frame.
local pipelineId = Pipelines.worldPipeline()
local tilt = (not pipelineId) and Tilt.active()
-- the pipeline's finished world image, once it has run; nil keeps every
-- path below on the vanilla flat/tilt draw
local override
if not pipelineId then
self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh)
self.map.renderer:draw(cam.x, bgY, vw, vh)
for _, nb in ipairs(self.neighbors) do
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
end
end
-- per-billboard SGB palette source; only needed (and only paid for) when
-- tilting. nil headless / on stale palettes -> billboards go uncolorized.
@@ -3774,7 +3810,114 @@ function OverworldState:drawWorld()
end
end
if not tilt then
if pipelineId then
-- === PIPELINE PATH: a mod owns the world pass. ======================
-- It renders terrain and characters however it likes and hands back one
-- window-resolution image; the field FX stay ordinary 2D draws
-- composited on top by ctx.drawFx, each anchored to where its ground
-- point projects under the pipeline's own camera. That is the direct
-- analogue of what :billboard does for tilt, and it keeps exactly one
-- copy of every effect: the closures above are the ones that run.
local pw, ph = love.graphics.getDimensions()
local pscale = Zoom.scale(Game.renderer:fitScale())
local ctx = {
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
width = pw, height = ph, scale = pscale,
level = Pipelines.level(pipelineId),
-- the SGB world palette a map draws under; nil in the true-colour
-- modes, whose art is already baked (and must not be re-mapped)
paletteFor = function(map)
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
end,
spriteColors = function(map)
if PaletteFX.usesGbcPack() then return nil end
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
end,
fx = { heal = fxHeal, dust = fxDust, cutTree = fxCutTree,
emote = fxEmote, dark = fxDark, bird = fxBird, rod = fxRod },
}
-- Draw every active field FX into the finished scene. `project(wx, wy)`
-- maps a world point to canvas pixels (nil when it is behind the
-- camera) and `scale` is canvas pixels per world pixel; the pipeline
-- owns the camera, this owns where each effect belongs and how the
-- closures' flat coordinates are slid onto the projected anchor.
-- Deliberately unscaled by depth, like :billboard: an effect keeps its
-- crisp authored size and only its anchor moves.
ctx.drawFx = function(project, scale)
scale = scale or pscale
local colors = ctx.spriteColors()
local function at(drawFn, wx, wy)
if not drawFn then return end
local sx, sy = project(wx, wy)
if not sx then return end -- behind the camera
local shader = colors and PaletteFX.shader() or nil
if shader then
PaletteFX.sendColors(shader, colors)
love.graphics.setShader(shader)
end
-- the closures draw relative to the flat foot; slide that onto the
-- projected anchor, in world-pixel units inside the scaled transform
local fx, fy = wx - cam.x, wy - cam.y
love.graphics.push()
love.graphics.scale(scale, scale)
love.graphics.translate(sx / scale - fx, sy / scale - fy)
drawFn()
love.graphics.pop()
if shader then love.graphics.setShader() end
end
-- ground-hugging effects sit on the cell they belong to
if self.dustAnim then
at(fxDust, self.dustAnim.x * 16 + 8, self.dustAnim.y * 16 + 8)
end
if self.cutAnim then
at(fxCutTree, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16)
end
if self.healAnim then
at(fxHeal, self.healAnim.px + 8, self.healAnim.py + 16)
end
-- standing effects anchor at the foot of whoever they belong to
if self.emote and self.emote.npc then
at(fxEmote, self.emote.npc.px + 8, self.emote.npc.py + 16)
end
if self.flyAnim then
at(fxBird, self.player.px + 8, self.player.py + 16)
end
if self.fishing then
at(fxRod, self.player.px + 8, self.player.py + 16)
end
-- Rock Tunnel darkness is a screen-space light window, not a ground
-- object: draw it flat over the finished scene like the tilt path.
-- It fills the view in world-pixel units, so it only needs the scale.
if self.dark then
love.graphics.push()
love.graphics.scale(scale, scale)
fxDark()
love.graphics.pop()
end
end
override = Pipelines.drawWorld(pipelineId, ctx)
-- world post-processes (a miniature-diorama blur, a colour grade) fold
-- over the finished scene here, so they never touch the UI drawn on top
if override then
override = Pipelines.worldPresent(override, ctx)
end
Game.renderer:setWorldOverride(override)
if not override then
-- The pipeline declined this frame (nothing to draw, or it threw and
-- was retired). The ground pass was skipped on its behalf above, so
-- draw it now and fall through to the flat path below rather than
-- compositing an empty canvas.
self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh)
self.map.renderer:draw(cam.x, bgY, vw, vh)
for _, nb in ipairs(self.neighbors) do
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
end
end
end
if override then
-- the pipeline owns the whole frame; nothing else draws into the world
elseif not tilt then
-- === FLAT PATH: everything into the one world canvas, as before =====
-- OBP-baked sprites replay after the zone pass in GBC mode, so their
-- grass feet-overdraw must replay over them too, colorized with the
+34 -16
View File
@@ -140,28 +140,29 @@ end
local SPIN_ORDER = { "down", "left", "up", "right" }
function Player:draw(camX, camY)
-- What this frame renders to: the sheet, where it sits, which way it faces
-- and how far through a step it is. Shared by the 2D draw below and by a
-- render pipeline's own geometry (src/render/Pipelines.lua), so the two can
-- never disagree about which sprite or facing is current.
--
-- The last return says the player is mid-ledge-hop, which is what the 2D
-- path draws the ground shadow from and a 3D path turns into vertical lift.
--
-- This ADVANCES the surf-bob and spinner timers, so exactly one of pose()
-- and draw() may run per frame -- and draw() is written in terms of pose()
-- to keep that true by construction. (hopFrames counts down in
-- Player:update, on the fixed step, so it is safe to read here.)
function Player:pose()
local py = self.py
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs.
-- hopFrames counts down in Player:update (fixed step), never here.
local hopping = false
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs
if self.hopFrames and self.hopFrames > 0 then
local total = self.hopTotal or 32
-- update runs before draw, so remaining N means N steps already
-- consumed this hop → t matches the old draw-side post-decrement phase
local t = 1 - self.hopFrames / total
py = py - math.floor(10 * math.sin(t * math.pi) + 0.5)
-- the shadow stays on the ground under the jumper: one 8x8 tile
-- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left
-- is 8px below the sprite's standing top-left (LoadHoppingShadowOAM +
-- LedgeHoppingShadowOAMBlock, engine/overworld/ledges.asm)
if self.shadowImg then
local sx = math.floor(self.px - camX)
local sy = math.floor(self.py - camY) - 4 + 8
love.graphics.draw(self.shadowImg, sx, sy)
love.graphics.draw(self.shadowImg, sx + 16, sy, 0, -1, 1)
love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1)
love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1)
end
hopping = true
elseif self.surfing then
self.bobTimer = ((self.bobTimer or 0) + 1) % 32
py = py + (self.bobTimer < 16 and 0 or 1)
@@ -186,7 +187,24 @@ function Player:draw(camX, camY)
end
local sprite = (self.surfing and self.surfSprite)
or (self.onBike and self.bikeSprite) or self.sprite
sprite:draw(self.px, py, camX, camY, facing, phase, flip)
return sprite, self.px, py, facing, phase, flip, hopping
end
function Player:draw(camX, camY)
local sprite, px, py, facing, phase, flip, hopping = self:pose()
-- the shadow stays on the ground under the jumper: one 8x8 tile
-- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left
-- is 8px below the sprite's standing top-left (LoadHoppingShadowOAM +
-- LedgeHoppingShadowOAMBlock, engine/overworld/ledges.asm)
if hopping and self.shadowImg then
local sx = math.floor(self.px - camX)
local sy = math.floor(self.py - camY) - 4 + 8
love.graphics.draw(self.shadowImg, sx, sy)
love.graphics.draw(self.shadowImg, sx + 16, sy, 0, -1, 1)
love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1)
love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1)
end
sprite:draw(px, py, camX, camY, facing, phase, flip)
end
return Player
+62
View File
@@ -0,0 +1,62 @@
-- S.S. Anne 1F cabins: ROM packs rooms so hallway doors map right→left
-- onto the 3×2 rooms map (warp 1 = top-left = rightmost door). Survey
-- zoom makes that look left/right flipped vs 2F/B1F. Reorder door→cell
-- and slide objects so each door keeps its OG occupants while L→R doors
-- land in reading order on the rooms map.
local SsAnneLayout = {}
local function leftmostRoomDoor(hall)
local best
for _, w in ipairs(hall.warps or {}) do
if w.destMap == "SS_ANNE_1F_ROOMS" then
if not best or w.x < best.x then best = w end
end
end
return best
end
function SsAnneLayout.apply(maps)
if not maps then return false end
local hall = maps.SS_ANNE_1F
local rooms = maps.SS_ANNE_1F_ROOMS
if not hall or not rooms then return false end
local left = leftmostRoomDoor(hall)
-- already hallway-ordered (leftmost door → rooms warp 1)
if not left or left.destWarp == 1 then return false end
local doors = {}
for _, w in ipairs(hall.warps) do
if w.destMap == "SS_ANNE_1F_ROOMS" then doors[#doors + 1] = w end
end
table.sort(doors, function(a, b) return a.x < b.x end)
for i, w in ipairs(doors) do w.destWarp = i end
-- rooms warps 1..6 return through hall warps 8..3 (L→R cabin doors)
for i, w in ipairs(rooms.warps or {}) do
if w.destMap == "SS_ANNE_1F" then w.destWarp = 9 - i end
end
local warpPos = {}
for i, w in ipairs(rooms.warps or {}) do
warpPos[i] = { x = w.x, y = w.y }
end
for _, o in ipairs(rooms.objects or {}) do
local col, row = math.floor(o.x / 10), math.floor(o.y / 10)
local oldK
for k, p in ipairs(warpPos) do
if math.floor(p.x / 10) == col and math.floor(p.y / 10) == row then
oldK = k
break
end
end
if oldK then
local newK = 7 - oldK
o.x = o.x + (warpPos[newK].x - warpPos[oldK].x)
o.y = o.y + (warpPos[newK].y - warpPos[oldK].y)
end
end
return true
end
return SsAnneLayout