Bugsquashingjuly26 (#264)

* big bug squash

* ok
This commit is contained in:
bryanthaboi
2026-07-26 14:11:07 -04:00
committed by GitHub
parent f7695308b7
commit 043f6b2426
9 changed files with 296 additions and 113 deletions
+7
View File
@@ -2781,6 +2781,13 @@ function BattleState:enemyMonFainted()
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
self.enemy.mon.level, self.kind == "trainer",
split, mon.traded)
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
-- without this gate it re-triggers after every later fight (#213).
if #levels > 0 then
self.leveledUp = self.leveledUp or {}
self.leveledUp[mon] = true
end
Runtime.emit("battle.exp_gained", {
battle = self, mon = mon, gained = gained, levels = levels,
})
+15 -6
View File
@@ -184,13 +184,22 @@ function Evolution.request(game, mon, trigger, onDone)
return species
end
-- After-battle hook: evolve everyone who qualifies (queued one at a time).
function Evolution.checkParty(game, onDone)
-- After-battle hook: evolve mons that leveled this battle and still
-- qualify (queued one at a time, party order). Gen1 EvolveAfterBattle
-- only considers mons that gained a level during the fight — a B-cancel
-- means "not this time", and the next offer waits for the next level-up
-- (or Rare Candy / stone, which call Evolution.evolve directly).
-- leveledUp is a set of party mon tables; nil/empty yields no evolutions.
function Evolution.checkParty(game, onDone, leveledUp)
local pending = {}
for _, mon in ipairs(game.save.party) do
local target, evo = Evolution.pendingFor(game, mon, { kind = "levelup" })
if target then
table.insert(pending, { mon = mon, to = target, via = evo and evo.method })
if leveledUp then
for _, mon in ipairs(game.save.party) do
if leveledUp[mon] then
local target, evo = Evolution.pendingFor(game, mon, { kind = "levelup" })
if target then
table.insert(pending, { mon = mon, to = target, via = evo and evo.method })
end
end
end
end
local i = 0
+105 -86
View File
@@ -39,39 +39,42 @@ end
-- endFrame composites the padded canvas back with a matching offset.
Renderer.UPRIGHT_MARGIN = 160
-- LOVE units + framebuffer pixels + the live unit→pixel ratio.
-- Android's DisplayMetrics.density (love.graphics.getDPIScale) is often
-- non-integer (1.5, 2.75, …). Integer scaling in units then maps each GB
-- pixel to a fractional number of framebuffer pixels → shimmer, uneven /
-- non-square "pixels", and movement judder (issue #87). Always derive the
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi)
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
-- LOVE units + framebuffer pixels + per-axis unit→pixel ratios.
-- Android's DisplayMetrics.density is often non-integer (1.5, 2.75, …).
-- Integer scaling in units then maps each GB pixel to a fractional number of
-- framebuffer pixels → shimmer, uneven / non-square "pixels", and movement
-- judder (issue #87). Always derive the crisp integer scale from the
-- *drawable* pixel size (the window framebuffer we present into -- not a
-- combined multi-display metric), then draw with (pixels / axisDpi) so the
-- GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
--
-- `dpi` here must be the factor LOVE actually applies to every draw call
-- (getDPIScale), NOT the drawable/unit size ratio pw/ww. On a normal device
-- those are equal (getPixelDimensions == getDimensions * getDPIScale), but on
-- the AYN Thor dual-screen surface in forced landscape they diverge: LOVE
-- reports pw/ww ≈ 1 while its real transform is 1.5, so scaling by pw/ww lands
-- each GB pixel on Sp*(getDPIScale/(pw/ww)) physical pixels -- a fractional,
-- stretched/non-square count (issue #208). Prefer getDPIScale so draws land
-- on whole physical pixels through LOVE's actual transform; fall back to pw/ww
-- (then 1) only when getDPIScale is unavailable. Since getDPIScale == pw/ww
-- on every normal device, #87's behavior is byte-identical there.
-- LOVE's projection is anisotropic: 1 unit in X covers pw/ww framebuffer
-- pixels and 1 unit in Y covers ph/wh. Those ratios match on a normal
-- highdpi surface, but diverge when unit sizes are truncated independently
-- (`(int)(pixels/density)`) or when a dual-screen / forced-rotation device
-- reports mismatched unit vs drawable aspects (AYN Thor, issue #208).
-- love.graphics.getDPIScale() is only ph/wh, so using it (or pw/ww alone)
-- for both axes makes the other axis land on a fractional, stretched count.
-- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
-- on BOTH axes (square).
local function displayMetrics()
local ww, wh = love.graphics.getDimensions()
local pw, ph = ww, wh
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
local dpi
if love.graphics.getDPIScale then
dpi = love.graphics.getDPIScale()
local dpiX, dpiY = 1, 1
if ww > 0 and pw > 0 then dpiX = pw / ww end
if wh > 0 and ph > 0 then dpiY = ph / wh end
-- No pixel API (headless / old stub): fall back to getDPIScale, else 1.
if (dpiX == 1 and dpiY == 1) and not love.graphics.getPixelDimensions
and love.graphics.getDPIScale then
local d = love.graphics.getDPIScale()
if d and d > 1e-6 then dpiX, dpiY = d, d end
end
if (not dpi or dpi < 1e-6) and ww > 0 and pw > 0 then
dpi = pw / ww
end
if not dpi or dpi < 1e-6 then dpi = 1 end
return ww, wh, pw, ph, dpi
if dpiX < 1e-6 then dpiX = 1 end
if dpiY < 1e-6 then dpiY = 1 end
return ww, wh, pw, ph, dpiX, dpiY
end
function Renderer:init()
@@ -105,33 +108,43 @@ end
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE
-- units via / dpi when drawing.
-- units via / dpiX and / dpiY when drawing.
function Renderer:fitScale()
local _, _, pw, ph = displayMetrics()
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
end
-- The LOVE-unit draw scale endFrame uses for the UI blit: the integer
-- framebuffer scale (fitScale) divided by the live coordinate->pixel factor,
-- so a GB pixel lands on fitScale() whole PHYSICAL pixels once LOVE applies
-- its own transform (fitScale() == drawScale() * dpi). Exposed so #208's
-- regression can assert GB pixels stay square on divergent-DPI surfaces
-- without reaching into endFrame's locals; endFrame recomputes the same value
-- inline (`S = Sp / dpi`).
function Renderer:drawScale()
local _, _, _, _, dpi = displayMetrics()
return self:fitScale() / dpi
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
-- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel
-- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies
-- its projection (fitScale() == drawScaleX() * dpiX == drawScaleY() * dpiY).
-- Exposed for #208's regression (square pixels when dpiX ≠ dpiY).
function Renderer:drawScaleX()
local _, _, _, _, dpiX = displayMetrics()
return self:fitScale() / dpiX
end
-- world-pass canvas size in world pixels: enough to fill the window at s'.
-- In tilt mode the canvas grows (both dimensions, by Tilt.viewGrowth) so
-- the projected ground plane still covers the whole window with no
-- background peeking at the receded top/bottom corners; flat mode returns
-- exactly today's size (growth factor is 1 when tilt is inactive).
function Renderer:drawScaleY()
local _, _, _, _, _, dpiY = displayMetrics()
return self:fitScale() / dpiY
end
-- Back-compat alias: uniform surfaces have drawScaleX == drawScaleY.
function Renderer:drawScale()
return self:drawScaleX()
end
-- world-pass canvas size in world pixels: enough to fill the drawable at s'.
-- Sized from framebuffer pixels (not unit dims) so anisotropic dpiX/dpiY
-- cannot over/under-cover the window. In tilt mode the canvas grows (both
-- dimensions, by Tilt.viewGrowth) so the projected ground plane still covers
-- the whole window with no background peeking at the receded top/bottom
-- corners; flat mode returns exactly today's size (growth factor is 1 when
-- tilt is inactive).
function Renderer:worldViewSize()
local ww, wh, _, _, dpi = displayMetrics()
local s = Zoom.scale(self:fitScale()) / dpi
local vw, vh = Zoom.fillViewSize(s, ww, wh)
local _, _, pw, ph = displayMetrics()
local sp = Zoom.scale(self:fitScale())
local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
if vw % 2 ~= 0 then vw = vw + 1 end
@@ -169,21 +182,24 @@ end
-- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox
-- into the surrounding window, matching BattleTransition wipe progress.
-- Tiles that sit entirely inside the 160x144 square are left to the OG wipe.
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S)
-- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces).
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, Sx, Sy)
if not prog or prog <= 0 then return end
local TILE = 8 * S
if TILE < 1 then TILE = 1 end
local cols = math.ceil(ww / TILE)
local rows = math.ceil(wh / TILE)
Sy = Sy or Sx
local TILE_W, TILE_H = 8 * Sx, 8 * Sy
if TILE_W < 1 then TILE_W = 1 end
if TILE_H < 1 then TILE_H = 1 end
local cols = math.ceil(ww / TILE_W)
local rows = math.ceil(wh / TILE_H)
local cx, cy = ox + vpw / 2, oy + vph / 2
local order = {}
for row = 0, rows - 1 do
for col = 0, cols - 1 do
local x, y = col * TILE, row * TILE
local x, y = col * TILE_W, row * TILE_H
-- any tile with area outside the letterbox participates
if x < ox or y < oy or x + TILE > ox + vpw or y + TILE > oy + vph then
local dist = math.max(math.abs(x + TILE / 2 - cx),
math.abs(y + TILE / 2 - cy))
if x < ox or y < oy or x + TILE_W > ox + vpw or y + TILE_H > oy + vph then
local dist = math.max(math.abs(x + TILE_W / 2 - cx),
math.abs(y + TILE_H / 2 - cy))
order[#order + 1] = { x, y, dist }
end
end
@@ -200,7 +216,7 @@ function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S)
love.graphics.setScissor(0, 0, ww, wh)
for i = 1, n do
local t = order[i]
love.graphics.rectangle("fill", t[1], t[2], TILE, TILE)
love.graphics.rectangle("fill", t[1], t[2], TILE_W, TILE_H)
end
love.graphics.setScissor()
love.graphics.setColor(1, 1, 1, 1)
@@ -323,10 +339,11 @@ end
-- into (nil = default framebuffer; presentCanvas when CRT is on).
-- Returns true on success; false (no shader/mesh) tells endFrame to fall
-- back to the flat blit unchanged.
function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
local shader = self:tiltShader()
local mesh = self:tiltMesh()
if not (shader and mesh) then return false end
sy = sy or sx
local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight()
@@ -375,7 +392,7 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
mesh:setVertices(Tilt.meshCorners(wvw, wvh))
love.graphics.push()
love.graphics.translate(wox, woy)
love.graphics.scale(s, s)
love.graphics.scale(sx, sy)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setShader(shader)
love.graphics.draw(mesh)
@@ -417,14 +434,15 @@ end
-- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones)
love.graphics.setCanvas()
local ww, wh, pw, ph, dpi = displayMetrics()
-- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale.
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
-- Sp = integer framebuffer pixels per GB pixel;
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
local Sp = self:fitScale()
local S = Sp / dpi
local vpw, vph = self.WIDTH * S, self.HEIGHT * S
local Sx, Sy = Sp / dpiX, Sp / dpiY
local vpw, vph = self.WIDTH * Sx, self.HEIGHT * Sy
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpiX
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpiY
local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -469,14 +487,15 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1)
-- blit `canvas` at `scale` (LOVE units) into origin (bx, by), scissored to
-- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone
-- coords (canvas-space) into screen units.
local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH)
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
-- convert zone coords (canvas-space) into screen units.
local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then
love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, scale, scale)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
love.graphics.setScissor()
return
end
@@ -492,10 +511,10 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setShader(not plain and shader or nil)
end
if not plain then PaletteFX.sendColors(shader, z.colors) end
if scissorClamped(bx + z.x * zoneScale, by + z.y * zoneScale,
z.w * zoneScale, z.h * zoneScale,
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
z.w * zoneSx, z.h * zoneSy,
boxX, boxY, boxW, boxH) then
love.graphics.draw(canvas, bx, by, 0, scale, scale)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
end
love.graphics.setScissor()
@@ -510,7 +529,7 @@ function Renderer:endFrame(zones, worldZones)
-- 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.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
love.graphics.setScissor()
-- the screen-space overlays the flat path draws over its composite
local fade = self.worldFadeAlpha
@@ -520,15 +539,15 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setColor(1, 1, 1, 1)
end
if self.battleCascadeProg then
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S)
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
end
elseif self.worldActive then
local sp = Zoom.scale(Sp)
local s = sp / dpi
local sx, sy = sp / dpiX, sp / dpiY
local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight()
local wox = math.floor((pw - wvw * sp) / 2) / dpi
local woy = math.floor((ph - wvh * sp) / 2) / dpi
local wox = math.floor((pw - wvw * sp) / 2) / dpiX
local woy = math.floor((ph - wvh * sp) / 2) / dpiY
-- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or
@@ -536,12 +555,12 @@ function Renderer:endFrame(zones, worldZones)
-- falls through to the flat blit, keeping the flat frame byte-for-byte
-- identical to today.
local projected =
Tilt.active() and self:drawTiltedWorld(worldZones or zones, s, wox, woy, present)
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present)
if not projected then
if worldZones then
blit(self.worldCanvas, s, worldZones, s, wox, woy, 0, 0, ww, wh)
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh)
else
blit(self.worldCanvas, s, zones, S, wox, woy, 0, 0, ww, wh)
blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh)
end
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
@@ -562,11 +581,11 @@ function Renderer:endFrame(zones, worldZones)
end
if wanted then PaletteFX.sendColors(wanted, r.colors) end
if r.quad then
love.graphics.draw(r.image, r.quad, wox + r.x * s, woy + r.y * s,
0, s * r.sx, s)
love.graphics.draw(r.image, r.quad, wox + r.x * sx, woy + r.y * sy,
0, sx * r.sx, sy)
else
love.graphics.draw(r.image, wox + r.x * s, woy + r.y * s,
0, s * r.sx, s)
love.graphics.draw(r.image, wox + r.x * sx, woy + r.y * sy,
0, sx * r.sx, sy)
end
end
if activeShader then love.graphics.setShader() end
@@ -583,7 +602,7 @@ function Renderer:endFrame(zones, worldZones)
local M = self.UPRIGHT_MARGIN
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s)
love.graphics.draw(self.uprightCanvas, wox - M * sx, woy - M * sy, 0, sx, sy)
love.graphics.setScissor()
end
-- Screen-space warp fade (Transition) over the full world composite so
@@ -599,11 +618,11 @@ function Renderer:endFrame(zones, worldZones)
-- Battle transition: cascade black blocks into the area outside the
-- classic 160x144 wipe square (world still shows through until filled).
if self.battleCascadeProg then
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S)
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
end
end
-- UI stays in the classic centered GB letterbox
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
blit(self.canvas, Sx, Sy, zones, Sx, Sy, ox, oy, ox, oy, vpw, vph)
if present then
love.graphics.setCanvas()
@@ -613,7 +632,7 @@ function Renderer:endFrame(zones, worldZones)
-- 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
{ width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present
if GBCFX.active() then
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(composed, Sp)
+10 -2
View File
@@ -465,6 +465,14 @@ function PartyMenu:bottomMessage()
end
end
-- Name-row pixel Y for party slot i (1-based).
-- pokered party_menu.asm RedrawPartyMenu_: hlcoord 3, 0, then each entry
-- advances 2*SCREEN_WIDTH (16 px). The bottom message box sits at tile
-- row 12 (y=96); slot 6's HP row is therefore at y=88. #262
function PartyMenu.entryY(i)
return (i - 1) * 16
end
function PartyMenu:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
@@ -476,9 +484,9 @@ function PartyMenu:draw()
local HudTiles = require("src.render.HudTiles")
for i, mon in ipairs(party) do
local def = self.game.data.pokemon[mon.species]
local y = (i - 1) * 16 + 12
local y = PartyMenu.entryY(i)
love.graphics.setColor(1, 1, 1, 1)
drawIcon(self.game, mon, 8, y - 2, i == self.index, self.blink or 0)
drawIcon(self.game, mon, 8, y, i == self.index, self.blink or 0)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(mon.nickname or def.name, 24, y)
-- level at column 13 (<LV> tile + digits, PrintLevel) AND the
+3 -1
View File
@@ -3257,7 +3257,9 @@ function OverworldState:afterBattle(result, battle)
lead and lead.stats.hp or 0)
local Evolution = require("src.pokemon.Evolution")
local function evolutions()
Evolution.checkParty(Game)
-- Only mons that gained a level this battle (EXP.ALL included).
-- Scanning the whole party re-offered B-cancelled evolutions forever (#213).
Evolution.checkParty(Game, nil, battle and battle.leveledUp)
end
if result == "lose" then
local oaksLabRival = battle and battle.oppClass == "OPP_RIVAL1"
@@ -8,6 +8,8 @@
-- Case 1 (level path, cancelable): open EvolutionState directly, wait a
-- few frames into the flash (t well under FLASH_FRAMES=220), hold B, and
-- assert the mon stays CATERPIE with "stopped evolving" text on screen.
-- Case 1b: after cancel, checkParty with no level-ups must not re-offer;
-- a subsequent level-up set must offer again (EvolveAfterBattle parity).
-- Case 2 (control): let the flash run to completion with no input and
-- assert the mon becomes METAPOD with the "Congratulations!" text.
--
@@ -103,6 +105,27 @@ return function(game)
assert(done1, "cancel onDone never fired")
assert(mon.species == "CATERPIE", "species changed after cancel tail")
-- === Case 1b: after B-cancel, checkParty without a level-up must not
-- re-offer (regression: cancelled mons stuck at threshold tried again
-- after every later battle, even non-participants). ===
local nQuiet = Evolution.checkParty(game, nil, {})
assert(nQuiet == 0, "checkParty with no level-ups re-offered after cancel")
assert(not evoTop(), "EvolutionState opened after quiet afterBattle")
assert(mon.species == "CATERPIE", "species changed on quiet checkParty")
local nLevel = Evolution.checkParty(game, nil, { [mon] = true })
assert(nLevel == 1, "checkParty after a real level-up should offer once")
if not waitFor(evoTop, 300) then
error("EvolutionState never opened after level-up re-offer")
end
U.wait(20)
U.hold(game, "b", 20) -- cancel so case 2 stays independent
if not waitFor(function() return not evoTop() end, 240) then
error("level-up re-offer did not abort on B")
end
mashUntil(function() return not findText("stopped evolving") end, 80)
assert(mon.species == "CATERPIE", "species changed after re-offer cancel")
-- === Case 2 (control): no input -> evolution completes ===
local mon2 = Pokemon.new(game.data, "CATERPIE", 7)
table.insert(game.save.party, 1, mon2)
@@ -0,0 +1,46 @@
-- Driver: party list must not sit under the bottom message box (#262).
-- Gen1 (pokered engine/menus/party_menu.asm RedrawPartyMenu_) places the
-- first name at hlcoord 3, 0 and advances each entry by 2*SCREEN_WIDTH.
-- The recomp used y = (i-1)*16 + 12, which shoved a full party down so
-- slot 6 was clipped by the "Choose a POKéMON." text box (tile row 12).
-- This driver opens a 6-mon party menu, screenshots it, and asserts
-- PartyMenu.entryY keeps slot 6's HP row above y=96.
-- POKEPORT_DRIVER=tests/drivers/party_bug262_offset_test.lua \
-- POKEPORT_IDENTITY=bug262 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local PartyMenu = require("src.ui.PartyMenu")
local pass, fail = 0, 0
local function check(label, ok)
if ok then pass = pass + 1; U.log("PASS", label)
else fail = fail + 1; U.log("FAIL", label) end
end
game.save.party = {
Pokemon.new(game.data, "CHARMANDER", 12),
Pokemon.new(game.data, "PARAS", 10),
Pokemon.new(game.data, "DIGLETT", 15),
Pokemon.new(game.data, "FARFETCHD", 5),
Pokemon.new(game.data, "GASTLY", 20),
Pokemon.new(game.data, "SANDSHREW", 22),
}
U.teleport(game, "ROUTE_1", 5, 5, "down")
Screens.push(game, "PartyMenu")
U.wait(8)
U.shot(game, DIR .. "/party_bug262_offset.png")
local pm = game.stack:top()
check("top is PartyMenu", getmetatable(pm) == PartyMenu)
check("entryY(1) == 0", PartyMenu.entryY(1) == 0)
check("entryY(6) == 80", PartyMenu.entryY(6) == 80)
check("slot 6 HP row < message box y=96", PartyMenu.entryY(6) + 8 < 96)
check("field message == 'Choose a POKéMON.'",
pm and pm.bottomMessage and pm:bottomMessage() == "Choose a POKéMON.")
U.log(("RESULT pass=%d fail=%d"):format(pass, fail))
end
+40
View File
@@ -512,6 +512,46 @@ do
"the registered method's own gate holds")
end
-- ------- checkParty only offers evolutions for mons that leveled (#213)
do
local caterpie = Pokemon.new(Data, "CATERPIE", 7) -- at LEVEL evo threshold
local bench = Pokemon.new(Data, "PIDGEY", 5)
local save = SaveData.newGame()
save.party = { caterpie, bench }
local stack = { states = {} }
function stack:push(state) self.states[#self.states + 1] = state end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
local game = { data = Data, save = save, stack = stack }
local offered = {}
local origEvolve = Evolution.evolve
Evolution.evolve = function(_, mon, to, onDone)
offered[#offered + 1] = { mon = mon, to = to }
if onDone then onDone() end
end
check(Evolution.checkParty(game) == 0,
"checkParty with no leveledUp set offers nothing")
check(#offered == 0, "no evolve calls without leveledUp")
check(Evolution.checkParty(game, nil, {}) == 0,
"checkParty with empty leveledUp offers nothing")
check(#offered == 0, "cancel-at-threshold does not re-offer next battle")
check(Evolution.checkParty(game, nil, { [bench] = true }) == 0,
"a leveled mon with no pending evo is skipped")
check(#offered == 0, "bench level-up alone does not evolve CATERPIE")
check(Evolution.checkParty(game, nil, { [caterpie] = true }) == 1,
"checkParty offers evo for the mon that leveled")
check(#offered == 1 and offered[1].mon == caterpie and offered[1].to == "METAPOD",
"the leveled CATERPIE is queued for METAPOD")
Evolution.evolve = origEvolve
end
-- ------- the rare-candy flow runs the hook-wrapped dispatch
do
+47 -18
View File
@@ -1845,9 +1845,12 @@ do
eq(cam.y, 160 - (288 / 2 - 8), "wide view keeps player centered y")
end
-- ---------------------------------------------------------------- dpi fit scale (#87)
-- ---------------------------------------------------------------- dpi fit scale (#87 / #208)
-- Android density is often non-integer; fitScale must use framebuffer
-- pixels so each GB pixel maps to a whole number of physical pixels.
-- When axis unit→pixel ratios diverge (truncated highdpi sizes, dual-screen
-- / forced-rotation mismatch), draw scales must be anisotropic so X and Y
-- both land on the same integer physical count (square pixels).
do
local Renderer = require("src.render.Renderer")
local Zoom = require("src.render.Zoom")
@@ -1873,27 +1876,46 @@ do
local vw, vh = Renderer:worldViewSize()
check(vw % 2 == 0 and vh % 2 == 0, "world view sizes are even (integer camera)")
-- ceil(pw/Sp)=ceil(1920/7)=275 → even 276; ceil(1080/7)=155 → even 156
eq(vw, 276, "world fill width covers the unit window at pixel scale 7")
eq(vh, 156, "world fill height covers the unit window at pixel scale 7")
eq(vw, 276, "world fill width covers the drawable at pixel scale 7")
eq(vh, 156, "world fill height covers the drawable at pixel scale 7")
-- #208: on a dual-screen surface (AYN Thor, forced landscape) LOVE can
-- report drawable == unit size (pw/ww == 1) while its real coordinate->pixel
-- transform is 1.5. fitScale stays keyed off the drawable pixel size, but
-- the unit<->pixel conversion must use the REAL getDPIScale, not pw/ww, so
-- each GB pixel still lands on a WHOLE number of physical pixels (square) --
-- not the stretched 6-vs-9-device-px fractional pixels the reporter saw.
-- Before the fix drawScale()==Sp/(pw/ww)==7 and 7*1.5==10.5 px/GB px.
-- #208: axis DPI mismatch. LOVE's projection uses pw/ww on X and ph/wh
-- on Y independently; getDPIScale() is only ph/wh. A single-dpi draw
-- scale makes one axis fractional → stretched / non-square GB pixels
-- (fonts especially). Truncated density-2.75 unit sizes on 1080p:
local dpiX = 1920 / 698
local dpiY = 1080 / 392
Zoom.reset()
g.getDimensions = function() return 1920, 1080 end
g.getDimensions = function() return 698, 392 end
g.getPixelDimensions = function() return 1920, 1080 end
g.getDPIScale = function() return 1.5 end
g.getDPIScale = function() return dpiY end -- real love.graphics.getDPIScale
eq(Renderer:fitScale(), 7,
"#208 divergent DPI: fitScale still 7 from drawable pixels")
local physical = Renderer:drawScale() * g.getDPIScale()
local rounded = math.floor(physical + 0.5)
check(math.abs(physical - rounded) < 1e-9,
"#208 GB pixel lands on a whole number of physical pixels (square)")
eq(rounded, 7, "#208 each GB pixel covers exactly fitScale (7) physical px")
"#208 anisotropic DPI: fitScale still 7 from drawable pixels")
local physX = Renderer:drawScaleX() * dpiX
local physY = Renderer:drawScaleY() * dpiY
check(math.abs(physX - 7) < 1e-9,
"#208 GB pixel covers exactly fitScale (7) physical px on X")
check(math.abs(physY - 7) < 1e-9,
"#208 GB pixel covers exactly fitScale (7) physical px on Y")
check(math.abs(physX - physY) < 1e-9,
"#208 physical X/Y match (square pixels)")
-- single-dpi (getDPIScale-only) path would stretch X:
-- 7 * dpiX / dpiY ≈ 6.989 ≠ 7
check(math.abs(7 * dpiX / dpiY - 7) > 1e-6,
"#208 fixture really has dpiX ≠ dpiY (guards the regression)")
-- #208 severe case: unit aspect swapped vs drawable (forced-rotation /
-- dual-screen mis-report). Uniform dpi would heavily stretch one axis;
-- anisotropic scales keep 7x7 physical GB pixels.
Zoom.reset()
g.getDimensions = function() return 1080, 1920 end
g.getPixelDimensions = function() return 1920, 1080 end
g.getDPIScale = function() return 1080 / 1920 end
eq(Renderer:fitScale(), 7, "#208 swapped-aspect: fitScale from drawable")
physX = Renderer:drawScaleX() * (1920 / 1080)
physY = Renderer:drawScaleY() * (1080 / 1920)
check(math.abs(physX - 7) < 1e-9 and math.abs(physY - 7) < 1e-9,
"#208 swapped-aspect still yields square 7x7 physical GB pixels")
-- missing pixel API falls back to getDimensions (headless / old stub)
g.getPixelDimensions = nil
@@ -3003,6 +3025,13 @@ eq(frameFor("WATER", true), 3, "WATER animates to the walk frame")
-- icons outside the table keep the old uniform fallback
eq(frameFor("BALL", true, 96), 3, "fallback: 16x96 sheet animates to 3")
eq(frameFor("HELIX", true, 32), 1, "fallback: 16x32 sheet animates to 1")
-- party list Y (pokered party_menu.asm hlcoord 3, 0 + 2-row stride). #262
-- A +12 offset pushed slot 6 into the bottom message box at tile row 12.
local entryY = require("src.ui.PartyMenu").entryY
eq(entryY(1), 0, "party slot 1 name row is y=0")
eq(entryY(6), 80, "party slot 6 name row is y=80")
check(entryY(6) + 8 < 96, "slot 6 HP row stays above the message box (y=96)")
end
-- ---------------------------------------------- suite discovery