updated tests

This commit is contained in:
DramaticShape
2026-07-26 16:53:00 -04:00
parent 702d8049f0
commit aa49898fd6
11 changed files with 1020 additions and 14 deletions
+16 -14
View File
@@ -95,11 +95,11 @@ long as the thing throwing them is tall.
branch inside it, because it needs shader derivatives (`fwidth`) -- the
one part of the mode a driver can refuse. A refusal costs the grid and
nothing else.
- `tests/drivers/voxel_shadow_probe.lua`: reports the fitted frustum and
the resolution rung, dumps the map itself, and shoots a stand point at
every pitch. `SHADOW_SUN="kx,kz"` retunes the bearing for one run,
`SHADOW_GRID=1` forces the wireframe on, and `SHADOW_ZOOM` pins the
zoom, without which two runs are not comparable -- a driver inherits
- `mods/DRAMATIC_SHAPE/tests/voxel_shadow_probe.lua`: reports the fitted
frustum and the resolution rung, dumps the map itself, and shoots a
stand point at every pitch. `SHADOW_SUN="kx,kz"` retunes the bearing for
one run, `SHADOW_GRID=1` forces the wireframe on, and `SHADOW_ZOOM` pins
the zoom, without which two runs are not comparable -- a driver inherits
whatever the player left in `options.lua`, and the world view size (which
the light frustum is fitted to) swings 3x across that range.
@@ -128,10 +128,10 @@ long as the thing throwing them is tall.
announces it, and checked ahead of the active() gate so switching it
while voxel mode is off still drops what is cached.
`tests/drivers/voxel_void_probe.lua` walks the three modes and reports
the border block, whether the mesh built and whether the scene took the
3D path. It deliberately does NOT invalidate the cache itself, since
doing so would hide the second half of this.
`mods/DRAMATIC_SHAPE/tests/voxel_void_probe.lua` walks the three modes
and reports the border block, whether the mesh built and whether the
scene took the 3D path. It deliberately does NOT invalidate the cache
itself, since doing so would hide the second half of this.
- Water and flowers did not animate. The 2D path animates them by
OVERDRAWING the animated cells on top of the static tile layer each
@@ -448,8 +448,9 @@ written up in `assets/docs/buidling_to_voxel/`.
## 1.1.0
Interior furniture gets the shapes it depicts (docs/voxel-survey.md is the
procedure that found and verified these).
Interior furniture gets the shapes it depicts
(mods/DRAMATIC_SHAPE/tools/voxel-survey.md is the procedure that found and
verified these).
### Added
@@ -487,9 +488,10 @@ procedure that found and verified these).
standing monitor, bookcases, TV standing on the floor behind the game
console's relief, potted plant, flower pot, both staircases, and the
wall/window band.
- `tests/drivers/voxel_survey.lua`: screenshot-survey driver behind the
repeatable inspection procedure (SURVEY_MAP / SURVEY_SPOTS /
SURVEY_LEVELS / SHOT_DIR), documented in docs/voxel-survey.md.
- `mods/DRAMATIC_SHAPE/tests/voxel_survey.lua`: screenshot-survey driver
behind the repeatable inspection procedure (SURVEY_MAP / SURVEY_SPOTS /
SURVEY_LEVELS / SHOT_DIR), documented in
mods/DRAMATIC_SHAPE/tools/voxel-survey.md.
### Changed
+133
View File
@@ -0,0 +1,133 @@
-- Driver: does the water/flower tile animation reach voxel mode?
--
-- Shoots a burst of frames spaced about one animation period apart, in
-- voxel mode and (for reference) flat. The evidence is the difference
-- between consecutive shots: water tile $14 rolls sideways a pixel a step
-- and flower tile $03 cycles three frames, so the burst should NOT be
-- eight copies of the same picture.
--
-- POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/voxel_anim_probe.lua lovec .
--
-- knobs (env):
-- ANIM_MAP map id (default PALLET_TOWN)
-- ANIM_SPOT "x,y[,facing]" (default 5,6,down)
-- ANIM_SHOTS frames in the burst (default 6)
-- ANIM_LEVEL voxel rung, 0 = flat (default 3)
-- SHOT_DIR output directory, must exist (default "shots")
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local TileRenderer = require("src.render.TileRenderer")
local SPEED = math.max(1,
math.floor(tonumber(os.getenv("POKEPORT_SPEED")) or 1))
local function wait(n) U.wait(n * SPEED) end
local DIR = os.getenv("SHOT_DIR") or "shots"
local mapId = os.getenv("ANIM_MAP") or "PALLET_TOWN"
local shots = math.floor(tonumber(os.getenv("ANIM_SHOTS")) or 6)
local level = math.floor(tonumber(os.getenv("ANIM_LEVEL")) or 3)
local sx, sy, facing = (os.getenv("ANIM_SPOT") or "5,6,down")
:match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)")
facing = (facing ~= "" and facing) or "down"
local tileset = game.data.maps[mapId] and game.data.maps[mapId].tileset
local specs = tileset and game.data.tilesets
and TileRenderer.defaultAnimatedTiles(game.data.tilesets[tileset])
print(("[anim] %s tileset=%s animated entries=%d")
:format(mapId, tostring(tileset), specs and #specs or 0))
for _, s in ipairs(specs or {}) do
print(("[anim] tile $%02X kind=%s period=%s")
:format(s.tile or -1, tostring(s.kind), tostring(s.period)))
end
local Zoom = require("src.render.Zoom")
Zoom.reset()
Pipelines.setLevel("tiltshift", 0)
U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing)
wait(20)
Pipelines.setLevel("voxel", level)
wait(30) -- outlast the camera tween
-- the atlas the terrain mesh actually samples, dumped per step: if these
-- differ but the frames do not, the animated tiles are not reaching the
-- geometry (baked into prop prisms, say) rather than not animating
local V = game.mods.exports["DRAMATIC_SHAPE"]
V = V and V.lib
local TerrainAtlas = V and V.require("TerrainAtlas")
local PaletteFX = require("src.render.PaletteFX")
local function dumpAtlas(tag)
if not TerrainAtlas then return end
local ow = game.overworld
local map = ow and ow.map
if not map then return end
local colors = nil
if not PaletteFX.usesGbcPack() and ow.paletteFor then
colors = ow:paletteFor(map)
end
local img = TerrainAtlas.forMap(map, colors)
if not img then return end
print(("[anim] atlas %s: animated copy=%s")
:format(tag, tostring(img ~= map.renderer.image)))
local ok = pcall(function()
local w, h = img:getDimensions()
local c = love.graphics.newCanvas(w, h)
love.graphics.setCanvas(c)
love.graphics.clear(0, 0, 0, 0)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img)
love.graphics.setCanvas()
c:newImageData():encode("png", "atlas_" .. tag .. ".png")
end)
if not ok then print("[anim] atlas dump failed") end
end
-- where the animated tiles ended up in the geometry: a flat top quad
-- samples its tile through uvRect and picks the atlas patch up, but a
-- cell Structures turned into a prop prism has its pixels baked as
-- point-sampled voxels and would not
if V and specs then
local Structures = V.require("Structures")
local ow = game.overworld
local S = ow and ow.map and Structures.forMap(ow.map)
if S then
local def = ow.map.def
for _, spec in ipairs(specs) do
local flat, prop, run, first = 0, 0, 0, nil
for ty = 0, def.height * 4 - 1 do
for tx = 0, def.width * 4 - 1 do
local k = (ty + 64) * 4096 + (tx + 64)
if S.tileAt[k] == spec.tile then
if S.skip[k] then prop = prop + 1
elseif S.runs[k] then run = run + 1
else
flat = flat + 1
first = first or { tx, ty }
end
end
end
end
print(("[anim] tile $%02X on this map: %d flat, %d in a volume run,"
.. " %d inside a prop%s"):format(spec.tile, flat, run, prop,
first and (" -- first at cell " .. math.floor(first[1] / 2)
.. "," .. math.floor(first[2] / 2)) or ""))
end
end
end
-- one animation period is 20 logic frames at 60Hz, and TileRenderer.tick
-- runs on wall-clock steps, so ~22 rendered frames lands the next step
for i = 1, shots do
game.capturePath = ("%s/anim_%s_L%d_%02d.png"):format(DIR, mapId, level, i)
wait(3)
print(("[anim] shot %d at animFrame %d step %d"):format(
i, TileRenderer.animFrame(), math.floor(TileRenderer.animFrame() / 20) % 8))
dumpAtlas(tostring(i))
wait(22)
end
Pipelines.setLevel("voxel", 0)
wait(5)
print("[anim] done")
end
+43
View File
@@ -0,0 +1,43 @@
-- Driver: the reported "2D flash when entering a building".
-- Switches voxel mode on, walks through Red's front door, and records for
-- EVERY frame of the warp whether a pipeline owned the world pass. Any
-- frame with the mode on but no pipeline is a frame the player saw flat.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "shots"
local Pipelines = require("src.render.Pipelines")
U.teleport(game, "PALLET_TOWN", 5, 6, "up")
U.wait(30)
Pipelines.setLevel("voxel", 2)
Pipelines.setLevel("tiltshift", 0)
U.wait(30)
print("[door] outside -- level:", Pipelines.level("voxel"),
"pipeline:", tostring(Pipelines.worldPipeline()))
U.shot(game, DIR .. "/door_0_outside.png")
local flat, total = 0, 0
local firstFlatAt = nil
-- walk onto the doormat and through the warp, sampling every frame
for i = 1, 90 do
game.input.state = game.input.state or {}
U.tap(game, "up")
U.wait(1)
total = total + 1
if Pipelines.level("voxel") > 0 and Pipelines.worldPipeline() == nil then
flat = flat + 1
firstFlatAt = firstFlatAt or i
if flat <= 3 then
U.shot(game, DIR .. ("/door_flat_frame%d.png"):format(i))
end
end
if i == 45 then U.shot(game, DIR .. "/door_1_mid.png") end
end
U.wait(20)
print("[door] inside map:", game.overworld.map and game.overworld.map.id)
print("[door] pipeline:", tostring(Pipelines.worldPipeline()))
print(("[door] RESULT flat frames %d / %d (first at %s)")
:format(flat, total, tostring(firstFlatAt)))
U.shot(game, DIR .. "/door_2_inside.png")
end
+67
View File
@@ -0,0 +1,67 @@
-- Driver: measure voxel-mode memory growth over the Pallet -> Mt Moon
-- trek. Teleports along the chain with voxel engaged, and after each map
-- prints the Lua heap (post-collect), LOVE texture memory, and the size
-- of the voxel mod's retained caches -- so the 4.9GB growth decomposes
-- into named pools.
return function(game)
local U = dofile("tests/drivers/util.lua")
local clock = (love.timer and love.timer.getTime) or os.clock
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[mem] DRAMATIC_SHAPE mod not loaded")
return
end
local V = handle.lib
local CHAIN = {
{ "PALLET_TOWN", 10, 8 },
{ "ROUTE_1", 10, 18 },
{ "VIRIDIAN_CITY", 20, 20 },
{ "ROUTE_2", 8, 30 },
{ "VIRIDIAN_FOREST", 17, 24 },
{ "ROUTE_2", 8, 10 },
{ "PEWTER_CITY", 18, 20 },
{ "ROUTE_3", 20, 5 },
{ "ROUTE_4", 10, 5 },
{ "MT_MOON_1F", 15, 20 },
}
game:keypressed("6") -- voxel 15
U.wait(20)
local lastLua = 0
for i, step in ipairs(CHAIN) do
local t0 = clock()
U.teleport(game, step[1], step[2], step[3], "down")
U.wait(30) -- let the voxel frame build meshes for map + neighbors
local buildT = clock() - t0
collectgarbage("collect")
collectgarbage("collect")
local luaKB = collectgarbage("count")
local stats = love.graphics.getStats()
print(("[mem] %-18s lua=%7.1fMB (+%6.1f) tex=%7.1fMB images=%4d canvases=%3d fonts=%d t=%5.0fms")
:format(step[1], luaKB / 1024, (luaKB - lastLua) / 1024,
(stats.texturememory or 0) / 1048576,
stats.images or -1, stats.canvases or -1, stats.fonts or -1,
buildT * 1000))
lastLua = luaKB
end
-- decompose the retained Lua heap: drop the mod's caches one at a time
-- and re-collect, so each pool's share is named
local ChunkMesher = V.require("ChunkMesher")
local Structures = V.require("Structures")
collectgarbage("collect")
local before = collectgarbage("count")
ChunkMesher.invalidate() -- drops mesh cache AND Structures cache
collectgarbage("collect")
collectgarbage("collect")
local after = collectgarbage("count")
print(("[mem] voxel caches held %.1fMB of Lua heap (%.1f -> %.1f)")
:format((before - after) / 1024, before / 1024, after / 1024))
local stats = love.graphics.getStats()
print(("[mem] texture memory after invalidate: %.1fMB (meshes are not textures; GPU mesh memory is untracked)")
:format((stats.texturememory or 0) / 1048576))
print("[mem] done")
end
+204
View File
@@ -0,0 +1,204 @@
-- Driver: diagnose the voxel mode-switch hitch and map-seam artifacts.
--
-- Three passes, all printed:
-- 1. Teleport to Route 1, engage voxel, and time every build-side
-- function (Structures, ChunkMesher, meshes, atlases) plus per-frame
-- wall time, so the mode-switch hitch decomposes into named costs.
-- 2. Rebuild the seam-band geometry CPU-side and list every quad that
-- rises above the ground plane near the Route 1 / Pallet seam, to
-- name the source of the stray pixels.
-- 3. Walk across the seam both ways logging the ground height the
-- voxel pass would stand the player on each frame (the "hop").
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "shots"
local clock = (love.timer and love.timer.getTime) or os.clock
U.teleport(game, "ROUTE_1", 10, 30, "down")
U.wait(10)
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[perf] DRAMATIC_SHAPE mod not loaded")
return
end
local V = handle.lib
local Structures = V.require("Structures")
local ChunkMesher = V.require("ChunkMesher")
local Voxel3D = V.require("Voxel3D")
local TerrainAtlas = V.require("TerrainAtlas")
local TileShape = V.require("TileShape")
-- ---- pass 1: instrument the build ----
local stats, order = {}, {}
local function wrap(tbl, name, label, argLabel)
local orig = tbl[name]
if not orig then print("[perf] missing " .. label); return end
tbl[name] = function(...)
local t0 = clock()
local r1, r2, r3 = orig(...)
local dt = clock() - t0
local lbl = label
if argLabel then
local extra = argLabel(...)
if extra then lbl = label .. "[" .. tostring(extra) .. "]" end
end
local s = stats[lbl]
if not s then
s = { n = 0, total = 0, max = 0 }
stats[lbl] = s
order[#order + 1] = lbl
end
s.n = s.n + 1
s.total = s.total + dt
if dt > s.max then s.max = dt end
return r1, r2, r3
end
end
local mapId = function(map) return map.id end
wrap(Structures, "forMap", "Structures.forMap", mapId)
wrap(Structures, "extractObjects", "Structures.extractObjects")
wrap(Structures, "buildVolume", "Structures.buildVolume")
wrap(Structures, "buildObject", "Structures.buildObject")
wrap(Structures, "buildGrass", "Structures.buildGrass")
wrap(Structures, "buildCylinders", "Structures.buildCylinders")
wrap(Structures, "buildStairs", "Structures.buildStairs")
wrap(Structures, "buildBookcases", "Structures.buildBookcases")
wrap(ChunkMesher, "geometry", "ChunkMesher.geometry", mapId)
wrap(ChunkMesher, "build", "ChunkMesher.build", mapId)
wrap(Voxel3D, "newMesh", "Voxel3D.newMesh")
wrap(TerrainAtlas, "forMap", "TerrainAtlas.forMap", mapId)
-- engage voxel level 2 (35 degrees) like the player: 6, then 6 again
local frameTimes = {}
game:keypressed("6")
for i = 1, 30 do
local f0 = clock()
coroutine.yield()
frameTimes[i] = clock() - f0
end
game:keypressed("6")
for i = 31, 60 do
local f0 = clock()
coroutine.yield()
frameTimes[i] = clock() - f0
end
U.shot(game, DIR .. "/perf_route1_voxel.png")
local sorted = {}
for _, lbl in ipairs(order) do sorted[#sorted + 1] = lbl end
table.sort(sorted, function(a, b) return stats[a].total > stats[b].total end)
print("[perf] ---- build stats (sorted by total) ----")
for _, lbl in ipairs(sorted) do
local s = stats[lbl]
print(("[perf] %-50s n=%5d total=%8.1fms max=%7.1fms")
:format(lbl, s.n, s.total * 1000, s.max * 1000))
end
print("[perf] ---- frame spikes over 25ms ----")
for i, ft in ipairs(frameTimes) do
if ft > 0.025 then
print(("[perf] frame %02d: %.1fms"):format(i, ft * 1000))
end
end
-- ---- pass 2: seam-band geometry scan ----
local function scanQuads(tag, verts, ox, oy, zlo, zhi, xlo, xhi)
local n = 0
for i = 1, #verts, 4 do
local minX, minY, minZ = math.huge, math.huge, math.huge
local maxX, maxY, maxZ = -math.huge, -math.huge, -math.huge
for j = i, i + 3 do
local v = verts[j]
local x, y, z = v[1] + ox, v[2], v[3] + oy
if x < minX then minX = x end
if x > maxX then maxX = x end
if v[2] < minY then minY = y end
if y > maxY then maxY = y end
if z < minZ then minZ = z end
if z > maxZ then maxZ = z end
end
if maxY > 0.01 and minZ < zhi and maxZ > zlo
and (not xlo or (minX < xhi and maxX > xlo)) then
n = n + 1
if n <= 60 then
print(("[seam] %s x[%6.1f..%6.1f] y[%5.1f..%5.1f] z[%6.1f..%6.1f]")
:format(tag, minX, maxX, minY, maxY, minZ, maxZ))
end
end
end
print(("[seam] %s: %d raised quads in band"):format(tag, n))
end
local function flatQuads(list)
-- Structures object/grass quad lists ({v1..v4, uv=..} entries) -> verts
local verts = {}
for _, q in ipairs(list) do
for j = 1, 4 do verts[#verts + 1] = q[j] end
end
return verts
end
local ow = game.overworld
print("[seam] current map: " .. tostring(ow.map.id))
local masks = {}
for _, nb in ipairs(ow.neighbors or {}) do
masks[#masks + 1] = { nb.ox, nb.oy, nb.ox + nb.map.def.width * 32,
nb.oy + nb.map.def.height * 32 }
print(("[seam] neighbor %-16s ox=%d oy=%d"):format(nb.map.id, nb.ox, nb.oy))
end
local seamZ = ow.map.def.height * 32
local zlo, zhi = seamZ - 24, seamZ + 24
print(("[seam] scanning band z=[%d..%d] (Route 1 south edge at %d)")
:format(zlo, zhi, seamZ))
do
local verts = ChunkMesher.geometry(ow.map, false, masks)
scanQuads("cur.full ", verts, 0, 0, zlo, zhi)
local S = Structures.forMap(ow.map)
scanQuads("cur.grass", flatQuads(S.grassQuads), 0, 0, zlo, zhi)
scanQuads("cur.objs ", flatQuads(S.objectQuads), 0, 0, zlo, zhi)
end
for _, nb in ipairs(ow.neighbors or {}) do
local verts = ChunkMesher.geometry(nb.map, true)
scanQuads("nb." .. nb.map.id, verts, nb.ox, nb.oy, zlo, zhi)
local S = Structures.forMap(nb.map)
scanQuads("nb." .. nb.map.id .. ".grass", flatQuads(S.grassQuads),
nb.ox, nb.oy, zlo, zhi)
end
-- ---- pass 3: cross the seam both ways, logging ground height ----
local function crossLog(fromMap, x, y, dir, steps, tag)
U.teleport(game, fromMap, x, y, dir)
U.wait(20)
print(("[hop] ---- %s: %s (%d,%d) heading %s ----")
:format(tag, fromMap, x, y, dir))
for i = 1, steps do
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
local f0 = clock()
coroutine.yield()
local ft = clock() - f0
local o = game.overworld
local p = o.player
local m = o.map
local shapes = TileShape.forMap(m)
local s = shapes[m:cellTile(p.cellX, p.cellY)]
local g = 0
if s and s.art ~= "stair" and s.h > 0 then g = s.h end
if not m:inBounds(p.cellX, p.cellY) or g ~= 0 or ft > 0.025 then
print(("[hop] f%02d map=%-14s cell=(%d,%d) py=%.0f inB=%s gh=%d cls=%s ft=%.0fms")
:format(i, m.id, p.cellX, p.cellY, p.py,
tostring(m:inBounds(p.cellX, p.cellY)), g,
s and s.class or "?", ft * 1000))
end
game.input.state[dir] = false
end
local o = game.overworld
print(("[hop] end map=%s cell=(%d,%d)")
:format(o.map.id, o.player.cellX, o.player.cellY))
end
crossLog("ROUTE_1", 10, 34, "down", 60, "route1 -> pallet")
U.shot(game, DIR .. "/perf_pallet_after_cross.png")
crossLog("PALLET_TOWN", 10, 1, "up", 60, "pallet -> route1")
U.shot(game, DIR .. "/perf_route1_after_cross.png")
print("[perf] done")
end
+67
View File
@@ -0,0 +1,67 @@
-- Driver: prove the render-pipeline seam end to end.
--
-- Teleports to Pallet Town, screenshots the flat world, engages the
-- DRAMATIC_SHAPE mod's pipeline exactly the way the player does (hotkey 6),
-- screenshots the diorama, then walks the T-SHIFT ladder with hotkey 9.
-- Every gate on the path is printed, so a run that comes back flat says
-- which check refused rather than just looking wrong.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "shots"
local Pipelines = require("src.render.Pipelines")
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(30)
U.shot(game, DIR .. "/pipeline_0_flat.png")
print("[probe] pipelines registered:")
for _, entry in ipairs(Pipelines.list()) do
print((" %-10s label=%-8s world=%s worldPresent=%s present=%s hotkey=%s")
:format(entry.id, tostring(entry.def.label),
entry.def.drawWorld ~= nil, entry.def.worldPresent ~= nil,
entry.def.present ~= nil, tostring(entry.def.hotkey)))
end
local defs = game.data.render_pipelines or {}
print("[probe] voxel available:",
defs.voxel and defs.voxel.available and defs.voxel.available())
-- press 6 exactly like the player
game:keypressed("6")
print("[probe] after key6 level:", Pipelines.level("voxel"),
"saved:", game.save.options.pipelines
and game.save.options.pipelines.voxel,
"tilt:", game.save.options.tilt)
U.wait(30)
print("[probe] world pipeline:",
tostring(Pipelines.worldPipeline(game.stack:top(), game.overworld)))
print("[probe] renderer override:",
tostring(game.renderer.worldOverride))
U.shot(game, DIR .. "/pipeline_1_voxel15.png")
for _, level in ipairs({ 2, 3 }) do
game:keypressed("6")
U.wait(25)
print(("[probe] voxel level %d -> override %s")
:format(Pipelines.level("voxel"), tostring(game.renderer.worldOverride)))
U.shot(game, DIR .. ("/pipeline_1_voxel%d.png"):format(level))
end
-- tilt-shift ladder over the diorama
for _, level in ipairs({ 1, 2, 3 }) do
game:keypressed("9")
U.wait(20)
print(("[probe] t-shift level %d"):format(Pipelines.level("tiltshift")))
U.shot(game, DIR .. ("/pipeline_2_tshift%d.png"):format(level))
end
-- and back off: the world must return to the flat draw, not stay stuck
game:keypressed("9")
game:keypressed("6")
U.wait(30)
print("[probe] back off -- voxel:", Pipelines.level("voxel"),
"override:", tostring(game.renderer.worldOverride))
U.shot(game, DIR .. "/pipeline_3_backflat.png")
print("[probe] done")
end
+34
View File
@@ -0,0 +1,34 @@
-- Driver: screenshot a voxel-mode connection crossing frame by frame.
--
-- Stands at the Viridian -> Route 1 seam (script-free, unlike Pallet's
-- north edge where Oak interrupts a fresh save), engages voxel 35, waits
-- for the neighbourhood to finish building, then walks south across the
-- seam capturing every other frame -- the burst shows whether the seam
-- line carries stray pixels and whether the walker's height stays glued
-- to the ground through the crossing.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "shots"
U.teleport(game, "VIRIDIAN_CITY", 20, 33, "down")
game:keypressed("6")
U.wait(5)
game:keypressed("6") -- 35 degrees
U.wait(150) -- let every neighbour mesh land
U.shot(game, DIR .. "/seam_0_viridian.png")
local shot = 0
for i = 1, 56 do
table.insert(game.input.pressQueue, "down")
game.input.state.down = true
coroutine.yield()
game.input.state.down = false
if i % 2 == 0 and i >= 24 then
shot = shot + 1
U.shot(game, DIR .. ("/seam_cross_%02d.png"):format(shot))
end
end
print("[seam-shots] end map " .. game.overworld.map.id
.. " cell " .. game.overworld.player.cellX
.. "," .. game.overworld.player.cellY)
end
+128
View File
@@ -0,0 +1,128 @@
-- Driver: prove out the voxel sun pass.
--
-- Reports whether the shadow map can run, dumps the map itself as a PNG
-- (the packed depth reads as a red/green gradient -- what matters is that
-- it is populated and the right way up), and screenshots a stand point at
-- every camera pitch so the cast shadows can be eyeballed against the flat
-- ground truth.
--
-- POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/voxel_shadow_probe.lua lovec .
--
-- knobs (env):
-- SHADOW_MAP map id (default PALLET_TOWN)
-- SHADOW_SPOT "x,y[,facing]" (default 5,6,down)
-- SHOT_DIR output directory, must exist (default "shots")
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local SPEED = math.max(1,
math.floor(tonumber(os.getenv("POKEPORT_SPEED")) or 1))
local function wait(n) U.wait(n * SPEED) end
local DIR = os.getenv("SHOT_DIR") or "shots"
local mapId = os.getenv("SHADOW_MAP") or "PALLET_TOWN"
local sx, sy, facing = (os.getenv("SHADOW_SPOT") or "5,6,down")
:match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)")
facing = (facing ~= "" and facing) or "down"
-- reach the mod's lib namespace the way a companion mod would
local handle = game.mods.exports["DRAMATIC_SHAPE"]
local V = handle and handle.lib
assert(V, "DRAMATIC_SHAPE exports not reachable")
local ShadowMap = V.require("ShadowMap")
local Voxel3D = V.require("Voxel3D")
local VoxelGrid = V.require("VoxelGrid")
-- SHADOW_GRID=1 forces the voxel wireframe on for the run, and
-- SHADOW_CURVE=n the curved-world rung -- neither touches the player's
-- persisted setting
if os.getenv("SHADOW_GRID") == "1" then
VoxelGrid.sync(true)
print("[shadow] voxel grid forced on; shader built="
.. tostring(Voxel3D.shader(true) ~= nil))
end
local WorldCurve = V.require("WorldCurve")
local curve = math.floor(tonumber(os.getenv("SHADOW_CURVE")) or 0)
if curve > 0 then
WorldCurve.sync(curve)
print(("[shadow] world curve rung %d (amount %.2f)")
:format(curve, WorldCurve.AMOUNTS[curve + 1] or 0))
end
-- SHADOW_SUN="kx,kz" retunes the bearing for one run, so a sun can be
-- compared against another without an edit-rebuild cycle
local skx, skz = (os.getenv("SHADOW_SUN") or ""):match("^(-?[%d.]+),(-?[%d.]+)$")
if skx then
ShadowMap.KX, ShadowMap.KZ = tonumber(skx), tonumber(skz)
Voxel3D.SHADOW_KX, Voxel3D.SHADOW_KZ = ShadowMap.KX, ShadowMap.KZ
end
print(("[shadow] sun shear kx=%.2f kz=%.2f alpha=%.2f res=%d")
:format(ShadowMap.KX, ShadowMap.KZ, Voxel3D.SHADOW_ALPHA, ShadowMap.res))
-- pin the zoom: the world view size drives the light frustum, and a run
-- that inherits whatever the player left in options.lua is not
-- comparable with the one before it
local Zoom = require("src.render.Zoom")
Zoom.reset()
local steps = math.floor(tonumber(os.getenv("SHADOW_ZOOM")) or 0)
for _ = 1, math.abs(steps) do
Zoom.step(steps > 0 and 1 or -1, game.renderer and game.renderer:fitScale())
end
Pipelines.setLevel("tiltshift", 0)
U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing)
wait(20)
do
local vw, vh = game.renderer:worldViewSize()
print(("[shadow] world view %dx%d px, zoom offset %d")
:format(vw, vh, Zoom.offset))
end
Pipelines.setLevel("voxel", 0)
wait(25)
game.capturePath = ("%s/shadow_%s_flat.png"):format(DIR, mapId)
wait(3)
print("[shadow] available=" .. tostring(ShadowMap.available()))
for level = 1, Pipelines.maxLevel("voxel") do
Pipelines.setLevel("voxel", level)
wait(30) -- outlast the 0.25s camera tween
local label = Pipelines.levelLabel("voxel", level) or level
game.capturePath = ("%s/shadow_%s_v%s.png"):format(DIR, mapId, label)
wait(3)
local e = ShadowMap.extent or { 0, 0, 0 }
print(("[shadow] level %s active=%s bias=%.6f frustum=%.0fx%.0f deep %.0f"
.. " (%.2f world px/texel)")
:format(label, tostring(ShadowMap.active()), ShadowMap.bias,
e[1], e[2], e[3], e[1] / ShadowMap.res))
end
-- the map itself: packed depth, so red is the high byte of "how far the
-- sun got". A blank (all-white) dump means nothing was drawn into it.
local canvas = ShadowMap.texture()
if canvas and canvas.newImageData then
local ok, err = pcall(function()
local data = canvas:newImageData()
local w, h = data:getDimensions()
local hit = 0
for y = 0, h - 1, 8 do
for x = 0, w - 1, 8 do
local r = data:getPixel(x, y)
if r < 0.999 then hit = hit + 1 end
end
end
print(("[shadow] map %dx%d, %d/%d sampled texels written")
:format(w, h, hit, (w / 8) * (h / 8)))
data:encode("png", "shadowmap.png")
end)
print("[shadow] dump " .. (ok and "ok (save dir/shadowmap.png)"
or tostring(err)))
end
Pipelines.setLevel("voxel", 0)
wait(5)
print("[shadow] done")
end
+93
View File
@@ -0,0 +1,93 @@
-- Driver: voxel-accuracy survey of one map.
--
-- Teleports to a map, walks a list of stand points, and screenshots each
-- one flat (2D ground truth) and at every voxel camera pitch, tilt-shift
-- forced off. The shots are the evidence an agent (or a human) reads to
-- find sprites whose 3D shape does not match what the object is -- a bed
-- extruded into a wall, a flat stool, stairs drawn as a box.
--
-- POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/voxel_survey.lua love .
--
-- knobs (env):
-- SURVEY_MAP map id (default REDS_HOUSE_2F)
-- SURVEY_SPOTS "x,y[,facing][@label]; ..." (default centre of map)
-- cell coordinates, facing up/down/left/right
-- SURVEY_LEVELS comma list of voxel levels 1..3 (default "1,2,3";
-- 15/35/50 degrees)
-- SHOT_DIR output directory, must exist (default "shots")
--
-- Levels are set through Pipelines.setLevel, not the player hotkey, so the
-- run never writes the player's options.lua and cannot leave tilt-shift on.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
-- Under POKEPORT_SPEED=N the driver is resumed N times per RENDERED
-- frame, so anything that must span a draw (a capture flush, a camera
-- tween) needs its yield count scaled by N or the screenshot lands on a
-- later state than the one it names.
local SPEED = math.max(1, math.floor(tonumber(os.getenv("POKEPORT_SPEED")) or 1))
local function wait(frames) U.wait(frames * SPEED) end
local DIR = os.getenv("SHOT_DIR") or "shots"
local mapId = os.getenv("SURVEY_MAP") or "REDS_HOUSE_2F"
local mapDef = game.data.maps and game.data.maps[mapId]
assert(mapDef, "unknown map: " .. tostring(mapId))
local spots = {}
local spec = os.getenv("SURVEY_SPOTS")
if spec and spec ~= "" then
for entry in spec:gmatch("[^;]+") do
local body, label = entry:match("^%s*(.-)%s*@%s*(%S+)%s*$")
body = body or entry
local x, y, facing = body:match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)")
assert(x, "bad SURVEY_SPOTS entry: " .. entry)
spots[#spots + 1] = {
x = tonumber(x), y = tonumber(y),
facing = (facing ~= "" and facing) or "up",
label = label or (x .. "x" .. y),
}
end
else
spots[1] = { x = math.floor(mapDef.width * 2 / 2),
y = math.floor(mapDef.height * 2 / 2),
facing = "up", label = "centre" }
end
local levels = {}
for n in (os.getenv("SURVEY_LEVELS") or "1,2,3"):gmatch("%d") do
levels[#levels + 1] = tonumber(n)
end
local function shot(name)
local path = ("%s/%s_%s.png"):format(DIR, mapId, name)
game.capturePath = path
wait(3) -- let the capture flush through a real draw
print("[survey] shot " .. path)
end
-- the survey's whole point is reading the voxel geometry: the miniature
-- blur would soften exactly the edges under inspection
Pipelines.setLevel("tiltshift", 0)
for i, s in ipairs(spots) do
U.teleport(game, mapId, s.x, s.y, s.facing)
wait(20)
Pipelines.setLevel("voxel", 0)
wait(25)
if i == 1 then shot(s.label .. "_flat") end
for _, level in ipairs(levels) do
Pipelines.setLevel("voxel", level)
wait(25) -- outlast the 0.25s camera tween
shot(("%s_v%s"):format(s.label,
Pipelines.levelLabel("voxel", level) or level))
end
end
Pipelines.setLevel("voxel", 0)
wait(5)
print("[survey] done: " .. #spots .. " spots")
end
+68
View File
@@ -0,0 +1,68 @@
-- Driver: does voxel mode survive every VOID FILL mode?
--
-- The ring around a map's body is baked into the terrain mesh, and which
-- block it is made of comes from the same TileRenderer.borderBlockFor the
-- 2D path uses -- including the BLACK mode, which is not a block at all.
-- This walks the three modes on an outdoor map, reports whether the mesh
-- built and whether the scene actually took the 3D path, and shoots each.
--
-- POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/voxel_void_probe.lua lovec .
--
-- knobs (env):
-- VOID_MAP map id (default PALLET_TOWN)
-- VOID_SPOT "x,y[,facing]" (default 5,6,down)
-- VOID_LEVEL voxel rung (default 3)
-- SHOT_DIR output directory, must exist (default "shots")
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local TileRenderer = require("src.render.TileRenderer")
local SPEED = math.max(1,
math.floor(tonumber(os.getenv("POKEPORT_SPEED")) or 1))
local function wait(n) U.wait(n * SPEED) end
local DIR = os.getenv("SHOT_DIR") or "shots"
local mapId = os.getenv("VOID_MAP") or "PALLET_TOWN"
local level = math.floor(tonumber(os.getenv("VOID_LEVEL")) or 3)
local sx, sy, facing = (os.getenv("VOID_SPOT") or "5,6,down")
:match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)")
facing = (facing ~= "" and facing) or "down"
local V = game.mods.exports["DRAMATIC_SHAPE"]
V = V and V.lib
assert(V, "DRAMATIC_SHAPE exports not reachable")
local Voxel = V.require("VoxelState")
local ChunkMesher = V.require("ChunkMesher")
local Zoom = require("src.render.Zoom")
Zoom.reset()
Pipelines.setLevel("tiltshift", 0)
U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing)
wait(20)
Pipelines.setLevel("voxel", level)
wait(30)
for _, mode in ipairs(TileRenderer.VOID_FILLS) do
-- Set it the way the options row does and NOTHING else: the ring is
-- baked into the mesh, so the mod has to notice the change and drop
-- the cache itself. Invalidating here would hide exactly the bug this
-- driver exists to catch.
TileRenderer.setVoidFill(mode)
wait(40)
local ow = game.overworld
local border = TileRenderer.borderBlockFor(ow.map)
local ok, mesh = pcall(ChunkMesher.peek, ow.map, false)
print(("[void] %-6s border=%-5s mesh=%s ready=%s pending=%d")
:format(mode, tostring(border),
tostring(ok and mesh ~= nil), tostring(Voxel.ready),
ChunkMesher.pending()))
game.capturePath = ("%s/void_%s_%s.png"):format(DIR, mapId, mode)
wait(4)
end
TileRenderer.setVoidFill("trees")
Pipelines.setLevel("voxel", 0)
wait(5)
print("[void] done")
end
+167
View File
@@ -0,0 +1,167 @@
# Voxel accuracy survey
A repeatable procedure -- runnable by an agent or a human -- for finding
map objects the DRAMATIC_SHAPE mod voxelizes into the wrong shape (a bed
extruded to wall height, a table merged into the wall, stairs lying flat)
and for fixing them so each object gets the 3D shape it depicts.
The loop is: **survey → diagnose → pin → re-survey → spot-check**.
## 1. Survey a location
`mods/DRAMATIC_SHAPE/tests/voxel_survey.lua` teleports to a map, walks a
list of stand points and screenshots each one flat (the 2D ground truth)
and at every voxel camera pitch, with tilt-shift forced off (it would blur
exactly the edges under inspection). Levels are set through
`Pipelines.setLevel`, so the run never writes the player's options.
```powershell
$env:POKEPORT_DRIVER = "mods/DRAMATIC_SHAPE/tests/voxel_survey.lua"
$env:SHOT_DIR = "<absolute scratch dir, must exist>"
$env:SURVEY_MAP = "REDS_HOUSE_2F"
$env:SURVEY_SPOTS = "3,4,up@centre; 2,6,left@bed; 5,2,right@stairs"
$env:POKEPORT_SPEED = "4" # optional; the driver scales its waits
& lovec.exe .
```
- `SURVEY_SPOTS` is `x,y[,facing][@label]` in cell coordinates, `;`
separated. Pick the room centre plus one spot near each object of
interest, so every object is seen close up and from more than one
parallax.
- `SURVEY_LEVELS` defaults to `1,2,3` (15/35/50 degrees). All three
matter: tall-object errors scream at 35/50, thin-object errors (a prop
with no body) only show near top-down at 15.
- The run quits by itself and names shots `<map>_<label>_<flat|v15|v35|v50>.png`.
## 2. Diagnose against the flat shot
The `_flat` shot is the authority for what each object IS. Compare every
voxel shot against it and record, per object: cells, what it looks like,
what it should look like. The recurring failure modes:
| symptom | cause | fix class |
| --- | --- | --- |
| furniture as a wall-height box | detector defaults solid tiles to `wall` | `bed` / `table` / `desk` |
| furniture towering 3-6 blocks | flood-fill merged it into the wall region, region consensus adopted the tall height | pin it; pinned tiles leave the region |
| walkable art lying flat (stairs, mats) | walkable cells resolve to `ground` | `stair_*`, or leave (mats ARE flat) |
| prop as a solid box wrapped in its art | background flood could not reach around it | `billboard` (forced per-pixel prop) |
| top-down drawing standing upright | art depicts a surface, class folds it | any `top`-art class (`bed`, `ledge`) |
| a house as a cube wearing its own elevation | one drawing packs roof, facade and slopes, and the volume path folds all three upright | a `buildings` template (see below) |
To identify tiles: the map's `blocks` list in `data/generated/maps.lua`
indexes `tileset.blocks` (0-based; each block is 4x4 tile ids over 2x2
cells), and the atlas is the tileset's `image` PNG, 16 tiles per row.
Zoom it with a grid to read ids:
```python
from PIL import Image, ImageDraw
im = Image.open("assets/generated/tilesets/<atlas>.png").convert("RGB")
z = 8; big = im.resize((im.width*z, im.height*z), Image.NEAREST)
d = ImageDraw.Draw(big)
for t in range(im.width//8 * im.height//8):
x, y = (t % 16)*8*z, (t//16)*8*z
d.rectangle([x, y, x+8*z-1, y+8*z-1], outline=(255,0,0))
d.text((x+3, y+2), str(t), fill=(255,0,255))
big.save("<scratch>/atlas_ids.png")
```
## 3. Pin shapes in the profile
`mods/DRAMATIC_SHAPE/data/voxel_heights.lua` maps tile ids to classes per
tileset id; a pinned tile bypasses detection entirely. The classes:
- `bed` (h7, art on top) -- anything drawn from above that lies low.
Also the fallback for a drawing that depicts furniture WITH someone on
it: a standee would shred it (see the shade note below) and an upright
fold duplicates them onto the box's top and front, but a slab draws the
whole thing exactly once.
- `counter` (h8) -- a service counter: half a cell, one 8px band, so the
drawn front panel stands up and the counter top stays on top. Shorter
than `table` on purpose; a 12px counter reads as a wall stub.
- `table` (h12) / `desk` (h24) -- boxes; the south face folds the drawing
upright, flanks wear the front stack darkened, and the top keeps the
drawn surface (a meal drawn on the tabletop stays on the tabletop).
- `billboard` (10px) / `prop` (5px) / `stool` (5px) / `cutout` (1px) --
standing per-pixel cutouts: TVs, plants, monitors, stools, vases.
Segmented by the art's BLACK OUTLINE: background is the shades
touching the cluster's edge, flooded in from around it; the outline
and everything it encloses survives, paint whites included -- so a
white vase cuts cleanly out of a grey tabletop. Solid pixels then
split into connected components, each standing on its own feet in the
row it is drawn in (two stacked stools stay two stools; nothing
floats), and a prop drawn directly above a pinned box stands ON that
box (the monitor on its desk, the vase on the table). Touching
drawings that must stay separate objects go in different pools.
`stool` additionally seats characters: standing on its walkable cell
lifts them to its 8px class height.
**Check the drawing has a floor margin before reaching for these.**
The background is read off the shades touching the cluster's rim, so a
drawing that runs edge to edge sees its own body colours flood: the
Pokemon Center bench loses 307 of its 420 interior pixels that way and
comes out a bare outline. Histogram the rim against the interior first
-- if all three non-black shades appear on the rim, no standee pool
will work and the object wants a box or a slab instead.
- `relief` (h3) -- a prop drawn from above (a game console on the
floor): the drawing stays flat and only the pixels inside its outline
extrude, art on the top face.
- `bookcase` (h32) -- a free-standing shelf drawn tall, not deep: each
rank collapses onto a one-cell-deep box at its full drawn height,
back rows become hidden floor, and a trim row above that cannot be
pinned (shared with other furniture) is adopted as the cap. Pin the
book rows and base; leave the shared trim unpinned.
- `stair_e` / `stair_w` -- a rising flight of four steps climbing toward
the named side, for stairs leading UP.
- `stair_down_e` / `stair_down_w` -- a sunken stairwell descending toward
the named side, for stairs leading DOWN. Read the drawn railing to pick
the side: its high end is where the player enters at floor level.
- `wall` -- pin the wall band (and its windows) when de-merging furniture
would otherwise leave the auto-detected wall patchy.
A whole building is not a tile pin. Its drawing packs several 3D facings
at once -- roof from above, facade face-on, ends as diagonal silhouettes --
and no single class covers that, so buildings go in the profile's
`buildings` list instead, as a BAND TABLE over the drawing's rows
(`mods/DRAMATIC_SHAPE/lib/Buildings.lua`; the pipeline is
`mods/DRAMATIC_SHAPE/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md`).
A template is matched by its exact tile grid, which
`mods/DRAMATIC_SHAPE/assets/docs/buildings/` catalogues per building along
with every map that places it, so one entry covers all of them. Author only what needs a human to read the drawing -- which rows are
roof, how the roof's depth maps onto them, the slab, the eave, an awning
band -- because the silhouette, the taper rate (the slope), the eave
height and every window are measured off the pixels.
Verify a new template against `mods/DRAMATIC_SHAPE/tools/building_voxels.py`,
which builds the same model offline and renders isometric previews: the
voxel and shell counts it prints must match the runtime's (the mod's
`Buildings.stats()`), and it asserts the intent -- symmetric profile,
constant taper rate, nothing poking through the roof, every wall column
covered.
Heights live in the same file; a cell is 16x16 px, a "block" of height
is 8. The voxelization must be pixel perfect: every standee and relief
voxel carries exactly its source texel, and a segmentation that eats or
keeps the wrong pixels (a cut-off monitor, a slab of tabletop in the
cutout) is a bug -- fix the pin set or the segmentation, don't accept it.
## 4. Re-survey and compare
Re-run step 1 into a fresh `SHOT_DIR` and put before/after side by side.
Check every object at every pitch, not just the one you fixed -- pins
change region shapes, so neighbours can shift.
## 5. Spot-check the blast radius
- Every map sharing the tileset id inherits the pins (grep
`tileset = "<id>"` in `data/generated/maps.lua`) -- survey at least one.
- A change to the shared analysis (anything in `lib/Structures.lua`
rather than the data file) affects every map of that kind; survey one
unrelated busy interior (e.g. `OAKS_LAB`) to prove nothing regressed.
- `luajit mods/DRAMATIC_SHAPE/tests/dramatic_shape_test.lua` for the headless
invariants.
## Gameplay is out of bounds
Shape pins are purely presentational. Collision, warps and triggers read
the same data they always did -- a stairwell cell is still the walkable
warp cell it was when it was flat. If a fix seems to need a collision
change, it is the wrong fix.