diff --git a/CHANGELOG.md b/CHANGELOG.md index 4557c58..331bf61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,119 @@ # Changelog +## 1.3.0 + +### Added + +- **BACK SPRITES, 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 SPRITES 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. + +### Fixed + +- **Battle pics were see-through, and it took a back sprite on a tiled floor + to make it obvious.** Gen 1 pics are two-bit art whose lightest shade is + white, and the decoded PNGs key that shade to alpha 0 -- which cost nothing + when the field behind them was white too. Over a route, every belly, every + eye white and every highlight is a hole with the world showing through, and + the mon reads as a stencil. + + `BattlePics` exists to put that paper back and, as written, put none of it + back. It flood-filled the outside from the border and filled what the flood + could not reach, which is exact and, on this game's art, empty: a Gen 1 + figure is an open drawing, and its belly walks out to the border through the + gap between its legs. Read across all 305 of the game's battle pics, that + rule finds an enclosed hole in exactly none of them. + + So a second rule: paper is what the figure is drawn OVER. A transparent + pixel with ink somewhere to its left AND to its right AND above it is under + the drawing and gets filled; the sky between a pair of ears has nothing over + it and stays sky, the ground beside a foot has nothing on one side of it and + stays ground. The silhouette is untouched either way, so the mon still cuts + cleanly against the world. Three running scans, cached per pic. + + Asking for ink BELOW as well was the obvious first cut and it is wrong, + which took a second pass to find: a battle pic is cropped flush at the feet, + bottom-aligned in its slot with all the margin at the top, so half of any + mon's belly has bare frame edge under it. Requiring it left a clean vertical + channel of world showing straight down the middle of a Clefairy. Below + cannot be accepted on its own either, or the whole sky over a mon's head + fills in -- it has the mon under it. The asymmetry is the rule. + + Both mons were affected -- the cards in the arena as much as anything -- so + this lands wherever a battle pic is drawn over the world, not just under + BACK SPRITES. + +- **The pinned back pic was lit at noon while the world behind it was not.** + Everything standing in the arena goes through the voxel shader, and that + shader multiplies by the hour's tint, so at dusk the diorama warms and at + night it goes blue -- the two mons' cards included, because they are drawn + in the same pass as the ground they stand on. A back pic pinned to the menu + is not in that pass; it is a flat blit over the finished shot, and it stayed + bright over a midnight route. + + The same tint is now applied to that one draw, by multiplying every colour + the pics layer sets on its way past -- so the alpha, the faint slide's fade + and the damage blink all compose with it instead of being overwritten. What + it does not get is the sun: the cards are shadow-mapped and a pic pinned to + the menu has no position in the scene to be shadowed at, so it carries the + hour and not the weather. + +### Changed + +- **TILT and GBC FX are off the OPTIONS menu entirely while this mod is + installed.** Both fight the diorama and both were already half-taken: the + mode's own key forces them off on every press, and the registry switches + TILT off whenever a world pipeline takes the pass. What was left was two + rows a player could set and watch get reverted -- TILT being the flat fake + of what this mode does for real, and GBC FX a full-screen present pass over + the top of the whole thing. + + Dropped AND held at zero, which is the part that matters: hiding a live + setting is a trap, because a save written before the mod was installed can + carry TILT 3 and a row that is not there cannot turn it back off. Pinned + wherever the value could arrive from -- the menu opening, a save being + loaded or begun -- so there is no route by which either is on and + unreachable. Uninstalling the mod puts both rows back, at whatever they were + last set to. + +- **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..b40d6cf 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,21 @@ 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 SPRITES** 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. + +Two of the engine's own rows are taken away while this mod is installed: +**TILT**, which is the flat fake of what this mode does for real, and **GBC +FX**, a full-screen present pass over the top of the diorama. Both are held at +off rather than merely hidden — a row that is not there cannot switch off a +value an older save arrived with. Uninstall and both come back, at whatever +they were last set to. + +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/BattlePics.lua b/lib/BattlePics.lua index 9cafa7b..e2983c7 100644 --- a/lib/BattlePics.lua +++ b/lib/BattlePics.lua @@ -9,12 +9,48 @@ -- of every eye, the highlight down a Pikachu's cheek: all of it turns into a -- hole with the world showing through, and the mon reads as a stencil. -- --- So the paper is put back, and only where the paper was: the pic is read --- back once, the transparent region OUTSIDE the figure is flood-filled from --- the border, and every transparent pixel the flood could not reach -- every --- hole enclosed by the artwork -- is filled opaque white. The silhouette is --- untouched, so the mon still cuts cleanly against the world; only its --- insides stop being see-through. +-- So the paper is put back, and only where the paper was. Which pixels those +-- are is the whole problem, and it has to be ANSWERED rather than looked up: +-- the hardware drew the mon's white belly and the white field behind it with +-- the same shade, the decoder keyed both to the same alpha, and nothing in the +-- image says which was which. There is no distinction to recover; there is one +-- to draw. +-- +-- Two rules, and a pixel filled by either is paper: +-- +-- ENCLOSED the transparent region outside the figure is flood-filled from +-- the border, and anything transparent the flood cannot reach is +-- a hole the artwork closes on all sides. +-- +-- UNDER THE anything transparent with ink somewhere to its left AND to its +-- DRAWING right AND above it. +-- +-- The first is exact and catches almost nothing: Gen 1 pics are open figures, +-- and a belly reaches the border through the gap between two legs. Read across +-- all 305 of this game's battle pics, it fills an enclosed hole in none of them +-- -- so on its own it left every mon a stencil, which is the bug this file +-- exists to fix and did not. +-- +-- The second is the one that does the work: paper is what the figure is drawn +-- OVER, and a pixel with the drawing to either side of it and over it is under +-- the drawing. The sky above a pair of ears has nothing over it and stays sky; +-- the ground beside a foot has nothing on one side of it and stays ground. +-- +-- The asymmetry is the whole trick and it is not arbitrary. Look BELOW a pixel +-- and the question has no answer, because a battle pic is CROPPED FLUSH AT THE +-- FEET -- bottom-aligned in its slot, with the margin all at the top. Half of +-- any mon's belly has bare frame edge under it and nothing else, which is why +-- requiring ink below left a clean vertical channel of world showing through +-- the middle of a Clefairy. And below cannot be accepted on its own either, or +-- the entire sky over a mon's head fills in: it has the mon under it. +-- +-- It is a heuristic and it is allowed to be one. What it fills that hardware +-- would not have distinguished is a deep notch -- the gap between two legs, +-- with a belly over it -- and the hardware drew white there too, so that is +-- the pixel the artist saw. +-- +-- The silhouette is untouched either way, so the mon still cuts cleanly +-- against the world; only its insides stop being see-through. -- -- Read back off the GPU rather than off the asset, deliberately. What comes -- back is the pic the engine actually decided to draw -- species palette, @@ -111,6 +147,52 @@ local function markOutside(data, w, h) return outside end +-- Mark every transparent pixel with ink to its left, to its right and above. +-- +-- Three running scans rather than three searches per pixel: sweeping each row +-- left to right carries "has there been ink yet" along with it, the same sweep +-- the other way, and one down the columns. Three passes over the image however +-- big the figure is. +local function markUnderDrawing(data, w, h) + local ink = {} + for y = 0, h - 1 do + local row = y * w + for x = 0, w - 1 do + local _, _, _, a = data:getPixel(x, y) + if a > CUT then ink[row + x] = true end + end + end + + local left, right, above = {}, {}, {} + for y = 0, h - 1 do + local row, seen = y * w, false + for x = 0, w - 1 do + left[row + x] = seen + seen = seen or ink[row + x] or false + end + seen = false + for x = w - 1, 0, -1 do + right[row + x] = seen + seen = seen or ink[row + x] or false + end + end + for x = 0, w - 1 do + local seen = false + for y = 0, h - 1 do + above[y * w + x] = seen + seen = seen or ink[y * w + x] or false + end + end + + local under = {} + for key = 0, w * h - 1 do + if not ink[key] and left[key] and right[key] and above[key] then + under[key] = true + end + end + return under +end + -- The pic with its enclosed holes filled, or the pic itself when that could -- not be done (no pixel access, a driver that refused the readback). Never -- nil for a non-nil argument: a caller must always have something to draw. @@ -125,12 +207,13 @@ function BattlePics.filled(img) if not data then return end local w, h = data:getDimensions() local outside = markOutside(data, w, h) + local under = markUnderDrawing(data, w, h) local fill = BattlePics.FILL local changed = false for y = 0, h - 1 do local row = y * w for x = 0, w - 1 do - if not outside[row + x] then + if not outside[row + x] or under[row + x] then local _, _, _, a = data:getPixel(x, y) if a <= CUT then data:setPixel(x, y, fill[1], fill[2], fill[3], fill[4]) diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index edb5f67..d8d0170 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -473,6 +473,11 @@ function BattleScene.render(state, arena, textures, token) -- the letterbox, so the depth-of-field pass can put its sharp band on -- the two marks rather than on a fraction of the window lx = lx, ly = ly, scale = s, pw = pw, ph = ph, + -- and the hour's light, for anything drawn over this shot that is NOT + -- geometry and so never went past the shader that applied it -- the back + -- pic pinned to the menu (see OverworldBattle.backPinned). Neutral + -- indoors, which is what DayNight.tint answers for a room. + tint = Voxel3D.tint, } end) -- the placed camera is ours for exactly this pass; anything else that 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..dddf97d 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -72,6 +72,38 @@ function OverworldBattle.enabled() return OverworldBattle.setting:get() and true or false end +-- ------- BACK SPRITES: 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 SPRITES 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 SPRITES" + +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 +113,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 SPRITES is on, 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 +127,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 +177,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 +270,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 +564,81 @@ 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 hour's light, on a pic that is not geometry +-- +-- Everything standing in the arena goes through the voxel shader, and that +-- shader multiplies by the hour's tint: at dusk the whole diorama warms, at +-- night it goes blue, and the two mons' cards go with it because they are +-- drawn in the same pass as the ground they stand on. +-- +-- A back pic pinned to the menu is not in that pass. It is the engine's own +-- flat blit over the finished shot, so it arrived at noon while the world +-- behind it was at midnight -- a mon lit by nothing in the frame. +-- +-- So the tint is applied by hand, to that one draw. Every colour the pics +-- layer sets is multiplied on its way past, which is the whole of it: the +-- layer draws the pic with love.graphics.draw and LOVE multiplies by the draw +-- colour, so tinting the colour tints the pixels -- and the alpha, the faint +-- slide's fade and the blink's own colour all compose with it rather than +-- being overwritten. +-- +-- What this does NOT get is the sun: the cards are shadow-mapped, so one +-- standing under a tree is darker than the tint alone, and this pic has no +-- position in the scene to be shadowed at. It carries the hour and not the +-- weather, which is the part the eye reads. +local function withTint(tint, fn, ...) + if not tint then return fn(...) end + local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1 + if r > 0.999 and g > 0.999 and b > 0.999 then return fn(...) end + local gfx = love.graphics + local setColor = gfx.setColor + gfx.setColor = function(cr, cg, cb, ca, ...) + if type(cr) == "table" then + return setColor({ (cr[1] or 1) * r, (cr[2] or 1) * g, (cr[3] or 1) * b, + cr[4] }, cg, ...) + end + if cr == nil then return setColor(cr, cg, cb, ca, ...) end + return setColor(cr * r, (cg or 1) * g, (cb or 1) * b, ca, ...) + end + local ok, err = pcall(fn, ...) + gfx.setColor = setColor + -- the layer leaves whatever colour it last set, and that one is tinted; + -- hand the next caller plain white rather than a dimmed one + setColor(1, 1, 1, 1) + 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 +768,19 @@ function OverworldBattle.flashing(battle) end -- Both sides, or nil when neither has anything to show. +-- +-- One side under BACK SPRITES: 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 +895,44 @@ 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 SPRITES, 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) + local shot = self.dramaticShapeShot + if not shot then + return innerPics(self, slide, sx, sy, onlySide, skipMenuClip) + end + if OverworldBattle.backPinned() and onlySide ~= "enemy" then + -- under the hour's own light, like everything else in the frame -- see + -- withTint, and the tint BattleScene hands over with the shot. + -- + -- Except on the wavy path, where the pic is baked into the GRAYSCALE bg + -- canvas for the zone pass to colour by region. That pass keys off the + -- red channel, and a night tint pulls red down -- it would not darken + -- the mon, it would remap it to the wrong shade. SE_WAVY_SCREEN lasts a + -- second and the hour survives it fine. + local tint = not self.grayPics and shot.tint or nil + return withTint(tint, 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 +950,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 SPRITES 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 +1095,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 +1135,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..26c78e6 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 SPRITES 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 " @@ -354,9 +367,12 @@ mod.options:define(schema) -- AND the engine's TILT on the same press. -- -- Consequences worth being explicit about: while this mod is enabled, TILT --- (3) and GBC FX (5) are unreachable by key. Both are still reachable on --- the OPTIONS menu, and TILT is the one this mode supersedes anyway -- the --- registry already forces it off whenever a world pipeline takes the pass. +-- (3) and GBC FX (5) are unreachable by key -- and unreachable on the OPTIONS +-- menu too, where both rows are taken away and both values held at zero (see +-- pinEngineFx). Nothing is being hidden that still does something: TILT is the +-- flat fake of what this mode does for real, the registry already forces it +-- off whenever a world pipeline takes the pass, and GBC FX is a full-screen +-- present pass over the top of the diorama. Uninstalling puts both back. -- -- Everything the engine does around a pipeline hotkey has to happen here -- too, so the work is DELEGATED rather than reimplemented: Pipelines.hotkey @@ -475,12 +491,50 @@ local function dropRow(out, id) return out end +-- ------- TILT and GBC FX are gone while this mod is installed +-- +-- Both fight the diorama, and both were already half-taken: the mode's own key +-- (3) forces them off on every press, and the registry switches TILT off +-- whenever a world pipeline takes the pass. What was left was two rows the +-- player could set and watch get reverted -- TILT is the flat fake of what +-- this mode does for real, and GBC FX is a full-screen present pass over the +-- top of the whole thing. +-- +-- So they come OFF the menu, and are HELD at zero rather than merely dropped. +-- Hiding a live setting is a trap: a save written before the mod was installed +-- can carry TILT 3, and a row that is not there is a row that cannot turn it +-- back off. Pinned wherever the value could have arrived from -- the menu +-- opening, a save being loaded or begun -- so there is no route by which one +-- of them is on and unreachable. +-- +-- Everything they did is still reachable: uninstall the mod and both rows are +-- back, at whatever they were last set to. +local function pinEngineFx(game) + game = game or require("src.core.Game") + local opts = game and game.save and game.save.options + local Tilt = require("src.render.Tilt") + local GBCFX = require("src.render.GBCFX") + local changed = false + if opts then + changed = (opts.tilt or 0) ~= 0 or (opts.gbcfx or 0) ~= 0 + opts.tilt, opts.gbcfx = 0, 0 + end + pcall(Tilt.setLevel, 0) + pcall(GBCFX.setLevel, 0) + if changed and game.writeOptions then pcall(game.writeOptions, game) end +end + -- call next() first and decorate what comes back, so every other mod's -- rows survive this one mod.hooks:wrap("ui.options.rows", function(next, game, rows) local out = next(game, rows) if type(out) ~= "table" then return out end local Pipelines = require("src.render.Pipelines") + -- ahead of every branch below, including FULL's early return: these two are + -- off the menu whatever else this mod is or is not doing + pinEngineFx(game) + dropRow(out, "tilt") + dropRow(out, "gbcfx") -- BATTLE LAYOUT is the ENGINE's row, and this is the one place the mod takes -- one away. While a fight can be staged on the map, OG is the only layout it -- can be composed in (OverworldBattle.forceOG), so the value is pinned there @@ -499,7 +553,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 SPRITES, 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) @@ -728,10 +787,16 @@ end) mod.events:on("save.loaded", function() DayNight.restore() + -- a save written before this mod was installed can carry TILT or GBC FX + -- switched on, and their rows are not there to switch them back off (see + -- pinEngineFx). Answered here rather than only when the menu opens, so a + -- player who never opens it is not left playing under one. + pinEngineFx() end) mod.events:on("save.created", function() DayNight.restore() + pinEngineFx() end) -- The engine's own time-of-day seam. OverworldState:timeOfDay() is an @@ -745,7 +810,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..9f72264 100644 --- a/mod.card +++ b/mod.card @@ -11,17 +11,18 @@ 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", - "hotkeys 3 and 5 are taken over from the engine's TILT and GBC FX; both remain on the OPTIONS menu", - "the VOXEL key (3) turns TILT and GBC FX off on every press -- both fight the diorama, and 3 is now the only key that reaches either", + "the engine's TILT and GBC FX rows are taken OFF the OPTIONS menu and held at off for as long as this mod is installed -- TILT is the flat fake of what this mode does for real, GBC FX is a full-screen pass over the top of it; uninstalling puts both rows back", + "hotkeys 3 and 5 are taken over from those two, which have no key and no row while this is loaded", }, added = { "VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees)", "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 SPRITES 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..a637c31 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -210,6 +210,53 @@ T.eq(layoutGame.save.options.battleLayout, "og", Battles.setting:setIndex(1, layoutGame) end +-- ------- TILT and GBC FX are off the menu entirely +-- +-- Two ENGINE rows, taken away for as long as this mod is installed. Both fight +-- the diorama and both were already half-taken -- the mode's own key forces +-- them off on every press, and the registry switches TILT off whenever a world +-- pipeline takes the pass -- so what was left was two rows a player could set +-- and watch get reverted. +-- +-- Dropped AND held at zero, which is the part that matters: a save written +-- before the mod was installed can carry TILT 3, and a row that is not there +-- is a row that cannot turn it back off. +do +local fxGame = { + data = Data, + save = { options = { tilt = 3, gbcfx = 2, pipelines = {}, modOptions = {} } }, + mods = { modOptions = {} }, + writeOptions = function() end, +} +local Tilt = require("src.render.Tilt") +local GBCFX = require("src.render.GBCFX") +Tilt.setLevel(3) +GBCFX.setLevel(2) + +local fxRows = Runtime.call("ui.options.rows", function(_, r) return r end, + fxGame, + { { id = "tilt" }, { id = "gbcfx" }, + { id = "colors" }, { id = "pipeline:voxel" } }) +local fxIds = {} +for _, row in ipairs(fxRows) do fxIds[row.id] = true end +T.check(not fxIds["tilt"], "TILT is off the OPTIONS menu") +T.check(not fxIds["gbcfx"], "and so is GBC FX") +T.check(fxIds["colors"] and fxIds["pipeline:voxel"], + "with every other row the engine offered still on it") + +T.eq(fxGame.save.options.tilt, 0, + "a save that had TILT on is pinned back to off, not left on with no row") +T.eq(fxGame.save.options.gbcfx, 0, "and GBC FX with it") +T.eq(Tilt.level, 0, "the live level follows, so the frame is not still tilted") + +-- and FULL, which returns early from the rows hook, must not be a way back in +Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL) +local fullFx = Runtime.call("ui.options.rows", function(_, r) return r end, + fxGame, { { id = "tilt" }, { id = "gbcfx" } }) +T.eq(#fullFx, 0, "under FULL they are gone too -- its early return is below them") +Pipelines.setLevel("voxel", 2) +end + -- ------- and off FULL, the rows come back, grouped with the mode -- -- The engine splices a pipeline row in beside TILT and lands a mod's own @@ -306,9 +353,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 +367,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 SPRITES", "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 +1921,202 @@ 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 SPRITES 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 SPRITES 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 SPRITES 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 SPRITES 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 + +-- ------- and a pinned back pic is not a stencil +-- +-- Gen 1 battle pics are two-bit art whose lightest shade is WHITE, and the +-- decoded PNGs key that shade to alpha 0 -- free on a white field, a hole with +-- the world showing through over a route. BattlePics puts the paper back. +-- +-- It used to do that by flood-filling the outside and filling what the flood +-- could not reach, which is exact and, on this game's art, fills NOTHING: a +-- Gen 1 figure is an open drawing and its belly reaches the border through the +-- gap between two legs. Every mon was a stencil, most obviously the back pic +-- pinned to the menu, which is drawn flat over the tiles rather than shaded +-- like a card. So paper is also anything with ink to its left AND right AND +-- ABOVE it -- what the figure is drawn over. +-- +-- The asymmetry is the load-bearing part, so it is what this drives at. A pic +-- is cropped flush at the FEET, so half a mon's belly has bare frame edge +-- under it: ask for ink below as well and a clean channel of world shows +-- through the middle of the figure, which is the bug that got reported. +-- +-- Driven against a hand-drawn figure with exactly that shape -- ears with sky +-- between them, a belly, legs, and a bottom edge flush with the frame. +-- NOTHING in it is enclosed: every transparent pixel inside walks out between +-- the legs and off that bottom edge, so the flood-fill rule alone leaves this +-- figure exactly as it found it. +do +local BattlePics = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattlePics") + +local FIGURE = { + "..#..#..", -- two ears, with sky between them + "..#..#..", + "..####..", -- and the head closing under them + ".#....#.", -- belly: sides beside it, head over it, bare frame under it + ".#....#.", + ".#....#.", + ".#.##.#.", -- legs, with the gap between them open to the bottom edge + ".#.##.#.", -- which the frame cuts flush, exactly as a battle pic is cut +} +local W, H = #FIGURE[1], #FIGURE + +local function fakeData(rows) + local px = {} + for y = 0, H - 1 do + for x = 0, W - 1 do + px[y * W + x] = rows[y + 1]:sub(x + 1, x + 1) == "#" and 1 or 0 + end + end + return { + px = px, + getDimensions = function() return W, H end, + getPixel = function(self, x, y) + local a = self.px[y * W + x] + return 0, 0, 0, a + end, + setPixel = function(self, x, y, r, g, b, a) self.px[y * W + x] = a end, + } +end + +local built = nil +local realNewCanvas, realNewImage = love.graphics.newCanvas, love.graphics.newImage +love.graphics.newCanvas = function() + return { setFilter = function() end, release = function() end, + newImageData = function() return fakeData(FIGURE) end } +end +love.graphics.newImage = function(data) + built = data + return { setFilter = function() end, dramaticShapeFilled = true } +end + +local pic = { getDimensions = function() return W, H end } +local out = BattlePics.filled(pic) +love.graphics.newCanvas, love.graphics.newImage = realNewCanvas, realNewImage + +T.check(out ~= pic and built ~= nil, + "the pic comes back rebuilt: there was paper to put back") +local function opaque(x, y) return built.px[y * W + x] > 0.5 end + +-- the belly, filled edge to edge. Every one of these has bare frame under it +-- rather than ink, which is the case that used to leave a channel of world +-- showing straight down the middle of the mon +T.check(opaque(2, 3) and opaque(3, 4) and opaque(5, 5), + "the figure's white insides are filled back in, so it is not see-through") + +-- the sky between two ears is NOT paper, and this is what the third ray is +-- for: nothing is drawn over it +T.check(not opaque(3, 0) and not opaque(4, 1), + "the sky between its ears stays sky -- there is no drawing over it") + +-- the outside is untouched, which is what keeps the silhouette cutting +-- cleanly against the world instead of standing in a white box +T.check(not opaque(0, 0) and not opaque(7, 0), + "the corners it never covered stay transparent") +T.check(not opaque(0, 4) and not opaque(7, 4), + "and so do the columns beside it -- ink on one side is not being inside") + +-- the notch between the legs, on the other hand, has a belly over it and legs +-- either side, so it is paper. The hardware drew white there too +T.check(opaque(2, 7), "the notch between its legs is under the drawing") + +-- and the answer is cached on the image, so a pic costs one readback a +-- session rather than one a frame +T.eq(BattlePics.filled(pic), out, "the rebuilt pic is cached on the original") +BattlePics.invalidate() +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