mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 07:10:51 +02:00
updated tests
This commit is contained in:
+16
-14
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user