From aa49898fd690b8ce7a9f8b0b40700e9793632760 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sun, 26 Jul 2026 16:53:00 -0400 Subject: [PATCH] updated tests --- CHANGELOG.md | 30 ++--- tests/voxel_anim_probe.lua | 133 +++++++++++++++++++++ tests/voxel_door_probe.lua | 43 +++++++ tests/voxel_mem_probe.lua | 67 +++++++++++ tests/voxel_perf_probe.lua | 204 +++++++++++++++++++++++++++++++++ tests/voxel_pipeline_probe.lua | 67 +++++++++++ tests/voxel_seam_shots.lua | 34 ++++++ tests/voxel_shadow_probe.lua | 128 +++++++++++++++++++++ tests/voxel_survey.lua | 93 +++++++++++++++ tests/voxel_void_probe.lua | 68 +++++++++++ tools/voxel-survey.md | 167 +++++++++++++++++++++++++++ 11 files changed, 1020 insertions(+), 14 deletions(-) create mode 100644 tests/voxel_anim_probe.lua create mode 100644 tests/voxel_door_probe.lua create mode 100644 tests/voxel_mem_probe.lua create mode 100644 tests/voxel_perf_probe.lua create mode 100644 tests/voxel_pipeline_probe.lua create mode 100644 tests/voxel_seam_shots.lua create mode 100644 tests/voxel_shadow_probe.lua create mode 100644 tests/voxel_survey.lua create mode 100644 tests/voxel_void_probe.lua create mode 100644 tools/voxel-survey.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f91f08d..afc8558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/tests/voxel_anim_probe.lua b/tests/voxel_anim_probe.lua new file mode 100644 index 0000000..5b6c5ff --- /dev/null +++ b/tests/voxel_anim_probe.lua @@ -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 diff --git a/tests/voxel_door_probe.lua b/tests/voxel_door_probe.lua new file mode 100644 index 0000000..c59f37e --- /dev/null +++ b/tests/voxel_door_probe.lua @@ -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 diff --git a/tests/voxel_mem_probe.lua b/tests/voxel_mem_probe.lua new file mode 100644 index 0000000..a6d8a21 --- /dev/null +++ b/tests/voxel_mem_probe.lua @@ -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 diff --git a/tests/voxel_perf_probe.lua b/tests/voxel_perf_probe.lua new file mode 100644 index 0000000..49e2677 --- /dev/null +++ b/tests/voxel_perf_probe.lua @@ -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 diff --git a/tests/voxel_pipeline_probe.lua b/tests/voxel_pipeline_probe.lua new file mode 100644 index 0000000..eb0c442 --- /dev/null +++ b/tests/voxel_pipeline_probe.lua @@ -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 diff --git a/tests/voxel_seam_shots.lua b/tests/voxel_seam_shots.lua new file mode 100644 index 0000000..36bf12b --- /dev/null +++ b/tests/voxel_seam_shots.lua @@ -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 diff --git a/tests/voxel_shadow_probe.lua b/tests/voxel_shadow_probe.lua new file mode 100644 index 0000000..5cf9178 --- /dev/null +++ b/tests/voxel_shadow_probe.lua @@ -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 diff --git a/tests/voxel_survey.lua b/tests/voxel_survey.lua new file mode 100644 index 0000000..9f9f405 --- /dev/null +++ b/tests/voxel_survey.lua @@ -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 diff --git a/tests/voxel_void_probe.lua b/tests/voxel_void_probe.lua new file mode 100644 index 0000000..554a69c --- /dev/null +++ b/tests/voxel_void_probe.lua @@ -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 diff --git a/tools/voxel-survey.md b/tools/voxel-survey.md new file mode 100644 index 0000000..9709873 --- /dev/null +++ b/tools/voxel-survey.md @@ -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 = "" +$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 `_