bump version
DramaticShape · 2026-08-02 ·
parent a7c9541ac4
bump version
Changed files
CHANGELOG.md | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
data/voxel_heights.lua | 70 +++++++++++++++++++++
main.lua | 2 +-
manifest.json | 2 +-
tests/bike_shop_shots.lua | 79 ++++++++++++++++++++++++
tests/bills_desk_shots.lua | 77 +++++++++++++++++++++++
tests/chief_house_shots.lua | 81 ++++++++++++++++++++++++
tests/heal_machine_shots.lua | 78 +++++++++++++++++++++++
tests/house_furniture_shots.lua | 98 +++++++++++++++++++++++++++++
tests/mart_shots.lua | 76 +++++++++++++++++++++++
tests/potted_plant_shots.lua | 91 +++++++++++++++++++++++++++
tests/roof_curve_shots.lua | 80 ++++++++++++++++++++++++
tests/roof_span_probe.lua | 47 ++++++++++++++
tests/round_regress_shots.lua | 75 ++++++++++++++++++++++
tests/shelf_relief_shots.lua | 92 +++++++++++++++++++++++++++
tests/ship_can_shots.lua | 78 +++++++++++++++++++++++
tests/trash_can_shots.lua | 80 ++++++++++++++++++++++++
tests/water_curve_shots.lua | 121 ++++++++++++++++++++++++++++++++++++
tools/building_voxels.py | 59 ++++++++++++++++++
19 files changed, 1486 insertions(+), 2 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5bb313c..e6f0ad3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,207 @@
# Changelog
+## 1.4.3
+
+### Added
+
+- **The furniture of the whole game goes through the building
+ pipeline.** 1.4.1 put four drawings through it; this is the rest of
+ the rooms. Every one of them is the same read -- the drawing's own
+ bands say what is a top seen from above, what is a face seen head-on,
+ and where the thing ends on the floor -- and every one of them
+ replaces a pinned box that wore its drawing as a decal. The pins all
+ stay as the degradation path, neutralized wherever a template stamps.
+
+ - **The bookcase, the commonest piece of furniture in the game** --
+ 58 placements across two drawings on the town-house atlas (books
+ and a bowl on each shelf at the west end of eighteen homes, books
+ on both at the east), plus Red's and the Copycat's pair. Pinned
+ `desk` it was a 24px box with the books painted on its flat front.
+ Modelled it is 23 voxels of cabinet with its top seen from above,
+ and every book, bowl and door panel sunk a voxel behind the frame
+ the drawing seals it in.
+ - **Celadon's display cabinets** -- the tall one with the trophy
+ behind its glass and the short one beside it, band for band the
+ same object as the town house's on another atlas, which is what
+ makes the pair read as one line of furniture: 23 voxels and 15,
+ exactly the 8 rows of drawing between them.
+ - **The dining table, everywhere it is drawn** -- the generic town
+ house's at 18 placements, Red's and the Copycat's, and the chief's
+ long table at four cells wide. All of them the lab table's read at
+ a different width, all of them 6 voxels, all of them standing on
+ the ground line their legs are drawn stopping at rather than on
+ the grid's floor.
+ - **The stool at every one of those tables** -- 94 placements on the
+ house atlas alone, ten more in Red's and the Copycat's, and the Fan
+ Club's four members' chairs, a different drawing that is
+ pixel-identical from the seat down. The first template with no base
+ piece at all: a stool is drawn mid-cell over its own floor, so it
+ is a desk-set of exactly one part, seat lid over legs with the
+ floor showing between them.
+ - **The Pokemon Center's healing machine** -- two variants, 24
+ placements, plus the Indigo Plateau lobby's pair. A wall-height
+ cabinet with its monitor perched on the front of its top face,
+ drawn across two map rows because it towers over the 16px band
+ behind it, which the volume path could only read as more wall. The
+ hoses leaving its side are modelled as hoses, at the elevation and
+ the depth the two stacked motifs put them; the west machine's
+ keyboard is a shelf at counter height wearing its own top-view art.
+ - **Bill's desk, and the Silph president's** -- the same drawing in
+ both rooms. Its terminal is drawn in 2:1 isometric, turned 45
+ degrees to the map, and builds as a cube rather than the slab a 2:1
+ reading gives; the kinked dark run between keyboard and computer is
+ raised to the keyboard's height and reads as the cable it is. The
+ desk stops at its own two cells because the artist drew its apron
+ into the walkable cell in front, sharing tiles with the chair
+ pushed up to it -- so the chair is modelled as a part of the desk.
+ - **The Bike Shop's open toolbox.** The drawing looks down INTO the
+ tray, which is why every solid treatment failed it -- as a
+ `billboard` the whole cell went up as one 10-voxel slab wearing the
+ drawing as a decal. `tray` builds four walls, a floor and air
+ between them, with the lid standing open on its hinge.
+
+ What the template language grew to carry them: `tray`; a `desk` band
+ that lays its top face flat as a lid; the `box`, `flat` and `iso`
+ part kinds; `stretch` for a band mapped over a deeper plot than it
+ was drawn on; `inset` for a pane sunk by hand; `panes = false` where
+ the global recess pass has the polarity backwards; a `wall` element
+ so a template can keep the band behind it solid; `plane` for a height
+ the drawing states elsewhere; and `scrub`/`keep`/`support`, which let
+ a template model a surface while leaving an object standing on it to
+ its own standee -- Red's potted plant on the dining table.
+
+- **Round bins: the `can` class.** Vermilion Gym's switch puzzle stands
+ fifteen galvanised trash cans in a row, and the S.S. Anne redraws the
+ same object pixel for pixel as its galley barrels. Left to the thin
+ standee pool they were flat discs on edge -- fifteen coins standing
+ in a row; pinned a plain `cylinder` the drawing's base arc revolves
+ too and they came out as barrels balanced on a three-voxel stem.
+ `can` is the round hull cut at both ends, hollowed and tapered: the
+ drawn mouth ellipse projects across the top and down the well so you
+ look into the bin, the drawn base ellipse is ground contact rather
+ than body, and the plan narrows toward the floor. The two ellipses
+ are measured off the pixels; the height, the well and the taper are
+ authored, and the entry says why.
+
+- **The rock gyms' boulders are round.** 87 placements over Pewter's
+ walls and maze and Bruno's clusters, and every one of them was a
+ square bar wearing a boulder texture in relief -- the repeat-aware
+ scenery path extruding the whole drawing as one course. Each cell is
+ now a hull whose plan is its own drawn width profile turned in depth:
+ a dome full-width from the drawn shoulder down, tapering over the top
+ five rows exactly where the art tapers, with the floor's corner
+ diamonds opening between them the way the drawing has them. Still
+ 16px, so nothing standing on or beside a rock moves.
+
+- **The potted plant stands as a plant.** The most repeated interior
+ prop in the game -- 78 placements over 13 maps, six per Pokemon
+ Center -- and its urn was rendering as a hollow black frame, because
+ the drawing's foot lies flush on the block's bottom edge and the
+ background vote took the plant's own darks away with the floor. Named
+ outright as light and white instead, it stands as one organic
+ silhouette 32px tall over its two stacked cells, crown overhanging
+ the stem. `planter` carries the same reading for a round drawing
+ stacked two cells high on one cell of plot.
+
+- **Bicycles, in both places the Bike Shop draws them.** The six on the
+ showroom floor get their own pool at two voxels rather than the thin
+ pool's five: a bike is a line drawing, and at five voxels every
+ stroke closes the gap to its neighbour with its own side faces, so
+ from any angle but dead-on the air inside the frames filled in and
+ the six came out as one dark lump. And the two against the north wall
+ get `mounted`, a new authored-mask escape for a thing drawn INTO a
+ wall band: it holds the wall's plane as a thin per-pixel slab instead
+ of standing up as a sprite card, and it keeps its drawn elevation, so
+ a bicycle hung clear of the floor stays hung. Its mask is measured
+ rather than hand-drawn -- the plain panel tile composited across the
+ same grid and the background flooded in through the pixels that still
+ match it, which separates bicycle from stripe exactly.
+
+- **The Marts' cash register is a machine, not a decal.** An authored
+ figure may now state a `depth`, which makes it an object rather than
+ a person: a per-pixel solid standing on the counter instead of the
+ flat card that turned edge-on with the camera. And the drawing is not
+ a box -- its black linework packs two facings, an L of base and arm
+ around a keypad that is the machine's deck seen from above. `flat`
+ lays that rect horizontal in the notch of the L, and `thin` gives the
+ receipt curl a paper's thickness where the body's would have made it
+ a wedge.
+
+- **Shelf fronts have relief.** Everything the `bookcase` collapse is
+ used for is a shelf, a rack or a display case, and all of them seal
+ their contents behind the drawing's own black frame -- so those
+ regions now sink a voxel, the same rule a facade's window panes are
+ recessed by, and the books stand in the shelf instead of being
+ painted on it. A tileset that borrows the collapse for something that
+ is not a shelf says `bookcase_relief = false`: the League's masonry
+ and pilasters, whose courses are the wall itself, and Bill's
+ transporter drums, whose light regions are a lit barrel.
+
+### Changed
+
+- **Class heights now follow the models under them.** A tileset's
+ `heights` gets stools at 5 and tables at 6 in the houses, Bill's desk
+ at 8, and cans at 9 -- each of them the drawn elevation the new
+ template or hull stands at, so whoever sits on a stool sits on the
+ seat, and whatever object sprite stands on a table lands on the
+ modelled top rather than three voxels over it or under it.
+- The healing machines' two flanks leave the `wall` pin for the thin
+ standee pool. They are equipment standing beside the console -- a
+ pair of pipes and a keyboard -- and as wall each was boxed into a
+ solid 16px half-cell wearing its drawing in relief.
+
+### Fixed
+
+- **Water no longer hides behind water.** The reflective pass writes no
+ depth -- the depth canvas is detached for the length of it so the
+ shader can read it -- so nothing put a lake in the buffer and no lake
+ could occlude another; the sheets were simply painted in mesh order.
+ Flat water never showed it, one plane, a farther sheet always landing
+ farther down the screen. The world curve ends that: it drops the far
+ side of the map into the near field of view, and a sea a hundred and
+ fifty tiles away came out rasterised on top of the pond at the
+ player's feet, tall grass and all -- water and terrain "from the
+ other side of the map", not reflected but there. The water meshes now
+ go down flat first, through the ordinary scene shader with depth
+ writes on, and the reflective pass draws over what survived. The
+ buffer holds the surface, so the pass's own test throws the far sheet
+ away; the reflection copy holds it too, so a ray grazing another part
+ of the lake reads water rather than the void behind it; and a frame
+ that cannot run the pass at all is unchanged, because the flat draw
+ is the fallback that was already there.
+- **Reflections under the world curve.** The bend tips the world away
+ and the things standing on it do not lean with it -- and a lake is
+ one of those things. Reflected off the bowl the bend makes, the far
+ half of a pond was a mirror tilted twenty degrees: it threw the ray
+ past the vertical, where the sky ramp's own measure swings from one
+ end to the other across a single column, and hard-edged patches of
+ the wrong sky stamped into the water; the same tilt sent the
+ screen-space march grazing along the bank rather than over it, which
+ is what smeared the dock and the roofs across the harbour. What the
+ water reflects is now worked out in the flat world, exactly as it
+ would be with the curve off, and every marched sample is bent on its
+ way to the screen by the vertex stage's own displacement -- so the
+ ray is straight where it should be and lands where the geometry did.
+ The wave columns are read on the flat sheet too: the relief walk is
+ built on an even slab over a level plane, and in the curved world
+ that slab is a bowl, which handed back a column a pixel or three off
+ per fragment -- a patch of noise in the middle of a pond.
+- **Merged runs tore open under the curve.** A quad's interior is the
+ chord of a parabola its neighbours draw the arc of, so a long run
+ hangs below the short quads butted against it. Nothing bounded a
+ run's length, and the ones that ran away were those wearing a
+ constant texel -- a roof's black eave outline, its fascia, its shaded
+ underside -- because a flat run has no art to break it. At 102px
+ across a gym the eave tore off the roof and the slot showed the
+ building's dark interior through it. Runs now stop at the next 8px
+ lattice line, which is the lattice buildings are stamped on and the
+ one every other quad in the scene already ends on, so every join is
+ vertex-for-vertex and the bend carries them together. It costs quads
+ whether the curve is on or not -- Cerulean's object stream goes from
+ 35.7k to 41.6k -- and that is deliberate: the mesh is cached per map
+ and built over seconds, so meshing for the curve's sake only when the
+ curve is on would mean rebuilding every live map on a keypress.
+
## 1.4.1
### Added
diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua
index a98c059..55c075c 100644
--- a/data/voxel_heights.lua
+++ b/data/voxel_heights.lua
@@ -4198,6 +4198,60 @@ return {
roofRows = 28, roofBack = 24, roofFront = 0, roofCycle = { 2, 23 },
slab = 3, frontEave = 0, ledge = nil,
},
+ -- F10: the town house's BOOKCASE -- the commonest piece of
+ -- furniture in the game at 58 placements across three drawings,
+ -- and the piece this family was really for: pinned `desk` it was a
+ -- 24px box with a flat front and the books PAINTED on it. Band for
+ -- band it is the Celadon display cabinet (F05/F06 under MANSION)
+ -- on a different atlas, and it takes those numbers unchanged: rows
+ -- 0-8 the top seen from above (black rim, white highlight courses
+ -- along the north and west, grey field, the front corner shaded at
+ -- row 8); row 9 the top's own black front edge -- which is what the
+ -- rim treatment paints, so slab = 1 and that row folds into the
+ -- roof band instead of extruding under it; 10-31 the front: two
+ -- shelves in their dark openings over the cupboard's two panelled
+ -- doors. Every book, bowl and door panel is a non-black region the
+ -- drawing seals behind its own black frame, so the measured pane
+ -- pass sinks each one a voxel and the frames stand proud of them --
+ -- the relief the `bookcase` class now carries too, arrived at the
+ -- same way.
+ --
+ -- Nine drawn top rows over a 32px plot: the rims map 1:1 (the drawn
+ -- front-corner shading lands one voxel behind the front edge) and
+ -- the uniform field cycles between. BOTH cell rows of the plot are
+ -- blocked and both are cabinet drawing, so D is the whole grid --
+ -- the case is built into the room's north wall. 23 voxels tall,
+ -- one under the `desk` pin it replaces and exactly the Celadon
+ -- trophy case's stand; the `desk` pins stay as the degradation
+ -- path, neutralized wherever this stamps.
+ --
+ -- This drawing carries books and a bowl on each shelf: 36
+ -- placements, the west end of eighteen homes.
+ {
+ id = "house_bookcase_bowls",
+ tiles = {
+ { 38, 41 },
+ { 14, 15 },
+ { 14, 15 },
+ { 30, 31 },
+ },
+ roofRows = 10, roofBack = 8, roofFront = 2, roofCycle = { 2, 7 },
+ slab = 1, frontEave = 0, ledge = nil,
+ },
+ -- F10 again: its twin at the east end, books on both shelves -- 22
+ -- placements, and the only one of the two that Cerulean's trashed
+ -- house also puts at the west end.
+ {
+ id = "house_bookcase_books",
+ tiles = {
+ { 38, 41 },
+ { 48, 49 },
+ { 48, 49 },
+ { 30, 31 },
+ },
+ roofRows = 10, roofBack = 8, roofFront = 2, roofCycle = { 2, 7 },
+ slab = 1, frontEave = 0, ledge = nil,
+ },
-- F09: the stool at every one of those tables (94 placements on
-- this tileset) -- the first template with NO base piece. The
-- drawing is one object, a round seat on legs, drawn MID-CELL over
@@ -4268,6 +4322,22 @@ return {
keep = { 40, 54, 55, 56, 57 },
support = 6,
},
+ -- F10 again: Red's and the Copycat's bookcase pair, cells (0,0)
+ -- and (1,0) of both maps (4 placements). The same object as the
+ -- town house's, one shelf of each of its two drawings -- 48/49
+ -- books above, 34/35 below -- and the same band numbers; see HOUSE
+ -- above for the read.
+ {
+ id = "reds_bookcase",
+ tiles = {
+ { 38, 41 },
+ { 48, 49 },
+ { 34, 35 },
+ { 50, 51 },
+ },
+ roofRows = 10, roofBack = 8, roofFront = 2, roofCycle = { 2, 7 },
+ slab = 1, frontEave = 0, ledge = nil,
+ },
-- F09 again: the stools around it (10 placements across Red's and
-- the Copycat's ground floors), pixel for pixel the house
-- tileset's drawing. Same part table; see HOUSE above.
diff --git a/main.lua b/main.lua
index 01aaf0f..f0b4883 100644
--- a/main.lua
+++ b/main.lua
@@ -890,7 +890,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx)
return DayNight.tod()
end)
-mod.exports.version = "1.4.1"
+mod.exports.version = "1.4.3"
-- exposed so a companion mod can pin its own tiles' shapes or read the
-- camera without reaching into this mod's file layout
mod.exports.lib = V
diff --git a/manifest.json b/manifest.json
index b2382d4..1f6184c 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
- "version": "1.4.1",
+ "version": "1.4.3",
"api": 2,
"entry": "main.lua",
"profile": "content",
diff --git a/tests/bike_shop_shots.lua b/tests/bike_shop_shots.lua
new file mode 100644
index 0000000..7a896ed
--- /dev/null
+++ b/tests/bike_shop_shots.lua
@@ -0,0 +1,79 @@
+-- Scratch driver: shots of the Bike Shop showroom, for the bicycle
+-- voxelization. Two viewpoints -- the north wall (the two bikes drawn
+-- INTO the wall band) and the showroom floor (the six standing bikes).
+--
+-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/bike_shop_shots.lua \
+-- SHOT_DIR=.scratchpad/bikes AB_TAG=before lovec.exe .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Pipelines = require("src.render.Pipelines")
+
+ local ROOT = (os.getenv("SHOT_DIR") or "shots/bikes")
+ .. "/" .. (os.getenv("AB_TAG") or "before")
+
+ local handle = game.mods.exports["DRAMATIC_SHAPE"]
+ if not (handle and handle.lib) then
+ print("[bike] DRAMATIC_SHAPE mod not loaded")
+ return
+ end
+ local V = handle.lib
+ local DayNight = V.require("DayNight")
+ local ChunkMesher = V.require("ChunkMesher")
+ local Voxel = V.require("VoxelState")
+
+ require("src.world.OverworldController").rollEncounter = function() return nil end
+ local TileRenderer = require("src.render.TileRenderer")
+ TileRenderer.tick = function() end
+ TileRenderer.animFrame = function() return 0 end
+ DayNight.setting:sync("day")
+
+ pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+ pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+ local Zoom = require("src.render.Zoom")
+ pcall(function()
+ game.save.options.zoom = 1
+ Zoom.applyOptions(game.save.options)
+ end)
+
+ local function settle()
+ for _ = 1, 900 do
+ if ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ for _ = 1, 300 do
+ if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ U.wait(40)
+ end
+
+ -- cells: wall bikes ride cell row 0 (tile cols 1-3 and 6-8); the six
+ -- floor bikes stand in cell columns 0 and 2, rows 1-2 and 4-5
+ local SCENES = {
+ { x = 2, y = 2, face = "up", label = "wall" },
+ { x = 3, y = 3, face = "up", label = "room" },
+ { x = 2, y = 4, face = "left", label = "floor" },
+ { x = 3, y = 6, face = "up", label = "wide" },
+ -- the two toolboxes, cells (6,6) and (7,7)
+ { x = 5, y = 6, face = "right", label = "tools" },
+ { x = 6, y = 4, face = "down", label = "tools2" },
+ }
+
+ local shots = 0
+ for _, s in ipairs(SCENES) do
+ for _, rung in ipairs({ 3, 5 }) do
+ U.teleport(game, "BIKE_SHOP", s.x, s.y, s.face)
+ Pipelines.setLevel("voxel", rung)
+ Pipelines.setLevel("tiltshift", 0)
+ settle()
+ local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
+ game.capturePath = path
+ U.wait(6)
+ local f = io.open(path, "rb")
+ if f then f:close() shots = shots + 1
+ else print("[bike] capture missed: " .. path) end
+ end
+ end
+ print(("[bike] %d shots into %s"):format(shots, ROOT))
+end
diff --git a/tests/bills_desk_shots.lua b/tests/bills_desk_shots.lua
new file mode 100644
index 0000000..f6c7898
--- /dev/null
+++ b/tests/bills_desk_shots.lua
@@ -0,0 +1,77 @@
+-- Scratch driver: shots of Bill's desk, for the `bills_desk`
+-- voxelization. The desk fills cells (1,4) and (2,4) of Bill's house
+-- with its chair in the walkable cell (1,5) below it, so these are the
+-- angles you can actually stand at: head-on from the floor two cells
+-- south, and from either flank.
+--
+-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/bills_desk_shots.lua \
+-- SHOT_DIR=.scratchpad/billsdesk AB_TAG=after lovec.exe .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Pipelines = require("src.render.Pipelines")
+
+ local ROOT = (os.getenv("SHOT_DIR") or "shots/billsdesk")
+ .. "/" .. (os.getenv("AB_TAG") or "after")
+
+ local handle = game.mods.exports["DRAMATIC_SHAPE"]
+ if not (handle and handle.lib) then
+ print("[bills] DRAMATIC_SHAPE mod not loaded")
+ return
+ end
+ local V = handle.lib
+ local DayNight = V.require("DayNight")
+ local ChunkMesher = V.require("ChunkMesher")
+ local Voxel = V.require("VoxelState")
+
+ require("src.world.OverworldController").rollEncounter = function() return nil end
+ local TileRenderer = require("src.render.TileRenderer")
+ TileRenderer.tick = function() end
+ TileRenderer.animFrame = function() return 0 end
+ DayNight.setting:sync("day")
+
+ pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+ pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+ local Zoom = require("src.render.Zoom")
+ pcall(function()
+ game.save.options.zoom = 1
+ Zoom.applyOptions(game.save.options)
+ end)
+
+ local function settle()
+ for _ = 1, 900 do
+ if ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ for _ = 1, 300 do
+ if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ U.wait(40)
+ end
+
+ local SCENES = {
+ { x = 1, y = 6, face = "up", label = "headon" },
+ { x = 2, y = 6, face = "up", label = "headon_e" },
+ { x = 4, y = 5, face = "left", label = "east" },
+ { x = 0, y = 5, face = "right", label = "west" },
+ }
+
+ local shots = 0
+ for _, s in ipairs(SCENES) do
+ for _, rung in ipairs({ 3, 5 }) do
+ U.teleport(game, "BILLS_HOUSE", s.x, s.y, s.face)
+ Pipelines.setLevel("voxel", rung)
+ Pipelines.setLevel("tiltshift", 0)
+ settle()
+ local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
+ game.capturePath = path
+ U.wait(6)
+ local f = io.open(path, "rb")
+ if f then f:close() shots = shots + 1
+ else print("[bills] capture missed: " .. path) end
+ end
+ end
+ print(("[bills] %d shots into %s"):format(shots, ROOT))
+ love.event.quit()
+end
diff --git a/tests/chief_house_shots.lua b/tests/chief_house_shots.lua
new file mode 100644
index 0000000..2fec3a8
--- /dev/null
+++ b/tests/chief_house_shots.lua
@@ -0,0 +1,81 @@
+-- Scratch driver: shots of the Celadon chief's house, for the display
+-- cabinet and long table voxelizations. Three viewpoints -- the
+-- cabinet rank along the north wall, the long table in the middle of
+-- the room, and a wide shot with both in frame.
+--
+-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/chief_house_shots.lua \
+-- SHOT_DIR=.scratchpad/chief AB_TAG=after lovec.exe .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Pipelines = require("src.render.Pipelines")
+
+ local ROOT = (os.getenv("SHOT_DIR") or "shots/chief")
+ .. "/" .. (os.getenv("AB_TAG") or "after")
+
+ local handle = game.mods.exports["DRAMATIC_SHAPE"]
+ if not (handle and handle.lib) then
+ print("[chief] DRAMATIC_SHAPE mod not loaded")
+ return
+ end
+ local V = handle.lib
+ local DayNight = V.require("DayNight")
+ local ChunkMesher = V.require("ChunkMesher")
+ local Voxel = V.require("VoxelState")
+
+ require("src.world.OverworldController").rollEncounter = function() return nil end
+ local TileRenderer = require("src.render.TileRenderer")
+ TileRenderer.tick = function() end
+ TileRenderer.animFrame = function() return 0 end
+ DayNight.setting:sync("day")
+
+ pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+ pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+ local Zoom = require("src.render.Zoom")
+ pcall(function()
+ game.save.options.zoom = 1
+ Zoom.applyOptions(game.save.options)
+ end)
+
+ local function settle()
+ for _ = 1, 900 do
+ if ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ for _ = 1, 300 do
+ if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ U.wait(40)
+ end
+
+ -- the cabinets occupy cells 2..5 of rows 0-1; the long table cells
+ -- 2..5 of rows 3-4; the player walks rows 2 and 5
+ local SCENES = {
+ { x = 3, y = 2, face = "up", label = "cabinets" },
+ { x = 5, y = 2, face = "up", label = "bookcase" },
+ { x = 3, y = 5, face = "up", label = "table" },
+ { x = 1, y = 5, face = "right", label = "wide" },
+ -- the same rank on CELADON_MANSION_1F, where it stands against the
+ -- interior partition and the grids start on an ODD tile row
+ { map = "CELADON_MANSION_1F", x = 2, y = 4, face = "up",
+ label = "mansion1f" },
+ }
+
+ local shots = 0
+ for _, s in ipairs(SCENES) do
+ for _, rung in ipairs({ 3, 5 }) do
+ U.teleport(game, s.map or "CELADON_CHIEF_HOUSE", s.x, s.y, s.face)
+ Pipelines.setLevel("voxel", rung)
+ Pipelines.setLevel("tiltshift", 0)
+ settle()
+ local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
+ game.capturePath = path
+ U.wait(6)
+ local f = io.open(path, "rb")
+ if f then f:close() shots = shots + 1
+ else print("[chief] capture missed: " .. path) end
+ end
+ end
+ print(("[chief] %d shots into %s"):format(shots, ROOT))
+end
diff --git a/tests/heal_machine_shots.lua b/tests/heal_machine_shots.lua
new file mode 100644
index 0000000..7f148cd
--- /dev/null
+++ b/tests/heal_machine_shots.lua
@@ -0,0 +1,78 @@
+-- Scratch driver: shots of the Pokemon Center healing machines behind
+-- the counter, for the center_heal_machine voxelization. The pair
+-- stands at cells (1,0):(2,1) and (6,0):(7,1) of every Center; the
+-- nurse aisle (row 2) is the row you can actually face them from, and
+-- the public floor south of the counter gives the wide view.
+--
+-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/heal_machine_shots.lua \
+-- SHOT_DIR=.scratchpad/healshots AB_TAG=after lovec.exe .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Pipelines = require("src.render.Pipelines")
+
+ local ROOT = (os.getenv("SHOT_DIR") or "shots/healmachine")
+ .. "/" .. (os.getenv("AB_TAG") or "after")
+
+ local handle = game.mods.exports["DRAMATIC_SHAPE"]
+ if not (handle and handle.lib) then
+ print("[heal] DRAMATIC_SHAPE mod not loaded")
+ return
+ end
+ local V = handle.lib
+ local DayNight = V.require("DayNight")
+ local ChunkMesher = V.require("ChunkMesher")
+ local Voxel = V.require("VoxelState")
+
+ require("src.world.OverworldController").rollEncounter = function() return nil end
+ local TileRenderer = require("src.render.TileRenderer")
+ TileRenderer.tick = function() end
+ TileRenderer.animFrame = function() return 0 end
+ DayNight.setting:sync("day")
+
+ pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+ pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+ local Zoom = require("src.render.Zoom")
+ pcall(function()
+ game.save.options.zoom = 1
+ Zoom.applyOptions(game.save.options)
+ end)
+
+ local function settle()
+ for _ = 1, 900 do
+ if ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ for _ = 1, 300 do
+ if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ U.wait(40)
+ end
+
+ local SCENES = {
+ { x = 1, y = 2, face = "up", label = "west_head" },
+ { x = 2, y = 2, face = "up", label = "west_keyboard" },
+ { x = 3, y = 2, face = "left", label = "west_side" },
+ { x = 6, y = 2, face = "up", label = "east_head" },
+ { x = 3, y = 4, face = "up", label = "wide" },
+ }
+
+ local shots = 0
+ for _, s in ipairs(SCENES) do
+ for _, rung in ipairs({ 3, 5 }) do
+ U.teleport(game, "VIRIDIAN_POKECENTER", s.x, s.y, s.face)
+ Pipelines.setLevel("voxel", rung)
+ Pipelines.setLevel("tiltshift", 0)
+ settle()
+ local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
+ game.capturePath = path
+ U.wait(6)
+ local f = io.open(path, "rb")
+ if f then f:close() shots = shots + 1
+ else print("[heal] capture missed: " .. path) end
+ end
+ end
+ print(("[heal] %d shots into %s"):format(shots, ROOT))
+ love.event.quit()
+end
diff --git a/tests/house_furniture_shots.lua b/tests/house_furniture_shots.lua
new file mode 100644
index 0000000..4a3a136
--- /dev/null
+++ b/tests/house_furniture_shots.lua
@@ -0,0 +1,98 @@
+-- Scratch driver: shots of the house dining tables and stools, for the
+-- band-table + no-desk-part voxelization. Every generic home places the
+-- table at cells (3,3):(4,4) with four stools around it (Blue's house has
+-- Daisy seated at hers); Red's and the Copycat's ground floors place the
+-- same furniture one cell lower, with the potted plant CUTOUT standing on
+-- the tabletop -- the standee the table template must support, not
+-- swallow.
+--
+-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/house_furniture_shots.lua \
+-- SHOT_DIR=.scratchpad/housefurn AB_TAG=after lovec.exe .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Pipelines = require("src.render.Pipelines")
+
+ local ROOT = (os.getenv("SHOT_DIR") or "shots/housefurn")
+ .. "/" .. (os.getenv("AB_TAG") or "after")
+
+ local handle = game.mods.exports["DRAMATIC_SHAPE"]
+ if not (handle and handle.lib) then
+ print("[housefurn] DRAMATIC_SHAPE mod not loaded")
+ return
+ end
+ local V = handle.lib
+ local DayNight = V.require("DayNight")
+ local ChunkMesher = V.require("ChunkMesher")
+ local Voxel = V.require("VoxelState")
+
+ require("src.world.OverworldController").rollEncounter = function() return nil end
+ local TileRenderer = require("src.render.TileRenderer")
+ TileRenderer.tick = function() end
+ TileRenderer.animFrame = function() return 0 end
+ DayNight.setting:sync("day")
+
+ pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+ pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+ local Zoom = require("src.render.Zoom")
+ pcall(function()
+ game.save.options.zoom = 1
+ Zoom.applyOptions(game.save.options)
+ end)
+
+ local function settle()
+ for _ = 1, 900 do
+ if ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ for _ = 1, 300 do
+ if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ U.wait(40)
+ end
+
+ local SCENES = {
+ -- head on: below Blue's table looking north over a stool at it,
+ -- Daisy seated at the left one
+ { map = "BLUES_HOUSE", x = 3, y = 5, face = "up", label = "blues_front" },
+ -- from the east, table and both east stools in profile
+ { map = "BLUES_HOUSE", x = 6, y = 3, face = "left", label = "blues_side" },
+ -- from the north wall looking south down over the tabletop
+ { map = "BLUES_HOUSE", x = 4, y = 2, face = "down", label = "blues_over" },
+ -- close beside a stool: seat top, legs and the gap between them
+ { map = "BLUES_HOUSE", x = 2, y = 5, face = "up", label = "stool_close" },
+ -- Red's table head on from the south: the plant cutout standing on
+ -- the modelled tabletop
+ { map = "REDS_HOUSE_1F", x = 4, y = 6, face = "up", label = "reds_front" },
+ -- and from the east along the stool row, plant in profile
+ { map = "REDS_HOUSE_1F", x = 6, y = 4, face = "left", label = "reds_side" },
+ -- the Fan Club's four members' chairs round the boardroom table:
+ -- from the south of the west pair, both stools stacked in profile
+ { map = "POKEMON_FAN_CLUB", x = 1, y = 5, face = "up",
+ label = "club_west_pair" },
+ -- across the table from the west, both pairs and the octagon between
+ { map = "POKEMON_FAN_CLUB", x = 0, y = 3, face = "right",
+ label = "club_across" },
+ -- close on the east pair from the north, looking down over the seats
+ { map = "POKEMON_FAN_CLUB", x = 6, y = 2, face = "down",
+ label = "club_over" },
+ }
+
+ local shots = 0
+ for _, s in ipairs(SCENES) do
+ for _, rung in ipairs({ 3, 5 }) do
+ U.teleport(game, s.map, s.x, s.y, s.face)
+ Pipelines.setLevel("voxel", rung)
+ Pipelines.setLevel("tiltshift", 0)
+ settle()
+ local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
+ game.capturePath = path
+ U.wait(6)
+ local f = io.open(path, "rb")
+ if f then f:close() shots = shots + 1
+ else print("[housefurn] capture missed: " .. path) end
+ end
+ end
+ print(("[housefurn] %d shots into %s"):format(shots, ROOT))
+end
diff --git a/tests/mart_shots.lua b/tests/mart_shots.lua
new file mode 100644
index 0000000..428c4dc
--- /dev/null
+++ b/tests/mart_shots.lua
@@ -0,0 +1,76 @@
+-- Scratch driver: shots of a Poke Mart's clerk counter, for the cash
+-- register voxelization. The register is drawn at cell (1,5) of the 4x4
+-- shop layout every Mart shares, in the middle of the counter's east arm,
+-- so these are the three angles you can actually stand at: head-on from
+-- the aisle, side-on from the east, and over the counter's south arm.
+--
+-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mart_shots.lua \
+-- SHOT_DIR=.scratchpad/register AB_TAG=after lovec.exe .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Pipelines = require("src.render.Pipelines")
+
+ local ROOT = (os.getenv("SHOT_DIR") or "shots/register")
+ .. "/" .. (os.getenv("AB_TAG") or "after")
+
+ local handle = game.mods.exports["DRAMATIC_SHAPE"]
+ if not (handle and handle.lib) then
+ print("[mart] DRAMATIC_SHAPE mod not loaded")
+ return
+ end
+ local V = handle.lib
+ local DayNight = V.require("DayNight")
+ local ChunkMesher = V.require("ChunkMesher")
+ local Voxel = V.require("VoxelState")
+
+ require("src.world.OverworldController").rollEncounter = function() return nil end
+ local TileRenderer = require("src.render.TileRenderer")
+ TileRenderer.tick = function() end
+ TileRenderer.animFrame = function() return 0 end
+ DayNight.setting:sync("day")
+
+ pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+ pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+ local Zoom = require("src.render.Zoom")
+ pcall(function()
+ game.save.options.zoom = 1
+ Zoom.applyOptions(game.save.options)
+ end)
+
+ local function settle()
+ for _ = 1, 900 do
+ if ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ for _ = 1, 300 do
+ if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ U.wait(40)
+ end
+
+ local SCENES = {
+ { x = 1, y = 7, face = "up", label = "aisle" },
+ { x = 2, y = 5, face = "left", label = "side" },
+ { x = 2, y = 6, face = "left", label = "over" },
+ }
+
+ local shots = 0
+ for _, s in ipairs(SCENES) do
+ for _, rung in ipairs({ 3, 5 }) do
+ U.teleport(game, "VIRIDIAN_MART", s.x, s.y, s.face)
+ Pipelines.setLevel("voxel", rung)
+ Pipelines.setLevel("tiltshift", 0)
+ settle()
+ local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
+ game.capturePath = path
+ U.wait(6)
+ local f = io.open(path, "rb")
+ if f then f:close() shots = shots + 1
+ else print("[mart] capture missed: " .. path) end
+ end
+ end
+ print(("[mart] %d shots into %s"):format(shots, ROOT))
+ love.event.quit()
+end
diff --git a/tests/potted_plant_shots.lua b/tests/potted_plant_shots.lua
new file mode 100644
index 0000000..533bdba
--- /dev/null
+++ b/tests/potted_plant_shots.lua
@@ -0,0 +1,91 @@
+-- Scratch driver: shots of the potted plants, for the plant standee
+-- voxelization. Every Center places three side-by-side pairs on its
+-- bottom row -- crowns at cells (0,6)/(1,6), (6,6)/(7,6), (12,6)/(13,6),
+-- pots below at y=7 -- and INDIGO_PLATEAU_LOBBY (the MART tileset id,
+-- same atlas) lines four of them along its hall at cells (12,10)..(15,10).
+--
+-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/potted_plant_shots.lua \
+-- SHOT_DIR=.scratchpad/plants AB_TAG=after lovec.exe .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Pipelines = require("src.render.Pipelines")
+
+ local ROOT = (os.getenv("SHOT_DIR") or "shots/plants")
+ .. "/" .. (os.getenv("AB_TAG") or "after")
+
+ local handle = game.mods.exports["DRAMATIC_SHAPE"]
+ if not (handle and handle.lib) then
+ print("[plant] DRAMATIC_SHAPE mod not loaded")
+ return
+ end
+ local V = handle.lib
+ local DayNight = V.require("DayNight")
+ local ChunkMesher = V.require("ChunkMesher")
+ local Voxel = V.require("VoxelState")
+
+ require("src.world.OverworldController").rollEncounter = function() return nil end
+ local TileRenderer = require("src.render.TileRenderer")
+ TileRenderer.tick = function() end
+ TileRenderer.animFrame = function() return 0 end
+ DayNight.setting:sync("day")
+
+ pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+ pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+ local Zoom = require("src.render.Zoom")
+ pcall(function()
+ game.save.options.zoom = 1
+ Zoom.applyOptions(game.save.options)
+ end)
+
+ local function settle()
+ for _ = 1, 900 do
+ if ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ for _ = 1, 300 do
+ if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ U.wait(40)
+ end
+
+ local SCENES = {
+ -- east of the left pair, looking west along the bottom row: both
+ -- plants in profile, crown overhang and pot silhouette side-on
+ { map = "VIRIDIAN_POKECENTER", x = 3, y = 6, face = "left",
+ label = "pair_side" },
+ -- north of the left pair, looking south down over the crowns
+ { map = "VIRIDIAN_POKECENTER", x = 1, y = 5, face = "down",
+ label = "pair_over" },
+ -- head on: standing below the middle pair looking north at it
+ { map = "VIRIDIAN_POKECENTER", x = 6, y = 7, face = "up",
+ label = "pair_front" },
+ -- close up beside the east pair's pot
+ { map = "VIRIDIAN_POKECENTER", x = 11, y = 7, face = "right",
+ label = "close" },
+ -- the Plateau lobby's row of four (MART tileset id), along the row
+ { map = "INDIGO_PLATEAU_LOBBY", x = 11, y = 10, face = "right",
+ label = "lobby_row" },
+ -- and head on from the hall below
+ { map = "INDIGO_PLATEAU_LOBBY", x = 13, y = 12, face = "up",
+ label = "lobby_front" },
+ }
+
+ local shots = 0
+ for _, s in ipairs(SCENES) do
+ for _, rung in ipairs({ 3, 5 }) do
+ U.teleport(game, s.map, s.x, s.y, s.face)
+ Pipelines.setLevel("voxel", rung)
+ Pipelines.setLevel("tiltshift", 0)
+ settle()
+ local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
+ game.capturePath = path
+ U.wait(6)
+ local f = io.open(path, "rb")
+ if f then f:close() shots = shots + 1
+ else print("[plant] capture missed: " .. path) end
+ end
+ end
+ print(("[plant] %d shots into %s"):format(shots, ROOT))
+end
diff --git a/tests/roof_curve_shots.lua b/tests/roof_curve_shots.lua
new file mode 100644
index 0000000..71c06b0
--- /dev/null
+++ b/tests/roof_curve_shots.lua
@@ -0,0 +1,80 @@
+-- Scratch driver: the Cerulean gym and the houses beside it, shot at
+-- several camera rungs with V-CURVE walked OFF..3, to see what the world
+-- bend does to a building's roof.
+--
+-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/roof_curve_shots.lua \
+-- SHOT_DIR=.scratchpad/roofcurve AB_TAG=before lovec.exe .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Pipelines = require("src.render.Pipelines")
+
+ local ROOT = (os.getenv("SHOT_DIR") or "shots/roofcurve")
+ .. "/" .. (os.getenv("AB_TAG") or "after")
+
+ local handle = game.mods.exports["DRAMATIC_SHAPE"]
+ if not (handle and handle.lib) then
+ print("[roof] DRAMATIC_SHAPE mod not loaded")
+ return
+ end
+ local V = handle.lib
+ local DayNight = V.require("DayNight")
+ local ChunkMesher = V.require("ChunkMesher")
+ local Voxel = V.require("VoxelState")
+ local WorldCurve = V.require("WorldCurve")
+
+ require("src.world.OverworldController").rollEncounter = function() return nil end
+ local TileRenderer = require("src.render.TileRenderer")
+ TileRenderer.tick = function() end
+ TileRenderer.animFrame = function() return 0 end
+ DayNight.setting:sync("day")
+
+ pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+ pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+ local Zoom = require("src.render.Zoom")
+ pcall(function()
+ game.save.options.zoom = 1
+ Zoom.applyOptions(game.save.options)
+ end)
+
+ local function settle()
+ for _ = 1, 900 do
+ if ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ for _ = 1, 300 do
+ if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ U.wait(30)
+ end
+
+ -- the gym door is CERULEAN_CITY (30,19); the bike shop and the row of
+ -- houses along the west side give a second, smaller roof in frame
+ local SCENES = {
+ { map = "CERULEAN_CITY", x = 30, y = 20, face = "up", label = "gym" },
+ { map = "CERULEAN_CITY", x = 27, y = 21, face = "up", label = "gymwide" },
+ { map = "PALLET_TOWN", x = 5, y = 6, face = "up", label = "house" },
+ }
+
+ local shots = 0
+ for _, s in ipairs(SCENES) do
+ for _, rung in ipairs({ 3, 5 }) do
+ for _, curve in ipairs({ 0, 3 }) do
+ U.teleport(game, s.map, s.x, s.y, s.face)
+ Pipelines.setLevel("voxel", rung)
+ Pipelines.setLevel("tiltshift", 0)
+ WorldCurve.setting:setIndex(curve + 1, game)
+ settle()
+ local path = ("%s/%s_v%d_c%d.png"):format(ROOT, s.label, rung, curve)
+ game.capturePath = path
+ U.wait(8)
+ local f = io.open(path, "rb")
+ if f then f:close() shots = shots + 1
+ else print("[roof] capture missed: " .. path) end
+ end
+ end
+ end
+ print(("[roof] %d shots into %s"):format(shots, ROOT))
+ love.event.quit()
+end
diff --git a/tests/roof_span_probe.lua b/tests/roof_span_probe.lua
new file mode 100644
index 0000000..99e9563
--- /dev/null
+++ b/tests/roof_span_probe.lua
@@ -0,0 +1,47 @@
+-- Scratch probe: how long do a building model's merged quads get?
+--
+-- A quad's longest world-space edge is what decides how far its CHORD
+-- falls below the world curve's parabola, so this is the number that says
+-- whether the bend can crack the mesh open.
+--
+-- BUILD_MAP=CERULEAN_CITY POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/roof_span_probe.lua lovec .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local mapId = os.getenv("BUILD_MAP") or "CERULEAN_CITY"
+ U.teleport(game, mapId, tonumber(os.getenv("BUILD_X") or "30"),
+ tonumber(os.getenv("BUILD_Y") or "20"), "up")
+ U.wait(30)
+
+ local V = game.mods.exports["DRAMATIC_SHAPE"]
+ V = V and V.lib
+ local Structures = V and V.require("Structures")
+ local ow = game.overworld
+ if not (Structures and ow and ow.map) then
+ print("[span] mod or map unavailable")
+ love.event.quit()
+ return
+ end
+ local S = Structures.forMap(ow.map)
+ local hist, worst = {}, 0
+ for _, q in ipairs(S.objectQuads) do
+ local dx = math.max(q[1][1], q[2][1], q[3][1], q[4][1])
+ - math.min(q[1][1], q[2][1], q[3][1], q[4][1])
+ local dz = math.max(q[1][3], q[2][3], q[3][3], q[4][3])
+ - math.min(q[1][3], q[2][3], q[3][3], q[4][3])
+ local dy = math.max(q[1][2], q[2][2], q[3][2], q[4][2])
+ - math.min(q[1][2], q[2][2], q[3][2], q[4][2])
+ local span = math.max(dx, dz, dy)
+ local bucket = span <= 8 and "<=8" or (span <= 16 and "<=16"
+ or (span <= 32 and "<=32" or (span <= 64 and "<=64" or ">64")))
+ bucket = bucket .. (q.own and " bld" or " prop")
+ hist[bucket] = (hist[bucket] or 0) + 1
+ if span > worst then worst = span end
+ end
+ print(("[span] %d object quads, longest edge %d px"):format(#S.objectQuads, worst))
+ for _, b in ipairs({ "<=8", "<=16", "<=32", "<=64", ">64" }) do
+ for _, kind in ipairs({ " bld", " prop" }) do
+ print(("[span] %-10s %d"):format(b .. kind, hist[b .. kind] or 0))
+ end
+ end
+ love.event.quit()
+end
diff --git a/tests/round_regress_shots.lua b/tests/round_regress_shots.lua
new file mode 100644
index 0000000..246b302
--- /dev/null
+++ b/tests/round_regress_shots.lua
@@ -0,0 +1,75 @@
+-- Scratch driver: one shot of every OTHER user of the round-hull builder
+-- (tree canopies, boulders, hedges, stumps, the Center planter), to check
+-- that the `can` class's base cut is the identity it is supposed to be for
+-- everything that does not ask for it.
+--
+-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/round_regress_shots.lua \
+-- SHOT_DIR=.scratchpad/round AB_TAG=after lovec.exe .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Pipelines = require("src.render.Pipelines")
+
+ local ROOT = (os.getenv("SHOT_DIR") or "shots/round")
+ .. "/" .. (os.getenv("AB_TAG") or "after")
+
+ local handle = game.mods.exports["DRAMATIC_SHAPE"]
+ if not (handle and handle.lib) then return end
+ local V = handle.lib
+ local DayNight = V.require("DayNight")
+ local ChunkMesher = V.require("ChunkMesher")
+ local Voxel = V.require("VoxelState")
+
+ require("src.world.OverworldController").rollEncounter = function() return nil end
+ local TileRenderer = require("src.render.TileRenderer")
+ TileRenderer.tick = function() end
+ TileRenderer.animFrame = function() return 0 end
+ DayNight.setting:sync("day")
+
+ pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+ pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+ local Zoom = require("src.render.Zoom")
+ pcall(function()
+ game.save.options.zoom = 1
+ Zoom.applyOptions(game.save.options)
+ end)
+
+ local function settle()
+ for _ = 1, 900 do
+ if ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ for _ = 1, 300 do
+ if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ U.wait(40)
+ end
+
+ local SCENES = {
+ { map = "VIRIDIAN_FOREST", x = 16, y = 20, face = "up", label = "forest" },
+ { map = "PEWTER_GYM", x = 4, y = 10, face = "up", label = "boulders" },
+ { map = "CELADON_GYM", x = 4, y = 8, face = "up", label = "hedges" },
+ { map = "VIRIDIAN_POKECENTER", x = 6, y = 5, face = "up", label = "planter" },
+ { map = "PALLET_TOWN", x = 5, y = 8, face = "up", label = "trees" },
+ }
+
+ local shots = 0
+ for _, s in ipairs(SCENES) do
+ local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
+ if ok then
+ Pipelines.setLevel("voxel", 5)
+ Pipelines.setLevel("tiltshift", 0)
+ settle()
+ local path = ("%s/%s.png"):format(ROOT, s.label)
+ game.capturePath = path
+ U.wait(6)
+ local f = io.open(path, "rb")
+ if f then f:close() shots = shots + 1
+ else print("[round] capture missed: " .. path) end
+ else
+ print("[round] teleport failed: " .. s.map)
+ end
+ end
+ print(("[round] %d shots into %s"):format(shots, ROOT))
+end
diff --git a/tests/shelf_relief_shots.lua b/tests/shelf_relief_shots.lua
new file mode 100644
index 0000000..633fe37
--- /dev/null
+++ b/tests/shelf_relief_shots.lua
@@ -0,0 +1,92 @@
+-- Scratch driver: shots of the `bookcase` class across the tilesets that
+-- pin it, for the shelf-front relief. Two of them are NOT shelves --
+-- the League's gate walls and the terraces on PLATEAU -- and are here as
+-- the control: their courses run edge to edge, so nothing should sink.
+--
+-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shelf_relief_shots.lua \
+-- SHOT_DIR=.scratchpad/shelves AB_TAG=before lovec.exe .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Pipelines = require("src.render.Pipelines")
+
+ local ROOT = (os.getenv("SHOT_DIR") or "shots/shelves")
+ .. "/" .. (os.getenv("AB_TAG") or "before")
+
+ local handle = game.mods.exports["DRAMATIC_SHAPE"]
+ if not (handle and handle.lib) then
+ print("[shelf] DRAMATIC_SHAPE mod not loaded")
+ return
+ end
+ local V = handle.lib
+ local DayNight = V.require("DayNight")
+ local ChunkMesher = V.require("ChunkMesher")
+ local Voxel = V.require("VoxelState")
+
+ require("src.world.OverworldController").rollEncounter = function() return nil end
+ local TileRenderer = require("src.render.TileRenderer")
+ TileRenderer.tick = function() end
+ TileRenderer.animFrame = function() return 0 end
+ DayNight.setting:sync("day")
+
+ pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+ pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+ local Zoom = require("src.render.Zoom")
+ pcall(function()
+ game.save.options.zoom = 1
+ Zoom.applyOptions(game.save.options)
+ end)
+
+ local function settle()
+ for _ = 1, 900 do
+ if ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ for _ = 1, 300 do
+ if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ U.wait(40)
+ end
+
+ local SCENES = {
+ { map = "OAKS_LAB", x = 7, y = 2, face = "up", label = "dojo_lab" },
+ { map = "CELADON_MANSION_2F", x = 2, y = 4, face = "up", label = "mansion_2f" },
+ { map = "CELADON_MART_2F", x = 5, y = 5, face = "up", label = "lobby_mart" },
+ { map = "MUSEUM_1F", x = 2, y = 4, face = "up", label = "museum" },
+ { map = "VIRIDIAN_MART", x = 3, y = 5, face = "up", label = "mart" },
+ { map = "SS_ANNE_CAPTAINS_ROOM", x = 5, y = 2, face = "up", label = "ship" },
+ -- the controls, both of them tilesets that borrow the collapse for
+ -- something that is NOT a shelf and say so with `bookcase_relief =
+ -- false`: the League's masonry gate walls, and Bill's transporter
+ -- drums. Nothing in either may move.
+ { map = "INDIGO_PLATEAU", x = 2, y = 5, face = "up", label = "plateau" },
+ { map = "BILLS_HOUSE", x = 2, y = 3, face = "up", label = "bills" },
+ -- the house shelves: pinned `desk`, NOT `bookcase`, so they go
+ -- through the world mesher's box fold and this relief never reaches
+ -- them. Here to show the gap.
+ { map = "REDS_HOUSE_1F", x = 1, y = 2, face = "up", label = "reds" },
+ { map = "BLUES_HOUSE", x = 1, y = 2, face = "up", label = "blues" },
+ }
+
+ local shots = 0
+ for _, s in ipairs(SCENES) do
+ local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
+ -- twice: the first load of the session has no mesh to settle against
+ pcall(U.teleport, game, s.map, s.x, s.y, s.face)
+ if ok then
+ Pipelines.setLevel("voxel", 5)
+ Pipelines.setLevel("tiltshift", 0)
+ settle()
+ local path = ("%s/%s.png"):format(ROOT, s.label)
+ game.capturePath = path
+ U.wait(6)
+ local f = io.open(path, "rb")
+ if f then f:close() shots = shots + 1
+ else print("[shelf] capture missed: " .. path) end
+ else
+ print("[shelf] teleport failed: " .. s.map)
+ end
+ end
+ print(("[shelf] %d shots into %s"):format(shots, ROOT))
+end
diff --git a/tests/ship_can_shots.lua b/tests/ship_can_shots.lua
new file mode 100644
index 0000000..6eb535f
--- /dev/null
+++ b/tests/ship_can_shots.lua
@@ -0,0 +1,78 @@
+-- Scratch driver: shots of the SS Anne's galley barrels, which are Lt.
+-- Surge's trash can redrawn on the ship atlas. Three down the kitchen's
+-- east wall at cells (13,5)/(13,7)/(13,9), one in the captain's room at
+-- (4,1), and one each in the two ship-interior houses at (7,7).
+--
+-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/ship_can_shots.lua \
+-- SHOT_DIR=.scratchpad/ssanne AB_TAG=after lovec.exe .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Pipelines = require("src.render.Pipelines")
+
+ local ROOT = (os.getenv("SHOT_DIR") or "shots/ssanne")
+ .. "/" .. (os.getenv("AB_TAG") or "after")
+
+ local handle = game.mods.exports["DRAMATIC_SHAPE"]
+ if not (handle and handle.lib) then
+ print("[ship] DRAMATIC_SHAPE mod not loaded")
+ return
+ end
+ local V = handle.lib
+ local DayNight = V.require("DayNight")
+ local ChunkMesher = V.require("ChunkMesher")
+ local Voxel = V.require("VoxelState")
+
+ require("src.world.OverworldController").rollEncounter = function() return nil end
+ local TileRenderer = require("src.render.TileRenderer")
+ TileRenderer.tick = function() end
+ TileRenderer.animFrame = function() return 0 end
+ DayNight.setting:sync("day")
+
+ pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+ pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+ local Zoom = require("src.render.Zoom")
+ pcall(function()
+ game.save.options.zoom = 1
+ Zoom.applyOptions(game.save.options)
+ end)
+
+ local function settle()
+ for _ = 1, 900 do
+ if ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ for _ = 1, 300 do
+ if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ U.wait(40)
+ end
+
+ local SCENES = {
+ { map = "SS_ANNE_KITCHEN", x = 12, y = 11, face = "up", label = "galley_up" },
+ { map = "SS_ANNE_KITCHEN", x = 12, y = 3, face = "down", label = "galley_down" },
+ { map = "SS_ANNE_KITCHEN", x = 11, y = 7, face = "right", label = "galley_side" },
+ { map = "SS_ANNE_CAPTAINS_ROOM", x = 4, y = 4, face = "up", label = "captain" },
+ { map = "CERULEAN_BADGE_HOUSE", x = 6, y = 7, face = "right", label = "house" },
+ }
+
+ local shots = 0
+ for _, s in ipairs(SCENES) do
+ local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
+ if ok then
+ Pipelines.setLevel("voxel", 5)
+ Pipelines.setLevel("tiltshift", 0)
+ settle()
+ local path = ("%s/%s.png"):format(ROOT, s.label)
+ game.capturePath = path
+ U.wait(6)
+ local f = io.open(path, "rb")
+ if f then f:close() shots = shots + 1
+ else print("[ship] capture missed: " .. path) end
+ else
+ print("[ship] teleport failed: " .. s.map)
+ end
+ end
+ print(("[ship] %d shots into %s"):format(shots, ROOT))
+end
diff --git a/tests/trash_can_shots.lua b/tests/trash_can_shots.lua
new file mode 100644
index 0000000..408165e
--- /dev/null
+++ b/tests/trash_can_shots.lua
@@ -0,0 +1,80 @@
+-- Scratch driver: shots of Vermilion Gym's trash cans, for the trash can
+-- voxelization. The fifteen cans stand on odd cell columns 1..9 in cell
+-- rows 7, 9 and 11; the sixteenth is up at cell (6,1) beside the leader's
+-- platform. Even columns are open floor, so the player can be parked
+-- between two cans and look along a row.
+--
+-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/trash_can_shots.lua \
+-- SHOT_DIR=.scratchpad/cans AB_TAG=before lovec.exe .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Pipelines = require("src.render.Pipelines")
+
+ local ROOT = (os.getenv("SHOT_DIR") or "shots/cans")
+ .. "/" .. (os.getenv("AB_TAG") or "before")
+
+ local handle = game.mods.exports["DRAMATIC_SHAPE"]
+ if not (handle and handle.lib) then
+ print("[can] DRAMATIC_SHAPE mod not loaded")
+ return
+ end
+ local V = handle.lib
+ local DayNight = V.require("DayNight")
+ local ChunkMesher = V.require("ChunkMesher")
+ local Voxel = V.require("VoxelState")
+
+ require("src.world.OverworldController").rollEncounter = function() return nil end
+ local TileRenderer = require("src.render.TileRenderer")
+ TileRenderer.tick = function() end
+ TileRenderer.animFrame = function() return 0 end
+ DayNight.setting:sync("day")
+
+ pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+ pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+ local Zoom = require("src.render.Zoom")
+ pcall(function()
+ game.save.options.zoom = 1
+ Zoom.applyOptions(game.save.options)
+ end)
+
+ local function settle()
+ for _ = 1, 900 do
+ if ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ for _ = 1, 300 do
+ if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ U.wait(40)
+ end
+
+ local SCENES = {
+ -- head on into the middle of the field, cans left, right and ahead
+ { x = 4, y = 12, face = "up", label = "field" },
+ -- close up: standing between two cans of the bottom row
+ { x = 2, y = 11, face = "left", label = "close" },
+ -- along the row, so the cans line up in depth
+ { x = 4, y = 13, face = "up", label = "row" },
+ -- from the north, looking back down over all three rows
+ { x = 4, y = 6, face = "down", label = "over" },
+ }
+
+ local shots = 0
+ for _, s in ipairs(SCENES) do
+ for _, rung in ipairs({ 3, 5 }) do
+ U.teleport(game, "VERMILION_GYM", s.x, s.y, s.face)
+ Pipelines.setLevel("voxel", rung)
+ Pipelines.setLevel("tiltshift", 0)
+ settle()
+ local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
+ game.capturePath = path
+ U.wait(6)
+ local f = io.open(path, "rb")
+ if f then f:close() shots = shots + 1
+ else print("[can] capture missed: " .. path) end
+ end
+ end
+ print(("[can] %d shots into %s"):format(shots, ROOT))
+end
diff --git a/tests/water_curve_shots.lua b/tests/water_curve_shots.lua
new file mode 100644
index 0000000..26d8f1f
--- /dev/null
+++ b/tests/water_curve_shots.lua
@@ -0,0 +1,121 @@
+-- Scratch driver: water shot with V-CURVE walked OFF..3, to see what the
+-- world bend does to the reflective pass.
+--
+-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/water_curve_shots.lua \
+-- SHOT_DIR=.scratchpad/watercurve AB_TAG=before lovec.exe .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local Pipelines = require("src.render.Pipelines")
+
+ local ROOT = (os.getenv("SHOT_DIR") or "shots/watercurve")
+ .. "/" .. (os.getenv("AB_TAG") or "after")
+
+ local handle = game.mods.exports["DRAMATIC_SHAPE"]
+ if not (handle and handle.lib) then
+ print("[water] DRAMATIC_SHAPE mod not loaded")
+ return
+ end
+ local V = handle.lib
+ local DayNight = V.require("DayNight")
+ local ChunkMesher = V.require("ChunkMesher")
+ local Voxel = V.require("VoxelState")
+ local WorldCurve = V.require("WorldCurve")
+ local Water = V.require("Water")
+ -- WATER_RUNG=sky drops the screen-space march and leaves the sky path, to
+ -- tell an artefact of the one from an artefact of the other
+ if os.getenv("WATER_RUNG") then Water.setting:sync(os.getenv("WATER_RUNG")) end
+
+ require("src.world.OverworldController").rollEncounter = function() return nil end
+ local TileRenderer = require("src.render.TileRenderer")
+ TileRenderer.tick = function() end
+ TileRenderer.animFrame = function() return 0 end
+ DayNight.setting:sync(os.getenv("WATER_TIME") or "night")
+
+ pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+ pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+ local Zoom = require("src.render.Zoom")
+ pcall(function()
+ game.save.options.zoom = 1
+ Zoom.applyOptions(game.save.options)
+ end)
+
+ local function settle()
+ for _ = 1, 900 do
+ if ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ for _ = 1, 300 do
+ if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+ U.wait(40)
+ end
+
+ -- Stand on the walkable cell just north of the widest run of water on the
+ -- map, so a scene is picked by where the water actually is rather than by
+ -- a coordinate guessed off the block list.
+ local TileShape = V.require("TileShape")
+ local function shore(map)
+ local def = map.def
+ local shapes = TileShape.forMap(map)
+ local function classAt(cx, cy)
+ local tx, ty = cx * 2, cy * 2
+ local s = TileShape.at(map, shapes, map:tileAt(tx, ty), tx, ty)
+ return s and s.class
+ end
+ local best, bestRun = nil, 0
+ for cy = 1, def.height * 2 - 1 do
+ local run, start = 0, nil
+ for cx = 0, def.width * 2 - 1 do
+ if classAt(cx, cy) == "water" then
+ start = start or cx
+ run = run + 1
+ if run > bestRun and classAt(cx, cy - 1) ~= "water" then
+ bestRun, best = run, { x = start + math.floor(run / 2), y = cy - 1 }
+ end
+ else
+ run, start = 0, nil
+ end
+ end
+ end
+ return best
+ end
+
+ local SCENES = {
+ { map = "PALLET_TOWN", label = "pallet" },
+ { map = "CERULEAN_CITY", label = "cerulean" },
+ { map = "VERMILION_CITY", x = 18, y = 27, label = "vermilion" },
+ { map = "ROUTE_24", label = "route24" },
+ { map = "VIRIDIAN_CITY", label = "viridian" },
+ }
+
+ local shots = 0
+ for _, s in ipairs(SCENES) do
+ if not s.x then
+ -- teleport once so the map is loaded, then let the scan place us
+ U.teleport(game, s.map, 1, 1, "down")
+ U.wait(4)
+ local spot = game.overworld and game.overworld.map and shore(game.overworld.map)
+ if spot then s.x, s.y = spot.x, spot.y else s.x, s.y = 1, 1 end
+ print(("[water] %s shore at (%d,%d)"):format(s.label, s.x, s.y))
+ end
+ for _, rung in ipairs({ 3, 4 }) do
+ for _, curve in ipairs({ 0, 3 }) do
+ U.teleport(game, s.map, s.x, s.y, s.face or "down")
+ Pipelines.setLevel("voxel", rung)
+ Pipelines.setLevel("tiltshift", 0)
+ WorldCurve.setting:setIndex(curve + 1, game)
+ settle()
+ local path = ("%s/%s_v%d_c%d.png"):format(ROOT, s.label, rung, curve)
+ game.capturePath = path
+ U.wait(8)
+ local f = io.open(path, "rb")
+ if f then f:close() shots = shots + 1
+ else print("[water] capture missed: " .. path) end
+ end
+ end
+ end
+ print(("[water] %d shots into %s"):format(shots, ROOT))
+ love.event.quit()
+end
diff --git a/tools/building_voxels.py b/tools/building_voxels.py
index bcc9078..c8718c0 100644
--- a/tools/building_voxels.py
+++ b/tools/building_voxels.py
@@ -723,6 +723,65 @@ TEMPLATES = {
slab=3, front_eave=0, ledge=None, tileset="reds_house",
scrub=[(10, 5, 21, 15)],
),
+ # F10: the town house's bookcase -- the commonest piece of furniture
+ # in the game at 58 placements across three drawings, and the reason
+ # this family exists: the `desk` box it used to be had a flat front
+ # with the books PAINTED on it. Band for band it is the Celadon
+ # cabinet (F05/F06) on a different atlas, and it takes those numbers
+ # unchanged: 0-8 the top seen from above (black rim, white highlight
+ # courses along the north and west, grey field, the front corner
+ # shaded at row 8), row 9 the top's own black front edge -- so
+ # slab = 1 and that row folds into the roof band -- and 10-31 the
+ # front: two shelves in their dark openings over the cupboard's two
+ # panelled doors. Every one of those is a non-black region the
+ # drawing seals behind its own black frame, so the measured pane
+ # pass sinks each a voxel and the frames stand proud of them.
+ # Nine drawn top rows over a 32px plot: the rims map 1:1 and the
+ # uniform field cycles between. BOTH cell rows of the plot are
+ # blocked and both are cabinet drawing, so D is the whole grid --
+ # the case is built into the room's north wall. 23 voxels tall,
+ # one under the `desk` pin it replaces, and the same 23 the Celadon
+ # trophy case stands.
+ #
+ # This one carries books and a bowl on each shelf; 36 placements,
+ # the west end of eighteen homes.
+ "house_bookcase_bowls": dict(
+ tiles=[
+ [38, 41],
+ [14, 15],
+ [14, 15],
+ [30, 31],
+ ],
+ roof_rows=10, roof_back=8, roof_front=2, roof_cycle=(2, 7),
+ slab=1, front_eave=0, ledge=None, tileset="house",
+ ),
+ # F10 again: its twin at the east end, books on both shelves --
+ # 22 placements, and the only drawing of the two that the trashed
+ # house in Cerulean puts at the west end as well.
+ "house_bookcase_books": dict(
+ tiles=[
+ [38, 41],
+ [48, 49],
+ [48, 49],
+ [30, 31],
+ ],
+ roof_rows=10, roof_back=8, roof_front=2, roof_cycle=(2, 7),
+ slab=1, front_eave=0, ledge=None, tileset="house",
+ ),
+ # F10 once more, on the reds_house atlas: Red's and the Copycat's
+ # ground-floor pair (4 placements). The same object, one shelf of
+ # each drawing -- 48/49 books above, 34/35 below -- and the same
+ # band numbers.
+ "reds_bookcase": dict(
+ tiles=[
+ [38, 41],
+ [48, 49],
+ [34, 35],
+ [50, 51],
+ ],
+ roof_rows=10, roof_back=8, roof_front=2, roof_cycle=(2, 7),
+ slab=1, front_eave=0, ledge=None, tileset="reds_house",
+ ),
# F09: the stool at every one of those tables -- the first template
# with NO base piece. The drawing is one object, a round seat on
# legs, drawn MID-CELL over its own floor (rows 0-4 are the room