capture drivers: real 1x, one window at a time

A POKEPORT_DRIVER run is deliberately unpaced -- main.lua's pacingEnabled()
returns false whenever the variable is set, so love.run spins as fast as the
machine allows and takes one Game:update(1/60) per turn of the loop. Right
for a screenshot script, wrong for a recording: "wait 180 frames" is three
seconds only by coincidence of hardware.

No engine change needed. The loop is blocked while the driver coroutine is
running, so sleeping inside it before each yield paces the whole thing -- one
logic step per one sixtieth of real time, 1x by construction. Measured with
DS_AUTOCLOSE: asked for 6.0s, took 6.31s, and it errs slow rather than fast.

shiny_one.lua does ONE encounter and then never finishes, which is how the
window stays up: main.lua quits the moment a driver coroutine goes dead. So
closing the window by hand is what ends the take. shiny_run.sh launches the
five in turn and blocks on each, so the next never opens over the top of one
still being recorded, and a bad take can just be closed and re-run.

It also puts options.lua back afterwards. The game persists the whole options
table mid-run, so every capture leaves its display settings on disk.
This commit is contained in:
DramaticShape
2026-08-08 13:22:52 -04:00
parent 322defbbd0
commit 120f9716b6
3 changed files with 299 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
-- Driver: five shiny encounters, five outdoor places, paced for a capture.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_obs.lua \
-- "/c/Program Files/LOVE/lovec.exe" .
--
-- Run from the PROJECT ROOT. Nothing here is a screenshot test -- it is a
-- performance, timed for somebody recording the window.
--
-- Each beat: teleport, let the ground mesh, open a wild battle, let the
-- Pokemon ARRIVE (the wipe, the send-out, the shiny sparkle), then hold
-- three full seconds on it before closing the battle and moving on. The hold
-- is deliberately silent -- no button taps during it -- so the recording is
-- of the Pokemon standing there and not of a text box being clicked through.
--
-- Every encounter is shiny, because the odds are pinned to 1 for the run and
-- the roll still happens where it always does, inside Pokemon.new.
return function(game)
local U = dofile("tests/drivers/util.lua")
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
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 -- nothing to show")
return
end
local Shiny = lib.require("Shiny")
local OverworldBattle = lib.require("OverworldBattle")
local StadiumInstall = lib.require("StadiumInstall")
-- ------- timing, in frames at 60fps
local SETTLE = 110 -- after a teleport, for the neighbourhood to mesh
local ARRIVE = 70 -- the wipe, then the Pokemon landing on its tile
local HOLD = 180 -- THE THREE SECONDS
local BETWEEN = 45 -- back on the map before the next one opens
-- ------- the models
if not StadiumInstall.ready() then
U.log("building stadium models first...")
StadiumInstall.begin()
local guard = 0
while not StadiumInstall.ready() and guard < 2000 do
for _ = 1, 6 do StadiumInstall.step() end
U.wait(1)
guard = guard + 1
end
end
U.log("stadium ready: " .. tostring(StadiumInstall.ready()))
OverworldBattle.setting:setValue("stadium", game)
Shiny.setOdds(1) -- every encounter, shiny
game.save.player.name = "RED"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
-- Five outdoor places, deliberately unalike -- a coast, an open route, a
-- water city, a rocky pass and a wooded shore -- so the recording is five
-- different-looking fights and not the same meadow five times. Each mon is
-- put somewhere its colour has something to sit against.
local RUNS = {
{ "BLASTOISE", 45, "PALLET_TOWN", 5, 6 },
{ "PIDGEOTTO", 32, "ROUTE_1", 5, 8 },
{ "GYARADOS", 45, "CERULEAN_CITY", 10, 12 },
{ "CHARIZARD", 50, "ROUTE_4", 10, 5 },
{ "NINETALES", 42, "ROUTE_25", 12, 5 },
}
-- a beat before the first one, so a recorder that started with the window
-- is not already mid-encounter by the time it is rolling
U.log("starting in 3...")
U.wait(60)
U.log("2...")
U.wait(60)
U.log("1...")
U.wait(60)
for i, r in ipairs(RUNS) do
local species, level, map, cx, cy = r[1], r[2], r[3], r[4], r[5]
if not game.data.pokemon[species] then
U.log(("skip %s -- not in this dataset"):format(species))
elseif not game.data.maps[map] then
U.log(("skip %s -- no map %s"):format(species, map))
else
U.teleport(game, map, cx, cy, "down")
U.wait(SETTLE)
local battle = BattleState.newWild(game, species, level)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
local mon = battle.enemy and battle.enemy.mon
U.log(("%d/5 %-10s %-14s shiny=%s")
:format(i, species, map, tostring(Shiny.isShiny(mon))))
-- the arrival, then three seconds of nothing but the Pokemon
U.wait(ARRIVE)
U.wait(HOLD)
-- close the battle and go back to the map
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end
U.wait(BETWEEN)
end
end
Shiny.setOdds(8192)
U.log("done -- five encounters")
end
+133
View File
@@ -0,0 +1,133 @@
-- Driver: ONE shiny encounter, at real 1x speed, held open until you close
-- the window. Meant to be launched once per Pokemon by tests/shiny_run.sh.
--
-- DS_SPECIES=GYARADOS DS_LEVEL=45 DS_MAP=CERULEAN_CITY DS_CX=10 DS_CY=12 \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_one.lua \
-- "/c/Program Files/LOVE/lovec.exe" .
--
-- ------- why the pacing is done here
--
-- A driver run is deliberately UNPACED: main.lua's pacingEnabled() returns
-- false whenever POKEPORT_DRIVER is set, so love.run spins as fast as the
-- machine will go and the loop takes one logic step (Game:update(1/60)) per
-- turn of it. That is right for a screenshot script and wrong for a capture:
-- the game runs at whatever multiple of real time the hardware manages, so
-- "wait 180 frames" is three seconds only by coincidence.
--
-- The fix does not need an engine change. The loop is blocked while this
-- coroutine is running, so sleeping HERE before each yield paces the whole
-- thing -- one 1/60 logic step per 1/60 of real time, which is 1x by
-- construction. Nothing else in the engine has to know.
--
-- ------- and why it never finishes
--
-- When a driver coroutine goes dead, main.lua calls love.event.quit(). So
-- holding the battle open forever is exactly how the window stays up until
-- somebody closes it, which is the handshake this run wants: one window, one
-- Pokemon, closed by hand, and only then the next.
return function(game)
local U = dofile("tests/drivers/util.lua")
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SPECIES = os.getenv("DS_SPECIES") or "GYARADOS"
local LEVEL = tonumber(os.getenv("DS_LEVEL") or "") or 45
local MAP = os.getenv("DS_MAP") or "ROUTE_1"
local CX = tonumber(os.getenv("DS_CX") or "") or 5
local CY = tonumber(os.getenv("DS_CY") or "") or 8
local LEAD = tonumber(os.getenv("DS_LEAD") or "") or 2.0 -- seconds
-- ------- real-time pacing
--
-- Each call is one logic step AND one sixtieth of a second of wall clock.
-- The target is carried forward rather than measured from "now" so a slow
-- frame is absorbed by the next one instead of compounding into drift.
local nextAt = nil
local function step()
local now = love.timer.getTime()
nextAt = (nextAt and (nextAt + 1 / 60)) or (now + 1 / 60)
local slack = nextAt - now
if slack > 0 then
love.timer.sleep(slack)
else
nextAt = now -- fell behind; do not try to catch up
end
coroutine.yield()
end
local function hold(seconds)
for _ = 1, math.floor(seconds * 60) do step() end
end
-- Unpaced, on purpose: a model rebuild is a loading screen, not part of
-- the capture, and there is no reason to watch it at 1x.
local function fast(n)
for _ = 1, n do coroutine.yield() end
end
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")
return
end
local Shiny = lib.require("Shiny")
local OverworldBattle = lib.require("OverworldBattle")
local StadiumInstall = lib.require("StadiumInstall")
if not StadiumInstall.ready() then
U.log("building stadium models (not part of the capture)...")
StadiumInstall.begin()
local guard = 0
while not StadiumInstall.ready() and guard < 2000 do
for _ = 1, 6 do StadiumInstall.step() end
fast(1)
guard = guard + 1
end
end
OverworldBattle.setting:setValue("stadium", game)
Shiny.setOdds(1) -- this encounter is shiny
game.save.player.name = "RED"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
if not game.data.pokemon[SPECIES] then
U.log("no such species: " .. SPECIES)
return
end
if not game.data.maps[MAP] then
U.log("no such map: " .. MAP)
return
end
U.teleport(game, MAP, CX, CY, "down")
hold(1.6) -- let the neighbourhood mesh
hold(LEAD) -- a beat before the fight opens
local battle = BattleState.newWild(game, SPECIES, LEVEL)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
local mon = battle.enemy and battle.enemy.mon
U.log(("%s at %s -- shiny=%s (1x; close the window for the next one)")
:format(SPECIES, MAP, tostring(Shiny.isShiny(mon))))
-- The battle stays up, at 1x, until the window is closed by hand. No taps:
-- the recording should be the Pokemon standing there, not a text box being
-- clicked through.
--
-- DS_AUTOCLOSE is for checking the pacing without a person in the loop: set
-- it to a number of seconds and the run should take about that long by the
-- wall clock, which is the only way to prove 1x is actually 1x.
local auto = tonumber(os.getenv("DS_AUTOCLOSE") or "") or 0
if auto > 0 then
local t0 = love.timer.getTime()
hold(auto)
U.log(("autoclose: asked for %.1fs, took %.2fs")
:format(auto, love.timer.getTime() - t0))
return
end
while true do step() end
end
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Five shiny encounters, five outdoor places, one window at a time.
#
# bash mods/DramaticShapeVoxelMod/tests/shiny_run.sh
#
# Run from the PROJECT ROOT.
#
# Each Pokemon gets its OWN game window, launched only after the previous one
# has been closed -- the loop blocks on the process, so closing the window is
# what advances it. Nothing is on a timer, so a take can be as long as it
# needs to be, and a bad one can just be closed and re-run.
#
# The battles run at true 1x: tests/shiny_one.lua paces itself against the
# wall clock, because a POKEPORT_DRIVER run is otherwise unpaced and goes as
# fast as the machine can manage.
set -u
LOVE=${LOVE:-/c/Program Files/LOVE/lovec.exe}
DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_one.lua
# species | level | map | cell x | cell y
RUNS=(
"BLASTOISE|45|PALLET_TOWN|5|6"
"PIDGEOTTO|32|ROUTE_1|5|8"
"GYARADOS|45|CERULEAN_CITY|10|12"
"CHARIZARD|50|ROUTE_4|10|5"
"NINETALES|42|ROUTE_25|12|5"
)
OPTS="$APPDATA/LOVE/pokemon-love2d/options.lua"
if [ -f "$OPTS" ]; then
cp "$OPTS" "$OPTS.shiny_run_backup"
echo "backed up options.lua"
fi
i=0
for row in "${RUNS[@]}"; do
i=$((i + 1))
IFS='|' read -r SPECIES LEVEL MAP CX CY <<< "$row"
echo ""
echo "=== $i/5 $SPECIES at $MAP ==="
echo " close the window when you are done recording it"
DS_SPECIES="$SPECIES" DS_LEVEL="$LEVEL" DS_MAP="$MAP" \
DS_CX="$CX" DS_CY="$CY" \
POKEPORT_DRIVER="$DRIVER" "$LOVE" .
done
# The game persists the whole options table mid-run (OverworldBattle.forceOG),
# so a run leaves the display settings it used on disk. Put them back.
if [ -f "$OPTS.shiny_run_backup" ]; then
cp "$OPTS.shiny_run_backup" "$OPTS"
echo ""
echo "options.lua restored"
fi
echo "done -- five encounters"