From 6806d5a235e8a24ad2c84d8ea1849e9a2aae1350 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 30 Sep 2026 13:54:23 -0400 Subject: [PATCH] CLOSES #2577, CLOSES #2573, CLOSES #2572 --- src/core/Game.lua | 5 +- src/core/Game3.lua | 5 +- src/core/game3/battle/init.lua | 26 +++-- src/core/game3/collision.lua | 12 ++ src/core/game3/field_effects.lua | 57 +++++++-- src/core/game3/field_effects_rse.lua | 109 +++++++++++++++--- src/core/game3/map.lua | 94 +++++++++++---- src/core/game3/palette.lua | 64 ++++++++++- src/core/game3/tileset_anim.lua | 17 ++- src/import/gba/native_pack.lua | 20 ++-- src/ui/game3/rse/birch_speech.lua | 2 +- src/ui/game3/rse/scene_kit.lua | 9 ++ tests/drivers/em_neighbor_reflection.lua | 42 +++++++ tests/drivers/game3_speed_hotkey_h1.lua | 4 +- tests/engine/game3_speed_hotkey_test.lua | 5 +- tests/engine/game_speed_categories_test.lua | 22 +++- tests/engine/gate_gen3_mod_api.lua | 1 + tests/game3_low_hp_hook_test.lua | 71 ++++++++++++ tests/game3_printer_clip_test.lua | 79 +++++++++++++ tests/game3_reflection_neighbor_test.lua | 77 +++++++++++++ tests/game3_seam_warm_budget_test.lua | 121 ++++++++++++++++++++ 21 files changed, 760 insertions(+), 82 deletions(-) create mode 100644 tests/drivers/em_neighbor_reflection.lua create mode 100644 tests/game3_low_hp_hook_test.lua create mode 100644 tests/game3_printer_clip_test.lua create mode 100644 tests/game3_reflection_neighbor_test.lua create mode 100644 tests/game3_seam_warm_budget_test.lua diff --git a/src/core/Game.lua b/src/core/Game.lua index 91f1ba95..8addc853 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -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 diff --git a/src/core/Game3.lua b/src/core/Game3.lua index 43fe50d6..fd37eb81 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -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 diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index e0cc7631..90cdde87 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -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 diff --git a/src/core/game3/collision.lua b/src/core/game3/collision.lua index fe5f831b..7acc45f3 100644 --- a/src/core/game3/collision.lua +++ b/src/core/game3/collision.lua @@ -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 diff --git a/src/core/game3/field_effects.lua b/src/core/game3/field_effects.lua index 9debabef..df207d5f 100644 --- a/src/core/game3/field_effects.lua +++ b/src/core/game3/field_effects.lua @@ -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) diff --git a/src/core/game3/field_effects_rse.lua b/src/core/game3/field_effects_rse.lua index adf8c083..7efc0412 100644 --- a/src/core/game3/field_effects_rse.lua +++ b/src/core/game3/field_effects_rse.lua @@ -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"] diff --git a/src/core/game3/map.lua b/src/core/game3/map.lua index 039f37bf..0f688092 100644 --- a/src/core/game3/map.lua +++ b/src/core/game3/map.lua @@ -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 diff --git a/src/core/game3/palette.lua b/src/core/game3/palette.lua index f8a86b57..5a75121c 100644 --- a/src/core/game3/palette.lua +++ b/src/core/game3/palette.lua @@ -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). diff --git a/src/core/game3/tileset_anim.lua b/src/core/game3/tileset_anim.lua index 5ea5969a..cf788842 100644 --- a/src/core/game3/tileset_anim.lua +++ b/src/core/game3/tileset_anim.lua @@ -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 diff --git a/src/import/gba/native_pack.lua b/src/import/gba/native_pack.lua index 7d958678..4febe655 100644 --- a/src/import/gba/native_pack.lua +++ b/src/import/gba/native_pack.lua @@ -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) diff --git a/src/ui/game3/rse/birch_speech.lua b/src/ui/game3/rse/birch_speech.lua index c837d55e..2353f7e0 100644 --- a/src/ui/game3/rse/birch_speech.lua +++ b/src/ui/game3/rse/birch_speech.lua @@ -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 diff --git a/src/ui/game3/rse/scene_kit.lua b/src/ui/game3/rse/scene_kit.lua index a3c2516b..e3f6776a 100644 --- a/src/ui/game3/rse/scene_kit.lua +++ b/src/ui/game3/rse/scene_kit.lua @@ -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 diff --git a/tests/drivers/em_neighbor_reflection.lua b/tests/drivers/em_neighbor_reflection.lua new file mode 100644 index 00000000..9d1996d5 --- /dev/null +++ b/tests/drivers/em_neighbor_reflection.lua @@ -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 diff --git a/tests/drivers/game3_speed_hotkey_h1.lua b/tests/drivers/game3_speed_hotkey_h1.lua index 0cb9d189..38e0f4a5 100644 --- a/tests/drivers/game3_speed_hotkey_h1.lua +++ b/tests/drivers/game3_speed_hotkey_h1.lua @@ -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 diff --git a/tests/engine/game3_speed_hotkey_test.lua b/tests/engine/game3_speed_hotkey_test.lua index 9ca29c62..404ad6ea 100644 --- a/tests/engine/game3_speed_hotkey_test.lua +++ b/tests/engine/game3_speed_hotkey_test.lua @@ -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") diff --git a/tests/engine/game_speed_categories_test.lua b/tests/engine/game_speed_categories_test.lua index 85c8ad05..0cfbb990 100644 --- a/tests/engine/game_speed_categories_test.lua +++ b/tests/engine/game_speed_categories_test.lua @@ -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 diff --git a/tests/engine/gate_gen3_mod_api.lua b/tests/engine/gate_gen3_mod_api.lua index 92209c7e..cb1f4d15 100644 --- a/tests/engine/gate_gen3_mod_api.lua +++ b/tests/engine/gate_gen3_mod_api.lua @@ -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) diff --git a/tests/game3_low_hp_hook_test.lua b/tests/game3_low_hp_hook_test.lua new file mode 100644 index 00000000..db4e4b2e --- /dev/null +++ b/tests/game3_low_hp_hook_test.lua @@ -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) diff --git a/tests/game3_printer_clip_test.lua b/tests/game3_printer_clip_test.lua new file mode 100644 index 00000000..24fadb6f --- /dev/null +++ b/tests/game3_printer_clip_test.lua @@ -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) diff --git a/tests/game3_reflection_neighbor_test.lua b/tests/game3_reflection_neighbor_test.lua new file mode 100644 index 00000000..f73d053f --- /dev/null +++ b/tests/game3_reflection_neighbor_test.lua @@ -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") diff --git a/tests/game3_seam_warm_budget_test.lua b/tests/game3_seam_warm_budget_test.lua new file mode 100644 index 00000000..e89155af --- /dev/null +++ b/tests/game3_seam_warm_budget_test.lua @@ -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)