CLOSES #2577, CLOSES #2573, CLOSES #2572

This commit is contained in:
bryanthaboi
2026-09-30 13:54:23 -04:00
parent 370aaf4e1f
commit 6806d5a235
21 changed files with 760 additions and 82 deletions
+4 -1
View File
@@ -823,7 +823,10 @@ function Game:_cycleSpeed(dir)
if busy then return end
local GameSpeed = require("src.core.GameSpeed")
local key = GameSpeed.optionKey(Game.speedCategoryInStack(self.stack))
self.save.options[key] = GameSpeed.cycle(self.save.options[key], dir)
local nextSpeed = GameSpeed.cycle(self.save.options[key], dir)
for _, c in ipairs(GameSpeed.CATEGORIES) do
self.save.options[GameSpeed.optionKey(c)] = nextSpeed
end
self:writeOptions()
end
+4 -1
View File
@@ -677,7 +677,10 @@ function Game3:_cycleSpeed(dir)
if self:speedLocked() then return end
local GameSpeed = require("src.core.GameSpeed")
local key = GameSpeed.optionKey(self:speedCategory())
self.options[key] = GameSpeed.cycle(self.options[key], dir)
local nextSpeed = GameSpeed.cycle(self.options[key], dir)
for _, c in ipairs(GameSpeed.CATEGORIES) do
self.options[GameSpeed.optionKey(c)] = nextSpeed
end
self:writeOptions()
end
+19 -7
View File
@@ -93,6 +93,17 @@ local function stop_low_hp_song()
end
end
local function apply_low_hp(on)
if on and not Battle._lowHpSong then
Battle._lowHpSong = true
if Audio and Audio.playSe and SE then
Audio.playSe(SE.SE_LOW_HEALTH, { loop = true })
end
elseif not on then
stop_low_hp_song()
end
end
--- pret HandleLowHpMusicChange / HandleBattleLowHpMusicChange
local function update_low_hp_music()
local st = Battle._st
@@ -106,16 +117,17 @@ local function update_low_hp_music()
local hp = math.floor(tonumber(pres and pres.displayHp) or tonumber(mon.hp) or 0)
local maxHp = math.floor(tonumber(pres and pres.displayMaxHp) or tonumber(mon.maxHp) or 0)
local red = hp_bar_red(hp, maxHp)
if red and not Battle._lowHpSong then
Battle._lowHpSong = true
if Audio and Audio.playSe and SE then
Audio.playSe(SE.SE_LOW_HEALTH, { loop = true })
end
elseif not red then
stop_low_hp_song()
if ModRuntime.wantsHook("battle.low_health_alarm") then
ModRuntime.call("battle.low_health_alarm", function(ctx)
apply_low_hp(ctx.on)
end, { on = red, battle = st })
else
apply_low_hp(red)
end
end
Battle._updateLowHpMusic = update_low_hp_music
local function party_menu_input(PartyMenu, input)
local SummaryMenu = package.loaded["src.ui.game3.summary_menu"]
if SummaryMenu and SummaryMenu.isOpen and SummaryMenu.isOpen() then
+12
View File
@@ -250,6 +250,18 @@ function Collision.behavior(cx,cy)
return Collision.behaviorOn(Collision._mapDef, cx, cy)
end
local worldMap
-- pokefirered/src/fieldmap.c:129
function Collision.worldBehavior(cx, cy)
local def = Collision._mapDef
if not (def and def.midLayout) then return nil end
worldMap = worldMap or require("src.core.game3.map")
local mid, pair = worldMap.worldMidAt(cx, cy, def)
local behaviors = InteractionScripts.behaviors[pair]
return behaviors and behaviors[mid]
end
-- pokefirered/include/constants/metatile_behaviors.h:39
local MB_IMPASSABLE_EAST = 0x30
local MB_IMPASSABLE_WEST = 0x31
+49 -8
View File
@@ -46,6 +46,7 @@ local FLY_BIRD_W, FLY_BIRD_H, FLY_BIRD_FRAMES = 64, 64, 5
local frlgReflective = nil
local frlgReflectionQuad = nil
local frlgModules = nil
local FRLG_REFL_CULL = 64
local objectPoseOpts = {}
local playerPoseOpts = {}
local function isFrlgReflective(behavior)
@@ -99,6 +100,7 @@ local function drawFrlgReflections(camX, camY)
if not modules then return end
local Collision, Ow = modules.Collision, modules.Ow
local Objects, P, Runtime = modules.Objects, modules.Player, modules.Runtime
local behaviorAt = Collision.worldBehavior or Collision.behavior
local drawn = 0
if Objects.forDraw then
for _, obj in ipairs(Objects.forDraw()) do
@@ -109,7 +111,45 @@ local function drawFrlgReflections(camX, camY)
local frame, flip = Ow.pose(spr, obj.facing, Objects.walkPhase(obj), obj.stepFlip,
objectPoseOpts)
if drawFrlgReflection(obj, obj.graphicsId, frame, flip, camX, camY,
Collision.behavior, Ow) then drawn = drawn + 1 end
behaviorAt, Ow) then drawn = drawn + 1 end
end
end
end
end
local Map = package.loaded["src.core.game3.map"]
local Ghosts = package.loaded["src.core.game3.ghosts"]
local world = Map and Map.world
if Ghosts and Ghosts.forDraw and type(world) == "table" then
local host = Collision._mapDef
local FV = package.loaded["src.core.game3.field_view"]
local Display = require("src.core.game3.display")
local vw = FV and FV._viewW or Display.W
local vh = FV and FV._viewH or Display.H
local x0, y0 = camX - FRLG_REFL_CULL, camY - FRLG_REFL_CULL
local x1, y1 = camX + vw + FRLG_REFL_CULL, camY + vh + FRLG_REFL_CULL
for i = 1, #world do
local entry = world[i]
local ox, oy = entry.ox or 0, entry.oy or 0
local L = entry.def and entry.def.midLayout
local ex, ey = ox * CELL, oy * CELL
if entry.def ~= host and L
and ex + (L.width or 0) * CELL > x0 and ex < x1
and ey + (L.height or 0) * CELL > y0 and ey < y1 then
local live = Ghosts.forDraw(entry.id)
if live then
for j = 1, #live do
local obj = live[j]
if not obj.hideReflection and obj.graphicsId and not obj.virtualId then
local spr = Ow.getDraw(obj.graphicsId)
if spr then
objectPoseOpts.frame = obj.customFrame
local frame, flip = Ow.pose(spr, obj.facing, Objects.walkPhase(obj), obj.stepFlip,
objectPoseOpts)
if drawFrlgReflection(obj, obj.graphicsId, frame, flip, camX, camY,
behaviorAt, Ow, ox, oy) then drawn = drawn + 1 end
end
end
end
end
end
end
@@ -122,22 +162,23 @@ local function drawFrlgReflections(camX, camY)
local frame, flip = Ow.pose(spr, P.facing, P.walkPhase and P.walkPhase() or 0,
P.drawFlip and P.drawFlip() or false, playerPoseOpts)
if drawFrlgReflection(P, gid, frame, flip, camX, camY,
Collision.behavior, Ow) then drawn = drawn + 1 end
behaviorAt, Ow) then drawn = drawn + 1 end
end
end
FieldEffects.lastFrlgReflections = drawn
end
drawFrlgReflection = function(obj, graphicsId, frame, hflip, camX, camY, behaviorAt, Ow)
drawFrlgReflection = function(obj, graphicsId, frame, hflip, camX, camY, behaviorAt, Ow, ox, oy)
local spr = Ow.getReflectionDraw and Ow.getReflectionDraw(graphicsId)
if not (spr and spr.quads and spr.quads[frame]) then return false end
local cx = obj.moving and obj.targetX or obj.cellX
local cy = obj.moving and obj.targetY or obj.cellY
local pcx, pcy = obj.cellX, obj.cellY
ox, oy = ox or 0, oy or 0
local cx = (obj.moving and obj.targetX or obj.cellX) + ox
local cy = (obj.moving and obj.targetY or obj.cellY) + oy
local pcx, pcy = obj.cellX + ox, obj.cellY + oy
if not frlgReflectionType(cx, cy, pcx, pcy, spr.width, spr.height, behaviorAt) then return false end
local w, h = spr.width, spr.height
local left = (obj.px or cx * CELL) + (16 - w) / 2
local top = (obj.py or cy * CELL) + 14
local left = (obj.px or (cx - ox) * CELL) + ox * CELL + (16 - w) / 2
local top = (obj.py or (cy - oy) * CELL) + oy * CELL + 14
local x0, x1 = math.floor(left / CELL), math.floor((left + w - 1) / CELL)
local y0, y1 = math.floor(top / CELL), math.floor((top + h - 1) / CELL)
local q = frlgReflectionQuad or love.graphics.newQuad(0, 0, 1, 1, spr.width, spr.height * spr.frameCount)
+92 -17
View File
@@ -144,6 +144,12 @@ local function behaviorAt(x, y)
return C.behavior(x, y)
end
local function worldBehaviorAt(x, y)
local C = Collision()
if not (C and C.worldBehavior) or x == nil or y == nil then return nil end
return C.worldBehavior(x, y)
end
function FxRse.animCmds(name, idx)
local o = FE().manifestObject(name)
@@ -574,7 +580,7 @@ end
-- pokeemerald/src/event_object_movement.c:7625
local function reflectionAt(x, y)
local b = behaviorAt(x, y)
local b = worldBehaviorAt(x, y)
if b == nil then return 0 end
if B.ice(b) then return 1 end
if B.reflective(b) then return 2 end
@@ -645,19 +651,23 @@ end
local reflQuad
local coverCells, coverN = {}, 0
local coverBuckets, coverPairs = {}, {}
local REFL_CULL = 64
local ghostPose = {}
local function drawReflection(obj, gid, frame, hflip, x2, y2, camX, camY)
local function drawReflection(obj, gid, frame, hflip, x2, y2, camX, camY, ox, oy)
local Ow = package.loaded["src.core.game3.ow_sprites"]
local spr = Ow and Ow.getDraw and Ow.getDraw(gid)
if not (spr and spr.quads and spr.quads[frame]) then return false end
local cx = obj.moving and obj.targetX or obj.cellX
local cy = obj.moving and obj.targetY or obj.cellY
local pcx, pcy = obj.cellX, obj.cellY
if obj == Player() then pcx, pcy = obj.prevCellX or cx, obj.prevCellY or cy end
ox, oy = ox or 0, oy or 0
local cx = (obj.moving and obj.targetX or obj.cellX) + ox
local cy = (obj.moving and obj.targetY or obj.cellY) + oy
local pcx, pcy = obj.cellX + ox, obj.cellY + oy
if obj == Player() then pcx, pcy = (obj.prevCellX or cx - ox) + ox, (obj.prevCellY or cy - oy) + oy end
local rtype = FxRse.reflectionType(cx, cy, pcx, pcy, spr.width, spr.height)
if rtype == 0 then return false end
local bridge = B.bridgeType(behaviorAt(pcx, pcy))
if bridge == 0 then bridge = B.bridgeType(behaviorAt(cx, cy)) end
local bridge = B.bridgeType(worldBehaviorAt(pcx, pcy))
if bridge == 0 then bridge = B.bridgeType(worldBehaviorAt(cx, cy)) end
local info = fcGfx(gid)
local tag = FxRse.reflectionPaletteTag(gid, bridge)
local src = (tag and recolored(gid, spr, tag)) or spr
@@ -667,8 +677,8 @@ local function drawReflection(obj, gid, frame, hflip, x2, y2, camX, camY)
local offs = { [1] = 12, [2] = 28, [3] = 44 }
local extra = (not (info and info.noReflectionLoad)) and offs[bridge] or 0
local w, h = spr.width, spr.height
local left = (obj.px or cx * CELL) + (16 - w) / 2 + (x2 or 0)
local top = (obj.py or cy * CELL) + 16 - h + (h - 2) + extra - (y2 or 0)
local left = (obj.px or (cx - ox) * CELL) + ox * CELL + (16 - w) / 2 + (x2 or 0)
local top = (obj.py or (cy - oy) * CELL) + oy * CELL + 16 - h + (h - 2) + extra - (y2 or 0)
local iw, ih = src.image:getDimensions()
reflQuad = reflQuad or love.graphics.newQuad(0, 0, 1, 1, iw, ih)
local fy = frame * h
@@ -677,7 +687,7 @@ local function drawReflection(obj, gid, frame, hflip, x2, y2, camX, camY)
love.graphics.setColor(1, 1, 1, 1)
for ty = c0y, c1y do
for tx = c0x, c1x do
local b = behaviorAt(tx, ty)
local b = worldBehaviorAt(tx, ty)
if b and B.reflective(b) then
local ix0, ix1 = math.max(left, tx * CELL), math.min(left + w, tx * CELL + CELL)
local iy0, iy1 = math.max(top, ty * CELL), math.min(top + h, ty * CELL + CELL)
@@ -723,17 +733,45 @@ local function coverReflections(camX, camY)
local mapDef = C and C._mapDef
local layout = mapDef and mapDef.midLayout
local NT = package.loaded["src.core.game3.tileset_native"]
if not (layout and NT) then return end
local ts = NT.get(mapDef.pair or layout.pair)
local shader = ts and ts.midImage and getCoverShader()
local Map = package.loaded["src.core.game3.map"]
if not (layout and NT and Map) then return end
local shader = getCoverShader()
if not shader then return end
shader:send("mask", ts.midImage)
love.graphics.setShader(shader)
love.graphics.setColor(1, 1, 1, 1)
local loaded = NT._pairs or {}
local np = 0
for i = 1, n, 2 do
local tx, ty = coverCells[i], coverCells[i + 1]
local q = NT.quad(ts, NT.slotFor(ts, layout:midAt(tx, ty)))
if q then love.graphics.draw(ts.image, q, tx * CELL - camX, ty * CELL - camY) end
local mid, pair = Map.worldMidAt(tx, ty, mapDef)
local ts = pair and loaded[pair]
if ts and ts.midImage then
local bucket = coverBuckets[pair]
if not bucket then
bucket = { n = 0 }
coverBuckets[pair] = bucket
end
if bucket.n == 0 then
np = np + 1
coverPairs[np] = pair
end
local b = bucket.n
bucket[b + 1], bucket[b + 2], bucket[b + 3] = tx, ty, mid
bucket.n = b + 3
end
end
for p = 1, np do
local pair = coverPairs[p]
local bucket = coverBuckets[pair]
local ts = loaded[pair]
shader:send("mask", ts.midImage)
for i = 1, bucket.n, 3 do
local tx, ty = bucket[i], bucket[i + 1]
local q = NT.quad(ts, NT.slotFor(ts, bucket[i + 2]))
if q then love.graphics.draw(ts.image, q, tx * CELL - camX, ty * CELL - camY) end
end
bucket.n = 0
coverPairs[p] = nil
end
love.graphics.setShader()
end
@@ -755,6 +793,43 @@ local function drawReflections(camX, camY)
end
end
end
local Map = package.loaded["src.core.game3.map"]
local Ghosts = package.loaded["src.core.game3.ghosts"]
local world = Map and Map.world
if O and Ghosts and Ghosts.forDraw and type(world) == "table" then
local C = Collision()
local host = C and C._mapDef
local FV = package.loaded["src.core.game3.field_view"]
local Display = require("src.core.game3.display")
local vw = FV and FV._viewW or Display.W
local vh = FV and FV._viewH or Display.H
local x0, y0 = camX - REFL_CULL, camY - REFL_CULL
local x1, y1 = camX + vw + REFL_CULL, camY + vh + REFL_CULL
for i = 1, #world do
local entry = world[i]
local ox, oy = entry.ox or 0, entry.oy or 0
local L = entry.def and entry.def.midLayout
local ex, ey = ox * CELL, oy * CELL
if entry.def ~= host and L
and ex + (L.width or 0) * CELL > x0 and ex < x1
and ey + (L.height or 0) * CELL > y0 and ey < y1 then
local live = Ghosts.forDraw(entry.id)
if live then
for j = 1, #live do
local eo = live[j]
if not eo.hideReflection and eo.graphicsId and not eo.virtualId then
local spr = Ow.getDraw(eo.graphicsId)
if spr then
ghostPose.frame = eo.customFrame
local frame, flip = Ow.pose(spr, eo.facing, O.walkPhase(eo), eo.stepFlip, ghostPose)
drawReflection(eo, eo.graphicsId, frame, flip, eo.raiseX, eo.raiseY, camX, camY, ox, oy)
end
end
end
end
end
end
end
local P = Player()
if P and P.isVisible and P.isVisible() and not P.hideReflection then
local rt = package.loaded["src.core.game3.runtime"]
+71 -23
View File
@@ -149,26 +149,27 @@ function Map.refreshWorld(game, reachW, reachH, rootId)
Map._loadedLayouts[entry.id] = true
end
local NativeTileset = package.loaded["src.core.game3.tileset_native"]
if Map._warmPairs and NativeTileset and NativeTileset.get then
if NativeTileset and NativeTileset.get then
local sync = Map._warmPairs
Map._warmPairs = nil
Map._warmQueue = nil
local x0, y0, x1, y1 = Map.warmRect()
local queue, seen, entries = {}, {}, {}
for _, entry in ipairs(Map.world) do
local pair = entry.def and (entry.def.pair or (entry.def.midLayout and entry.def.midLayout.pair))
if pair and NativeTileset.ready(pair) then pcall(NativeTileset.get, pair) end
end
elseif NativeTileset and NativeTileset.get then
-- Seamless crossing: queue the new world's unloaded pairs and load one per
-- frame (Map.stepWarm) so a pair is ready before it scrolls into view.
local queue, seen = {}, {}
for _, entry in ipairs(Map.world) do
local pair = entry.def and (entry.def.pair or (entry.def.midLayout and entry.def.midLayout.pair))
if type(pair) == "string" and not seen[pair]
and not (NativeTileset._pairs and NativeTileset._pairs[pair]) then
seen[pair] = true
queue[#queue + 1] = pair
if sync and pair and Map.warmNear(entry, x0, y0, x1, y1) then
if NativeTileset.ready(pair) then pcall(NativeTileset.get, pair) end
elseif type(pair) == "string" and not (NativeTileset._pairs and NativeTileset._pairs[pair]) then
if not seen[pair] then
seen[pair] = true
queue[#queue + 1] = pair
entries[pair] = {}
end
local list = entries[pair]
list[#list + 1] = entry
end
end
Map._warmQueue = queue[1] and queue or nil
Map._warmEntries = queue[1] and entries or nil
end
Map._worldRoot = rootId
Map._worldReachW = reachW
@@ -178,21 +179,68 @@ function Map.refreshWorld(game, reachW, reachH, rootId)
return Map.world
end
--- Load at most one queued tileset pair (see refreshWorld).
Map.WARM_MARGIN_SCREENS = 1
Map.WARM_BUDGET_SEC = 0.030
Map.WARM_MAX_DEFER = 3
Map._warmDefer = 0
function Map.warmRect()
local P = package.loaded["src.core.game3.player"]
local px, py = P and tonumber(P.cellX), P and tonumber(P.cellY)
if not (px and py) then return nil end
local FieldView = package.loaded["src.core.game3.field_view"]
local Display = package.loaded["src.core.game3.display"]
local vw = (FieldView and FieldView._viewW) or (Display and Display.W) or 240
local vh = (FieldView and FieldView._viewH) or (Display and Display.H) or 160
local cols, rows = math.ceil(vw / 16), math.ceil(vh / 16)
local mx = math.ceil(cols / 2) + 1 + cols * Map.WARM_MARGIN_SCREENS
local my = math.ceil(rows / 2) + 1 + rows * Map.WARM_MARGIN_SCREENS
return px - mx, py - my, px + mx, py + my
end
function Map.warmNear(entry, x0, y0, x1, y1)
if not x0 then return true end
local layout = entry.def and entry.def.midLayout
local w, h = layout and layout.width, layout and layout.height
if not (w and h) then return true end
local ox, oy = entry.ox or 0, entry.oy or 0
return ox + w > x0 and ox <= x1 and oy + h > y0 and oy <= y1
end
function Map.stepWarm()
local queue = Map._warmQueue
if not queue then return false end
local NativeTileset = package.loaded["src.core.game3.tileset_native"]
while queue[1] do
local pair = table.remove(queue, 1)
if NativeTileset and not (NativeTileset._pairs and NativeTileset._pairs[pair])
and NativeTileset.ready and NativeTileset.ready(pair) then
pcall(NativeTileset.get, pair)
if not queue[1] then Map._warmQueue = nil end
return true
local timer = love and love.timer
if timer and timer.getDelta and timer.getDelta() > Map.WARM_BUDGET_SEC
and Map._warmDefer < Map.WARM_MAX_DEFER then
Map._warmDefer = Map._warmDefer + 1
return false
end
Map._warmDefer = 0
local entries = Map._warmEntries or {}
local x0, y0, x1, y1 = Map.warmRect()
local i = 1
while queue[i] do
local pair = queue[i]
if NativeTileset and (NativeTileset._pairs and NativeTileset._pairs[pair])
or not (NativeTileset and NativeTileset.ready and NativeTileset.ready(pair)) then
table.remove(queue, i)
else
local near = not entries[pair]
for _, entry in ipairs(entries[pair] or {}) do
if Map.warmNear(entry, x0, y0, x1, y1) then near = true break end
end
if near then
table.remove(queue, i)
pcall(NativeTileset.get, pair)
if not queue[1] then Map._warmQueue = nil end
return true
end
i = i + 1
end
end
Map._warmQueue = nil
if not queue[1] then Map._warmQueue = nil end
return false
end
+59 -5
View File
@@ -21,6 +21,62 @@ function Palette.load(blob)
return NativePack.palsToRgb8(bgr), bgr
end
local bit = require("bit")
local band, bor, bxor, bnot = bit.band, bit.bor, bit.bxor, bit.bnot
local lshift, rshift, rol = bit.lshift, bit.rshift, bit.rol
local MD5_K, MD5_S = {}, {
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
}
for i = 0, 63 do
MD5_K[i + 1] = math.floor(math.abs(math.sin(i + 1)) * 4294967296) % 4294967296
end
local function md5hex(msg)
local len = #msg
local padLen = (55 - len) % 64
msg = msg .. "\128" .. string.rep("\0", padLen)
.. string.char(
(len * 8) % 256, math.floor(len * 8 / 256) % 256,
math.floor(len * 8 / 65536) % 256, math.floor(len * 8 / 16777216) % 256,
0, 0, 0, 0)
local a0, b0, c0, d0 = 0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476
local M = {}
for off = 1, #msg, 64 do
for j = 0, 15 do
local b1, b2, b3, b4 = msg:byte(off + j * 4, off + j * 4 + 3)
M[j] = bor(b1, lshift(b2, 8), lshift(b3, 16), lshift(b4, 24))
end
local A, B, C, D = a0, b0, c0, d0
for i = 0, 63 do
local F, g
if i < 16 then
F = bor(band(B, C), band(bnot(B), D)); g = i
elseif i < 32 then
F = bor(band(D, B), band(bnot(D), C)); g = (5 * i + 1) % 16
elseif i < 48 then
F = bxor(B, C, D); g = (3 * i + 5) % 16
else
F = bxor(C, bor(B, bnot(D))); g = (7 * i) % 16
end
F = F + A + MD5_K[i + 1] + M[g]
A = D; D = C; C = B
B = B + rol(F, MD5_S[i + 1])
end
a0, b0, c0, d0 = a0 + A, b0 + B, c0 + C, d0 + D
end
local out = {}
for _, v in ipairs({ a0, b0, c0, d0 }) do
for k = 0, 3 do out[#out + 1] = string.format("%02x", band(rshift(v, k * 8), 255)) end
end
return table.concat(out)
end
Palette._md5hex = md5hex
--- Stable hash of BGR555 pals (+ optional extraBlob) for RGBA disk cache keys.
function Palette.hash(bgrPals, extraBlob)
local parts = {}
@@ -33,13 +89,11 @@ function Palette.hash(bgrPals, extraBlob)
if extraBlob and type(extraBlob) == "string" then
parts[#parts + 1] = extraBlob
end
-- FNV-1a 32-bit over hex stream (stable, no bit lib).
local h = 2166136261
local s = table.concat(parts)
for i = 1, #s do
h = (h * 16777619 + s:byte(i)) % 4294967296
if love and love.data and love.data.hash then
return (love.data.encode("string", "hex", love.data.hash("md5", s)):sub(1, 16))
end
return string.format("%08x", h)
return md5hex(s):sub(1, 16)
end
--- Identity tint hook (weather / time later).
+12 -5
View File
@@ -28,6 +28,7 @@ function TilesetAnim.install(cache)
end
function TilesetAnim.invalidate()
TilesetAnim._manifests = nil
TilesetAnim._pairs = {}
TilesetAnim._visible = {}
TilesetAnim.counter = 0
@@ -42,9 +43,18 @@ local function native_root()
end
local function read_manifest(cache, pair)
local memo = TilesetAnim._manifests
if not memo then
memo = {}
TilesetAnim._manifests = memo
end
local hit = memo[pair]
if hit ~= nil then return hit or nil end
local src = cache:read(native_root() .. pair .. "/anim_manifest.lua")
local chunk = src and load(src, "@anim_manifest.lua", "t", {})
return chunk and chunk() or nil
local man = chunk and chunk() or nil
memo[pair] = man or false
return man
end
local function rse_luts(cache, pair)
@@ -295,10 +305,7 @@ function TilesetAnim.bindPair(pair, atlas)
local entry = TilesetAnim._pairs[pair]
if entry == false then return false end
if not entry then
local src = cache:read(
(Extract.CACHE_ROOT or "data/generated/gba") .. "/native/" .. pair .. "/anim_manifest.lua")
local chunk = src and load(src, "@anim_manifest.lua", "t", {})
local man = chunk and chunk()
local man = read_manifest(cache, pair)
if not man then
TilesetAnim._pairs[pair] = false
return false
+13 -7
View File
@@ -134,20 +134,26 @@ function NativePack.decodeIdx(blob)
midIds[i] = read_u16(blob, off)
off = off + 2
end
local pixels = {}
local dataOff = off
local n = midCount * 256
for i = 1, n do
pixels[i] = blob:byte(off + i - 1) or 0
end
return {
return setmetatable({
formatVersion = formatVersion,
flags = flags,
midCount = midCount,
atlasCols = atlasCols,
atlasRows = atlasRows,
midIds = midIds,
pixels = pixels,
}
}, {
__index = function(self, k)
if k ~= "pixels" then return nil end
local pixels = {}
for i = 1, n do
pixels[i] = blob:byte(dataOff + i - 1) or 0
end
rawset(self, "pixels", pixels)
return pixels
end,
})
end
function NativePack.encodePalettes(mapPals)
+1 -1
View File
@@ -991,7 +991,7 @@ function Birch:draw()
if self.showDialogue then
local w = WIN_TEXT
Kit.birchDialogueFrame(w.left, w.top, w.width, w.height)
if self.printer then self.printer:draw(w.left * 8, w.top * 8 + 1, { colors = Kit.messageColors() }) end
if self.printer then self.printer:draw(w.left * 8, w.top * 8 + 1, { colors = Kit.messageColors(), clip = { w.left * 8, w.top * 8, w.width * 8, w.height * 8 } }) end
end
if self.genderMenu then
local w = WIN_GENDER
+9
View File
@@ -461,6 +461,12 @@ function Printer:draw(x, y, opts)
opts = opts or {}
local colors = opts.colors or Kit.messageColors()
local cx, cy = x, y
local clip = opts.clip
local sx, sy, sw, sh
if clip then
sx, sy, sw, sh = love.graphics.getScissor()
love.graphics.intersectScissor(clip[1], clip[2], clip[3], clip[4])
end
for i, line in ipairs(self.lines) do
local ly = y + (i - 1) * self.pitch - self.scrollY
if line ~= "" then
@@ -471,6 +477,9 @@ function Printer:draw(x, y, opts)
end
cy = ly
end
if clip then
if sx then love.graphics.setScissor(sx, sy, sw, sh) else love.graphics.setScissor() end
end
if self.arrowFrame then
local img = Kit.rgbaImage("data/generated/gba/chrome/fonts/down_arrow.rgba", 8, 48)
if img then
+42
View File
@@ -0,0 +1,42 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "shots"
local function fail(msg)
print("FAIL " .. msg)
love.event.quit(1)
end
return function(game)
for _ = 1, 900 do
if game.phase == "boot" and game.boot then break end
U.wait(1)
end
game:_handleBootAction({ action = "new_game", name = "RED" })
U.wait(240)
local Map = require("src.core.game3.map")
local FxRse = require("src.core.game3.field_effects_rse")
local Ghosts = require("src.core.game3.ghosts")
local Objects = require("src.core.game3.objects")
local r104 = game.data.maps["EM_ROUTE104"]
if not r104 then return fail("EM_ROUTE104 missing") end
Map.load(nil, game, "EM_ROUTE104", { x = 28, y = 1, facing = "down" })
U.wait(60)
U.still(game, DIR .. "/em_control_player_on_own_map_reflects.png")
if (FxRse.lastReflections or 0) < 1 then return fail("control player has no reflection") end
Map.load(nil, game, "EM_RUSTBORO_CITY", { x = 28, y = 57, facing = "down" })
U.wait(60)
U.still(game, DIR .. "/em_rustboro_view_route104_no_npc.png")
local defs = { { localId = 1, index = 1, x = 28, y = 1, graphicsId = 5, movementType = 8, elevation = 3, flag = 0 } }
Ghosts._pools["EM_ROUTE104"] = Objects.spawnFromDefs(defs, r104, "EM_ROUTE104")
U.wait(30)
U.still(game, DIR .. "/em_rustboro_view_route104_ghost_npc_on_shore.png")
local n = FxRse.lastReflections or 0
print("GHOST_VIEW_REFL", n)
if n < 1 then return fail("neighbor NPC has no reflection") end
print("PASS")
love.event.quit(0)
end
+2 -2
View File
@@ -38,8 +38,8 @@ return function(game)
U.wait(20)
expect(require("src.ui.game3.stack").busy(), "h1_start_menu_open")
key("1")
expect(game.options.speedOverworld == 2 and game.options.speedMenu == 1,
"h1_start_menu_1_bumps_overworld")
expect(game.options.speedOverworld == 2 and game.options.speedMenu == 2
and game.options.speedBattle == 2, "h1_start_menu_1_syncs_all_categories")
game.speedOverride = 1
U.still(game, ".bazinga/BSA/09-24-26-01-followup/shots/game3_speed_hotkey_h1/h1_start_menu_speed2.png")
game.speedOverride = nil
+3 -2
View File
@@ -33,7 +33,8 @@ do
eq(g:speedCategory(), "overworld", "menu over the field inherits overworld")
g:keypressed("1")
eq(g.options.speedOverworld, 2, "1 with a field menu open bumps OVERWORLD SPEED")
eq(g.options.speedMenu, 1, "1 with a field menu open leaves MENU SPEED alone")
eq(g.options.speedMenu, 2, "1 with a field menu open syncs MENU SPEED")
eq(g.options.speedBattle, 2, "and syncs BATTLE SPEED")
stackBusy = false
eq(g:logicSpeed(), 2, "walk speed sticks after the menu closes")
battleActive = true
@@ -42,7 +43,7 @@ do
eq(g:logicSpeed(), 10, "a local gen3 battle runs at BATTLE SPEED")
g:keypressed("1")
check(g.options.speedBattle ~= 10, "1 in battle cycles BATTLE SPEED")
eq(g.options.speedOverworld, 2, "and leaves OVERWORLD SPEED alone")
eq(g.options.speedOverworld, g.options.speedBattle, "and syncs OVERWORLD SPEED")
battleActive = false
eq(newGame3("boot"):speedCategory(), "menu", "boot phase stays menu")
eq(newGame3("quest_log"):speedCategory(), "menu", "quest log stays menu")
+19 -3
View File
@@ -180,8 +180,8 @@ do
function g:writeOptions() writeOptions.calls = writeOptions.calls + 1 end
g:_cycleSpeed(1)
eq(g.save.options.speedBattle, 2, "cycling during battle bumps speedBattle")
eq(g.save.options.speedOverworld, 1, "...and leaves speedOverworld alone")
eq(g.save.options.speedMenu, 1, "...and leaves speedMenu alone")
eq(g.save.options.speedOverworld, 2, "...and syncs speedOverworld")
eq(g.save.options.speedMenu, 2, "...and syncs speedMenu")
eq(writeOptions.calls, 1, "a successful cycle persists the option")
end
do
@@ -200,7 +200,8 @@ do
function g:writeOptions() end
g:_cycleSpeed(1)
eq(g.save.options.speedOverworld, 2, "cycling on the overworld bumps speedOverworld")
eq(g.save.options.speedBattle, 1, "...and leaves speedBattle alone")
eq(g.save.options.speedBattle, 2, "...and syncs speedBattle")
eq(g.save.options.speedMenu, 2, "...and syncs speedMenu")
end
do
local g = gameWith({ overlay },
@@ -208,6 +209,21 @@ do
function g:writeOptions() end
g:_cycleSpeed(1)
eq(g.save.options.speedMenu, 2, "cycling in a menu bumps speedMenu")
eq(g.save.options.speedOverworld, 2, "...and syncs speedOverworld")
end
do
local g = gameWith({ overworld },
{ speedOverworld = 3, speedBattle = 10, speedMenu = 1 })
function g:writeOptions() end
g:_cycleSpeed(-1)
eq(g.save.options.speedOverworld, 2, "speed down steps the active category")
eq(g.save.options.speedBattle, 2, "speed down syncs battle")
eq(g.save.options.speedMenu, 2, "speed down syncs menu")
g:_cycleSpeed(-1)
g:_cycleSpeed(-1)
eq(g.save.options.speedOverworld, GameSpeed.LEVELS[#GameSpeed.LEVELS],
"wrapping down from NORMAL lands on the top level for every category")
eq(g.save.options.speedMenu, GameSpeed.LEVELS[#GameSpeed.LEVELS], "including menu")
end
-- A cart may narrow the ladder (CartManifest's `speeds`), and returning to
+1
View File
@@ -181,6 +181,7 @@ local GEN3_HOOKS = {
"trainer.party", "catch.rate", "exp.gain", "evolution.check",
"battle.damage", "battle.crit", "battle.accuracy", "battle.charge_required",
"battle.run", "battle.turn_order", "battle.enemy_action",
"battle.low_health_alarm",
}
local function assertShared(name, sites, kind)
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
require("tests.game3_cache").requireData("game3_low_hp_hook_test")
local failed = 0
local function check(cond, msg)
if cond then
print("[ok] " .. msg)
else
failed = failed + 1
print("[FAIL] " .. msg)
end
end
local Audio = require("src.core.game3.audio")
local SE = require("src.core.game3.se_ids")
local ModRuntime = require("src.mods.Runtime")
local Battle = require("src.core.game3.battle")
local plays, stops = 0, 0
Audio.playSe = function(id)
if id == SE.SE_LOW_HEALTH then plays = plays + 1 end
end
Audio.stopSe = function(id)
if id == SE.SE_LOW_HEALTH then stops = stops + 1 end
end
local function reset()
plays, stops = 0, 0
Battle._lowHpSong = false
Battle._st = { player = { mon = { hp = 1, maxHp = 100 } } }
end
local function step(n)
for _ = 1, n do Battle._updateLowHpMusic() end
end
reset()
step(3)
check(plays == 1, "no hook: siren starts once")
reset()
ModRuntime.hooks = {
chains = { ["battle.low_health_alarm"] = {} },
call = function(_, _, vanilla, ctx)
ctx.on = false
return vanilla(ctx)
end,
}
step(3)
check(plays == 0, "hook forcing on=false suppresses siren")
reset()
local sawBattle = false
ModRuntime.hooks = {
chains = { ["battle.low_health_alarm"] = {} },
call = function(_, _, vanilla, ctx)
sawBattle = ctx.battle == Battle._st and ctx.on == true
return vanilla(ctx)
end,
}
step(2)
check(plays == 1 and sawBattle, "pass-through hook sees on=true and battle, siren plays once")
if failed > 0 then
print(failed .. " failed")
os.exit(1)
end
print("all passed")
os.exit(0)
+79
View File
@@ -0,0 +1,79 @@
package.path = package.path .. ";./?.lua"
local calls = {}
local scissor = nil
love = love or {}
love.graphics = love.graphics or {}
love.graphics.getScissor = function()
if scissor then return scissor[1], scissor[2], scissor[3], scissor[4] end
return nil
end
love.graphics.setScissor = function(x, y, w, h)
if x then scissor = { x, y, w, h } else scissor = nil end
end
love.graphics.intersectScissor = function(x, y, w, h)
if not scissor then
scissor = { x, y, w, h }
return
end
local x1 = math.max(scissor[1], x)
local y1 = math.max(scissor[2], y)
local x2 = math.min(scissor[1] + scissor[3], x + w)
local y2 = math.min(scissor[2] + scissor[4], y + h)
scissor = { x1, y1, math.max(0, x2 - x1), math.max(0, y2 - y1) }
end
love.graphics.setColor = function() end
love.graphics.draw = function() end
local okKit, Kit = pcall(require, "src.ui.game3.rse.scene_kit")
if not okKit then
print("[skip] scene_kit needs runtime: " .. tostring(Kit))
os.exit(0)
end
local FrlgFont = require("src.ui.game3.frlg_font")
FrlgFont.draw = function(line, x, y)
calls[#calls + 1] = { y = y, scissor = scissor and { scissor[1], scissor[2], scissor[3], scissor[4] } or nil }
return x + 8, nil
end
FrlgFont.measure = function() return 8 end
Kit.messageColors = function() return {} end
local okP, p = pcall(Kit.printer, {}, { speed = 0 })
if not okP then
print("[skip] printer needs cache: " .. tostring(p))
os.exit(0)
end
local failed = 0
local function check(cond, msg)
if cond then
print(" PASS: " .. msg)
else
failed = failed + 1
print(" FAIL: " .. msg)
end
end
p.lines = { "aaa", "bbb", "" }
p.scrollY = 5
p.arrowFrame = nil
local clip = { 16, 120, 216, 32 }
p:draw(16, 121, { clip = clip })
check(#calls == 2, "lines drawn")
local s = calls[1].scissor
check(s and s[1] == 16 and s[2] == 120 and s[3] == 216 and s[4] == 32, "scissor equals clip while drawing")
check(scissor == nil, "scissor restored to none")
scissor = { 0, 0, 240, 100 }
calls = {}
p:draw(16, 121, { clip = clip })
check(calls[1].scissor[2] == 120 and calls[1].scissor[4] == 0 + 100 - 120 + 0 or calls[1].scissor[4] == 0, "clip intersects outer scissor")
check(scissor and scissor[4] == 100, "outer scissor restored")
scissor = nil
calls = {}
p:draw(16, 121)
check(calls[1].scissor == nil, "no clip leaves scissor untouched")
os.exit(failed == 0 and 0 or 1)
+77
View File
@@ -0,0 +1,77 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local failed = 0
local function check(ok, msg)
if not ok then
failed = failed + 1
print("FAIL " .. msg)
end
end
love = love or {}
local draws = 0
love.graphics = love.graphics or {}
love.graphics.setColor = function() end
love.graphics.newQuad = function() return { setViewport = function() end } end
love.graphics.draw = function() draws = draws + 1 end
local okMap, Map = pcall(require, "src.core.game3.map")
if not okMap then
print("[skip] map needs runtime: " .. tostring(Map))
os.exit(0)
end
local Collision = require("src.core.game3.collision")
local Scripts = require("src.core.game3.scripting.interaction_scripts")
local MB = require("src.core.game3.mb")
local FxRse = require("src.core.game3.field_effects_rse")
local WATER = MB.id("POND_WATER")
local function layout(pair, mid)
return { width = 4, height = 4, pair = pair, midAt = function() return mid end }
end
local host = { midLayout = layout("test_host", 1), pair = "test_host" }
local south = { midLayout = layout("test_south", 7), pair = "test_south" }
Scripts.behaviors.test_host = {}
Scripts.behaviors.test_south = { [7] = WATER }
Collision._mapDef = host
Map.neighborList = { { dir = "south", offset = 0, def = south } }
Map.world = {
{ id = "HOST", def = host, ox = 0, oy = 0 },
{ id = "SOUTH", def = south, ox = 0, oy = 4 },
}
check(Collision.behavior(1, 5) == nil, "gameplay behavior stays bounded to the current map")
check(Collision.worldBehavior and Collision.worldBehavior(1, 5) == WATER,
"worldBehavior resolves a neighbor cell across the seam")
check(Collision.worldBehavior and Collision.worldBehavior(1, 1) == nil,
"worldBehavior keeps current-map cells")
local spr = {
width = 16, height = 32, frameCount = 1, quads = { [0] = true },
image = { getDimensions = function() return 16, 32 end },
}
package.loaded["src.core.game3.ow_sprites"] = {
getDraw = function() return spr end,
pose = function() return 0, false end,
}
FxRse._fc = { gfx = {}, palTagNone = 0x11FF }
local ghost = { cellX = 1, cellY = 0, px = 16, py = 0, moving = false }
draws = 0
check(FxRse.drawReflection(ghost, 5, 0, false, 0, 0, 0, 0, 0, 4) == true,
"ghost object on a neighbor map reflects off neighbor water")
check(draws > 0, "ghost reflection draws quads")
local near = { cellX = 1, cellY = 2, px = 16, py = 32, moving = false }
draws = 0
check(FxRse.drawReflection(near, 5, 0, false, 0, 0, 0, 0) == true,
"current-map object next to the seam reflects off neighbor water")
local dry = { cellX = 1, cellY = 0, px = 16, py = 0, moving = false }
check(FxRse.drawReflection(dry, 5, 0, false, 0, 0, 0, 0) == false,
"object with no water below does not reflect")
if failed > 0 then os.exit(1) end
print("game3_reflection_neighbor_test ok")
+121
View File
@@ -0,0 +1,121 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local failures = 0
local function check(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failures = failures + 1 end
end
local NativePack = require("src.import.gba.native_pack")
local Palette = require("src.core.game3.palette")
do
local pixels = {}
for i = 1, 512 do pixels[i] = (i * 7) % 256 end
local blob = NativePack.encodeIdx({
midCount = 2, atlasCols = 16, atlasRows = 1, midIds = { 0, 5 }, pixels = pixels,
})
local t = NativePack.decodeIdx(blob)
check(rawget(t, "pixels") == nil, "decodeIdx leaves pixels undecoded")
check(t.midCount == 2 and t.midIds[2] == 5, "header and midIds decode eagerly")
local px = t.pixels
check(#px == 512 and px[1] == pixels[1] and px[512] == pixels[512], "pixels decode on first access")
check(rawget(t, "pixels") == px and t.pixels == px, "pixels decode once")
local rgb = {}
for p = 0, 15 do
rgb[p] = {}
for c = 0, 15 do rgb[p][c] = { p * 10, c * 10, 3 } end
end
local rgba, w, h = NativePack.bakeRgba(NativePack.decodeIdx(blob), rgb, {})
check(#rgba == w * h * 4, "bakeRgba works on a lazy table")
end
do
check(Palette._md5hex("") == "d41d8cd98f00b204e9800998ecf8427e", "md5 empty vector")
check(Palette._md5hex("The quick brown fox jumps over the lazy dog") == "9e107d9d372bb6826bd81d3542a419d6",
"md5 fox vector")
local bgr = {}
for p = 0, 15 do
bgr[p] = {}
for c = 0, 15 do bgr[p][c] = (p * 977 + c * 31) % 32768 end
end
local chunks = {}
for i = 1, 4099 do chunks[i] = string.char((i * 13 + i % 7) % 256) end
local extra = table.concat(chunks)
local key = Palette.hash(bgr, extra)
check(key:match("^%x+$") and #key == 16, "Palette.hash is 16 hex chars")
check(Palette.hash(bgr, extra) == key, "Palette.hash is deterministic")
local tweaked = extra:sub(1, 2000) .. "\255" .. extra:sub(2002)
check(Palette.hash(bgr, tweaked) ~= key, "one byte of extra changes the key")
bgr[3][4] = (bgr[3][4] + 1) % 32768
check(Palette.hash(bgr, extra) ~= key, "one palette color changes the key")
bgr[3][4] = (bgr[3][4] - 1) % 32768
check(Palette.hash(bgr, extra) == key, "restoring the color restores the key")
local savedLove = love
love = { data = {
hash = function(_, s) return (Palette._md5hex(s):gsub("%x%x", function(h) return string.char(tonumber(h, 16)) end)) end,
encode = function(_, _, s) return (s:gsub(".", function(c) return string.format("%02x", c:byte()) end)) end,
} }
check(Palette.hash(bgr, extra) == key, "love.data path yields the same key as the fallback")
love = savedLove
end
do
local loads = {}
local Native = { _pairs = {} }
function Native.ready() return true end
function Native.get(pair) loads[#loads + 1] = pair; Native._pairs[pair] = {}; return {} end
package.loaded["src.core.game3.tileset_native"] = Native
package.loaded["src.core.game3.player"] = { cellX = 10, cellY = 10 }
local Map = require("src.core.game3.map")
local function def(pair, w, h) return { pair = pair, midLayout = { pair = pair, width = w, height = h } } end
Map.computeWorld = function()
return {
{ id = "NEAR", def = def("pnear", 20, 20), ox = 20, oy = 0 },
{ id = "FAR", def = def("pfar", 20, 20), ox = 400, oy = 400 },
}
end
Map.ensureMidLayout = function() end
Map._warmPairs = nil
Map._worldRoot = nil
Map.refreshWorld({ data = { maps = {} } }, 10, 10, "ROOT")
check(#Map._warmQueue == 2, "both pairs queued")
Map.stepWarm()
check(#loads == 1 and loads[1] == "pnear", "near pair loads first")
Map.stepWarm()
check(#loads == 1 and Map._warmQueue and #Map._warmQueue == 1, "far pair is not loaded")
package.loaded["src.core.game3.player"].cellX = 400
package.loaded["src.core.game3.player"].cellY = 400
Map.stepWarm()
check(#loads == 2 and loads[2] == "pfar" and Map._warmQueue == nil, "far pair loads once in range")
Native._pairs = {}
loads = {}
package.loaded["src.core.game3.player"].cellX = 10
package.loaded["src.core.game3.player"].cellY = 10
Map._warmPairs = true
Map._worldRoot = nil
Map.refreshWorld({ data = { maps = {} } }, 10, 10, "ROOT")
check(#loads == 1 and loads[1] == "pnear" and Map._warmQueue and #Map._warmQueue == 1,
"warp preload only loads near pairs synchronously")
love = { timer = { getDelta = function() return 0.2 end } }
Map._warmDefer = 0
loads = {}
Native._pairs = {}
Map._warmQueue = { "pnear" }
Map._warmEntries = {}
Map.stepWarm()
check(#loads == 0, "slow frame defers the load")
Map.stepWarm()
Map.stepWarm()
Map.stepWarm()
check(#loads == 1, "deferral is bounded")
love = nil
package.loaded["src.core.game3.tileset_native"] = nil
package.loaded["src.core.game3.player"] = nil
end
print(failures == 0 and "PASS game3_seam_warm_budget_test"
or ("FAIL game3_seam_warm_budget_test failures=" .. failures))
os.exit(failures == 0 and 0 or 1)