fix merge conflicts with dev

This commit is contained in:
1jamie
2026-08-13 07:39:11 -05:00
60 changed files with 1900 additions and 251 deletions
+18 -18
View File
@@ -1619,26 +1619,26 @@ function Battle:useMove(attacker, defender, moveId)
return
end
-- Damage that skips the formula entirely (constantdamage).
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random, def)
-- Damage that skips the formula entirely. The move's own power goes with
-- it: EFFECT_STATIC_DAMAGE's arm of BattleCommand_ConstantDamage reads
-- BATTLE_VARS_MOVE_POWER as the damage (effect_commands.asm:3157-3161).
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random,
def.power)
if fixed then
-- StaticDamage's effect script runs resettypematchup after constantdamage
-- (data/moves/effects.asm): immunities still miss (Sonic Boom vs Ghost).
-- Flat damage is otherwise unscaled — no STAB / effectiveness multiply.
if def.effect == "EFFECT_STATIC_DAMAGE" then
local defTypes = (self:speciesDef(defender) or {}).types or defender.types
local matchups = self.data.type_chart and self.data.type_chart.matchups
local mult = Damage.typeMultiplier(def.type, defTypes, matchups)
if mult == 0 then
self:markMissed()
self:emit({ kind = "message",
text = "It doesn't affect " .. self:monName(defender) .. "..." })
return
end
-- The constant-damage effect list carries `resettypematchup` instead of
-- `stab`, and that command misses the move outright when the matchup byte
-- is 0 (effect_commands.asm:1480-1493) -- an immune target is the one
-- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG.
local defenderTypes = (self:speciesDef(defender) or {}).types
or defender.types
local matchups = self.data.type_chart and self.data.type_chart.matchups
if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then
self:markMissed()
self:emit({ kind = "message",
text = "It doesn't affect " .. self:monName(defender) .. "..." })
return
end
self:dealDamage(attacker, defender, fixed, {
move = def, moveId = moveId, effectiveness = 10,
})
self:dealDamage(attacker, defender, fixed, { move = def, moveId = moveId })
return
end
+12 -8
View File
@@ -179,10 +179,9 @@ end
-- --------------------------------------------------------------- fixed damage
-- BattleCommand_ConstantDamage / LevelDamage / SuperFang / Psywave /
-- StaticDamage (Sonic Boom, Dragon Rage): skip the damage formula entirely.
-- `move` is optional; EFFECT_STATIC_DAMAGE reads move.power (20 / 40).
function Effects.fixedDamage(effect, attacker, defender, random, move)
-- BattleCommand_ConstantDamage (engine/battle/effect_commands.asm:3131-3205),
-- one command for the whole SuperFang / Psywave / StaticDamage list.
function Effects.fixedDamage(effect, attacker, defender, random, power)
if effect == "EFFECT_LEVEL_DAMAGE" then
return math.max(1, attacker.level or 1)
end
@@ -190,12 +189,17 @@ function Effects.fixedDamage(effect, attacker, defender, random, move)
return math.max(1, math.floor((defender.hp or 1) / 2))
end
if effect == "EFFECT_PSYWAVE" then
-- 1..(level * 1.5), rerolled until it is in range; one roll is enough here.
local ceiling = math.max(1, math.floor((attacker.level or 1) * 3 / 2))
return math.max(1, (random and random(ceiling) or 0) + 1)
-- .psywave rerolls until the byte is nonzero AND below level * 1.5, so the
-- top of the range is that ceiling minus one (effect_commands.asm:3163).
local ceiling = math.max(2, math.floor((attacker.level or 1) * 3 / 2))
return math.max(1, (random and random(ceiling - 1) or 0) + 1)
end
-- SONIC BOOM and DRAGON RAGE share EFFECT_STATIC_DAMAGE, whose arm reads
-- BATTLE_VARS_MOVE_POWER straight into the damage word: their stored power
-- (20 and 40) IS the damage, never a formula input
-- (effect_commands.asm:3157-3161).
if effect == "EFFECT_STATIC_DAMAGE" then
return math.max(1, (move and move.power) or 1)
return math.max(1, math.floor(power or 0))
end
return nil
end
+12
View File
@@ -313,6 +313,16 @@ local function equalData(a, b)
return okA and okB and encodedA == encodedB
end
local function normalizeVerificationMetadata(actual, expected)
if type(actual) == "table" and type(actual.identity) == "table"
and type(expected) == "table" and type(expected.identity) == "table" then
-- RFC 0004 treats engineVersion as compatibility metadata, not runtime
-- state. A fresh recapture stamps the running engine, so normalize only
-- that metadata before differential verification.
actual.identity.engineVersion = expected.identity.engineVersion
end
end
local function firstDifference(a, b, path)
path = path or "$"
if type(a) ~= type(b) then return path .. " (type)" end
@@ -394,6 +404,7 @@ function Checkpoint.restore(game, checkpoint)
if ok then
local restored, verifyCode = Checkpoint.capture(game)
if restored and validated.rng == nil then restored.rng = nil end
normalizeVerificationMetadata(restored, validated)
if restored and equalData(restored, validated) then
emitRestored(game, validated)
return true
@@ -474,6 +485,7 @@ function Checkpoint.resume(game, checkpoint)
if ok then
local restored, verifyCode = Checkpoint.capture(game)
if restored and validated.rng == nil then restored.rng = nil end
normalizeVerificationMetadata(restored, validated)
if restored and equalData(restored, validated) then
emitRestored(game, validated)
return true
+41
View File
@@ -0,0 +1,41 @@
-- Optional native-host display lifecycle.
--
-- The engine always owns simulation and drawing. A packaged host may install
-- one backend to observe per-frame updates and prepare/finalize a render target
-- around an otherwise unchanged draw. With no backend installed every method
-- is a no-op, which is the normal desktop and mobile path.
--
-- Backend methods are optional:
-- backend:update(dt)
-- backend:beginFrame(kind, subject)
-- backend:endFrame(kind, subject)
--
-- `kind` is "editor", "touch_editor", "launcher", or "game". `subject` is
-- the object whose existing draw method runs between beginFrame and endFrame.
local HostDisplay = {}
local backend
function HostDisplay.setBackend(value)
if value ~= nil and type(value) ~= "table" then
error("host display backend must be a table or nil", 2)
end
backend = value
end
function HostDisplay.update(dt)
local fn = backend and backend.update
if fn then return fn(backend, dt) end
end
function HostDisplay.beginFrame(kind, subject)
local fn = backend and backend.beginFrame
if fn then return fn(backend, kind, subject) end
end
function HostDisplay.endFrame(kind, subject)
local fn = backend and backend.endFrame
if fn then return fn(backend, kind, subject) end
end
return HostDisplay
+31 -8
View File
@@ -335,11 +335,11 @@ function SaveData.defaultOptions()
-- TouchControls.normalizeConfig folds it into both orientations on load.
touchControls = { enabled = true },
-- Haptic feedback level for on-screen pad presses (#806):
-- off | light | medium | heavy, mapped to a love.system.vibrate
-- duration in src/core/TouchControls.lua. LIGHT by default, like the
-- overlay itself defaulting on, so an options.lua predating this key
-- gets the tick without going looking for the row. Inert wherever the
-- overlay never appears (desktop) or LOVE has no vibrator.
-- off | light | normal | strong, mapped to a love.system.vibrate duration
-- in src/core/TouchControls.lua. LIGHT by default, so an options.lua
-- predating this key gets the tick without going looking for the row.
-- Inert wherever the overlay never appears (desktop) or LOVE has no
-- vibrator.
haptics = "light",
-- Shared UI/mod timestamp presentation. DEVICE follows the process time
-- locale where the platform exposes one; otherwise DateTime falls back to
@@ -818,9 +818,19 @@ end
function SaveData.slotSummary(save)
if type(save) ~= "table" then return nil, nil end
local name = save.player and save.player.name or nil
-- A Gen 2 slot carries no badge items and fills pokedex.caught, so both
-- counts come off wJohtoBadges/wPokedexCaught (engine/menus/intro_menu.asm:461).
local vinfo = type(save.version) == "string" and GameVersion.info(save.version)
local gen2 = save.generation == 2 or (vinfo and vinfo.generation == 2) or false
local dexCount = 0
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
dexCount = dexCount + 1
if gen2 then
for _, has in pairs((save.pokedex and save.pokedex.caught) or {}) do
if has then dexCount = dexCount + 1 end
end
else
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
dexCount = dexCount + 1
end
end
-- playTime is a plain seconds count in a Gen 1 save but a
-- { hours, minutes, seconds, frames } table in a Gen 2 (Gold) save, matching
@@ -838,8 +848,21 @@ function SaveData.slotSummary(save)
end
local timeText = ("%d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60)
local badges
if gen2 then
-- Continue_DisplayBadgeCount walks TWO bytes, Johto then Kanto
-- (engine/menus/intro_menu.asm:461-469).
badges = 0
local p = save.player or {}
for _, has in pairs(p.badges or {}) do if has then badges = badges + 1 end end
for _, has in pairs(p.kantoBadges or {}) do
if has then badges = badges + 1 end
end
else
badges = Badges.count(nil, save)
end
return name, {
badges = Badges.count(nil, save),
badges = badges,
timeText = timeText,
dexCount = dexCount,
}
+9 -5
View File
@@ -105,17 +105,21 @@ end
-- would be dropped on every editor save.
-- love.system.vibrate takes a duration and nothing else, so "intensity" is a
-- duration preset: Android runs the platform vibrator for exactly that long,
-- while iOS ignores the duration and fires the fixed system vibration, so
-- there the three levels all read as simply on.
TouchControls.HAPTICS = { "off", "light", "medium", "heavy" }
-- while iOS maps each duration to a matching Taptic Engine impact.
TouchControls.HAPTICS = { "off", "light", "normal", "strong" }
TouchControls.HAPTIC_DEFAULT = "light"
local HAPTIC_SECONDS = { off = 0, light = 0.012, medium = 0.025, heavy = 0.045 }
local HAPTIC_SECONDS = {
off = 0, light = 0.012, normal = 0.025, strong = 0.045,
}
local HAPTIC_LABELS = {
off = "OFF", light = "LIGHT", medium = "MEDIUM", heavy = "HEAVY",
off = "OFF", light = "LIGHT", normal = "NORMAL", strong = "STRONG",
}
function TouchControls.normalizeHaptics(level)
if level == "physical" then return "light" end
if level == "medium" then return "normal" end
if level == "heavy" then return "strong" end
if HAPTIC_SECONDS[level] then return level end
return TouchControls.HAPTIC_DEFAULT
end
+3
View File
@@ -8,6 +8,9 @@ local Version = {
-- "-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
payloadHost = "love", -- native host family for in-place Lua payloads.
-- A payload must name the same family; this prevents
-- mounting code packaged for a different native host.
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
+5
View File
@@ -764,9 +764,14 @@ end
function Save.summary(save)
if type(save) ~= "table" then return nil end
local badges = 0
-- Continue_DisplayBadgeCount counts TWO bytes, wJohtoBadges then wKantoBadges
-- (engine/menus/intro_menu.asm:461-469).
for _, has in pairs(save.player and save.player.badges or {}) do
if has then badges = badges + 1 end
end
for _, has in pairs(save.player and save.player.kantoBadges or {}) do
if has then badges = badges + 1 end
end
local caught = 0
for _, has in pairs(save.pokedex and save.pokedex.caught or {}) do
if has then caught = caught + 1 end
+67 -56
View File
@@ -62,6 +62,68 @@ local FILTERS = { "OFF", "1X", "2X", "3X" }
-- The core rows. Helper modules are required lazily under pcall: they are
-- pure label/cycle tables, but the launcher must never die because a render
-- module grew a dependency on live game data.
-- TOUCH PAD, VIBRATION and the layout editor, shared by both row sets.
--
-- Gold reads these out of its own `gold` block (src/core/gen2/Save.lua:297),
-- so `opts` is whichever table the gear is editing and the rows never have to
-- know which game they belong to. #1100 / #1135: the Gold gear carried none
-- of them, so a phone player could turn the pad and the buzz off in Red and
-- had no way to reach either in Gold.
local function addTouchRows(rows, add, opts, hooks)
-- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's
-- gate (mobile, or desktop forced by POKEPORT_TOUCH=1).
local env = os.getenv("POKEPORT_TOUCH")
local osName = love.system and love.system.getOS and love.system.getOS()
local show = env == "1"
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
if show then
add(Strings("TOUCH PAD"),
function()
local tc = opts.touchControls
local on = not (type(tc) == "table" and tc.enabled == false)
return on and Strings("ON") or Strings("OFF")
end,
function()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled = tc.enabled == false
opts.touchControls = tc
return true
end)
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
-- the row buzzes once at the level being selected.
local okTC, TC = pcall(require, "src.core.TouchControls")
if okTC then
add(Strings("VIBRATION"),
function() return Strings(TC.hapticLabel(opts.haptics)) end,
function(dir)
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
TC.buzz(opts.haptics)
return true
end)
end
end
-- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a
-- button on the game panel, once per game -- but the overlay layout is
-- global (options.touchControls.layouts), so three tabs offered three
-- buttons that edited the same thing while crowding the column that has to
-- hold Play. It belongs with the other control rows, behind the gear.
-- The host owns the editor screen, so the row only fires when a hook was
-- supplied (the standalone save editor opens this model with none).
if hooks and hooks.editTouchControls then
rows[#rows + 1] = {
label = Strings("TOUCH CONTROLS"),
actionLabel = Strings("Edit"),
action = function()
hooks.editTouchControls()
-- The editor replaces the whole screen: nothing left to persist here
-- beyond what the caller already saved on the way out.
return false
end,
}
end
end
local function coreRows(opts, hooks)
local rows = {}
local function add(label, value, step)
@@ -236,60 +298,7 @@ local function coreRows(opts, hooks)
end)
end
-- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's
-- gate (mobile, or desktop forced by POKEPORT_TOUCH=1).
do
local env = os.getenv("POKEPORT_TOUCH")
local osName = love.system and love.system.getOS and love.system.getOS()
local show = env == "1"
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
if show then
add(Strings("TOUCH PAD"),
function()
local tc = opts.touchControls
local on = not (type(tc) == "table" and tc.enabled == false)
return on and Strings("ON") or Strings("OFF")
end,
function()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled = tc.enabled == false
opts.touchControls = tc
return true
end)
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
-- the row buzzes once at the level being selected.
local okTC, TC = pcall(require, "src.core.TouchControls")
if okTC then
add(Strings("VIBRATION"),
function() return Strings(TC.hapticLabel(opts.haptics)) end,
function(dir)
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
TC.buzz(opts.haptics)
return true
end)
end
end
end
-- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a
-- button on the game panel, once per game -- but the overlay layout is
-- global (options.touchControls.layouts), so three tabs offered three
-- buttons that edited the same thing while crowding the column that has to
-- hold Play. It belongs with the other control rows, behind the gear.
-- The host owns the editor screen, so the row only fires when a hook was
-- supplied (the standalone save editor opens this model with none).
if hooks and hooks.editTouchControls then
rows[#rows + 1] = {
label = Strings("TOUCH CONTROLS"),
actionLabel = Strings("Edit"),
action = function()
hooks.editTouchControls()
-- The editor replaces the whole screen: nothing left to persist here
-- beyond what the caller already saved on the way out.
return false
end,
}
end
addTouchRows(rows, add, opts, hooks)
-- RESET REBINDS, directly under the touch-pad row. Rebinds are additive
-- (src/core/Input.lua:applyBindings layers options.bindings over the
@@ -462,7 +471,7 @@ end
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
local GEN2_KEY = "gold"
local function gen2Rows(opts)
local function gen2Rows(opts, hooks)
local rows = {}
local function add(label, value, step)
rows[#rows + 1] = { label = label, value = value, step = step }
@@ -551,6 +560,8 @@ local function gen2Rows(opts)
end)
end
addTouchRows(rows, add, opts, hooks)
return rows
end
@@ -574,7 +585,7 @@ function LauncherSettings.open(hooks, version)
opts[GEN2_KEY] = block
end
sections = {
{ title = Strings("OPTIONS"), rows = gen2Rows(block) },
{ title = Strings("OPTIONS"), rows = gen2Rows(block, hooks) },
}
else
sections = {
+11 -1
View File
@@ -2652,11 +2652,14 @@ function RomImporter:_openSettings()
-- hook rather than reaching for main.lua's handler itself. Closing the
-- settings panel FIRST persists the pending edits (_closeSettings saves)
-- and leaves no modal behind the editor to return to.
-- The tab rides along: the editor persists the layout into that game's own
-- option block, and Gold's is not the flat Gen 1 one (#1100).
local hooks = {}
if self.onEditTouchControls then
local version = self.tab
hooks.editTouchControls = function()
self:_closeSettings()
self.onEditTouchControls()
self.onEditTouchControls(version)
end
end
-- The tab the gear was opened on decides the row set: Gold reads a
@@ -2741,6 +2744,13 @@ function RomImporter:keypressed(key)
end
if self._modConfirm or self._modVersions or self._modReleaseNotes
or self._findDetails then
-- Focus navigation belongs to the visible modal as well as the launcher
-- beneath it. Route arrows and an already-armed confirm before this guard
-- returns; unarmed Enter still falls through to the modal guard. Keep this
-- inside the modal branch so text fields retain exclusive keyboard input.
if self._flex and require("src.import.LauncherView").keypressed(self, key) then
return
end
if key == "escape" then
if self._findDetails then
self._findDetails = nil
+4 -1
View File
@@ -266,7 +266,10 @@ function OakSpeech.new(game, onDone)
-- RedSprite: the walking sprite the pic shrinks into (frame 0 =
-- standing, facing down)
local playerSprites = (game.data.field and game.data.field.playerSprites) or {}
local red = game.data.sprites and game.data.sprites[playerSprites.walk or "SPRITE_RED"] or game.data.sprites.SPRITE_RED
-- The fallback has to read the same guarded table: reaching for
-- game.data.sprites.SPRITE_RED after the `and` already found it nil threw.
local sprites = game.data.sprites or {}
local red = sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED
self.walkSheet = tryImage(red and red.image)
return self
end
+2 -2
View File
@@ -501,7 +501,7 @@ local function buildRows(game)
return true
end },
-- Haptic feedback for on-screen pad presses (#806): OFF / LIGHT /
-- MEDIUM / HEAVY, where the intensity is a vibration duration --
-- NORMAL / STRONG, where the intensity is a vibration duration --
-- love.system.vibrate takes nothing else. Hidden with TOUCH PAD below,
-- since the only thing that buzzes is a virtual button press.
{ id = "haptics", label = Strings("VIBRATION"),
@@ -515,7 +515,7 @@ local function buildRows(game)
o.haptics = TC.cycleHaptics(o.haptics, dir)
TC:applyOptions(o)
-- sample the level being selected: stepping the row is the only way
-- to compare LIGHT against HEAVY without leaving the menu
-- to compare LIGHT against STRONG without leaving the menu
TC.buzz(o.haptics)
return true
end },
+54 -11
View File
@@ -106,6 +106,10 @@ local TEXT_ASK_FORGET_SLOT = Strings.source("Which move should\nbe forgotten?")
local TEXT_CANT_FORGET_HM = Strings.source("HM moves can't be\nforgotten now.")
local TEXT_STOP_LEARNING = Strings.source("Stop learning\n%s?")
-- BattleText_EnemyIsAboutToUseWillPlayerChangeMon (data/text/battle.asm:222-231).
local TEXT_ENEMY_ABOUT_TO_USE = Strings.source(
"%s\nis about to use\v%s.\fWill %s\nchange POKéMON?")
-- _AskForgetMoveText, all three paragraphs (data/text/common_3.asm:141-165).
local TEXT_ASK_FORGET_MOVE = Strings.source(
"%s is\ntrying to learn\v%s.\fBut %s\ncan't learn more\vthan four moves."
@@ -548,6 +552,14 @@ BattleState.PLAYER_PIC_TILES = 6
-- box's own bottom centre.
local PIC_RESIZE_TILES = { [0] = 6, [1] = 4, [2] = 2, [3] = 7, [4] = 5, [5] = 3 }
-- SUBSTATUS_UNDERGROUND / SUBSTATUS_FLYING, which BattleCommand_Charge sets on
-- FLY and DIG only (engine/battle/effect_commands.asm:5478-5485); the port
-- carries both as the volatile `vanished` flag.
function BattleState.isVanished(mon)
local volatiles = mon and mon.volatile
return (volatiles and volatiles.vanished) and true or false
end
function BattleState:drawPic(mon, back)
-- During the intro slide the player-side pic belongs to presentSlide's
-- backpic overlay, not to the baked bands (see BattleAnimView).
@@ -571,6 +583,13 @@ function BattleState:drawPic(mon, back)
-- The box is empty either because the animation running right now has
-- cleared it, or because the last one ENDED with it cleared (picHidden).
if (anim and anim.hidden) or self.picHidden[side] then return end
-- Mid FLY / DIG the box is empty: DisappearUser ClearBoxes it
-- (engine/battle/misc.asm:1-13), AppearUserRaiseSub puts it back on the
-- stored attack (engine/battle/effect_commands.asm:2113-2117).
if not (trainerBack or enemyTrainer) and BattleState.isVanished(mon)
and not (self.vanishAnim and self.vanishAnim == self.anim) then
return
end
local G = love.graphics
local w, h = image:getDimensions()
local px, py
@@ -582,10 +601,12 @@ function BattleState:drawPic(mon, back)
py = BattleState.PLAYER_PIC_TILE_Y * 8 + (box - h)
boxTiles = BattleState.PLAYER_PIC_TILES
else
-- Bottom-aligned and horizontally centred inside the 7x7 box.
-- PadFrontpic pads a short pic and never a long one
-- (engine/gfx/load_pics.asm:342-386), so an oversized mod pic pins to the
-- box's own corner at hlcoord 12, 0 rather than to a negative offset.
local box = BattleState.ENEMY_PIC_TILES * 8
px = BattleState.ENEMY_PIC_TILE_X * 8 + math.floor((box - w) / 2)
py = BattleState.ENEMY_PIC_TILE_Y * 8 + (box - h)
px = BattleState.ENEMY_PIC_TILE_X * 8 + math.max(0, math.floor((box - w) / 2))
py = BattleState.ENEMY_PIC_TILE_Y * 8 + math.max(0, box - h)
boxTiles = BattleState.ENEMY_PIC_TILES
end
-- One tile per two frames to the right, SlideBattlePicOut's own step.
@@ -903,11 +924,12 @@ function BattleState:startAnim(key, opts)
param = opts.param or 0,
sfxOrder = audio.sfxOrder,
ballPalette = opts.ballPalette,
-- BGEffect_CheckFlyDigStatus reads wPlayerSubStatus3 / wEnemySubStatus3
-- (engine/battle_anims/bg_effects.asm:2838-2851); the port keeps that bit
-- on the mon's volatile table, not on the mon itself.
flying = {
player = self.battle and self.battle.player
and self.battle.player.vanished or false,
enemy = self.battle and self.battle.enemy
and self.battle.enemy.vanished or false,
player = BattleState.isVanished(self.battle and self.battle.player),
enemy = BattleState.isVanished(self.battle and self.battle.enemy),
},
hooks = {
-- anim_sound (engine/battle_anims/anim_commands.asm:1105) calls
@@ -1365,6 +1387,13 @@ function BattleState:advanceQueue()
end
end
end
-- BattleCommand_Charge runs LoadMoveAnim BEFORE DisappearUser
-- (engine/battle/effect_commands.asm:5459-5470), so FLY / DIG still draw
-- the take-off or the burrow on the turn the substatus goes up; the box
-- only empties once THIS animation is done with.
if BattleState.isVanished(self:activeMon(event.side)) then
self.vanishAnim = self.anim
end
elseif event.kind == "damage" and event.side then
-- ANIM_x_DAMAGE is the MOVE's after-anim (effect_commands.asm:1963-1972),
-- so only a move hit gets it; `animMove` is HandleWrap's (core.asm:1198-1203).
@@ -1864,6 +1893,20 @@ function BattleState:update(_dt)
return
end
-- The prompt's earlier pages: PlaceYesNoBox only follows the LAST one
-- (engine/battle/core.asm:3302-3305), so the mon is named and read first.
if self.phase == "shift-intro" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
self:nextPage()
if not self.messagePages then self.phase = "ask-shift" end
return
end
-- OfferSwitch's YesNoBox: YES opens PickSwitchMonInBattle, NO (and B) falls
-- straight through to the enemy's send-out (engine/battle/core.asm:3305-3310).
if self.phase == "ask-shift" then
@@ -2464,13 +2507,13 @@ end
-- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231).
function BattleState:offerShiftSwitch(mon)
self.shiftIndex = 1
self.phase = "ask-shift"
local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe"
local player = (self.save and self.save.player and self.save.player.name)
or "GOLD"
self.message = trainer .. " is about to use " .. self:name(mon)
.. ". Will " .. player .. " change POKéMON?"
self.messageTimer = MESSAGE_FRAMES
-- The `para` splits this in two (data/text/battle.asm:222-231): the incoming
-- mon is NAMED on its own page, and only the second carries the yes/no box.
self:showPages(Strings(TEXT_ENEMY_ABOUT_TO_USE, trainer, self:name(mon), player))
self.phase = self.messagePages and "shift-intro" or "ask-shift"
end
function BattleState:answerUseNextMon(yes)
+48 -13
View File
@@ -80,7 +80,8 @@ local function purgeBundledModules()
end
end
-- Boot.probePayload(rel) -> { engine = string, minShell = number } | nil, err
-- Boot.probePayload(rel)
-- -> { engine = string, minShell = number, payloadHost = string } | 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
@@ -104,24 +105,50 @@ function Boot.probePayload(rel)
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 }
return {
engine = v.engine,
minShell = tonumber(v.minShell) or 1,
payloadHost = type(v.payloadHost) == "string" and v.payloadHost or "love",
}
end
-- Boot.select(candidates, bundledEngine, bundledShell) -> chosen | nil, toDelete
-- Pure host gate shared by boot selection and the download worker. Missing
-- payloadHost fields mean "love" so payloads made before this contract remain
-- compatible with ordinary LOVE packages.
function Boot.canHost(info, bundledShell, bundledPayloadHost)
if type(info) ~= "table" then return false end
local payloadHost = type(info.payloadHost) == "string"
and info.payloadHost or "love"
local host = type(bundledPayloadHost) == "string"
and bundledPayloadHost or "love"
return payloadHost == host and (tonumber(info.minShell) or 1)
<= (tonumber(bundledShell) or 1)
end
local function samePayloadHost(info, bundledPayloadHost)
local payloadHost = type(info.payloadHost) == "string"
and info.payloadHost or "love"
local host = type(bundledPayloadHost) == "string"
and bundledPayloadHost or "love"
return payloadHost == host
end
-- Boot.select(candidates, bundledEngine, bundledShell, bundledPayloadHost)
-- -> chosen | nil, toDelete
--
-- Pure (no love.*): decide which payload to run and which to delete.
-- candidates is a list of { name = , engine = , minShell = }.
-- candidates is a list of { name = , engine = , minShell = , payloadHost = }.
-- * chosen: the highest engine that is STRICTLY newer than bundledEngine and
-- whose minShell <= bundledShell (a payload the running shell can host).
-- whose payloadHost matches and minShell <= bundledShell.
-- * 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)
-- A newer incompatible payload is kept: a matching host or future shell
-- may be able to run it.
function Boot.select(candidates, bundledEngine, bundledShell, bundledPayloadHost)
local chosen
for _, c in ipairs(candidates) do
local newer = Semver.compare(c.engine, bundledEngine) > 0
local runnable = (c.minShell or 1) <= bundledShell
local runnable = Boot.canHost(c, bundledShell, bundledPayloadHost)
if newer and runnable then
if not chosen or Semver.compare(c.engine, chosen.engine) > 0 then
chosen = c
@@ -132,9 +159,15 @@ function Boot.select(candidates, bundledEngine, bundledShell)
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
local stale = false
-- Never clean up another host family's payloads. A shared save directory
-- may be opened by multiple native packages, and only the matching host
-- can decide whether one of its own archives is stale.
if samePayloadHost(c, bundledPayloadHost) then
stale = Semver.compare(c.engine, bundledEngine) <= 0
if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then
stale = true
end
end
if stale then toDelete[#toDelete + 1] = c.name end
end
@@ -223,6 +256,7 @@ local function runInner(args)
name = entry,
engine = info.engine,
minShell = info.minShell,
payloadHost = info.payloadHost,
}
end
end
@@ -230,7 +264,8 @@ local function runInner(args)
end
local Version = require("src.core.Version")
local chosen, toDelete = Boot.select(candidates, Version.engine, Version.shell)
local chosen, toDelete = Boot.select(candidates, Version.engine,
Version.shell, Version.payloadHost)
for _, victim in ipairs(toDelete) do
love.filesystem.remove(PAYLOAD_DIR .. "/" .. victim)
+3 -2
View File
@@ -153,8 +153,9 @@ local function gatePasses(rel)
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
local payloadHost = (Version and Version.payloadHost) or "love"
if Boot.canHost then return Boot.canHost(info, shell, payloadHost) end
return not (info.minShell and info.minShell > shell)
end
-- ---------------------------------------------------------------------------
+18 -5
View File
@@ -1397,14 +1397,16 @@ function OverworldState:checkLedgeHop(dir)
return false
end
require("src.core.Sound").play(Game.data, "Ledge")
p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic)
local hop = (p.stepFramesCur or p.stepFrames or 16) * 2
p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic)
self:scriptMove(p, dir, 1, function() self:checkEdgeExit(dir) end)
return true
end
if not Collision.occupied(self.entities, lx, ly, p)
and self.map:isWalkableCell(lx, ly) then
require("src.core.Sound").play(Game.data, "Ledge")
p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic)
local hop = (p.stepFramesCur or p.stepFrames or 16) * 2
p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic)
self:scriptMove(p, dir, 2)
return true
end
@@ -3515,10 +3517,21 @@ end
function OverworldState:onStepComplete()
local p = self.player
local suppressWildEncounter = self.wildEncounterGraceSteps > 0
if suppressWildEncounter then
self.wildEncounterGraceSteps = self.wildEncounterGraceSteps - 1
-- Defaulted: a state built without the constructor (a mod harness, a test
-- fixture) reaches this before :234 ever ran, and nil > 0 threw the step.
local grace = self.wildEncounterGraceSteps or 0
if grace > 0 then
self.wildEncounterGraceSteps = grace - 1
end
-- TryDoWildEncounter's first guard is `ld a, [wNPCMovementScriptPointerTable
-- Num] / and a / ret nz` (engine/battle/wild_encounters.asm:3-9): a step the
-- player did not take never rolls, which is why Oak's escort walks to the lab
-- through Pallet's grass without being jumped.
local runner = self.runner
local scripted = (runner and runner.isRunning and runner:isRunning())
or #(self.scriptMoves or {}) > 0
or self.engaging or self.emote or self.teleportOut
local suppressWildEncounter = grace > 0 or scripted and true or false
self.todSteps = (self.todSteps or 0) + 1
-- UpdatePikachuHappinessAndMood rides the step counter (poison.asm)
require("src.world.PikachuFollower").onStep(Game.save)
+2 -1
View File
@@ -3946,7 +3946,8 @@ function World:rollEncounter(kind, terrain, tables, vanilla)
local ctx = {
mapId = map and map.id,
terrain = terrain,
rng = love.math.random,
-- Same guard World:rockRandom uses: a headless suite has no love global.
rng = (love and love.math and love.math.random) or math.random,
kind = kind,
daytime = self.daytime,
environment = map and map.def and map.def.environment,