From f12b564dcde63a98703e200355d489ad576dc248 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Thu, 30 Jul 2026 21:34:25 -0400 Subject: [PATCH] moved route 1 battle location again, added transparency to bottom battle menu --- CHANGELOG.md | 48 +++++ README.md | 9 +- data/battle_arenas.lua | 11 +- lib/ImageCache.lua | 65 +++++++ lib/OverworldBattle.lua | 206 ++++++++++++++++++-- lib/Perf.lua | 353 ++++++++++++++++++++++++++++++++++ main.lua | 22 ++- manifest.json | 2 +- mod.card | 2 + tests/dramatic_shape_test.lua | 102 +++++++++- tests/route1_candidates.lua | 218 +++++++++++++++++++++ tests/voxel_bench.lua | 246 +++++++++++++++++++++++ tests/voxel_shots_ab.lua | 346 +++++++++++++++++++++++++++++++++ 13 files changed, 1606 insertions(+), 24 deletions(-) create mode 100644 lib/ImageCache.lua create mode 100644 lib/Perf.lua create mode 100644 tests/route1_candidates.lua create mode 100644 tests/voxel_bench.lua create mode 100644 tests/voxel_shots_ab.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 4557c58..6e7f461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,53 @@ # Changelog +## 1.3.0 + +### Added + +- **BACK, a new row under 3D-BTL: your own Pokémon stays on the battle menu.** + The staged shot stands both mons on the map, which is the mode's whole claim + -- and it costs the framing Gen 1 is most recognisable by: your own Pokémon, + seen from behind, sitting on top of the battle menu with its feet on the box. + + With BACK on the foe is still geometry standing on its own tile at the far + end of the arena, and the player's side goes back to being the GB's own flat + back pic in the GB's own slot: same art, same 2x, same feet on row 96. It is + the engine's own pics layer that draws it, through the `onlySide` argument + that layer already takes, so every pic effect -- the grow-out-of-the-ball, + the faint slide, the damage blink, the send-out trainer pic -- comes along + unchanged and none of it is reimplemented. + + Nothing else about the shot moves. The arena, the camera and the drift are + solved exactly as they were, so the foe stands where it always stood and the + player's cell is simply empty ground in the foreground. Two things follow the + setting: the `pokemon.sprite` hook stops asking for the front pic on the + player's side (it is a back view again, and the front art would be that mon + turned round to face the player it belongs to), and the move-animation offset + drops that side's contribution, because a pic that has not moved cannot have + moved the pair's centre. + + OFF by default -- what the mode advertises is the two of them out there -- + and only on the OPTIONS menu while 3D-BTL is on, since with staged battles + off the engine already draws exactly this. + +### Changed + +- **The battle's text box and menus are frosted glass, like the HUDs.** The + HUD blocks got panels because black glyphs on grass are not readable. The box + at the bottom had the opposite problem and the same cause: it is drawn as an + opaque white slab with a black border, which was the field's own colour back + when the field was white and is a sheet of paper laid over the bottom third + of the diorama now that it is not. + + It gets exactly what the HUDs get -- the world behind it, blurred to frosted + glass and laid back down translucent, at the same frost and the same tint -- + and it is measured into the same brightness verdict, so the ink over the menu + flips white with the ink over the HUDs rather than against it. Only the FILL + is taken away: the border, the text, the cursor and the down arrow are the + engine's own glyphs in their own places. The move menu's TYPE/PP box and + Mimic's copy menu get their own panels, trimmed to the rows above the box + below them so no pixel is frosted twice. + ## 1.2.1 ### Fixed diff --git a/README.md b/README.md index e87e059..7fae02b 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,14 @@ menu. | `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) | | `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon | | `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field | +| the **BACK** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it | | the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors; held at SYNC (and off the menu) while VOXEL is FULL | **3D-BTL** is on by default and is independent of **VOXEL**: battles draw -on the world whether or not the free-roam camera is pitched over. \ No newline at end of file +on the world whether or not the free-roam camera is pitched over. + +Everything the battle screen draws as a box — the two HUD blocks, the text +box and the menus over it — sits on frosted glass rather than on the white +field it used to have behind it: the world underneath, blurred and laid back +down translucent, with the ink flipping white where the ground it lands on is +dark. Nothing the engine draws inside a box moves; only the paper is gone. \ No newline at end of file diff --git a/data/battle_arenas.lua b/data/battle_arenas.lua index 96da891..52714a1 100644 --- a/data/battle_arenas.lua +++ b/data/battle_arenas.lua @@ -41,8 +41,15 @@ return { -- ------- routes -- narrow, deliberately: the route's interior is a 3-cell-wide lane and the -- wide shape only fits in the western connection border, which staged every - -- fight at the edge of the world instead of on the road - ["ROUTE_1"] = { x = 9, y = 16, shape = "narrow" }, + -- fight at the edge of the world instead of on the road. + -- + -- Of the seventeen spots the route has outside that border, fourteen are + -- this one mid-route clearing and the other three bury the near mon behind + -- a hedge -- which the clearance test passes, since it measures terrain + -- height along the sightline and a hedge in the apron row is not terrain. + -- So the choice is where in the clearing, and this is its west end: tree + -- line square behind the pair, nothing crossing either of them. + ["ROUTE_1"] = { x = 4, y = 14, shape = "narrow" }, ["ROUTE_2"] = { x = 1, y = 49, shape = "wide" }, ["ROUTE_3"] = { x = 57, y = 1, shape = "wide" }, ["ROUTE_4"] = { x = 46, y = 7, shape = "wide" }, diff --git a/lib/ImageCache.lua b/lib/ImageCache.lua new file mode 100644 index 0000000..475acc5 --- /dev/null +++ b/lib/ImageCache.lua @@ -0,0 +1,65 @@ +-- Voxel world mode: decoded pixels, kept. +-- +-- Assets.imageData is deliberately uncached upstream -- "pixel-level reads +-- resolve the same way but stay uncached: the caller keeps the derived +-- product" (src/render/Assets.lua) -- which is the right contract for the +-- flat renderer, whose one caller decodes a strip once and keeps the strip. +-- +-- This mod is not that caller. It reads the same handful of images over and +-- over, from several places that do not know about each other: +-- +-- * the tileset atlas, decoded by Structures (its own cache), by +-- TerrainAtlas twice (the SGB bake and the RED++ rebake), by +-- TerrainAtlas again to learn a tile's shades, and by GlassMask; +-- * the FLOWER FRAME files, decoded inside patch() -- which runs every +-- time the animation step turns over, about three times a second, for +-- as long as the map is on screen. That one is not a load cost at all, +-- it is a recurring per-second cost on the render thread, and it was +-- the single clearest waste the first profile turned up. +-- +-- So: one table, keyed by the path as the CALLER gave it, holding the +-- decoded ImageData. Registered with Assets.invalidate so a hot reload +-- drops it alongside every other downstream cache. +-- +-- The entries are never evicted by size. That is deliberate and bounded: +-- what lands here is tileset art and animation frames -- a few dozen small +-- images for a whole session, tens of kilobytes each -- not per-map bakes, +-- which have their own eviction in TerrainAtlas.setLive. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Assets = require("src.render.Assets") +local Perf = V.require("Perf") + +local ImageCache = {} + +local cache = {} + +-- The decoded pixels for `path`, or nil when it cannot be read. +-- +-- `false` is cached for an unreadable path, so a missing or corrupt asset +-- costs one failed decode for the session rather than one per frame -- the +-- same sticky-failure shape the rest of this mod uses for GPU objects. +function ImageCache.get(path) + if not path then return nil end + local hit = cache[path] + if hit ~= nil then + Perf.count("imageCache.hit") + return hit or nil + end + local t0 = Perf.now() + local ok, data = pcall(Assets.imageData, path) + Perf.add("ImageCache.decode", t0) + Perf.count("imageCache.miss") + cache[path] = (ok and data) or false + return cache[path] or nil +end + +function ImageCache.invalidate() + cache = {} +end + +Assets.register(ImageCache.invalidate) + +return ImageCache diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 83e669d..75ab330 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -72,6 +72,37 @@ function OverworldBattle.enabled() return OverworldBattle.setting:get() and true or false end +-- ------- BACK: the player's own mon stays on the menu +-- +-- The staged shot stands BOTH mons on the map, which is the mode's whole +-- claim -- but it costs the one piece of framing Gen 1 is most recognisable +-- by: your own Pokemon, seen from behind, sitting on top of the battle menu +-- with its feet on the box. That silhouette is the series' shot. +-- +-- So BACK is offered as a middle setting rather than a compromise imposed on +-- everyone. With it on the foe is still geometry standing on its tile at the +-- far end of the arena, and the player's side goes back to being the GB's own +-- flat back pic in the GB's own slot: same art, same 2x, same feet on row 96. +-- Nothing else about the shot moves -- the arena, the camera and the drift are +-- solved exactly as they were, so the foe stands where it always stood and the +-- player's cell is simply empty ground in the foreground. +-- +-- OFF by default: what the mode advertises is the pair of them out there. +OverworldBattle.BACK_KEY = "battleBack" +OverworldBattle.BACK_LABEL = "BACK" + +OverworldBattle.backSetting = ModSetting.new(OverworldBattle.BACK_KEY, + OverworldBattle.BACK_LABEL, + { false, true }, { "OFF", "ON" }) + +-- Gated on 3D-BTL rather than read alone: with staged battles off there is no +-- staged shot for a back pic to be pinned in FRONT of, and the engine's own +-- battle screen already draws exactly this. +function OverworldBattle.backPinned() + if not OverworldBattle.enabled() then return false end + return OverworldBattle.backSetting:get() and true or false +end + -- ------- both mons face you -- -- Standing on a map, seen from in front, a Pokemon showing you its BACK is @@ -81,6 +112,10 @@ end -- through the engine's own pokemon.sprite hook -- the seam that exists for -- exactly this, so no battle code has to be touched to get it. -- +-- Unless BACK is on, which is the setting that asks for the back pic back: +-- that mon is drawn in its own slot on the menu, seen from behind, and the +-- front art would be it turned round to face the player it belongs to. +-- -- Answered BEFORE a battle exists, because the battler is built before the -- battle is pushed. So it cannot ask whether this fight is staged; it asks -- whether one on this map WOULD be -- the row is on, the 3D pass is @@ -91,6 +126,7 @@ local staged = { mapId = nil, ok = false } function OverworldBattle.wantsFront() if not OverworldBattle.enabled() then return false end + if OverworldBattle.backPinned() then return false end if not Voxel3D.available() then return false end -- required here rather than through the file's own helper: this runs -- while a battler is being built, which is before that helper is defined @@ -140,6 +176,51 @@ OverworldBattle.HUD_RECT = { player = { 72, 56, 88, 40 }, } +-- ------- the box at the bottom, on the same glass +-- +-- The HUDs got frosted panels because black glyphs on grass are not readable. +-- The battle's text box and its menu had the opposite problem and the same +-- cause: they are drawn as an OPAQUE WHITE slab with a black border, which was +-- the field's own colour when the field was white and is a sheet of paper laid +-- over the bottom third of the diorama now that it is not. +-- +-- So the box gets exactly what the HUDs get: the world behind it, blurred to +-- frosted glass and laid back down translucent, with the border and the text +-- drawn over it unchanged, and the same brightness verdict flipping the ink +-- when the ground under it is dark. Only the FILL is taken away -- every glyph +-- the engine draws inside the box is still the engine's own, in its own place. +-- +-- These are the boxes BattleState:drawTextArea lays down, as GB-frame rects. +-- READ-ONLY duplicates of that function's own branches, the same kind of +-- mirror hudLive is and for the same reason: there is no seam that reports "a +-- move menu is up", and glass has to go down BEFORE the box that sits on it. +-- The worst a future engine change can do is frost a rectangle nothing lands +-- on, or leave a box unfrosted -- never break a battle. +-- +-- Each rect stops where the next one starts rather than overlapping it: two +-- panels over the same pixels would frost it twice and leave a visible step +-- along the seam. +OverworldBattle.TEXT_RECT = { + box = { 0, 96, 160, 48 }, -- Font.drawBox(0, 12, 20, 6), always + -- moveSelect's TYPE/PP box, Font.drawBox(0, 8, 11, 5), trimmed to the rows + -- above the box above -- its last tile row sits inside that one + moves = { 0, 64, 88, 32 }, + -- mimicSelect's copy menu, Font.drawBox(0, 7, 16, 6), trimmed the same way + mimic = { 0, 56, 128, 40 }, +} + +function OverworldBattle.textRects(battle) + if not battle or battle.blankForAskName then return {} end + local r = OverworldBattle.TEXT_RECT + local out = { box = r.box } + if battle.phase == "moveSelect" then + out.moves = r.moves + elseif battle.phase == "mimicSelect" then + out.mimic = r.mimic + end + return out +end + -- ------- the HUDs, out at the window's own edges -- -- The battle screen is 160x144 in the MIDDLE of the window and the world is the @@ -188,6 +269,17 @@ function OverworldBattle.snapRects(shot) return rects, { enemy = ex, player = px } end +-- A rect measured in the GB frame, in WORLD-canvas pixels: where the letterbox +-- blit will actually put it. The text box has not moved anywhere -- it is drawn +-- where it always was -- but its glass is laid into the world image alongside +-- the HUDs' (see snapHUDs), which is the surface that reaches the screen a +-- pixel to a pixel rather than magnified out of a 160x144 canvas. +local function toWorld(rect, shot) + local s = shot.scale + return { shot.lx + rect[1] * s, shot.ly + rect[2] * s, + rect[3] * s, rect[4] * s } +end + -- ------- the live battle -- -- nil when no overworld battle is running. Never more than one: battles do @@ -471,6 +563,37 @@ local function withoutBackgroundFill(battle, fn) if not ok then error(err, 0) end end +-- ------- the box, without its paper +-- +-- Font.drawBox is a white fill and then six border glyphs, and the fill is the +-- opaque slab the frosted panel underneath is there to replace. So for the +-- length of one drawTextArea the white fills are dropped and everything else +-- -- the border, the text, the cursor, the down arrow -- draws exactly as it +-- always did, over the glass instead of over paper. +-- +-- Every fill drawTextArea issues is one of those: the box's own, and the two +-- eight-pixel cells MoveSelectionMenu wipes back to box white before it writes +-- the border glyphs that hardware would have overwritten. Both are opaque +-- white, both are paper, and both go. +-- +-- The same shim shape as withoutBackgroundFill above, and scoped as tightly: +-- installed around a single call, removed on the way out including on error, +-- never live outside a battle frame this mode is drawing. +local function withoutBoxFill(battle, fn) + local g = love.graphics + local rectangle = g.rectangle + g.rectangle = function(mode, ...) + if mode == "fill" then + local r, gr, b, a = g.getColor() + if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 then return end + end + return rectangle(mode, ...) + end + local ok, err = pcall(fn, battle) + g.rectangle = rectangle + if not ok then error(err, 0) end +end + -- ------- the mons, as textures for the 3D pass -- -- The two Pokemon are not composited over the world any more: they are quads @@ -600,11 +723,19 @@ function OverworldBattle.flashing(battle) end -- Both sides, or nil when neither has anything to show. +-- +-- One side under BACK: the player's mon is not standing on the map at all +-- there, it is on the menu, so it has no card to be a texture for -- and +-- nothing downstream has to know that. No billboard, and no shadow on the +-- ground under a mon that is not on it. function OverworldBattle.textures(battle) if not battle then return nil end local out = {} local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy") - local okP, player = pcall(OverworldBattle.sideTexture, battle, "player") + local okP, player = true, nil + if not OverworldBattle.backPinned() then + okP, player = pcall(OverworldBattle.sideTexture, battle, "player") + end out.enemy = okE and enemy or nil out.player = okP and player or nil if not (out.enemy or out.player) then return nil end @@ -719,10 +850,33 @@ function OverworldBattle.install() -- before this screen is composited at all, so the flat pics layer has -- nothing left to do here. Skipped rather than left to draw underneath, or -- every Pokemon would appear twice: once on its tile and once in its slot. + -- + -- Except under BACK, where the player's side never became geometry and this + -- layer is the only thing that draws it. The engine's own onlySide argument + -- does the whole job: one call, the player's branches alone, in the slot and + -- at the scale the GB always put them -- feet on the box, 2x, back view. innerPics = BattleState.drawPicsLayer - function BattleState:drawPicsLayer(slide, sx, sy) - if self.dramaticShapeShot then return end - return innerPics(self, slide, sx, sy) + function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) + if not self.dramaticShapeShot then + return innerPics(self, slide, sx, sy, onlySide, skipMenuClip) + end + if OverworldBattle.backPinned() and onlySide ~= "enemy" then + return innerPics(self, slide, sx, sy, "player", skipMenuClip) + end + end + + -- The battle's text box and its menus, over the frosted glass laid down for + -- them rather than over their own white paper -- and their ink flipped with + -- the HUD's when the ground under the frame is dark, by the same rule and + -- off the same verdict. + local innerText = BattleState.drawTextArea + function BattleState:drawTextArea() + if not self.dramaticShapeShot then return innerText(self) end + local battle = self + if not self.dramaticShapeDark then return withoutBoxFill(battle, innerText) end + BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function() + withoutBoxFill(battle, innerText) + end) end -- Move animations are authored against the pics' fixed slots, and a single @@ -740,10 +894,13 @@ function OverworldBattle.install() -- mons' projected positions, less the midpoint of the slots they used to -- sit in. A hit still lands on the mon it is aimed at. local a = OverworldBattle.ANCHOR - local dx = (shot.enemy[1] + shot.player[1]) / 2 - - (a.enemy[1] + a.player[1]) / 2 - local dy = (shot.enemy[2] + shot.player[2]) / 2 - - (a.enemy[2] + a.player[2]) / 2 + -- BACK leaves the player's mon exactly where the GB put it, so that side + -- contributes no movement at all and the pair's centre has gone half as + -- far as the foe's mark did. + local px, py = shot.player[1], shot.player[2] + if OverworldBattle.backPinned() then px, py = a.player[1], a.player[2] end + local dx = (shot.enemy[1] + px) / 2 - (a.enemy[1] + a.player[1]) / 2 + local dy = (shot.enemy[2] + py) / 2 - (a.enemy[2] + a.player[2]) / 2 love.graphics.push() love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5)) local ok, err = pcall(innerAnim, self, colorized) @@ -882,10 +1039,21 @@ function OverworldBattle.snapHUDs(battle, shot) local live = {} if enemy then live.enemy = rects.enemy end if player then live.player = rects.player end + -- and the text box's own glass, on the same pass. It stays in the middle of + -- the frame where the engine draws it -- only the HUDs were snapped out -- + -- so its GB rect is mapped into the letterbox rather than to an edge. + for key, rect in pairs(OverworldBattle.textRects(battle)) do + live[key] = toWorld(rect, shot) + end -- measured under the SNAPPED rects: the panels are over whatever the world -- shows at the window's edges now, which is not what was behind them in the - -- middle of the frame + -- middle of the frame. ONE verdict over all of them, HUDs and box together, + -- for the reason BattleHud.verdict gives: a frame with white glyphs in the + -- corner and black ones on the menu reads as a bug rather than as adaptation. local dark = BattleHud.verdict(live, shot, true) + -- the box's own ink is flipped where the engine draws it, in the GB frame, + -- so the answer has to outlive this function (see drawHudPanels) + if session then session.dark = dark end local layer = OverworldBattle.hudTexture(battle, slide, dark) if not layer then return false end @@ -911,23 +1079,29 @@ function OverworldBattle.snapHUDs(battle, shot) return true end --- Lay the frosted glass down under whichever HUD is about to draw, and --- record which way the glyphs have to flip. +-- Lay the frosted glass down under whichever HUD and box are about to draw, +-- and record which way the glyphs have to flip. -- --- The fallback path only: with the HUDs snapped out to the window's edges their --- panels went with them, and there is nothing left inside the GB frame to lay --- glass under. +-- The panels are the fallback path only: normally the HUDs are snapped out to +-- the window's edges and their glass, and the box's, went into the world image +-- with them (snapHUDs). The VERDICT is needed either way -- the box's ink is +-- drawn here, in the GB frame, whichever path laid the glass under it. function OverworldBattle.drawHudPanels(battle) local shot = battle.dramaticShapeShot battle.dramaticShapeDark = nil - if not shot or snapped() then return end + if not shot then return end + if snapped() then + battle.dramaticShapeDark = session and session.dark or nil + return + end local slide = (battle.introSlide or 0) * 4 local enemy, player = OverworldBattle.hudLive(battle, slide) - if not (enemy or player) then return end local rect = OverworldBattle.HUD_RECT local live = {} if enemy then live.enemy = rect.enemy end if player then live.player = rect.player end + for key, r in pairs(OverworldBattle.textRects(battle)) do live[key] = r end + if not next(live) then return end local dark = BattleHud.verdict(live, shot) battle.dramaticShapeDark = dark for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end diff --git a/lib/Perf.lua b/lib/Perf.lua new file mode 100644 index 0000000..3550758 --- /dev/null +++ b/lib/Perf.lua @@ -0,0 +1,353 @@ +-- Voxel world mode: the instrumentation core. +-- +-- Ships DARK. Every entry point is one boolean test away from doing +-- nothing, and the boolean is false unless a run explicitly asks for +-- measurement (DS_PERF in the environment, or a ds_perf.flag file in the +-- save directory for a device that has no environment to set). A mod that +-- measures itself in every player's session is a mod that costs every +-- player the measurement, so the default has to be off and the off path +-- has to be free. +-- +-- What it measures, and why those three things: +-- +-- * LABELS -- named spans (a bake, a mesh build, a shader compile), +-- accumulated as {n, total, max}. `max` is the one that matters: a +-- bake that costs 40ms ONCE is a visible hitch, and an average hides +-- it completely. +-- * FRAMES -- a ring of the last N whole-frame times, stamped once per +-- rendered frame. Frame time is the only number the player actually +-- experiences; every label total is a hypothesis about which frames. +-- * COUNTERS -- plain integers a caller bumps (sun-pass redraws, atlas +-- rebakes). Cheaper than a span when the question is "how often", +-- not "how long". +-- +-- Spans are wall time, and on a GPU that means submission time, not +-- completion time -- the driver is free to finish the work later. So a +-- GPU-side saving shows up in the FRAME numbers rather than in the label +-- for the pass that caused it, and both are reported. + +local Perf = {} + +local clock = (love and love.timer and love.timer.getTime) or os.clock + +-- Read through pcall: the loader's sandbox does not hand a mod `os`, and +-- instrumentation must never be the reason the mod fails to load. Same +-- shape as OverworldBattle's DS_BATTLE_DEBUG probe. +local function envFlag(name) + local ok, value = pcall(function() return os.getenv(name) end) + if not ok then return nil end + if value == nil or value == "" or value == "0" then return nil end + return value +end + +local function flagFile() + if not (love and love.filesystem and love.filesystem.getInfo) then + return false + end + local ok, info = pcall(love.filesystem.getInfo, "ds_perf.flag") + return ok and info ~= nil +end + +Perf.enabled = (envFlag("DS_PERF") ~= nil) or flagFile() + +Perf.labels = {} -- label -> { n, total, max } +Perf.order = {} -- insertion order, so a report reads chronologically +Perf.counters = {} -- name -> integer +Perf.frames = {} -- ring of frame times, seconds +Perf.frameCount = 0 +Perf.RING = 4096 + +-- The segment a frame belongs to ("map:ROUTE_1:first"). A benchmark +-- names the phase it is driving; every frame and every label span +-- recorded while that name is set is attributed to it, which is what +-- turns "the walk was slow" into "the walk was slow ONLY on the frames +-- right after ROUTE_1 came into view". +Perf.segment = nil +Perf.segments = {} -- name -> { frames = {}, labels = {}, order = {} } + +local function segmentEntry() + local name = Perf.segment + if not name then return nil end + local s = Perf.segments[name] + if not s then + s = { name = name, frames = {}, labels = {}, order = {} } + Perf.segments[name] = s + Perf.segments[#Perf.segments + 1] = s -- array half preserves order + end + return s +end + +function Perf.setSegment(name) + Perf.segment = name + if name then segmentEntry() end +end + +-- ---------------------------------------------------------------- spans +-- +-- Call shape at the measured site: +-- +-- local t0 = Perf.now() +-- ... the work ... +-- Perf.add("TerrainAtlas.staticAtlas", t0) +-- +-- When disabled, now() returns nil and add() returns on the nil -- two +-- function calls and a branch, no table touched, no string built. Sites +-- that would run thousands of times a frame (per draw call, per vertex) +-- are still too hot for that and are deliberately NOT instrumented; the +-- frame ring covers them in aggregate. + +function Perf.now() + if not Perf.enabled then return nil end + return clock() +end + +local function bump(store, order, label, dt) + local s = store[label] + if not s then + s = { n = 0, total = 0, max = 0 } + store[label] = s + order[#order + 1] = label + end + s.n = s.n + 1 + s.total = s.total + dt + if dt > s.max then s.max = dt end +end + +function Perf.add(label, t0) + if t0 == nil then return end + local dt = clock() - t0 + bump(Perf.labels, Perf.order, label, dt) + local seg = segmentEntry() + if seg then bump(seg.labels, seg.order, label, dt) end +end + +-- Wrap a function in a table, in place. Used by drivers to instrument +-- module internals they do not own; the mod's own code calls now()/add() +-- directly so the label is visible at the site. +function Perf.wrap(tbl, name, label) + local orig = tbl and tbl[name] + if not orig then return false end + tbl[name] = function(...) + if not Perf.enabled then return orig(...) end + local t0 = clock() + local a, b, c, d = orig(...) + Perf.add(label or name, t0) + return a, b, c, d + end + return true +end + +-- ------------------------------------------------------------- counters + +function Perf.count(name, by) + if not Perf.enabled then return end + Perf.counters[name] = (Perf.counters[name] or 0) + (by or 1) +end + +-- --------------------------------------------------------------- frames +-- +-- Called once per RENDERED frame (the endFrame seam), not once per +-- logic update: a scripted run can step the game many times per render, +-- and a frame the player never saw cannot have hitched for them. + +local lastFrame = nil + +function Perf.frame() + if not Perf.enabled then return end + local t = clock() + if lastFrame then + local dt = t - lastFrame + local n = Perf.frameCount + 1 + Perf.frameCount = n + Perf.frames[(n - 1) % Perf.RING + 1] = dt + local seg = segmentEntry() + if seg then seg.frames[#seg.frames + 1] = dt end + end + lastFrame = t +end + +-- Discard the pending frame stamp: after a long blocking operation the +-- next frame delta would include it and libel the renderer. +function Perf.resync() + lastFrame = Perf.enabled and clock() or nil +end + +-- ------------------------------------------------------------ reporting + +local function percentile(sorted, p) + local n = #sorted + if n == 0 then return 0 end + local i = math.ceil(p * n) + if i < 1 then i = 1 end + if i > n then i = n end + return sorted[i] +end + +-- Frame statistics in MILLISECONDS. p95/p99 rather than the average +-- because smoothness is a tail property: a run that averages 9ms and +-- spikes to 60ms four times reads as stuttering, and its average reads +-- as fine. +function Perf.frameStats(list) + local src = list or Perf.frames + local sorted = {} + for i = 1, #src do sorted[i] = src[i] * 1000 end + table.sort(sorted) + local n = #sorted + local total = 0 + for i = 1, n do total = total + sorted[i] end + local over16, over33 = 0, 0 + for i = 1, n do + if sorted[i] > 16.7 then over16 = over16 + 1 end + if sorted[i] > 33.3 then over33 = over33 + 1 end + end + return { + n = n, + avg = n > 0 and total / n or 0, + p50 = percentile(sorted, 0.50), + p95 = percentile(sorted, 0.95), + p99 = percentile(sorted, 0.99), + worst = n > 0 and sorted[n] or 0, + over16 = over16, + over33 = over33, + } +end + +function Perf.reset() + Perf.labels, Perf.order = {}, {} + Perf.counters = {} + Perf.frames, Perf.frameCount = {}, 0 + Perf.segments = {} + Perf.segment = nil + lastFrame = nil +end + +local function sortedLabels(store, order) + local out = {} + for _, lbl in ipairs(order) do out[#out + 1] = lbl end + table.sort(out, function(a, b) return store[a].total > store[b].total end) + return out +end + +function Perf.printReport(title) + print(("[perf] ==== %s ===="):format(tostring(title or "report"))) + local f = Perf.frameStats() + print(("[perf] frames n=%d avg=%.2fms p50=%.2f p95=%.2f p99=%.2f worst=%.2f >16.7ms=%d >33.3ms=%d") + :format(f.n, f.avg, f.p50, f.p95, f.p99, f.worst, f.over16, f.over33)) + for _, seg in ipairs(Perf.segments) do + local s = Perf.frameStats(seg.frames) + print(("[perf] seg %-28s n=%4d avg=%6.2f p95=%6.2f p99=%6.2f worst=%7.2f >16.7=%3d >33.3=%3d") + :format(seg.name, s.n, s.avg, s.p95, s.p99, s.worst, s.over16, s.over33)) + end + print("[perf] ---- labels (ms, sorted by total) ----") + for _, lbl in ipairs(sortedLabels(Perf.labels, Perf.order)) do + local s = Perf.labels[lbl] + print(("[perf] %-46s n=%6d total=%9.1f max=%8.2f") + :format(lbl, s.n, s.total * 1000, s.max * 1000)) + end + local names = {} + for k in pairs(Perf.counters) do names[#names + 1] = k end + table.sort(names) + if #names > 0 then print("[perf] ---- counters ----") end + for _, k in ipairs(names) do + print(("[perf] %-46s %d"):format(k, Perf.counters[k])) + end +end + +-- ------------------------------------------------------------------ json +-- +-- Hand-rolled rather than pulled from the engine: the report has to be +-- readable by a diff tool between two runs, and that means stable key +-- ORDER, which a generic serializer does not promise. + +local function q(s) + return '"' .. tostring(s):gsub('[%c"\\]', function(c) + if c == '"' then return '\\"' end + if c == "\\" then return "\\\\" end + return ("\\u%04x"):format(c:byte()) + end) .. '"' +end + +local function num(x) + return ("%.4f"):format(x) +end + +local function statsJson(f) + return ("{\"n\":%d,\"avg\":%s,\"p50\":%s,\"p95\":%s,\"p99\":%s,\"worst\":%s,\"over16\":%d,\"over33\":%d}") + :format(f.n, num(f.avg), num(f.p50), num(f.p95), num(f.p99), + num(f.worst), f.over16, f.over33) +end + +local function labelsJson(store, order) + local parts = {} + for _, lbl in ipairs(sortedLabels(store, order)) do + local s = store[lbl] + parts[#parts + 1] = ("%s:{\"n\":%d,\"total\":%s,\"max\":%s}") + :format(q(lbl), s.n, num(s.total * 1000), num(s.max * 1000)) + end + return "{" .. table.concat(parts, ",") .. "}" +end + +function Perf.toJson(meta) + local parts = {} + parts[#parts + 1] = "{" + parts[#parts + 1] = "\"meta\":{" + local m = {} + for k, v in pairs(meta or {}) do + m[#m + 1] = q(k) .. ":" .. (type(v) == "number" and num(v) or q(v)) + end + table.sort(m) + parts[#parts + 1] = table.concat(m, ",") .. "}," + parts[#parts + 1] = "\"frames\":" .. statsJson(Perf.frameStats()) .. "," + parts[#parts + 1] = "\"segments\":{" + local segs = {} + for _, seg in ipairs(Perf.segments) do + segs[#segs + 1] = q(seg.name) .. ":{\"frames\":" + .. statsJson(Perf.frameStats(seg.frames)) + .. ",\"labels\":" .. labelsJson(seg.labels, seg.order) .. "}" + end + parts[#parts + 1] = table.concat(segs, ",") .. "}," + parts[#parts + 1] = "\"labels\":" .. labelsJson(Perf.labels, Perf.order) .. "," + local cs = {} + for k, v in pairs(Perf.counters) do cs[#cs + 1] = q(k) .. ":" .. v end + table.sort(cs) + parts[#parts + 1] = "\"counters\":{" .. table.concat(cs, ",") .. "}" + parts[#parts + 1] = "}" + return table.concat(parts, "") +end + +-- Written through love.filesystem (the save directory) rather than io: +-- a driver run and an Android session both have one, and neither is +-- guaranteed a writable working directory. +function Perf.write(name, meta) + local body = Perf.toJson(meta) + if love and love.filesystem then + pcall(love.filesystem.createDirectory, "ds_bench") + local ok = pcall(love.filesystem.write, "ds_bench/" .. name .. ".json", body) + if ok then + print("[perf] wrote " .. tostring(love.filesystem.getSaveDirectory()) + .. "/ds_bench/" .. name .. ".json") + return true + end + end + print("[perf] JSON " .. name .. ": " .. body) + return false +end + +-- ----------------------------------------------------------- draw stats +-- +-- love.graphics.getStats() resets per frame, so it is only meaningful +-- read at the END of a frame -- which is where Perf.frame() runs. + +function Perf.drawStats() + if not (love and love.graphics and love.graphics.getStats) then return end + local s = love.graphics.getStats() + Perf.count("stat.drawcalls", s.drawcalls or 0) + Perf.count("stat.canvasswitches", s.canvasswitches or 0) + Perf.count("stat.shaderswitches", s.shaderswitches or 0) + Perf.count("stat.frames", 1) + Perf.texturememory = s.texturememory + Perf.canvases = s.canvases + Perf.images = s.images +end + +return Perf diff --git a/main.lua b/main.lua index 4051295..beba655 100644 --- a/main.lua +++ b/main.lua @@ -288,6 +288,11 @@ applyFull = function(level) -- half of it is spent. Set rather than forced -- the row is gone from the -- menu while FULL is on, but a save that already had it off gets it on. OverworldBattle.setting:setIndex(1, Game) + -- with both mons out there on it: BACK keeps the player's own on the menu, + -- which is the one part of the old screen FULL is least about. Set rather + -- than held, like every other line here -- a player who wants their back pic + -- back can say so again on the row, or from the mod manager's page. + OverworldBattle.backSetting:setIndex(1, Game) -- and the battle screen the staged fight is composed for. WIDE re-lays that -- screen out on a 304x144 surface, which moves every anchor the arena camera -- is solved against (OverworldBattle.forceOG); FULL has just switched staged @@ -320,6 +325,14 @@ local SETTINGS = { { OverworldBattle.setting, "Fight on the map: the battle draws over the nearest clear ground, " .. "shot over the shoulder with a slow parallax drift." }, + -- Only offered while a fight can actually be staged on the map: with 3D-BTL + -- off the engine draws the classic screen, which is this row's ON already, + -- and a row that no longer decides anything is worse than no row. + { OverworldBattle.backSetting, + "Keep your own Pokemon on the battle menu, seen from behind in its " + .. "original slot, instead of standing it on the map facing the foe. " + .. "The foe is still out there on its own tile.", + when = function() return stagedBattles() end }, { DayNight.setting, "What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, " .. "let CYCLE run it -- ten minutes of sun, ten of moon, with the " @@ -499,7 +512,12 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows) return dropRow(out, "pipeline:tiltshift") end local extra = {} - for _, entry in ipairs(SETTINGS) do extra[#extra + 1] = entry[1]:row() end + for _, entry in ipairs(SETTINGS) do + -- a row whose own switch is off the table this frame (BACK, which needs a + -- staged fight to be about) is left off with it; the mod manager's page + -- still carries every one of them + if not entry.when or entry.when() then extra[#extra + 1] = entry[1]:row() end + end return insertGrouped(out, extra) end) @@ -745,7 +763,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.2.1" +mod.exports.version = "1.3.0" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index 7a5c712..be71b75 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.2.1", + "version": "1.3.0", "api": 2, "entry": "main.lua", "profile": "content", diff --git a/mod.card b/mod.card index ef13bc1..6f11298 100644 --- a/mod.card +++ b/mod.card @@ -11,6 +11,7 @@ return { "with VOXEL on, the overworld draws as 3D geometry instead of flat tiles", "occlusion comes from a depth buffer rather than a y-sort, so buildings really hide what is behind them", "with 3D-BTL on, a battle draws over the map's nearest clear ground instead of over a white field", + "the battle's text box and menu are frosted glass over that ground rather than an opaque white slab, on the same panels the HUDs sit on", "the map's NPCs are culled for the length of a battle, so the wipe plays over an empty map", "a battle's letterbox voids go black rather than white, because the battle canvas is no longer white", "VOXEL and the engine's TILT are mutually exclusive -- turning one on switches the other off", @@ -22,6 +23,7 @@ return { "T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur", "V-GRID on hotkey 5 and V-CURVE on hotkey 7", "3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map", + "BACK options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map", "an over-the-shoulder battle camera on a slow parallax orbit, with a depth-of-field pass that holds both mons sharp", "a sky behind the diorama at the 75-degree rung, outdoor maps only, coloured by the active palette mode", "a hand-authored tile shape profile (data/voxel_heights.lua) a mod can extend", diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index e38788a..7a29cd5 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -306,9 +306,9 @@ end Pipelines.setLevel("voxel", 2) local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, { data = Data }, { { id = "text_speed" } }) -T.eq(#hookedRows, 5, "the options hook added a row per setting") +T.eq(#hookedRows, 6, "the options hook added a row per setting") local grid, curve, battles = hookedRows[2], hookedRows[3], hookedRows[4] -local daytime = hookedRows[5] +local backRow, daytime = hookedRows[5], hookedRows[6] T.eq(daytime.label, "DAYTIME", "the day/night row carries its label") T.eq(daytime.value(), "SYNC", "and defaults to SYNC -- no value set follows the clock on the wall") @@ -320,6 +320,12 @@ T.eq(battles.label, "3D-BTL", "the overworld-battle row carries its label") T.eq(battles.value(), "ON", "overworld battles are on by default -- the mode's headline is the world " .. "in 3D, and a battle is where the player spends half the game") +T.eq(backRow.label, "BACK", "the back-pic row carries its label") +T.eq(backRow.value(), "OFF", + "and is off by default -- what the mode advertises is BOTH mons out on the " + .. "map, so the classic slot is opt-in") +T.check(backRow.id ~= battles.id and backRow.id:find("battleBack", 1, true), + "on its own key, so it persists beside 3D-BTL rather than over it") -- stepping writes through to the one place both rows read local settingGame = { save = { options = {} }, mods = { modOptions = {} } } @@ -1868,6 +1874,98 @@ T.eq(e[1], 0, "the bands are full width") T.eq(e[3], 160, "so a shaken HUD or a long name is carried out with its block") end +-- ------- and the box at the bottom is on the same glass +-- +-- The HUDs got frosted panels because black glyphs on grass are not readable. +-- The text box had the opposite problem and the same cause: an opaque white +-- slab over the bottom third of the diorama, which was the field's own colour +-- back when the field was white. The rects here are what the glass is cut to, +-- and they are a READ-ONLY mirror of drawTextArea's own branches -- so this is +-- where a future engine that moves a box says so. +do +local rects = Battles.textRects({ phase = "messages" }) +T.check(rects.box ~= nil, "there is always a box: drawTextArea opens with one") +T.eq(rects.box[2] + rects.box[4], 144, + "and it reaches the bottom of the frame") +T.eq(rects.box[3], 160, "full width, like Font.drawBox(0, 12, 20, 6)") +T.eq(rects.box[2], 96, "starting on the row the player's mon stands on") + +-- the menu the player picks FIGHT on is that same box, so nothing is added +T.eq(Battles.textRects({ phase = "menu" }).moves, nil, + "the battle menu draws inside the box already there") + +-- the two phases that put a SECOND box above it get a second panel, trimmed +-- to the rows above the first: two panels over the same pixels would frost it +-- twice and leave a step along the seam +for _, phase in ipairs({ "moveSelect", "mimicSelect" }) do + local more = Battles.textRects({ phase = phase }) + local extra = more.moves or more.mimic + T.check(extra ~= nil, phase .. " raises a box of its own, and it is frosted") + T.eq(extra[2] + extra[4], more.box[2], + "which stops exactly where the box below it starts, so they never overlap") + T.check(extra[1] >= 0 and extra[1] + extra[3] <= 160 and extra[2] >= 0, + "and stays inside the frame the battle is drawn in") +end + +-- AskName blanks the field on purpose -- the nickname prompt is meant to sit +-- on nothing -- so there is no box and no glass under one +T.eq(next(Battles.textRects({ phase = "menu", blankForAskName = true })), nil, + "the nickname prompt's blank field gets no glass") +T.eq(next(Battles.textRects(nil)), nil, "and no battle, no boxes") +end + +-- ------- BACK: the player's own mon stays on the menu +-- +-- The staged shot stands both mons on the map, which costs the framing Gen 1 +-- is most recognisable by: your own Pokemon seen from behind, sitting on the +-- battle menu. BACK hands that back without giving up the fight on the map -- +-- the foe is still geometry on its own tile. +do +T.eq(Battles.backSetting:get(), false, + "BACK is off by default: both mons out on the map is what the mode is") +T.eq(Battles.backPinned(), false, "so nothing is pinned to the menu") + +local backGame = { save = { options = { modOptions = {} } }, + mods = { modOptions = {} } } +Battles.setting:setIndex(1, backGame) -- 3D-BTL on +Battles.backSetting:setIndex(2, backGame) -- BACK on +T.eq(Battles.backPinned(), true, "switched on, the back pic is pinned") +T.eq(backGame.save.options.modOptions.DRAMATIC_SHAPE.battleBack, true, + "and it persists on its own key, beside 3D-BTL rather than over it") +T.eq(backGame.save.options.modOptions.DRAMATIC_SHAPE.battles, true, + "which is still where it always was") + +-- and it means nothing at all with staged battles off: there is no staged +-- shot for a back pic to be pinned in front of, and the engine's own battle +-- screen already draws exactly this +Battles.setting:setIndex(2, backGame) +T.eq(Battles.backPinned(), false, + "with 3D-BTL off the setting decides nothing, whatever it is left at") +T.eq(Battles.backSetting:get(), true, "without being rewritten underneath") + +-- ...so the row comes off the menu with it, on the same reasoning the mod's +-- other absent rows come off: a row that no longer decides anything is worse +-- than no row +local offRows = Runtime.call("ui.options.rows", function(_, r) return r end, + backGame, { { id = "tilt" } }) +local offIds = {} +for _, row in ipairs(offRows) do offIds[row.id] = true end +T.check(offIds["DRAMATIC_SHAPE:battles"], "3D-BTL itself is still offered") +T.check(not offIds["DRAMATIC_SHAPE:battleBack"], + "but BACK is off the menu while there is no staged fight to be about") + +Battles.setting:setIndex(1, backGame) +local onRows = Runtime.call("ui.options.rows", function(_, r) return r end, + backGame, { { id = "tilt" } }) +local onAt = {} +for i, row in ipairs(onRows) do onAt[row.id] = i end +T.check(onAt["DRAMATIC_SHAPE:battleBack"], "switched back on, so is the row") +T.eq(onAt["DRAMATIC_SHAPE:battleBack"] - onAt["DRAMATIC_SHAPE:battles"], 1, + "directly under the row it belongs to") + +Battles.backSetting:setIndex(1, backGame) -- and off for the rows below +end + -- ------- the way out of a battle is a fade, not a cut -- -- The engine wipes INTO a battle and cuts straight out of it. While voxel mode diff --git a/tests/route1_candidates.lua b/tests/route1_candidates.lua new file mode 100644 index 0000000..2b3ebc2 --- /dev/null +++ b/tests/route1_candidates.lua @@ -0,0 +1,218 @@ +-- Driver: propose and photograph several battle arenas for ONE map. +-- +-- data/battle_arenas.lua holds a single authored spot per area, chosen by +-- arena_pick's nearest-to-the-middle search and then looked at. This is the +-- other half of that job: when the shipped spot is up for review, it lays out +-- the ALTERNATIVES -- every arena on the map both mons can be seen in, spread +-- along the map so the shortlist is places rather than neighbours -- and +-- stages a real battle in each so they can be compared by eye. +-- +-- SHOT_DIR=.scratchpad/route1_candidates CAND_MAP=ROUTE_1 CAND_N=5 \ +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/route1_candidates.lua love . +-- +-- CAND_MAP is the map id (default ROUTE_1), CAND_N how many to photograph. +-- One `CAND` line per shot, ready to paste into the data file, plus a PNG +-- named for its corner and shape. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad/route1_candidates" + local MAP = os.getenv("CAND_MAP") or "ROUTE_1" + local WANT = tonumber(os.getenv("CAND_N") or "") or 5 + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) } + game.save.player.name = "RED" + + local exports = game.mods and game.mods.exports + local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib + if not lib then + U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again") + return + end + local Arena = lib.require("BattleArena") + local Battles = lib.require("OverworldBattle") + + U.teleport(game, MAP, 1, 1, "down") + local map = game.overworld.map + U.log(("%s is %dx%d cells"):format(MAP, map.widthCells, map.heightCells)) + + -- ------- every arena the map can offer + -- + -- BattleArena.search answers "the nearest one", which is the wrong question + -- for a shortlist -- it returns one spot and hides the rest. So walk the + -- same grid ourselves and keep them all, tagged with whether the pair would + -- actually be SEEN there (Arena.clearance), because an obstructed spot is + -- not a candidate no matter how good the ground looks. + -- + -- The map's outermost cells are its CONNECTION BORDER -- the strip the + -- neighbouring map is drawn into, walkable so the player can step across. + -- Ground there passes every test and is still the wrong answer: a fight + -- staged on it happens at the edge of the world with the border ring's tree + -- wall at the mons' backs, and every spot in the strip looks like every + -- other one. CAND_MARGIN keeps the shortlist on the route proper. + local MARGIN = tonumber(os.getenv("CAND_MARGIN") or "") or 2 + local cands = {} + for _, shape in ipairs(Arena.SHAPES) do + for y = MARGIN, map.heightCells - shape.h - MARGIN do + for x = MARGIN, map.widthCells - shape.w - MARGIN do + local fits = true + for cy = y, y + shape.h - 1 do + for cx = x, x + shape.w - 1 do + if not Arena.openCell(map, cx, cy, false) then fits = false break end + end + if not fits then break end + end + if fits then + local a = Arena.at(x, y, shape.id) + if a and Arena.clearance(map, a) then + cands[#cands + 1] = { x = x, y = y, shape = shape.id, + mx = x + (shape.w - 1) / 2, + my = y + (shape.h - 1) / 2 } + end + end + end + end + end + U.log(("%d clear arenas on %s"):format(#cands, MAP)) + if #cands == 0 then U.log("done -- nothing to propose") return end + + for _, c in ipairs(cands) do + U.log((" fit %d,%d %s"):format(c.x, c.y, c.shape)) + end + + -- CAND_AT=x,y,shape;x,y,shape;... photographs an explicit shortlist instead + -- of the spread one. The spread is a first pass over ground the clearance + -- test approved, and that test measures terrain height along the sightline + -- only -- it has no opinion on a hedge sitting in the apron row between the + -- camera and the near mon, which is the failure that keeps turning up. So + -- the loop is: spread, look, then re-shoot the survivors and the + -- replacements by hand. + local explicit = os.getenv("CAND_AT") + if explicit and explicit ~= "" then + local list = {} + for spot in explicit:gmatch("[^;]+") do + local x, y, s = spot:match("^%s*(%-?%d+)%s*,%s*(%-?%d+)%s*,%s*(%a+)%s*$") + if x then + list[#list + 1] = { x = tonumber(x), y = tonumber(y), shape = s } + else + U.log("BAD CAND_AT entry: " .. spot) + end + end + cands = list + WANT = #list + U.log(("%d spots given explicitly"):format(#list)) + end + + local function shapeOf(id) + for _, s in ipairs(Arena.SHAPES) do if s.id == id then return s end end + end + for _, c in ipairs(cands) do + local s = shapeOf(c.shape) + c.mx = c.x + ((s and s.w or 1) - 1) / 2 + c.my = c.y + ((s and s.h or 1) - 1) / 2 + end + + -- ------- thin them down to a shortlist that is actually a CHOICE + -- + -- Adjacent corners are the same patch of ground shifted a cell, so a naive + -- top-N is five photographs of one place, and pure farthest-point selection + -- goes straight to the extremes -- which on a route means the ends, where + -- the ground is emptiest and the shots are least distinguishable. + -- + -- So: spread along the route's LONG AXIS, one pick per band, and within a + -- band take the spot nearest the middle of the road. The road is measured + -- rather than assumed -- the median cross-axis position of everywhere a + -- fight fits IS the lane, on a map whose walkable ground is mostly lane. + local horizontal = map.widthCells > map.heightCells + local function along(c) return horizontal and c.mx or c.my end + local function across(c) return horizontal and c.my or c.mx end + + local xs = {} + for _, c in ipairs(cands) do xs[#xs + 1] = across(c) end + table.sort(xs) + local road = xs[math.ceil(#xs / 2)] + U.log(("road runs %s, centre of the lane is %s = %.1f") + :format(horizontal and "east-west" or "north-south", + horizontal and "y" or "x", road)) + + local lo, hi = math.huge, -math.huge + for _, c in ipairs(cands) do + lo, hi = math.min(lo, along(c)), math.max(hi, along(c)) + end + + -- an explicit shortlist is already the answer; the spread below would only + -- thin it, and its overlap rule would silently drop two spots deliberately + -- asked for a cell apart + local picked, taken = {}, {} + for band = 1, (explicit and explicit ~= "") and 0 or WANT do + -- band centres, not band edges: the first and last picks sit inside the + -- route rather than on its two connection mouths + local target = lo + (hi - lo) * (band - 0.5) / WANT + local best, bestScore + for _, c in ipairs(cands) do + if not taken[c] then + local da = along(c) - target + local dr = across(c) - road + -- distance from the band centre, plus a heavier penalty for being off + -- the lane; wide arenas are worth a detour, being the shot this mode + -- is framed for + local score = da * da + 4 * dr * dr - (c.shape == "wide" and 100 or 0) + if not bestScore or score < bestScore then best, bestScore = c, score end + end + end + if best then + picked[#picked + 1] = best + -- everything overlapping the pick is off the table, so two bands whose + -- best spots touch cannot return the same patch of ground twice + for _, c in ipairs(cands) do + if math.abs(along(c) - along(best)) < 3 + and math.abs(across(c) - across(best)) < 3 then + taken[c] = true + end + end + end + end + + if #picked == 0 then picked = cands end + + -- north to south (or west to east), so the filenames read along the route + table.sort(picked, function(a, b) + if along(a) ~= along(b) then return along(a) < along(b) end + return across(a) < across(b) + end) + + for i, c in ipairs(picked) do + U.log(("CAND %d [%q] = { x = %d, y = %d, shape = %q },") + :format(i, MAP, c.x, c.y, c.shape)) + -- forced through the authored-entry seam, so what gets staged is exactly + -- this spot rather than whatever the search would pick from the player's + -- cell + Arena.setOverride(MAP, { x = c.x, y = c.y, shape = c.shape }) + local staged = Arena.find(map, 0, 0, false) + if not staged then + U.log(("SKIP %d -- override did not stage"):format(i)) + else + game.overworld.player.cellX = staged.playerCell[1] + game.overworld.player.cellY = staged.playerCell[2] + U.wait(90) -- let the meshes land + local battle = BattleState.newWild(game, "NIDORINO", 20) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + U.wait(70) + for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end + local got = Battles.arena() + U.log(("SHOT %d staged at %s,%s"):format(i, tostring(got and got.x), + tostring(got and got.y))) + U.shot(game, ("%s/%d_%s_x%d_y%d_%s.png") + :format(DIR, i, MAP:lower(), c.x, c.y, c.shape)) + while game.stack:top() and game.stack:top() ~= game.overworld do + game.stack:pop() + end + U.wait(6) + end + end + Arena.setOverride(MAP, nil) + + U.log("done -- " .. DIR) +end diff --git a/tests/voxel_bench.lua b/tests/voxel_bench.lua new file mode 100644 index 0000000..96fbd33 --- /dev/null +++ b/tests/voxel_bench.lua @@ -0,0 +1,246 @@ +-- Driver: the A/B performance benchmark. +-- +-- One scripted, deterministic session that exercises the three things the +-- mod is slow at, and writes the numbers to a JSON file so two runs can be +-- diffed: +-- +-- 1. LOADING IN -- engaging the mode from flat: shader compiles, +-- canvas allocations, the first map's mesh, its +-- atlas bakes and its glass mask, all at once. +-- 2. A NEW AREA -- walking Pallet -> Route 1 -> Viridian with cold +-- caches, then walking the SAME route again with +-- them warm. The gap between the two is the +-- complaint; closing it is the fix. +-- 3. A LOW CAMERA -- standing still at each pitch rung. The 75 degree +-- rung puts the horizon in frame, which triples the +-- sun frustum and hands the sky its disc to draw. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_bench.lua \ +-- DS_PERF=1 BENCH_TAG=baseline lovec.exe . +-- +-- knobs (env): +-- DS_PERF must be set, or lib/Perf.lua stays dark and measures nothing +-- BENCH_TAG output name, ds_bench/.json (default "run") +-- BENCH_HOLD frames to walk per leg (default 900) +-- +-- THREE THINGS THIS RUN CONTROLS FOR, because a benchmark that does not is +-- measuring the weather: +-- +-- * VSYNC OFF. With it on every frame costs exactly one refresh interval +-- and the whole exercise reads as 16.7ms flat, saving or no saving. +-- * THE CLOCK PINNED to day. The day/night cycle changes the sky, the sun +-- angle, the shadow frustum and whether windows are lit -- so an +-- unpinned run compares two different scenes. +-- * ENCOUNTERS OFF. A wild battle mid-walk derails the route and charges +-- its frames to whichever map the script thought it was on. Stubbed on +-- the state class for this process only; nothing is written to disk. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + local OverworldState = require("src.world.OverworldController") + + local TAG = os.getenv("BENCH_TAG") or "run" + -- Route 1 is 36 cells tall and a walk step is 16 frames, so a leg that + -- means to reach Viridian needs about 1200 -- short of that the "new + -- area" the benchmark is named for never gets entered. + local HOLD = math.floor(tonumber(os.getenv("BENCH_HOLD")) or 1300) + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[bench] DRAMATIC_SHAPE mod not loaded -- nothing to measure") + return + end + local V = handle.lib + local Perf = V.require("Perf") + if not Perf.enabled then + print("[bench] DS_PERF is not set -- run with DS_PERF=1 or this measures nothing") + return + end + local loadBytes = V.loadBytes + local Structures = V.require("Structures") + local ChunkMesher = V.require("ChunkMesher") + local Buildings = V.require("Buildings") + local DayNight = V.require("DayNight") + local VoxelScene = V.require("VoxelScene") + + -- module internals worth naming that the mod does not time itself + Perf.wrap(Structures, "forMap", "Structures.forMap") + Perf.wrap(Buildings, "build", "Buildings.build") + Perf.wrap(ChunkMesher, "pump", "ChunkMesher.pump") + Perf.wrap(VoxelScene, "render", "VoxelScene.render") + + if love.window and love.window.setVSync then + pcall(love.window.setVSync, 0) + end + DayNight.setting:sync("day") + OverworldState.rollEncounter = function() return nil end + + -- ---- the run ------------------------------------------------------ + + local function seg(name) + Perf.setSegment(name) + -- the frame that STRADDLES a segment boundary belongs to neither: it + -- carries the teleport, the level change or the report print that + -- opened it, and charging that to the new segment libels it + Perf.resync() + end + + local function settle(frames) + Perf.setSegment(nil) + U.wait(frames or 60) + Perf.resync() + end + + -- Hold a direction, attributing each frame to the map the player is + -- STANDING ON as it lands. Crossing a seam mid-leg is the whole point of + -- the walk, so the segment has to follow the player rather than the + -- script's idea of where they are. + local function walk(dir, frames, prefix) + local last = nil + for _ = 1, frames do + local o = game.overworld + local id = o and o.map and o.map.id + if id ~= last then + last = id + seg(prefix .. ":" .. tostring(id)) + print(("[bench] %s entered %s at frame %d"):format(prefix, tostring(id), + U.frame())) + end + -- press and RELEASE each frame, the way tests/voxel_perf_probe's seam + -- crossing does: a direction left held accumulates in pressQueue and + -- the walk stalls where a single tap would have stepped + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + game.input.state[dir] = false + end + local o = game.overworld + print(("[bench] %s ended on %s at cell (%d,%d)"):format( + prefix, tostring(o and o.map and o.map.id), + o and o.player and o.player.cellX or -1, + o and o.player and o.player.cellY or -1)) + Perf.setSegment(nil) + end + + print("[bench] tag=" .. TAG .. " loadBytes=" .. tostring(loadBytes)) + + -- ORDER MATTERS. The cold walk has to be the first time this session + -- draws Route 1 and Viridian, so everything before it stays in Pallet + -- Town -- a map whose caches the walk does not depend on. Measuring a + -- "first entry" into a map an earlier segment already warmed is the one + -- way to make this whole benchmark lie. + + -- 1. the flat reference: the game with this mod present but not + -- drawing. Every later number is only interesting against this one. + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + Pipelines.setLevel("voxel", 0) + Pipelines.setLevel("tiltshift", 0) + settle(90) + seg("flat") + U.wait(180) + + -- 2. loading in: FULL is what a player picks first and it is the most + -- expensive configuration there is (tilt-shift to maximum, 3D + -- battles on). Measured from the frame the level changes, so it + -- carries the shader compiles, the canvas allocations, the first + -- mesh, the first atlas bake and the first glass scan together. + settle(60) + seg("engage.full") + Pipelines.setLevel("voxel", 1) -- FULL + U.wait(240) + Perf.setSegment(nil) + + -- 3. the low camera. 75 degrees puts the horizon in frame; the rungs + -- below it are the control. Standing still, so what is measured is + -- the frame's own cost and not the walk's mesh streaming. + for _, rung in ipairs({ 2, 3, 4, 5 }) do -- 15, 35, 50, 75 degrees + Pipelines.setLevel("voxel", rung) + settle(60) -- let the tween finish + seg("pitch:" .. tostring(V.require("VoxelState").ANGLE_LABELS[rung + 1])) + U.wait(180) + Perf.setSegment(nil) + end + + -- 3b. the same low camera at DUSK, which is where the sky costs most: + -- the horizon is in frame, so the banded region is at its tallest, + -- and the sun is low enough to be in it -- the disc only draws at + -- all when it is above the horizon point, so a midday run never + -- touches that code and would report it as free. + for _, when in ipairs({ "dusk", "night" }) do + DayNight.setting:sync(when) + settle(60) + seg("pitch:75:" .. when) + U.wait(180) + Perf.setSegment(nil) + end + DayNight.setting:sync("day") + settle(30) + + -- 4/5. arriving somewhere new, at the rung that hurts. + -- + -- Arrival is measured by LOADING each map rather than by walking into + -- it. Walking would be more lifelike, but Route 1's ledges make a held + -- direction stall against geometry, so a fixed frame count buys a + -- different amount of travel on every run -- and a benchmark whose + -- route drifts cannot compare two runs at all. A load is the same + -- arrival stripped of the travel: the map swaps, and the next frames + -- pay for its mesh, its structure analysis, its atlas bake and its + -- glass mask exactly as they do behind a door fade. + -- + -- Then the identical list a second time. Every cost in the gap between + -- the two passes is a cache that was cold, and that gap IS the + -- complaint. + Pipelines.setLevel("voxel", 5) -- 75 degrees + local TOUR = { "ROUTE_1", "VIRIDIAN_CITY", "ROUTE_2", "ROUTE_22", + "VIRIDIAN_FOREST", "PEWTER_CITY" } + local DWELL = math.max(60, math.floor(HOLD / #TOUR)) + + -- Stand in the middle of each map, derived from its own def rather than + -- written down: a hardcoded cell that falls outside a map teleports the + -- player nowhere and the segment silently measures the previous map. + local function centreOf(id) + local def = game.data.maps and game.data.maps[id] + if not def then return nil end + return math.floor(def.width), math.floor(def.height) + end + + local function tour(prefix) + for _, id in ipairs(TOUR) do + local cx, cy = centreOf(id) + if cx then + U.teleport(game, id, cx, cy, "up") + -- the segment opens on the frame AFTER the teleport, so the load + -- itself is not charged to the arrival it caused + seg(prefix .. ":" .. id) + U.wait(DWELL) + Perf.setSegment(nil) + else + print("[bench] skipping unknown map " .. id) + end + end + end + + tour("first") + tour("revisit") + + -- 6. streaming while walking: the one crossing that is reliably + -- walkable (tests/voxel_perf_probe crosses the same seam) -- Route 1 + -- south into Pallet, which pulls a neighbour's meshes in mid-stride. + U.teleport(game, "ROUTE_1", 10, 34, "down") + settle(120) + walk("down", 240, "walk") + + -- ---- the report --------------------------------------------------- + + Perf.setSegment(nil) + Perf.printReport("bench " .. TAG) + Perf.write(TAG, { + tag = TAG, + loadBytes = loadBytes, + hold = HOLD, + texturememory = Perf.texturememory or 0, + canvases = Perf.canvases or 0, + images = Perf.images or 0, + }) + print("[bench] done") +end diff --git a/tests/voxel_shots_ab.lua b/tests/voxel_shots_ab.lua new file mode 100644 index 0000000..b166c40 --- /dev/null +++ b/tests/voxel_shots_ab.lua @@ -0,0 +1,346 @@ +-- Driver: the pixel-identity gate for performance work. +-- +-- Every optimization in this mod's performance pass claims the frame comes +-- out the same. This driver is what makes that claim checkable rather than +-- asserted: it renders a fixed set of scenes -- several maps, indoors and +-- out, at every camera rung, in every display mode -- and writes one PNG +-- per scene. Run it before a change and after it, hash the two directories, +-- and any file whose hash moved is a scene the change altered. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_shots_ab.lua \ +-- SHOT_DIR= AB_TAG=before lovec.exe . +-- +-- knobs (env): +-- SHOT_DIR output directory (created if missing) (default "shots/ab") +-- AB_TAG subdirectory under SHOT_DIR (default "before") +-- AB_MODES display modes to sweep, comma list (default all four) +-- +-- DETERMINISM is the whole game here, because a shot that differs for a +-- reason other than the change under test makes the gate useless: +-- +-- * the day/night clock is PINNED (an unpinned sky is a different sky +-- every second, and it drives the sun angle and the shadow frustum); +-- * the animated tile slots ride the engine's 60Hz counter, so every +-- scene is reached after the SAME number of frames from the same +-- starting state, and the water is at the same point in its roll; +-- * levels are set through Pipelines.setLevel, never the hotkey, so the +-- run cannot write the player's options; +-- * encounters are stubbed off -- a wild battle would replace the scene +-- the shot is named for. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + local OverworldState = require("src.world.OverworldController") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/ab") + .. "/" .. (os.getenv("AB_TAG") or "before") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[ab] DRAMATIC_SHAPE mod not loaded -- nothing to compare") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + + OverworldState.rollEncounter = function() return nil end + + -- NPCs roam on a random timer (src/world/NPC.lua), and LOVE's RNG is + -- seeded differently every launch -- so two runs of this driver put the + -- same townsfolk in different places and every shot with a person in it + -- differs for a reason that has nothing to do with the change under + -- test. Freeze them: `frozen` is the flag the NPC's own update already + -- honours, and an NPC mid-step still finishes it, so the settle below + -- lands on a still scene. They are still POSED and still drawn, so the + -- billboard, its lean and its shadow are all still under test. + local NPC = require("src.world.NPC") + if not NPC.dramaticShapeAbFreeze then + local inner = NPC.update + function NPC:update(...) + self.frozen = true + return inner(self, ...) + end + NPC.dramaticShapeAbFreeze = true + end + pcall(love.math.setRandomSeed, 20260730) + + -- Freeze the tile-animation clock, on BOTH routes to it. + -- + -- TileRenderer.tick consumes WALL-CLOCK dt (so the water rolls at the + -- same speed on a 60Hz and a 144Hz panel), which means the step a shot + -- catches depends on how fast the machine got there rather than on + -- anything the run did. Stubbing tick pins the counter the flat tile + -- layer reads. + -- + -- The mod reads the SAME counter but through its own chain + -- (TerrainAtlas.animFrame): TileRenderer.animFrame if the build exports + -- one, else the local off tick's upvalues, else -- and this is the trap + -- -- wall-clock time. A stubbed tick has no upvalues, so stubbing it + -- ALONE knocks the mod onto the wall-clock fallback and makes the + -- flowers drift between two otherwise identical runs. Exporting a + -- constant animFrame takes the first branch and pins that route too. + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + + -- The scenes. Chosen for what each one can BREAK, not for looks: + -- ROUTE_1 open ground, grass billboards, a long view north -- + -- the case a shadow-frustum or culling change moves + -- VIRIDIAN_CITY buildings, window panes (the glass mask), signs + -- PALLET_TOWN the seam with Route 1: neighbour meshes and ring + -- VIRIDIAN_FOREST dense round-tree hulls, heavy occlusion + -- REDS_HOUSE_1F indoors: no sky, no sun, authored figures + -- PEWTER_CITY a second tileset with its own atlas bake + local SCENES = { + { id = "ROUTE_1", x = 10, y = 20, face = "up", label = "open" }, + { id = "VIRIDIAN_CITY", x = 20, y = 26, face = "up", label = "town" }, + { id = "PALLET_TOWN", x = 10, y = 2, face = "up", label = "seam" }, + { id = "VIRIDIAN_FOREST", x = 16, y = 24, face = "up", label = "trees" }, + { id = "REDS_HOUSE_1F", x = 4, y = 4, face = "up", label = "indoor" }, + { id = "PEWTER_CITY", x = 16, y = 20, face = "down", label = "pewter" }, + } + + -- OFF is in the list deliberately: a change that speeds the 3D path up + -- must not have touched the flat one either. Then the three camera + -- rungs, 75 last because it is the low camera this performance work is + -- aimed at. + -- + -- FULL (rung 1) is NOT here, and cannot usefully be. It is a settings + -- PRESET, not a render path: it sets tilt-shift to maximum, flattens the + -- world curve, fits the zoom, switches 3D battles on -- and pins DAYTIME + -- to SYNC and HOLDS it there (main.lua's applyFull / DayNight.forceSync), + -- which overrides this driver's pinned clock and makes every shot after + -- it depend on the wall clock. It also persists all of that, so one run's + -- FULL changes the options the NEXT run starts from. What FULL renders is + -- 35 degrees with the blur at 3, which rung 3 plus AB_TSHIFT=3 covers + -- exactly. + local RUNGS = {} + for n in (os.getenv("AB_RUNGS") or "0,2,3,5"):gmatch("%d+") do + RUNGS[#RUNGS + 1] = tonumber(n) + end + + local TSHIFT = math.floor(tonumber(os.getenv("AB_TSHIFT")) or 0) + + -- AB_SHADOW=0 renders with the sun pass's contribution turned off + -- (SHADOW_ALPHA 0 short-circuits the lookup in the scene shader). A + -- bisection tool: when a set of shots will not reproduce, this says + -- whether what is moving is in the shadow map or somewhere else. + if os.getenv("AB_SHADOW") == "0" then + V.require("Voxel3D").SHADOW_ALPHA = 0 + end + + -- PaletteFX.MODES, minus the inverted novelties: `ogred` and `classic` + -- are the SGB paths this mod bakes an atlas for, `gbc` is the shared + -- default, and `redpp` is the one that rebakes an atlas PER MAP -- four + -- genuinely different routes through TerrainAtlas. + local MODES = {} + for m in (os.getenv("AB_MODES") or "ogred,classic,gbc,redpp"):gmatch("[^,]+") do + MODES[#MODES + 1] = m + end + + -- Two times of day, because half the shader only runs in one of them: + -- the window lamps, the moon disc and the night tint are all dark-only, + -- and the glint sweep and the sun disc are day-only. + local TIMES = { "day", "night" } + + local shots, missed = 0, 0 + + -- U.shot's own mkdir is the POSIX one, which cmd.exe does not + -- understand, and a missing directory makes every capture vanish + -- silently. Try both spellings once, up front. + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + -- A capture that always costs the SAME number of frames. + -- + -- U.shot spins up to 120 frames waiting for the capture to land, which + -- is right for a screenshot and wrong for this: the animated tile slots + -- (water rolling, flowers opening) ride the engine's frames-since-boot + -- counter, so a scene reached after a different number of frames renders + -- its water at a different point in the roll and the shot differs for a + -- reason no change caused. A driver resume and a rendered frame are 1:1 + -- here, so the capture lands on the next draw and a fixed budget is both + -- enough and constant. + local CAPTURE_FRAMES = 4 + + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + local ShadowMap = V.require("ShadowMap") + + -- Wait for the scene to actually BE the scene the shot is named for. + -- Two things are still in motion after a teleport, and both are timed in + -- wall-clock seconds rather than frames, so "wait N frames" settles them + -- by a different amount on every machine and every run: + -- + -- the build queue -- meshes are built on a per-frame time budget, so a + -- slower run captures a half-built neighbour; + -- the camera tween -- Voxel.t runs on dt over TWEEN_TIME, so a shot + -- taken before it lands is at some arbitrary intermediate pitch. + -- + -- Both are waited on by their own completion flag, then a short fixed + -- settle. The variable wait is harmless now that the animation clock is + -- frozen above -- otherwise it would move the water instead. + -- and the CAMERA, which is the subtle one. It eases toward the player + -- over wall-clock dt, so after a fixed wait it has covered a distance + -- that depends on how fast the machine ran -- and the sun pass is only + -- redrawn when the camera crosses a quarter-world-pixel (VoxelScene's + -- shadow signature), so a frame caught mid-ease carries a shadow map + -- fitted for a slightly different camera than the one it is drawn with. + -- That is a real and deliberate tolerance in the mod, but it makes the + -- gate compare two arbitrary points inside it. Waiting for the camera + -- to stop moving entirely puts every shot at the same steady state. + local function cameraStill() + local o = game.overworld + local c = o and o.camera + if not c then return true end + local lx, ly, held = nil, nil, 0 + for _ = 1, 300 do + if c.x == lx and c.y == ly then + held = held + 1 + if held >= 10 then return true end + else + held = 0 + lx, ly = c.x, c.y + end + U.wait(1) + end + return false + end + + local function settleBuild() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + cameraStill() + -- and then force one final sun pass at the settled camera. The map is + -- only redrawn when the camera crosses a quarter world pixel, so a + -- still camera holds whatever was drawn at the moment it last did -- + -- correct to within that tolerance, but fitted from a position that + -- depends on where the easing happened to be, which differs by a few + -- hundredths of a pixel between runs and moves every shadow edge by a + -- shade or two. Forgetting the stamp redraws from the state the shot + -- is actually taken in, and two runs then agree exactly. + -- guarded so this driver can also be pointed at a build that predates + -- the seam, which is exactly what capturing a "before" reference means + if ShadowMap.forget then ShadowMap.forget() end + U.wait(20) + end + + -- AB_TRACE=1 prints the state each shot was taken in. When two runs of + -- this driver disagree, this is what says which input moved. + local TRACE = os.getenv("AB_TRACE") == "1" + + local function trace(name) + if not TRACE then return end + local o = game.overworld + local e = ShadowMap.extent or {} + print(("[ab] %-28s cam=(%.4f,%.4f) player=(%.3f,%.3f) res=%d extent=(%.3f,%.3f,%.3f) KX=%.6f KZ=%.6f angle=%.6f pend=%d") + :format(name, + o and o.camera and o.camera.x or -1, + o and o.camera and o.camera.y or -1, + o and o.player and o.player.px or -1, + o and o.player and o.player.py or -1, + ShadowMap.res or 0, + e[1] or 0, e[2] or 0, e[3] or 0, + ShadowMap.KX or 0, ShadowMap.KZ or 0, + Voxel.angle or 0, + ChunkMesher.pending())) + end + + -- AB_SHADOWDUMP=1 also writes the packed depth map itself, into the save + -- directory. When the scene differs but every input to the sun pass is + -- identical, the map is the only place left to look. + local DUMP = os.getenv("AB_SHADOWDUMP") == "1" + + local function dumpShadow(name) + if not DUMP then return end + local tex = ShadowMap.texture() + if not (tex and tex.newImageData) then return end + pcall(function() + love.filesystem.createDirectory("ab_shadow") + tex:newImageData():encode("png", "ab_shadow/" .. name .. ".png") + end) + end + + local function capture(name) + trace(name) + dumpShadow(name) + local path = ("%s/%s.png"):format(ROOT, name) + game.capturePath = path + U.wait(CAPTURE_FRAMES) + local f = io.open(path, "rb") + if f then + f:close() + shots = shots + 1 + else + missed = missed + 1 + print("[ab] capture did not reach disk: " .. path) + end + end + + local PaletteFX = require("src.render.PaletteFX") + + local function setMode(mode) + local known = false + for _, m in ipairs(PaletteFX.MODES) do + if m == mode then known = true break end + end + if not known then return false end + return (pcall(PaletteFX.setMode, mode)) + end + + -- A fixed zoom, so the view size every shot is composed at is the same + -- one. Zoom is persisted, so without this a session that ever ran the + -- FULL preset (which fits the zoom to the window) leaves a different + -- view size behind for every later run. + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + for _, mode in ipairs(MODES) do + if setMode(mode) then + for _, when in ipairs(TIMES) do + DayNight.setting:sync(when) + for _, s in ipairs(SCENES) do + for _, rung in ipairs(RUNGS) do + U.teleport(game, s.id, s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + -- pinned AFTER the voxel rung, because FULL is a preset that + -- reaches over and sets this row itself (main.lua's applyFull) + -- and persists it -- so without this, one run's FULL leaks a + -- blur level into the NEXT run's options.lua and every shot + -- differs for a reason no code change caused. Sharp by + -- default: a gaussian smears a one-pixel geometry difference + -- across the whole frame, which is exactly what a gate meant + -- to localise differences must not do. AB_TSHIFT=3 runs the + -- blur path deliberately. + Pipelines.setLevel("tiltshift", TSHIFT) + -- Wait for the build queue to drain rather than for a fixed + -- number of frames. Meshes are built on a per-frame time + -- budget, so how much of a map exists after N frames is a + -- property of the MACHINE -- a slower run captures a + -- half-built neighbour and the shot differs for no reason the + -- change caused. Draining first, then settling a fixed 40 + -- frames for the camera tween, makes the scene the same + -- scene everywhere. (Safe now that the animation clock above + -- is frozen: a variable wait no longer moves the water.) + settleBuild() + capture(("%s_%s_%s_v%d"):format(mode, when, s.label, rung)) + end + end + end + else + print("[ab] display mode " .. mode .. " unavailable, skipped") + end + end + + print(("[ab] %d shots into %s (%d failed to reach disk)") + :format(shots, ROOT, missed)) +end